/* 软件开发部 · 关键任务追踪看板 */ (function () { "use strict"; const Ico = window.Icon; const state = { view: "board", gran: "week", weekId: null, monthIdx: null, categoryId: null, keyOnly: false, keyword: "", idle: "key", showArchived: false, meta: null, periods: null, categories: [], board: null, stats: null, projects: [], drawerProject: null, // 详情抽屉:周期反馈的显示粒度 / 范围 / 往前翻页偏移 dw: { group: "week", range: 4, back: 0 }, // 定制交付 customMeta: null, customTab: "pipeline", customRegion: "", customStage: "", customArchived: "false", customRows: [], customBoard: null, customStats: null, intakeType: "general", // 更新录入的两种方式:direct = 直接写入(原样存 1 条);ai = AI 整理(补结构化字段,也只存 1 条) intakeMode: "direct", intakePeriodId: null, drawerCustom: null, // 周看板(左栏总览 + 右栏详情) weekly: null, weeklySel: null, weeklyOwner: "", weeklyAllCustom: false, // 项目管理 projOwner: "", projIdleOnly: false, // 每周重点工作 focusRows: [], focusFilter: "week", focusArchived: false, // 人力资源库 staff: [], staffDict: { groups: [], affiliations: [] }, staffArchived: "false", staffGroup: "", }; /* ---------------------------------------------------------------- utils */ const $ = (sel) => document.querySelector(sel); const $$ = (sel) => Array.prototype.slice.call(document.querySelectorAll(sel)); function esc(s) { if (s === null || s === undefined) return ""; return String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } const nl2br = (s) => esc(s).replace(/\n/g, "
"); const money = (n) => n === null || n === undefined ? "—" : "¥" + Number(n).toLocaleString("zh-CN", { maximumFractionDigits: 2 }); const wan = (n) => n === null || n === undefined ? "—" : (Number(n) / 10000).toFixed(1) + " 万"; const fen = (n) => (n === null || n === undefined ? "—" : (Number(n) / 10000).toFixed(2)); /** 重点标记:统一用星标图标,on = 已标重点 */ const starMark = (on, readonly, title) => `${Ico.svg("star")}`; async function api(path, opts) { const res = await fetch("/api" + path, Object.assign({ headers: { "Content-Type": "application/json" } }, opts || {})); if (!res.ok) { let msg = res.statusText; try { msg = (await res.json()).detail || msg; } catch (e) { /* ignore */ } throw new Error(msg); } return res.json(); } const post = (path, body) => api(path, { method: "POST", body: JSON.stringify(body) }); const put = (path, body) => api(path, { method: "PUT", body: JSON.stringify(body) }); const patch = (path, body) => api(path, { method: "PATCH", body: JSON.stringify(body) }); const del = (path) => api(path, { method: "DELETE" }); let toastTimer = null; function toast(msg) { const t = $("#toast"); t.textContent = msg; t.classList.add("show"); clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove("show"), 2400); } /* ------------------------------------------------ 列表页统一骨架 */ /** 列表页统一样式(照「定制交付 · 项目明细」):工具栏在卡片上方, 卡片头部 = 标题 + 汇总说明 + 卡内筛选,下面是可滚动表格或空态。 */ function listCard(o) { return `

${esc(o.title)}${o.sub ? `${esc(o.sub)}` : ""}

${o.summary ? `${o.summary}` : ""} ${o.fields ? `
${o.fields}
` : ""}
${o.rows.length ? `
${o.table}
` : `
${o.empty}
`}
`; } /** 页内搜索框:与右上角全局搜索共用 state.keyword */ function keywordInputHtml(id, placeholder, width) { return ``; } /** 绑定页内搜索:防抖取数,重绘后把光标放回搜索框,连续打字不中断 */ function bindKeyword(id) { const el = $("#" + id); if (!el) return; let t = null; el.addEventListener("input", (e) => { clearTimeout(t); const v = e.target.value.trim(); t = setTimeout(() => { if (v === state.keyword) return; keywordFocus = true; state.keyword = v; $("#globalSearch").value = v; refresh(); }, 320); }); if (keywordFocus) { keywordFocus = false; el.focus(); el.setSelectionRange(el.value.length, el.value.length); } } /* ---------------------------------------------------- 弹窗 / 确认框 */ let modalResolve = null; function openModal(html, onMount) { $("#modalMask").classList.add("open"); $("#modal").innerHTML = html; if (onMount) onMount(); const f = $("#modal").querySelector("[data-autofocus]"); if (f) f.focus(); } /** 关闭弹窗;result 省略时按「取消」处理 */ function closeModal(result) { if (!$("#modalMask").classList.contains("open")) return; $("#modalMask").classList.remove("open"); $("#modal").innerHTML = ""; const cb = modalResolve; modalResolve = null; if (cb) cb(result === undefined ? false : result); } function modalError(msg) { const e = $("#mErr"); if (!e) return; e.textContent = msg || ""; e.style.display = msg ? "" : "none"; } /** 需要用户明确点确认才继续;requireText 时还必须逐字输入该文本 */ function confirmDialog(o) { return new Promise((resolve) => { openModal( ` ` ); modalResolve = resolve; $("#mOk").addEventListener("click", () => closeModal(true)); if (o.requireText) { const t = $("#mConfirmText"); const ok = $("#mOk"); t.addEventListener("input", () => { ok.disabled = t.value.trim() !== o.requireText; }); t.addEventListener("keydown", (e) => { if (e.key === "Enter" && !ok.disabled) closeModal(true); }); } }); } function statusMeta(code) { const list = (state.meta && state.meta.statuses) || []; return list.find((s) => s.code === code) || { code, name: code, color: "#94a3b8" }; } function statusPill(code) { const s = statusMeta(code); return `${Ico.status(code)}${esc(s.name)}`; } function catPill(cat) { if (!cat) return `未分类`; return `${esc(cat.name)}`; } /** 规范省级行政区下拉(按大区分组)。selected 不在标准列表时补一个选项,避免丢值。 */ function regionOptions(selected, withEmpty) { const groups = (state.customMeta && state.customMeta.region_groups) || []; const all = groups.reduce((acc, g) => acc.concat(g.regions), []); let html = withEmpty === false ? "" : ``; for (const g of groups) { html += `` + g.regions.map((r) => ``).join("") + ``; } if (selected && all.indexOf(selected) < 0) { html += ``; } return html; } /** 触发浏览器下载(导出 Excel) */ function download(url) { const a = document.createElement("a"); a.href = url; a.style.display = "none"; document.body.appendChild(a); a.click(); setTimeout(() => a.remove(), 1000); toast("正在生成 Excel,请稍候…"); } /** * 负责人下拉。选项来自人力资源库:已归档人员不再出现在选项里, * 只有他本来就是当前负责人时才带「(已归档)」标注出现一次,避免历史记录丢值; * 库里没有的历史人名同样补一个选项,保证老数据不被改坏。 */ function ownerSelectHtml(value, attrs) { const cur = (value || "").trim(); let html = ``; } /** * 多负责人 / 团队成员的可编辑名单:人名下拉(选项来自人力资源库)横向排开、自动换行, * 不分主次,每个人都能删;「+ 添加」紧跟在名单末尾。 * 增删按钮的事件统一委托在 #drawerBody 上(见 bindDrawerPeople)。 */ function peopleEditorHtml(id, label, values, opts) { const list = values && values.length ? values : [""]; const rows = list .map( (n) => `
${ownerSelectHtml(n, 'style="width:118px"')}
`, ) .join(""); return `
${rows}
${opts.hint ? `
${opts.hint}
` : ""}
`; } /** 读回名单里的人名(按行序,空行忽略) */ function peopleValues(id) { return $$("#" + id + " select").map((x) => x.value.trim()).filter(Boolean); } /** 名单增删:抽屉里所有「多负责人 / 团队成员」共用一个委托 */ function bindDrawerPeople() { $("#drawerBody").addEventListener("click", (e) => { const add = e.target.closest("[data-padd]"); if (add) { const box = document.getElementById(add.dataset.padd); if (!box) return; const row = document.createElement("div"); row.className = "people-row"; row.innerHTML = ownerSelectHtml("", 'style="width:130px"') + ``; box.insertBefore(row, add); row.querySelector("select").focus(); return; } const del = e.target.closest("[data-pdel]"); if (del) del.closest(".people-row").remove(); }); } /** 只读名单:小标签一排 */ function nameChips(list) { return (list || []).length ? list.map((n) => `${esc(n)}`).join(" ") : "—"; } /** 拉一次花名册,供各处「负责人」下拉使用 */ async function loadStaff() { const d = await api("/staff?archived=all"); state.staff = d.items; state.staffDict = d.dict; return d; } /* ---------------------------------------------------------------- scope */ function scopeQuery(extra) { const p = []; if (state.gran === "week") { if (state.weekId) p.push("period_id=" + state.weekId); } else { const m = currentMonth(); if (m) p.push("year=" + m.year, "month=" + m.month); } if (state.keyword) p.push("keyword=" + encodeURIComponent(state.keyword)); if (extra) p.push.apply(p, extra); return p.length ? "?" + p.join("&") : ""; } function currentMonth() { if (!state.periods || state.monthIdx === null) return null; return state.periods.months[state.monthIdx] || null; } function scopePeriodIds() { if (state.gran === "week") return state.weekId ? [state.weekId] : []; const m = currentMonth(); return m ? m.period_ids : []; } function scopeLabel() { if (state.gran === "week") { const w = (state.periods.weeks || []).find((x) => x.id === state.weekId); return w ? w.full_label : "—"; } const m = currentMonth(); return m ? m.label : "—"; } /* ---------------------------------------------------------------- charts */ function barChart(items, opts) { opts = opts || {}; const W = 660, H = opts.height || 190, padL = 34, padB = 30, padT = 14; const innerW = W - padL - 12, innerH = H - padB - padT; const max = Math.max(1, ...items.map((d) => d.value)); const n = items.length || 1; const slot = innerW / n; const bw = Math.min(38, slot * 0.6); let bars = ""; items.forEach((d, i) => { const h = Math.max(2, (d.value / max) * innerH); const x = padL + slot * i + (slot - bw) / 2; const y = padT + innerH - h; bars += `${esc(d.label)}: ${d.value}${esc(opts.unit || "")}`; if (d.value > 0) { bars += `${d.value}`; } bars += `${esc(d.label)}`; }); let grid = ""; for (let g = 0; g <= 3; g++) { const y = padT + (innerH / 3) * g; grid += ``; grid += `${Math.round(max - (max / 3) * g)}`; } return `${grid}${bars}`; } function donutChart(items) { const total = items.reduce((a, b) => a + b.value, 0) || 1; const r = 58, cx = 80, cy = 80, C = 2 * Math.PI * r; let acc = 0, segs = ""; items.forEach((d) => { const len = (d.value / total) * C; segs += `${esc(d.name)}: ${d.value}`; acc += len; }); return ` ${segs} ${total} 项目总数 `; } function stackBar(items) { const total = items.reduce((a, b) => a + b.count, 0) || 1; let x = 0, segs = ""; items.filter((i) => i.count > 0).forEach((d) => { const w = (d.count / total) * 100; segs += `${esc(d.name)}: ${d.count}`; x += w; }); return `${segs}`; } function barList(rows) { return rows.map((r) => `
${r.is_key ? starMark(true) + " " : ""}${esc(r.name)}
${r.progress}${r.estimated ? "*" : ""}
`).join(""); } /* ---------------------------------------------------------------- periods */ async function createNewPeriod() { const label = prompt("新建周期(格式如:9月第1周)\n年份会自动接在最新周期之后,也可写成 2027年1月第1周", ""); if (!label || !label.trim()) return null; try { const r = await post("/periods", { label: label.trim() }); await loadPeriods(); state.gran = "week"; state.weekId = r.period.id; state.intakePeriodId = r.period.id; $$("#granSeg .seg-btn").forEach((x) => x.classList.toggle("active", x.dataset.gran === "week")); renderPeriodSelect(); toast(r.created ? `已新增周期 ${r.period.full_label}` : `周期 ${r.period.full_label} 已存在,已切换`); return r.period.id; } catch (err) { toast("新建周期失败:" + err.message); return null; } } async function loadPeriods() { state.periods = await api("/periods"); if (!state.periods.weeks.length) return; // 刷新后恢复的周期可能已经被删除/重建,不在列表里就回落到今天所在的周(没有就取最新一期) if (!state.periods.weeks.some((w) => w.id === state.weekId)) { state.weekId = state.periods.today_id || state.periods.weeks[state.periods.weeks.length - 1].id; } if (state.monthIdx === null || state.monthIdx >= state.periods.months.length) { state.monthIdx = state.periods.months.length - 1; } renderPeriodSelect(); } function renderPeriodSelect() { saveUI(); const sel = $("#periodSelect"); if (!state.periods) return; if (state.gran === "week") { sel.innerHTML = state.periods.weeks .map((w) => ``) .join("") + ``; } else { sel.innerHTML = state.periods.months .map((m, i) => ``) .join(""); } $("#periodHint").textContent = state.gran === "week" ? "周视图" : "月视图(自动汇总该月所有周)"; } function shiftPeriod(delta) { if (state.gran === "week") { const arr = state.periods.weeks; let i = arr.findIndex((w) => w.id === state.weekId); i = Math.min(arr.length - 1, Math.max(0, i + delta)); state.weekId = arr[i].id; } else { let i = state.monthIdx + delta; i = Math.min(state.periods.months.length - 1, Math.max(0, i)); state.monthIdx = i; } renderPeriodSelect(); refresh(); } /* ---------------------------------------------------------------- dashboard */ /** 统计概览(原「执行面板」):折进工作看板底部,不再占一个主导航位 */ async function dashboardHtml() { const s = await api("/stats" + scopeQuery()); state.stats = s; const k = s.kpi; const trend = s.trend.map((t) => { const cur = scopePeriodIds().indexOf(t.period_id) >= 0; return { label: `${t.month}/${t.week || ""}`, value: t.update_count, color: cur ? "#2563eb" : "#93b4f5", dim: !cur }; }); return `
${Ico.badge("layers")}
在管项目
${k.project_total}
重点项目 ${k.key_total} 个
${esc(scopeLabel())} · 执行记录
${k.update_count}
覆盖项目 ${k.covered_projects} / ${k.project_total}
${Ico.badge("gauge")}
平均进度
${k.avg_progress}%
按项目最新进度估算
${Ico.badge("alert")}
风险 / 阻塞
${k.risk_count}
本周期需关注条目
${Ico.badge("alert-circle")}
重点项目未更新
${s.idle_key.length}
本周期无执行记录

周期更新活跃度近 ${trend.length} 个周期的执行记录条数

${barChart(trend, { unit: " 条" })}

项目分类分布

${donutChart(s.category_dist.map((c) => ({ name: c.name, color: c.color, value: c.count })))}
${s.category_dist.map((c) => `
${esc(c.name)} ${c.count}
`).join("")}

执行状态分布${esc(scopeLabel())}

${stackBar(s.status_dist)}
${s.status_dist.map((x) => `${esc(x.name)} ${x.count}`).join("")}
进度领先 Top 10
${barList(s.top_progress)}

风险与阻塞${s.risks.length} 条

${s.risks.length ? s.risks.map((r) => `
${esc(r.project_name)}${statusPill(r.status)} ${esc(r.period_label || "")}
${nl2br(r.risk || r.content || "")}
`).join("") : `
本周期暂无风险上报
`}

低进度预警进度 < 30% 且未完成

${s.low_progress.length ? barList(s.low_progress) : `
暂无
`}

最近更新

${s.recent_updates.length ? s.recent_updates.map((u) => `
${esc(u.project_name)}${statusPill(u.status)}${esc(u.task_name || "")}${esc(u.period_label || "")}
${nl2br((u.summary || u.content || "").slice(0, 160))}
`).join("") : `
暂无
`}
`; } /* ---------------------------------------------------------------- 周看板 */ /** 左栏一行:重点项目 / 定制项目共用 */ function weeklyItem(r) { const sel = state.weeklySel; const active = sel && sel.kind === r.kind && sel.id === r.id; const color = r.kind === "project" ? ((r.category && r.category.color) || "#2563eb") : stageMeta(r.stage).color; const badge = r.has_update ? `${r.update_count} 条` : `未更新`; return `
${r.is_key ? starMark(true) : ""}${esc(r.name)} ${badge}
${r.kind === "project" ? statusPill(r.status) : stagePill(r.stage)} ${esc(r.owner || "未指派")} ${r.kind === "project" ? `${r.progress}%` : `${fen(r.amount)} 万`} ${r.risks.length ? `风险 ${r.risks.length}` : ""}
`; } /** 交付环节步骤条:日期可直接补录,补了日期后端会自动推进商务阶段 */ function pipelineBar(p, readonly) { const steps = (state.customMeta && state.customMeta.pipeline) || []; return `
${steps.map((s, i) => ` ${i ? `
${Ico.svg("chevron-right")}
` : ""}
${Ico.pipe(s.code)}${esc(s.name)}
${readonly ? `
${p[s.date_field] ? esc(p[s.date_field]) : "未填"}
` : ``}
`).join("")}
`; } /** 一条执行 / 交付记录 */ function updateItem(u) { return `
${esc(u.task_name || u.period_full || "记录")} ${u.stage ? stagePill(u.stage) : statusPill(u.status)} ${u.progress !== null && u.progress !== undefined ? `${u.progress}%` : ""} ${u.period_full && u.period_full !== u.task_name ? `${esc(u.period_label || "")}` : ""}
${nl2br(u.summary || u.content || "(无正文)")}
${u.risk ? `
${Ico.svg("alert-circle")}
${nl2br(u.risk)}
` : ""} ${u.next_step ? `
${Ico.svg("arrow-right")}
${nl2br(u.next_step)}
` : ""}
`; } function weeklyHead(row) { const isP = row.kind === "project"; const color = isP ? ((row.category && row.category.color) || "#2563eb") : stageMeta(row.stage).color; return `
${Ico.badge(isP ? Ico.nameOfStatus(row.status) : Ico.nameOfStage(row.stage))}${row.is_key ? starMark(true) : ""}${esc(row.name)}
${isP ? catPill(row.category) + " " + statusPill(row.status) : stagePill(row.stage)} ${esc(row.owner || "未指派")} 最近更新 ${esc(row.last_update || "尚无记录")} ${row.idle_weeks ? `已 ${row.idle_weeks} 周没更新` : ""}
`; } /* 右栏:常规项目详情 */ async function weeklyProjectDetail(row) { const d = await api("/projects/" + row.id); const p = d.project; const color = (p.category && p.category.color) || "#2563eb"; const trend = d.timeline.slice(-8).reverse(); return ` ${weeklyHead(row)}
本期执行记录(${row.updates.length})
${row.updates.length ? row.updates.map(updateItem).join("") : `
本周期还没有执行记录。重点项目每周都要更新一次,点上方「录一笔执行记录」补上。
`}
进度与状态${esc(row.progress_source)} · 共 ${p.update_count} 条历史记录 / ${d.tasks.length} 个子任务
整体进度${row.progress}%
${d.tasks.length ? `
${d.tasks.map((t) => `${esc(t.name)}`).join("")}
` : ""}
${trend.length ? `
近 ${trend.length} 期趋势
${trend.map((t) => `
${esc(t.period.full_label.replace(/^\d+年/, ""))}
${statusPill(t.status)}${t.progress !== null ? ` ${t.progress}%` : ""} ${t.updates.length} 条
`).join("")}
` : ""}`; } /* 右栏:定制项目详情(交付环节 + 财务) */ async function weeklyCustomDetail(row) { const d = await api("/custom/projects/" + row.id); const p = d.project; const hist = d.updates.slice(-5).reverse(); return ` ${weeklyHead(row)}
交付环节补录日期会自动推进商务阶段
${pipelineBar(p)} ${p.remark ? `
${nl2br(p.remark)}
` : ""}
财务跟踪
${Ico.badge("wallet")}
合同额
${wan(p.amount)}
${Ico.badge("receipt")}
可确认收入
${wan(p.confirmed_amount)}
${p.revenue_ratio}%
${Ico.badge("wallet")}
已计收
${wan(p.revenue_amount)}
${Ico.badge("hourglass")}
待计收
${wan(p.pending_amount)}
待开票 ${fen(p.pending_invoice)} 万 · 待回款 ${fen(p.pending_received)} 万
本期交付记录(${row.updates.length})
${row.updates.length ? row.updates.map(updateItem).join("") : `
本周期没有交付记录。
`}
${hist.length ? `
更早的交付记录
${hist.map((u) => `
${esc((u.period_full || "").replace(/^\d+年/, ""))}
${stagePill(u.stage)}${u.revenue_delta ? ` 计收 ${fen(u.revenue_delta)}万` : ""}
`).join("")}
` : ""}`; } function bindWeeklyDetail(box, row) { const inBtn = $("#wkIntake"); if (inBtn) inBtn.addEventListener("click", () => { state.intakePeriodId = state.weekId; if (row.kind === "project") { state.intakeType = "general"; intakeProjectId = row.id; } else { state.intakeType = "custom"; customIntakeId = row.id; } setView("intake"); }); const dw = $("#wkDrawer"); if (dw) dw.addEventListener("click", () => row.kind === "project" ? openDrawer(row.id) : openCustomDrawer(row.id) ); box.querySelectorAll("[data-cf]").forEach((inp) => inp.addEventListener("change", async () => { const f = inp.dataset.cf; let v = inp.value; if (f === "invoice_amount" || f === "received_amount") v = v === "" ? null : Number(v); try { await patch("/custom/projects/" + row.id, { [f]: v }); toast("已保存"); await afterWrite(); } catch (e) { toast("保存失败:" + e.message); } }) ); } /** 定制项目本周动态里的一条:整行可点,点进去看该项目详情 */ function weeklyCustomRow(r, tag) { return `
${r.is_key ? starMark(true) : ""}${esc(r.name)} ${tag || ""}
${stagePill(r.stage)} ${esc(r.owner || "未指派")} ${fen(r.amount)} 万 ${r.period_revenue_delta ? `计收 ${fen(r.period_revenue_delta)} 万` : ""} ${r.risks.length ? `风险 ${r.risks.length}` : ""}
${r.summary ? `
${esc(String(r.summary).replace(/\s+/g, " ").slice(0, 90))}
` : ""}
`; } /** 右栏:定制项目本周动态。左栏的「定制项目」只给入口,清单都收在这里 */ function weeklyCustomOverview(data) { const rows = data.custom; const added = rows.filter((r) => r.is_new); const moved = rows.filter((r) => r.has_update && !r.is_new); const idle = rows.filter((r) => !r.is_new && !r.has_update); // 合计只算列出来的行,免得数字对不上清单 const shown = added.concat(moved).concat(state.weeklyAllCustom ? idle : []); const sum = shown.reduce((a, r) => a + Number(r.amount || 0), 0); const sec = (title, sub, list, tag) => `
${title}${sub} · ${list.length} 个
${list.length ? list.map((r) => weeklyCustomRow(r, tag(r))).join("") : `
本周期没有
`}
`; return `
${Ico.badge("package")}定制项目 · 本周动态
新入库 ${added.length} 个 有交付动态 ${moved.length} 个 合同额合计 ${fen(sum)} 万 本期计收 ${fen(data.kpi.revenue_delta)} 万
${sec("本周新增", "入库时间落在本周期", added, (r) => `${esc(r.entry_date || "新入库")}`)} ${sec("本周有变化", "本周期有交付记录", moved, (r) => `${r.update_count} 条记录`)} ${state.weeklyAllCustom ? sec("其他在途项目", "本周没有动态", idle, (r) => (r.is_key ? `重点` : "")) : ""} ${!state.weeklyAllCustom && idle.length ? `
另有 ${idle.length} 个在途定制项目本周没有动态,勾选「列入全部在途」一并列出。
` : ""}`; } function bindWeeklyCustomOverview(box) { const ac = box.querySelector("#wkAllCustom"); if (ac) ac.addEventListener("change", () => { state.weeklyAllCustom = ac.checked; renderBoard(); }); const gc = box.querySelector("#wkGoCustom"); if (gc) gc.addEventListener("click", () => setView("custom")); box.querySelectorAll("[data-cpick]").forEach((el) => el.addEventListener("click", () => { state.weeklySel = { kind: "custom", id: Number(el.dataset.cpick) }; renderWeeklyDetail(); }) ); } async function renderWeeklyDetail() { const box = $("#wkRight"); if (!box) return; const sel = state.weeklySel; if (sel && sel.kind === "focus") { const f = (state.focusRows || []).find((x) => x.id === sel.id); box.innerHTML = f ? focusDetailHtml(f) : `
在左侧选择一条重点工作查看详情
`; const eb = $("#wkFocusEdit"); if (eb) eb.addEventListener("click", () => openFocusDrawer(sel.id, "edit")); return; } if (sel && sel.kind === "customs") { box.innerHTML = weeklyCustomOverview(state.weekly); bindWeeklyCustomOverview(box); return; } const row = sel && (sel.kind === "project" ? state.weekly.projects : state.weekly.custom) .find((r) => r.id === sel.id); if (!row) { box.innerHTML = `
在左侧选择一个项目查看执行详情
`; return; } box.innerHTML = `
加载中…
`; try { box.innerHTML = row.kind === "project" ? await weeklyProjectDetail(row) : await weeklyCustomDetail(row); bindWeeklyDetail(box, row); } catch (e) { box.innerHTML = `
加载失败:${esc(e.message)}
`; } } async function renderBoard() { saveUI(); const host = $("#view-board"); host.innerHTML = `
加载中…
`; if (!state.customMeta) state.customMeta = await api("/custom/meta"); const extra = ["all_custom=" + (state.weeklyAllCustom ? "true" : "false")]; if (state.weeklyOwner) extra.push("owner=" + encodeURIComponent(state.weeklyOwner)); const [data, focusRows] = await Promise.all([ api("/weekly" + scopeQuery(extra)), api("/focus" + focusQuery(["archived=false"])), ]); state.weekly = data; state.focusRows = focusRows; const k = data.kpi; const focus = focusBuckets(focusRows); // 默认选中本期有记录的第一条,选中项还在列表里就保持不变 const all = data.projects.concat(data.custom); const cur = state.weeklySel; const keep = cur && (cur.kind === "customs" ? true : cur.kind === "focus" ? focusRows.some((f) => f.id === cur.id) : all.some((r) => r.kind === cur.kind && r.id === cur.id)) ? cur : null; const pick = keep || all.find((r) => r.has_update) || all[0] || null; state.weeklySel = pick ? { kind: pick.kind, id: pick.id } : null; const owners = {}; all.forEach((r) => { if (r.owner) owners[r.owner] = true; }); const toolbar = `
${esc(scopeLabel())} · 项目 ${data.projects.length} 个 · 定制动态 ${data.kpi.custom_new + data.kpi.custom_updated} 个
`; const kpi = `
${Ico.badge("star")}
重点项目
${k.key_total}
本期已更新 ${k.key_updated}
${Ico.badge("alert-circle")}
重点项目未更新
${k.key_idle}
每周都要更新一次
${Ico.badge("list-checks")}
本周重点工作
${focus.week.length}
延期 ${focus.overdue.length} 条 · 已完成 ${focus.done.length} 条
${Ico.badge("activity")}
执行记录
${k.update_count}
在办项目 ${k.project_listed} 个
${Ico.badge("wallet")}
定制合同额
${wan(k.custom_amount)}
${k.custom_total} 个项目 · 本期计收 ${fen(k.revenue_delta)} 万
${Ico.badge("alert")}
风险 / 阻塞
${k.risk_count}
本期上报条目
`; const group = (title, rows, tip) => `
${title}${rows.length} ${rows.filter((r) => r.has_update).length} ${tip}
${rows.length ? rows.map(weeklyItem).join("") : `
本周期没有记录
`}
`; // 左栏顺序:本周工作重点 → 重点项目 → 定制项目(只给入口,点开在右栏看清单) const focusList = focus.overdue.concat(focus.week); const cNew = data.custom.filter((r) => r.is_new); const cUpd = data.custom.filter((r) => r.has_update && !r.is_new); const openCustom = cur && (cur.kind === "customs" || cur.kind === "custom"); host.innerHTML = kpi + toolbar + `
本周工作重点${focusList.length} ${focus.overdue.length ? focus.overdue.length + " 条延期" : "已完成 " + focus.done.length + " 条"}
${focusList.length ? focusList.map(focusItem).join("") : `
本周没有安排重点工作
`}
${group("重点项目", data.projects, "个已更新")}
定制项目${data.custom.length} 本周动态 ${cNew.length + cUpd.length} 个
${Ico.svg("package")} 本周定制项目动态 ${Ico.svg("arrow-right")}
新增 ${cNew.length} 有变化 ${cUpd.length} ${state.weeklyAllCustom ? `含全部在途` : ""}
加载中…
`; const wo = $("#wkOwner"); if (wo) wo.addEventListener("change", () => { state.weeklyOwner = wo.value; renderBoard(); }); const wex = $("#wkExportUpdates"); if (wex) wex.addEventListener("click", () => download("/api/export/updates" + scopeQuery([]))); // 统计概览(原「执行面板」)折在最底下,展开才看;失败也不影响看板主体 dashboardHtml().then((html) => { host.insertAdjacentHTML("beforeend", `
统计概览更新活跃度 / 分类分布 / 风险与阻塞
${html}
`); }).catch((e) => { host.insertAdjacentHTML("beforeend", `
统计概览加载失败:${esc(e.message)}
`); }); host.querySelectorAll("[data-wk]").forEach((el) => el.addEventListener("click", () => { const parts = el.dataset.wk.split(":"); state.weeklySel = { kind: parts[0], id: Number(parts[1]) }; host.querySelectorAll("[data-wk]").forEach((x) => x.classList.toggle("active", x === el)); renderWeeklyDetail(); }) ); renderWeeklyDetail(); } /* ------------------------------------------------------------ 每周重点工作 */ /** 重点工作一律按「周」跟踪,基准周取顶栏当前选中的那一周 */ function focusWeek() { return (state.periods.weeks || []).find((w) => w.id === state.weekId) || null; } function focusQuery(extra) { const p = extra ? extra.slice() : []; if (state.keyword) p.push("keyword=" + encodeURIComponent(state.keyword)); return p.length ? "?" + p.join("&") : ""; } /** 分堆按「录入周」(f.sort_key 就是条目录入的那一周),和预计完成时间无关: week = 本周录入的;overdue = 更早周次录入、标了延期又没做完的结转条目。 「已完成」是交叉视图,已完成条目仍留在自己那一周里,避免录完就看不见。 更早周次里没标延期的未完成条目不进当周视图,只能在「全部」里查得到。 */ function focusBuckets(rows) { const w = focusWeek(); const cur = w ? w.sort_key : 0; const out = { cur: cur, overdue: [], week: [], done: [] }; rows.forEach((f) => { if (f.sort_key === cur) out.week.push(f); else if (f.sort_key < cur && focusIsLate(f)) out.overdue.push(f); if (f.status === "done") out.done.push(f); }); return out; } /** 是否延期:录入时人工打开的开关,不再按日期自动判定 */ function focusIsLate(f) { return f.status !== "done" && !!f.delayed; } /** 本地今天(YYYY-MM-DD),新增条目的预计完成时间默认值 */ function todayIso() { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } /** 预计完成时间:有日历日期就显示日期,早期没日期的条目退回显示录入周 */ function focusDueText(f) { return f.due_date || f.period_full || "—"; } /** 状态徽章:打开「是否延期」的未完成条目挂一个「延期」 */ function focusPill(f) { return (focusIsLate(f) ? `延期` : "") + statusPill(f.status); } /** 工作看板左栏的一条重点工作 */ function focusItem(f) { const sel = state.weeklySel; const late = focusIsLate(f); return `
${Ico.status(f.status)} ${esc(f.content)} ${late ? `延期` : ""}
${statusPill(f.status)} 预计 ${esc(focusDueText(f))} ${late ? `录入 ${esc(f.period_label || "—")}` : ""}
`; } /** 工作看板右栏的一条重点工作(只读,改内容走抽屉) */ function focusDetailHtml(f) { const ro = (label, val) => `
${val}
`; return `
${Ico.statusBadge(f.status)}${esc(f.content)}
${focusPill(f)} 预计完成 ${esc(focusDueText(f))}
说明只读
${f.detail ? nl2br(f.detail) : `
未填写说明
`}
结果反馈只读
${f.result ? nl2br(f.result) : `
未填写结果反馈
`}
`; } async function renderFocus() { saveUI(); const host = $("#view-focus"); host.innerHTML = `
加载中…
`; const rows = await api("/focus" + focusQuery(["archived=" + (state.focusArchived ? "all" : "false")])); state.focusRows = rows; const b = focusBuckets(rows); const week = focusWeek(); const shown = state.focusFilter === "all" ? rows : b[state.focusFilter] || []; const filters = [["week", "本周"], ["overdue", "延期"], ["done", "已完成"], ["all", "全部"]]; const toolbar = `
${filters.map((x) => ``).join("")} ${keywordInputHtml("fKeyword", "搜索工作内容…", 200)} 本周/延期按录入周统计 · 基准周 ${esc(week ? week.full_label : "—")}
`; const table = ` ${shown.map((f) => ` `).join("")}
工作内容说明 预计完成时间状态结果反馈更新操作
${esc(f.content)} ${f.archived ? `已归档` : ""}
${esc((f.detail || "—").replace(/\s+/g, " ").slice(0, 48))} ${esc(focusDueText(f))}${f.period_label ? `
录入 ${esc(f.period_label)}
` : ""}
${focusPill(f)} ${esc((f.result || "—").replace(/\s+/g, " ").slice(0, 40))} ${esc((f.updated_at || "").slice(0, 10) || "—")}
`; const body = listCard({ title: "重点工作清单", sub: "按录入时间倒序 · 共 " + rows.length + " 条", summary: `当前 ${shown.length} 条 · 已完成 ${b.done.length} · 延期 ${b.overdue.length}`, fields: ``, rows: shown, table: table, empty: state.keyword ? "没有匹配「" + esc(state.keyword) + "」的重点工作" : state.focusFilter === "week" ? "本周(" + esc(week ? week.full_label : "—") + ")还没有录入重点工作,点右上角「新增重点工作」添加" : "这一堆还没有条目,点右上角「新增重点工作」添加", }); host.innerHTML = toolbar + body; bindKeyword("fKeyword"); host.querySelectorAll("[data-ffilter]").forEach((c) => c.addEventListener("click", () => { state.focusFilter = c.dataset.ffilter; renderFocus(); }) ); $("#fcArchived").addEventListener("change", (e) => { state.focusArchived = e.target.checked; renderFocus(); }); $("#btnExportFocus").addEventListener("click", () => download("/api/export/focus" + focusQuery(["archived=" + (state.focusArchived ? "all" : "false")])) ); host.querySelectorAll("[data-fopen]").forEach((el) => el.addEventListener("click", () => openFocusDrawer(Number(el.dataset.fopen), "view")) ); host.querySelectorAll("[data-fedit]").forEach((el) => el.addEventListener("click", () => openFocusDrawer(Number(el.dataset.fedit), "edit")) ); host.querySelectorAll("[data-farch]").forEach((el) => el.addEventListener("click", async () => { const toArchive = el.dataset.archived !== "1"; try { await patch("/focus/" + el.dataset.farch, { archived: toArchive }); } catch (e) { toast("操作失败:" + e.message); return; } toast(toArchive ? "已归档(勾选「含归档条目」可查看)" : "已恢复"); await afterWrite(); }) ); host.querySelectorAll("[data-fdel]").forEach((el) => el.addEventListener("click", () => { const row = rows.find((x) => x.id === Number(el.dataset.fdel)); if (row) deleteFocus(row); }) ); $("#btnAddFocus").addEventListener("click", () => openFocusDrawer(null, "create")); } /** 删除重点工作条目:列表和抽屉共用。条目不是项目,危险确认即可,不要求逐字输入 */ async function deleteFocus(f) { const ok = await confirmDialog({ title: "删除重点工作条目", danger: true, okText: "永久删除", body: `
「${esc(f.content)}」将被删除,删除后无法恢复。
只是这一周不再跟进?请改用「归档」。
`, }); if (!ok) return; try { await del("/focus/" + f.id); } catch (e) { toast("删除失败:" + e.message); return; } toast("已删除「" + f.content + "」"); closeDrawer(); await afterWrite(); } /** 重点工作抽屉:新增走单独面板,详情只读、修改才出表单;基础信息的动作一律在面板顶部 */ async function openFocusDrawer(fid, mode) { mode = mode === "create" ? "create" : mode === "edit" ? "edit" : "view"; $("#drawerMask").classList.add("open"); $("#drawer").classList.add("open"); $("#drawerBody").innerHTML = `
加载中…
`; drawerActions(""); let f = null; if (mode === "create") { f = { content: "", detail: "", period_id: state.weekId, status: state.meta.statuses[0].code, archived: false }; } else { try { const rows = await api("/focus?archived=all"); f = rows.find((x) => x.id === fid) || null; } catch (e) { $("#drawerBody").innerHTML = `
加载失败:${esc(e.message)}
`; return; } if (!f) { $("#drawerBody").innerHTML = `
条目不存在,可能已被删除
`; return; } } // 录入周 = 顶栏当前选中的那一周(新增时定下来,之后不随预计完成时间变) const w = focusWeek(); const entryWeek = mode === "create" ? (w ? w.full_label : "顶栏当前周") : (f.period_full || f.period_label || "—"); $("#drawerTitle").innerHTML = mode === "create" ? `新增每周重点工作` : `${esc(f.content)}`; $("#drawerSub").innerHTML = mode === "create" ? `一条就是一个活儿,不用先建项目 · 录入周 ${esc(entryWeek)}` : `${focusPill(f)} · 录入 ${esc(f.period_full || f.period_label || "—")} · 预计完成 ${esc(focusDueText(f))}`; const ro = (label, val) => `
${val}
`; // 基础信息的动作在标题栏右侧按钮条:危险动作在左,主按钮在最右 drawerActions(mode === "view" ? ` ` : ` `); const form = mode === "view" ? `
基本信息只读
${ro("工作内容", esc(f.content) + (f.archived ? ` 已归档` : ""))} ${ro("说明", f.detail ? nl2br(f.detail) : "—")} ${ro("预计完成时间", esc(focusDueText(f)))} ${ro("录入周", esc(f.period_full || f.period_label || "—"))} ${ro("执行状态", statusPill(f.status))} ${ro("是否延期", f.delayed ? `延期` : "未延期")} ${ro("结果反馈", f.result ? nl2br(f.result) : "—")} ${ro("最近更新", esc((f.updated_at || "").replace("T", " ") || "—"))}
` : `
基本信息${mode === "create" ? "新增" : "编辑"}
${esc(entryWeek)}
条目归在「录入周」这一周,不随预计完成时间变;延期人工开关,更早周次没做完的标上延期就结转进当周视图
`; $("#drawerBody").innerHTML = (mode === "view" ? "" : ``) + form; const on = (sel, fn) => { const el = $(sel); if (el) el.addEventListener("click", fn); }; const after = () => afterWrite(); on("#fdEdit", () => openFocusDrawer(fid, "edit")); on("#fdCancel", () => (mode === "create" ? closeDrawer() : openFocusDrawer(fid, "view"))); on("#fdSave", async () => { const btn = $("#fdSave"); const err = $("#fdErr"); const content = $("#fdContent").value.trim(); if (!content) { err.textContent = "请填写工作内容"; err.style.display = ""; return; } btn.disabled = true; err.style.display = "none"; const due = $("#fdDue").value; if (!due) { err.textContent = "请选择预计完成时间"; err.style.display = ""; return; } const body = { content: content, detail: $("#fdDetail").value, status: $("#fdStatus").value, due_date: due, result: $("#fdResult").value, delayed: $("#fdDelayed").checked, }; if (mode === "create") body.period_id = state.weekId; // 录入周 = 顶栏当前周 try { if (mode === "create") fid = (await post("/focus", body)).id; else await patch("/focus/" + fid, body); } catch (e) { btn.disabled = false; err.textContent = "保存失败:" + e.message; err.style.display = ""; return; } toast(mode === "create" ? "已添加「" + content + "」" : "已保存"); after(); openFocusDrawer(fid, "view"); }); on("#fdArchive", async () => { try { await patch("/focus/" + fid, { archived: !f.archived }); } catch (e) { toast("操作失败:" + e.message); return; } toast(f.archived ? "已恢复" : "已归档"); closeDrawer(); after(); }); on("#fdDelete", () => deleteFocus(f)); } /* ---------------------------------------------------------------- 项目管理 */ async function renderProjects() { saveUI(); const host = $("#view-projects"); host.innerHTML = `
加载中…
`; const extra = ["archived=" + (state.showArchived ? "all" : "false")]; if (state.categoryId) extra.push("category_id=" + state.categoryId); if (state.keyOnly) extra.push("is_key=true"); if (state.projOwner) extra.push("owner=" + encodeURIComponent(state.projOwner)); const rows = await api("/projects" + scopeQuery(extra)); const shown = state.projIdleOnly ? rows.filter((r) => !r.updated_this_period) : rows; const idle = rows.filter((r) => !r.updated_this_period).length; const ownerFilter = ``; const keyCount = rows.filter((r) => r.is_key).length; const toolbar = `
${keywordInputHtml("pKeyword", "搜索项目名称…", 200)} ${ownerFilter} ${esc(scopeLabel())} · ${rows.length} 个项目
`; const table = ` ${shown.map((p) => ` `).join("")}
项目名称分类负责人状态 进度%本${state.gran === "week" ? "周" : "月"}更新 记录子任务最近记录操作
${starMark(p.is_key, true, "重点标记在详情面板中维护")} ${esc(p.name)} ${p.archived ? `已归档` : ""}
${catPill(p.category)}
${nameChips(p.owners)}
${statusPill(p.status)} ${Math.round(p.progress)}% ${p.updated_this_period ? `已更新` : `${p.idle_weeks === null ? "从未更新" : p.idle_weeks + " 周未更新"}`} ${p.update_count} ${p.task_count} ${esc((p.last_update && p.last_update.period_full) || "—")}
`; const body = listCard({ title: "重点项目明细", sub: "重点项目排在前面 · 列表只读,改数据请点「修改」", summary: `共 ${rows.length} 个 · 重点 ${keyCount} 个 · 本${state.gran === "week" ? "周" : "月"}未更新 ${idle} 个`, fields: ``, rows: shown, table: table, empty: state.keyword ? "没有匹配「" + esc(state.keyword) + "」的项目" : "还没有项目", }); host.innerHTML = toolbar + body; bindKeyword("pKeyword"); host.querySelectorAll("[data-pidle]").forEach((b) => b.addEventListener("click", () => { state.projIdleOnly = !state.projIdleOnly; renderProjects(); }) ); const po = $("#pOwner"); if (po) po.addEventListener("change", () => { state.projOwner = po.value; renderProjects(); }); host.querySelectorAll("[data-popen]").forEach((a) => a.addEventListener("click", () => openDrawer(Number(a.dataset.popen), "view")) ); host.querySelectorAll("[data-pedit]").forEach((b) => b.addEventListener("click", () => openDrawer(Number(b.dataset.pedit), "edit")) ); host.querySelectorAll("[data-parch]").forEach((b) => b.addEventListener("click", async () => { const toArchive = b.dataset.archived !== "1"; await patch("/projects/" + b.dataset.parch, { archived: toArchive }); toast(toArchive ? "已归档(勾选「含归档项目」可查看)" : "已恢复"); await afterWrite(); }) ); $("#chkArchived").addEventListener("change", (e) => { state.showArchived = e.target.checked; renderProjects(); }); $("#btnExportProjects").addEventListener("click", () => { const q = ["archived=" + (state.showArchived ? "all" : "false")]; if (state.categoryId) q.push("category_id=" + state.categoryId); if (state.keyword) q.push("keyword=" + encodeURIComponent(state.keyword)); download("/api/export/projects?" + q.join("&")); }); $("#btnNewProject").addEventListener("click", () => { openModal( ` ` ); $("#mOk").addEventListener("click", async () => { const name = $("#npName").value.trim(); if (!name) return modalError("请填写项目名称"); const btn = $("#mOk"); btn.disabled = true; try { await post("/projects", { name: name, category_id: $("#npCat").value ? Number($("#npCat").value) : null, owner: $("#npOwner").value || null, is_key: $("#npKey").checked, }); closeModal(); toast("已创建「" + name + "」"); await afterWrite(); } catch (e) { btn.disabled = false; modalError(e.message); } }); $("#npName").addEventListener("keydown", (e) => { if (e.key === "Enter") $("#mOk").click(); }); }); } /* ---------------------------------------------------------------- 定制交付 */ function stageMeta(code) { const list = (state.customMeta && state.customMeta.stages) || []; return list.find((s) => s.code === code) || { code, name: code, color: "#94a3b8" }; } function stagePill(code) { const s = stageMeta(code); return `${Ico.stage(code)}${esc(s.name)}`; } /** 定制交付视图的查询条件:周期 + 项目名称检索 + 区域(+ 归档态) */ function customQuery(extra) { const p = []; if (state.gran === "week") { if (state.weekId) p.push("period_id=" + state.weekId); } else { const m = currentMonth(); if (m) p.push("year=" + m.year, "month=" + m.month); } if (state.keyword) p.push("keyword=" + encodeURIComponent(state.keyword)); if (state.customRegion) p.push("region=" + encodeURIComponent(state.customRegion)); (extra || []).forEach((x) => p.push(x)); return "?" + p.join("&"); } let keywordFocus = false; async function renderCustom() { saveUI(); const host = $("#view-custom"); host.innerHTML = `
加载中…
`; if (!state.customMeta) state.customMeta = await api("/custom/meta"); const [board, stats] = await Promise.all([ api("/custom/board" + customQuery()), api("/custom/stats" + customQuery(["archived=" + state.customArchived])), ]); state.customBoard = board; state.customStats = stats; const k = stats.kpi; // 项目明细 / 交付流水线 / 财务跟踪 都基于 /custom/projects: // 支持归档态筛选,并按入库时间倒序 const needRows = ["list", "pipeline", "finance"].indexOf(state.customTab) >= 0; state.customRows = needRows ? await api("/custom/projects" + customQuery(["archived=" + state.customArchived])) : []; // 区域筛选:用规范名称、按大区分组,只列出实际有项目的区域 const usedSet = {}; (state.customMeta.regions || []).forEach((r) => { usedSet[r] = true; }); const stdAll = (state.customMeta.region_groups || []).reduce((a, g) => a.concat(g.regions), []); let regionFilterHtml = ``; (state.customMeta.region_groups || []).forEach((g) => { const hit = g.regions.filter((r) => usedSet[r]); if (!hit.length) return; regionFilterHtml += `` + hit.map((r) => ``).join("") + ``; }); const nonStd = (state.customMeta.regions || []).filter((r) => stdAll.indexOf(r) < 0); if (nonStd.length) { regionFilterHtml += `` + nonStd.map((r) => ``).join("") + ``; } const tabs = `
${keywordInputHtml("cKeyword", "搜索项目名称…", 200)} ${esc(scopeLabel())} · ${k.project_count} 个项目
`; let body = ""; if (state.customTab === "pipeline") body = renderCustomPipeline(state.customRows, k); else if (state.customTab === "board") body = renderCustomBoard(board); else if (state.customTab === "finance") body = renderCustomFinance(stats, state.customRows); else body = renderCustomList(state.customRows); host.innerHTML = tabs + body; host.querySelectorAll("[data-ctab]").forEach((b) => b.addEventListener("click", () => { state.customTab = b.dataset.ctab; renderCustom(); }) ); const rs = $("#cRegion"); if (rs) rs.addEventListener("change", () => { state.customRegion = rs.value; renderCustom(); }); bindKeyword("cKeyword"); const bn = $("#btnNewCustom"); if (bn) bn.addEventListener("click", () => openNewCustomDialog(() => afterWrite())); const bex = $("#btnExportCustom"); if (bex) bex.addEventListener("click", () => { const q = ["archived=" + state.customArchived]; if (state.customRegion) q.push("region=" + encodeURIComponent(state.customRegion)); if (state.keyword) q.push("keyword=" + encodeURIComponent(state.keyword)); download("/api/export/custom" + (q.length ? "?" + q.join("&") : "")); }); // 卡片只是入口:重点标记等基础信息一律进详情面板改,外层不再直接写数据 host.querySelectorAll("[data-cpid]").forEach((c) => c.addEventListener("click", () => openCustomDrawer(Number(c.dataset.cpid))) ); bindCustomList(host); } function renderCustomBoard(board) { const cols = board.columns.filter((c) => c.count > 0 || c.code !== "closed"); return `
${cols.map((c) => `
${Ico.stage(c.code)}${esc(c.name)}
${c.count} 个 · ${fen(c.amount)} 万${c.updated_count ? ` · 本期 ${c.updated_count}` : ""}
${c.projects.length ? c.projects.map((p) => `
${esc(p.name)}
${starMark(p.is_key, true, "重点标记在详情面板中维护")}
${esc(p.region || "未标注")} ${p.amount ? fen(p.amount) + " 万" : "金额待定"}
${p.stage === "developing" ? `
开发进度${p.progress}%
` : ""}
可确认 ${fen(p.confirmed_amount)} 万 已计收 ${fen(p.revenue_amount || 0)} 万
${p.remark ? `
${esc(p.remark.slice(0, 60))}
` : ""}
`).join("") : `
—
`}
`).join("")}
`; } /* 商务阶段 -> 已走完的环节下标。导入的老数据只有阶段没有环节日期,用它兜底 */ const STAGE_STEP = { lead: 0, signed: 1, developing: 1, delivered: 2, accepted: 3, revenue: 4 }; /** 按已填的环节日期取最后一环(与后端 custom_current_step 口径一致) */ function customDateIndex(p) { const steps = (state.customMeta && state.customMeta.pipeline) || []; let step = -1; steps.forEach((s, i) => { if ((p[s.date_field] || "").trim()) step = i; }); return step; } /** 项目当前走到哪一环:环节日期与商务阶段取更靠后的那个 */ function customStepIndex(p) { const byStage = STAGE_STEP[p.stage]; return Math.max(byStage === undefined ? -1 : byStage, customDateIndex(p)); } function daysSince(iso) { if (!iso) return null; const t = Date.parse(iso + "T00:00:00"); return isNaN(t) ? null : Math.floor((Date.now() - t) / 86400000); } /** 交付流水线:入库 → 签单 → 交付 → 验收 → 计收 的漏斗与卡点 */ function renderCustomPipeline(rows, kpi) { const steps = (state.customMeta && state.customMeta.pipeline) || []; const agingDays = (state.customMeta && state.customMeta.aging_days) || 45; const total = rows.length; const funnel = steps.map((s, i) => { const hit = rows.filter((p) => customStepIndex(p) >= i); return { key: s.code, name: s.name, color: s.color, count: hit.length, amount: hit.reduce((a, p) => a + (p.amount || 0), 0), }; }); const maxCount = Math.max(1, ...funnel.map((f) => f.count)); // 卡住的项目:还没走到计收,且最后一个有日期的环节停留超过 agingDays 天 const stuck = []; rows.forEach((p) => { const i = customStepIndex(p); if (i < 0 || i >= steps.length - 1) return; const di = customDateIndex(p); if (di < 0) return; const days = daysSince(p[steps[di].date_field]); if (days === null || days < agingDays) return; stuck.push({ p: p, step: steps[i], from: p[steps[di].date_field], days: days }); }); stuck.sort((a, b) => b.days - a.days); const noDate = rows.filter((p) => customDateIndex(p) < 0).length; return `

交付漏斗在库 ${total} 个 · 重点 ${kpi.key_count} 个 · 本期更新 ${kpi.period_updated} 个

金额与项目数按环节累计 · 商务阶段与已录环节日期取先到哪一步
${funnel.map((f) => `
${Ico.pipe(f.key)}${esc(f.name)}
${f.count}
${fen(f.amount)} 万
`).join("")}
${noDate ? `
还有 ${noDate} 个项目一个环节日期都没有,去「项目明细」补上入库时间后漏斗才准。
` : ""}

卡点提醒最后一个有日期的环节停留超过 ${agingDays} 天

${stuck.length} 个项目需要推进
${stuck.length ? `
${stuck.slice(0, 20).map((x) => ` `).join("")}
项目名称卡在环节日期停留天数合同额负责人操作
${esc(x.p.name)} ${esc(x.step.name)} ${esc(x.p[x.step.date_field] || x.from || "—")} ${x.days} 天 ${fen(x.p.amount)} 万 ${esc(x.p.owner || "未指派")}
` : `
没有停滞超期的项目
`}
`; } /** 财务跟踪:按负责人 / 区域汇总,并列出待回款排行 */ function renderCustomFinance(stats, rows) { const byOwner = {}; rows.forEach((p) => { const key = p.owner || "(未指派)"; const o = byOwner[key] || (byOwner[key] = { name: key, count: 0, amount: 0, confirmed: 0, revenue: 0, invoice: 0, received: 0 }); o.count += 1; o.amount += p.amount || 0; o.confirmed += p.confirmed_amount || 0; o.revenue += p.revenue_amount || 0; o.invoice += p.invoice_amount || 0; o.received += p.received_amount || 0; }); const owners = Object.keys(byOwner).map((k) => byOwner[k]).sort((a, b) => b.amount - a.amount); const unpaid = rows.filter((p) => (p.amount || 0) > 0) .map((p) => ({ p: p, pend: (p.amount || 0) - (p.received_amount || 0) })) .sort((a, b) => b.pend - a.pend); const num = (n) => fen(n); const maxAmt = Math.max(1, ...stats.by_stage.map((x) => x.amount || 0)); const k = stats.kpi; return `
${Ico.badge("wallet")}
合同总额
${wan(k.amount_total)}
${k.project_count} 个项目 · 重点 ${k.key_count} 个
${Ico.badge("receipt")}
按进度可确认收入
${wan(k.confirmed_total)}
确认比例 ${k.progress_rate}%
${Ico.badge("wallet")}
已计收
${wan(k.revenue_total)}
计收率 ${k.revenue_rate}%
${Ico.badge("hourglass")}
待计收
${wan(k.pending_total)}
可确认 − 已计收
${Ico.badge("receipt")}
已开票
${wan(k.invoice_total)}
开票率 ${k.amount_total ? ((k.invoice_total / k.amount_total) * 100).toFixed(1) : 0}%
${Ico.badge("trending-up")}
已回款
${wan(k.received_total)}
回款率 ${k.received_rate}%
${Ico.badge("receipt")}
待开票
${wan(k.pending_invoice)}
合同额 − 已开票
${Ico.badge("hourglass")}
待回款
${wan(k.pending_received)}
合同额 − 已回款

阶段金额分布合同额与按进度可确认收入

${stats.by_stage.map((x) => `
${esc(x.name)} ${x.count} 个
${num(x.amount)}
`).join("")}

周期记录活跃度各周期有记录的项目数

${barChart(stats.trend.map((t) => ({ label: `${t.month}/${t.week || ""}`, value: t.update_count, color: scopePeriodIds().indexOf(t.period_id) >= 0 ? "#7c3aed" : "#c4b5fd", dim: scopePeriodIds().indexOf(t.period_id) < 0, })), { unit: " 条", height: 170 })}

按负责人合同额 / 计收 / 开票 / 回款

${owners.map((o) => ``).join("")}
负责人项目合同额可确认已计收已开票已回款
${esc(o.name)}${o.count} ${num(o.amount)}${num(o.confirmed)} ${num(o.revenue)}${num(o.invoice)} ${num(o.received)}

按区域合同额与可确认收入

${stats.by_region.slice(0, 14).map((r) => ``).join("")}
区域项目合同额可确认已计收
${esc(r.name)}${r.count} ${num(r.amount)}${num(r.confirmed)} ${num(r.revenue)}

待回款排行合同额 − 已回款 · 共 ${unpaid.length} 个有合同额的项目

${unpaid.length ? `
${unpaid.slice(0, 20).map((x) => ` `).join("")}
项目名称区域负责人验收 合同额开票回款待回款操作
${esc(x.p.name)} ${esc(x.p.region || "—")} ${esc(x.p.owner || "未指派")} ${esc(x.p.accept_date || "未验收")} ${num(x.p.amount)} ${num(x.p.invoice_amount)} ${num(x.p.received_amount)} ${num(x.pend)}
` : `
暂无合同额数据
`}
`; } function renderCustomList(rows) { const archOpts = [ ["false", "在库"], ["true", "已归档"], ["all", "全部"], ] .map((x) => ``) .join(""); const sum = (f) => fen(rows.reduce((a, p) => a + Number(p[f] || 0), 0)); return `

定制项目明细按入库时间倒序 · 共 ${rows.length} 个 · 列表只读,改数据请点「修改」

合同额 ${sum("amount")} 万 · 可确认 ${sum("confirmed_amount")} 万 · 已计收 ${sum("revenue_amount")} 万 · 开票 ${sum("invoice_amount")} 万 · 回款 ${sum("received_amount")} 万
${rows.length ? `
${rows.map((p) => ` ${["entry_date", "sign_date", "deliver_date", "accept_date"].map((f) => ``).join("")} `).join("")}
项目名称区域负责人 入库签单交付验收 阶段合同额/万进度% 可确认/万已计收/万 开票/万回款/万 备注操作
${starMark(p.is_key, true, "重点标记在详情面板中维护")} ${esc(p.name)} ${p.archived ? `已归档` : ""}
${esc(p.region || "—")} ${esc(p.owner || "—")}${p[f] ? esc(p[f]) : "—"}${stagePill(p.stage)} ${fen(p.amount)} ${Math.round(p.progress)}% ${fen(p.confirmed_amount)} ${fen(p.revenue_amount)} ${fen(p.invoice_amount)} ${fen(p.received_amount)} ${esc((p.remark || "").slice(0, 40)) || "—"}
` : `
${state.keyword ? "没有匹配「" + esc(state.keyword) + "」的定制项目" : "当前筛选条件下没有定制项目"}
`}
`; } /** 项目明细表:列表只读,只有详情 / 修改 / 归档三个动作 */ function bindCustomList(host) { const ca = $("#cArchived"); if (ca) ca.addEventListener("change", () => { state.customArchived = ca.value; renderCustom(); }); host.querySelectorAll("[data-copen]").forEach((a) => a.addEventListener("click", () => openCustomDrawer(Number(a.dataset.copen), "view")) ); host.querySelectorAll("[data-cedit]").forEach((b) => b.addEventListener("click", () => openCustomDrawer(Number(b.dataset.cedit), "edit")) ); host.querySelectorAll("[data-carch]").forEach((b) => b.addEventListener("click", () => { const p = (state.customRows || []).find((x) => x.id === Number(b.dataset.carch)); if (p) archiveCustomProject(p, () => afterWrite()); }) ); } /** * 新建定制项目 —— 全站唯一的新建入口。 * 「定制交付」工具栏和「更新录入」页都调用它,避免两套录入逻辑。 */ function openNewCustomDialog(onDone) { const today = new Date(); const iso = today.getFullYear() + "-" + String(today.getMonth() + 1).padStart(2, "0") + "-" + String(today.getDate()).padStart(2, "0"); const stageOpts = ((state.customMeta && state.customMeta.stages) || []) .map((s) => ``) .join(""); openModal( ` ` ); const submit = async () => { const name = $("#ncName").value.trim(); if (!name) return modalError("请填写项目名称"); const btn = $("#mOk"); btn.disabled = true; try { const p = await post("/custom/projects", { name: name, region: $("#ncRegion").value || null, customer: $("#ncCustomer").value.trim() || null, amount: $("#ncAmount").value === "" ? null : Number($("#ncAmount").value), stage: $("#ncStage").value, owner: $("#ncOwner").value || null, entry_date: $("#ncEntry").value || null, is_key: $("#ncKey").checked, remark: $("#ncRemark").value.trim() || null, }); closeModal(); toast("已创建「" + p.name + "」"); if (onDone) onDone(p); else refresh(); } catch (e) { btn.disabled = false; modalError(e.message); } }; $("#mOk").addEventListener("click", submit); $("#ncName").addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); }); } /** 归档 / 恢复:归档后不进看板与统计,随时可恢复 */ async function archiveCustomProject(p, onDone) { const toArchive = !p.archived; const ok = await confirmDialog({ title: toArchive ? "归档定制项目" : "恢复定制项目", okText: toArchive ? "确认归档" : "确认恢复", body: `

${esc(p.name)}

` + `
${ toArchive ? "归档后该项目不再出现在阶段看板、收入统计和默认列表中,历史周期记录完整保留。" : "恢复后重新计入看板与统计。" }
`, }); if (!ok) return; try { await patch("/custom/projects/" + p.id, { archived: toArchive }); toast(toArchive ? "已归档" : "已恢复"); if (onDone) onDone(); else refresh(); } catch (e) { toast("操作失败:" + e.message); } } /** 删除:连带周期记录一起清除,需逐字输入项目名确认 */ async function deleteCustomProject(p, onDone) { const ok = await confirmDialog({ title: "删除定制项目", danger: true, okText: "永久删除", requireText: p.name, body: `
将连同该项目 ${p.update_count || 0} 条周期记录一并删除,删除后无法恢复。
只是暂时不想在看板里看到它?请改用「归档」,数据全部保留。
`, }); if (!ok) return; try { const r = await del("/custom/projects/" + p.id); toast("已删除「" + r.name + "」及其 " + r.deleted_updates + " 条周期记录"); if (onDone) onDone(); else refresh(); } catch (e) { toast("删除失败:" + e.message); } } /** cid 定制项目抽屉;mode = "view" 只读详情(默认) / "edit" 编辑基础信息 */ async function openCustomDrawer(cid, mode) { mode = mode === "edit" ? "edit" : "view"; $("#drawerMask").classList.add("open"); $("#drawer").classList.add("open"); $("#drawerBody").innerHTML = `
加载中…
`; drawerActions(""); const d = await api("/custom/projects/" + cid); state.drawerCustom = d; const p = d.project; $("#drawerTitle").innerHTML = `${p.is_key ? starMark(true) : ""}${esc(p.name)}`; $("#drawerSub").innerHTML = `${stagePill(p.stage)} · ${esc(p.region || "未标注区域")} · 合同额 ${money(p.amount)}`; const stageOpts = state.customMeta.stages .map((s) => ``) .join(""); const timeline = d.timeline.slice().reverse().map((t) => `
${esc(t.period.full_label.replace(/^\d+年/, ""))}
${stagePill(t.stage)}${t.progress !== null ? ` ${t.progress}%` : ""} ${t.revenue_delta ? ` 计收 ${fen(t.revenue_delta)}万` : ""}
${nl2br(t.content || "")}
`).join(""); // 「录一笔交付记录」落到顶栏当前选中的那一周,与更新录入页的周期下拉一致 const recWeek = (state.periods.weeks || []).find((x) => x.id === state.weekId); const recLabel = recWeek ? recWeek.full_label : "本周"; const ratioLabel = p.manual_ratio !== null && p.manual_ratio !== undefined ? `手动 ${(p.manual_ratio * 100).toFixed(0)}%` : `按阶段 ${p.revenue_ratio}%`; const ro = (label, val) => `
${val}
`; // 基础信息:详情只读、修改才给表单,操作条只出现在自己那一档里 const baseSec = mode === "edit" ? `
基础信息编辑
只改基础信息与财务数字,不会动下面的交付记录
%
% 当前 ${ratioLabel}
${pipelineBar(p)}
入库时间默认取该项目最早记录所在周期的第一天(周一),可手工修正;补上后续环节日期会自动推进阶段。
${ownerSelectHtml(p.owner, 'id="cdOwner" style="width:150px"')}
${peopleEditorHtml("cdMembers", "团队成员", p.members, { add: "成员" })}
` : `
基础信息只读
${ro("项目名称", esc(p.name) + (p.archived ? ` 已归档` : ""))} ${ro("阶段", stagePill(p.stage))} ${ro("合同额", money(p.amount))} ${ro("开发进度", Math.round(p.progress) + "%")} ${ro("确认比例", `${esc(ratioLabel)} · 可确认 ${money(p.confirmed_amount)}`)} ${ro("交付环节", `
${pipelineBar(p, true)}
`)} ${ro("区域", esc(p.region || "—"))} ${ro("交付负责人", esc(p.owner || "—"))} ${ro("团队成员", nameChips(p.members))} ${ro("客户", esc(p.customer || "—"))} ${ro("备注", p.remark ? nl2br(p.remark) : "—")}
`; drawerActions(mode === "edit" ? ` ` : ` `); $("#drawerBody").innerHTML = ` ${mode === "edit" ? `` : ""} ${baseSec}
收入确认${mode === "edit" ? "开票与回款可编辑" : "计算值 · 只读"}
${Ico.badge("wallet")}
合同额
${wan(p.amount)}
${Ico.badge("receipt")}
可确认收入
${wan(p.confirmed_amount)}
比例 ${p.revenue_ratio}%
${Ico.badge("wallet")}
已计收
${wan(p.revenue_amount)}
${Ico.badge("hourglass")}
待计收
${wan(p.pending_amount)}
${Ico.badge("receipt")}
已开票
${wan(p.invoice_amount)}
待开票 ${wan(p.pending_invoice)}
${Ico.badge("trending-up")}
已回款
${wan(p.received_amount)}
待回款 ${wan(p.pending_received)}
${mode === "edit" ? `
开票与回款 · 编辑 随标题栏「保存」一并提交
` : ""}
周期执行记录(${d.updates.length})附表 · 只读
附表 · 更新记录 到「更新录入 · 定制交付」写入
${d.updates.length ? d.updates.map((u) => `
${esc(u.period_full || "")}${stagePill(u.stage)}${u.progress !== null ? `${u.progress}%` : ""}
${nl2br(u.content || u.summary || "")}
${u.risk ? `
${Ico.svg("alert-circle")}
${nl2br(u.risk)}
` : ""} ${u.next_step ? `
${Ico.svg("arrow-right")}
${nl2br(u.next_step)}
` : ""}
`).join("") : `
暂无记录
`}
${d.timeline.length ? `
阶段流转附表 · 只读
${timeline}
` : ""}`; const on = (sel, fn) => { const el = $(sel); if (el) el.addEventListener("click", fn); }; on("#cdEdit", () => openCustomDrawer(cid, "edit")); on("#cdAddUpd", () => { state.intakeType = "custom"; state.intakePeriodId = state.weekId; customIntakeId = cid; closeDrawer(); setView("intake"); }); on("#cdCancel", () => openCustomDrawer(cid, "view")); on("#cdSave", async () => { const btn = $("#cdSave"); const err = $("#cdErr"); const ratioRaw = $("#cdRatio").value.trim(); btn.disabled = true; err.style.display = "none"; try { await patch("/custom/projects/" + cid, { name: $("#cdName").value.trim(), stage: $("#cdStage").value, amount: $("#cdAmount").value === "" ? null : Number($("#cdAmount").value), progress: Number($("#cdProgress").value), revenue_amount: $("#cdRevenue").value === "" ? null : Number($("#cdRevenue").value), manual_ratio: ratioRaw === "" ? null : Number(ratioRaw) / 100, entry_date: $('#drawerBody [data-cf="entry_date"]').value || null, sign_date: $('#drawerBody [data-cf="sign_date"]').value || null, deliver_date: $('#drawerBody [data-cf="deliver_date"]').value || null, accept_date: $('#drawerBody [data-cf="accept_date"]').value || null, revenue_date: $('#drawerBody [data-cf="revenue_date"]').value || null, invoice_amount: $("#cdInvoice").value === "" ? null : Number($("#cdInvoice").value), received_amount: $("#cdReceived").value === "" ? null : Number($("#cdReceived").value), region: $("#cdRegion").value, owner: $("#cdOwner").value || null, members: peopleValues("cdMembers"), customer: $("#cdCustomer").value, remark: $("#cdRemark").value, }); } catch (e) { btn.disabled = false; err.textContent = "保存失败:" + e.message; err.style.display = ""; return; } toast("已保存"); await afterWrite(); openCustomDrawer(cid, "view"); }); on("#cdKey", async () => { await patch("/custom/projects/" + cid, { is_key: !p.is_key }); await afterWrite(); openCustomDrawer(cid, mode); }); on("#cdArchive", () => archiveCustomProject(p, () => { closeDrawer(); afterWrite(); })); on("#cdDelete", () => deleteCustomProject(p, () => { closeDrawer(); afterWrite(); })); } /* ------------------------------------------------ 录入方式(两类项目共用) */ /** 录入方式分段控件:直接写入(第一位)/ AI 整理 */ function intakeModeSegHtml() { const mode = intakeMode(); return `
录入方式
${mode === "ai" ? "AI 补出摘要 / 进度 / 状态 / 风险 / 下一步,正文保持你的原文,最终只写 1 条" : "填进去什么就存什么,不拆解、不改写、不动项目进度"}
`; } function intakeMode() { return state.intakeMode === "ai" ? "ai" : "direct"; } /** 切换录入方式:保留已填的原文,重画当前录入页 */ function bindIntakeMode(rawSel, rerender) { document.querySelectorAll("#intakeModeSeg [data-imode]").forEach((b) => b.addEventListener("click", () => { if (intakeMode() === b.dataset.imode) return; const keep = $(rawSel) ? $(rawSel).value : ""; state.intakeMode = b.dataset.imode; Promise.resolve(rerender()).then(() => { const el = $(rawSel); if (el) el.value = keep; }); }) ); } /* ------------------------------------------------ 定制项目录入 */ async function renderCustomIntake(host) { const projects = await api("/custom/projects"); const weeks = state.periods.weeks.slice().reverse(); const curPeriodId = state.gran === "week" ? state.weekId : currentMonth() ? currentMonth().period_ids[currentMonth().period_ids.length - 1] : null; host.innerHTML = `
${intakeModeSegHtml()}

定制项目更新录入${intakeMode() === "ai" ? "给已有定制项目记一期:粘贴原文 → AI 整理成 1 条(含阶段 / 金额 / 计收)→ 确认写入" : "给已有定制项目记一期:内容原样存成 1 条,不拆解、不改写"}

1选择定制项目与周期
项目按入库时间倒序排列。列表里没有要找的项目?点标题右侧「+ 新建定制项目」先建档,建好会自动选中回到这里。
2粘贴该项目的原始内容
${intakeMode() === "ai" ? `
` : `
`}
`; const sel = $("#cInProject"); host.querySelectorAll("[data-itype]").forEach((b) => b.addEventListener("click", () => { state.intakeType = b.dataset.itype; renderIntake(); }) ); sel.addEventListener("change", () => { customIntakeId = Number(sel.value); }); $("#btnNewCustomIntake").addEventListener("click", () => openNewCustomDialog((p) => { customIntakeId = p.id; renderCustomIntake(host); }) ); $("#cInPeriod").addEventListener("change", async (e) => { if (e.target.value === "__new") { await createNewPeriod(); await renderIntake(); return; } state.intakePeriodId = Number(e.target.value); }); $("#cInSearch").addEventListener("input", (e) => { const kw = e.target.value.trim(); sel.innerHTML = projects.filter((p) => !kw || p.name.includes(kw)) .map((p) => ``).join(""); }); bindIntakeMode("#cInRaw", () => renderCustomIntake(host)); const on = (sel2, fn) => { const el = $(sel2); if (el) el.addEventListener("click", fn); }; on("#btnCClear", () => { $("#cInRaw").value = ""; $("#cInPreview").innerHTML = ""; $("#cInMode").textContent = ""; }); on("#btnCParse", () => doCustomParse(true)); on("#btnCRule", () => doCustomParse(false)); on("#btnCDirect", commitCustomDirect); } /** 定制交付 · 直接写入:内容一字不改存成 1 条执行记录,阶段 / 金额 / 计收一律不动 */ async function commitCustomDirect() { const raw = $("#cInRaw").value.trim(); if (!raw) return toast("请先填写内容"); const id = Number($("#cInProject").value); if (!id) return toast("请选择定制项目"); const pid = Number($("#cInPeriod").value); if (!pid) return toast("请选择周期"); const btn = $("#btnCDirect"); btn.disabled = true; try { await post("/custom/updates", { custom_project_id: id, period_id: pid, content: raw, source: "manual", }); $("#cInRaw").value = ""; $("#cInPreview").innerHTML = `
写入成功:已按原文存入 1 条执行记录,未拆解、未改写。
`; toast("已写入 1 条执行记录"); await afterWrite(); } catch (e) { toast("写入失败:" + e.message); } finally { btn.disabled = false; } } let customIntakeId = null; let customIntakeParsed = null; let customIntakeRaw = ""; async function doCustomParse(useAI) { const raw = $("#cInRaw").value.trim(); if (!raw) return toast("请先粘贴内容"); const sel = $("#cInProject"); const id = Number(sel.value); if (!id) return toast("请选择定制项目"); const btn = useAI ? $("#btnCParse") : $("#btnCRule"); const old = btn.innerHTML; btn.disabled = true; btn.innerHTML = ` 整理中…`; try { const r = await post("/custom/ai/parse", { custom_project_id: id, project_name: name, period_id: Number($("#cInPeriod").value), raw_text: raw, use_ai: useAI, }); customIntakeId = r.project ? r.project.id : customIntakeId; customIntakeParsed = r.parsed; customIntakeRaw = raw; renderCustomPreview(r); } catch (e) { $("#cInPreview").innerHTML = `
整理失败:${esc(e.message)}
`; } finally { btn.disabled = false; btn.innerHTML = old; } } function renderCustomPreview(r) { const p = r.parsed; const stageOpts = state.customMeta.stages .map((s) => ``) .join(""); $("#cInMode").textContent = r.mode === "ai" ? `已由 ${r.parsed.model || "大模型"} 整理` : "本地规则整理"; $("#cInPreview").innerHTML = ` ${r.warning ? `
${esc(r.warning)}
` : ""} ${r.error ? `
AI 调用异常:${esc(r.error)}
` : ""}
整理结果 · 1 条记录(可直接修改后写入)
${esc(r.project ? r.project.name : "(新建)")} · ${esc(r.period ? r.period.full_label : "")}
正文用你的原文,AI 只补上面这些字段;写入后阶段、金额、计收同步更新到「定制交付」
`; $("#btnCCommit").addEventListener("click", commitCustomIntake); } function collectCustomPreview() { const num = (id) => { const v = $(id).value.trim(); return v === "" ? null : Number(v); }; const lines = (id) => $(id).value.split("\n").map((s) => s.trim()).filter(Boolean); return { stage: $("#cPvStage").value, progress: num("#cPvProgress"), amount: num("#cPvAmount"), revenue_amount: num("#cPvRevenue"), revenue_delta: num("#cPvDelta"), summary: $("#cPvSummary").value.trim(), risks: lines("#cPvRisks"), next_steps: lines("#cPvNext"), updates: [], // AI 整理只落 1 条:正文走 raw_text }; } async function commitCustomIntake() { const sel = $("#cInProject"); const btn = $("#btnCCommit"); btn.disabled = true; try { const res = await post("/custom/ai/commit", { custom_project_id: Number(sel.value), project_name: null, period_id: Number($("#cInPeriod").value), raw_text: ($("#cPvContent").value || "").trim() || customIntakeRaw, parsed: collectCustomPreview(), mode: customIntakeParsed && customIntakeParsed.model ? "ai" : "rule", model: (customIntakeParsed && customIntakeParsed.model) || null, }); toast(`已写入 ${res.created.length} 条记录`); $("#cInPreview").innerHTML = `
写入成功:「${esc(res.project.name)}」阶段 ${esc(stageMeta(res.project.stage).name)},可确认收入 ${wan(res.project.confirmed_amount)}。
`; await afterWrite(); } catch (e) { toast("写入失败:" + e.message); } finally { btn.disabled = false; } } /* ---------------------------------------------------------------- 更新录入 */ let intakeProjectId = null; let intakeParsed = null; async function renderIntake() { const host = $("#view-intake"); if (!state.customMeta) state.customMeta = await api("/custom/meta"); if (state.intakeType === "custom") return renderCustomIntake(host); const projects = await api("/projects?archived=false"); const weeks = state.periods.weeks.slice().reverse(); const curPeriodId = state.gran === "week" ? state.weekId : (currentMonth() ? currentMonth().period_ids[currentMonth().period_ids.length - 1] : null); const mode = intakeMode(); const actionRow = mode === "ai" ? `
` : `
`; host.innerHTML = `
${intakeModeSegHtml()}

更新录入${mode === "ai" ? "选择项目 → 粘贴原始内容 → AI 整理 → 确认写入" : "选择项目 → 填写内容 → 原样写入 1 条"}

1选择项目与周期
2粘贴该项目的原始内容
${actionRow}
`; host.querySelectorAll("[data-itype]").forEach((b) => b.addEventListener("click", () => { state.intakeType = b.dataset.itype; renderIntake(); }) ); bindIntakeMode("#intakeRaw", renderIntake); const sel = $("#intakeProject"); sel.addEventListener("change", () => { $("#intakeNewProject").style.display = sel.value === "__new" ? "" : "none"; }); $("#intakePeriod").addEventListener("change", async (e) => { if (e.target.value === "__new") { await createNewPeriod(); await renderIntake(); return; } state.intakePeriodId = Number(e.target.value); }); $("#intakeProjectSearch").addEventListener("input", (e) => { const kw = e.target.value.trim(); sel.innerHTML = projects.filter((p) => !kw || p.name.includes(kw)) .map((p) => ``).join("") + ``; }); $("#btnClearIntake").addEventListener("click", () => { $("#intakeRaw").value = ""; $("#intakePreview").innerHTML = ""; $("#intakeMode").textContent = ""; }); const on = (sel, fn) => { const el = $(sel); if (el) el.addEventListener("click", fn); }; on("#btnAIParse", () => doParse(true)); on("#btnRuleParse", () => doParse(false)); on("#btnDirectWrite", commitDirectWrite); } /** * 重点项目 · 直接写入:填入的内容一字不改存成 1 条执行记录。 * 不调大模型、不建子任务、不写 progress,项目的进度和状态一律不动。 */ async function commitDirectWrite() { const raw = $("#intakeRaw").value.trim(); if (!raw) return toast("请先填写内容"); const sel = $("#intakeProject"); const btn = $("#btnDirectWrite"); let projectId = sel.value === "__new" ? null : Number(sel.value); btn.disabled = true; try { if (!projectId) { const name = $("#intakeNewProject").value.trim(); if (!name) { toast("请填写项目名称"); return; } const created = await post("/projects", { name }); projectId = created.id; intakeProjectId = created.id; sel.insertAdjacentHTML("beforeend", ``); $("#intakeNewProject").style.display = "none"; } await post("/updates", { project_id: projectId, period_id: Number($("#intakePeriod").value), content: raw, source: "manual", }); $("#intakeRaw").value = ""; $("#intakePreview").innerHTML = `
写入成功:已按原文存入 1 条执行记录,未拆解、未改写。
`; toast("已写入 1 条执行记录"); await afterWrite(); } catch (e) { toast("写入失败:" + e.message); } finally { btn.disabled = false; } } async function doParse(useAI) { const raw = $("#intakeRaw").value.trim(); if (!raw) return toast("请先粘贴内容"); const sel = $("#intakeProject"); const projectId = sel.value === "__new" ? null : Number(sel.value); const projectName = sel.value === "__new" ? $("#intakeNewProject").value.trim() : null; if (!projectId && !projectName) return toast("请选择或填写项目名称"); const btn = useAI ? $("#btnAIParse") : $("#btnRuleParse"); const old = btn.innerHTML; btn.disabled = true; btn.innerHTML = ` 整理中…`; try { const r = await post("/ai/parse", { project_id: projectId, project_name: projectName, period_id: Number($("#intakePeriod").value), raw_text: raw, use_ai: useAI, }); intakeProjectId = r.project ? r.project.id : intakeProjectId; intakeParsed = r.parsed; renderIntakePreview(r, raw); } catch (e) { $("#intakePreview").innerHTML = `
整理失败:${esc(e.message)}
`; } finally { btn.disabled = false; btn.innerHTML = old; } } function renderIntakePreview(r, raw) { const p = r.parsed; const statusOpts = state.meta.statuses.map((s) => ``).join(""); $("#intakeMode").textContent = r.mode === "ai" ? `已由 ${r.parsed.model || "大模型"} 整理` : "本地规则整理"; $("#intakePreview").innerHTML = ` ${r.warning ? `
${esc(r.warning)}
` : ""} ${r.error ? `
AI 调用异常:${esc(r.error)}
` : ""}
整理结果 · 1 条记录(可直接修改后写入)
${esc(r.project ? r.project.name : "")} · ${esc(r.period ? r.period.full_label : "")}
正文用你的原文,AI 只补摘要 / 进度 / 状态 / 风险 / 下一步;写入后可在「周期看板」中立即查看
`; $("#btnCommit").addEventListener("click", () => commitIntake(r, raw)); } /** 收集预览表单。tasks 恒为空 → 后端按「1 条执行记录」写入,不建子任务 */ function collectPreview() { const lines = (id) => $(id).value.split("\n").map((s) => s.trim()).filter(Boolean); const pg = $("#pvProgress").value; return { summary: $("#pvSummary").value.trim(), progress: pg === "" ? null : Number(pg), status: $("#pvStatus").value, risks: lines("#pvRisks"), next_steps: lines("#pvNext"), tasks: [], }; } async function commitIntake(r, raw) { const sel = $("#intakeProject"); const btn = $("#btnCommit"); btn.disabled = true; try { const res = await post("/ai/commit", { project_id: sel.value === "__new" ? null : Number(sel.value), project_name: sel.value === "__new" ? $("#intakeNewProject").value.trim() : null, period_id: Number($("#intakePeriod").value), raw_text: ($("#pvContent").value || "").trim() || raw, parsed: collectPreview(), mode: r.mode, model: r.parsed.model || null, }); toast(`已写入 ${res.created.length} 条执行记录`); $("#intakePreview").innerHTML = `
写入成功:项目「${esc(res.project.name)}」新增 ${res.created.length} 条记录。可切换到「周期看板」查看。
`; await afterWrite(); } catch (e) { toast("写入失败:" + e.message); } finally { btn.disabled = false; } } /* ---------------------------------------------------------------- settings */ async function renderSettings() { const host = $("#view-settings"); const [s, health, periods] = await Promise.all([ api("/settings"), fetch("/health").then((r) => r.json()), api("/periods"), ]); const weeks = periods.weeks.slice().reverse(); host.innerHTML = `

AI 整理配置OpenAI 兼容协议

${state.meta.ai.enabled ? `
已启用 AI 整理(模型:${esc(state.meta.ai.model || "—")})
` : `
未配置 API Key,当前使用本地规则解析。填写下方配置后自动切换到大模型整理。
`}
兼容:DeepSeek、通义千问(兼容模式)、Moonshot、智谱、OpenAI、本地 Ollama 等。
例:Ollama → Base URL http://localhost:11434/v1,模型 qwen2.5:14b,Key 任意填。

数据库

${esc(health.db)}
默认使用 SQLite(data/board.db),无需安装任何服务。

迁移到 MySQL:
1. 安装驱动:pip install pymysql cryptography
2. 在 .env 中设置:
DATABASE_URL=mysql+pymysql://user:pwd@host:3306/unis_board?charset=utf8mb4
3. 重启服务,表结构自动创建;再用 python scripts/import_excel.py --reset 重新灌数即可。

数据访问层全部走 SQLAlchemy,无原生 SQL,切换方言不需要改业务代码。

周期管理共 ${periods.weeks.length} 个周期 · 录入新周期数据前可先在这里新增

${weeks.map((w) => ` `).join("")}
周期常规记录定制记录
${esc(w.full_label)} (原标签:${esc(w.label)}) ${w.update_count || 0} ${w.custom_update_count || 0}
`; $("#btnAddPeriod").addEventListener("click", async () => { const id = await createNewPeriod(); if (id) renderSettings(); }); host.querySelectorAll("[data-delperiod]").forEach((b) => b.addEventListener("click", async () => { const id = Number(b.dataset.delperiod); const row = b.closest("tr"); const name = row.querySelector("b").textContent; if (!confirm(`确认删除周期「${name}」?`)) return; try { await del("/periods/" + id); toast("已删除周期 " + name); await loadPeriods(); renderSettings(); } catch (e) { toast("删除失败:" + e.message); } }) ); $("#btnSaveCfg").addEventListener("click", async () => { await put("/settings", { values: { ai_api_base: $("#cfgBase").value.trim(), ai_api_key: $("#cfgKey").value.trim(), ai_model: $("#cfgModel").value.trim(), ai_temperature: $("#cfgTemp").value.trim() || "0.2", }, }); state.meta = await api("/meta"); toast("配置已保存"); renderSettings(); }); } /* ---------------------------------------------------------------- drawer */ /** pid 详情抽屉;mode = "view" 只读详情(默认) / "edit" 编辑基础信息 */ async function openDrawer(pid, mode) { mode = mode === "edit" ? "edit" : "view"; $("#drawerMask").classList.add("open"); $("#drawer").classList.add("open"); $("#drawerBody").innerHTML = `
加载中…
`; drawerActions(""); const d = await api("/projects/" + pid); state.drawerProject = d; const p = d.project; $("#drawerTitle").innerHTML = `${p.is_key ? starMark(true) : ""}${esc(p.name)}`; $("#drawerSub").innerHTML = `${catPill(p.category)} ${statusPill(p.status)} · 进度 ${Math.round(p.progress)}% · ${p.update_count} 条执行记录 / ${p.task_count} 个子任务`; const ro = (label, val) => `
${val}
`; // 基础信息:详情只读、修改才给表单,两档各自一条操作条,别和下面的附表混在一起 const baseSec = mode === "edit" ? `
基础信息编辑
只改基础信息,不会动下面的周期记录
${peopleEditorHtml("dwOwners", "负责人", p.owners, { add: "负责人", // 界面上负责人不分主次,但存储里 owner = owners[0],统计口径照实说明 hint: "可多位。看板、筛选、导出与人力负载取名单里的第一位做归属统计。", })} ${peopleEditorHtml("dwMembers", "团队成员", p.members, { add: "成员" })}
%
` : `
基础信息只读
${ro("项目名称", esc(p.name) + (p.archived ? ` 已归档` : ""))} ${ro("分类", catPill(p.category))} ${ro("状态", statusPill(p.status))} ${ro("负责人", nameChips(p.owners))} ${ro("团队成员", nameChips(p.members))} ${ro("进度", Math.round(p.progress) + "%")} ${ro("标签", p.tags.length ? p.tags.map((t) => `${esc(t.name)}`).join(" ") : "—")} ${ro("记录", `${p.task_count} 个子任务 / ${p.update_count} 条执行记录`)}
`; // 基础信息的动作在标题栏右侧按钮条;附表的更新动作仍在它的列表上方,两类不混 drawerActions(mode === "edit" ? ` ` : ` `); const updItem = (u) => `
${esc(u.task_name || "整体进展")}${statusPill(u.status)}${u.progress !== null ? `${u.progress}%` : ""}
${nl2br(u.content || u.summary || "")}
${u.risk ? `
${Ico.svg("alert-circle")}
${nl2br(u.risk)}
` : ""} ${u.next_step ? `
${Ico.svg("arrow-right")}
${nl2br(u.next_step)}
` : ""}
`; // 把项目的 timeline 按「周」或「月」归成显示单元(timeline 已由后端按时间升序返回) function feedUnits() { if (state.dw.group === "week") { return d.timeline.map((t) => ({ label: t.period.full_label, count: t.updates.length, progress: t.progress, periods: [t], ids: [t.period.id], })); } const map = new Map(); d.timeline.forEach((t) => { const k = t.period.year + "-" + t.period.month; if (!map.has(k)) map.set(k, { label: t.period.year + "年" + t.period.month + "月", count: 0, progress: null, periods: [], ids: [] }); const u = map.get(k); u.periods.push(t); u.count += t.updates.length; u.ids.push(t.period.id); if (t.progress !== null) u.progress = u.progress === null ? t.progress : Math.max(u.progress, t.progress); }); return [...map.values()]; } const FEED_RANGES = { week: [[1, "本周"], [4, "近 4 周"], [12, "近 12 周"], [0, "全部"]], month: [[1, "本月"], [3, "近 3 月"], [6, "近 6 月"], [0, "全部"]], }; // 附表的写入走「更新录入」,和基础信息的保存彻底分成两条路 const recWeek = (state.periods.weeks || []).find((x) => x.id === state.weekId); const recLabel = recWeek ? recWeek.full_label : "本周"; const gotoIntake = () => { state.intakeType = "general"; state.intakePeriodId = state.weekId; intakeProjectId = pid; closeDrawer(); setView("intake"); }; // 范围以顶栏选中的周期为锚点,和看板其余部分保持一致 function renderFeed() { const host = $("#dwFeed"); const units = feedUnits(); const unitName = state.dw.group === "week" ? "周" : "个月"; /* 「7 月」会被读成七月,用「个月」 */ const scope = scopePeriodIds(); let anchor = -1; units.forEach((u, i) => { if (u.ids.some((id) => scope.indexOf(id) >= 0)) anchor = i; }); if (anchor < 0) anchor = units.length - 1; const n = state.dw.range; const step = n === 0 ? Math.max(1, anchor + 1) : n; const last = Math.max(0, anchor - step * state.dw.back); const first = n === 0 ? 0 : Math.max(0, last - n + 1); const win = units.slice(first, last + 1); const nUpd = win.reduce((a, u) => a + u.count, 0); const bar = `
查看范围(只读筛选)
${FEED_RANGES[state.dw.group].map(([v, l]) => ``).join("")}
显示 ${win.length} / ${units.length} ${unitName} · ${nUpd} 条${first > 0 ? ` · 更早还有 ${first} ${unitName}` : ""}
`; // win 是按升序取的窗口,展示时倒过来:新的在前 const body = win.length ? win.slice().reverse().map((u) => `
${esc(u.label)}${u.progress !== null ? `进度 ${u.progress}%` : ""}${u.count} 条
${u.periods.slice().reverse().map((t) => ` ${state.dw.group === "month" ? `
${esc(t.period.full_label.replace(/^\d+年/, ""))}${t.progress !== null ? " · 进度 " + t.progress + "%" : ""} ${statusPill(t.status)}
` : ""} ${t.updates.map(updItem).join("")}`).join("")}`).join("") : `
还没有执行记录
`; host.innerHTML = `
周期反馈附表 · 只读
附表 · 更新记录 到「更新录入」页粘贴原文写入
${bar}
${body}
`; host.querySelector("#dwAddUpd").addEventListener("click", gotoIntake); host.querySelectorAll("#dwGroup [data-g]").forEach((b) => b.addEventListener("click", () => { const g = b.dataset.g; if (state.dw.group === g) return; state.dw = { group: g, range: g === "week" ? 4 : 3, back: 0 }; renderFeed(); })); host.querySelectorAll(".feed-chips [data-r]").forEach((b) => b.addEventListener("click", () => { state.dw.range = Number(b.dataset.r); state.dw.back = 0; renderFeed(); })); host.querySelector("#dwOlder").addEventListener("click", () => { state.dw.back += 1; renderFeed(); }); host.querySelector("#dwNewer").addEventListener("click", () => { state.dw.back = Math.max(0, state.dw.back - 1); renderFeed(); }); } const timeline = d.timeline.slice().reverse().map((t) => `
${esc(t.period.full_label.replace(/^\d+年/, ""))}
${t.progress === null ? "—" : t.progress + "%"}
`).join(""); // 周期反馈单独渲染,切换范围/周期不必重新请求 state.dw = { group: "week", range: 4, back: 0 }; $("#drawerBody").innerHTML = (mode === "edit" ? `` : "") + baseSec + `
` + `
历史进度走势附表 · 只读
${timeline || '
暂无
'}
`; renderFeed(); const on = (sel, fn) => { const el = $(sel); if (el) el.addEventListener("click", fn); }; on("#dwEdit", () => openDrawer(pid, "edit")); on("#dwCancel", () => openDrawer(pid, "view")); on("#dwSave", async () => { const btn = $("#dwSave"); const err = $("#dwErr"); btn.disabled = true; err.style.display = "none"; try { await patch("/projects/" + pid, { name: $("#dwName").value.trim(), category_id: $("#dwCat").value === "" ? null : Number($("#dwCat").value), status: $("#dwStatus").value, owners: peopleValues("dwOwners"), members: peopleValues("dwMembers"), progress: Number($("#dwProgress").value), tags: $("#dwTags").value.split(/[、,,;;\s]+/).filter(Boolean), }); } catch (e) { btn.disabled = false; err.textContent = "保存失败:" + e.message; err.style.display = ""; return; } toast("已保存"); await afterWrite(); openDrawer(pid, "view"); }); on("#dwKey", async () => { await patch("/projects/" + pid, { is_key: !p.is_key }); await afterWrite(); openDrawer(pid, mode); }); on("#dwArchive", async () => { await patch("/projects/" + pid, { archived: !p.archived }); toast(p.archived ? "已恢复" : "已归档"); closeDrawer(); await afterWrite(); }); // 删除是高危动作:必须逐字输入项目名才能点「永久删除」 on("#dwDelete", async () => { const ok = await confirmDialog({ title: "删除项目", danger: true, okText: "永久删除", requireText: p.name, body: `
将连同 ${p.task_count} 个子任务、${p.update_count} 条执行记录一并删除,删除后无法恢复。
只是暂时不想在看板里看到它?请改用「归档」,数据全部保留。
`, }); if (!ok) return; try { const r = await del("/projects/" + pid); toast(`已删除「${r.name}」:${r.deleted_tasks} 个子任务 / ${r.deleted_updates} 条记录`); closeDrawer(); await afterWrite(); } catch (e) { toast("删除失败:" + e.message); } }); } // 标题栏右侧的标准按钮条:按钮由当前打开的抽屉自己填,主按钮在最右 function drawerActions(html) { $("#drawerActions").innerHTML = html || ""; } function closeDrawer() { drawerActions(""); $("#drawer").classList.remove("open"); $("#drawerMask").classList.remove("open"); } /* ---------------------------------------------------------------- 人力资源库 */ function openStaffDialog(person, onDone) { const isNew = !person; const p = person || {}; const groups = (state.staffDict.groups || []).map( (g) => `` ).join(""); const affs = (state.staffDict.affiliations || []).map( (a) => `` ).join(""); openModal( ` ` ); $("#mOk").addEventListener("click", async () => { const name = $("#sfName").value.trim(); if (!name) return modalError("请填写姓名"); const btn = $("#mOk"); btn.disabled = true; const body = { name: name, group: $("#sfGroup").value || null, affiliation: $("#sfAff").value || null, note: $("#sfNote").value.trim() || null, }; try { let msg = "已新增「" + name + "」"; if (isNew) { await post("/staff", body); } else { const r = await patch("/staff/" + p.id, body); const n = r.synced.projects + r.synced.custom_projects; msg = n ? `已更新,并同步 ${n} 个项目的负责人` : "已更新"; } closeModal(); toast(msg); if (onDone) onDone(); else await afterWrite(); } catch (e) { btn.disabled = false; modalError(e.message); } }); $("#sfName").addEventListener("keydown", (e) => { if (e.key === "Enter") $("#mOk").click(); }); } async function deleteStaff(m) { const ok = await confirmDialog({ title: "删除人员", danger: true, okText: "确认删除", body: `

确认从花名册删除 ${esc(m.name)}?

删除后这个人不再出现在「负责人」下拉里。 若他名下还有在管项目,接口会拒绝删除 —— 离职人员请用归档,历史数据完整保留。
`, }); if (!ok) return; try { await del("/staff/" + m.id); toast("已删除「" + m.name + "」"); await afterWrite(); } catch (e) { toast(e.message); } } async function renderStaff() { saveUI(); const host = $("#view-staff"); host.innerHTML = `
加载中…
`; const extra = ["archived=" + state.staffArchived]; if (state.staffGroup) extra.push("group=" + encodeURIComponent(state.staffGroup)); if (state.keyword) extra.push("keyword=" + encodeURIComponent(state.keyword)); const d = await api("/staff?" + extra.join("&")); await loadStaff(); // 负责人下拉始终用完整花名册 const sum = d.summary; const archOpts = [["false", "在编"], ["true", "已归档"], ["all", "全部"]] .map((x) => ``).join(""); const groupOpts = `` + (d.dict.groups || []).map((g) => ``).join(""); const kpi = `
${Ico.badge("users")}
在册人员
${sum.total}
其中 ${sum.with_project} 人带项目
${sum.groups.slice(0, 4).map((g, i) => `
${Ico.badge("users")}
${esc(g.name)}
${g.count}
`).join("")}

人员归属汇智 / 云数 / 外服

${sum.affiliations.map((a) => `
${esc(a.name)}
${a.count}
`).join("")}

花名册外的负责人历史数据里对不上花名册的人名

${sum.outside.length ? sum.outside.map((o) => `
${esc(o.name)} 项目 ${o.project_count} 定制 ${o.custom_count}
`).join("") : `
所有项目负责人都能对上花名册
`}
`; const toolbar = `
${keywordInputHtml("stKeyword", "搜索姓名…", 200)} 在册 ${sum.total} 人
`; const table = ` ${d.items.map((m) => ` `).join("")}
姓名归属小组人员归属说明 在管项目定制项目定制合同额 名下待计收最近更新操作
${esc(m.name)} ${m.archived ? `已归档` : ""}
${m.project_count ? `${m.project_count}${m.key_project_count ? ` ${starMark(true, false, "重点项目")}${m.key_project_count}` : ""}` : "—"} ${m.custom_count} ${m.custom_amount ? fen(m.custom_amount) + " 万" : "—"} ${m.custom_pending ? `${fen(m.custom_pending)} 万` : "—"} ${esc(m.last_update || "—")}
`; const body = listCard({ title: "人员花名册", sub: "小组 / 归属 / 说明可直接在表内改", summary: `当前 ${d.items.length} 人 · 带项目 ${sum.with_project} 人 · 花名册外 ${sum.outside.length} 人`, fields: ` `, rows: d.items, table: table, empty: state.keyword ? "没有匹配「" + esc(state.keyword) + "」的人员" : (state.staffGroup ? "「" + esc(state.staffGroup) + "」还没有人员" : "还没有人员"), }); host.innerHTML = toolbar + kpi + body; bindKeyword("stKeyword"); $("#stArchived").addEventListener("change", (e) => { state.staffArchived = e.target.value; renderStaff(); }); $("#stGroup").addEventListener("change", (e) => { state.staffGroup = e.target.value; renderStaff(); }); $("#btnNewStaff").addEventListener("click", () => openStaffDialog(null)); $("#btnExportStaff").addEventListener("click", () => download("/api/export/staff?archived=" + state.staffArchived)); host.querySelectorAll("[data-sedit]").forEach((b) => b.addEventListener("click", () => { const m = d.items.find((x) => x.id === Number(b.dataset.sedit)); openStaffDialog(m); }) ); host.querySelectorAll("[data-sarch]").forEach((b) => b.addEventListener("click", async () => { const m = d.items.find((x) => x.id === Number(b.dataset.sarch)); await patch("/staff/" + m.id, { archived: !m.archived }); toast(m.archived ? "已恢复" : `已归档「${m.name}」,不再出现在负责人下拉里`); await afterWrite(); }) ); host.querySelectorAll("[data-sdel]").forEach((b) => b.addEventListener("click", () => deleteStaff(d.items.find((x) => x.id === Number(b.dataset.sdel))) ) ); host.querySelectorAll("[data-outside]").forEach((b) => b.addEventListener("click", () => openStaffDialog({ name: b.dataset.outside })) ); host.querySelectorAll("tr[data-sid]").forEach((tr) => { const id = Number(tr.dataset.sid); tr.querySelectorAll("[data-sf]").forEach((inp) => inp.addEventListener("change", async () => { const f = inp.dataset.sf; try { await patch("/staff/" + id, { [f]: inp.value || null }); toast("已保存"); await afterWrite(); } catch (e) { toast("保存失败:" + e.message); } }) ); }); } /* ---------------------------------------------------------------- shell */ async function loadCategories() { state.categories = await api("/categories"); const box = $("#sideCategories"); box.innerHTML = `` + state.categories.map((c) => ` `).join(""); box.querySelectorAll("[data-cat]").forEach((b) => b.addEventListener("click", () => { state.categoryId = b.dataset.cat === "" ? null : Number(b.dataset.cat); loadCategories(); if (state.view === "board") renderBoard(); else if (state.view === "projects") renderProjects(); }) ); } function setView(view) { state.view = view; saveUI(); $$("#nav .nav-item").forEach((b) => b.classList.toggle("active", b.dataset.view === view)); $$(".view").forEach((v) => v.classList.add("hidden")); $("#view-" + view).classList.remove("hidden"); refresh(); } async function refresh() { const v = state.view; try { if (v === "board") await renderBoard(); else if (v === "projects") await renderProjects(); else if (v === "custom") await renderCustom(); else if (v === "focus") await renderFocus(); else if (v === "staff") await renderStaff(); else if (v === "intake") await renderIntake(); else if (v === "settings") await renderSettings(); } catch (e) { $("#view-" + v).innerHTML = `
加载失败:${esc(e.message)}
`; } $("#sideFoot").innerHTML = `当前周期:${esc(scopeLabel())}
共 ${state.periods.weeks.length} 个周期 / ${state.periods.months.length} 个月`; } /** 写操作统一收尾:刷新侧栏计数 + 当前视图。 录入页不重绘,否则会清空用户正在编辑的表单。 */ async function afterWrite() { await Promise.all([loadCategories(), loadStaff()]); if (state.view === "intake") return; await refresh(); } function bindShell() { $$("#nav .nav-item").forEach((b) => b.addEventListener("click", () => setView(b.dataset.view))); $$("#granSeg .seg-btn").forEach((b) => b.addEventListener("click", () => { state.gran = b.dataset.gran; $$("#granSeg .seg-btn").forEach((x) => x.classList.toggle("active", x === b)); renderPeriodSelect(); refresh(); }) ); $("#prevPeriod").addEventListener("click", () => shiftPeriod(-1)); $("#nextPeriod").addEventListener("click", () => shiftPeriod(1)); $("#periodSelect").addEventListener("change", (e) => { if (e.target.value === "__new") { renderPeriodSelect(); createNewPeriod().then((id) => { if (id) refresh(); }); return; } if (state.gran === "week") state.weekId = Number(e.target.value); else state.monthIdx = Number(e.target.value); renderPeriodSelect(); refresh(); }); $("#sideKeyOnly").addEventListener("change", (e) => { state.keyOnly = e.target.checked; refresh(); }); $("#btnIntake").addEventListener("click", () => setView("intake")); $("#drawerClose").addEventListener("click", closeDrawer); bindDrawerPeople(); $("#drawerMask").addEventListener("click", closeDrawer); $("#modalMask").addEventListener("click", (e) => { if (e.target.id === "modalMask" || e.target.dataset.mclose !== undefined) closeModal(false); }); let t = null; $("#globalSearch").addEventListener("input", (e) => { clearTimeout(t); const v = e.target.value.trim(); t = setTimeout(() => { state.keyword = v; refresh(); }, 320); }); document.addEventListener("keydown", (e) => { if (e.key !== "Escape") return; if ($("#modalMask").classList.contains("open")) closeModal(false); else closeDrawer(); }); } /* ------------------------------------------- 界面状态(刷新后保持) */ const UI_KEY = "unis.ui.v1"; const UI_FIELDS = ["view", "gran", "weekId", "monthIdx", "categoryId", "keyOnly", "keyword", "projOwner", "projIdleOnly", "showArchived", "focusFilter", "focusArchived", "customTab", "customArchived", "customRegion", "staffArchived", "staffGroup"]; function saveUI() { const o = {}; UI_FIELDS.forEach((k) => { o[k] = state[k]; }); try { sessionStorage.setItem(UI_KEY, JSON.stringify(o)); } catch (e) { /* 存不了就算了 */ } } function restoreUI() { let o = null; try { o = JSON.parse(sessionStorage.getItem(UI_KEY) || "null"); } catch (e) { o = null; } if (!o) return; UI_FIELDS.forEach((k) => { if (k in o) state[k] = o[k]; }); if (["board", "projects", "custom", "focus", "staff", "intake", "settings"].indexOf(state.view) < 0) state.view = "board"; if (state.gran !== "month") state.gran = "week"; if (state.focusFilter === "later") state.focusFilter = "week"; } async function main() { restoreUI(); state.meta = await api("/meta"); await loadStaff(); await loadPeriods(); await loadCategories(); bindShell(); // 恢复出来的视图状态要回写到顶栏/侧栏控件上 $$("#granSeg .seg-btn").forEach((x) => x.classList.toggle("active", x.dataset.gran === state.gran)); $("#sideKeyOnly").checked = !!state.keyOnly; $("#globalSearch").value = state.keyword || ""; setView(state.view); } main().catch((e) => { document.body.insertAdjacentHTML("beforeend", `
初始化失败:${esc(e.message)}
`); }); })();