/* 软件开发部 · 关键任务追踪看板 */ (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, // 定开:项目明细 / 本周进展 两个 tab(原交付流水线 / 阶段看板 / 财务跟踪已下线) customTab: "list", customRegion: "", customStage: "", customStatus: "", customOrigin: "", customAcceptPlan: "", customSort: null, // { key: amount | sign_date | update_date, dir: asc | desc } customArchived: "false", customRows: [], intakeType: "general", // 更新录入的两种方式:direct = 直接写入(原样存 1 条);ai = AI 整理(补结构化字段,也只存 1 条) intakeMode: "direct", intakePeriodId: null, drawerCustom: null, // 周看板(左栏总览 + 右栏详情) weekly: null, weeklySel: null, 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 ``; } /** 绑定页内搜索。 默认:防抖实时搜索;opts.enter=true 时只在「回车 / 清空(×)」后才查询。 重绘后把光标放回搜索框,连续操作不中断。 */ function bindKeyword(id, opts) { opts = opts || {}; const el = $("#" + id); if (!el) return; const apply = (v) => { v = (v || "").trim(); if (v === state.keyword) return; keywordFocus = true; state.keyword = v; $("#globalSearch").value = v; refresh(); }; if (opts.enter) { // 输入阶段不查询;回车提交后才检索 el.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); apply(el.value); } }); // type=search 的清除「×」会触发 search 事件 el.addEventListener("search", () => apply(el.value)); } else { let t = null; el.addEventListener("input", (e) => { clearTimeout(t); const v = e.target.value.trim(); t = setTimeout(() => apply(v), 320); }); } if (keywordFocus) { keywordFocus = false; el.focus(); el.setSelectionRange(el.value.length, el.value.length); } } /* ---------------------------------------------------- 弹窗 / 确认框 */ let modalResolve = null; function openModal(html, onMount) { $("#modalMask").classList.add("open"); $("#modal").innerHTML = html; if (onMount) onMount(); const f = $("#modal").querySelector("[data-autofocus]"); if (f) f.focus(); } /** 关闭弹窗;result 省略时按「取消」处理 */ function closeModal(result) { if (!$("#modalMask").classList.contains("open")) return; $("#modalMask").classList.remove("open"); $("#modal").innerHTML = ""; const cb = modalResolve; modalResolve = null; if (cb) cb(result === undefined ? false : result); } function modalError(msg) { const e = $("#mErr"); if (!e) return; e.textContent = msg || ""; e.style.display = msg ? "" : "none"; } /** 需要用户明确点确认才继续;requireText 时还必须逐字输入该文本 */ function confirmDialog(o) { return new Promise((resolve) => { openModal( ` ` ); 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", { enter: true }); 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)}`; } /* ---- 市场台账(《定开项目.xlsx》)的小色块 ---- */ const CUSTOM_STATUS_COLOR = { 已验收: "#16a34a", 交付完成: "#0891b2", 进行中: "#2563eb", 待开发: "#b45309", 退单: "#7a8394", }; /** 项目状态:已验收/交付完成/进行中/待开发/退单 */ function statusPill(code) { if (!code) return `—`; const color = CUSTOM_STATUS_COLOR[code] || "#7a8394"; return `${esc(code)}`; } /** 风险值:1.高风险 红 / 2.低风险 绿 */ function riskPill(code) { if (!code) return `—`; const high = String(code).includes("高"); const color = high ? "#dc2626" : "#16a34a"; const text = String(code).replace(/^\d+\./, ""); return `${esc(text)}`; } let keywordFocus = false; /** 定开「项目归属」取值:前三项视为「已下单」,提前交付为下单前状态 */ const CUSTOM_ORIGINS = ["华智下单", "存量项目", "汇智直签", "提前交付"]; const CUSTOM_PLACED = ["华智下单", "存量项目", "汇智直签"]; const isPlaced = (p) => CUSTOM_PLACED.indexOf(p.origin) >= 0; /** 年度计收目标(万元),来源《年度任务.xlsx》(Q1 35 / Q2 225 / Q3 160 / Q4 260,合计 680) */ const CUSTOM_ANNUAL_TARGETS = { 1: 35, 2: 225, 3: 160, 4: 260 }; async function renderCustom() { saveUI(); const host = $("#view-custom"); host.innerHTML = `
加载中…
`; if (!state.customMeta) state.customMeta = await api("/custom/meta"); // 一次性取「全部定开项目」(不带搜索/筛选条件): // 列表在本地按关键字/区域/状态筛选,而统计条始终基于全部数据。 const allRows = await api("/custom/projects?archived=" + state.customArchived); state.customRows = allRows; // 区域筛选:用规范名称、按大区分组,只列出实际有项目的区域 const usedSet = {}; (state.customMeta.regions || []).forEach((r) => { usedSet[r] = true; }); const stdAll = (state.customMeta.region_groups || []).reduce((a, g) => a.concat(g.regions), []); let regionFilterHtml = ``; (state.customMeta.region_groups || []).forEach((g) => { const hit = g.regions.filter((r) => usedSet[r]); if (!hit.length) return; regionFilterHtml += `` + hit.map((r) => ``).join("") + ``; }); const nonStd = (state.customMeta.regions || []).filter((r) => stdAll.indexOf(r) < 0); if (nonStd.length) { regionFilterHtml += `` + nonStd.map((r) => ``).join("") + ``; } const STATUS_OPTIONS = ["已验收", "待开发", "交付完成", "进行中", "退单"]; const statusFilterHtml = `` + STATUS_OPTIONS.map((s) => ``).join(""); const originFilterHtml = `` + CUSTOM_ORIGINS.map((o) => ``).join(""); // 推动验收计划:取数据里实际出现过的值 const planVals = Array.from(new Set(allRows.map((p) => String(p.accept_plan || "").trim()).filter(Boolean))).sort(); const planFilterHtml = `` + planVals.map((v) => ``).join(""); const isList = state.customTab !== "weekly" && state.customTab !== "annual"; const toolbar = `
${isList ? ` ${keywordInputHtml("cKeyword", "搜索项目名称…(回车查询)", 200)} 全部 ${allRows.length} 个项目` : state.customTab === "annual" ? `计收按「验收时间」、签单按「下单时间」归属季度` : `${esc(scopeLabel())} · 共 ${allRows.length} 个项目`}
`; host.innerHTML = toolbar + ( state.customTab === "weekly" ? renderCustomWeekly(allRows) : state.customTab === "annual" ? renderCustomAnnual(allRows) : renderCustomList(allRows)); host.querySelectorAll("[data-ctab]").forEach((b) => b.addEventListener("click", () => { state.customTab = b.dataset.ctab; renderCustom(); }) ); const os = $("#cOrigin"); if (os) os.addEventListener("change", () => { state.customOrigin = os.value; renderCustom(); }); const rs = $("#cRegion"); if (rs) rs.addEventListener("change", () => { state.customRegion = rs.value; renderCustom(); }); const st = $("#cStatus"); if (st) st.addEventListener("change", () => { state.customStatus = st.value; renderCustom(); }); const ap = $("#cAcceptPlan"); if (ap) ap.addEventListener("change", () => { state.customAcceptPlan = ap.value; renderCustom(); }); if (isList) bindKeyword("cKeyword", { enter: true }); const bn = $("#btnNewCustom"); if (bn) bn.addEventListener("click", () => openNewCustomDialog(() => afterWrite())); const bex = $("#btnExportCustom"); if (bex) bex.addEventListener("click", () => { // 导出与「项目明细」当前筛选一致:归档态 + 归属 + 区域 + 状态 + 关键字 const q = ["archived=" + state.customArchived]; if (state.customOrigin) q.push("origin=" + encodeURIComponent(state.customOrigin)); if (state.customRegion) q.push("region=" + encodeURIComponent(state.customRegion)); if (state.customStatus) q.push("status=" + encodeURIComponent(state.customStatus)); if (state.customAcceptPlan) q.push("accept_plan=" + encodeURIComponent(state.customAcceptPlan)); if (state.keyword) q.push("keyword=" + encodeURIComponent(state.keyword)); download("/api/export/custom?" + q.join("&")); }); bindCustomList(host); } function renderCustomList(allRows) { // 列表:按 关键字 + 归属 + 区域 + 状态 + 推动验收计划 在本地筛选;再按表头排序 const kw = (state.keyword || "").trim(); let list = allRows; if (kw) list = list.filter((p) => String(p.name || "").includes(kw)); if (state.customOrigin) list = list.filter((p) => (p.origin || "") === state.customOrigin); if (state.customRegion) list = list.filter((p) => (p.region || "") === state.customRegion); if (state.customStatus) list = list.filter((p) => (p.project_status || "") === state.customStatus); if (state.customAcceptPlan) list = list.filter((p) => String(p.accept_plan || "").trim() === state.customAcceptPlan); const s = state.customSort || { key: "update_date", dir: "desc" }; const sv = (p, k) => (k === "amount" ? Number(p.amount || 0) : String(p[k] || "")); const view = list.slice().sort((a, b) => { const va = sv(a, s.key), vb = sv(b, s.key); const c = va < vb ? -1 : va > vb ? 1 : 0; return s.dir === "asc" ? c : -c; }); // ---- 统计条:基于「全部已下单项目」,不随搜索 / 筛选变化 ---- const placed = allRows.filter(isPlaced); // 已下单 = 华智下单 / 存量项目 / 汇智直签 const today = new Date(); const Y = today.getFullYear(); const M = today.getMonth() + 1; const Q = Math.floor((M - 1) / 3) + 1; // 当前季度 1~4 const sum = (arr, f) => arr.reduce((a, p) => a + Number(p[f] || 0), 0); // 取日期里的「年-月」;像「2024年3月前」这种文本不参与季度/年度判定 const ymOf = (d) => { const s = String(d || ""); return /^\d{4}-\d{2}/.test(s) ? { y: Number(s.slice(0, 4)), m: Number(s.slice(5, 7)) } : null; }; const inYear = (d) => { const t = ymOf(d); return !!t && t.y === Y; }; const inQuarter = (d) => { const t = ymOf(d); return !!t && t.y === Y && Math.floor((t.m - 1) / 3) + 1 === Q; }; const signedQuarter = placed.filter((p) => inQuarter(p.sign_date)); const signedYear = placed.filter((p) => inYear(p.sign_date)); // 验收:以「验收时间」(accept_date,季度文本 Q1~Q4) 为准;本季度 = 当前季度 const qOf = (s) => { const t = String(s || "").trim().toUpperCase(); return /^Q[1-4]$/.test(t) ? t : ""; }; const thisQ = "Q" + Q; const accRows = allRows.filter((p) => qOf(p.accept_date)); // 有「验收时间」即视为已验收 const accQuarter = accRows.filter((p) => qOf(p.accept_date) === thisQ); const accYear = accRows; const stat = (label, count, amount, color) => `
${count}个 · ${fen(amount)}万
${label}
`; const statBar = `
${stat("已下单项目 / 合同额", placed.length, sum(placed, "amount"), "var(--primary)")} ${stat(`本季度下单(${Y}年Q${Q})`, signedQuarter.length, sum(signedQuarter, "amount"), "var(--violet)")} ${stat(`本年新增下单(${Y}年)`, signedYear.length, sum(signedYear, "amount"), "var(--violet)")} ${stat(`本季度验收(${Y}年Q${Q})`, accQuarter.length, sum(accQuarter, "amount"), "var(--amber)")} ${stat(`本年验收项目(${Y}年)`, accYear.length, sum(accYear, "amount"), "var(--amber)")}
「已下单项目 / 合同额」与「本季度·本年下单」只统计 华智下单 / 存量项目 / 汇智直签(提前交付不计入下单);统计基于全部项目,不受搜索 / 筛选影响。
`; const manDays = view.reduce((a, p) => a + Number(p.man_days || 0), 0); const highRisk = view.filter((p) => String(p.risk_level || "").includes("高")).length; const accepted = view.filter((p) => p.project_status === "已验收").length; const sortLabel = { sign_date: "下单时间", amount: "订单金额", update_date: "更新时间" }[s.key] || "更新时间"; return `

定开项目总览按「${sortLabel}」${s.dir === "asc" ? "升序" : "降序"} · 共 ${view.length} 个${state.customStatus ? "(" + esc(state.customStatus) + ")" : ""} · 点表头(下单时间 / 订单金额 / 更新时间)可排序;列表只读,改数据请点「修改」

人天 ${manDays.toLocaleString()} · 高风险 ${highRisk} 个 · 已验收 ${accepted} 个
${statBar} ${view.length ? customTableHtml(view) : `
${state.keyword ? "没有匹配「" + esc(state.keyword) + "」的定开项目" : "当前筛选条件下没有定开项目"}
`}
`; } /** 定开项目表格(「项目总览」与「本周进展」两个 tab 共用);下单时间 / 订单金额 / 更新时间 表头可排序 */ function customTableHtml(view, withOps) { const ops = withOps !== false; const s = state.customSort || { key: "update_date", dir: "desc" }; const sth = (label, k, cls) => `${label}${s.key === k ? (s.dir === "asc" ? " ▲" : " ▼") : ""}`; return `
${sth("下单时间", "sign_date")} ${sth("订单金额", "amount", "num")} ${sth("更新时间", "update_date")} ${ops ? `` : ""} ${view.map((p) => ` ${ops ? `` : ""} `).join("")}
项目名称 项目ID项目归属 办事处 行业 市场下单人天市场责任人 项目状态 进度 验收时间 推动验收计划 风险值备注操作
${starMark(p.is_key, true, "重点标记在详情面板中维护")} ${esc(p.name)} ${p.archived ? `已归档` : ""}
${esc(p.project_code || "—")} ${p.sign_date ? esc(p.sign_date) : "—"} ${esc(p.origin || "—")} ${esc(p.office || "—")} ${esc(p.industry || "—")} ${p.man_days == null ? "—" : Math.round(p.man_days).toLocaleString()} ${money(p.amount)} ${esc(p.market_owner || "—")} ${statusPill(p.project_status)} ${(p.project_status === "进行中" || Number(p.progress || 0) > 0) ? Math.round(p.progress || 0) + "%" : "—"} ${p.accept_date ? esc(p.accept_date) : "—"} ${esc(p.accept_plan || "—")} ${riskPill(p.risk_level)} ${esc(p.update_date || "—")} ${esc((p.remark || "").slice(0, 40)) || "—"}
`; } /** 本周进展:更新时间落在本周(周一~周日)的项目;并按 验收/下单/提前交付/进行中 分类统计 */ function renderCustomWeekly(allRows) { const d = new Date(); const dow = (d.getDay() + 6) % 7; // 周一 = 0 const mon = new Date(d.getFullYear(), d.getMonth(), d.getDate() - dow); const sun = new Date(mon.getFullYear(), mon.getMonth(), mon.getDate() + 6); const iso = (x) => x.getFullYear() + "-" + String(x.getMonth() + 1).padStart(2, "0") + "-" + String(x.getDate()).padStart(2, "0"); const start = iso(mon), end = iso(sun); const dayOf = (v) => String(v || "").slice(0, 10); const inWeek = (v) => { const s = dayOf(v); return !!s && s >= start && s <= end; }; const week = allRows.filter((p) => inWeek(p.update_date)); // 更新时间在本周 const sum = (arr, f) => arr.reduce((a, p) => a + Number(p[f] || 0), 0); const acc = week.filter((p) => p.project_status === "已验收"); // 新增项目:按「下单时间」在本周的新下单 / 提前进行中项目(不是「本周更新」) const newDeal = allRows.filter((p) => inWeek(p.sign_date) && (isPlaced(p) || p.origin === "提前交付")); // 进行中项目:本周更新的「进行中 + 交付完成」 const doing = week.filter((p) => p.project_status === "进行中" || p.project_status === "交付完成"); const stat = (label, rows, color) => `
${rows.length}个 · ${fen(sum(rows, "amount"))}万
${label}
`; const statBar = `
${stat("验收情况", acc, "var(--amber)")} ${stat("新增项目", newDeal, "var(--violet)")} ${stat("进行中项目", doing, "var(--primary)")}
本周 = ${start} ~ ${end};新增项目 = 按「下单时间」在本周的新下单 / 提前进行中项目(已下单 + 提前交付);验收情况 / 进行中项目 为「更新时间」在本周的项目(进行中项目 = 进行中 + 交付完成)。
`; return `

本周进展更新时间在本周(${start} ~ ${end})· 共 ${week.length} 个项目 · 列表只读,改数据请点项目名称

验收 ${acc.length} · 新增 ${newDeal.length} · 进行中 ${doing.length}
${statBar} ${week.length ? customTableHtml(week.slice().sort((a, b) => String(b.update_date || "").localeCompare(String(a.update_date || ""))), false) : `
本周(${start} ~ ${end})没有更新的定开项目
`}
`; } /** 年度任务:季度计收目标(《年度任务.xlsx》)+ 按「验收时间 / 下单时间」回填的完成数据 */ function renderCustomAnnual(allRows) { const Y = new Date().getFullYear(); const qOf = (s) => { const t = String(s || "").trim().toUpperCase(); return /^Q[1-4]$/.test(t) ? t : ""; }; const signQ = (d) => { const s = String(d || ""); if (!/^\d{4}-\d{2}/.test(s) || Number(s.slice(0, 4)) !== Y) return 0; return Math.floor((Number(s.slice(5, 7)) - 1) / 3) + 1; }; const sumW = (arr) => arr.reduce((a, p) => a + Number(p.amount || 0), 0) / 10000; // 元 → 万元 const fmt = (v) => v.toLocaleString("zh-CN", { maximumFractionDigits: 2 }); const rows = [1, 2, 3, 4].map((q) => { const acc = allRows.filter((p) => qOf(p.accept_date) === "Q" + q); // 计收:验收项目 const sign = allRows.filter((p) => isPlaced(p) && signQ(p.sign_date) === q); // 签单:下单项目(已下单) const target = CUSTOM_ANNUAL_TARGETS[q] || 0; const accAmt = sumW(acc); return { q, target, accN: acc.length, accAmt, rate: target ? (accAmt / target) * 100 : null, signN: sign.length, signAmt: sumW(sign) }; }); const tot = rows.reduce((a, r) => ({ target: a.target + r.target, accN: a.accN + r.accN, accAmt: a.accAmt + r.accAmt, signN: a.signN + r.signN, signAmt: a.signAmt + r.signAmt, }), { target: 0, accN: 0, accAmt: 0, signN: 0, signAmt: 0 }); tot.rate = tot.target ? (tot.accAmt / tot.target) * 100 : null; const tr = (label, r, strong) => ` ${label} ${fmt(r.target)} ${r.accN} ${fmt(r.accAmt)} ${r.rate == null ? "—" : r.rate.toFixed(1) + "%"} ${r.signN} ${fmt(r.signAmt)} `; return `

年度任务(${Y}年)计收目标取自《年度任务.xlsx》;完成数据按时间自动回填

已验收 ${tot.accN} 个 · 新增签单 ${tot.signN} 个
${rows.map((r) => tr("Q" + r.q, r, false)).join("")} ${tr("合计", tot, true)}
季度 计收目标(万元) 计收项目数量 计收项目金额(万元) 计收完成率 新增签单项目数量 新增签单项目数量金额(万元)
计收项目 = 按「验收时间」归属季度的项目;新增签单 = 按「下单时间」归属本年度季度、且项目归属为已下单(华智下单 / 存量项目 / 汇智直签)的项目;金额由「元」换算为「万元」;计收完成率 = 计收项目金额 ÷ 计收目标。
`; } /** 项目总览表:列表只读,行内只有「修改」;名称可点开详情;表头(下单时间 / 订单金额 / 更新时间)可排序 */ function bindCustomList(host) { host.querySelectorAll("[data-copen]").forEach((a) => a.addEventListener("click", () => openCustomDrawer(Number(a.dataset.copen), "view")) ); host.querySelectorAll("[data-cedit]").forEach((b) => b.addEventListener("click", () => openCustomDrawer(Number(b.dataset.cedit), "edit")) ); host.querySelectorAll("[data-sort]").forEach((th) => th.addEventListener("click", () => { const k = th.dataset.sort; const cur = state.customSort || { key: "update_date", dir: "desc" }; state.customSort = { key: k, dir: cur.key === k && cur.dir === "desc" ? "asc" : "desc" }; renderCustom(); }) ); } /** * 新建定开项目 —— 全站唯一的新建入口。 * 「定开项目」工具栏和「更新录入」页都调用它,避免两套录入逻辑。 */ function openNewCustomDialog(onDone) { const today = new Date(); const iso = today.getFullYear() + "-" + String(today.getMonth() + 1).padStart(2, "0") + "-" + String(today.getDate()).padStart(2, "0"); // 选项直接对齐《定开项目.xlsx》台账字段取值 const opts = (list, sel) => list.map((v) => ``).join(""); const originOpts = opts(CUSTOM_ORIGINS, "华智下单"); const industryOpts = opts(["安平", "政府", "企业", "教育", "交通", "医疗", "高速", "行业", "其他"], ""); const statusOpts = opts(["待开发", "进行中", "交付完成", "已验收", "退单"], "待开发"); const riskOpts = opts(["1.高风险", "2.低风险"], "2.低风险"); const numVal = (sel) => ($(sel).value === "" ? null : Number($(sel).value)); const txtVal = (sel) => $(sel).value.trim() || null; openModal( ` ` ); const submit = async () => { const name = txtVal("#ncName"); if (!name) return modalError("请填写项目名称"); const btn = $("#mOk"); btn.disabled = true; try { const p = await post("/custom/projects", { name: name, origin: txtVal("#ncOrigin"), project_code: txtVal("#ncCode"), sign_date: txtVal("#ncSign"), office: txtVal("#ncOffice"), industry: txtVal("#ncIndustry"), man_days: numVal("#ncManDays"), progress: numVal("#ncProgress") == null ? 0 : numVal("#ncProgress"), amount: numVal("#ncAmount"), market_owner: txtVal("#ncMarketOwner"), project_status: txtVal("#ncStatus"), accept_date: txtVal("#ncAcceptQuarter"), accept_plan: txtVal("#ncAcceptPlan"), risk_level: txtVal("#ncRisk"), accept_date_real: txtVal("#ncAcceptReal"), is_key: $("#ncKey").checked, remark: txtVal("#ncRemark"), }); closeModal(); toast("已创建「" + p.name + "」"); if (onDone) onDone(p); else refresh(); } catch (e) { btn.disabled = false; modalError(e.message); } }; $("#mOk").addEventListener("click", submit); $("#ncName").addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); }); } /** 归档 / 恢复:归档后不进看板与统计,随时可恢复 */ async function archiveCustomProject(p, onDone) { const toArchive = !p.archived; const ok = await confirmDialog({ title: toArchive ? "归档定开项目" : "恢复定开项目", okText: toArchive ? "确认归档" : "确认恢复", body: `

${esc(p.name)}

` + `
${ toArchive ? "归档后该项目不再出现在项目明细与统计中,历史周期记录完整保留。" : "恢复后重新计入项目明细与统计。" }
`, }); if (!ok) return; try { await patch("/custom/projects/" + p.id, { archived: toArchive }); toast(toArchive ? "已归档" : "已恢复"); if (onDone) onDone(); else refresh(); } catch (e) { toast("操作失败:" + e.message); } } /** 删除:连带周期记录一起清除,需逐字输入项目名确认 */ async function deleteCustomProject(p, onDone) { const ok = await confirmDialog({ title: "删除定开项目", danger: true, okText: "永久删除", requireText: p.name, body: `
将连同该项目 ${p.update_count || 0} 条周期记录一并删除,删除后无法恢复。
只是暂时不想在看板里看到它?请改用「归档」,数据全部保留。
`, }); 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 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 optList = (list, sel) => list.map((v) => ``).join(""); const originOpts = optList(CUSTOM_ORIGINS, p.origin); const industryOpts = optList(["安平", "政府", "企业", "教育", "交通", "医疗", "高速", "行业", "其他"], p.industry); const statusOpts = optList(["待开发", "进行中", "交付完成", "已验收", "退单"], p.project_status); const riskOpts = optList(["1.高风险", "2.低风险"], p.risk_level); // 市场台账(来源《定开项目.xlsx》):详情只读,修改模式给输入框 const numOrDash = (v) => (v === null || v === undefined ? "—" : String(Math.round(v))); const ledgerSec = `
定开项目信息${mode === "edit" ? "编辑" : "只读"}
${mode === "edit" ? `
%
${esc(p.update_date || "—")}跟随「下单时间」
` : ` ${ro("项目归属", esc(p.origin || "—"))} ${ro("项目ID", esc(p.project_code || "—"))} ${ro("办事处", esc(p.office || "—"))} ${ro("行业", esc(p.industry || "—"))} ${ro("下单时间", esc(p.sign_date || "—"))} ${ro("市场下单人天", numOrDash(p.man_days))} ${ro("市场责任人", esc(p.market_owner || "—"))} ${ro("项目状态", statusPill(p.project_status))} ${ro("进度", (p.project_status === "进行中" || Number(p.progress || 0) > 0) ? Math.round(p.progress || 0) + "%" : "—")} ${ro("验收时间", esc(p.accept_date || "—"))} ${ro("推动验收计划", esc(p.accept_plan || "—"))} ${ro("风险值", riskPill(p.risk_level))} ${ro("更新时间", esc(p.update_date || "—"))} `}
`; // 基础信息:只读展示(编辑请用上方「定开项目信息」表单) const baseSec = `
基础信息只读
${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" ? `` : ""} ${ledgerSec} ${mode === "view" ? ` ${baseSec}
收入确认计算值 · 只读
${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)}
周期执行记录(${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"); btn.disabled = true; err.style.display = "none"; try { await patch("/custom/projects/" + cid, { name: $("#cdName").value.trim(), origin: $("#cdOrigin").value.trim() || null, project_code: $("#cdCode").value.trim() || null, sign_date: $("#cdSign").value || null, office: $("#cdOffice").value.trim() || null, industry: $("#cdIndustry").value.trim() || null, man_days: $("#cdManDays").value === "" ? null : Number($("#cdManDays").value), amount: $("#cdAmount").value === "" ? null : Number($("#cdAmount").value), market_owner: $("#cdMarketOwner").value.trim() || null, project_status: $("#cdStatus").value.trim() || null, progress: $("#cdProgress").value === "" ? 0 : Number($("#cdProgress").value), accept_date: $("#cdAcceptQuarter").value.trim() || null, accept_plan: $("#cdAcceptPlan").value.trim() || null, risk_level: $("#cdRisk").value.trim() || null, accept_date_real: $("#cdAcceptReal").value || null, remark: $("#cdRemark").value, }); } catch (e) { btn.disabled = false; err.textContent = "保存失败:" + e.message; err.style.display = ""; return; } toast("已保存"); await afterWrite(); openCustomDrawer(cid, "view"); }); on("#cdKey", async () => { await patch("/custom/projects/" + cid, { is_key: !p.is_key }); await afterWrite(); openCustomDrawer(cid, mode); }); on("#cdArchive", () => archiveCustomProject(p, () => { closeDrawer(); afterWrite(); })); on("#cdDelete", () => deleteCustomProject(p, () => { closeDrawer(); afterWrite(); })); } /* ------------------------------------------------ 录入方式(两类项目共用) */ /** 录入方式分段控件:直接写入(第一位)/ AI 整理 */ function intakeModeSegHtml() { const mode = intakeMode(); return `
录入方式
${mode === "ai" ? "AI 补出摘要 / 进度 / 状态 / 风险 / 下一步,正文保持你的原文,最终只写 1 条" : "填进去什么就存什么,不拆解、不改写、不动项目进度"}
`; } function intakeMode() { return state.intakeMode === "ai" ? "ai" : "direct"; } /** 切换录入方式:保留已填的原文,重画当前录入页 */ function bindIntakeMode(rawSel, rerender) { document.querySelectorAll("#intakeModeSeg [data-imode]").forEach((b) => b.addEventListener("click", () => { if (intakeMode() === b.dataset.imode) return; const keep = $(rawSel) ? $(rawSel).value : ""; state.intakeMode = b.dataset.imode; Promise.resolve(rerender()).then(() => { const el = $(rawSel); if (el) el.value = keep; }); }) ); } /* ------------------------------------------------ 定开项目录入 */ async function renderCustomIntake(host) { const projects = await api("/custom/projects"); const weeks = state.periods.weeks.slice().reverse(); const curPeriodId = state.gran === "week" ? state.weekId : currentMonth() ? currentMonth().period_ids[currentMonth().period_ids.length - 1] : null; host.innerHTML = `
${intakeModeSegHtml()}

定开项目更新录入${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", "customOrigin", "customAcceptPlan", "customSort", "staffArchived", "staffGroup"]; function saveUI() { const o = {}; UI_FIELDS.forEach((k) => { o[k] = state[k]; }); try { sessionStorage.setItem(UI_KEY, JSON.stringify(o)); } catch (e) { /* 存不了就算了 */ } } function restoreUI() { let o = null; try { o = JSON.parse(sessionStorage.getItem(UI_KEY) || "null"); } catch (e) { o = null; } if (!o) return; UI_FIELDS.forEach((k) => { if (k in o) state[k] = o[k]; }); if (["board", "projects", "custom", "focus", "staff", "intake", "settings"].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)}
`); }); })();