3359 lines
174 KiB
JavaScript
3359 lines
174 KiB
JavaScript
/* 软件开发部 · 关键任务追踪看板 */
|
||
(function () {
|
||
"use strict";
|
||
|
||
const Ico = window.Icon;
|
||
|
||
const state = {
|
||
view: "board",
|
||
gran: "week",
|
||
weekId: null,
|
||
monthIdx: null,
|
||
categoryId: null,
|
||
keyOnly: false,
|
||
keyword: "",
|
||
idle: "key",
|
||
showArchived: false,
|
||
meta: null,
|
||
periods: null,
|
||
categories: [],
|
||
board: null,
|
||
stats: null,
|
||
projects: [],
|
||
drawerProject: null,
|
||
// 详情抽屉:周期反馈的显示粒度 / 范围 / 往前翻页偏移
|
||
dw: { group: "week", range: 4, back: 0 },
|
||
// 定制交付
|
||
customMeta: null,
|
||
customTab: "pipeline",
|
||
customRegion: "",
|
||
customStage: "",
|
||
customArchived: "false",
|
||
customRows: [],
|
||
customBoard: null,
|
||
customStats: null,
|
||
intakeType: "general",
|
||
// 更新录入的两种方式:direct = 直接写入(原样存 1 条);ai = AI 整理(补结构化字段,也只存 1 条)
|
||
intakeMode: "direct",
|
||
intakePeriodId: null,
|
||
drawerCustom: null,
|
||
// 周看板(左栏总览 + 右栏详情)
|
||
weekly: null,
|
||
weeklySel: null,
|
||
weeklyOwner: "",
|
||
weeklyAllCustom: false,
|
||
// 项目管理
|
||
projOwner: "",
|
||
projIdleOnly: false,
|
||
// 每周重点工作
|
||
focusRows: [],
|
||
focusFilter: "week",
|
||
focusArchived: false,
|
||
// 人力资源库
|
||
staff: [],
|
||
staffDict: { groups: [], affiliations: [] },
|
||
staffArchived: "false",
|
||
staffGroup: "",
|
||
};
|
||
|
||
/* ---------------------------------------------------------------- utils */
|
||
const $ = (sel) => document.querySelector(sel);
|
||
const $$ = (sel) => Array.prototype.slice.call(document.querySelectorAll(sel));
|
||
|
||
function esc(s) {
|
||
if (s === null || s === undefined) return "";
|
||
return String(s)
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.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 */ }
|
||
throw new Error(msg);
|
||
}
|
||
return res.json();
|
||
}
|
||
const post = (path, body) => api(path, { method: "POST", body: JSON.stringify(body) });
|
||
const put = (path, body) => api(path, { method: "PUT", body: JSON.stringify(body) });
|
||
const patch = (path, body) => api(path, { method: "PATCH", body: JSON.stringify(body) });
|
||
const del = (path) => api(path, { method: "DELETE" });
|
||
|
||
let toastTimer = null;
|
||
function toast(msg) {
|
||
const t = $("#toast");
|
||
t.textContent = msg;
|
||
t.classList.add("show");
|
||
clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => t.classList.remove("show"), 2400);
|
||
}
|
||
|
||
/* ------------------------------------------------ 列表页统一骨架 */
|
||
/** 列表页统一样式(照「定制交付 · 项目明细」):工具栏在卡片上方,
|
||
卡片头部 = 标题 + 汇总说明 + 卡内筛选,下面是可滚动表格或空态。 */
|
||
function listCard(o) {
|
||
return `<div class="card">
|
||
<div class="card-head">
|
||
<h4><span class="t">${esc(o.title)}</span>${o.sub ? `<span class="sub">${esc(o.sub)}</span>` : ""}</h4>
|
||
${o.summary ? `<span class="period-hint">${o.summary}</span>` : ""}
|
||
${o.fields ? `<div class="inline-fields">${o.fields}</div>` : ""}
|
||
</div>
|
||
${o.rows.length ? `<div class="tbl-wrap">${o.table}</div>` : `<div class="empty">${o.empty}</div>`}
|
||
</div>`;
|
||
}
|
||
|
||
/** 页内搜索框:与右上角全局搜索共用 state.keyword */
|
||
function keywordInputHtml(id, placeholder, width) {
|
||
return `<input id="${id}" class="search" type="search" placeholder="${esc(placeholder)}" value="${esc(state.keyword)}" style="width:${width}px;height:30px" />`;
|
||
}
|
||
|
||
/** 绑定页内搜索:防抖取数,重绘后把光标放回搜索框,连续打字不中断 */
|
||
function bindKeyword(id) {
|
||
const el = $("#" + id);
|
||
if (!el) return;
|
||
let t = null;
|
||
el.addEventListener("input", (e) => {
|
||
clearTimeout(t);
|
||
const v = e.target.value.trim();
|
||
t = setTimeout(() => {
|
||
if (v === state.keyword) return;
|
||
keywordFocus = true;
|
||
state.keyword = v;
|
||
$("#globalSearch").value = v;
|
||
refresh();
|
||
}, 320);
|
||
});
|
||
if (keywordFocus) {
|
||
keywordFocus = false;
|
||
el.focus();
|
||
el.setSelectionRange(el.value.length, el.value.length);
|
||
}
|
||
}
|
||
|
||
/* ---------------------------------------------------- 弹窗 / 确认框 */
|
||
let modalResolve = null;
|
||
|
||
function openModal(html, onMount) {
|
||
$("#modalMask").classList.add("open");
|
||
$("#modal").innerHTML = html;
|
||
if (onMount) onMount();
|
||
const f = $("#modal").querySelector("[data-autofocus]");
|
||
if (f) f.focus();
|
||
}
|
||
|
||
/** 关闭弹窗;result 省略时按「取消」处理 */
|
||
function closeModal(result) {
|
||
if (!$("#modalMask").classList.contains("open")) return;
|
||
$("#modalMask").classList.remove("open");
|
||
$("#modal").innerHTML = "";
|
||
const cb = modalResolve;
|
||
modalResolve = null;
|
||
if (cb) cb(result === undefined ? false : result);
|
||
}
|
||
|
||
function modalError(msg) {
|
||
const e = $("#mErr");
|
||
if (!e) return;
|
||
e.textContent = msg || "";
|
||
e.style.display = msg ? "" : "none";
|
||
}
|
||
|
||
/** 需要用户明确点确认才继续;requireText 时还必须逐字输入该文本 */
|
||
function confirmDialog(o) {
|
||
return new Promise((resolve) => {
|
||
openModal(
|
||
`<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="逐字输入项目名称" style="width:100%" /></div>`
|
||
: ""
|
||
}
|
||
<div class="notice err" id="mErr" style="display:none"></div>
|
||
</div>
|
||
<div class="modal-foot">
|
||
<button class="btn" data-mclose>${esc(o.cancelText || "取消")}</button>
|
||
<button class="btn ${o.danger ? "danger solid" : "primary"}" id="mOk" ${o.requireText ? "disabled" : ""}>${esc(o.okText || "确认")}</button>
|
||
</div>`
|
||
);
|
||
modalResolve = resolve;
|
||
$("#mOk").addEventListener("click", () => closeModal(true));
|
||
if (o.requireText) {
|
||
const t = $("#mConfirmText");
|
||
const ok = $("#mOk");
|
||
t.addEventListener("input", () => {
|
||
ok.disabled = t.value.trim() !== o.requireText;
|
||
});
|
||
t.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter" && !ok.disabled) closeModal(true);
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
function statusMeta(code) {
|
||
const list = (state.meta && state.meta.statuses) || [];
|
||
return list.find((s) => s.code === code) || { code, name: code, color: "#94a3b8" };
|
||
}
|
||
function statusPill(code) {
|
||
const s = statusMeta(code);
|
||
return `<span class="pill" style="background:${s.color}1a;color:${s.color}">${Ico.status(code)}${esc(s.name)}</span>`;
|
||
}
|
||
function catPill(cat) {
|
||
if (!cat) return `<span class="tag-mini">未分类</span>`;
|
||
return `<span class="tag-mini" style="background:${cat.color}18;color:${cat.color}">${esc(cat.name)}</span>`;
|
||
}
|
||
|
||
/** 规范省级行政区下拉(按大区分组)。selected 不在标准列表时补一个选项,避免丢值。 */
|
||
function regionOptions(selected, withEmpty) {
|
||
const groups = (state.customMeta && state.customMeta.region_groups) || [];
|
||
const all = groups.reduce((acc, g) => acc.concat(g.regions), []);
|
||
let html = withEmpty === false ? "" : `<option value="" ${!selected ? "selected" : ""}>(未标注)</option>`;
|
||
for (const g of groups) {
|
||
html += `<optgroup label="${esc(g.group)}">` +
|
||
g.regions.map((r) => `<option value="${esc(r)}" ${r === selected ? "selected" : ""}>${esc(r)}</option>`).join("") +
|
||
`</optgroup>`;
|
||
}
|
||
if (selected && all.indexOf(selected) < 0) {
|
||
html += `<option value="${esc(selected)}" selected>${esc(selected)}(非标准)</option>`;
|
||
}
|
||
return html;
|
||
}
|
||
|
||
/** 触发浏览器下载(导出 Excel) */
|
||
function download(url) {
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.style.display = "none";
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
setTimeout(() => a.remove(), 1000);
|
||
toast("正在生成 Excel,请稍候…");
|
||
}
|
||
|
||
/**
|
||
* 负责人下拉。选项来自人力资源库:已归档人员不再出现在选项里,
|
||
* 只有他本来就是当前负责人时才带「(已归档)」标注出现一次,避免历史记录丢值;
|
||
* 库里没有的历史人名同样补一个选项,保证老数据不被改坏。
|
||
*/
|
||
function ownerSelectHtml(value, attrs) {
|
||
const cur = (value || "").trim();
|
||
let html = `<select class="sel" ${attrs || 'style="width:104px"'}>
|
||
<option value="">(未指定)</option>`;
|
||
state.staff.forEach((s) => {
|
||
if (s.archived && s.name !== cur) return;
|
||
html += `<option value="${esc(s.name)}" ${s.name === cur ? "selected" : ""}>${esc(s.name)}${s.archived ? "(已归档)" : ""}</option>`;
|
||
});
|
||
if (cur && !state.staff.some((s) => s.name === cur)) {
|
||
html += `<option value="${esc(cur)}" selected>${esc(cur)}(不在花名册)</option>`;
|
||
}
|
||
return html + `</select>`;
|
||
}
|
||
|
||
/**
|
||
* 多负责人 / 团队成员的可编辑名单:人名下拉(选项来自人力资源库)横向排开、自动换行,
|
||
* 不分主次,每个人都能删;「+ 添加」紧跟在名单末尾。
|
||
* 增删按钮的事件统一委托在 #drawerBody 上(见 bindDrawerPeople)。
|
||
*/
|
||
function peopleEditorHtml(id, label, values, opts) {
|
||
const list = values && values.length ? values : [""];
|
||
const rows = list
|
||
.map(
|
||
(n) => `
|
||
<div class="people-row">
|
||
${ownerSelectHtml(n, 'style="width:118px"')}
|
||
<button class="btn sm ghost people-del" type="button" data-pdel title="移除">${Ico.svg("x")}</button>
|
||
</div>`,
|
||
)
|
||
.join("");
|
||
return `
|
||
<div class="form-row top">
|
||
<label>${label}</label>
|
||
<div>
|
||
<div class="people-rows" id="${id}">${rows}
|
||
<button class="btn sm ghost" type="button" data-padd="${id}">${Ico.svg("plus")}添加${opts.add}</button>
|
||
</div>
|
||
${opts.hint ? `<div class="sec-hint" style="margin:6px 0 0">${opts.hint}</div>` : ""}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
/** 读回名单里的人名(按行序,空行忽略) */
|
||
function peopleValues(id) {
|
||
return $$("#" + id + " select").map((x) => x.value.trim()).filter(Boolean);
|
||
}
|
||
|
||
/** 名单增删:抽屉里所有「多负责人 / 团队成员」共用一个委托 */
|
||
function bindDrawerPeople() {
|
||
$("#drawerBody").addEventListener("click", (e) => {
|
||
const add = e.target.closest("[data-padd]");
|
||
if (add) {
|
||
const box = document.getElementById(add.dataset.padd);
|
||
if (!box) return;
|
||
const row = document.createElement("div");
|
||
row.className = "people-row";
|
||
row.innerHTML =
|
||
ownerSelectHtml("", 'style="width:130px"') +
|
||
`<button class="btn sm ghost people-del" type="button" data-pdel title="移除">${Ico.svg("x")}</button>`;
|
||
box.insertBefore(row, add);
|
||
row.querySelector("select").focus();
|
||
return;
|
||
}
|
||
const del = e.target.closest("[data-pdel]");
|
||
if (del) del.closest(".people-row").remove();
|
||
});
|
||
}
|
||
|
||
/** 只读名单:小标签一排 */
|
||
function nameChips(list) {
|
||
return (list || []).length
|
||
? list.map((n) => `<span class="tag-mini">${esc(n)}</span>`).join(" ")
|
||
: "—";
|
||
}
|
||
|
||
/** 拉一次花名册,供各处「负责人」下拉使用 */
|
||
async function loadStaff() {
|
||
const d = await api("/staff?archived=all");
|
||
state.staff = d.items;
|
||
state.staffDict = d.dict;
|
||
return d;
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- scope */
|
||
function scopeQuery(extra) {
|
||
const p = [];
|
||
if (state.gran === "week") {
|
||
if (state.weekId) p.push("period_id=" + state.weekId);
|
||
} else {
|
||
const m = currentMonth();
|
||
if (m) p.push("year=" + m.year, "month=" + m.month);
|
||
}
|
||
if (state.keyword) p.push("keyword=" + encodeURIComponent(state.keyword));
|
||
if (extra) p.push.apply(p, extra);
|
||
return p.length ? "?" + p.join("&") : "";
|
||
}
|
||
function currentMonth() {
|
||
if (!state.periods || state.monthIdx === null) return null;
|
||
return state.periods.months[state.monthIdx] || null;
|
||
}
|
||
function scopePeriodIds() {
|
||
if (state.gran === "week") return state.weekId ? [state.weekId] : [];
|
||
const m = currentMonth();
|
||
return m ? m.period_ids : [];
|
||
}
|
||
function scopeLabel() {
|
||
if (state.gran === "week") {
|
||
const w = (state.periods.weeks || []).find((x) => x.id === state.weekId);
|
||
return w ? w.full_label : "—";
|
||
}
|
||
const m = currentMonth();
|
||
return m ? m.label : "—";
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- charts */
|
||
function barChart(items, opts) {
|
||
opts = opts || {};
|
||
const W = 660, H = opts.height || 190, padL = 34, padB = 30, padT = 14;
|
||
const innerW = W - padL - 12, innerH = H - padB - padT;
|
||
const max = Math.max(1, ...items.map((d) => d.value));
|
||
const n = items.length || 1;
|
||
const slot = innerW / n;
|
||
const bw = Math.min(38, slot * 0.6);
|
||
let bars = "";
|
||
items.forEach((d, i) => {
|
||
const h = Math.max(2, (d.value / max) * innerH);
|
||
const x = padL + slot * i + (slot - bw) / 2;
|
||
const y = padT + innerH - h;
|
||
bars += `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${bw.toFixed(1)}" height="${h.toFixed(1)}" rx="3" fill="${d.color || "#2563eb"}" opacity="${d.dim ? .45 : .9}"><title>${esc(d.label)}: ${d.value}${esc(opts.unit || "")}</title></rect>`;
|
||
if (d.value > 0) {
|
||
bars += `<text x="${(x + bw / 2).toFixed(1)}" y="${(y - 4).toFixed(1)}" font-size="10" fill="#7a8394" text-anchor="middle">${d.value}</text>`;
|
||
}
|
||
bars += `<text x="${(padL + slot * i + slot / 2).toFixed(1)}" y="${(H - 10).toFixed(1)}" font-size="10" fill="#7a8394" text-anchor="middle">${esc(d.label)}</text>`;
|
||
});
|
||
let grid = "";
|
||
for (let g = 0; g <= 3; g++) {
|
||
const y = padT + (innerH / 3) * g;
|
||
grid += `<line x1="${padL}" y1="${y.toFixed(1)}" x2="${W - 8}" y2="${y.toFixed(1)}" stroke="#eef0f4"/>`;
|
||
grid += `<text x="4" y="${(y + 3).toFixed(1)}" font-size="9" fill="#aeb5c2">${Math.round(max - (max / 3) * g)}</text>`;
|
||
}
|
||
return `<svg class="svg-chart" viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet">${grid}${bars}</svg>`;
|
||
}
|
||
|
||
function donutChart(items) {
|
||
const total = items.reduce((a, b) => a + b.value, 0) || 1;
|
||
const r = 58, cx = 80, cy = 80, C = 2 * Math.PI * r;
|
||
let acc = 0, segs = "";
|
||
items.forEach((d) => {
|
||
const len = (d.value / total) * C;
|
||
segs += `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${d.color}" stroke-width="22" stroke-dasharray="${len.toFixed(2)} ${(C - len).toFixed(2)}" stroke-dashoffset="${(-acc).toFixed(2)}" transform="rotate(-90 ${cx} ${cy})"><title>${esc(d.name)}: ${d.value}</title></circle>`;
|
||
acc += len;
|
||
});
|
||
return `<svg class="svg-chart" viewBox="0 0 160 160" style="max-width:190px;margin:0 auto">
|
||
${segs}
|
||
<text x="80" y="76" text-anchor="middle" font-size="22" font-weight="700" fill="#1f2430">${total}</text>
|
||
<text x="80" y="95" text-anchor="middle" font-size="10" fill="#7a8394">项目总数</text>
|
||
</svg>`;
|
||
}
|
||
|
||
function stackBar(items) {
|
||
const total = items.reduce((a, b) => a + b.count, 0) || 1;
|
||
let x = 0, segs = "";
|
||
items.filter((i) => i.count > 0).forEach((d) => {
|
||
const w = (d.count / total) * 100;
|
||
segs += `<rect x="${x.toFixed(2)}%" y="0" width="${w.toFixed(2)}%" height="14" fill="${d.color}"><title>${esc(d.name)}: ${d.count}</title></rect>`;
|
||
x += w;
|
||
});
|
||
return `<svg class="svg-chart" viewBox="0 0 100 14" preserveAspectRatio="none" style="height:14px;border-radius:99px;overflow:hidden">${segs}</svg>`;
|
||
}
|
||
|
||
function barList(rows) {
|
||
return rows.map((r) => `
|
||
<div class="bar-row">
|
||
<div class="b-name" title="${esc(r.name)}">${r.is_key ? starMark(true) + " " : ""}${esc(r.name)}</div>
|
||
<div class="b-track"><i style="width:${Math.min(100, r.progress)}%;background:${r.color}"></i></div>
|
||
<div class="b-val">${r.progress}${r.estimated ? "*" : ""}</div>
|
||
</div>`).join("");
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- periods */
|
||
async function createNewPeriod() {
|
||
const label = prompt("新建周期(格式如:9月第1周)\n年份会自动接在最新周期之后,也可写成 2027年1月第1周", "");
|
||
if (!label || !label.trim()) return null;
|
||
try {
|
||
const r = await post("/periods", { label: label.trim() });
|
||
await loadPeriods();
|
||
state.gran = "week";
|
||
state.weekId = r.period.id;
|
||
state.intakePeriodId = r.period.id;
|
||
$$("#granSeg .seg-btn").forEach((x) => x.classList.toggle("active", x.dataset.gran === "week"));
|
||
renderPeriodSelect();
|
||
toast(r.created ? `已新增周期 ${r.period.full_label}` : `周期 ${r.period.full_label} 已存在,已切换`);
|
||
return r.period.id;
|
||
} catch (err) {
|
||
toast("新建周期失败:" + err.message);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function loadPeriods() {
|
||
state.periods = await api("/periods");
|
||
if (!state.periods.weeks.length) return;
|
||
// 刷新后恢复的周期可能已经被删除/重建,不在列表里就回落到今天所在的周(没有就取最新一期)
|
||
if (!state.periods.weeks.some((w) => w.id === state.weekId)) {
|
||
state.weekId = state.periods.today_id || state.periods.weeks[state.periods.weeks.length - 1].id;
|
||
}
|
||
if (state.monthIdx === null || state.monthIdx >= state.periods.months.length) {
|
||
state.monthIdx = state.periods.months.length - 1;
|
||
}
|
||
renderPeriodSelect();
|
||
}
|
||
|
||
function renderPeriodSelect() {
|
||
saveUI();
|
||
const sel = $("#periodSelect");
|
||
if (!state.periods) return;
|
||
if (state.gran === "week") {
|
||
sel.innerHTML =
|
||
state.periods.weeks
|
||
.map((w) => `<option value="${w.id}" ${w.id === state.weekId ? "selected" : ""}>${esc(w.full_label)}</option>`)
|
||
.join("") + `<option value="__new">+ 新建周期…</option>`;
|
||
} else {
|
||
sel.innerHTML = state.periods.months
|
||
.map((m, i) => `<option value="${i}" ${i === state.monthIdx ? "selected" : ""}>${esc(m.label)}(${m.week_count} 周)</option>`)
|
||
.join("");
|
||
}
|
||
$("#periodHint").textContent = state.gran === "week" ? "周视图" : "月视图(自动汇总该月所有周)";
|
||
}
|
||
|
||
function shiftPeriod(delta) {
|
||
if (state.gran === "week") {
|
||
const arr = state.periods.weeks;
|
||
let i = arr.findIndex((w) => w.id === state.weekId);
|
||
i = Math.min(arr.length - 1, Math.max(0, i + delta));
|
||
state.weekId = arr[i].id;
|
||
} else {
|
||
let i = state.monthIdx + delta;
|
||
i = Math.min(state.periods.months.length - 1, Math.max(0, i));
|
||
state.monthIdx = i;
|
||
}
|
||
renderPeriodSelect();
|
||
refresh();
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- dashboard */
|
||
/** 统计概览(原「执行面板」):折进工作看板底部,不再占一个主导航位 */
|
||
async function dashboardHtml() {
|
||
const s = await api("/stats" + scopeQuery());
|
||
state.stats = s;
|
||
const k = s.kpi;
|
||
const trend = s.trend.map((t) => {
|
||
const cur = scopePeriodIds().indexOf(t.period_id) >= 0;
|
||
return { label: `${t.month}/${t.week || ""}`, value: t.update_count, color: cur ? "#2563eb" : "#93b4f5", dim: !cur };
|
||
});
|
||
|
||
return `
|
||
<div class="grid kpi" style="margin-bottom:16px">
|
||
<div class="kpi accent-blue">${Ico.badge("layers")}<div class="k-label">在管项目</div><div class="k-value">${k.project_total}</div><div class="k-foot">重点项目 ${k.key_total} 个</div></div>
|
||
<div class="kpi accent-violet"><div class="k-label">${esc(scopeLabel())} · 执行记录</div><div class="k-value">${k.update_count}</div><div class="k-foot">覆盖项目 ${k.covered_projects} / ${k.project_total}</div></div>
|
||
<div class="kpi accent-green">${Ico.badge("gauge")}<div class="k-label">平均进度</div><div class="k-value">${k.avg_progress}%</div><div class="k-foot">按项目最新进度估算</div></div>
|
||
<div class="kpi accent-amber">${Ico.badge("alert")}<div class="k-label">风险 / 阻塞</div><div class="k-value">${k.risk_count}</div><div class="k-foot">本周期需关注条目</div></div>
|
||
<div class="kpi accent-red">${Ico.badge("alert-circle")}<div class="k-label">重点项目未更新</div><div class="k-value">${s.idle_key.length}</div><div class="k-foot">本周期无执行记录</div></div>
|
||
</div>
|
||
|
||
<div class="grid g2" style="margin-bottom:16px">
|
||
<div class="card">
|
||
<div class="card-head"><h4>周期更新活跃度<span class="sub">近 ${trend.length} 个周期的执行记录条数</span></h4></div>
|
||
<div class="card-body">${barChart(trend, { unit: " 条" })}</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-head"><h4>项目分类分布</h4></div>
|
||
<div class="card-body">
|
||
<div style="display:flex;gap:18px;align-items:center;flex-wrap:wrap">
|
||
<div style="flex:0 0 190px">${donutChart(s.category_dist.map((c) => ({ name: c.name, color: c.color, value: c.count })))}</div>
|
||
<div class="legend" style="flex-direction:column;gap:6px;flex:1">
|
||
${s.category_dist.map((c) => `<div><i style="background:${c.color}"></i>${esc(c.name)} <b>${c.count}</b></div>`).join("")}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid g2e" style="margin-bottom:16px">
|
||
<div class="card">
|
||
<div class="card-head"><h4>执行状态分布<span class="sub">${esc(scopeLabel())}</span></h4></div>
|
||
<div class="card-body">
|
||
${stackBar(s.status_dist)}
|
||
<div class="legend">${s.status_dist.map((x) => `<span><i style="background:${x.color}"></i>${esc(x.name)} ${x.count}</span>`).join("")}</div>
|
||
<div class="sec-title" style="margin-top:18px">进度领先 Top 10</div>
|
||
${barList(s.top_progress)}
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-head"><h4>风险与阻塞<span class="sub">${s.risks.length} 条</span></h4></div>
|
||
<div class="card-body" style="max-height:330px;overflow:auto">
|
||
${s.risks.length ? s.risks.map((r) => `
|
||
<div class="upd-item">
|
||
<div class="u-head">
|
||
<span class="u-task">${esc(r.project_name)}</span>${statusPill(r.status)}
|
||
<span class="tag-mini">${esc(r.period_label || "")}</span>
|
||
</div>
|
||
<div class="u-body">${nl2br(r.risk || r.content || "")}</div>
|
||
</div>`).join("") : `<div class="empty">本周期暂无风险上报</div>`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid g2">
|
||
<div class="card">
|
||
<div class="card-head"><h4>低进度预警<span class="sub">进度 < 30% 且未完成</span></h4></div>
|
||
<div class="card-body">${s.low_progress.length ? barList(s.low_progress) : `<div class="empty">暂无</div>`}</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-head"><h4>最近更新</h4></div>
|
||
<div class="card-body" style="max-height:300px;overflow:auto">
|
||
${s.recent_updates.length ? s.recent_updates.map((u) => `
|
||
<div class="upd-item">
|
||
<div class="u-head"><span class="u-task">${esc(u.project_name)}</span>${statusPill(u.status)}<span class="tag-mini">${esc(u.task_name || "")}</span><span class="tag-mini">${esc(u.period_label || "")}</span></div>
|
||
<div class="u-body">${nl2br((u.summary || u.content || "").slice(0, 160))}</div>
|
||
</div>`).join("") : `<div class="empty">暂无</div>`}
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- 周看板 */
|
||
/** 左栏一行:重点项目 / 定制项目共用 */
|
||
function weeklyItem(r) {
|
||
const sel = state.weeklySel;
|
||
const active = sel && sel.kind === r.kind && sel.id === r.id;
|
||
const color = r.kind === "project"
|
||
? ((r.category && r.category.color) || "#2563eb")
|
||
: stageMeta(r.stage).color;
|
||
const badge = r.has_update
|
||
? `<span class="tag-mini" style="background:#e9f7ef;color:#16a34a">${r.update_count} 条</span>`
|
||
: `<span class="tag-mini" style="background:#fdecec;color:#dc2626">未更新</span>`;
|
||
return `
|
||
<div class="wk-item ${active ? "active" : ""}" style="--c:${color}" data-wk="${r.kind}:${r.id}">
|
||
<div class="wk-i-top">
|
||
<span class="wk-i-name">${r.is_key ? starMark(true) : ""}${esc(r.name)}</span>
|
||
${badge}
|
||
</div>
|
||
<div class="wk-i-meta">
|
||
${r.kind === "project" ? statusPill(r.status) : stagePill(r.stage)}
|
||
<span class="tag-mini">${esc(r.owner || "未指派")}</span>
|
||
${r.kind === "project"
|
||
? `<span class="tag-mini">${r.progress}%</span>`
|
||
: `<span class="tag-mini">${fen(r.amount)} 万</span>`}
|
||
${r.risks.length ? `<span class="tag-mini" style="background:#fff4e0;color:#b45309">风险 ${r.risks.length}</span>` : ""}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
/** 交付环节步骤条:日期可直接补录,补了日期后端会自动推进商务阶段 */
|
||
function pipelineBar(p, readonly) {
|
||
const steps = (state.customMeta && state.customMeta.pipeline) || [];
|
||
return `<div class="pipe">
|
||
${steps.map((s, i) => `
|
||
${i ? `<div class="pipe-arrow">${Ico.svg("chevron-right")}</div>` : ""}
|
||
<div class="pipe-step ${p[s.date_field] ? "done" : ""} ${p.stage === s.stage ? "now" : ""}" style="--c:${s.color}">
|
||
<div class="pipe-name">${Ico.pipe(s.code)}${esc(s.name)}</div>
|
||
${readonly
|
||
? `<div class="pipe-date ${p[s.date_field] ? "" : "none"}">${p[s.date_field] ? esc(p[s.date_field]) : "未填"}</div>`
|
||
: `<input class="inp" type="date" data-cf="${s.date_field}" value="${p[s.date_field] || ""}" />`}
|
||
</div>`).join("")}
|
||
</div>`;
|
||
}
|
||
|
||
/** 一条执行 / 交付记录 */
|
||
function updateItem(u) {
|
||
return `
|
||
<div class="upd-item">
|
||
<div class="u-head">
|
||
<span class="u-task">${esc(u.task_name || u.period_full || "记录")}</span>
|
||
${u.stage ? stagePill(u.stage) : statusPill(u.status)}
|
||
${u.progress !== null && u.progress !== undefined ? `<span class="tag-mini">${u.progress}%</span>` : ""}
|
||
${u.period_full && u.period_full !== u.task_name ? `<span class="tag-mini">${esc(u.period_label || "")}</span>` : ""}
|
||
</div>
|
||
<div class="u-body">${nl2br(u.summary || u.content || "(无正文)")}</div>
|
||
${u.risk ? `<div class="u-risk">${Ico.svg("alert-circle")}<div>${nl2br(u.risk)}</div></div>` : ""}
|
||
${u.next_step ? `<div class="u-next">${Ico.svg("arrow-right")}<div>${nl2br(u.next_step)}</div></div>` : ""}
|
||
</div>`;
|
||
}
|
||
|
||
function weeklyHead(row) {
|
||
const isP = row.kind === "project";
|
||
const color = isP ? ((row.category && row.category.color) || "#2563eb") : stageMeta(row.stage).color;
|
||
return `
|
||
<div class="wk-head" style="--c:${color}">
|
||
<div class="wk-head-title">${Ico.badge(isP ? Ico.nameOfStatus(row.status) : Ico.nameOfStage(row.stage))}${row.is_key ? starMark(true) : ""}${esc(row.name)}</div>
|
||
<div class="wk-i-meta">
|
||
${isP ? catPill(row.category) + " " + statusPill(row.status) : stagePill(row.stage)}
|
||
<span class="tag-mini">${esc(row.owner || "未指派")}</span>
|
||
<span class="tag-mini">最近更新 ${esc(row.last_update || "尚无记录")}</span>
|
||
${row.idle_weeks ? `<span class="tag-mini" style="background:#fff4e0;color:#b45309">已 ${row.idle_weeks} 周没更新</span>` : ""}
|
||
</div>
|
||
<div class="inline-fields" style="margin-top:10px">
|
||
<button class="btn sm primary" id="wkIntake">${Ico.svg("plus")}录一笔${isP ? "执行" : "交付"}记录</button>
|
||
<button class="btn sm" id="wkDrawer">完整详情</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
/* 右栏:常规项目详情 */
|
||
async function weeklyProjectDetail(row) {
|
||
const d = await api("/projects/" + row.id);
|
||
const p = d.project;
|
||
const color = (p.category && p.category.color) || "#2563eb";
|
||
const trend = d.timeline.slice(-8).reverse();
|
||
return `
|
||
${weeklyHead(row)}
|
||
<div class="sec">
|
||
<div class="sec-title">本期执行记录(${row.updates.length})</div>
|
||
${row.updates.length
|
||
? row.updates.map(updateItem).join("")
|
||
: `<div class="notice warn">本周期还没有执行记录。重点项目每周都要更新一次,点上方「录一笔执行记录」补上。</div>`}
|
||
</div>
|
||
|
||
<div class="sec">
|
||
<div class="sec-title">进度与状态<span class="sub">${esc(row.progress_source)} · 共 ${p.update_count} 条历史记录 / ${d.tasks.length} 个子任务</span></div>
|
||
<div class="progress"><i style="width:${Math.min(100, row.progress)}%;background:${color}"></i></div>
|
||
<div class="progress-label"><span>整体进度</span><span>${row.progress}%</span></div>
|
||
${d.tasks.length ? `<div class="inline-fields" style="margin-top:10px">${d.tasks.map((t) => `<span class="tag-mini">${esc(t.name)}</span>`).join("")}</div>` : ""}
|
||
</div>
|
||
|
||
${trend.length ? `
|
||
<div class="sec">
|
||
<div class="sec-title">近 ${trend.length} 期趋势</div>
|
||
${trend.map((t) => `
|
||
<div class="timeline-row">
|
||
<div class="t-label">${esc(t.period.full_label.replace(/^\d+年/, ""))}</div>
|
||
<div style="flex:1">${statusPill(t.status)}${t.progress !== null ? ` <span class="tag-mini">${t.progress}%</span>` : ""}
|
||
<span class="tag-mini">${t.updates.length} 条</span></div>
|
||
</div>`).join("")}
|
||
</div>` : ""}`;
|
||
}
|
||
|
||
/* 右栏:定制项目详情(交付环节 + 财务) */
|
||
async function weeklyCustomDetail(row) {
|
||
const d = await api("/custom/projects/" + row.id);
|
||
const p = d.project;
|
||
const hist = d.updates.slice(-5).reverse();
|
||
return `
|
||
${weeklyHead(row)}
|
||
<div class="sec">
|
||
<div class="sec-title">交付环节<span class="sub">补录日期会自动推进商务阶段</span></div>
|
||
${pipelineBar(p)}
|
||
${p.remark ? `<div class="kcard-remark" style="margin-top:10px">${nl2br(p.remark)}</div>` : ""}
|
||
</div>
|
||
|
||
<div class="sec">
|
||
<div class="sec-title">财务跟踪</div>
|
||
<div class="grid kpi" style="gap:10px;grid-template-columns:repeat(auto-fit,minmax(132px,1fr))">
|
||
<div class="kpi">${Ico.badge("wallet")}<div class="k-label">合同额</div><div class="k-value" style="font-size:18px">${wan(p.amount)}</div></div>
|
||
<div class="kpi">${Ico.badge("receipt")}<div class="k-label">可确认收入</div><div class="k-value" style="font-size:18px">${wan(p.confirmed_amount)}</div><div class="k-foot">${p.revenue_ratio}%</div></div>
|
||
<div class="kpi">${Ico.badge("wallet")}<div class="k-label">已计收</div><div class="k-value" style="font-size:18px">${wan(p.revenue_amount)}</div></div>
|
||
<div class="kpi">${Ico.badge("hourglass")}<div class="k-label">待计收</div><div class="k-value" style="font-size:18px">${wan(p.pending_amount)}</div></div>
|
||
</div>
|
||
<div class="inline-fields" style="margin-top:12px">
|
||
<label class="field-mini">开票金额<input class="inp" type="number" data-cf="invoice_amount" value="${p.invoice_amount === null ? "" : p.invoice_amount}" style="width:110px;text-align:right" /></label>
|
||
<label class="field-mini">已回款<input class="inp" type="number" data-cf="received_amount" value="${p.received_amount === null ? "" : p.received_amount}" style="width:110px;text-align:right" /></label>
|
||
<span class="tag-mini" style="background:#fff4e0;color:#b45309">待开票 ${fen(p.pending_invoice)} 万 · 待回款 ${fen(p.pending_received)} 万</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="sec">
|
||
<div class="sec-title">本期交付记录(${row.updates.length})</div>
|
||
${row.updates.length
|
||
? row.updates.map(updateItem).join("")
|
||
: `<div class="notice ${row.is_key ? "warn" : "info"}">本周期没有交付记录。</div>`}
|
||
</div>
|
||
|
||
${hist.length ? `
|
||
<div class="sec">
|
||
<div class="sec-title">更早的交付记录</div>
|
||
${hist.map((u) => `
|
||
<div class="timeline-row">
|
||
<div class="t-label">${esc((u.period_full || "").replace(/^\d+年/, ""))}</div>
|
||
<div style="flex:1">${stagePill(u.stage)}${u.revenue_delta ? ` <span class="tag-mini" style="background:#e9f7ef;color:#16a34a">计收 ${fen(u.revenue_delta)}万</span>` : ""}</div>
|
||
</div>`).join("")}
|
||
</div>` : ""}`;
|
||
}
|
||
|
||
function bindWeeklyDetail(box, row) {
|
||
const inBtn = $("#wkIntake");
|
||
if (inBtn) inBtn.addEventListener("click", () => {
|
||
state.intakePeriodId = state.weekId;
|
||
if (row.kind === "project") { state.intakeType = "general"; intakeProjectId = row.id; }
|
||
else { state.intakeType = "custom"; customIntakeId = row.id; }
|
||
setView("intake");
|
||
});
|
||
const dw = $("#wkDrawer");
|
||
if (dw) dw.addEventListener("click", () =>
|
||
row.kind === "project" ? openDrawer(row.id) : openCustomDrawer(row.id)
|
||
);
|
||
box.querySelectorAll("[data-cf]").forEach((inp) =>
|
||
inp.addEventListener("change", async () => {
|
||
const f = inp.dataset.cf;
|
||
let v = inp.value;
|
||
if (f === "invoice_amount" || f === "received_amount") v = v === "" ? null : Number(v);
|
||
try {
|
||
await patch("/custom/projects/" + row.id, { [f]: v });
|
||
toast("已保存");
|
||
await afterWrite();
|
||
} catch (e) { toast("保存失败:" + e.message); }
|
||
})
|
||
);
|
||
}
|
||
|
||
/** 定制项目本周动态里的一条:整行可点,点进去看该项目详情 */
|
||
function weeklyCustomRow(r, tag) {
|
||
return `
|
||
<div class="wk-item" style="--c:${stageMeta(r.stage).color}" data-cpick="${r.id}">
|
||
<div class="wk-i-top">
|
||
<span class="wk-i-name">${r.is_key ? starMark(true) : ""}${esc(r.name)}</span>
|
||
${tag || ""}
|
||
</div>
|
||
<div class="wk-i-meta">
|
||
${stagePill(r.stage)}
|
||
<span class="tag-mini">${esc(r.owner || "未指派")}</span>
|
||
<span class="tag-mini">${fen(r.amount)} 万</span>
|
||
${r.period_revenue_delta ? `<span class="tag-mini" style="background:#e9f7ef;color:#16a34a">计收 ${fen(r.period_revenue_delta)} 万</span>` : ""}
|
||
${r.risks.length ? `<span class="tag-mini" style="background:#fff4e0;color:#b45309">风险 ${r.risks.length}</span>` : ""}
|
||
</div>
|
||
${r.summary ? `<div class="wk-i-sub">${esc(String(r.summary).replace(/\s+/g, " ").slice(0, 90))}</div>` : ""}
|
||
</div>`;
|
||
}
|
||
|
||
/** 右栏:定制项目本周动态。左栏的「定制项目」只给入口,清单都收在这里 */
|
||
function weeklyCustomOverview(data) {
|
||
const rows = data.custom;
|
||
const added = rows.filter((r) => r.is_new);
|
||
const moved = rows.filter((r) => r.has_update && !r.is_new);
|
||
const idle = rows.filter((r) => !r.is_new && !r.has_update);
|
||
// 合计只算列出来的行,免得数字对不上清单
|
||
const shown = added.concat(moved).concat(state.weeklyAllCustom ? idle : []);
|
||
const sum = shown.reduce((a, r) => a + Number(r.amount || 0), 0);
|
||
const sec = (title, sub, list, tag) => `
|
||
<div class="sec">
|
||
<div class="sec-title">${title}<span class="sub">${sub} · ${list.length} 个</span></div>
|
||
${list.length ? list.map((r) => weeklyCustomRow(r, tag(r))).join("")
|
||
: `<div class="empty" style="padding:14px 4px;font-size:12px">本周期没有</div>`}
|
||
</div>`;
|
||
return `
|
||
<div class="wk-head" style="--c:#0d9488">
|
||
<div class="wk-head-title">${Ico.badge("package")}定制项目 · 本周动态</div>
|
||
<div class="wk-i-meta">
|
||
<span class="tag-mini">新入库 ${added.length} 个</span>
|
||
<span class="tag-mini">有交付动态 ${moved.length} 个</span>
|
||
<span class="tag-mini">合同额合计 ${fen(sum)} 万</span>
|
||
<span class="tag-mini">本期计收 ${fen(data.kpi.revenue_delta)} 万</span>
|
||
</div>
|
||
<div class="inline-fields" style="margin-top:10px">
|
||
<label class="switch"><input type="checkbox" id="wkAllCustom" ${state.weeklyAllCustom ? "checked" : ""} /> 列入全部在途</label>
|
||
<button class="btn sm" id="wkGoCustom">去定制交付</button>
|
||
</div>
|
||
</div>
|
||
${sec("本周新增", "入库时间落在本周期", added,
|
||
(r) => `<span class="tag-mini" style="background:#e8f0ff;color:#2563eb">${esc(r.entry_date || "新入库")}</span>`)}
|
||
${sec("本周有变化", "本周期有交付记录", moved,
|
||
(r) => `<span class="tag-mini" style="background:#e9f7ef;color:#16a34a">${r.update_count} 条记录</span>`)}
|
||
${state.weeklyAllCustom ? sec("其他在途项目", "本周没有动态", idle,
|
||
(r) => (r.is_key ? `<span class="tag-mini">重点</span>` : "")) : ""}
|
||
${!state.weeklyAllCustom && idle.length
|
||
? `<div class="notice info" style="margin-top:14px">另有 <b>${idle.length}</b> 个在途定制项目本周没有动态,勾选「列入全部在途」一并列出。</div>` : ""}`;
|
||
}
|
||
|
||
function bindWeeklyCustomOverview(box) {
|
||
const ac = box.querySelector("#wkAllCustom");
|
||
if (ac) ac.addEventListener("change", () => { state.weeklyAllCustom = ac.checked; renderBoard(); });
|
||
const gc = box.querySelector("#wkGoCustom");
|
||
if (gc) gc.addEventListener("click", () => setView("custom"));
|
||
box.querySelectorAll("[data-cpick]").forEach((el) =>
|
||
el.addEventListener("click", () => {
|
||
state.weeklySel = { kind: "custom", id: Number(el.dataset.cpick) };
|
||
renderWeeklyDetail();
|
||
})
|
||
);
|
||
}
|
||
|
||
async function renderWeeklyDetail() {
|
||
const box = $("#wkRight");
|
||
if (!box) return;
|
||
const sel = state.weeklySel;
|
||
if (sel && sel.kind === "focus") {
|
||
const f = (state.focusRows || []).find((x) => x.id === sel.id);
|
||
box.innerHTML = f ? focusDetailHtml(f) : `<div class="empty">在左侧选择一条重点工作查看详情</div>`;
|
||
const eb = $("#wkFocusEdit");
|
||
if (eb) eb.addEventListener("click", () => openFocusDrawer(sel.id, "edit"));
|
||
return;
|
||
}
|
||
if (sel && sel.kind === "customs") {
|
||
box.innerHTML = weeklyCustomOverview(state.weekly);
|
||
bindWeeklyCustomOverview(box);
|
||
return;
|
||
}
|
||
const row = sel && (sel.kind === "project" ? state.weekly.projects : state.weekly.custom)
|
||
.find((r) => r.id === sel.id);
|
||
if (!row) { box.innerHTML = `<div class="empty">在左侧选择一个项目查看执行详情</div>`; return; }
|
||
box.innerHTML = `<div class="empty">加载中…</div>`;
|
||
try {
|
||
box.innerHTML = row.kind === "project"
|
||
? await weeklyProjectDetail(row)
|
||
: await weeklyCustomDetail(row);
|
||
bindWeeklyDetail(box, row);
|
||
} catch (e) {
|
||
box.innerHTML = `<div class="notice err">加载失败:${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
async function renderBoard() {
|
||
saveUI();
|
||
const host = $("#view-board");
|
||
host.innerHTML = `<div class="empty">加载中…</div>`;
|
||
if (!state.customMeta) state.customMeta = await api("/custom/meta");
|
||
const extra = ["all_custom=" + (state.weeklyAllCustom ? "true" : "false")];
|
||
if (state.weeklyOwner) extra.push("owner=" + encodeURIComponent(state.weeklyOwner));
|
||
const [data, focusRows] = await Promise.all([
|
||
api("/weekly" + scopeQuery(extra)),
|
||
api("/focus" + focusQuery(["archived=false"])),
|
||
]);
|
||
state.weekly = data;
|
||
state.focusRows = focusRows;
|
||
const k = data.kpi;
|
||
const focus = focusBuckets(focusRows);
|
||
|
||
// 默认选中本期有记录的第一条,选中项还在列表里就保持不变
|
||
const all = data.projects.concat(data.custom);
|
||
const cur = state.weeklySel;
|
||
const keep = cur && (cur.kind === "customs" ? true
|
||
: cur.kind === "focus"
|
||
? focusRows.some((f) => f.id === cur.id)
|
||
: all.some((r) => r.kind === cur.kind && r.id === cur.id)) ? cur : null;
|
||
const pick = keep || all.find((r) => r.has_update) || all[0] || null;
|
||
state.weeklySel = pick ? { kind: pick.kind, id: pick.id } : null;
|
||
|
||
const owners = {};
|
||
all.forEach((r) => { if (r.owner) owners[r.owner] = true; });
|
||
const toolbar = `
|
||
<div class="board-toolbar">
|
||
<select id="wkOwner" class="sel" style="width:120px">
|
||
<option value="">全部负责人</option>
|
||
${Object.keys(owners).sort().map((n) => `<option value="${esc(n)}" ${n === state.weeklyOwner ? "selected" : ""}>${esc(n)}</option>`).join("")}
|
||
</select>
|
||
<span style="flex:1"></span>
|
||
<button class="btn sm" id="wkExportUpdates">导出本周期记录</button>
|
||
<span class="period-hint">${esc(scopeLabel())} · 项目 ${data.projects.length} 个 · 定制动态 ${data.kpi.custom_new + data.kpi.custom_updated} 个</span>
|
||
</div>`;
|
||
|
||
const kpi = `
|
||
<div class="grid kpi" style="margin-bottom:14px">
|
||
<div class="kpi accent-blue">${Ico.badge("star")}<div class="k-label">重点项目</div><div class="k-value">${k.key_total}</div><div class="k-foot">本期已更新 ${k.key_updated}</div></div>
|
||
<div class="kpi accent-red">${Ico.badge("alert-circle")}<div class="k-label">重点项目未更新</div><div class="k-value">${k.key_idle}</div><div class="k-foot">每周都要更新一次</div></div>
|
||
<div class="kpi accent-blue">${Ico.badge("list-checks")}<div class="k-label">本周重点工作</div><div class="k-value">${focus.week.length}</div><div class="k-foot">延期 ${focus.overdue.length} 条 · 已完成 ${focus.done.length} 条</div></div>
|
||
<div class="kpi accent-violet">${Ico.badge("activity")}<div class="k-label">执行记录</div><div class="k-value">${k.update_count}</div><div class="k-foot">在办项目 ${k.project_listed} 个</div></div>
|
||
<div class="kpi accent-green">${Ico.badge("wallet")}<div class="k-label">定制合同额</div><div class="k-value" style="font-size:22px">${wan(k.custom_amount)}</div><div class="k-foot">${k.custom_total} 个项目 · 本期计收 ${fen(k.revenue_delta)} 万</div></div>
|
||
<div class="kpi accent-amber">${Ico.badge("alert")}<div class="k-label">风险 / 阻塞</div><div class="k-value">${k.risk_count}</div><div class="k-foot">本期上报条目</div></div>
|
||
</div>`;
|
||
|
||
const group = (title, rows, tip) => `
|
||
<div class="wk-group">
|
||
<div class="wk-group-head">${title}<span class="cnt">${rows.length}</span>
|
||
<span class="wk-group-foot">${rows.filter((r) => r.has_update).length} ${tip}</span></div>
|
||
${rows.length ? rows.map(weeklyItem).join("") : `<div class="empty" style="padding:16px 6px;font-size:12px">本周期没有记录</div>`}
|
||
</div>`;
|
||
|
||
// 左栏顺序:本周工作重点 → 重点项目 → 定制项目(只给入口,点开在右栏看清单)
|
||
const focusList = focus.overdue.concat(focus.week);
|
||
const cNew = data.custom.filter((r) => r.is_new);
|
||
const cUpd = data.custom.filter((r) => r.has_update && !r.is_new);
|
||
const openCustom = cur && (cur.kind === "customs" || cur.kind === "custom");
|
||
|
||
host.innerHTML = kpi + toolbar + `
|
||
<div class="wk-split">
|
||
<div class="wk-left card">
|
||
<div class="wk-group">
|
||
<div class="wk-group-head">本周工作重点<span class="cnt">${focusList.length}</span>
|
||
<span class="wk-group-foot">${focus.overdue.length ? focus.overdue.length + " 条延期" : "已完成 " + focus.done.length + " 条"}</span></div>
|
||
${focusList.length ? focusList.map(focusItem).join("") : `<div class="empty" style="padding:16px 6px;font-size:12px">本周没有安排重点工作</div>`}
|
||
</div>
|
||
${group("重点项目", data.projects, "个已更新")}
|
||
<div class="wk-group">
|
||
<div class="wk-group-head">定制项目<span class="cnt">${data.custom.length}</span>
|
||
<span class="wk-group-foot">本周动态 ${cNew.length + cUpd.length} 个</span></div>
|
||
<div class="wk-item ${openCustom ? "active" : ""}" style="--c:#0d9488" data-wk="customs:0">
|
||
<div class="wk-i-top">
|
||
<span class="wk-i-ic">${Ico.svg("package")}</span>
|
||
<span class="wk-i-name">本周定制项目动态</span>
|
||
<span class="tag-mini">${Ico.svg("arrow-right")}</span>
|
||
</div>
|
||
<div class="wk-i-meta">
|
||
<span class="tag-mini">新增 ${cNew.length}</span>
|
||
<span class="tag-mini">有变化 ${cUpd.length}</span>
|
||
${state.weeklyAllCustom ? `<span class="tag-mini">含全部在途</span>` : ""}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="wk-right card" id="wkRight"><div class="empty">加载中…</div></div>
|
||
</div>`;
|
||
|
||
const wo = $("#wkOwner");
|
||
if (wo) wo.addEventListener("change", () => { state.weeklyOwner = wo.value; renderBoard(); });
|
||
const wex = $("#wkExportUpdates");
|
||
if (wex) wex.addEventListener("click", () => download("/api/export/updates" + scopeQuery([])));
|
||
// 统计概览(原「执行面板」)折在最底下,展开才看;失败也不影响看板主体
|
||
dashboardHtml().then((html) => {
|
||
host.insertAdjacentHTML("beforeend",
|
||
`<details class="card ov"><summary><span class="ov-chev"><i data-icon="chevron-right"></i></span>统计概览<span class="sub">更新活跃度 / 分类分布 / 风险与阻塞</span></summary><div class="ov-body">${html}</div></details>`);
|
||
}).catch((e) => {
|
||
host.insertAdjacentHTML("beforeend", `<div class="notice err">统计概览加载失败:${esc(e.message)}</div>`);
|
||
});
|
||
|
||
host.querySelectorAll("[data-wk]").forEach((el) =>
|
||
el.addEventListener("click", () => {
|
||
const parts = el.dataset.wk.split(":");
|
||
state.weeklySel = { kind: parts[0], id: Number(parts[1]) };
|
||
host.querySelectorAll("[data-wk]").forEach((x) => x.classList.toggle("active", x === el));
|
||
renderWeeklyDetail();
|
||
})
|
||
);
|
||
renderWeeklyDetail();
|
||
}
|
||
|
||
/* ------------------------------------------------------------ 每周重点工作 */
|
||
/** 重点工作一律按「周」跟踪,基准周取顶栏当前选中的那一周 */
|
||
function focusWeek() {
|
||
return (state.periods.weeks || []).find((w) => w.id === state.weekId) || null;
|
||
}
|
||
|
||
function focusQuery(extra) {
|
||
const p = extra ? extra.slice() : [];
|
||
if (state.keyword) p.push("keyword=" + encodeURIComponent(state.keyword));
|
||
return p.length ? "?" + p.join("&") : "";
|
||
}
|
||
|
||
/** 分堆按「录入周」(f.sort_key 就是条目录入的那一周),和预计完成时间无关:
|
||
week = 本周录入的;overdue = 更早周次录入、标了延期又没做完的结转条目。
|
||
「已完成」是交叉视图,已完成条目仍留在自己那一周里,避免录完就看不见。
|
||
更早周次里没标延期的未完成条目不进当周视图,只能在「全部」里查得到。 */
|
||
function focusBuckets(rows) {
|
||
const w = focusWeek();
|
||
const cur = w ? w.sort_key : 0;
|
||
const out = { cur: cur, overdue: [], week: [], done: [] };
|
||
rows.forEach((f) => {
|
||
if (f.sort_key === cur) out.week.push(f);
|
||
else if (f.sort_key < cur && focusIsLate(f)) out.overdue.push(f);
|
||
if (f.status === "done") out.done.push(f);
|
||
});
|
||
return out;
|
||
}
|
||
|
||
/** 是否延期:录入时人工打开的开关,不再按日期自动判定 */
|
||
function focusIsLate(f) {
|
||
return f.status !== "done" && !!f.delayed;
|
||
}
|
||
|
||
/** 本地今天(YYYY-MM-DD),新增条目的预计完成时间默认值 */
|
||
function todayIso() {
|
||
const d = new Date();
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||
}
|
||
|
||
/** 预计完成时间:有日历日期就显示日期,早期没日期的条目退回显示录入周 */
|
||
function focusDueText(f) {
|
||
return f.due_date || f.period_full || "—";
|
||
}
|
||
|
||
/** 状态徽章:打开「是否延期」的未完成条目挂一个「延期」 */
|
||
function focusPill(f) {
|
||
return (focusIsLate(f) ? `<span class="pill" style="background:#fdecec;color:#dc2626">延期</span>` : "") +
|
||
statusPill(f.status);
|
||
}
|
||
|
||
/** 工作看板左栏的一条重点工作 */
|
||
function focusItem(f) {
|
||
const sel = state.weeklySel;
|
||
const late = focusIsLate(f);
|
||
return `
|
||
<div class="wk-item ${sel && sel.kind === "focus" && sel.id === f.id ? "active" : ""}"
|
||
style="--c:${late ? "#dc2626" : statusMeta(f.status).color}" data-wk="focus:${f.id}">
|
||
<div class="wk-i-top">
|
||
<span class="wk-i-ic">${Ico.status(f.status)}</span>
|
||
<span class="wk-i-name">${esc(f.content)}</span>
|
||
${late ? `<span class="tag-mini" style="background:#fdecec;color:#dc2626">延期</span>` : ""}
|
||
</div>
|
||
<div class="wk-i-meta">
|
||
${statusPill(f.status)}
|
||
<span class="tag-mini">预计 ${esc(focusDueText(f))}</span>
|
||
${late ? `<span class="tag-mini">录入 ${esc(f.period_label || "—")}</span>` : ""}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
/** 工作看板右栏的一条重点工作(只读,改内容走抽屉) */
|
||
function focusDetailHtml(f) {
|
||
const ro = (label, val) => `<div class="form-row"><label>${label}</label><div class="ro">${val}</div></div>`;
|
||
return `
|
||
<div class="wk-head" style="--c:${statusMeta(f.status).color}">
|
||
<div class="wk-head-title">${Ico.statusBadge(f.status)}${esc(f.content)}</div>
|
||
<div class="wk-i-meta">
|
||
${focusPill(f)}
|
||
<span class="tag-mini">预计完成 ${esc(focusDueText(f))}</span>
|
||
</div>
|
||
<div class="inline-fields" style="margin-top:10px">
|
||
<button class="btn sm primary" id="wkFocusEdit">修改</button>
|
||
</div>
|
||
</div>
|
||
<div class="sec">
|
||
<div class="sec-title">说明<span class="sec-kind ro">只读</span></div>
|
||
${f.detail ? nl2br(f.detail) : `<div class="empty">未填写说明</div>`}
|
||
</div>
|
||
<div class="sec">
|
||
<div class="sec-title">结果反馈<span class="sec-kind ro">只读</span></div>
|
||
${f.result ? nl2br(f.result) : `<div class="empty">未填写结果反馈</div>`}
|
||
</div>`;
|
||
}
|
||
|
||
async function renderFocus() {
|
||
saveUI();
|
||
const host = $("#view-focus");
|
||
host.innerHTML = `<div class="empty">加载中…</div>`;
|
||
const rows = await api("/focus" + focusQuery(["archived=" + (state.focusArchived ? "all" : "false")]));
|
||
state.focusRows = rows;
|
||
const b = focusBuckets(rows);
|
||
const week = focusWeek();
|
||
const shown = state.focusFilter === "all" ? rows : b[state.focusFilter] || [];
|
||
|
||
const filters = [["week", "本周"], ["overdue", "延期"], ["done", "已完成"], ["all", "全部"]];
|
||
const toolbar = `
|
||
<div class="board-toolbar">
|
||
${filters.map((x) => `<button class="chip ${state.focusFilter === x[0] ? "active" : ""}" data-ffilter="${x[0]}">${x[1]} ${b[x[0]] !== undefined ? b[x[0]].length : rows.length}</button>`).join("")}
|
||
<span style="flex:1"></span>
|
||
${keywordInputHtml("fKeyword", "搜索工作内容…", 200)}
|
||
<button class="btn sm" id="btnExportFocus">导出 Excel</button>
|
||
<button class="btn sm primary" id="btnAddFocus">${Ico.svg("plus")}新增重点工作</button>
|
||
<span class="period-hint">本周/延期按录入周统计 · 基准周 ${esc(week ? week.full_label : "—")}</span>
|
||
</div>`;
|
||
|
||
const table = `<table class="tbl">
|
||
<thead><tr>
|
||
<th style="min-width:220px">工作内容</th><th style="min-width:220px">说明</th>
|
||
<th>预计完成时间</th><th>状态</th><th style="min-width:150px">结果反馈</th><th>更新</th><th class="col-ops">操作</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
${shown.map((f) => `
|
||
<tr ${f.archived ? 'style="opacity:.62"' : ""}>
|
||
<td><div style="display:flex;align-items:center;gap:6px">
|
||
<a href="javascript:;" data-fopen="${f.id}" style="color:var(--text);text-decoration:none">${esc(f.content)}</a>
|
||
${f.archived ? `<span class="arch-badge">已归档</span>` : ""}
|
||
</div></td>
|
||
<td style="color:var(--muted)">${esc((f.detail || "—").replace(/\s+/g, " ").slice(0, 48))}</td>
|
||
<td class="nowrap">${esc(focusDueText(f))}${f.period_label ? `<div class="period-hint">录入 ${esc(f.period_label)}</div>` : ""}</td>
|
||
<td class="nowrap">${focusPill(f)}</td>
|
||
<td style="color:var(--muted)">${esc((f.result || "—").replace(/\s+/g, " ").slice(0, 40))}</td>
|
||
<td class="nowrap">${esc((f.updated_at || "").slice(0, 10) || "—")}</td>
|
||
<td class="nowrap col-ops">
|
||
<button class="btn sm" data-fopen="${f.id}">详情</button>
|
||
<button class="btn sm" data-fedit="${f.id}">修改</button>
|
||
<button class="btn sm" data-farch="${f.id}" data-archived="${f.archived ? "1" : "0"}">${f.archived ? "恢复" : "归档"}</button>
|
||
<button class="btn sm danger" data-fdel="${f.id}">删除</button>
|
||
</td>
|
||
</tr>`).join("")}
|
||
</tbody></table>`;
|
||
|
||
const body = listCard({
|
||
title: "重点工作清单",
|
||
sub: "按录入时间倒序 · 共 " + rows.length + " 条",
|
||
summary: `当前 ${shown.length} 条 · 已完成 ${b.done.length} · 延期 ${b.overdue.length}`,
|
||
fields: `<label class="switch"><input type="checkbox" id="fcArchived" ${state.focusArchived ? "checked" : ""} /> 含归档条目</label>`,
|
||
rows: shown,
|
||
table: table,
|
||
empty: state.keyword
|
||
? "没有匹配「" + esc(state.keyword) + "」的重点工作"
|
||
: state.focusFilter === "week"
|
||
? "本周(" + esc(week ? week.full_label : "—") + ")还没有录入重点工作,点右上角「新增重点工作」添加"
|
||
: "这一堆还没有条目,点右上角「新增重点工作」添加",
|
||
});
|
||
|
||
host.innerHTML = toolbar + body;
|
||
|
||
bindKeyword("fKeyword");
|
||
host.querySelectorAll("[data-ffilter]").forEach((c) =>
|
||
c.addEventListener("click", () => { state.focusFilter = c.dataset.ffilter; renderFocus(); })
|
||
);
|
||
$("#fcArchived").addEventListener("change", (e) => { state.focusArchived = e.target.checked; renderFocus(); });
|
||
$("#btnExportFocus").addEventListener("click", () =>
|
||
download("/api/export/focus" + focusQuery(["archived=" + (state.focusArchived ? "all" : "false")]))
|
||
);
|
||
host.querySelectorAll("[data-fopen]").forEach((el) =>
|
||
el.addEventListener("click", () => openFocusDrawer(Number(el.dataset.fopen), "view"))
|
||
);
|
||
host.querySelectorAll("[data-fedit]").forEach((el) =>
|
||
el.addEventListener("click", () => openFocusDrawer(Number(el.dataset.fedit), "edit"))
|
||
);
|
||
host.querySelectorAll("[data-farch]").forEach((el) =>
|
||
el.addEventListener("click", async () => {
|
||
const toArchive = el.dataset.archived !== "1";
|
||
try {
|
||
await patch("/focus/" + el.dataset.farch, { archived: toArchive });
|
||
} catch (e) { toast("操作失败:" + e.message); return; }
|
||
toast(toArchive ? "已归档(勾选「含归档条目」可查看)" : "已恢复");
|
||
await afterWrite();
|
||
})
|
||
);
|
||
host.querySelectorAll("[data-fdel]").forEach((el) =>
|
||
el.addEventListener("click", () => {
|
||
const row = rows.find((x) => x.id === Number(el.dataset.fdel));
|
||
if (row) deleteFocus(row);
|
||
})
|
||
);
|
||
$("#btnAddFocus").addEventListener("click", () => openFocusDrawer(null, "create"));
|
||
}
|
||
|
||
/** 删除重点工作条目:列表和抽屉共用。条目不是项目,危险确认即可,不要求逐字输入 */
|
||
async function deleteFocus(f) {
|
||
const ok = await confirmDialog({
|
||
title: "删除重点工作条目",
|
||
danger: true,
|
||
okText: "永久删除",
|
||
body: `<div class="notice err">「${esc(f.content)}」将被删除,<b>删除后无法恢复</b>。</div>
|
||
<div style="font-size:12px;color:var(--muted);margin-top:8px">只是这一周不再跟进?请改用「归档」。</div>`,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await del("/focus/" + f.id);
|
||
} catch (e) { toast("删除失败:" + e.message); return; }
|
||
toast("已删除「" + f.content + "」");
|
||
closeDrawer();
|
||
await afterWrite();
|
||
}
|
||
|
||
/** 重点工作抽屉:新增走单独面板,详情只读、修改才出表单;基础信息的动作一律在面板顶部 */
|
||
async function openFocusDrawer(fid, mode) {
|
||
mode = mode === "create" ? "create" : mode === "edit" ? "edit" : "view";
|
||
$("#drawerMask").classList.add("open");
|
||
$("#drawer").classList.add("open");
|
||
$("#drawerBody").innerHTML = `<div class="empty">加载中…</div>`;
|
||
drawerActions("");
|
||
let f = null;
|
||
if (mode === "create") {
|
||
f = { content: "", detail: "", period_id: state.weekId, status: state.meta.statuses[0].code, archived: false };
|
||
} else {
|
||
try {
|
||
const rows = await api("/focus?archived=all");
|
||
f = rows.find((x) => x.id === fid) || null;
|
||
} catch (e) {
|
||
$("#drawerBody").innerHTML = `<div class="notice err">加载失败:${esc(e.message)}</div>`;
|
||
return;
|
||
}
|
||
if (!f) {
|
||
$("#drawerBody").innerHTML = `<div class="empty">条目不存在,可能已被删除</div>`;
|
||
return;
|
||
}
|
||
}
|
||
// 录入周 = 顶栏当前选中的那一周(新增时定下来,之后不随预计完成时间变)
|
||
const w = focusWeek();
|
||
const entryWeek = mode === "create" ? (w ? w.full_label : "顶栏当前周") : (f.period_full || f.period_label || "—");
|
||
$("#drawerTitle").innerHTML = mode === "create"
|
||
? `<i data-icon="list-checks"></i>新增每周重点工作`
|
||
: `<i data-icon="list-checks"></i>${esc(f.content)}`;
|
||
$("#drawerSub").innerHTML = mode === "create"
|
||
? `一条就是一个活儿,不用先建项目 · 录入周 ${esc(entryWeek)}`
|
||
: `${focusPill(f)} · 录入 ${esc(f.period_full || f.period_label || "—")} · 预计完成 ${esc(focusDueText(f))}`;
|
||
|
||
const ro = (label, val) => `<div class="form-row"><label>${label}</label><div class="ro">${val}</div></div>`;
|
||
// 基础信息的动作在标题栏右侧按钮条:危险动作在左,主按钮在最右
|
||
drawerActions(mode === "view" ? `
|
||
<button class="btn sm danger" id="fdDelete">删除</button>
|
||
<button class="btn sm" id="fdArchive">${f.archived ? "恢复" : "归档"}</button>
|
||
<button class="btn sm primary" id="fdEdit">修改</button>` : `
|
||
<button class="btn sm" id="fdCancel">取消</button>
|
||
<button class="btn sm primary" id="fdSave">${mode === "create" ? "添加" : "保存"}</button>`);
|
||
|
||
const form = mode === "view" ? `
|
||
<div class="sec">
|
||
<div class="sec-title">基本信息<span class="sec-kind ro">只读</span></div>
|
||
${ro("工作内容", esc(f.content) + (f.archived ? ` <span class="arch-badge">已归档</span>` : ""))}
|
||
${ro("说明", f.detail ? nl2br(f.detail) : "—")}
|
||
${ro("预计完成时间", esc(focusDueText(f)))}
|
||
${ro("录入周", esc(f.period_full || f.period_label || "—"))}
|
||
${ro("执行状态", statusPill(f.status))}
|
||
${ro("是否延期", f.delayed ? `<span class="pill" style="background:#fdecec;color:#dc2626">延期</span>` : "未延期")}
|
||
${ro("结果反馈", f.result ? nl2br(f.result) : "—")}
|
||
${ro("最近更新", esc((f.updated_at || "").replace("T", " ") || "—"))}
|
||
</div>` : `
|
||
<div class="sec">
|
||
<div class="sec-title">基本信息<span class="sec-kind">${mode === "create" ? "新增" : "编辑"}</span></div>
|
||
<div class="form-row"><label>工作内容 *</label><input class="inp" id="fdContent" value="${esc(f.content)}" placeholder="要干的事,一句话" style="width:100%" /></div>
|
||
<div class="form-row"><label>说明</label><textarea class="inp" id="fdDetail" rows="4" placeholder="补充说明(可选)" style="width:100%">${esc(f.detail || "")}</textarea></div>
|
||
<div class="form-row"><label>录入周</label><div class="ro">${esc(entryWeek)}</div></div>
|
||
<div class="form-row"><label>预计完成时间 *</label><input class="inp" id="fdDue" type="date" value="${esc(f.due_date || todayIso())}" style="width:200px" /></div>
|
||
<div class="form-row"><label>执行状态</label><select class="sel" id="fdStatus" style="width:150px">
|
||
${state.meta.statuses.map((s) => `<option value="${s.code}" ${s.code === f.status ? "selected" : ""}>${esc(s.name)}</option>`).join("")}
|
||
</select></div>
|
||
<div class="form-row"><label>结果反馈</label><textarea class="inp" id="fdResult" rows="3" placeholder="做出来的结果 / 交付结论(可选)" style="width:100%">${esc(f.result || "")}</textarea></div>
|
||
<div class="form-row"><label>是否延期</label><label class="switch"><input type="checkbox" id="fdDelayed" ${f.delayed ? "checked" : ""} /> 标记为延期</label></div>
|
||
<div class="form-row"><label></label><span class="sec-hint">条目归在「录入周」这一周,不随预计完成时间变;延期人工开关,更早周次没做完的标上延期就结转进当周视图</span></div>
|
||
</div>`;
|
||
$("#drawerBody").innerHTML =
|
||
(mode === "view" ? "" : `<div class="notice err" id="fdErr" style="display:none"></div>`) + form;
|
||
|
||
const on = (sel, fn) => { const el = $(sel); if (el) el.addEventListener("click", fn); };
|
||
const after = () => afterWrite();
|
||
|
||
on("#fdEdit", () => openFocusDrawer(fid, "edit"));
|
||
on("#fdCancel", () => (mode === "create" ? closeDrawer() : openFocusDrawer(fid, "view")));
|
||
on("#fdSave", async () => {
|
||
const btn = $("#fdSave");
|
||
const err = $("#fdErr");
|
||
const content = $("#fdContent").value.trim();
|
||
if (!content) { err.textContent = "请填写工作内容"; err.style.display = ""; return; }
|
||
btn.disabled = true;
|
||
err.style.display = "none";
|
||
const due = $("#fdDue").value;
|
||
if (!due) { err.textContent = "请选择预计完成时间"; err.style.display = ""; return; }
|
||
const body = {
|
||
content: content,
|
||
detail: $("#fdDetail").value,
|
||
status: $("#fdStatus").value,
|
||
due_date: due,
|
||
result: $("#fdResult").value,
|
||
delayed: $("#fdDelayed").checked,
|
||
};
|
||
if (mode === "create") body.period_id = state.weekId; // 录入周 = 顶栏当前周
|
||
try {
|
||
if (mode === "create") fid = (await post("/focus", body)).id;
|
||
else await patch("/focus/" + fid, body);
|
||
} catch (e) {
|
||
btn.disabled = false;
|
||
err.textContent = "保存失败:" + e.message;
|
||
err.style.display = "";
|
||
return;
|
||
}
|
||
toast(mode === "create" ? "已添加「" + content + "」" : "已保存");
|
||
after();
|
||
openFocusDrawer(fid, "view");
|
||
});
|
||
on("#fdArchive", async () => {
|
||
try {
|
||
await patch("/focus/" + fid, { archived: !f.archived });
|
||
} catch (e) { toast("操作失败:" + e.message); return; }
|
||
toast(f.archived ? "已恢复" : "已归档");
|
||
closeDrawer();
|
||
after();
|
||
});
|
||
on("#fdDelete", () => deleteFocus(f));
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- 项目管理 */
|
||
async function renderProjects() {
|
||
saveUI();
|
||
const host = $("#view-projects");
|
||
host.innerHTML = `<div class="empty">加载中…</div>`;
|
||
const extra = ["archived=" + (state.showArchived ? "all" : "false")];
|
||
if (state.categoryId) extra.push("category_id=" + state.categoryId);
|
||
if (state.keyOnly) extra.push("is_key=true");
|
||
if (state.projOwner) extra.push("owner=" + encodeURIComponent(state.projOwner));
|
||
const rows = await api("/projects" + scopeQuery(extra));
|
||
const shown = state.projIdleOnly ? rows.filter((r) => !r.updated_this_period) : rows;
|
||
const idle = rows.filter((r) => !r.updated_this_period).length;
|
||
|
||
const ownerFilter = `<select id="pOwner" class="sel" style="width:120px">
|
||
<option value="">全部负责人</option>
|
||
${state.staff.map((s) => `<option value="${esc(s.name)}" ${s.name === state.projOwner ? "selected" : ""}>${esc(s.name)}${s.archived ? "(归档)" : ""}</option>`).join("")}
|
||
</select>`;
|
||
|
||
const keyCount = rows.filter((r) => r.is_key).length;
|
||
const toolbar = `
|
||
<div class="board-toolbar">
|
||
<button class="chip ${state.projIdleOnly ? "active" : ""}" data-pidle="1">只看本${state.gran === "week" ? "周" : "月"}未更新</button>
|
||
<span style="flex:1"></span>
|
||
${keywordInputHtml("pKeyword", "搜索项目名称…", 200)}
|
||
${ownerFilter}
|
||
<button class="btn sm" id="btnExportProjects">导出 Excel</button>
|
||
<button class="btn sm primary" id="btnNewProject">${Ico.svg("plus")}新建项目</button>
|
||
<span class="period-hint">${esc(scopeLabel())} · ${rows.length} 个项目</span>
|
||
</div>`;
|
||
|
||
const table = `<table class="tbl">
|
||
<thead><tr>
|
||
<th style="min-width:200px">项目名称</th><th>分类</th><th>负责人</th><th>状态</th>
|
||
<th class="num">进度%</th><th>本${state.gran === "week" ? "周" : "月"}更新</th>
|
||
<th class="num">记录</th><th class="num">子任务</th><th>最近记录</th><th class="col-ops">操作</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
${shown.map((p) => `
|
||
<tr data-pid="${p.id}" ${p.archived ? 'style="opacity:.62"' : ""}>
|
||
<td><div style="display:flex;align-items:center;gap:6px">
|
||
${starMark(p.is_key, true, "重点标记在详情面板中维护")}
|
||
<a href="javascript:;" data-popen="${p.id}" style="color:var(--text);text-decoration:none">${esc(p.name)}</a>
|
||
${p.archived ? `<span class="arch-badge">已归档</span>` : ""}
|
||
</div></td>
|
||
<td>${catPill(p.category)}</td>
|
||
<td><div class="people-cell">${nameChips(p.owners)}</div></td>
|
||
<td>${statusPill(p.status)}</td>
|
||
<td class="num">${Math.round(p.progress)}%</td>
|
||
<td class="nowrap">${p.updated_this_period
|
||
? `<span class="pill" style="background:#e9f7ef;color:#16a34a">已更新</span>`
|
||
: `<span class="pill" style="background:#fdecec;color:#dc2626">${p.idle_weeks === null ? "从未更新" : p.idle_weeks + " 周未更新"}</span>`}</td>
|
||
<td class="num">${p.update_count}</td>
|
||
<td class="num">${p.task_count}</td>
|
||
<td>${esc((p.last_update && p.last_update.period_full) || "—")}</td>
|
||
<td class="nowrap col-ops">
|
||
<button class="btn sm" data-popen="${p.id}">详情</button>
|
||
<button class="btn sm" data-pedit="${p.id}">修改</button>
|
||
<button class="btn sm" data-parch="${p.id}" data-archived="${p.archived ? "1" : "0"}">${p.archived ? "恢复" : "归档"}</button>
|
||
</td>
|
||
</tr>`).join("")}
|
||
</tbody></table>`;
|
||
|
||
const body = listCard({
|
||
title: "重点项目明细",
|
||
sub: "重点项目排在前面 · 列表只读,改数据请点「修改」",
|
||
summary: `共 ${rows.length} 个 · 重点 ${keyCount} 个 · 本${state.gran === "week" ? "周" : "月"}未更新 ${idle} 个`,
|
||
fields: `<label class="switch"><input type="checkbox" id="chkArchived" ${state.showArchived ? "checked" : ""} /> 含归档项目</label>`,
|
||
rows: shown,
|
||
table: table,
|
||
empty: state.keyword
|
||
? "没有匹配「" + esc(state.keyword) + "」的项目"
|
||
: "还没有项目",
|
||
});
|
||
|
||
host.innerHTML = toolbar + body;
|
||
|
||
bindKeyword("pKeyword");
|
||
host.querySelectorAll("[data-pidle]").forEach((b) =>
|
||
b.addEventListener("click", () => { state.projIdleOnly = !state.projIdleOnly; renderProjects(); })
|
||
);
|
||
const po = $("#pOwner");
|
||
if (po) po.addEventListener("change", () => { state.projOwner = po.value; renderProjects(); });
|
||
host.querySelectorAll("[data-popen]").forEach((a) =>
|
||
a.addEventListener("click", () => openDrawer(Number(a.dataset.popen), "view"))
|
||
);
|
||
host.querySelectorAll("[data-pedit]").forEach((b) =>
|
||
b.addEventListener("click", () => openDrawer(Number(b.dataset.pedit), "edit"))
|
||
);
|
||
host.querySelectorAll("[data-parch]").forEach((b) =>
|
||
b.addEventListener("click", async () => {
|
||
const toArchive = b.dataset.archived !== "1";
|
||
await patch("/projects/" + b.dataset.parch, { archived: toArchive });
|
||
toast(toArchive ? "已归档(勾选「含归档项目」可查看)" : "已恢复");
|
||
await afterWrite();
|
||
})
|
||
);
|
||
$("#chkArchived").addEventListener("change", (e) => { state.showArchived = e.target.checked; renderProjects(); });
|
||
$("#btnExportProjects").addEventListener("click", () => {
|
||
const q = ["archived=" + (state.showArchived ? "all" : "false")];
|
||
if (state.categoryId) q.push("category_id=" + state.categoryId);
|
||
if (state.keyword) q.push("keyword=" + encodeURIComponent(state.keyword));
|
||
download("/api/export/projects?" + q.join("&"));
|
||
});
|
||
$("#btnNewProject").addEventListener("click", () => {
|
||
openModal(
|
||
`<div class="modal-head"><h3>新建项目</h3><button class="icon-btn" data-mclose title="关闭">${Ico.svg("x")}</button></div>
|
||
<div class="modal-body">
|
||
<div class="form-grid">
|
||
<div class="form-row full"><label>项目名称 *</label><input class="inp" id="npName" data-autofocus style="width:100%" /></div>
|
||
<div class="form-row"><label>分类</label><select class="sel" id="npCat" style="width:100%">
|
||
<option value="">(未分类)</option>
|
||
${state.categories.map((c) => `<option value="${c.id}">${esc(c.name)}</option>`).join("")}
|
||
</select></div>
|
||
<div class="form-row"><label>负责人</label>${ownerSelectHtml("", 'id="npOwner" style="width:100%"')}</div>
|
||
<div class="form-row full"><label>重点项目</label><label class="switch"><input type="checkbox" id="npKey" /> 标记为重点(★,进入每周周看板)</label></div>
|
||
</div>
|
||
<div class="notice err" id="mErr" style="display:none"></div>
|
||
</div>
|
||
<div class="modal-foot"><button class="btn" data-mclose>取消</button><button class="btn primary" id="mOk">创建</button></div>`
|
||
);
|
||
$("#mOk").addEventListener("click", async () => {
|
||
const name = $("#npName").value.trim();
|
||
if (!name) return modalError("请填写项目名称");
|
||
const btn = $("#mOk");
|
||
btn.disabled = true;
|
||
try {
|
||
await post("/projects", {
|
||
name: name,
|
||
category_id: $("#npCat").value ? Number($("#npCat").value) : null,
|
||
owner: $("#npOwner").value || null,
|
||
is_key: $("#npKey").checked,
|
||
});
|
||
closeModal();
|
||
toast("已创建「" + name + "」");
|
||
await afterWrite();
|
||
} catch (e) { btn.disabled = false; modalError(e.message); }
|
||
});
|
||
$("#npName").addEventListener("keydown", (e) => { if (e.key === "Enter") $("#mOk").click(); });
|
||
});
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- 定制交付 */
|
||
function stageMeta(code) {
|
||
const list = (state.customMeta && state.customMeta.stages) || [];
|
||
return list.find((s) => s.code === code) || { code, name: code, color: "#94a3b8" };
|
||
}
|
||
function stagePill(code) {
|
||
const s = stageMeta(code);
|
||
return `<span class="pill" style="background:${s.color}1a;color:${s.color}">${Ico.stage(code)}${esc(s.name)}</span>`;
|
||
}
|
||
|
||
/** 定制交付视图的查询条件:周期 + 项目名称检索 + 区域(+ 归档态) */
|
||
function customQuery(extra) {
|
||
const p = [];
|
||
if (state.gran === "week") {
|
||
if (state.weekId) p.push("period_id=" + state.weekId);
|
||
} else {
|
||
const m = currentMonth();
|
||
if (m) p.push("year=" + m.year, "month=" + m.month);
|
||
}
|
||
if (state.keyword) p.push("keyword=" + encodeURIComponent(state.keyword));
|
||
if (state.customRegion) p.push("region=" + encodeURIComponent(state.customRegion));
|
||
(extra || []).forEach((x) => p.push(x));
|
||
return "?" + p.join("&");
|
||
}
|
||
|
||
let keywordFocus = false;
|
||
|
||
async function renderCustom() {
|
||
saveUI();
|
||
const host = $("#view-custom");
|
||
host.innerHTML = `<div class="empty">加载中…</div>`;
|
||
if (!state.customMeta) state.customMeta = await api("/custom/meta");
|
||
|
||
const [board, stats] = await Promise.all([
|
||
api("/custom/board" + customQuery()),
|
||
api("/custom/stats" + customQuery(["archived=" + state.customArchived])),
|
||
]);
|
||
state.customBoard = board;
|
||
state.customStats = stats;
|
||
const k = stats.kpi;
|
||
|
||
// 项目明细 / 交付流水线 / 财务跟踪 都基于 /custom/projects:
|
||
// 支持归档态筛选,并按入库时间倒序
|
||
const needRows = ["list", "pipeline", "finance"].indexOf(state.customTab) >= 0;
|
||
state.customRows = needRows
|
||
? await api("/custom/projects" + customQuery(["archived=" + state.customArchived]))
|
||
: [];
|
||
|
||
// 区域筛选:用规范名称、按大区分组,只列出实际有项目的区域
|
||
const usedSet = {};
|
||
(state.customMeta.regions || []).forEach((r) => { usedSet[r] = true; });
|
||
const stdAll = (state.customMeta.region_groups || []).reduce((a, g) => a.concat(g.regions), []);
|
||
let regionFilterHtml = `<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 tabs = `
|
||
<div class="board-toolbar">
|
||
<button class="chip ${state.customTab === "pipeline" ? "active" : ""}" data-ctab="pipeline">交付流水线</button>
|
||
<button class="chip ${state.customTab === "board" ? "active" : ""}" data-ctab="board">阶段看板</button>
|
||
<button class="chip ${state.customTab === "finance" ? "active" : ""}" data-ctab="finance">财务跟踪</button>
|
||
<button class="chip ${state.customTab === "list" ? "active" : ""}" data-ctab="list">项目明细</button>
|
||
<span style="flex:1"></span>
|
||
${keywordInputHtml("cKeyword", "搜索项目名称…", 200)}
|
||
<select id="cRegion" class="sel" style="width:132px">${regionFilterHtml}</select>
|
||
<button class="btn sm" id="btnExportCustom">导出 Excel</button>
|
||
<button class="btn sm primary" id="btnNewCustom">${Ico.svg("plus")}新建定制项目</button>
|
||
<span class="period-hint">${esc(scopeLabel())} · ${k.project_count} 个项目</span>
|
||
</div>`;
|
||
|
||
let body = "";
|
||
if (state.customTab === "pipeline") body = renderCustomPipeline(state.customRows, k);
|
||
else if (state.customTab === "board") body = renderCustomBoard(board);
|
||
else if (state.customTab === "finance") body = renderCustomFinance(stats, state.customRows);
|
||
else body = renderCustomList(state.customRows);
|
||
|
||
host.innerHTML = tabs + body;
|
||
|
||
host.querySelectorAll("[data-ctab]").forEach((b) =>
|
||
b.addEventListener("click", () => { state.customTab = b.dataset.ctab; renderCustom(); })
|
||
);
|
||
const rs = $("#cRegion");
|
||
if (rs) rs.addEventListener("change", () => { state.customRegion = rs.value; renderCustom(); });
|
||
bindKeyword("cKeyword");
|
||
const bn = $("#btnNewCustom");
|
||
if (bn) bn.addEventListener("click", () => openNewCustomDialog(() => afterWrite()));
|
||
const bex = $("#btnExportCustom");
|
||
if (bex) bex.addEventListener("click", () => {
|
||
const q = ["archived=" + state.customArchived];
|
||
if (state.customRegion) q.push("region=" + encodeURIComponent(state.customRegion));
|
||
if (state.keyword) q.push("keyword=" + encodeURIComponent(state.keyword));
|
||
download("/api/export/custom" + (q.length ? "?" + q.join("&") : ""));
|
||
});
|
||
// 卡片只是入口:重点标记等基础信息一律进详情面板改,外层不再直接写数据
|
||
host.querySelectorAll("[data-cpid]").forEach((c) =>
|
||
c.addEventListener("click", () => openCustomDrawer(Number(c.dataset.cpid)))
|
||
);
|
||
bindCustomList(host);
|
||
}
|
||
|
||
function renderCustomBoard(board) {
|
||
const cols = board.columns.filter((c) => c.count > 0 || c.code !== "closed");
|
||
return `<div class="kanban">
|
||
${cols.map((c) => `
|
||
<div class="kcol">
|
||
<div class="kcol-head">
|
||
<div class="kcol-title" style="--c:${c.color}">${Ico.stage(c.code)}${esc(c.name)}</div>
|
||
<div class="kcol-meta">${c.count} 个 · ${fen(c.amount)} 万${c.updated_count ? ` · <span style="color:#2563eb">本期 ${c.updated_count}</span>` : ""}</div>
|
||
</div>
|
||
<div class="kcol-body">
|
||
${c.projects.length ? c.projects.map((p) => `
|
||
<div class="kcard" data-cpid="${p.id}">
|
||
<div class="kcard-top">
|
||
<div class="kcard-name">${esc(p.name)}</div>
|
||
${starMark(p.is_key, true, "重点标记在详情面板中维护")}
|
||
</div>
|
||
<div class="kcard-meta">
|
||
<span class="tag-mini">${esc(p.region || "未标注")}</span>
|
||
<b style="font-size:12px">${p.amount ? fen(p.amount) + " 万" : "金额待定"}</b>
|
||
</div>
|
||
${p.stage === "developing" ? `<div class="progress" style="margin-top:8px"><i style="width:${p.progress}%;background:${c.color}"></i></div>
|
||
<div class="progress-label"><span>开发进度</span><span>${p.progress}%</span></div>` : ""}
|
||
<div class="kcard-foot">
|
||
<span>可确认 <b>${fen(p.confirmed_amount)} 万</b></span>
|
||
<span style="color:${p.revenue_amount ? "#16a34a" : "#7a8394"}">已计收 ${fen(p.revenue_amount || 0)} 万</span>
|
||
</div>
|
||
${p.remark ? `<div class="kcard-remark">${esc(p.remark.slice(0, 60))}</div>` : ""}
|
||
</div>`).join("") : `<div class="empty" style="padding:18px 6px;font-size:12px">—</div>`}
|
||
</div>
|
||
</div>`).join("")}
|
||
</div>`;
|
||
}
|
||
|
||
/* 商务阶段 -> 已走完的环节下标。导入的老数据只有阶段没有环节日期,用它兜底 */
|
||
const STAGE_STEP = { lead: 0, signed: 1, developing: 1, delivered: 2, accepted: 3, revenue: 4 };
|
||
|
||
/** 按已填的环节日期取最后一环(与后端 custom_current_step 口径一致) */
|
||
function customDateIndex(p) {
|
||
const steps = (state.customMeta && state.customMeta.pipeline) || [];
|
||
let step = -1;
|
||
steps.forEach((s, i) => { if ((p[s.date_field] || "").trim()) step = i; });
|
||
return step;
|
||
}
|
||
|
||
/** 项目当前走到哪一环:环节日期与商务阶段取更靠后的那个 */
|
||
function customStepIndex(p) {
|
||
const byStage = STAGE_STEP[p.stage];
|
||
return Math.max(byStage === undefined ? -1 : byStage, customDateIndex(p));
|
||
}
|
||
|
||
function daysSince(iso) {
|
||
if (!iso) return null;
|
||
const t = Date.parse(iso + "T00:00:00");
|
||
return isNaN(t) ? null : Math.floor((Date.now() - t) / 86400000);
|
||
}
|
||
|
||
/** 交付流水线:入库 → 签单 → 交付 → 验收 → 计收 的漏斗与卡点 */
|
||
function renderCustomPipeline(rows, kpi) {
|
||
const steps = (state.customMeta && state.customMeta.pipeline) || [];
|
||
const agingDays = (state.customMeta && state.customMeta.aging_days) || 45;
|
||
const total = rows.length;
|
||
|
||
const funnel = steps.map((s, i) => {
|
||
const hit = rows.filter((p) => customStepIndex(p) >= i);
|
||
return {
|
||
key: s.code, name: s.name, color: s.color,
|
||
count: hit.length,
|
||
amount: hit.reduce((a, p) => a + (p.amount || 0), 0),
|
||
};
|
||
});
|
||
const maxCount = Math.max(1, ...funnel.map((f) => f.count));
|
||
|
||
// 卡住的项目:还没走到计收,且最后一个有日期的环节停留超过 agingDays 天
|
||
const stuck = [];
|
||
rows.forEach((p) => {
|
||
const i = customStepIndex(p);
|
||
if (i < 0 || i >= steps.length - 1) return;
|
||
const di = customDateIndex(p);
|
||
if (di < 0) return;
|
||
const days = daysSince(p[steps[di].date_field]);
|
||
if (days === null || days < agingDays) return;
|
||
stuck.push({ p: p, step: steps[i], from: p[steps[di].date_field], days: days });
|
||
});
|
||
stuck.sort((a, b) => b.days - a.days);
|
||
const noDate = rows.filter((p) => customDateIndex(p) < 0).length;
|
||
|
||
return `
|
||
<div class="card" style="margin-bottom:16px">
|
||
<div class="card-head"><h4>交付漏斗<span class="sub">在库 ${total} 个 · 重点 ${kpi.key_count} 个 · 本期更新 ${kpi.period_updated} 个</span></h4>
|
||
<span class="period-hint">金额与项目数按环节累计 · 商务阶段与已录环节日期取先到哪一步</span></div>
|
||
<div class="card-body">
|
||
<div class="funnel">
|
||
${funnel.map((f) => `
|
||
<div class="fun-step" style="--c:${f.color}">
|
||
<div class="fun-name">${Ico.pipe(f.key)}${esc(f.name)}</div>
|
||
<div class="fun-count">${f.count}</div>
|
||
<div class="fun-bar"><i style="width:${Math.round((f.count / maxCount) * 100)}%;background:${f.color}"></i></div>
|
||
<div class="fun-amt">${fen(f.amount)} 万</div>
|
||
</div>`).join("")}
|
||
</div>
|
||
${noDate ? `<div class="notice info" style="margin-top:14px">还有 <b>${noDate}</b> 个项目一个环节日期都没有,去「项目明细」补上入库时间后漏斗才准。</div>` : ""}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-head"><h4>卡点提醒<span class="sub">最后一个有日期的环节停留超过 ${agingDays} 天</span></h4>
|
||
<span class="period-hint">${stuck.length} 个项目需要推进</span></div>
|
||
${stuck.length ? `<div class="tbl-wrap"><table class="tbl">
|
||
<thead><tr><th style="min-width:200px">项目名称</th><th>卡在</th><th>环节日期</th><th class="num">停留天数</th><th class="num">合同额</th><th>负责人</th><th class="col-ops">操作</th></tr></thead>
|
||
<tbody>
|
||
${stuck.slice(0, 20).map((x) => `
|
||
<tr>
|
||
<td><a href="javascript:;" data-copen="${x.p.id}" style="color:var(--text);text-decoration:none">${esc(x.p.name)}</a></td>
|
||
<td><span class="pill" style="background:${x.step.color}1a;color:${x.step.color}">${esc(x.step.name)}</span></td>
|
||
<td>${esc(x.p[x.step.date_field] || x.from || "—")}</td>
|
||
<td class="num"><b style="color:#dc2626">${x.days}</b> 天</td>
|
||
<td class="num">${fen(x.p.amount)} 万</td>
|
||
<td>${esc(x.p.owner || "未指派")}</td>
|
||
<td class="col-ops"><button class="btn sm" data-cedit="${x.p.id}">处理</button></td>
|
||
</tr>`).join("")}
|
||
</tbody></table></div>` : `<div class="empty">没有停滞超期的项目</div>`}
|
||
</div>`;
|
||
}
|
||
|
||
/** 财务跟踪:按负责人 / 区域汇总,并列出待回款排行 */
|
||
function renderCustomFinance(stats, rows) {
|
||
const byOwner = {};
|
||
rows.forEach((p) => {
|
||
const key = p.owner || "(未指派)";
|
||
const o = byOwner[key] || (byOwner[key] = { name: key, count: 0, amount: 0, confirmed: 0, revenue: 0, invoice: 0, received: 0 });
|
||
o.count += 1;
|
||
o.amount += p.amount || 0;
|
||
o.confirmed += p.confirmed_amount || 0;
|
||
o.revenue += p.revenue_amount || 0;
|
||
o.invoice += p.invoice_amount || 0;
|
||
o.received += p.received_amount || 0;
|
||
});
|
||
const owners = Object.keys(byOwner).map((k) => byOwner[k]).sort((a, b) => b.amount - a.amount);
|
||
const unpaid = rows.filter((p) => (p.amount || 0) > 0)
|
||
.map((p) => ({ p: p, pend: (p.amount || 0) - (p.received_amount || 0) }))
|
||
.sort((a, b) => b.pend - a.pend);
|
||
|
||
const num = (n) => fen(n);
|
||
const maxAmt = Math.max(1, ...stats.by_stage.map((x) => x.amount || 0));
|
||
const k = stats.kpi;
|
||
return `
|
||
<div class="grid kpi" style="margin-bottom:16px">
|
||
<div class="kpi accent-blue">${Ico.badge("wallet")}<div class="k-label">合同总额</div><div class="k-value" style="font-size:22px">${wan(k.amount_total)}</div><div class="k-foot">${k.project_count} 个项目 · 重点 ${k.key_count} 个</div></div>
|
||
<div class="kpi accent-violet">${Ico.badge("receipt")}<div class="k-label">按进度可确认收入</div><div class="k-value" style="font-size:22px">${wan(k.confirmed_total)}</div><div class="k-foot">确认比例 ${k.progress_rate}%</div></div>
|
||
<div class="kpi accent-green">${Ico.badge("wallet")}<div class="k-label">已计收</div><div class="k-value" style="font-size:22px">${wan(k.revenue_total)}</div><div class="k-foot">计收率 ${k.revenue_rate}%</div></div>
|
||
<div class="kpi accent-red">${Ico.badge("hourglass")}<div class="k-label">待计收</div><div class="k-value" style="font-size:22px">${wan(k.pending_total)}</div><div class="k-foot">可确认 − 已计收</div></div>
|
||
<div class="kpi accent-blue">${Ico.badge("receipt")}<div class="k-label">已开票</div><div class="k-value" style="font-size:22px">${wan(k.invoice_total)}</div><div class="k-foot">开票率 ${k.amount_total ? ((k.invoice_total / k.amount_total) * 100).toFixed(1) : 0}%</div></div>
|
||
<div class="kpi accent-green">${Ico.badge("trending-up")}<div class="k-label">已回款</div><div class="k-value" style="font-size:22px">${wan(k.received_total)}</div><div class="k-foot">回款率 ${k.received_rate}%</div></div>
|
||
<div class="kpi accent-amber">${Ico.badge("receipt")}<div class="k-label">待开票</div><div class="k-value" style="font-size:22px">${wan(k.pending_invoice)}</div><div class="k-foot">合同额 − 已开票</div></div>
|
||
<div class="kpi accent-red">${Ico.badge("hourglass")}<div class="k-label">待回款</div><div class="k-value" style="font-size:22px">${wan(k.pending_received)}</div><div class="k-foot">合同额 − 已回款</div></div>
|
||
</div>
|
||
<div class="grid g2" style="margin-bottom:16px">
|
||
<div class="card">
|
||
<div class="card-head"><h4>阶段金额分布<span class="sub">合同额与按进度可确认收入</span></h4></div>
|
||
<div class="card-body">
|
||
${stats.by_stage.map((x) => `
|
||
<div class="bar-row">
|
||
<div class="b-name"><i class="dot" style="background:${x.color};display:inline-block;margin-right:6px"></i>${esc(x.name)} <span style="color:var(--muted)">${x.count} 个</span></div>
|
||
<div class="b-track"><i style="width:${Math.round(((x.amount || 0) / maxAmt) * 100)}%;background:${x.color}"></i></div>
|
||
<div class="b-val">${num(x.amount)}</div>
|
||
</div>`).join("")}
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-head"><h4>周期记录活跃度<span class="sub">各周期有记录的项目数</span></h4></div>
|
||
<div class="card-body">
|
||
${barChart(stats.trend.map((t) => ({
|
||
label: `${t.month}/${t.week || ""}`,
|
||
value: t.update_count,
|
||
color: scopePeriodIds().indexOf(t.period_id) >= 0 ? "#7c3aed" : "#c4b5fd",
|
||
dim: scopePeriodIds().indexOf(t.period_id) < 0,
|
||
})), { unit: " 条", height: 170 })}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid g2" style="margin-bottom:16px">
|
||
<div class="card">
|
||
<div class="card-head"><h4>按负责人<span class="sub">合同额 / 计收 / 开票 / 回款</span></h4></div>
|
||
<div class="card-body" style="max-height:340px;overflow:auto">
|
||
<table class="tbl">
|
||
<thead><tr><th>负责人</th><th class="num">项目</th><th class="num">合同额</th><th class="num">可确认</th><th class="num">已计收</th><th class="num">已开票</th><th class="num">已回款</th></tr></thead>
|
||
<tbody>
|
||
${owners.map((o) => `<tr>
|
||
<td>${esc(o.name)}</td><td class="num">${o.count}</td>
|
||
<td class="num">${num(o.amount)}</td><td class="num">${num(o.confirmed)}</td>
|
||
<td class="num">${num(o.revenue)}</td><td class="num">${num(o.invoice)}</td>
|
||
<td class="num"><b>${num(o.received)}</b></td></tr>`).join("")}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-head"><h4>按区域<span class="sub">合同额与可确认收入</span></h4></div>
|
||
<div class="card-body" style="max-height:340px;overflow:auto">
|
||
<table class="tbl">
|
||
<thead><tr><th>区域</th><th class="num">项目</th><th class="num">合同额</th><th class="num">可确认</th><th class="num">已计收</th></tr></thead>
|
||
<tbody>
|
||
${stats.by_region.slice(0, 14).map((r) => `<tr>
|
||
<td>${esc(r.name)}</td><td class="num">${r.count}</td>
|
||
<td class="num">${num(r.amount)}</td><td class="num">${num(r.confirmed)}</td>
|
||
<td class="num">${num(r.revenue)}</td></tr>`).join("")}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-head"><h4>待回款排行<span class="sub">合同额 − 已回款 · 共 ${unpaid.length} 个有合同额的项目</span></h4></div>
|
||
${unpaid.length ? `<div class="tbl-wrap"><table class="tbl">
|
||
<thead><tr><th style="min-width:200px">项目名称</th><th>区域</th><th>负责人</th><th>验收</th>
|
||
<th class="num">合同额</th><th class="num">开票</th><th class="num">回款</th><th class="num">待回款</th><th class="col-ops">操作</th></tr></thead>
|
||
<tbody>
|
||
${unpaid.slice(0, 20).map((x) => `
|
||
<tr>
|
||
<td><a href="javascript:;" data-copen="${x.p.id}" style="color:var(--text);text-decoration:none">${esc(x.p.name)}</a></td>
|
||
<td>${esc(x.p.region || "—")}</td>
|
||
<td>${esc(x.p.owner || "未指派")}</td>
|
||
<td>${esc(x.p.accept_date || "未验收")}</td>
|
||
<td class="num">${num(x.p.amount)}</td>
|
||
<td class="num">${num(x.p.invoice_amount)}</td>
|
||
<td class="num" style="color:${x.p.received_amount ? "#16a34a" : "#7a8394"}">${num(x.p.received_amount)}</td>
|
||
<td class="num"><b style="color:#b45309">${num(x.pend)}</b></td>
|
||
<td class="col-ops"><button class="btn sm" data-cedit="${x.p.id}">填回款</button></td>
|
||
</tr>`).join("")}
|
||
</tbody></table></div>` : `<div class="empty">暂无合同额数据</div>`}
|
||
</div>`;
|
||
}
|
||
|
||
function renderCustomList(rows) {
|
||
const archOpts = [
|
||
["false", "在库"],
|
||
["true", "已归档"],
|
||
["all", "全部"],
|
||
]
|
||
.map((x) => `<option value="${x[0]}" ${x[0] === state.customArchived ? "selected" : ""}>${x[1]}</option>`)
|
||
.join("");
|
||
|
||
const sum = (f) => fen(rows.reduce((a, p) => a + Number(p[f] || 0), 0));
|
||
return `<div class="card">
|
||
<div class="card-head">
|
||
<h4>定制项目明细<span class="sub">按入库时间倒序 · 共 ${rows.length} 个 · 列表只读,改数据请点「修改」</span></h4>
|
||
<span class="period-hint">合同额 ${sum("amount")} 万 · 可确认 ${sum("confirmed_amount")} 万 · 已计收 ${sum("revenue_amount")} 万 · 开票 ${sum("invoice_amount")} 万 · 回款 ${sum("received_amount")} 万</span>
|
||
<div class="inline-fields">
|
||
<select id="cArchived" class="sel" style="width:104px" title="归档状态">${archOpts}</select>
|
||
</div>
|
||
</div>
|
||
${rows.length ? `<div class="tbl-wrap">
|
||
<table class="tbl">
|
||
<thead><tr>
|
||
<th style="min-width:200px">项目名称</th><th>区域</th><th>负责人</th>
|
||
<th>入库</th><th>签单</th><th>交付</th><th>验收</th>
|
||
<th>阶段</th><th class="num">合同额/万</th><th class="num">进度%</th>
|
||
<th class="num">可确认/万</th><th class="num">已计收/万</th>
|
||
<th class="num">开票/万</th><th class="num">回款/万</th>
|
||
<th>备注</th><th class="col-ops">操作</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
${rows.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.region || "—")}</td>
|
||
<td class="nowrap">${esc(p.owner || "—")}</td>
|
||
${["entry_date", "sign_date", "deliver_date", "accept_date"].map((f) => `<td class="nowrap">${p[f] ? esc(p[f]) : "—"}</td>`).join("")}
|
||
<td>${stagePill(p.stage)}</td>
|
||
<td class="num">${fen(p.amount)}</td>
|
||
<td class="num">${Math.round(p.progress)}%</td>
|
||
<td class="num"><b>${fen(p.confirmed_amount)}</b></td>
|
||
<td class="num">${fen(p.revenue_amount)}</td>
|
||
<td class="num">${fen(p.invoice_amount)}</td>
|
||
<td class="num">${fen(p.received_amount)}</td>
|
||
<td>${esc((p.remark || "").slice(0, 40)) || "—"}</td>
|
||
<td class="nowrap col-ops">
|
||
<button class="btn sm" data-copen="${p.id}">详情</button>
|
||
<button class="btn sm" data-cedit="${p.id}">修改</button>
|
||
<button class="btn sm" data-carch="${p.id}" data-archived="${p.archived ? "1" : "0"}">${p.archived ? "恢复" : "归档"}</button>
|
||
</td>
|
||
</tr>`).join("")}
|
||
</tbody>
|
||
</table>
|
||
</div>` : `<div class="empty">${state.keyword ? "没有匹配「" + esc(state.keyword) + "」的定制项目" : "当前筛选条件下没有定制项目"}</div>`}
|
||
</div>`;
|
||
}
|
||
|
||
/** 项目明细表:列表只读,只有详情 / 修改 / 归档三个动作 */
|
||
function bindCustomList(host) {
|
||
const ca = $("#cArchived");
|
||
if (ca) ca.addEventListener("change", () => { state.customArchived = ca.value; renderCustom(); });
|
||
|
||
host.querySelectorAll("[data-copen]").forEach((a) =>
|
||
a.addEventListener("click", () => openCustomDrawer(Number(a.dataset.copen), "view"))
|
||
);
|
||
host.querySelectorAll("[data-cedit]").forEach((b) =>
|
||
b.addEventListener("click", () => openCustomDrawer(Number(b.dataset.cedit), "edit"))
|
||
);
|
||
host.querySelectorAll("[data-carch]").forEach((b) =>
|
||
b.addEventListener("click", () => {
|
||
const p = (state.customRows || []).find((x) => x.id === Number(b.dataset.carch));
|
||
if (p) archiveCustomProject(p, () => afterWrite());
|
||
})
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 新建定制项目 —— 全站唯一的新建入口。
|
||
* 「定制交付」工具栏和「更新录入」页都调用它,避免两套录入逻辑。
|
||
*/
|
||
function openNewCustomDialog(onDone) {
|
||
const today = new Date();
|
||
const iso = today.getFullYear() + "-" +
|
||
String(today.getMonth() + 1).padStart(2, "0") + "-" +
|
||
String(today.getDate()).padStart(2, "0");
|
||
const stageOpts = ((state.customMeta && state.customMeta.stages) || [])
|
||
.map((s) => `<option value="${s.code}">${esc(s.name)}</option>`)
|
||
.join("");
|
||
|
||
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">只有<b>项目名称</b>必填,其余可先留空,建档后在列表里直接改。新项目会自动按入库时间排在最前面。</div>
|
||
<div class="form-grid">
|
||
<div class="form-row full"><label>项目名称 *</label><input class="inp" id="ncName" data-autofocus placeholder="如:重庆高新三期" style="width:100%" /></div>
|
||
<div class="form-row"><label>区域</label><select class="sel" id="ncRegion" style="width:100%">${regionOptions("")}</select></div>
|
||
<div class="form-row"><label>客户 / 办事处</label><input class="inp" id="ncCustomer" 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><select class="sel" id="ncStage" style="width:100%">${stageOpts}</select></div>
|
||
<div class="form-row"><label>负责人</label>${ownerSelectHtml("", 'id="ncOwner" style="width:100%"')}</div>
|
||
<div class="form-row"><label>入库时间</label><input class="inp" id="ncEntry" type="date" value="${iso}" style="width:100%" /></div>
|
||
<div class="form-row full"><label>重点项目</label><label class="switch"><input type="checkbox" id="ncKey" /> 标记为重点(★,看板置顶)</label></div>
|
||
<div class="form-row full"><label>备注</label><textarea class="inp" id="ncRemark" rows="2" style="width:100%"></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">创建</button>
|
||
</div>`
|
||
);
|
||
|
||
const submit = async () => {
|
||
const name = $("#ncName").value.trim();
|
||
if (!name) return modalError("请填写项目名称");
|
||
const btn = $("#mOk");
|
||
btn.disabled = true;
|
||
try {
|
||
const p = await post("/custom/projects", {
|
||
name: name,
|
||
region: $("#ncRegion").value || null,
|
||
customer: $("#ncCustomer").value.trim() || null,
|
||
amount: $("#ncAmount").value === "" ? null : Number($("#ncAmount").value),
|
||
stage: $("#ncStage").value,
|
||
owner: $("#ncOwner").value || null,
|
||
entry_date: $("#ncEntry").value || null,
|
||
is_key: $("#ncKey").checked,
|
||
remark: $("#ncRemark").value.trim() || null,
|
||
});
|
||
closeModal();
|
||
toast("已创建「" + p.name + "」");
|
||
if (onDone) onDone(p); else refresh();
|
||
} catch (e) {
|
||
btn.disabled = false;
|
||
modalError(e.message);
|
||
}
|
||
};
|
||
$("#mOk").addEventListener("click", submit);
|
||
$("#ncName").addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); });
|
||
}
|
||
|
||
/** 归档 / 恢复:归档后不进看板与统计,随时可恢复 */
|
||
async function archiveCustomProject(p, onDone) {
|
||
const toArchive = !p.archived;
|
||
const ok = await confirmDialog({
|
||
title: toArchive ? "归档定制项目" : "恢复定制项目",
|
||
okText: toArchive ? "确认归档" : "确认恢复",
|
||
body:
|
||
`<p style="margin:0 0 10px"><b>${esc(p.name)}</b></p>` +
|
||
`<div class="notice ${toArchive ? "warn" : "info"}">${
|
||
toArchive
|
||
? "归档后该项目不再出现在阶段看板、收入统计和默认列表中,历史周期记录完整保留。"
|
||
: "恢复后重新计入看板与统计。"
|
||
}</div>`,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await patch("/custom/projects/" + p.id, { archived: toArchive });
|
||
toast(toArchive ? "已归档" : "已恢复");
|
||
if (onDone) onDone(); else refresh();
|
||
} catch (e) {
|
||
toast("操作失败:" + e.message);
|
||
}
|
||
}
|
||
|
||
/** 删除:连带周期记录一起清除,需逐字输入项目名确认 */
|
||
async function deleteCustomProject(p, onDone) {
|
||
const ok = await confirmDialog({
|
||
title: "删除定制项目",
|
||
danger: true,
|
||
okText: "永久删除",
|
||
requireText: p.name,
|
||
body:
|
||
`<div class="notice err">将连同该项目 <b>${p.update_count || 0}</b> 条周期记录一并删除,<b>删除后无法恢复</b>。</div>
|
||
<ul class="bullet-list">
|
||
<li>阶段:${esc(stageMeta(p.stage).name)} 合同额:${money(p.amount)}</li>
|
||
<li>已计收:${money(p.revenue_amount)} 入库时间:${esc(p.entry_date || "—")}</li>
|
||
</ul>
|
||
<div style="font-size:12px;color:var(--muted);margin-top:8px">只是暂时不想在看板里看到它?请改用「归档」,数据全部保留。</div>`,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
const r = await del("/custom/projects/" + p.id);
|
||
toast("已删除「" + r.name + "」及其 " + r.deleted_updates + " 条周期记录");
|
||
if (onDone) onDone(); else refresh();
|
||
} catch (e) {
|
||
toast("删除失败:" + e.message);
|
||
}
|
||
}
|
||
|
||
/** cid 定制项目抽屉;mode = "view" 只读详情(默认) / "edit" 编辑基础信息 */
|
||
async function openCustomDrawer(cid, mode) {
|
||
mode = mode === "edit" ? "edit" : "view";
|
||
$("#drawerMask").classList.add("open");
|
||
$("#drawer").classList.add("open");
|
||
$("#drawerBody").innerHTML = `<div class="empty">加载中…</div>`;
|
||
drawerActions("");
|
||
const d = await api("/custom/projects/" + cid);
|
||
state.drawerCustom = d;
|
||
const p = d.project;
|
||
$("#drawerTitle").innerHTML = `${p.is_key ? starMark(true) : ""}${esc(p.name)}`;
|
||
$("#drawerSub").innerHTML = `${stagePill(p.stage)} · ${esc(p.region || "未标注区域")} · 合同额 ${money(p.amount)}`;
|
||
|
||
const stageOpts = state.customMeta.stages
|
||
.map((s) => `<option value="${s.code}" ${s.code === p.stage ? "selected" : ""}>${esc(s.name)}</option>`)
|
||
.join("");
|
||
|
||
const timeline = d.timeline.slice().reverse().map((t) => `
|
||
<div class="timeline-row">
|
||
<div class="t-label">${esc(t.period.full_label.replace(/^\d+年/, ""))}</div>
|
||
<div style="flex:1">${stagePill(t.stage)}${t.progress !== null ? ` <span class="tag-mini">${t.progress}%</span>` : ""}
|
||
${t.revenue_delta ? ` <span class="tag-mini" style="background:#e9f7ef;color:#16a34a">计收 ${fen(t.revenue_delta)}万</span>` : ""}</div>
|
||
</div>
|
||
<div style="font-size:12.5px;color:var(--text-2);line-height:1.7;padding:0 0 8px 120px;white-space:pre-wrap">${nl2br(t.content || "")}</div>`).join("");
|
||
|
||
// 「录一笔交付记录」落到顶栏当前选中的那一周,与更新录入页的周期下拉一致
|
||
const recWeek = (state.periods.weeks || []).find((x) => x.id === state.weekId);
|
||
const recLabel = recWeek ? recWeek.full_label : "本周";
|
||
|
||
const ratioLabel = p.manual_ratio !== null && p.manual_ratio !== undefined
|
||
? `手动 ${(p.manual_ratio * 100).toFixed(0)}%`
|
||
: `按阶段 ${p.revenue_ratio}%`;
|
||
|
||
const ro = (label, val) => `<div class="form-row"><label>${label}</label><div class="ro">${val}</div></div>`;
|
||
|
||
// 基础信息:详情只读、修改才给表单,操作条只出现在自己那一档里
|
||
const baseSec = mode === "edit" ? `
|
||
<div class="sec">
|
||
<div class="sec-title">基础信息<span class="sec-kind">编辑</span></div>
|
||
<div class="sec-hint" style="margin:0 0 10px">只改基础信息与财务数字,不会动下面的交付记录</div>
|
||
<div class="form-row"><label>项目名称</label><input class="inp" id="cdName" value="${esc(p.name)}" style="width:100%" /></div>
|
||
<div class="form-row"><label>阶段</label><select class="sel" id="cdStage" style="width:150px">${stageOpts}</select></div>
|
||
<div class="form-row"><label>合同额</label><input class="inp" id="cdAmount" type="number" value="${p.amount === null ? "" : p.amount}" style="width:150px" /></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" value="${Math.round(p.progress)}" style="width:90px" /> %</div></div>
|
||
<div class="form-row"><label>已计收</label><input class="inp" id="cdRevenue" type="number" value="${p.revenue_amount === null ? "" : p.revenue_amount}" style="width:150px" /></div>
|
||
<div class="form-row"><label>确认比例</label>
|
||
<div style="display:flex;align-items:center;gap:8px">
|
||
<input class="inp" id="cdRatio" type="number" min="0" max="100" step="5" placeholder="留空按阶段自动" value="${p.manual_ratio === null || p.manual_ratio === undefined ? "" : Math.round(p.manual_ratio * 100)}" style="width:130px" /> %
|
||
<span style="font-size:12px;color:var(--muted)">当前 ${ratioLabel}</span>
|
||
</div>
|
||
</div>
|
||
<div class="form-row"><label>交付环节</label>
|
||
<div style="flex:1">
|
||
${pipelineBar(p)}
|
||
<div style="font-size:12px;color:var(--muted);margin-top:6px">入库时间默认取该项目最早记录所在周期的第一天(周一),可手工修正;补上后续环节日期会自动推进阶段。</div>
|
||
</div>
|
||
</div>
|
||
<div class="form-row"><label>区域</label><select class="sel" id="cdRegion" style="width:130px">${regionOptions(p.region)}</select></div>
|
||
<div class="form-row"><label>交付负责人</label>${ownerSelectHtml(p.owner, 'id="cdOwner" style="width:150px"')}</div>
|
||
${peopleEditorHtml("cdMembers", "团队成员", p.members, { add: "成员" })}
|
||
<div class="form-row"><label>客户</label><input class="inp" id="cdCustomer" value="${esc(p.customer || "")}" style="width:180px" /></div>
|
||
<div class="form-row"><label>备注</label><textarea class="inp" id="cdRemark" rows="2" style="width:100%">${esc(p.remark || "")}</textarea></div>
|
||
</div>` : `
|
||
<div class="sec">
|
||
<div class="sec-title">基础信息<span class="sec-kind ro">只读</span></div>
|
||
${ro("项目名称", esc(p.name) + (p.archived ? ` <span class="arch-badge">已归档</span>` : ""))}
|
||
${ro("阶段", stagePill(p.stage))}
|
||
${ro("合同额", money(p.amount))}
|
||
${ro("开发进度", Math.round(p.progress) + "%")}
|
||
${ro("确认比例", `${esc(ratioLabel)} · 可确认 ${money(p.confirmed_amount)}`)}
|
||
${ro("交付环节", `<div style="max-width:100%">${pipelineBar(p, true)}</div>`)}
|
||
${ro("区域", esc(p.region || "—"))}
|
||
${ro("交付负责人", esc(p.owner || "—"))}
|
||
${ro("团队成员", nameChips(p.members))}
|
||
${ro("客户", esc(p.customer || "—"))}
|
||
${ro("备注", p.remark ? nl2br(p.remark) : "—")}
|
||
</div>`;
|
||
|
||
drawerActions(mode === "edit" ? `
|
||
<button class="btn sm" id="cdCancel">取消</button>
|
||
<button class="btn sm primary" id="cdSave">保存</button>` : `
|
||
<button class="btn sm danger" id="cdDelete">删除</button>
|
||
<button class="btn sm" id="cdArchive">${p.archived ? "恢复" : "归档"}</button>
|
||
<button class="btn sm" id="cdKey">${p.is_key ? "取消重点" : "标记重点"}</button>
|
||
<button class="btn sm primary" id="cdEdit">修改</button>`);
|
||
|
||
$("#drawerBody").innerHTML = `
|
||
${mode === "edit" ? `<div class="notice err" id="cdErr" style="display:none"></div>` : ""}
|
||
${baseSec}
|
||
|
||
<div class="sec">
|
||
<div class="sec-title">收入确认<span class="sec-kind ${mode === "edit" ? "" : "ro"}">${mode === "edit" ? "开票与回款可编辑" : "计算值 · 只读"}</span></div>
|
||
<div class="grid kpi" style="gap:10px">
|
||
<div class="kpi">${Ico.badge("wallet")}<div class="k-label">合同额</div><div class="k-value" style="font-size:19px">${wan(p.amount)}</div></div>
|
||
<div class="kpi">${Ico.badge("receipt")}<div class="k-label">可确认收入</div><div class="k-value" style="font-size:19px">${wan(p.confirmed_amount)}</div><div class="k-foot">比例 ${p.revenue_ratio}%</div></div>
|
||
<div class="kpi">${Ico.badge("wallet")}<div class="k-label">已计收</div><div class="k-value" style="font-size:19px">${wan(p.revenue_amount)}</div></div>
|
||
<div class="kpi">${Ico.badge("hourglass")}<div class="k-label">待计收</div><div class="k-value" style="font-size:19px">${wan(p.pending_amount)}</div></div>
|
||
</div>
|
||
<div class="grid kpi" style="gap:10px;margin-top:10px">
|
||
<div class="kpi">${Ico.badge("receipt")}<div class="k-label">已开票</div><div class="k-value" style="font-size:19px">${wan(p.invoice_amount)}</div><div class="k-foot">待开票 ${wan(p.pending_invoice)}</div></div>
|
||
<div class="kpi">${Ico.badge("trending-up")}<div class="k-label">已回款</div><div class="k-value" style="font-size:19px">${wan(p.received_amount)}</div><div class="k-foot">待回款 ${wan(p.pending_received)}</div></div>
|
||
</div>
|
||
${mode === "edit" ? `<div class="sec-actions">
|
||
<span class="sec-actions-label">开票与回款 · 编辑</span>
|
||
<label class="field-mini">开票金额(元)<input class="inp" id="cdInvoice" type="number" value="${p.invoice_amount === null ? "" : p.invoice_amount}" style="width:120px;text-align:right" /></label>
|
||
<label class="field-mini">已回款(元)<input class="inp" id="cdReceived" type="number" value="${p.received_amount === null ? "" : p.received_amount}" style="width:120px;text-align:right" /></label>
|
||
<span class="sec-actions-label">随标题栏「保存」一并提交</span>
|
||
</div>` : ""}
|
||
</div>
|
||
|
||
<div class="sec">
|
||
<div class="sec-title">周期执行记录(${d.updates.length})<span class="sec-kind ro">附表 · 只读</span></div>
|
||
<div class="sec-actions upd">
|
||
<span class="sec-actions-label">附表 · 更新记录</span>
|
||
<button class="btn sm primary" id="cdAddUpd">${Ico.svg("plus")}录一笔「${esc(recLabel)}」交付记录</button>
|
||
<span class="sec-hint">到「更新录入 · 定制交付」写入</span>
|
||
</div>
|
||
${d.updates.length ? d.updates.map((u) => `
|
||
<div class="upd-item">
|
||
<div class="u-head"><span class="u-task">${esc(u.period_full || "")}</span>${stagePill(u.stage)}${u.progress !== null ? `<span class="tag-mini">${u.progress}%</span>` : ""}</div>
|
||
<div class="u-body">${nl2br(u.content || u.summary || "")}</div>
|
||
${u.risk ? `<div class="u-risk">${Ico.svg("alert-circle")}<div>${nl2br(u.risk)}</div></div>` : ""}
|
||
${u.next_step ? `<div class="u-next">${Ico.svg("arrow-right")}<div>${nl2br(u.next_step)}</div></div>` : ""}
|
||
</div>`).join("") : `<div class="empty">暂无记录</div>`}
|
||
</div>
|
||
|
||
${d.timeline.length ? `<div class="sec"><div class="sec-title">阶段流转<span class="sec-kind ro">附表 · 只读</span></div>${timeline}</div>` : ""}`;
|
||
|
||
const on = (sel, fn) => { const el = $(sel); if (el) el.addEventListener("click", fn); };
|
||
|
||
on("#cdEdit", () => openCustomDrawer(cid, "edit"));
|
||
on("#cdAddUpd", () => {
|
||
state.intakeType = "custom";
|
||
state.intakePeriodId = state.weekId;
|
||
customIntakeId = cid;
|
||
closeDrawer();
|
||
setView("intake");
|
||
});
|
||
on("#cdCancel", () => openCustomDrawer(cid, "view"));
|
||
on("#cdSave", async () => {
|
||
const btn = $("#cdSave");
|
||
const err = $("#cdErr");
|
||
const ratioRaw = $("#cdRatio").value.trim();
|
||
btn.disabled = true;
|
||
err.style.display = "none";
|
||
try {
|
||
await patch("/custom/projects/" + cid, {
|
||
name: $("#cdName").value.trim(),
|
||
stage: $("#cdStage").value,
|
||
amount: $("#cdAmount").value === "" ? null : Number($("#cdAmount").value),
|
||
progress: Number($("#cdProgress").value),
|
||
revenue_amount: $("#cdRevenue").value === "" ? null : Number($("#cdRevenue").value),
|
||
manual_ratio: ratioRaw === "" ? null : Number(ratioRaw) / 100,
|
||
entry_date: $('#drawerBody [data-cf="entry_date"]').value || null,
|
||
sign_date: $('#drawerBody [data-cf="sign_date"]').value || null,
|
||
deliver_date: $('#drawerBody [data-cf="deliver_date"]').value || null,
|
||
accept_date: $('#drawerBody [data-cf="accept_date"]').value || null,
|
||
revenue_date: $('#drawerBody [data-cf="revenue_date"]').value || null,
|
||
invoice_amount: $("#cdInvoice").value === "" ? null : Number($("#cdInvoice").value),
|
||
received_amount: $("#cdReceived").value === "" ? null : Number($("#cdReceived").value),
|
||
region: $("#cdRegion").value,
|
||
owner: $("#cdOwner").value || null,
|
||
members: peopleValues("cdMembers"),
|
||
customer: $("#cdCustomer").value,
|
||
remark: $("#cdRemark").value,
|
||
});
|
||
} catch (e) {
|
||
btn.disabled = false;
|
||
err.textContent = "保存失败:" + e.message;
|
||
err.style.display = "";
|
||
return;
|
||
}
|
||
toast("已保存");
|
||
await afterWrite();
|
||
openCustomDrawer(cid, "view");
|
||
});
|
||
on("#cdKey", async () => {
|
||
await patch("/custom/projects/" + cid, { is_key: !p.is_key });
|
||
await afterWrite();
|
||
openCustomDrawer(cid, mode);
|
||
});
|
||
on("#cdArchive", () => archiveCustomProject(p, () => { closeDrawer(); afterWrite(); }));
|
||
on("#cdDelete", () => deleteCustomProject(p, () => { closeDrawer(); afterWrite(); }));
|
||
}
|
||
|
||
/* ------------------------------------------------ 录入方式(两类项目共用) */
|
||
/** 录入方式分段控件:直接写入(第一位)/ AI 整理 */
|
||
function intakeModeSegHtml() {
|
||
const mode = intakeMode();
|
||
return `
|
||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px;flex-wrap:wrap">
|
||
<span class="sec-actions-label">录入方式</span>
|
||
<div class="seg" id="intakeModeSeg" style="width:max-content">
|
||
<button class="seg-btn ${mode === "direct" ? "active" : ""}" data-imode="direct">直接写入</button>
|
||
<button class="seg-btn ${mode === "ai" ? "active" : ""}" data-imode="ai">AI 整理</button>
|
||
</div>
|
||
<span class="sec-hint">${mode === "ai"
|
||
? "AI 补出摘要 / 进度 / 状态 / 风险 / 下一步,正文保持你的原文,最终<b>只写 1 条</b>"
|
||
: "填进去什么就存什么,不拆解、不改写、不动项目进度"}</span>
|
||
</div>`;
|
||
}
|
||
|
||
function intakeMode() {
|
||
return state.intakeMode === "ai" ? "ai" : "direct";
|
||
}
|
||
|
||
/** 切换录入方式:保留已填的原文,重画当前录入页 */
|
||
function bindIntakeMode(rawSel, rerender) {
|
||
document.querySelectorAll("#intakeModeSeg [data-imode]").forEach((b) =>
|
||
b.addEventListener("click", () => {
|
||
if (intakeMode() === b.dataset.imode) return;
|
||
const keep = $(rawSel) ? $(rawSel).value : "";
|
||
state.intakeMode = b.dataset.imode;
|
||
Promise.resolve(rerender()).then(() => { const el = $(rawSel); if (el) el.value = keep; });
|
||
})
|
||
);
|
||
}
|
||
|
||
/* ------------------------------------------------ 定制项目录入 */
|
||
async function renderCustomIntake(host) {
|
||
const projects = await api("/custom/projects");
|
||
const weeks = state.periods.weeks.slice().reverse();
|
||
const curPeriodId =
|
||
state.gran === "week"
|
||
? state.weekId
|
||
: currentMonth()
|
||
? currentMonth().period_ids[currentMonth().period_ids.length - 1]
|
||
: null;
|
||
|
||
host.innerHTML = `
|
||
<div class="seg" id="intakeTypeSeg" style="margin-bottom:14px">
|
||
<button class="seg-btn ${state.intakeType === "general" ? "active" : ""}" data-itype="general">重点项目</button>
|
||
<button class="seg-btn ${state.intakeType === "custom" ? "active" : ""}" data-itype="custom">定制交付项目</button>
|
||
</div>
|
||
${intakeModeSegHtml()}
|
||
<div class="card">
|
||
<div class="card-head"><h4>定制项目更新录入<span class="sub">${intakeMode() === "ai"
|
||
? "给<b>已有</b>定制项目记一期:粘贴原文 → AI 整理成 1 条(含阶段 / 金额 / 计收)→ 确认写入"
|
||
: "给<b>已有</b>定制项目记一期:内容原样存成 1 条,不拆解、不改写"}</span></h4>
|
||
<button class="btn sm" id="btnNewCustomIntake">${Ico.svg("plus")}新建定制项目</button>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="step" style="margin-bottom:12px"><span class="step-no">1</span><span class="step-title">选择定制项目与周期</span></div>
|
||
<div class="inline-fields" style="margin-bottom:20px">
|
||
<input id="cInSearch" class="inp" placeholder="搜索项目名称…" style="width:200px" />
|
||
<select id="cInProject" class="sel" style="width:320px">
|
||
${projects.map((p) => `<option value="${p.id}" ${p.id === customIntakeId ? "selected" : ""}>${p.is_key ? "★ " : ""}${esc(p.name)}</option>`).join("")}
|
||
</select>
|
||
<select id="cInPeriod" class="sel" style="width:200px">
|
||
${weeks.map((w) => `<option value="${w.id}" ${w.id === (state.intakePeriodId || curPeriodId) ? "selected" : ""}>${esc(w.full_label)}</option>`).join("")}
|
||
<option value="__new">+ 新建周期…</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="notice info">项目按入库时间倒序排列。列表里没有要找的项目?点标题右侧<b>「+ 新建定制项目」</b>先建档,建好会自动选中回到这里。</div>
|
||
<div class="step" style="margin-bottom:10px"><span class="step-no">2</span><span class="step-title">粘贴该项目的原始内容</span></div>
|
||
<textarea id="cInRaw" class="inp big-textarea" placeholder="例如: 重庆高新三期:合同已签订,金额 100 万,开发交付阶段,本周完成集群部署,进度 60% 风险:客户机房断电,联调推迟 下一步:下周完成灰度上线,推动 30% 预付款计收"></textarea>
|
||
|
||
${intakeMode() === "ai" ? `
|
||
<div style="display:flex;gap:10px;margin:14px 0 6px;align-items:center;flex-wrap:wrap">
|
||
<button class="btn primary" id="btnCParse">AI 整理</button>
|
||
<button class="btn" id="btnCRule">本地规则整理(不调用模型)</button>
|
||
<button class="btn" id="btnCClear">清空</button>
|
||
<span id="cInMode" style="font-size:12px;color:var(--muted)"></span>
|
||
</div>` : `
|
||
<div style="display:flex;gap:10px;margin:14px 0 6px;align-items:center;flex-wrap:wrap">
|
||
<button class="btn primary" id="btnCDirect">直接写入这 1 条</button>
|
||
<button class="btn" id="btnCClear">清空</button>
|
||
<span id="cInMode" style="font-size:12px;color:var(--muted)"></span>
|
||
</div>`}
|
||
<div id="cInPreview"></div>
|
||
</div>
|
||
</div>`;
|
||
|
||
const sel = $("#cInProject");
|
||
host.querySelectorAll("[data-itype]").forEach((b) =>
|
||
b.addEventListener("click", () => { state.intakeType = b.dataset.itype; renderIntake(); })
|
||
);
|
||
sel.addEventListener("change", () => { customIntakeId = Number(sel.value); });
|
||
$("#btnNewCustomIntake").addEventListener("click", () =>
|
||
openNewCustomDialog((p) => { customIntakeId = p.id; renderCustomIntake(host); })
|
||
);
|
||
$("#cInPeriod").addEventListener("change", async (e) => {
|
||
if (e.target.value === "__new") {
|
||
await createNewPeriod();
|
||
await renderIntake();
|
||
return;
|
||
}
|
||
state.intakePeriodId = Number(e.target.value);
|
||
});
|
||
$("#cInSearch").addEventListener("input", (e) => {
|
||
const kw = e.target.value.trim();
|
||
sel.innerHTML = projects.filter((p) => !kw || p.name.includes(kw))
|
||
.map((p) => `<option value="${p.id}">${p.is_key ? "★ " : ""}${esc(p.name)}</option>`).join("");
|
||
});
|
||
bindIntakeMode("#cInRaw", () => renderCustomIntake(host));
|
||
const on = (sel2, fn) => { const el = $(sel2); if (el) el.addEventListener("click", fn); };
|
||
on("#btnCClear", () => {
|
||
$("#cInRaw").value = ""; $("#cInPreview").innerHTML = ""; $("#cInMode").textContent = "";
|
||
});
|
||
on("#btnCParse", () => doCustomParse(true));
|
||
on("#btnCRule", () => doCustomParse(false));
|
||
on("#btnCDirect", commitCustomDirect);
|
||
}
|
||
|
||
/** 定制交付 · 直接写入:内容一字不改存成 1 条执行记录,阶段 / 金额 / 计收一律不动 */
|
||
async function commitCustomDirect() {
|
||
const raw = $("#cInRaw").value.trim();
|
||
if (!raw) return toast("请先填写内容");
|
||
const id = Number($("#cInProject").value);
|
||
if (!id) return toast("请选择定制项目");
|
||
const pid = Number($("#cInPeriod").value);
|
||
if (!pid) return toast("请选择周期");
|
||
const btn = $("#btnCDirect");
|
||
btn.disabled = true;
|
||
try {
|
||
await post("/custom/updates", {
|
||
custom_project_id: id,
|
||
period_id: pid,
|
||
content: raw,
|
||
source: "manual",
|
||
});
|
||
$("#cInRaw").value = "";
|
||
$("#cInPreview").innerHTML = `<div class="notice info">写入成功:已按原文存入 <b>1</b> 条执行记录,未拆解、未改写。</div>`;
|
||
toast("已写入 1 条执行记录");
|
||
await afterWrite();
|
||
} catch (e) {
|
||
toast("写入失败:" + e.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
let customIntakeId = null;
|
||
let customIntakeParsed = null;
|
||
let customIntakeRaw = "";
|
||
|
||
async function doCustomParse(useAI) {
|
||
const raw = $("#cInRaw").value.trim();
|
||
if (!raw) return toast("请先粘贴内容");
|
||
const sel = $("#cInProject");
|
||
const id = Number(sel.value);
|
||
if (!id) return toast("请选择定制项目");
|
||
|
||
const btn = useAI ? $("#btnCParse") : $("#btnCRule");
|
||
const old = btn.innerHTML;
|
||
btn.disabled = true;
|
||
btn.innerHTML = `<span class="spinner"></span> 整理中…`;
|
||
try {
|
||
const r = await post("/custom/ai/parse", {
|
||
custom_project_id: id,
|
||
project_name: name,
|
||
period_id: Number($("#cInPeriod").value),
|
||
raw_text: raw,
|
||
use_ai: useAI,
|
||
});
|
||
customIntakeId = r.project ? r.project.id : customIntakeId;
|
||
customIntakeParsed = r.parsed;
|
||
customIntakeRaw = raw;
|
||
renderCustomPreview(r);
|
||
} catch (e) {
|
||
$("#cInPreview").innerHTML = `<div class="notice err">整理失败:${esc(e.message)}</div>`;
|
||
} finally {
|
||
btn.disabled = false; btn.innerHTML = old;
|
||
}
|
||
}
|
||
|
||
function renderCustomPreview(r) {
|
||
const p = r.parsed;
|
||
const stageOpts = state.customMeta.stages
|
||
.map((s) => `<option value="${s.code}" ${s.code === p.stage ? "selected" : ""}>${esc(s.name)}(${s.base_ratio}%)</option>`)
|
||
.join("");
|
||
$("#cInMode").textContent = r.mode === "ai" ? `已由 ${r.parsed.model || "大模型"} 整理` : "本地规则整理";
|
||
|
||
$("#cInPreview").innerHTML = `
|
||
${r.warning ? `<div class="notice warn">${esc(r.warning)}</div>` : ""}
|
||
${r.error ? `<div class="notice err">AI 调用异常:${esc(r.error)}</div>` : ""}
|
||
<div class="sec-title">整理结果 · 1 条记录(可直接修改后写入)</div>
|
||
<div class="preview-box">
|
||
<div class="kv"><label>项目</label><div><b>${esc(r.project ? r.project.name : "(新建)")}</b> · ${esc(r.period ? r.period.full_label : "")}</div></div>
|
||
<div class="kv"><label>执行内容(正文,原文照录)</label><textarea class="inp" id="cPvContent" rows="5" style="width:100%">${esc(customIntakeRaw)}</textarea></div>
|
||
<div class="kv"><label>商务阶段</label><select class="sel" id="cPvStage">${stageOpts}</select></div>
|
||
<div class="kv"><label>开发进度</label><input class="inp" id="cPvProgress" type="number" min="0" max="100" value="${p.progress === null || p.progress === undefined ? "" : p.progress}" style="width:100px" /></div>
|
||
<div class="kv"><label>合同额</label><input class="inp" id="cPvAmount" type="number" value="${p.amount === null || p.amount === undefined ? "" : p.amount}" style="width:160px" /></div>
|
||
<div class="kv"><label>累计已计收</label><input class="inp" id="cPvRevenue" type="number" value="${p.revenue_amount === null || p.revenue_amount === undefined ? "" : p.revenue_amount}" style="width:160px" /></div>
|
||
<div class="kv"><label>本期新增计收</label><input class="inp" id="cPvDelta" type="number" value="${p.revenue_delta === null || p.revenue_delta === undefined ? "" : p.revenue_delta}" style="width:160px" /></div>
|
||
<div class="kv"><label>摘要</label><input class="inp" id="cPvSummary" value="${esc(p.summary || "")}" style="width:100%" /></div>
|
||
<div class="kv"><label>风险</label><textarea class="inp" id="cPvRisks" rows="2" style="width:100%">${esc((p.risks || []).join("\n"))}</textarea></div>
|
||
<div class="kv"><label>下一步</label><textarea class="inp" id="cPvNext" rows="2" style="width:100%">${esc((p.next_steps || []).join("\n"))}</textarea></div>
|
||
</div>
|
||
<div style="display:flex;gap:10px;margin-top:14px">
|
||
<button class="btn primary" id="btnCCommit">确认写入这 1 条</button>
|
||
<span style="font-size:12px;color:var(--muted);align-self:center">正文用你的原文,AI 只补上面这些字段;写入后阶段、金额、计收同步更新到「定制交付」</span>
|
||
</div>`;
|
||
|
||
$("#btnCCommit").addEventListener("click", commitCustomIntake);
|
||
}
|
||
|
||
function collectCustomPreview() {
|
||
const num = (id) => { const v = $(id).value.trim(); return v === "" ? null : Number(v); };
|
||
const lines = (id) => $(id).value.split("\n").map((s) => s.trim()).filter(Boolean);
|
||
return {
|
||
stage: $("#cPvStage").value,
|
||
progress: num("#cPvProgress"),
|
||
amount: num("#cPvAmount"),
|
||
revenue_amount: num("#cPvRevenue"),
|
||
revenue_delta: num("#cPvDelta"),
|
||
summary: $("#cPvSummary").value.trim(),
|
||
risks: lines("#cPvRisks"),
|
||
next_steps: lines("#cPvNext"),
|
||
updates: [], // AI 整理只落 1 条:正文走 raw_text
|
||
};
|
||
}
|
||
|
||
async function commitCustomIntake() {
|
||
const sel = $("#cInProject");
|
||
const btn = $("#btnCCommit");
|
||
btn.disabled = true;
|
||
try {
|
||
const res = await post("/custom/ai/commit", {
|
||
custom_project_id: Number(sel.value),
|
||
project_name: null,
|
||
period_id: Number($("#cInPeriod").value),
|
||
raw_text: ($("#cPvContent").value || "").trim() || customIntakeRaw,
|
||
parsed: collectCustomPreview(),
|
||
mode: customIntakeParsed && customIntakeParsed.model ? "ai" : "rule",
|
||
model: (customIntakeParsed && customIntakeParsed.model) || null,
|
||
});
|
||
toast(`已写入 ${res.created.length} 条记录`);
|
||
$("#cInPreview").innerHTML = `<div class="notice info">写入成功:「${esc(res.project.name)}」阶段 <b>${esc(stageMeta(res.project.stage).name)}</b>,可确认收入 ${wan(res.project.confirmed_amount)}。</div>`;
|
||
await afterWrite();
|
||
} catch (e) {
|
||
toast("写入失败:" + e.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- 更新录入 */
|
||
let intakeProjectId = null;
|
||
let intakeParsed = null;
|
||
|
||
async function renderIntake() {
|
||
const host = $("#view-intake");
|
||
if (!state.customMeta) state.customMeta = await api("/custom/meta");
|
||
if (state.intakeType === "custom") return renderCustomIntake(host);
|
||
|
||
const projects = await api("/projects?archived=false");
|
||
const weeks = state.periods.weeks.slice().reverse();
|
||
const curPeriodId = state.gran === "week" ? state.weekId : (currentMonth() ? currentMonth().period_ids[currentMonth().period_ids.length - 1] : null);
|
||
|
||
const mode = intakeMode();
|
||
const actionRow = mode === "ai" ? `
|
||
<div style="display:flex;gap:10px;margin:14px 0 6px;align-items:center;flex-wrap:wrap">
|
||
<button class="btn primary" id="btnAIParse">AI 整理</button>
|
||
<button class="btn" id="btnRuleParse">本地规则整理(不调用模型)</button>
|
||
<button class="btn" id="btnClearIntake">清空</button>
|
||
<span id="intakeMode" style="font-size:12px;color:var(--muted)"></span>
|
||
</div>` : `
|
||
<div style="display:flex;gap:10px;margin:14px 0 6px;align-items:center;flex-wrap:wrap">
|
||
<button class="btn primary" id="btnDirectWrite">直接写入这 1 条</button>
|
||
<button class="btn" id="btnClearIntake">清空</button>
|
||
<span id="intakeMode" style="font-size:12px;color:var(--muted)"></span>
|
||
</div>`;
|
||
|
||
host.innerHTML = `
|
||
<div class="seg" id="intakeTypeSeg" style="margin-bottom:14px">
|
||
<button class="seg-btn ${state.intakeType === "general" ? "active" : ""}" data-itype="general">重点项目</button>
|
||
<button class="seg-btn ${state.intakeType === "custom" ? "active" : ""}" data-itype="custom">定制交付项目</button>
|
||
</div>
|
||
${intakeModeSegHtml()}
|
||
<div class="card">
|
||
<div class="card-head"><h4>更新录入<span class="sub">${mode === "ai" ? "选择项目 → 粘贴原始内容 → AI 整理 → 确认写入" : "选择项目 → 填写内容 → 原样写入 1 条"}</span></h4></div>
|
||
<div class="card-body">
|
||
<div class="step" style="margin-bottom:12px"><span class="step-no">1</span><span class="step-title">选择项目与周期</span></div>
|
||
<div class="inline-fields" style="margin-bottom:20px">
|
||
<input id="intakeProjectSearch" class="inp" placeholder="搜索项目…" style="width:200px" />
|
||
<select id="intakeProject" class="sel" style="width:300px">
|
||
${projects.map((p) => `<option value="${p.id}" ${p.id === intakeProjectId ? "selected" : ""}>${p.is_key ? "★ " : ""}${esc(p.name)}</option>`).join("")}
|
||
<option value="__new">+ 新建项目(按下方名称)</option>
|
||
</select>
|
||
<input id="intakeNewProject" class="inp" placeholder="新项目名称" style="width:220px;display:none" />
|
||
<select id="intakePeriod" class="sel" style="width:200px">
|
||
${weeks.map((w) => `<option value="${w.id}" ${w.id === (state.intakePeriodId || curPeriodId) ? "selected" : ""}>${esc(w.full_label)}</option>`).join("")}
|
||
<option value="__new">+ 新建周期…</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="step" style="margin-bottom:10px"><span class="step-no">2</span><span class="step-title">粘贴该项目的原始内容</span></div>
|
||
<textarea id="intakeRaw" class="inp big-textarea" placeholder="把周报 / 项目群消息 / 会议纪要原文粘进来,例如: 1、授权卸载管理已完成开发和联调,可以转测 2、集群部署,进度50% 3、国产化操作系统切换,进度50%;风险:license 未到位"></textarea>
|
||
|
||
${actionRow}
|
||
<div id="intakePreview"></div>
|
||
</div>
|
||
</div>`;
|
||
|
||
host.querySelectorAll("[data-itype]").forEach((b) =>
|
||
b.addEventListener("click", () => { state.intakeType = b.dataset.itype; renderIntake(); })
|
||
);
|
||
bindIntakeMode("#intakeRaw", renderIntake);
|
||
|
||
const sel = $("#intakeProject");
|
||
sel.addEventListener("change", () => {
|
||
$("#intakeNewProject").style.display = sel.value === "__new" ? "" : "none";
|
||
});
|
||
$("#intakePeriod").addEventListener("change", async (e) => {
|
||
if (e.target.value === "__new") {
|
||
await createNewPeriod();
|
||
await renderIntake();
|
||
return;
|
||
}
|
||
state.intakePeriodId = Number(e.target.value);
|
||
});
|
||
$("#intakeProjectSearch").addEventListener("input", (e) => {
|
||
const kw = e.target.value.trim();
|
||
sel.innerHTML = projects.filter((p) => !kw || p.name.includes(kw))
|
||
.map((p) => `<option value="${p.id}">${p.is_key ? "★ " : ""}${esc(p.name)}</option>`).join("")
|
||
+ `<option value="__new">+ 新建项目(按下方名称)</option>`;
|
||
});
|
||
$("#btnClearIntake").addEventListener("click", () => {
|
||
$("#intakeRaw").value = ""; $("#intakePreview").innerHTML = ""; $("#intakeMode").textContent = "";
|
||
});
|
||
const on = (sel, fn) => { const el = $(sel); if (el) el.addEventListener("click", fn); };
|
||
on("#btnAIParse", () => doParse(true));
|
||
on("#btnRuleParse", () => doParse(false));
|
||
on("#btnDirectWrite", commitDirectWrite);
|
||
}
|
||
|
||
/**
|
||
* 重点项目 · 直接写入:填入的内容一字不改存成 1 条执行记录。
|
||
* 不调大模型、不建子任务、不写 progress,项目的进度和状态一律不动。
|
||
*/
|
||
async function commitDirectWrite() {
|
||
const raw = $("#intakeRaw").value.trim();
|
||
if (!raw) return toast("请先填写内容");
|
||
const sel = $("#intakeProject");
|
||
const btn = $("#btnDirectWrite");
|
||
let projectId = sel.value === "__new" ? null : Number(sel.value);
|
||
btn.disabled = true;
|
||
try {
|
||
if (!projectId) {
|
||
const name = $("#intakeNewProject").value.trim();
|
||
if (!name) { toast("请填写项目名称"); return; }
|
||
const created = await post("/projects", { name });
|
||
projectId = created.id;
|
||
intakeProjectId = created.id;
|
||
sel.insertAdjacentHTML("beforeend", `<option value="${created.id}" selected>${esc(created.name)}</option>`);
|
||
$("#intakeNewProject").style.display = "none";
|
||
}
|
||
await post("/updates", {
|
||
project_id: projectId,
|
||
period_id: Number($("#intakePeriod").value),
|
||
content: raw,
|
||
source: "manual",
|
||
});
|
||
$("#intakeRaw").value = "";
|
||
$("#intakePreview").innerHTML = `<div class="notice info">写入成功:已按原文存入 <b>1</b> 条执行记录,未拆解、未改写。</div>`;
|
||
toast("已写入 1 条执行记录");
|
||
await afterWrite();
|
||
} catch (e) {
|
||
toast("写入失败:" + e.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function doParse(useAI) {
|
||
const raw = $("#intakeRaw").value.trim();
|
||
if (!raw) return toast("请先粘贴内容");
|
||
const sel = $("#intakeProject");
|
||
const projectId = sel.value === "__new" ? null : Number(sel.value);
|
||
const projectName = sel.value === "__new" ? $("#intakeNewProject").value.trim() : null;
|
||
if (!projectId && !projectName) return toast("请选择或填写项目名称");
|
||
|
||
const btn = useAI ? $("#btnAIParse") : $("#btnRuleParse");
|
||
const old = btn.innerHTML;
|
||
btn.disabled = true;
|
||
btn.innerHTML = `<span class="spinner"></span> 整理中…`;
|
||
try {
|
||
const r = await post("/ai/parse", {
|
||
project_id: projectId, project_name: projectName,
|
||
period_id: Number($("#intakePeriod").value),
|
||
raw_text: raw, use_ai: useAI,
|
||
});
|
||
intakeProjectId = r.project ? r.project.id : intakeProjectId;
|
||
intakeParsed = r.parsed;
|
||
renderIntakePreview(r, raw);
|
||
} catch (e) {
|
||
$("#intakePreview").innerHTML = `<div class="notice err">整理失败:${esc(e.message)}</div>`;
|
||
} finally {
|
||
btn.disabled = false; btn.innerHTML = old;
|
||
}
|
||
}
|
||
|
||
function renderIntakePreview(r, raw) {
|
||
const p = r.parsed;
|
||
const statusOpts = state.meta.statuses.map((s) => `<option value="${s.code}" ${s.code === p.status ? "selected" : ""}>${esc(s.name)}</option>`).join("");
|
||
$("#intakeMode").textContent = r.mode === "ai" ? `已由 ${r.parsed.model || "大模型"} 整理` : "本地规则整理";
|
||
|
||
$("#intakePreview").innerHTML = `
|
||
${r.warning ? `<div class="notice warn">${esc(r.warning)}</div>` : ""}
|
||
${r.error ? `<div class="notice err">AI 调用异常:${esc(r.error)}</div>` : ""}
|
||
<div class="sec-title">整理结果 · 1 条记录(可直接修改后写入)</div>
|
||
<div class="preview-box">
|
||
<div class="kv"><label>项目</label><div><b>${esc(r.project ? r.project.name : "")}</b> · ${esc(r.period ? r.period.full_label : "")}</div></div>
|
||
<div class="kv"><label>执行内容(正文,原文照录)</label><textarea class="inp" id="pvContent" rows="5" style="width:100%">${esc(raw)}</textarea></div>
|
||
<div class="kv"><label>一句话摘要</label><input class="inp" id="pvSummary" value="${esc(p.summary || "")}" style="width:100%" /></div>
|
||
<div class="kv"><label>总进度 %</label><input class="inp" id="pvProgress" type="number" min="0" max="100" value="${p.progress === null || p.progress === undefined ? "" : p.progress}" style="width:100px" /></div>
|
||
<div class="kv"><label>整体状态</label><select class="sel" id="pvStatus">${statusOpts}</select></div>
|
||
<div class="kv"><label>风险 / 阻塞</label><textarea class="inp" id="pvRisks" rows="2" style="width:100%">${esc((p.risks || []).join("\n"))}</textarea></div>
|
||
<div class="kv"><label>下一步计划</label><textarea class="inp" id="pvNext" rows="2" style="width:100%">${esc((p.next_steps || []).join("\n"))}</textarea></div>
|
||
</div>
|
||
<div style="display:flex;gap:10px;margin-top:14px">
|
||
<button class="btn primary" id="btnCommit">确认写入这 1 条</button>
|
||
<span style="font-size:12px;color:var(--muted);align-self:center">正文用你的原文,AI 只补摘要 / 进度 / 状态 / 风险 / 下一步;写入后可在「周期看板」中立即查看</span>
|
||
</div>`;
|
||
|
||
$("#btnCommit").addEventListener("click", () => commitIntake(r, raw));
|
||
}
|
||
|
||
/** 收集预览表单。tasks 恒为空 → 后端按「1 条执行记录」写入,不建子任务 */
|
||
function collectPreview() {
|
||
const lines = (id) => $(id).value.split("\n").map((s) => s.trim()).filter(Boolean);
|
||
const pg = $("#pvProgress").value;
|
||
return {
|
||
summary: $("#pvSummary").value.trim(),
|
||
progress: pg === "" ? null : Number(pg),
|
||
status: $("#pvStatus").value,
|
||
risks: lines("#pvRisks"),
|
||
next_steps: lines("#pvNext"),
|
||
tasks: [],
|
||
};
|
||
}
|
||
|
||
async function commitIntake(r, raw) {
|
||
const sel = $("#intakeProject");
|
||
const btn = $("#btnCommit");
|
||
btn.disabled = true;
|
||
try {
|
||
const res = await post("/ai/commit", {
|
||
project_id: sel.value === "__new" ? null : Number(sel.value),
|
||
project_name: sel.value === "__new" ? $("#intakeNewProject").value.trim() : null,
|
||
period_id: Number($("#intakePeriod").value),
|
||
raw_text: ($("#pvContent").value || "").trim() || raw,
|
||
parsed: collectPreview(),
|
||
mode: r.mode,
|
||
model: r.parsed.model || null,
|
||
});
|
||
toast(`已写入 ${res.created.length} 条执行记录`);
|
||
$("#intakePreview").innerHTML = `<div class="notice info">写入成功:项目「${esc(res.project.name)}」新增 ${res.created.length} 条记录。可切换到「周期看板」查看。</div>`;
|
||
await afterWrite();
|
||
} catch (e) {
|
||
toast("写入失败:" + e.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- settings */
|
||
async function renderSettings() {
|
||
const host = $("#view-settings");
|
||
const [s, health, periods] = await Promise.all([
|
||
api("/settings"),
|
||
fetch("/health").then((r) => r.json()),
|
||
api("/periods"),
|
||
]);
|
||
const weeks = periods.weeks.slice().reverse();
|
||
host.innerHTML = `
|
||
<div class="grid g2">
|
||
<div class="card">
|
||
<div class="card-head"><h4>AI 整理配置<span class="sub">OpenAI 兼容协议</span></h4></div>
|
||
<div class="card-body">
|
||
${state.meta.ai.enabled
|
||
? `<div class="notice info">已启用 AI 整理(模型:${esc(state.meta.ai.model || "—")})</div>`
|
||
: `<div class="notice warn">未配置 API Key,当前使用本地规则解析。填写下方配置后自动切换到大模型整理。</div>`}
|
||
<div class="form-row"><label>Base URL</label><input class="inp" id="cfgBase" value="${esc(s.ai_api_base || "")}" style="width:100%" placeholder="https://api.deepseek.com/v1" /></div>
|
||
<div class="form-row"><label>API Key</label><input class="inp" id="cfgKey" type="password" value="${esc(s.ai_api_key || "")}" style="width:100%" placeholder="sk-..." /></div>
|
||
<div class="form-row"><label>模型</label><input class="inp" id="cfgModel" value="${esc(s.ai_model || "")}" style="width:100%" placeholder="deepseek-chat" /></div>
|
||
<div class="form-row"><label>温度</label><input class="inp" id="cfgTemp" value="${esc(s.ai_temperature || "0.2")}" style="width:100px" /></div>
|
||
<div style="margin-top:14px"><button class="btn primary" id="btnSaveCfg">保存配置</button></div>
|
||
<div style="margin-top:14px;font-size:12px;color:var(--muted);line-height:1.8">
|
||
兼容:DeepSeek、通义千问(兼容模式)、Moonshot、智谱、OpenAI、本地 Ollama 等。<br>
|
||
例:Ollama → Base URL <code>http://localhost:11434/v1</code>,模型 <code>qwen2.5:14b</code>,Key 任意填。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-head"><h4>数据库</h4></div>
|
||
<div class="card-body">
|
||
<div class="form-row"><label>当前引擎</label><b>${esc(health.db)}</b></div>
|
||
<div style="font-size:12.5px;color:var(--text-2);line-height:1.9">
|
||
默认使用 SQLite(<code>data/board.db</code>),无需安装任何服务。<br><br>
|
||
<b>迁移到 MySQL:</b><br>
|
||
1. 安装驱动:<code>pip install pymysql cryptography</code><br>
|
||
2. 在 <code>.env</code> 中设置:<br>
|
||
<code>DATABASE_URL=mysql+pymysql://user:pwd@host:3306/unis_board?charset=utf8mb4</code><br>
|
||
3. 重启服务,表结构自动创建;再用
|
||
<code>python scripts/import_excel.py --reset</code> 重新灌数即可。<br><br>
|
||
数据访问层全部走 SQLAlchemy,无原生 SQL,切换方言不需要改业务代码。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" style="margin-top:16px">
|
||
<div class="card-head">
|
||
<h4>周期管理<span class="sub">共 ${periods.weeks.length} 个周期 · 录入新周期数据前可先在这里新增</span></h4>
|
||
<button class="btn sm primary" id="btnAddPeriod">+ 新建周期</button>
|
||
</div>
|
||
<div class="card-body" style="max-height:360px;overflow:auto;padding:0">
|
||
<table class="tbl">
|
||
<thead><tr><th>周期</th><th class="num">常规记录</th><th class="num">定制记录</th><th></th></tr></thead>
|
||
<tbody>
|
||
${weeks.map((w) => `
|
||
<tr data-pid="${w.id}">
|
||
<td><b>${esc(w.full_label)}</b> <span style="color:var(--muted);font-size:12px">(原标签:${esc(w.label)})</span></td>
|
||
<td class="num">${w.update_count || 0}</td>
|
||
<td class="num">${w.custom_update_count || 0}</td>
|
||
<td class="num"><button class="btn sm danger" data-delperiod="${w.id}" ${(w.update_count || w.custom_update_count) ? "disabled title='该周期已有记录,不能删除'" : ""}>删除</button></td>
|
||
</tr>`).join("")}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>`;
|
||
|
||
$("#btnAddPeriod").addEventListener("click", async () => {
|
||
const id = await createNewPeriod();
|
||
if (id) renderSettings();
|
||
});
|
||
host.querySelectorAll("[data-delperiod]").forEach((b) =>
|
||
b.addEventListener("click", async () => {
|
||
const id = Number(b.dataset.delperiod);
|
||
const row = b.closest("tr");
|
||
const name = row.querySelector("b").textContent;
|
||
if (!confirm(`确认删除周期「${name}」?`)) return;
|
||
try {
|
||
await del("/periods/" + id);
|
||
toast("已删除周期 " + name);
|
||
await loadPeriods();
|
||
renderSettings();
|
||
} catch (e) {
|
||
toast("删除失败:" + e.message);
|
||
}
|
||
})
|
||
);
|
||
$("#btnSaveCfg").addEventListener("click", async () => {
|
||
await put("/settings", {
|
||
values: {
|
||
ai_api_base: $("#cfgBase").value.trim(),
|
||
ai_api_key: $("#cfgKey").value.trim(),
|
||
ai_model: $("#cfgModel").value.trim(),
|
||
ai_temperature: $("#cfgTemp").value.trim() || "0.2",
|
||
},
|
||
});
|
||
state.meta = await api("/meta");
|
||
toast("配置已保存");
|
||
renderSettings();
|
||
});
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- drawer */
|
||
/** pid 详情抽屉;mode = "view" 只读详情(默认) / "edit" 编辑基础信息 */
|
||
async function openDrawer(pid, mode) {
|
||
mode = mode === "edit" ? "edit" : "view";
|
||
$("#drawerMask").classList.add("open");
|
||
$("#drawer").classList.add("open");
|
||
$("#drawerBody").innerHTML = `<div class="empty">加载中…</div>`;
|
||
drawerActions("");
|
||
const d = await api("/projects/" + pid);
|
||
state.drawerProject = d;
|
||
const p = d.project;
|
||
$("#drawerTitle").innerHTML = `${p.is_key ? starMark(true) : ""}${esc(p.name)}`;
|
||
$("#drawerSub").innerHTML = `${catPill(p.category)} ${statusPill(p.status)} · 进度 ${Math.round(p.progress)}% · ${p.update_count} 条执行记录 / ${p.task_count} 个子任务`;
|
||
|
||
const ro = (label, val) => `<div class="form-row"><label>${label}</label><div class="ro">${val}</div></div>`;
|
||
|
||
// 基础信息:详情只读、修改才给表单,两档各自一条操作条,别和下面的附表混在一起
|
||
const baseSec = mode === "edit" ? `
|
||
<div class="sec">
|
||
<div class="sec-title">基础信息<span class="sec-kind">编辑</span></div>
|
||
<div class="sec-hint" style="margin:0 0 10px">只改基础信息,不会动下面的周期记录</div>
|
||
<div class="form-row"><label>项目名称</label><input class="inp" id="dwName" value="${esc(p.name)}" style="width:100%" /></div>
|
||
<div class="form-row"><label>分类</label><select class="sel" id="dwCat" style="width:150px">
|
||
<option value="">(未分类)</option>
|
||
${state.categories.map((c) => `<option value="${c.id}" ${c.id === p.category_id ? "selected" : ""}>${esc(c.name)}</option>`).join("")}
|
||
</select></div>
|
||
<div class="form-row"><label>状态</label><select class="sel" id="dwStatus" style="width:130px">
|
||
${state.meta.statuses.map((s) => `<option value="${s.code}" ${s.code === p.status ? "selected" : ""}>${esc(s.name)}</option>`).join("")}
|
||
</select></div>
|
||
${peopleEditorHtml("dwOwners", "负责人", p.owners, {
|
||
add: "负责人",
|
||
// 界面上负责人不分主次,但存储里 owner = owners[0],统计口径照实说明
|
||
hint: "可多位。看板、筛选、导出与人力负载取名单里的第一位做归属统计。",
|
||
})}
|
||
${peopleEditorHtml("dwMembers", "团队成员", p.members, { add: "成员" })}
|
||
<div class="form-row"><label>进度</label><div style="display:flex;align-items:center;gap:6px"><input class="inp" id="dwProgress" type="number" min="0" max="100" value="${Math.round(p.progress)}" style="width:90px" /> %</div></div>
|
||
<div class="form-row"><label>标签</label><input class="inp" id="dwTags" placeholder="标签(、分隔)" value="${esc(p.tags.map((t) => t.name).join("、"))}" style="width:220px" /></div>
|
||
</div>` : `
|
||
<div class="sec">
|
||
<div class="sec-title">基础信息<span class="sec-kind ro">只读</span></div>
|
||
${ro("项目名称", esc(p.name) + (p.archived ? ` <span class="arch-badge">已归档</span>` : ""))}
|
||
${ro("分类", catPill(p.category))}
|
||
${ro("状态", statusPill(p.status))}
|
||
${ro("负责人", nameChips(p.owners))}
|
||
${ro("团队成员", nameChips(p.members))}
|
||
${ro("进度", Math.round(p.progress) + "%")}
|
||
${ro("标签", p.tags.length ? p.tags.map((t) => `<span class="tag-mini">${esc(t.name)}</span>`).join(" ") : "—")}
|
||
${ro("记录", `${p.task_count} 个子任务 / ${p.update_count} 条执行记录`)}
|
||
</div>`;
|
||
|
||
// 基础信息的动作在标题栏右侧按钮条;附表的更新动作仍在它的列表上方,两类不混
|
||
drawerActions(mode === "edit" ? `
|
||
<button class="btn sm" id="dwCancel">取消</button>
|
||
<button class="btn sm primary" id="dwSave">保存</button>` : `
|
||
<button class="btn sm danger" id="dwDelete">删除</button>
|
||
<button class="btn sm" id="dwArchive">${p.archived ? "恢复" : "归档"}</button>
|
||
<button class="btn sm" id="dwKey">${p.is_key ? "取消重点" : "标记重点"}</button>
|
||
<button class="btn sm primary" id="dwEdit">修改</button>`);
|
||
|
||
const updItem = (u) => `
|
||
<div class="upd-item">
|
||
<div class="u-head"><span class="u-task">${esc(u.task_name || "整体进展")}</span>${statusPill(u.status)}${u.progress !== null ? `<span class="tag-mini">${u.progress}%</span>` : ""}</div>
|
||
<div class="u-body">${nl2br(u.content || u.summary || "")}</div>
|
||
${u.risk ? `<div class="u-risk">${Ico.svg("alert-circle")}<div>${nl2br(u.risk)}</div></div>` : ""}
|
||
${u.next_step ? `<div class="u-next">${Ico.svg("arrow-right")}<div>${nl2br(u.next_step)}</div></div>` : ""}
|
||
</div>`;
|
||
|
||
// 把项目的 timeline 按「周」或「月」归成显示单元(timeline 已由后端按时间升序返回)
|
||
function feedUnits() {
|
||
if (state.dw.group === "week") {
|
||
return d.timeline.map((t) => ({
|
||
label: t.period.full_label, count: t.updates.length, progress: t.progress,
|
||
periods: [t], ids: [t.period.id],
|
||
}));
|
||
}
|
||
const map = new Map();
|
||
d.timeline.forEach((t) => {
|
||
const k = t.period.year + "-" + t.period.month;
|
||
if (!map.has(k)) map.set(k, { label: t.period.year + "年" + t.period.month + "月", count: 0, progress: null, periods: [], ids: [] });
|
||
const u = map.get(k);
|
||
u.periods.push(t);
|
||
u.count += t.updates.length;
|
||
u.ids.push(t.period.id);
|
||
if (t.progress !== null) u.progress = u.progress === null ? t.progress : Math.max(u.progress, t.progress);
|
||
});
|
||
return [...map.values()];
|
||
}
|
||
|
||
const FEED_RANGES = {
|
||
week: [[1, "本周"], [4, "近 4 周"], [12, "近 12 周"], [0, "全部"]],
|
||
month: [[1, "本月"], [3, "近 3 月"], [6, "近 6 月"], [0, "全部"]],
|
||
};
|
||
|
||
// 附表的写入走「更新录入」,和基础信息的保存彻底分成两条路
|
||
const recWeek = (state.periods.weeks || []).find((x) => x.id === state.weekId);
|
||
const recLabel = recWeek ? recWeek.full_label : "本周";
|
||
const gotoIntake = () => {
|
||
state.intakeType = "general";
|
||
state.intakePeriodId = state.weekId;
|
||
intakeProjectId = pid;
|
||
closeDrawer();
|
||
setView("intake");
|
||
};
|
||
|
||
// 范围以顶栏选中的周期为锚点,和看板其余部分保持一致
|
||
function renderFeed() {
|
||
const host = $("#dwFeed");
|
||
const units = feedUnits();
|
||
const unitName = state.dw.group === "week" ? "周" : "个月"; /* 「7 月」会被读成七月,用「个月」 */
|
||
const scope = scopePeriodIds();
|
||
let anchor = -1;
|
||
units.forEach((u, i) => { if (u.ids.some((id) => scope.indexOf(id) >= 0)) anchor = i; });
|
||
if (anchor < 0) anchor = units.length - 1;
|
||
|
||
const n = state.dw.range;
|
||
const step = n === 0 ? Math.max(1, anchor + 1) : n;
|
||
const last = Math.max(0, anchor - step * state.dw.back);
|
||
const first = n === 0 ? 0 : Math.max(0, last - n + 1);
|
||
const win = units.slice(first, last + 1);
|
||
const nUpd = win.reduce((a, u) => a + u.count, 0);
|
||
|
||
const bar = `
|
||
<div class="feed-bar">
|
||
<span class="sec-actions-label">查看范围(只读筛选)</span>
|
||
<div class="seg" id="dwGroup">
|
||
<button class="seg-btn ${state.dw.group === "week" ? "active" : ""}" data-g="week">按周</button>
|
||
<button class="seg-btn ${state.dw.group === "month" ? "active" : ""}" data-g="month">按月</button>
|
||
</div>
|
||
<div class="feed-chips">${FEED_RANGES[state.dw.group].map(([v, l]) =>
|
||
`<button class="chip ${state.dw.range === v ? "active" : ""}" data-r="${v}">${l}</button>`).join("")}</div>
|
||
<div class="feed-pager">
|
||
<button class="nav-arrow sm" id="dwOlder" title="更早" ${first <= 0 ? "disabled" : ""}>${Ico.svg("chevron-left")}</button>
|
||
<button class="nav-arrow sm" id="dwNewer" title="更新" ${state.dw.back <= 0 ? "disabled" : ""}>${Ico.svg("chevron-right")}</button>
|
||
</div>
|
||
<span class="feed-meta">显示 ${win.length} / ${units.length} ${unitName} · ${nUpd} 条${first > 0 ? ` · 更早还有 ${first} ${unitName}` : ""}</span>
|
||
</div>`;
|
||
|
||
// win 是按升序取的窗口,展示时倒过来:新的在前
|
||
const body = win.length ? win.slice().reverse().map((u) => `
|
||
<div class="feed-hd"><b>${esc(u.label)}</b>${u.progress !== null ? `<span>进度 ${u.progress}%</span>` : ""}<span class="feed-cnt">${u.count} 条</span></div>
|
||
${u.periods.slice().reverse().map((t) => `
|
||
${state.dw.group === "month" ? `<div class="feed-sub">${esc(t.period.full_label.replace(/^\d+年/, ""))}${t.progress !== null ? " · 进度 " + t.progress + "%" : ""} ${statusPill(t.status)}</div>` : ""}
|
||
${t.updates.map(updItem).join("")}`).join("")}`).join("")
|
||
: `<div class="empty">还没有执行记录</div>`;
|
||
|
||
host.innerHTML = `<div class="sec"><div class="sec-title">周期反馈<span class="sec-kind ro">附表 · 只读</span></div>
|
||
<div class="sec-actions upd">
|
||
<span class="sec-actions-label">附表 · 更新记录</span>
|
||
<button class="btn sm primary" id="dwAddUpd">${Ico.svg("plus")}录一笔「${esc(recLabel)}」执行记录</button>
|
||
<span class="sec-hint">到「更新录入」页粘贴原文写入</span>
|
||
</div>${bar}<div>${body}</div></div>`;
|
||
host.querySelector("#dwAddUpd").addEventListener("click", gotoIntake);
|
||
|
||
host.querySelectorAll("#dwGroup [data-g]").forEach((b) => b.addEventListener("click", () => {
|
||
const g = b.dataset.g;
|
||
if (state.dw.group === g) return;
|
||
state.dw = { group: g, range: g === "week" ? 4 : 3, back: 0 };
|
||
renderFeed();
|
||
}));
|
||
host.querySelectorAll(".feed-chips [data-r]").forEach((b) => b.addEventListener("click", () => {
|
||
state.dw.range = Number(b.dataset.r);
|
||
state.dw.back = 0;
|
||
renderFeed();
|
||
}));
|
||
host.querySelector("#dwOlder").addEventListener("click", () => { state.dw.back += 1; renderFeed(); });
|
||
host.querySelector("#dwNewer").addEventListener("click", () => { state.dw.back = Math.max(0, state.dw.back - 1); renderFeed(); });
|
||
}
|
||
|
||
const timeline = d.timeline.slice().reverse().map((t) => `
|
||
<div class="timeline-row">
|
||
<div class="t-label">${esc(t.period.full_label.replace(/^\d+年/, ""))}</div>
|
||
<div class="t-bar"><div class="progress" style="margin:0"><i style="width:${t.progress || 0}%;background:${statusMeta(t.status).color}"></i></div></div>
|
||
<div class="t-val">${t.progress === null ? "—" : t.progress + "%"}</div>
|
||
</div>`).join("");
|
||
|
||
// 周期反馈单独渲染,切换范围/周期不必重新请求
|
||
state.dw = { group: "week", range: 4, back: 0 };
|
||
$("#drawerBody").innerHTML =
|
||
(mode === "edit" ? `<div class="notice err" id="dwErr" style="display:none"></div>` : "") +
|
||
baseSec + `<div id="dwFeed"></div>` +
|
||
`<div class="sec"><div class="sec-title">历史进度走势<span class="sec-kind ro">附表 · 只读</span></div>${timeline || '<div class="empty">暂无</div>'}</div>`;
|
||
renderFeed();
|
||
|
||
const on = (sel, fn) => { const el = $(sel); if (el) el.addEventListener("click", fn); };
|
||
|
||
on("#dwEdit", () => openDrawer(pid, "edit"));
|
||
on("#dwCancel", () => openDrawer(pid, "view"));
|
||
on("#dwSave", async () => {
|
||
const btn = $("#dwSave");
|
||
const err = $("#dwErr");
|
||
btn.disabled = true;
|
||
err.style.display = "none";
|
||
try {
|
||
await patch("/projects/" + pid, {
|
||
name: $("#dwName").value.trim(),
|
||
category_id: $("#dwCat").value === "" ? null : Number($("#dwCat").value),
|
||
status: $("#dwStatus").value,
|
||
owners: peopleValues("dwOwners"),
|
||
members: peopleValues("dwMembers"),
|
||
progress: Number($("#dwProgress").value),
|
||
tags: $("#dwTags").value.split(/[、,,;;\s]+/).filter(Boolean),
|
||
});
|
||
} catch (e) {
|
||
btn.disabled = false;
|
||
err.textContent = "保存失败:" + e.message;
|
||
err.style.display = "";
|
||
return;
|
||
}
|
||
toast("已保存");
|
||
await afterWrite();
|
||
openDrawer(pid, "view");
|
||
});
|
||
on("#dwKey", async () => {
|
||
await patch("/projects/" + pid, { is_key: !p.is_key });
|
||
await afterWrite();
|
||
openDrawer(pid, mode);
|
||
});
|
||
on("#dwArchive", async () => {
|
||
await patch("/projects/" + pid, { archived: !p.archived });
|
||
toast(p.archived ? "已恢复" : "已归档");
|
||
closeDrawer();
|
||
await afterWrite();
|
||
});
|
||
// 删除是高危动作:必须逐字输入项目名才能点「永久删除」
|
||
on("#dwDelete", async () => {
|
||
const ok = await confirmDialog({
|
||
title: "删除项目",
|
||
danger: true,
|
||
okText: "永久删除",
|
||
requireText: p.name,
|
||
body:
|
||
`<div class="notice err">将连同 <b>${p.task_count}</b> 个子任务、<b>${p.update_count}</b> 条执行记录一并删除,<b>删除后无法恢复</b>。</div>
|
||
<div style="font-size:12px;color:var(--muted);margin-top:8px">只是暂时不想在看板里看到它?请改用「归档」,数据全部保留。</div>`,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
const r = await del("/projects/" + pid);
|
||
toast(`已删除「${r.name}」:${r.deleted_tasks} 个子任务 / ${r.deleted_updates} 条记录`);
|
||
closeDrawer();
|
||
await afterWrite();
|
||
} catch (e) {
|
||
toast("删除失败:" + e.message);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 标题栏右侧的标准按钮条:按钮由当前打开的抽屉自己填,主按钮在最右
|
||
function drawerActions(html) {
|
||
$("#drawerActions").innerHTML = html || "";
|
||
}
|
||
|
||
function closeDrawer() {
|
||
drawerActions("");
|
||
$("#drawer").classList.remove("open");
|
||
$("#drawerMask").classList.remove("open");
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- 人力资源库 */
|
||
function openStaffDialog(person, onDone) {
|
||
const isNew = !person;
|
||
const p = person || {};
|
||
const groups = (state.staffDict.groups || []).map(
|
||
(g) => `<option value="${esc(g)}" ${g === p.group ? "selected" : ""}>${esc(g)}</option>`
|
||
).join("");
|
||
const affs = (state.staffDict.affiliations || []).map(
|
||
(a) => `<option value="${esc(a)}" ${a === p.affiliation ? "selected" : ""}>${esc(a)}</option>`
|
||
).join("");
|
||
openModal(
|
||
`<div class="modal-head"><h3>${isNew ? "新增人员" : "编辑人员"}</h3><button class="icon-btn" data-mclose title="关闭">${Ico.svg("x")}</button></div>
|
||
<div class="modal-body">
|
||
<div class="form-grid">
|
||
<div class="form-row"><label>姓名 *</label><input class="inp" id="sfName" data-autofocus value="${esc(p.name || "")}" style="width:100%" /></div>
|
||
<div class="form-row"><label>归属小组</label><select class="sel" id="sfGroup" style="width:100%">${groups}</select></div>
|
||
<div class="form-row"><label>人员归属</label><select class="sel" id="sfAff" style="width:100%">${affs}</select></div>
|
||
<div class="form-row full"><label>说明</label><input class="inp" id="sfNote" value="${esc(p.note || "")}" placeholder="如:外包 / 实习 / 兼岗" style="width:100%" /></div>
|
||
</div>
|
||
${isNew ? "" : `<div class="notice info">改姓名会把名下项目的负责人一并改过来,历史记录不会断。</div>`}
|
||
<div class="notice err" id="mErr" style="display:none"></div>
|
||
</div>
|
||
<div class="modal-foot"><button class="btn" data-mclose>取消</button><button class="btn primary" id="mOk">${isNew ? "新增" : "保存"}</button></div>`
|
||
);
|
||
$("#mOk").addEventListener("click", async () => {
|
||
const name = $("#sfName").value.trim();
|
||
if (!name) return modalError("请填写姓名");
|
||
const btn = $("#mOk");
|
||
btn.disabled = true;
|
||
const body = {
|
||
name: name,
|
||
group: $("#sfGroup").value || null,
|
||
affiliation: $("#sfAff").value || null,
|
||
note: $("#sfNote").value.trim() || null,
|
||
};
|
||
try {
|
||
let msg = "已新增「" + name + "」";
|
||
if (isNew) { await post("/staff", body); }
|
||
else {
|
||
const r = await patch("/staff/" + p.id, body);
|
||
const n = r.synced.projects + r.synced.custom_projects;
|
||
msg = n ? `已更新,并同步 ${n} 个项目的负责人` : "已更新";
|
||
}
|
||
closeModal();
|
||
toast(msg);
|
||
if (onDone) onDone(); else await afterWrite();
|
||
} catch (e) { btn.disabled = false; modalError(e.message); }
|
||
});
|
||
$("#sfName").addEventListener("keydown", (e) => { if (e.key === "Enter") $("#mOk").click(); });
|
||
}
|
||
|
||
async function deleteStaff(m) {
|
||
const ok = await confirmDialog({
|
||
title: "删除人员",
|
||
danger: true,
|
||
okText: "确认删除",
|
||
body: `<p style="margin:0 0 10px">确认从花名册删除 <b>${esc(m.name)}</b>?</p>
|
||
<div class="notice warn">删除后这个人不再出现在「负责人」下拉里。
|
||
若他名下还有在管项目,接口会拒绝删除 —— 离职人员请用<b>归档</b>,历史数据完整保留。</div>`,
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await del("/staff/" + m.id);
|
||
toast("已删除「" + m.name + "」");
|
||
await afterWrite();
|
||
} catch (e) {
|
||
toast(e.message);
|
||
}
|
||
}
|
||
|
||
async function renderStaff() {
|
||
saveUI();
|
||
const host = $("#view-staff");
|
||
host.innerHTML = `<div class="empty">加载中…</div>`;
|
||
const extra = ["archived=" + state.staffArchived];
|
||
if (state.staffGroup) extra.push("group=" + encodeURIComponent(state.staffGroup));
|
||
if (state.keyword) extra.push("keyword=" + encodeURIComponent(state.keyword));
|
||
const d = await api("/staff?" + extra.join("&"));
|
||
await loadStaff(); // 负责人下拉始终用完整花名册
|
||
const sum = d.summary;
|
||
|
||
const archOpts = [["false", "在编"], ["true", "已归档"], ["all", "全部"]]
|
||
.map((x) => `<option value="${x[0]}" ${x[0] === state.staffArchived ? "selected" : ""}>${x[1]}</option>`).join("");
|
||
const groupOpts = `<option value="">全部小组</option>` +
|
||
(d.dict.groups || []).map((g) => `<option value="${esc(g)}" ${g === state.staffGroup ? "selected" : ""}>${esc(g)}</option>`).join("");
|
||
|
||
const kpi = `
|
||
<div class="grid kpi" style="margin-bottom:14px">
|
||
<div class="kpi accent-blue">${Ico.badge("users")}<div class="k-label">在册人员</div><div class="k-value">${sum.total}</div><div class="k-foot">其中 ${sum.with_project} 人带项目</div></div>
|
||
${sum.groups.slice(0, 4).map((g, i) => `<div class="kpi accent-${["violet", "green", "amber", "red"][i % 4]}">${Ico.badge("users")}<div class="k-label">${esc(g.name)}</div><div class="k-value">${g.count}</div></div>`).join("")}
|
||
</div>
|
||
<div class="grid g2e" style="margin-bottom:16px">
|
||
<div class="card">
|
||
<div class="card-head"><h4>人员归属<span class="sub">汇智 / 云数 / 外服</span></h4></div>
|
||
<div class="card-body">
|
||
${sum.affiliations.map((a) => `
|
||
<div class="bar-row">
|
||
<div class="b-name">${esc(a.name)}</div>
|
||
<div class="b-track"><i style="width:${Math.round((a.count / (sum.total || 1)) * 100)}%;background:#2563eb"></i></div>
|
||
<div class="b-val">${a.count}</div>
|
||
</div>`).join("")}
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-head"><h4>花名册外的负责人<span class="sub">历史数据里对不上花名册的人名</span></h4></div>
|
||
<div class="card-body">
|
||
${sum.outside.length ? sum.outside.map((o) => `
|
||
<div class="upd-item" style="display:flex;align-items:center;gap:10px">
|
||
<b>${esc(o.name)}</b>
|
||
<span class="tag-mini">项目 ${o.project_count}</span>
|
||
<span class="tag-mini">定制 ${o.custom_count}</span>
|
||
<span style="flex:1"></span>
|
||
<button class="btn sm" data-outside="${esc(o.name)}">加入花名册</button>
|
||
</div>`).join("") : `<div class="empty">所有项目负责人都能对上花名册</div>`}
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
const toolbar = `
|
||
<div class="board-toolbar">
|
||
<span style="flex:1"></span>
|
||
${keywordInputHtml("stKeyword", "搜索姓名…", 200)}
|
||
<button class="btn sm" id="btnExportStaff">导出 Excel</button>
|
||
<button class="btn sm primary" id="btnNewStaff">${Ico.svg("plus")}新增人员</button>
|
||
<span class="period-hint">在册 ${sum.total} 人</span>
|
||
</div>`;
|
||
|
||
const table = `<table class="tbl">
|
||
<thead><tr>
|
||
<th style="min-width:120px">姓名</th><th>归属小组</th><th>人员归属</th><th>说明</th>
|
||
<th class="num">在管项目</th><th class="num">定制项目</th><th class="num">定制合同额</th>
|
||
<th class="num">名下待计收</th><th>最近更新</th><th class="col-ops">操作</th>
|
||
</tr></thead><tbody>
|
||
${d.items.map((m) => `
|
||
<tr data-sid="${m.id}" ${m.archived ? 'style="opacity:.62"' : ""}>
|
||
<td><div style="display:flex;align-items:center;gap:6px">
|
||
<a href="javascript:;" data-sedit="${m.id}" style="color:var(--text);text-decoration:none;font-weight:600">${esc(m.name)}</a>
|
||
${m.archived ? `<span class="arch-badge">已归档</span>` : ""}
|
||
</div></td>
|
||
<td><select class="sel" data-sf="group" style="width:112px">
|
||
${(d.dict.groups || []).map((g) => `<option value="${esc(g)}" ${g === m.group ? "selected" : ""}>${esc(g)}</option>`).join("")}
|
||
${m.group && (d.dict.groups || []).indexOf(m.group) < 0 ? `<option value="${esc(m.group)}" selected>${esc(m.group)}</option>` : ""}
|
||
</select></td>
|
||
<td><select class="sel" data-sf="affiliation" style="width:92px">
|
||
${(d.dict.affiliations || []).map((a) => `<option value="${esc(a)}" ${a === m.affiliation ? "selected" : ""}>${esc(a)}</option>`).join("")}
|
||
${m.affiliation && (d.dict.affiliations || []).indexOf(m.affiliation) < 0 ? `<option value="${esc(m.affiliation)}" selected>${esc(m.affiliation)}</option>` : ""}
|
||
</select></td>
|
||
<td><input class="inp" data-sf="note" value="${esc(m.note || "")}" style="width:150px" placeholder="—" /></td>
|
||
<td class="num">${m.project_count ? `${m.project_count}${m.key_project_count ? ` ${starMark(true, false, "重点项目")}<span class="tag-mini">${m.key_project_count}</span>` : ""}` : "—"}</td>
|
||
<td class="num">${m.custom_count}</td>
|
||
<td class="num">${m.custom_amount ? fen(m.custom_amount) + " 万" : "—"}</td>
|
||
<td class="num">${m.custom_pending ? `<span style="color:#b45309">${fen(m.custom_pending)} 万</span>` : "—"}</td>
|
||
<td>${esc(m.last_update || "—")}</td>
|
||
<td class="nowrap col-ops">
|
||
<button class="btn sm" data-sedit="${m.id}">编辑</button>
|
||
<button class="btn sm" data-sarch="${m.id}" data-archived="${m.archived ? "1" : "0"}">${m.archived ? "恢复" : "归档"}</button>
|
||
<button class="btn sm danger" data-sdel="${m.id}">删除</button>
|
||
</td>
|
||
</tr>`).join("")}
|
||
</tbody></table>`;
|
||
|
||
const body = listCard({
|
||
title: "人员花名册",
|
||
sub: "小组 / 归属 / 说明可直接在表内改",
|
||
summary: `当前 ${d.items.length} 人 · 带项目 ${sum.with_project} 人 · 花名册外 ${sum.outside.length} 人`,
|
||
fields: `<select id="stArchived" class="sel" style="width:104px">${archOpts}</select>
|
||
<select id="stGroup" class="sel" style="width:120px">${groupOpts}</select>`,
|
||
rows: d.items,
|
||
table: table,
|
||
empty: state.keyword
|
||
? "没有匹配「" + esc(state.keyword) + "」的人员"
|
||
: (state.staffGroup ? "「" + esc(state.staffGroup) + "」还没有人员" : "还没有人员"),
|
||
});
|
||
|
||
host.innerHTML = toolbar + kpi + body;
|
||
|
||
bindKeyword("stKeyword");
|
||
$("#stArchived").addEventListener("change", (e) => { state.staffArchived = e.target.value; renderStaff(); });
|
||
$("#stGroup").addEventListener("change", (e) => { state.staffGroup = e.target.value; renderStaff(); });
|
||
$("#btnNewStaff").addEventListener("click", () => openStaffDialog(null));
|
||
$("#btnExportStaff").addEventListener("click", () => download("/api/export/staff?archived=" + state.staffArchived));
|
||
host.querySelectorAll("[data-sedit]").forEach((b) =>
|
||
b.addEventListener("click", () => {
|
||
const m = d.items.find((x) => x.id === Number(b.dataset.sedit));
|
||
openStaffDialog(m);
|
||
})
|
||
);
|
||
host.querySelectorAll("[data-sarch]").forEach((b) =>
|
||
b.addEventListener("click", async () => {
|
||
const m = d.items.find((x) => x.id === Number(b.dataset.sarch));
|
||
await patch("/staff/" + m.id, { archived: !m.archived });
|
||
toast(m.archived ? "已恢复" : `已归档「${m.name}」,不再出现在负责人下拉里`);
|
||
await afterWrite();
|
||
})
|
||
);
|
||
host.querySelectorAll("[data-sdel]").forEach((b) =>
|
||
b.addEventListener("click", () =>
|
||
deleteStaff(d.items.find((x) => x.id === Number(b.dataset.sdel)))
|
||
)
|
||
);
|
||
host.querySelectorAll("[data-outside]").forEach((b) =>
|
||
b.addEventListener("click", () => openStaffDialog({ name: b.dataset.outside }))
|
||
);
|
||
host.querySelectorAll("tr[data-sid]").forEach((tr) => {
|
||
const id = Number(tr.dataset.sid);
|
||
tr.querySelectorAll("[data-sf]").forEach((inp) =>
|
||
inp.addEventListener("change", async () => {
|
||
const f = inp.dataset.sf;
|
||
try {
|
||
await patch("/staff/" + id, { [f]: inp.value || null });
|
||
toast("已保存");
|
||
await afterWrite();
|
||
} catch (e) { toast("保存失败:" + e.message); }
|
||
})
|
||
);
|
||
});
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- shell */
|
||
async function loadCategories() {
|
||
state.categories = await api("/categories");
|
||
const box = $("#sideCategories");
|
||
box.innerHTML =
|
||
`<button class="side-item ${state.categoryId === null ? "active" : ""}" data-cat="">
|
||
<span class="dot" style="background:#c3c9d4"></span>全部项目</button>` +
|
||
state.categories.map((c) => `
|
||
<button class="side-item ${state.categoryId === c.id ? "active" : ""}" data-cat="${c.id}">
|
||
<span class="dot" style="background:${c.color}"></span>${esc(c.name)}
|
||
<span class="cnt">${c.project_count}</span>
|
||
</button>`).join("");
|
||
box.querySelectorAll("[data-cat]").forEach((b) =>
|
||
b.addEventListener("click", () => {
|
||
state.categoryId = b.dataset.cat === "" ? null : Number(b.dataset.cat);
|
||
loadCategories();
|
||
if (state.view === "board") renderBoard(); else if (state.view === "projects") renderProjects();
|
||
})
|
||
);
|
||
}
|
||
|
||
function setView(view) {
|
||
state.view = view;
|
||
saveUI();
|
||
$$("#nav .nav-item").forEach((b) => b.classList.toggle("active", b.dataset.view === view));
|
||
$$(".view").forEach((v) => v.classList.add("hidden"));
|
||
$("#view-" + view).classList.remove("hidden");
|
||
refresh();
|
||
}
|
||
|
||
async function refresh() {
|
||
const v = state.view;
|
||
try {
|
||
if (v === "board") await renderBoard();
|
||
else if (v === "projects") await renderProjects();
|
||
else if (v === "custom") await renderCustom();
|
||
else if (v === "focus") await renderFocus();
|
||
else if (v === "staff") await renderStaff();
|
||
else if (v === "intake") await renderIntake();
|
||
else if (v === "settings") await renderSettings();
|
||
} catch (e) {
|
||
$("#view-" + v).innerHTML = `<div class="notice err">加载失败:${esc(e.message)}</div>`;
|
||
}
|
||
$("#sideFoot").innerHTML = `当前周期:${esc(scopeLabel())}<br>共 ${state.periods.weeks.length} 个周期 / ${state.periods.months.length} 个月`;
|
||
}
|
||
|
||
/** 写操作统一收尾:刷新侧栏计数 + 当前视图。
|
||
录入页不重绘,否则会清空用户正在编辑的表单。 */
|
||
async function afterWrite() {
|
||
await Promise.all([loadCategories(), loadStaff()]);
|
||
if (state.view === "intake") return;
|
||
await refresh();
|
||
}
|
||
|
||
function bindShell() {
|
||
$$("#nav .nav-item").forEach((b) => b.addEventListener("click", () => setView(b.dataset.view)));
|
||
$$("#granSeg .seg-btn").forEach((b) =>
|
||
b.addEventListener("click", () => {
|
||
state.gran = b.dataset.gran;
|
||
$$("#granSeg .seg-btn").forEach((x) => x.classList.toggle("active", x === b));
|
||
renderPeriodSelect();
|
||
refresh();
|
||
})
|
||
);
|
||
$("#prevPeriod").addEventListener("click", () => shiftPeriod(-1));
|
||
$("#nextPeriod").addEventListener("click", () => shiftPeriod(1));
|
||
$("#periodSelect").addEventListener("change", (e) => {
|
||
if (e.target.value === "__new") {
|
||
renderPeriodSelect();
|
||
createNewPeriod().then((id) => { if (id) refresh(); });
|
||
return;
|
||
}
|
||
if (state.gran === "week") state.weekId = Number(e.target.value);
|
||
else state.monthIdx = Number(e.target.value);
|
||
renderPeriodSelect();
|
||
refresh();
|
||
});
|
||
$("#sideKeyOnly").addEventListener("change", (e) => { state.keyOnly = e.target.checked; refresh(); });
|
||
$("#btnIntake").addEventListener("click", () => setView("intake"));
|
||
$("#drawerClose").addEventListener("click", closeDrawer);
|
||
bindDrawerPeople();
|
||
$("#drawerMask").addEventListener("click", closeDrawer);
|
||
$("#modalMask").addEventListener("click", (e) => {
|
||
if (e.target.id === "modalMask" || e.target.dataset.mclose !== undefined) closeModal(false);
|
||
});
|
||
let t = null;
|
||
$("#globalSearch").addEventListener("input", (e) => {
|
||
clearTimeout(t);
|
||
const v = e.target.value.trim();
|
||
t = setTimeout(() => { state.keyword = v; refresh(); }, 320);
|
||
});
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key !== "Escape") return;
|
||
if ($("#modalMask").classList.contains("open")) closeModal(false);
|
||
else closeDrawer();
|
||
});
|
||
}
|
||
|
||
/* ------------------------------------------- 界面状态(刷新后保持) */
|
||
const UI_KEY = "unis.ui.v1";
|
||
const UI_FIELDS = ["view", "gran", "weekId", "monthIdx", "categoryId", "keyOnly", "keyword",
|
||
"projOwner", "projIdleOnly", "showArchived", "focusFilter", "focusArchived",
|
||
"customTab", "customArchived", "customRegion", "staffArchived", "staffGroup"];
|
||
|
||
function saveUI() {
|
||
const o = {};
|
||
UI_FIELDS.forEach((k) => { o[k] = state[k]; });
|
||
try { sessionStorage.setItem(UI_KEY, JSON.stringify(o)); } catch (e) { /* 存不了就算了 */ }
|
||
}
|
||
|
||
function restoreUI() {
|
||
let o = null;
|
||
try { o = JSON.parse(sessionStorage.getItem(UI_KEY) || "null"); } catch (e) { o = null; }
|
||
if (!o) return;
|
||
UI_FIELDS.forEach((k) => { if (k in o) state[k] = o[k]; });
|
||
if (["board", "projects", "custom", "focus", "staff", "intake", "settings"].indexOf(state.view) < 0) state.view = "board";
|
||
if (state.gran !== "month") state.gran = "week";
|
||
if (state.focusFilter === "later") state.focusFilter = "week";
|
||
}
|
||
|
||
async function main() {
|
||
restoreUI();
|
||
state.meta = await api("/meta");
|
||
await loadStaff();
|
||
await loadPeriods();
|
||
await loadCategories();
|
||
bindShell();
|
||
// 恢复出来的视图状态要回写到顶栏/侧栏控件上
|
||
$$("#granSeg .seg-btn").forEach((x) => x.classList.toggle("active", x.dataset.gran === state.gran));
|
||
$("#sideKeyOnly").checked = !!state.keyOnly;
|
||
$("#globalSearch").value = state.keyword || "";
|
||
setView(state.view);
|
||
}
|
||
|
||
main().catch((e) => {
|
||
document.body.insertAdjacentHTML("beforeend", `<div class="notice err" style="margin:20px">初始化失败:${esc(e.message)}</div>`);
|
||
});
|
||
})();
|