/* 软件开发部 · 关键任务追踪看板 */ (function () { "use strict"; const Ico = window.Icon; const state = { view: "board", gran: "week", weekId: null, monthIdx: null, categoryId: null, keyword: "", idle: "key", showArchived: false, meta: null, periods: null, categories: [], board: null, stats: null, projects: [], drawerProject: null, // 详情抽屉:周期反馈的显示粒度 / 范围 / 往前翻页偏移 dw: { group: "week", range: 2, back: 0 }, // 定开项目 customMeta: null, // 定开:项目明细 / 本周进展 两个 tab(原交付流水线 / 阶段看板 / 财务跟踪已下线) customTab: "list", customAnnualEdit: false, // 年度任务:默认查看模式,点「编辑」才可改 customRegion: "", customStage: "", customStatus: "", customOrigin: "", customSort: null, // { key: amount | sign_date | update_date, dir: asc | desc } customArchived: "false", customRows: [], intakeType: "general", // 更新录入的两种方式:direct = 直接写入(原样存 1 条);ai = AI 整理(补结构化字段,也只存 1 条) intakeMode: "direct", intakePeriodId: null, drawerCustom: null, // 周看板(左栏总览 + 右栏详情) weekly: null, weeklySel: null, // 项目管理 projOwner: "", projIdleOnly: false, // 每周重点工作 focusRows: [], focusFilter: "week", focusArchived: false, // 人力资源库 staff: [], staffDict: { groups: [], affiliations: [] }, staffArchived: "false", staffGroup: "", // 登录用户(含角色与菜单权限);未登录时为 null me: null, }; /* ---------------------------------------------------------------- utils */ const $ = (sel) => document.querySelector(sel); const $$ = (sel) => Array.prototype.slice.call(document.querySelectorAll(sel)); function esc(s) { if (s === null || s === undefined) return ""; return String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } const nl2br = (s) => esc(s).replace(/\n/g, "
"); const money = (n) => n === null || n === undefined ? "—" : "¥" + Number(n).toLocaleString("zh-CN", { maximumFractionDigits: 2 }); const wan = (n) => n === null || n === undefined ? "—" : (Number(n) / 10000).toFixed(1) + " 万"; const fen = (n) => (n === null || n === undefined ? "—" : (Number(n) / 10000).toFixed(2)); /** 重点标记:统一用星标图标,on = 已标重点 */ const starMark = (on, readonly, title) => `${Ico.svg("star")}`; async function api(path, opts) { const res = await fetch("/api" + path, Object.assign({ headers: { "Content-Type": "application/json" } }, opts || {})); if (!res.ok) { let msg = res.statusText; try { msg = (await res.json()).detail || msg; } catch (e) { /* ignore */ } // 会话过期 / 被管理员下线:任何请求都直接把界面切回登录页 if (res.status === 401 && path !== "/auth/login") showAuthGate(msg); const err = new Error(msg); err.status = res.status; throw err; } return res.json(); } const post = (path, body) => api(path, { method: "POST", body: JSON.stringify(body) }); const put = (path, body) => api(path, { method: "PUT", body: JSON.stringify(body) }); const patch = (path, body) => api(path, { method: "PATCH", body: JSON.stringify(body) }); const del = (path) => api(path, { method: "DELETE" }); let toastTimer = null; function toast(msg) { const t = $("#toast"); t.textContent = msg; t.classList.add("show"); clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove("show"), 2400); } /* ---------------------------------------------- 登录与菜单权限 */ /** 菜单权限:can = 能看(ro/rw),canW = 能改(只有 rw)。未登录一律 false。 */ function can(menu) { return !!(state.me && state.me.menus && state.me.menus[menu]); } const canR = can; function canW(menu) { return !!(state.me && state.me.menus && state.me.menus[menu] === "rw"); } function showAuthGate(msg) { $("#authGate").classList.remove("hidden"); const box = $("#authErr"); box.textContent = msg || ""; box.style.display = msg ? "" : "none"; const f = $("#authPass").value ? $("#authOk") : ($("#authUser").value ? $("#authPass") : $("#authUser")); setTimeout(() => f.focus(), 60); } function bindAuthForm() { $("#authForm").addEventListener("submit", async (e) => { e.preventDefault(); const btn = $("#authOk"); btn.disabled = true; try { await post("/auth/login", { username: $("#authUser").value.trim(), password: $("#authPass").value }); location.reload(); // 登录成功后整页重载,按新权限重新渲染 } catch (err) { const box = $("#authErr"); box.textContent = err.message; box.style.display = ""; btn.disabled = false; } }); } /** 只保留有权限的菜单;顶栏「更新录入」按钮同样跟着 intake 权限走 */ function applyNavPermissions() { $$("#nav .nav-item").forEach((b) => b.classList.toggle("hidden", !can(b.dataset.view))); $$("#nav .nav-label").forEach((l) => { let n = l.nextElementSibling, any = false; while (n && !n.classList.contains("nav-label")) { if (n.classList.contains("nav-item") && !n.classList.contains("hidden")) any = true; n = n.nextElementSibling; } l.classList.toggle("hidden", !any); }); $("#btnIntake").classList.toggle("hidden", !can("intake")); } /** 有权限的第一个菜单,用于回落 */ function firstAllowedView() { const b = $$("#nav .nav-item").filter((x) => can(x.dataset.view))[0]; return b ? b.dataset.view : null; } /* ---------------------------------------- 侧栏底部的用户胶囊 */ function renderUserChip() { const me = state.me; $("#sideUser").innerHTML = ` `; const menu = $("#userMenu"); $("#userChip").addEventListener("click", (e) => { e.stopPropagation(); menu.classList.toggle("hidden"); }); document.addEventListener("click", () => menu.classList.add("hidden")); $("#miProfile").addEventListener("click", openProfileDialog); $("#miLogout").addEventListener("click", doLogout); } /** 用户设置:改昵称 + 改自己的密码;角色由管理员在权限管理里分配 */ function openProfileDialog() { const me = state.me; openModal(` `); $("#pfOk").addEventListener("click", async () => { const btn = $("#pfOk"); btn.disabled = true; modalError(""); try { const name = $("#pfName").value.trim(); if (name && name !== me.name) state.me = await put("/auth/me", { name: name }); const nw = $("#pfNew").value.trim(); if (nw) await post("/auth/password", { old_password: $("#pfOld").value, new_password: nw }); renderUserChip(); closeModal(true); toast(nw ? "已保存,其他设备需重新登录" : "已保存"); } catch (e) { modalError(e.message); btn.disabled = false; } }); } async function doLogout() { try { await post("/auth/logout", {}); } catch (e) { /* 会话可能已经失效,忽略 */ } location.reload(); } /* ------------------------------------------------ 列表页统一骨架 */ /** 卡头 = 列表页的子 header:标题+计数(左)、筛选 chip(紧跟标题)、操作按钮(最右)。 工具栏不再单独占一行,所有页面统一走这里。 */ function cardHead(o) { const tools = [(o.fields || ""), (o.tools || "")].filter(Boolean).join(""); const cnt = o.count ? `${o.count}` : ""; // chipsRow:切 tab 的 chip 独占一行 header(标题与当前 tab 同名,省掉,计数跟在 chip 后), // 筛选器和主功能按钮放同一行。 if (o.chipsRow) { return `
${o.chips ? `
${o.chips}${cnt}
` : ""} ${tools ? `
${tools}
` : ""}
`; } return `

${esc(o.title)}${cnt}

${o.chips ? `
${o.chips}
` : ""} ${tools ? `
${tools}
` : ""}
`; } function listCard(o) { return `
${cardHead(o)} ${o.rows.length ? `
${o.table}
` : `
${o.empty}
`}
`; } /** 表格行内操作:全站统一「修改 / 归档 / 删除」三个图标按钮,名称点开详情。 事件属性名由调用方给出,沿用各表原有的绑定。 */ function rowOps(o) { // 只读角色不给行内「修改 / 归档 / 删除」 if (!canW(state.view)) return ""; const b = (attr, icon, title, cls) => ``; if (!o.edit && !o.archive && !o.remove) return ""; return `` + (o.edit ? b(o.edit, "pencil", "修改") : "") + (o.archive ? b(o.archive + ` data-archived="${o.archived ? "1" : "0"}"`, "archive", o.archived ? "恢复" : "归档") : "") + (o.remove ? b(o.remove, "trash", "删除", "danger") : "") + ``; } /* -------------------------------------- 周期记录(执行 / 交付)的新增·修改·删除 */ /** 记录条目右上角的操作按钮,鼠标移到条目上才显现 */ function updOps(kind, u) { if (!canW(kind === "custom" ? "custom" : "projects")) return ""; return ` `; } /** * 新增 / 修改一条周期记录,全站唯一入口(看板右栏、项目抽屉、定开抽屉共用)。 * kind = "project" 自研产品的执行记录,"custom" 定开项目的交付记录; * 传 update 就是修改,否则在 periodId 下新建;写完调 onDone 刷新所在区域。 */ function openUpdateDialog(o) { const isP = o.kind !== "custom"; const u = o.update || null; const weeks = state.periods.weeks || []; const periodId = u ? u.period_id : o.periodId; const stages = (state.customMeta && state.customMeta.stages) || []; const nv = (v) => (v === null || v === undefined ? "" : String(v)); // 新建时沿用项目当前的状态 / 阶段:后端会按记录回写档案,别让默认值改坏台账 const defStatus = u ? u.status : o.status || "in_progress"; const defStage = u ? u.stage : o.stage || ""; // 计收 / 下单增量在库里存的是元,页面上统一按万元填写 const toWan = (v) => (v === null || v === undefined ? "" : (Number(v) / 10000).toFixed(2)); const fields = isP ? `
` : `
`; openModal( ` ` ); const numVal = (id) => { const el = $(id); return el.value === "" ? null : Number(el.value); }; const wanVal = (id) => { const el = $(id); return el.value === "" ? null : Math.round(Number(el.value) * 10000); }; $("#mOk").addEventListener("click", async () => { const content = $("#udContent").value.trim(); if (!content) return modalError("请填写内容"); const btn = $("#mOk"); btn.disabled = true; const body = { period_id: Number($("#udPeriod").value), content: content, progress: numVal("#udProgress"), risk: $("#udRisk").value.trim() || null, next_step: $("#udNext").value.trim() || null, }; try { if (isP) { body.status = $("#udStatus").value; body.task_name = $("#udTask").value.trim(); if (u) await patch("/updates/" + u.id, body); else await post("/updates", Object.assign({ project_id: o.projectId, source: "manual" }, body)); } else { body.stage = $("#udStage").value; body.revenue_delta = wanVal("#udRevenue"); body.amount_delta = wanVal("#udAmount"); if (u) await patch("/custom/updates/" + u.id, body); else await post("/custom/updates", Object.assign({ custom_project_id: o.customProjectId, source: "manual" }, body)); } } catch (e) { btn.disabled = false; return modalError(e.message); } closeModal(); toast(u ? "已修改" : "已添加"); await afterWrite(); if (o.onDone) await o.onDone(); }); } /** 给一块区域里的周期记录绑上「修改 / 删除」。list 用来按 id 找回记录,reload 刷新这块区域。 */ function bindUpdateOps(root, list, reload) { root.querySelectorAll("[data-uop]").forEach((b) => b.addEventListener("click", async () => { const id = Number(b.dataset.uid); const kind = b.dataset.ukind; const u = (list || []).find((x) => x.id === id); if (!u) return; if (b.dataset.uop === "edit") { openUpdateDialog({ kind: kind, update: u, projectId: u.project_id, customProjectId: u.custom_project_id, onDone: reload, }); return; } const ok = await confirmDialog({ title: "删除这条记录?", body: `
删除后无法恢复。
${esc([u.period_full, u.task_name || u.project_name].filter(Boolean).join(" · "))}
`, okText: "永久删除", danger: true, }); if (!ok) return; try { await del(kind === "custom" ? "/custom/updates/" + id : "/updates/" + id); } catch (e) { toast("删除失败:" + e.message); return; } toast("已删除"); await afterWrite(); if (reload) await reload(); }) ); } /** 页内搜索框:与右上角全局搜索共用 state.keyword */ function keywordInputHtml(id, placeholder, width) { return ``; } /** 绑定页内搜索。 默认:防抖实时搜索;opts.enter=true 时只在「回车 / 清空(×)」后才查询。 重绘后把光标放回搜索框,连续操作不中断。 */ function bindKeyword(id, opts) { opts = opts || {}; const el = $("#" + id); if (!el) return; const apply = (v) => { v = (v || "").trim(); if (v === state.keyword) return; keywordFocus = true; state.keyword = v; $("#globalSearch").value = v; refresh(); }; if (opts.enter) { // 输入阶段不查询;回车提交后才检索 el.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); apply(el.value); } }); // type=search 的清除「×」会触发 search 事件 el.addEventListener("search", () => apply(el.value)); } else { let t = null; el.addEventListener("input", (e) => { clearTimeout(t); const v = e.target.value.trim(); t = setTimeout(() => apply(v), 320); }); } if (keywordFocus) { keywordFocus = false; el.focus(); el.setSelectionRange(el.value.length, el.value.length); } } /* ---------------------------------------------------- 弹窗 / 确认框 */ let modalResolve = null; function openModal(html, onMount) { $("#modalMask").classList.add("open"); $("#modal").innerHTML = html; if (onMount) onMount(); const f = $("#modal").querySelector("[data-autofocus]"); if (f) f.focus(); } /** 关闭弹窗;result 省略时按「取消」处理 */ function closeModal(result) { if (!$("#modalMask").classList.contains("open")) return; $("#modalMask").classList.remove("open"); $("#modal").innerHTML = ""; const cb = modalResolve; modalResolve = null; if (cb) cb(result === undefined ? false : result); } function modalError(msg) { const e = $("#mErr"); if (!e) return; e.textContent = msg || ""; e.style.display = msg ? "" : "none"; } /** 需要用户明确点确认才继续;requireText 时还必须逐字输入该文本 */ function confirmDialog(o) { return new Promise((resolve) => { openModal( ` ` ); 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() { if (!canR("staff")) return null; // 没有花名册权限时不请求,负责人下拉留空 const d = await api("/staff?archived=all"); state.staff = d.items; state.staffDict = d.dict; return d; } /* ---------------------------------------------------------------- scope */ function scopeQuery(extra) { const p = []; if (state.gran === "week") { if (state.weekId) p.push("period_id=" + state.weekId); } else { const m = currentMonth(); if (m) p.push("year=" + m.year, "month=" + m.month); } if (state.keyword) p.push("keyword=" + encodeURIComponent(state.keyword)); if (extra) p.push.apply(p, extra); return p.length ? "?" + p.join("&") : ""; } function currentMonth() { if (!state.periods || state.monthIdx === null) return null; return state.periods.months[state.monthIdx] || null; } function scopePeriodIds() { if (state.gran === "week") return state.weekId ? [state.weekId] : []; const m = currentMonth(); return m ? m.period_ids : []; } /** 顶栏当前选中周期(周 = 那一周,月 = 该月各周的并集)的实际起止日期,口径同后端 /periods */ function scopeRange() { if (state.gran === "week") { const w = (state.periods.weeks || []).find((x) => x.id === state.weekId); return w && w.first_day ? { start: w.first_day, end: w.last_day, label: w.full_label } : null; } const m = currentMonth(); return m && m.first_day ? { start: m.first_day, end: m.last_day, label: m.label } : null; } /* ---------------------------------------------------------------- charts */ function barChart(items, opts) { opts = opts || {}; const W = 660, H = opts.height || 190, padL = 34, padB = 30, padT = 14; const innerW = W - padL - 12, innerH = H - padB - padT; const max = Math.max(1, ...items.map((d) => d.value)); const n = items.length || 1; const slot = innerW / n; const bw = Math.min(38, slot * 0.6); let bars = ""; items.forEach((d, i) => { const h = Math.max(2, (d.value / max) * innerH); const x = padL + slot * i + (slot - bw) / 2; const y = padT + innerH - h; bars += `${esc(d.label)}: ${d.value}${esc(opts.unit || "")}`; if (d.value > 0) { bars += `${d.value}`; } bars += `${esc(d.label)}`; }); let grid = ""; for (let g = 0; g <= 3; g++) { const y = padT + (innerH / 3) * g; grid += ``; grid += `${Math.round(max - (max / 3) * g)}`; } return `${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} 个
执行记录
${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}

周期更新活跃度

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

项目分类分布

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

执行状态分布

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

风险与阻塞

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

低进度预警

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

最近更新

${s.recent_updates.length ? s.recent_updates.map((u) => `
${esc(u.project_name)}${statusPill(u.status)}${esc(u.task_name || "")}${esc(u.period_label || "")}
${nl2br((u.summary || u.content || "").slice(0, 160))}
`).join("") : `
暂无
`}
`; } /* ---------------------------------------------------------------- 周看板 */ /** 左栏一行:重点项目 / 定开项目共用 */ function weeklyItem(r) { const sel = state.weeklySel; const active = sel && sel.kind === r.kind && sel.id === r.id; const color = r.kind === "project" ? ((r.category && r.category.color) || "#2563eb") : stageMeta(r.stage).color; const badge = r.has_update ? `${r.update_count} 条` : `未更新`; return `
${r.is_key ? starMark(true) : ""}${esc(r.name)} ${badge}
${r.kind === "project" ? statusPill(r.status) : stagePill(r.stage)} ${esc(r.owner || "未指派")} ${r.kind === "project" ? `${r.progress}%` : `${fen(r.amount)} 万`} ${r.risks.length ? `风险 ${r.risks.length}` : ""}
`; } /** 交付环节步骤条:日期可直接补录,补了日期后端会自动推进商务阶段 */ function pipelineBar(p, readonly) { const steps = (state.customMeta && state.customMeta.pipeline) || []; return `
${steps.map((s, i) => ` ${i ? `
${Ico.svg("chevron-right")}
` : ""}
${Ico.pipe(s.code)}${esc(s.name)}
${readonly ? `
${p[s.date_field] ? esc(p[s.date_field]) : "未填"}
` : ``}
`).join("")}
`; } /** 一条执行 / 交付记录;传 kind 才给行内「修改 / 删除」 */ function updateItem(u, kind) { return `
${esc(u.task_name || u.period_full || "记录")} ${u.stage ? stagePill(u.stage) : statusPill(u.status)} ${u.progress !== null && u.progress !== undefined ? `${u.progress}%` : ""} ${u.task_name && u.period_full ? `${esc(u.period_label || "")}` : ""} ${kind ? updOps(kind, u) : ""}
${nl2br(u.summary || u.content || "(无正文)")}
${u.risk ? `
${Ico.svg("alert-circle")}
${nl2br(u.risk)}
` : ""} ${u.next_step ? `
${Ico.svg("arrow-right")}
${nl2br(u.next_step)}
` : ""}
`; } function weeklyHead(row) { const isP = row.kind === "project"; const color = isP ? ((row.category && row.category.color) || "#2563eb") : stageMeta(row.stage).color; const menu = isP ? "projects" : "custom"; return `
${Ico.badge(isP ? Ico.nameOfStatus(row.status) : Ico.nameOfStage(row.stage))}${row.is_key ? starMark(true) : ""}${esc(row.name)}
${isP ? catPill(row.category) + " " + statusPill(row.status) : stagePill(row.stage)} ${esc(row.owner || "未指派")} 最近更新 ${esc(row.last_update || "尚无记录")} ${row.idle_weeks ? `已 ${row.idle_weeks} 周没更新` : ""}
${canW(menu) ? `` : ""}
`; } /* 右栏:常规项目详情 */ async function weeklyProjectDetail(row) { const d = await api("/projects/" + row.id); const p = d.project; const color = (p.category && p.category.color) || "#2563eb"; const trend = d.timeline.slice(-8).reverse(); return ` ${weeklyHead(row)}
本期执行记录(${row.updates.length})
${row.updates.length ? row.updates.map((x) => updateItem(x, "project")).join("") : `
本周期还没有执行记录。重点项目每周都要更新一次,点上方「添加执行记录」补上。
`}
进度与状态
整体进度${row.progress}%
${d.tasks.length ? `
${d.tasks.map((t) => `${esc(t.name)}`).join("")}
` : ""}
${trend.length ? `
近 ${trend.length} 期趋势
${trend.map((t) => `
${esc(t.period.full_label.replace(/^\d+年/, ""))}
${statusPill(t.status)}${t.progress !== null ? ` ${t.progress}%` : ""} ${t.updates.length} 条
`).join("")}
` : ""}`; } /* 右栏:定开项目详情(交付环节 + 财务) */ async function weeklyCustomDetail(row) { const d = await api("/custom/projects/" + row.id); const p = d.project; const hist = d.updates.slice(-5).reverse(); return ` ${weeklyHead(row)}
交付环节
${pipelineBar(p, !canW("custom"))} ${p.remark ? `
${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((x) => updateItem(x, "custom")).join("") : `
本周期没有交付记录。
`}
${hist.length ? `
更早的交付记录
${hist.map((u) => `
${esc((u.period_full || "").replace(/^\d+年/, ""))}
${stagePill(u.stage)}${u.revenue_delta ? ` 计收 ${fen(u.revenue_delta)}万` : ""}
`).join("")}
` : ""}`; } function bindWeeklyDetail(box, row) { // 写入后 afterWrite() 会重绘看板并保留当前选中项,这里不用再自己刷新 const add = $("#wkAddUpd"); if (add) add.addEventListener("click", () => openUpdateDialog({ kind: row.kind, periodId: state.weekId, projectId: row.id, customProjectId: row.id, status: row.status, stage: row.stage, }) ); bindUpdateOps(box, row.updates); const dw = $("#wkDrawer"); if (dw) dw.addEventListener("click", () => row.kind === "project" ? openDrawer(row.id) : openCustomDrawer(row.id) ); box.querySelectorAll("[data-cf]").forEach((inp) => { if (!canW("custom")) { inp.disabled = true; return; } inp.addEventListener("change", async () => { const f = inp.dataset.cf; let v = inp.value; if (f === "invoice_amount" || f === "received_amount") v = v === "" ? null : Number(v); try { await patch("/custom/projects/" + row.id, { [f]: v }); toast("已保存"); await afterWrite(); } catch (e) { toast("保存失败:" + e.message); } }); }); } /** 定开项目本周动态里的一条:整行可点,点进去看该项目详情 */ function weeklyCustomRow(r, tag) { return `
${r.is_key ? starMark(true) : ""}${esc(r.name)} ${tag || ""}
${stagePill(r.stage)} ${esc(r.owner || "未指派")} ${fen(r.amount)} 万 ${r.period_revenue_delta ? `计收 ${fen(r.period_revenue_delta)} 万` : ""} ${(r.risks || []).length ? `风险 ${(r.risks || []).length}` : ""}
${r.summary ? `
${esc(String(r.summary).replace(/\s+/g, " ").slice(0, 90))}
` : ""}
`; } /** 右栏:定开项目本周动态。左栏的「定开项目」只给入口,清单都收在这里 */ function weeklyCustomOverview(data) { // 「本周新增 / 本周验收」与定开项目的「本周进展」tab 共用同一套口径 const { added, accepted, listed } = customWeeklySections(data.custom, scopeRange()); // 高风险取全部在档项目:/weekly 的 custom 只有本期有动态的,取全量才不会漏 const risky = (state.customAll || data.custom).filter((r) => isHighRisk(r)) .sort((a, b) => Number(b.amount || 0) - Number(a.amount || 0)); const RISK_SHOW = 10; // 高风险项目可能几十条,卡片里按合同额只显示前若干个 // 合计只算列出来的行(同一项目可能同时出现在新增与验收两段,这里按 id 去重),免得数字对不上清单 const sum = listed.reduce((a, r) => a + Number(r.amount || 0), 0); const sec = (title, sub, list, tag) => `
${title}
${list.length ? list.map((r) => weeklyCustomRow(r, tag(r))).join("") : `
本周期没有
`}
`; return `
${Ico.badge("package")}定开项目 · 本周动态
本周新增 ${added.length} 个 本周验收 ${accepted.length} 个 高风险 ${risky.length} 个 合同额合计 ${fen(sum)} 万 本期计收 ${fen(data.kpi.revenue_delta)} 万
${sec("本周新增", "下单时间落在本周期(已下单 / 提前交付)", added, (r) => `${esc(String(r.sign_date || "").slice(0, 10) || "本期下单")}`)} ${sec("本周验收", "更新时间落在本周期且状态为已验收", accepted, (r) => `${esc(String(r.update_date || "").slice(0, 10) || "本期验收")}`)} ${sec("高风险项目", "风险值为「高风险」的在档定开项目", risky.slice(0, RISK_SHOW), (r) => (r.project_status ? `${esc(r.project_status)}` : ""))} ${risky.length > RISK_SHOW ? `
共 ${risky.length} 个高风险项目,按合同额显示前 ${RISK_SHOW} 个
` : ""}`; } function bindWeeklyCustomOverview(box) { const gc = box.querySelector("#wkGoCustom"); if (gc) gc.addEventListener("click", () => setView("custom")); box.querySelectorAll("[data-cpick]").forEach((el) => el.addEventListener("click", () => { state.weeklySel = { kind: "custom", id: Number(el.dataset.cpick) }; renderWeeklyDetail(); }) ); } async function renderWeeklyDetail() { const box = $("#wkRight"); if (!box) return; const sel = state.weeklySel; if (sel && sel.kind === "focus") { const f = (state.focusRows || []).find((x) => x.id === sel.id); box.innerHTML = f ? focusDetailHtml(f) : `
在左侧选择一条重点工作查看详情
`; const eb = $("#wkFocusEdit"); if (eb) eb.addEventListener("click", () => openFocusDrawer(sel.id, "edit")); return; } if (sel && sel.kind === "customs") { box.innerHTML = weeklyCustomOverview(state.weekly); bindWeeklyCustomOverview(box); return; } const row = sel && (sel.kind === "project" ? state.weekly.projects : state.weekly.custom) .find((r) => r.id === sel.id); if (!row) { box.innerHTML = `
在左侧选择一个项目查看执行详情
`; return; } box.innerHTML = `
加载中…
`; try { box.innerHTML = row.kind === "project" ? await weeklyProjectDetail(row) : await weeklyCustomDetail(row); bindWeeklyDetail(box, row); } catch (e) { box.innerHTML = `
加载失败:${esc(e.message)}
`; } } async function renderBoard() { saveUI(); const host = $("#view-board"); host.innerHTML = `
加载中…
`; if (!state.customMeta) state.customMeta = await api("/custom/meta"); const [data, focusRows, customAll] = await Promise.all([ api("/weekly" + scopeQuery()), api("/focus" + focusQuery(["archived=false"])), // 「本季度验收」与「定制项目 · 项目总览」同一口径:按「验收时间」(Q1~Q4 季度文本) api("/custom/projects?archived=false"), ]); const now = new Date(); const qYear = now.getFullYear(), qNo = Math.floor(now.getMonth() / 3) + 1; const qOf = (s) => { const t = String(s || "").trim().toUpperCase(); return /^Q[1-4]$/.test(t) ? t : ""; }; const accQuarter = customAll.filter((p) => qOf(p.accept_date) === "Q" + qNo); const accQuarterAmount = accQuarter.reduce((a, p) => a + Number(p.amount || 0), 0); const accYearCount = customAll.filter((p) => qOf(p.accept_date)).length; state.weekly = data; state.customAll = customAll; // 右栏「高风险项目」用全量台账 state.focusRows = focusRows; const k = data.kpi; const focus = focusBuckets(focusRows); // 默认选中本期有记录的第一条,选中项还在列表里就保持不变 const all = data.projects.concat(data.custom); const cur = state.weeklySel; const keep = cur && (cur.kind === "customs" ? true : cur.kind === "focus" ? focusRows.some((f) => f.id === cur.id) : all.some((r) => r.kind === cur.kind && r.id === cur.id)) ? cur : null; const pick = keep || all.find((r) => r.has_update) || all[0] || null; state.weeklySel = pick ? { kind: pick.kind, id: pick.id } : null; const cSec = customWeeklySections(data.custom, scopeRange()); const cNew = cSec.added, cAcc = cSec.accepted, cRisk = customAll.filter((p) => isHighRisk(p)).length; const cDyn = cSec.listed.length; const kpi = `
${Ico.badge("star")}
重点项目
${k.key_total}
本期已更新 ${k.key_updated}
${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")}
本季度验收(${qYear}年Q${qNo})
${fen(accQuarterAmount)} 万
${accQuarter.length} 个项目 · 本年验收 ${accYearCount} 个
${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 openCustom = cur && (cur.kind === "customs" || cur.kind === "custom"); host.innerHTML = kpi + `
本周工作重点${focusList.length} ${focus.overdue.length ? focus.overdue.length + " 条延期" : "已完成 " + focus.done.length + " 条"}
${focusList.length ? focusList.map(focusItem).join("") : `
本周没有安排重点工作
`}
${group("重点项目", data.projects, "个已更新")}
定开项目${data.custom.length} 本周动态 ${cDyn} 个
${Ico.svg("package")} 本周定开项目动态 ${Ico.svg("arrow-right")}
新增 ${cNew.length} 验收 ${cAcc.length} 高风险 ${cRisk}
加载中…
`; // 统计概览(原「执行面板」)折在最底下,展开才看;失败也不影响看板主体 dashboardHtml().then((html) => { host.insertAdjacentHTML("beforeend", `
统计概览
${html}
`); }).catch((e) => { host.insertAdjacentHTML("beforeend", `
统计概览加载失败:${esc(e.message)}
`); }); host.querySelectorAll("[data-wk]").forEach((el) => el.addEventListener("click", () => { const parts = el.dataset.wk.split(":"); state.weeklySel = { kind: parts[0], id: Number(parts[1]) }; host.querySelectorAll("[data-wk]").forEach((x) => x.classList.toggle("active", x === el)); renderWeeklyDetail(); }) ); renderWeeklyDetail(); } /* ------------------------------------------------------------ 每周重点工作 */ /** 重点工作一律按「周」跟踪,基准周取顶栏当前选中的那一周 */ function focusWeek() { return (state.periods.weeks || []).find((w) => w.id === state.weekId) || null; } function focusQuery(extra) { const p = extra ? extra.slice() : []; if (state.keyword) p.push("keyword=" + encodeURIComponent(state.keyword)); return p.length ? "?" + p.join("&") : ""; } /** 分堆按「录入周」(f.sort_key 就是条目录入的那一周),和预计完成时间无关: week = 本周录入的;overdue = 更早周次录入、标了延期又没做完的结转条目。 「已完成」是交叉视图,已完成条目仍留在自己那一周里,避免录完就看不见。 更早周次里没标延期的未完成条目不进当周视图,只能在「全部」里查得到。 */ function focusBuckets(rows) { const w = focusWeek(); const cur = w ? w.sort_key : 0; const out = { cur: cur, overdue: [], week: [], done: [] }; rows.forEach((f) => { if (f.sort_key === cur) out.week.push(f); else if (f.sort_key < cur && focusIsLate(f)) out.overdue.push(f); if (f.status === "done") out.done.push(f); }); return out; } /** 是否延期:录入时人工打开的开关,不再按日期自动判定 */ function focusIsLate(f) { return f.status !== "done" && !!f.delayed; } /** 本地今天(YYYY-MM-DD),新增条目的预计完成时间默认值 */ function todayIso() { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } /** 预计完成时间:有日历日期就显示日期,早期没日期的条目退回显示录入周 */ function focusDueText(f) { return f.due_date || f.period_full || "—"; } /** 状态徽章:打开「是否延期」的未完成条目挂一个「延期」 */ function focusPill(f) { return (focusIsLate(f) ? `延期` : "") + statusPill(f.status); } /** 工作看板左栏的一条重点工作 */ function focusItem(f) { const sel = state.weeklySel; const late = focusIsLate(f); return `
${Ico.status(f.status)} ${esc(f.content)} ${late ? `延期` : ""}
${statusPill(f.status)} 预计 ${esc(focusDueText(f))} ${late ? `录入 ${esc(f.period_label || "—")}` : ""}
`; } /** 工作看板右栏的一条重点工作(只读,改内容走抽屉) */ function focusDetailHtml(f) { const ro = (label, val) => `
${val}
`; return `
${Ico.statusBadge(f.status)}${esc(f.content)}
${focusPill(f)} 预计完成 ${esc(focusDueText(f))}
${canW("focus") ? `
` : ""}
说明
${f.detail ? nl2br(f.detail) : `
未填写说明
`}
结果反馈
${f.result ? nl2br(f.result) : `
未填写结果反馈
`}
`; } async function renderFocus() { saveUI(); const host = $("#view-focus"); host.innerHTML = `
加载中…
`; const rows = await api("/focus" + focusQuery(["archived=" + (state.focusArchived ? "all" : "false")])); state.focusRows = rows; const b = focusBuckets(rows); const shown = state.focusFilter === "all" ? rows : b[state.focusFilter] || []; const filters = [["week", "本周"], ["overdue", "延期"], ["done", "已完成"], ["all", "全部"]]; const chips = filters.map((x) => ``).join(""); const table = ` ${shown.map((f) => ` `).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) || "—")} ${rowOps({ edit: `data-fedit="${f.id}"`, archive: `data-farch="${f.id}"`, archived: f.archived, remove: `data-fdel="${f.id}"`, })}
`; const body = listCard({ title: "重点工作清单", count: rows.length, chips: chips, fields: ``, tools: `${keywordInputHtml("fKeyword", "搜索工作内容…", 180)} ${canR("focus") ? `` : ""} ${canW("focus") ? `` : ""}`, rows: shown, table: table, empty: state.keyword ? "没有匹配「" + esc(state.keyword) + "」的重点工作" : state.focusFilter === "week" ? "本周还没有录入重点工作,点右上角「新增重点工作」添加" : "这一堆还没有条目,点右上角「新增重点工作」添加", }); host.innerHTML = body; bindKeyword("fKeyword"); host.querySelectorAll("[data-ffilter]").forEach((c) => c.addEventListener("click", () => { state.focusFilter = c.dataset.ffilter; renderFocus(); }) ); $("#fcArchived").addEventListener("change", (e) => { state.focusArchived = e.target.checked; renderFocus(); }); const bex = $("#btnExportFocus"); if (bex) bex.addEventListener("click", () => download("/api/export/focus" + focusQuery(["archived=" + (state.focusArchived ? "all" : "false")])) ); host.querySelectorAll("[data-fopen]").forEach((el) => el.addEventListener("click", () => openFocusDrawer(Number(el.dataset.fopen), "view")) ); host.querySelectorAll("[data-fedit]").forEach((el) => el.addEventListener("click", () => openFocusDrawer(Number(el.dataset.fedit), "edit")) ); host.querySelectorAll("[data-farch]").forEach((el) => el.addEventListener("click", async () => { const toArchive = el.dataset.archived !== "1"; try { await patch("/focus/" + el.dataset.farch, { archived: toArchive }); } catch (e) { toast("操作失败:" + e.message); return; } toast(toArchive ? "已归档(勾选「含归档条目」可查看)" : "已恢复"); await afterWrite(); }) ); host.querySelectorAll("[data-fdel]").forEach((el) => el.addEventListener("click", () => { const row = rows.find((x) => x.id === Number(el.dataset.fdel)); if (row) deleteFocus(row); }) ); const bad = $("#btnAddFocus"); if (bad) bad.addEventListener("click", () => openFocusDrawer(null, "create")); } /** 删除重点工作条目:列表和抽屉共用。条目不是项目,危险确认即可,不要求逐字输入 */ async function deleteFocus(f) { const ok = await confirmDialog({ title: "删除重点工作条目", danger: true, okText: "永久删除", body: `
「${esc(f.content)}」将被删除,删除后无法恢复。
只是这一周不再跟进?请改用「归档」。
`, }); if (!ok) return; try { await del("/focus/" + f.id); } catch (e) { toast("删除失败:" + e.message); return; } toast("已删除「" + f.content + "」"); closeDrawer(); await afterWrite(); } /** 重点工作抽屉:新增走单独面板,详情只读、修改才出表单;基础信息的动作一律在面板顶部 */ async function openFocusDrawer(fid, mode) { mode = mode === "create" ? "create" : mode === "edit" ? "edit" : "view"; if (!canW("focus")) mode = "view"; $("#drawerMask").classList.add("open"); $("#drawer").classList.add("open"); $("#drawerBody").innerHTML = `
加载中…
`; drawerActions(""); let f = null; if (mode === "create") { f = { content: "", detail: "", period_id: state.weekId, status: state.meta.statuses[0].code, archived: false }; } else { try { const rows = await api("/focus?archived=all"); f = rows.find((x) => x.id === fid) || null; } catch (e) { $("#drawerBody").innerHTML = `
加载失败:${esc(e.message)}
`; return; } if (!f) { $("#drawerBody").innerHTML = `
条目不存在,可能已被删除
`; return; } } // 录入周 = 顶栏当前选中的那一周(新增时定下来,之后不随预计完成时间变) const w = focusWeek(); const entryWeek = mode === "create" ? (w ? w.full_label : "顶栏当前周") : (f.period_full || f.period_label || "—"); $("#drawerTitle").innerHTML = mode === "create" ? `新增每周重点工作` : `${esc(f.content)}`; $("#drawerSub").innerHTML = mode === "create" ? `一条就是一个活儿,不用先建项目 · 录入周 ${esc(entryWeek)}` : `${focusPill(f)} · 录入 ${esc(f.period_full || f.period_label || "—")} · 预计完成 ${esc(focusDueText(f))}`; const ro = (label, val) => `
${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", " ") || "—"))}
` : `
基本信息
${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 deleteProject(p) { const ok = await confirmDialog({ title: "删除项目", danger: true, okText: "永久删除", requireText: p.name, body: `
将连同 ${p.task_count} 个子任务、${p.update_count} 条执行记录一并删除,删除后无法恢复。
只是暂时不想在看板里看到它?请改用「归档」,数据全部保留。
`, }); if (!ok) return; try { const r = await del("/projects/" + p.id); toast(`已删除「${r.name}」:${r.deleted_tasks} 个子任务 / ${r.deleted_updates} 条记录`); closeDrawer(); await afterWrite(); } catch (e) { toast("删除失败:" + e.message); } } async function renderProjects() { saveUI(); const host = $("#view-projects"); host.innerHTML = `
加载中…
`; const extra = ["archived=" + (state.showArchived ? "all" : "false")]; if (state.categoryId) extra.push("category_id=" + state.categoryId); if (state.projOwner) extra.push("owner=" + encodeURIComponent(state.projOwner)); const rows = await api("/projects" + scopeQuery(extra)); const shown = state.projIdleOnly ? rows.filter((r) => !r.updated_this_period) : rows; const idle = rows.filter((r) => !r.updated_this_period).length; const ownerFilter = ``; const chips = ``; // 原来挂在左侧菜单栏的分类筛选器,收进卡头,能力不变 const catFilter = ``; const table = ` ${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) || "—")} ${rowOps({ edit: `data-pedit="${p.id}"`, archive: `data-parch="${p.id}"`, archived: p.archived, remove: `data-pdel="${p.id}"`, })}
`; const body = listCard({ title: "重点项目明细", count: rows.length, chips: chips, fields: ``, tools: `${keywordInputHtml("pKeyword", "搜索项目名称…", 180)} ${catFilter} ${ownerFilter} ${canR("projects") ? `` : ""} ${canW("projects") ? `` : ""}`, rows: shown, table: table, empty: state.keyword ? "没有匹配「" + esc(state.keyword) + "」的项目" : "还没有项目", }); host.innerHTML = body; bindKeyword("pKeyword", { enter: true }); const pc = $("#pCat"); if (pc) pc.addEventListener("change", () => { state.categoryId = pc.value ? Number(pc.value) : null; renderProjects(); }); host.querySelectorAll("[data-pidle]").forEach((b) => b.addEventListener("click", () => { state.projIdleOnly = !state.projIdleOnly; renderProjects(); }) ); const po = $("#pOwner"); if (po) po.addEventListener("change", () => { state.projOwner = po.value; renderProjects(); }); host.querySelectorAll("[data-popen]").forEach((a) => a.addEventListener("click", () => openDrawer(Number(a.dataset.popen), "view")) ); host.querySelectorAll("[data-pedit]").forEach((b) => b.addEventListener("click", () => openDrawer(Number(b.dataset.pedit), "edit")) ); host.querySelectorAll("[data-parch]").forEach((b) => b.addEventListener("click", async () => { const toArchive = b.dataset.archived !== "1"; await patch("/projects/" + b.dataset.parch, { archived: toArchive }); toast(toArchive ? "已归档(勾选「含归档项目」可查看)" : "已恢复"); await afterWrite(); }) ); host.querySelectorAll("[data-pdel]").forEach((b) => b.addEventListener("click", () => { const p = shown.find((x) => x.id === Number(b.dataset.pdel)); if (p) deleteProject(p); }) ); $("#chkArchived").addEventListener("change", (e) => { state.showArchived = e.target.checked; renderProjects(); }); const bex = $("#btnExportProjects"); if (bex) bex.addEventListener("click", () => { const q = ["archived=" + (state.showArchived ? "all" : "false")]; if (state.categoryId) q.push("category_id=" + state.categoryId); if (state.keyword) q.push("keyword=" + encodeURIComponent(state.keyword)); download("/api/export/projects?" + q.join("&")); }); const bn = $("#btnNewProject"); if (bn) bn.addEventListener("click", () => { openModal( ` ` ); $("#mOk").addEventListener("click", async () => { const name = $("#npName").value.trim(); if (!name) return modalError("请填写项目名称"); const btn = $("#mOk"); btn.disabled = true; try { await post("/projects", { name: name, category_id: $("#npCat").value ? Number($("#npCat").value) : null, owner: $("#npOwner").value || null, is_key: $("#npKey").checked, }); closeModal(); toast("已创建「" + name + "」"); await afterWrite(); } catch (e) { btn.disabled = false; modalError(e.message); } }); $("#npName").addEventListener("keydown", (e) => { if (e.key === "Enter") $("#mOk").click(); }); }); } /* ---------------------------------------------------------------- 定开项目 */ function stageMeta(code) { const list = (state.customMeta && state.customMeta.stages) || []; return list.find((s) => s.code === code) || { code, name: code, color: "#94a3b8" }; } function stagePill(code) { const s = stageMeta(code); return `${Ico.stage(code)}${esc(s.name)}`; } /* ---- 市场台账(《定开项目.xlsx》)的小色块 ---- */ const CUSTOM_STATUS_COLOR = { 已验收: "#16a34a", 交付完成: "#0891b2", 进行中: "#2563eb", 待开发: "#b45309", 退单: "#7a8394", }; /** 台账里的项目状态:已验收/交付完成/进行中/待开发/退单,和项目状态机不是一套 */ function customStatusPill(code) { if (!code) return `—`; const color = CUSTOM_STATUS_COLOR[code] || "#7a8394"; return `${esc(code)}`; } /** 风险值是否「高风险」(台账取值为 1.高风险 / 2.低风险) */ const isHighRisk = (p) => String(p.risk_level || "").includes("高"); /** 风险值:1.高风险 红 / 2.低风险 绿 */ function riskPill(code) { if (!code) return `—`; const high = String(code).includes("高"); const color = high ? "#dc2626" : "#16a34a"; const text = String(code).replace(/^\d+\./, ""); return `${esc(text)}`; } let keywordFocus = false; /** 定开「项目归属」取值:前三项视为「已下单」,提前交付为下单前状态 */ const CUSTOM_ORIGINS = ["华智下单", "存量项目", "汇智直签", "提前交付"]; const CUSTOM_PLACED = ["华智下单", "存量项目", "汇智直签"]; const isPlaced = (p) => CUSTOM_PLACED.indexOf(p.origin) >= 0; async function renderCustom() { saveUI(); const host = $("#view-custom"); host.innerHTML = `
加载中…
`; if (!state.customMeta) state.customMeta = await api("/custom/meta"); // 一次性取「全部定开项目」(不带搜索/筛选条件): // 列表在本地按关键字/区域/状态筛选,而统计条始终基于全部数据。 const allRows = await api("/custom/projects?archived=" + state.customArchived); state.customRows = allRows; // 区域筛选:用规范名称、按大区分组,只列出实际有项目的区域 const usedSet = {}; (state.customMeta.regions || []).forEach((r) => { usedSet[r] = true; }); const stdAll = (state.customMeta.region_groups || []).reduce((a, g) => a.concat(g.regions), []); let regionFilterHtml = ``; (state.customMeta.region_groups || []).forEach((g) => { const hit = g.regions.filter((r) => usedSet[r]); if (!hit.length) return; regionFilterHtml += `` + 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 isList = state.customTab !== "weekly" && state.customTab !== "annual"; // 三个 tab 用顶部「周/月」那套分段控件,筛选器和主按钮(导出 / 新建)放下一行 const head = { chips: `
`, tools: isList ? ` ${keywordInputHtml("cKeyword", "搜索项目名称…(回车查询)", 200)} ${canR("custom") ? `` : ""} ${canW("custom") ? `` : ""}` : "", }; host.innerHTML = ( state.customTab === "weekly" ? renderCustomWeekly(allRows, head) : state.customTab === "annual" ? renderCustomAnnual(allRows, head) : renderCustomList(allRows, head)); host.querySelectorAll("[data-ctab]").forEach((b) => b.addEventListener("click", () => { if (b.dataset.ctab !== state.customTab) state.customAnnualEdit = false; // 切 tab 退出年度任务的编辑态 state.customTab = b.dataset.ctab; renderCustom(); }) ); const os = $("#cOrigin"); if (os) os.addEventListener("change", () => { state.customOrigin = os.value; renderCustom(); }); const rs = $("#cRegion"); if (rs) rs.addEventListener("change", () => { state.customRegion = rs.value; renderCustom(); }); const st = $("#cStatus"); if (st) st.addEventListener("change", () => { state.customStatus = st.value; renderCustom(); }); if (isList) bindKeyword("cKeyword", { enter: true }); const bn = $("#btnNewCustom"); if (bn) bn.addEventListener("click", () => openNewCustomDialog(() => afterWrite())); const bex = $("#btnExportCustom"); if (bex) bex.addEventListener("click", () => { // 导出与「项目总览」当前筛选一致:归档态 + 归属 + 区域 + 状态 + 关键字 const q = ["archived=" + state.customArchived]; if (state.customOrigin) q.push("origin=" + encodeURIComponent(state.customOrigin)); if (state.customRegion) q.push("region=" + encodeURIComponent(state.customRegion)); if (state.customStatus) q.push("status=" + encodeURIComponent(state.customStatus)); if (state.keyword) q.push("keyword=" + encodeURIComponent(state.keyword)); download("/api/export/custom?" + q.join("&")); }); bindCustomList(host); } function renderCustomList(allRows, hdr) { hdr = hdr || {}; // 列表:按 关键字 + 归属 + 区域 + 状态 在本地筛选;再按表头排序 const kw = (state.keyword || "").trim(); let list = allRows; if (kw) list = list.filter((p) => String(p.name || "").includes(kw)); if (state.customOrigin) list = list.filter((p) => (p.origin || "") === state.customOrigin); if (state.customRegion) list = list.filter((p) => (p.region || "") === state.customRegion); if (state.customStatus) list = list.filter((p) => (p.project_status || "") === state.customStatus); const s = state.customSort || { key: "update_date", dir: "desc" }; const sv = (p, k) => (k === "amount" ? Number(p.amount || 0) : String(p[k] || "")); const view = list.slice().sort((a, b) => { const va = sv(a, s.key), vb = sv(b, s.key); const c = va < vb ? -1 : va > vb ? 1 : 0; return s.dir === "asc" ? c : -c; }); // ---- 统计条:基于「全部已下单项目」,不随搜索 / 筛选变化 ---- const placed = allRows.filter(isPlaced); // 已下单 = 华智下单 / 存量项目 / 汇智直签 const today = new Date(); const Y = today.getFullYear(); const M = today.getMonth() + 1; const Q = Math.floor((M - 1) / 3) + 1; // 当前季度 1~4 const sum = (arr, f) => arr.reduce((a, p) => a + Number(p[f] || 0), 0); // 取日期里的「年-月」;像「2024年3月前」这种文本不参与季度/年度判定 const ymOf = (d) => { const s = String(d || ""); return /^\d{4}-\d{2}/.test(s) ? { y: Number(s.slice(0, 4)), m: Number(s.slice(5, 7)) } : null; }; const inYear = (d) => { const t = ymOf(d); return !!t && t.y === Y; }; const inQuarter = (d) => { const t = ymOf(d); return !!t && t.y === Y && Math.floor((t.m - 1) / 3) + 1 === Q; }; const signedQuarter = placed.filter((p) => inQuarter(p.sign_date)); const signedYear = placed.filter((p) => inYear(p.sign_date)); // 验收:以「验收时间」(accept_date,季度文本 Q1~Q4) 为准;本季度 = 当前季度 const qOf = (s) => { const t = String(s || "").trim().toUpperCase(); return /^Q[1-4]$/.test(t) ? t : ""; }; const thisQ = "Q" + Q; const accRows = allRows.filter((p) => qOf(p.accept_date)); // 有「验收时间」即视为已验收 const accQuarter = accRows.filter((p) => qOf(p.accept_date) === thisQ); const accYear = accRows; const stat = (label, count, amount, color) => `
${count}个 · ${fen(amount)}万
${label}
`; const statBar = `
${stat("已下单项目 / 合同额", placed.length, sum(placed, "amount"), "var(--primary)")} ${stat(`本季度下单(${Y}年Q${Q})`, signedQuarter.length, sum(signedQuarter, "amount"), "var(--violet)")} ${stat(`本年新增下单(${Y}年)`, signedYear.length, sum(signedYear, "amount"), "var(--violet)")} ${stat(`本季度验收(${Y}年Q${Q})`, accQuarter.length, sum(accQuarter, "amount"), "var(--amber)")} ${stat(`本年验收项目(${Y}年)`, accYear.length, sum(accYear, "amount"), "var(--amber)")}
`; return `
${cardHead({ title: "定开项目总览", count: view.length, chips: hdr.chips, tools: hdr.tools, chipsRow: true })} ${statBar} ${view.length ? customTableHtml(view) : `
${state.keyword ? "没有匹配「" + esc(state.keyword) + "」的定开项目" : "当前筛选条件下没有定开项目"}
`}
`; } /** 定开项目表格(「项目总览」与「本周进展」两个 tab 共用);下单时间 / 订单金额 / 更新时间 表头可排序 */ function customTableHtml(view, withOps) { const ops = withOps !== false; const s = state.customSort || { key: "update_date", dir: "desc" }; const sth = (label, k, cls) => `${label}${s.key === k ? (s.dir === "asc" ? " ▲" : " ▼") : ""}`; return `
${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 || "—")} ${customStatusPill(p.project_status)} ${(p.project_status === "进行中" || Number(p.progress || 0) > 0) ? Math.round(p.progress || 0) + "%" : "—"} ${p.accept_date ? esc(p.accept_date) : "—"} ${esc(p.accept_plan || "—")} ${riskPill(p.risk_level)} ${esc(p.update_date || "—")} ${esc((p.remark || "").slice(0, 40)) || "—"}${rowOps({ edit: `data-cedit="${p.id}"`, archive: `data-carch="${p.id}"`, archived: p.archived, remove: `data-cdel="${p.id}"`, })}
`; } /** 定开项目「本期」分堆 —— 工作看板右栏与「本周进展」tab 共用同一套口径: · 本周新增:下单时间落在本期 ∧(已下单 ∨ 提前交付),和「更新时间」无关 · 本周验收:更新时间落在本期 ∧ 项目状态为「已验收」 rows 传 /custom/projects 全量或 /weekly 的行都行,两者都带 sign_date / update_date / origin / project_status。 */ function customWeeklySections(rows, sc) { const inScope = (v) => { const s = String(v || "").slice(0, 10); return !!(sc && s && s >= sc.start && s <= sc.end); }; const added = rows.filter((r) => inScope(r.sign_date) && (isPlaced(r) || r.origin === "提前交付")); const accepted = rows.filter((r) => inScope(r.update_date) && r.project_status === "已验收"); const inAdded = {}, inAccepted = {}; added.forEach((r) => { inAdded[r.id] = true; }); accepted.forEach((r) => { inAccepted[r.id] = true; }); const seen = {}, listed = []; [added, accepted].forEach((list) => list.forEach((r) => { if (!seen[r.id]) { seen[r.id] = true; listed.push(r); } })); return { added, accepted, listed }; } /** 本周进展:更新时间落在「顶栏选中周期」内的项目;并按 验收/下单/提前交付/进行中 分类统计 */ function renderCustomWeekly(allRows, hdr) { hdr = hdr || {}; const sc = scopeRange(); if (!sc) return `
没有取到当前周期的起止日期(刚更新过后端的话,请重启服务后刷新)
`; const start = sc.start, end = sc.end; const dayOf = (v) => String(v || "").slice(0, 10); const inScope = (v) => { const s = dayOf(v); return !!s && s >= start && s <= end; }; const rows = allRows.filter((p) => inScope(p.update_date)); // 更新时间在本期 const sum = (arr, f) => arr.reduce((a, p) => a + Number(p[f] || 0), 0); // 新增项目 / 验收情况:与工作看板右栏共用 customWeeklySections,两处口径必须一致 const { added: newDeal, accepted: acc } = customWeeklySections(allRows, sc); // 进行中项目:本期更新的「进行中 + 交付完成」 const doing = rows.filter((p) => p.project_status === "进行中" || p.project_status === "交付完成"); const stat = (label, rows, color) => `
${rows.length}个 · ${fen(sum(rows, "amount"))}万
${label}
`; const statBar = `
${stat("验收情况", acc, "var(--amber)")} ${stat("新增项目", newDeal, "var(--violet)")} ${stat("进行中项目", doing, "var(--primary)")}
`; return `
${cardHead({ title: "本周进展", count: rows.length, chips: hdr.chips, tools: hdr.tools, chipsRow: true })} ${statBar} ${rows.length ? customTableHtml(rows.slice().sort((a, b) => String(b.update_date || "").localeCompare(String(a.update_date || ""))), false) : `
本期没有更新的定开项目
`}
`; } /** 年度任务:季度计收目标(可在页面上改,存后端)+ 按「验收时间 / 下单时间」回填的完成数据 */ function renderCustomAnnual(allRows, hdr) { hdr = hdr || {}; const meta = annualTargetMeta(); const Y = meta.year; const saved = meta.targets; const qOf = (s) => { const t = String(s || "").trim().toUpperCase(); return /^Q[1-4]$/.test(t) ? t : ""; }; const signQ = (d) => { const s = String(d || ""); if (!/^\d{4}-\d{2}/.test(s) || Number(s.slice(0, 4)) !== Y) return 0; return Math.floor((Number(s.slice(5, 7)) - 1) / 3) + 1; }; const sumW = (arr) => arr.reduce((a, p) => a + Number(p.amount || 0), 0) / 10000; // 元 → 万元 const fmt = (v) => v.toLocaleString("zh-CN", { maximumFractionDigits: 2 }); const rate = (target, acc) => (!target ? "—" : ((acc / target) * 100).toFixed(1) + "%"); const targetOf = (q) => Number(saved[String(q)] || 0); const rows = [1, 2, 3, 4].map((q) => { const acc = allRows.filter((p) => qOf(p.accept_date) === "Q" + q); // 计收:验收项目 const sign = allRows.filter((p) => isPlaced(p) && signQ(p.sign_date) === q); // 签单:下单项目(已下单) const accAmt = sumW(acc); return { q, target: targetOf(q), accN: acc.length, accAmt, signN: sign.length, signAmt: sumW(sign) }; }); const tot = rows.reduce((a, r) => ({ target: a.target + r.target, accN: a.accN + r.accN, accAmt: a.accAmt + r.accAmt, signN: a.signN + r.signN, signAmt: a.signAmt + r.signAmt, }), { target: 0, accN: 0, accAmt: 0, signN: 0, signAmt: 0 }); const editing = !!state.customAnnualEdit; const tr = (r) => ` Q${r.q} ${editing ? `` : fmt(r.target)} ${r.accN} ${fmt(r.accAmt)} ${rate(r.target, r.accAmt)} ${r.signN} ${fmt(r.signAmt)} `; return `
${cardHead({ title: "年度任务(" + Y + "年)", count: Y + "年", chips: hdr.chips, chipsRow: true, tools: canW("custom") ? `` : "", })}
${rows.map(tr).join("")}
季度 计收目标(万元) 计收项目数量 计收项目金额(万元) 计收完成率 新增签单项目数量 新增签单项目数量金额(万元)
合计 ${fmt(tot.target)} ${tot.accN} ${fmt(tot.accAmt)} ${rate(tot.target, tot.accAmt)} ${tot.signN} ${fmt(tot.signAmt)}
`; } /** 年度计收目标(万元):后端存一年一套,没配过时返回《年度任务.xlsx》的默认值 */ function annualTargetMeta() { const a = (state.customMeta && state.customMeta.annual_targets) || {}; return { year: a.year || new Date().getFullYear(), targets: a.targets || {} }; } /** 年度目标:默认只读,点「编辑」进入编辑模式(按钮变「保存」),改数字实时重算完成率与合计 */ function bindAnnualTargets(host) { const btn = host.querySelector("#atSave"); if (!btn) return; if (!state.customAnnualEdit) { btn.addEventListener("click", () => { state.customAnnualEdit = true; renderCustom(); }); return; } const meta = annualTargetMeta(); const save = btn; const inputs = Array.prototype.slice.call(host.querySelectorAll("[data-at]")); const fmt = (v) => v.toLocaleString("zh-CN", { maximumFractionDigits: 2 }); const valOf = (q) => { const el = inputs.filter((i) => i.dataset.at === String(q))[0]; return el ? Number(el.value) || 0 : 0; }; const rate = (target, acc) => (!target ? "—" : ((acc / target) * 100).toFixed(1) + "%"); const recalc = () => { const total = [1, 2, 3, 4].reduce((a, q) => a + valOf(q), 0); host.querySelectorAll("[data-ate]").forEach((cell) => { const q = Number(cell.dataset.ate); const target = q === 0 ? total : valOf(q); cell.textContent = rate(target, Number(cell.dataset.acc) || 0); }); host.querySelector("[data-att]").textContent = fmt(total); }; inputs.forEach((i) => i.addEventListener("input", recalc)); save.addEventListener("click", async () => { const targets = {}; inputs.forEach((i) => { targets[i.dataset.at] = Number(i.value) || 0; }); save.disabled = true; try { state.customMeta.annual_targets = await put("/custom/annual-targets", { year: meta.year, targets: targets }); } catch (e) { save.disabled = false; toast("保存失败:" + e.message); return; } state.customAnnualEdit = false; toast("已保存年度目标"); renderCustom(); }); } /** 项目总览表:行内「修改 / 归档 / 删除」,名称可点开详情;表头(下单时间 / 订单金额 / 更新时间)可排序 */ function bindCustomList(host) { host.querySelectorAll("[data-copen]").forEach((a) => a.addEventListener("click", () => openCustomDrawer(Number(a.dataset.copen), "view")) ); host.querySelectorAll("[data-cedit]").forEach((b) => b.addEventListener("click", () => openCustomDrawer(Number(b.dataset.cedit), "edit")) ); const findRow = (id) => (state.customRows || []).find((x) => x.id === id); host.querySelectorAll("[data-carch]").forEach((b) => b.addEventListener("click", () => { const p = findRow(Number(b.dataset.carch)); if (p) archiveCustomProject(p, afterWrite); }) ); host.querySelectorAll("[data-cdel]").forEach((b) => b.addEventListener("click", () => { const p = findRow(Number(b.dataset.cdel)); if (p) deleteCustomProject(p, afterWrite); }) ); bindAnnualTargets(host); host.querySelectorAll("[data-sort]").forEach((th) => th.addEventListener("click", () => { const k = th.dataset.sort; const cur = state.customSort || { key: "update_date", dir: "desc" }; state.customSort = { key: k, dir: cur.key === k && cur.dir === "desc" ? "asc" : "desc" }; renderCustom(); }) ); } /** * 新建定开项目 —— 全站唯一的新建入口。 * 「定开项目」工具栏和「更新录入」页都调用它,避免两套录入逻辑。 */ function openNewCustomDialog(onDone) { const today = new Date(); const iso = today.getFullYear() + "-" + String(today.getMonth() + 1).padStart(2, "0") + "-" + String(today.getDate()).padStart(2, "0"); // 选项直接对齐《定开项目.xlsx》台账字段取值 const opts = (list, sel) => list.map((v) => ``).join(""); const originOpts = opts(CUSTOM_ORIGINS, "华智下单"); const industryOpts = opts(["安平", "政府", "企业", "教育", "交通", "医疗", "高速", "行业", "其他"], ""); const statusOpts = opts(["待开发", "进行中", "交付完成", "已验收", "退单"], "待开发"); const riskOpts = opts(["1.高风险", "2.低风险"], "2.低风险"); const numVal = (sel) => ($(sel).value === "" ? null : Number($(sel).value)); const txtVal = (sel) => $(sel).value.trim() || null; openModal( ` ` ); 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"; if (!canW("custom")) mode = "view"; $("#drawerMask").classList.add("open"); $("#drawer").classList.add("open"); $("#drawerBody").innerHTML = `
加载中…
`; drawerActions(""); const d = await api("/custom/projects/" + cid); state.drawerCustom = d; const p = d.project; $("#drawerTitle").innerHTML = `${p.is_key ? starMark(true) : ""}${esc(p.name)}${p.archived ? ` 已归档` : ""}`; const progPct = (p.project_status === "进行中" || Number(p.progress || 0) > 0) ? Math.round(p.progress || 0) + "%" : null; $("#drawerSub").innerHTML = `${stagePill(p.stage)} ${customStatusPill(p.project_status)}` + (progPct ? ` 进度 ${progPct}` : ""); const timeline = d.timeline.slice().reverse().map((t) => `
${esc(t.period.full_label.replace(/^\d+年/, ""))}
${stagePill(t.stage)}${t.progress !== null ? ` ${t.progress}%` : ""} ${t.revenue_delta ? ` 计收 ${fen(t.revenue_delta)}万` : ""}
${nl2br(t.content || "")}
`).join(""); // 「添加动态」落到顶栏当前选中的那一周,与更新录入页的周期下拉一致 const ro = (label, val) => `
${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" ? `
%
${esc(p.update_date || "—")}跟随「下单时间」
` : ` ${ro("项目归属", esc(p.origin || "—"))} ${ro("项目ID", esc(p.project_code || "—"))} ${ro("办事处", esc(p.office || p.region || "—"))} ${ro("行业", esc(p.industry || "—"))} ${ro("下单时间", esc(p.sign_date || "—"))} ${ro("市场下单人天", numOrDash(p.man_days))} ${ro("市场责任人", esc(p.market_owner || "—"))} ${ro("交付负责人", esc(p.owner || "—"))} ${ro("团队成员", nameChips(p.members))} ${ro("客户", esc(p.customer || "—"))} ${ro("项目状态", customStatusPill(p.project_status))} ${ro("进度", progPct || "—")} ${ro("验收时间", esc(p.accept_date || "—"))} ${ro("推动验收计划", esc(p.accept_plan || "—"))} ${ro("风险值", riskPill(p.risk_level))} ${ro("交付环节", `
${pipelineBar(p, true)}
`)} ${ro("备注", p.remark ? nl2br(p.remark) : "—")} ${ro("更新时间", esc(p.update_date || "—"))} `}
`; drawerActions(mode === "edit" ? ` ` : ` `); $("#drawerBody").innerHTML = ` ${mode === "edit" ? `` : ""} ${ledgerSec} ${mode === "view" ? `
收入确认
${Ico.badge("wallet")}
合同额
${wan(p.amount)}
${Ico.badge("receipt")}
可确认收入
${wan(p.confirmed_amount)}
${p.manual_ratio == null ? "按阶段" : "手动"} ${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) => updateItem(u, "custom")).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", () => openUpdateDialog({ kind: "custom", periodId: state.weekId, customProjectId: cid, stage: p.stage, onDone: () => openCustomDrawer(cid, "view") })); on("#cdIntake", () => { state.intakeType = "custom"; state.intakePeriodId = state.weekId; customIntakeId = cid; closeDrawer(); setView("intake"); }); bindUpdateOps($("#drawerBody"), d.updates, () => openCustomDrawer(cid, "view")); on("#cdCancel", () => openCustomDrawer(cid, "view")); on("#cdSave", async () => { const btn = $("#cdSave"); const err = $("#cdErr"); btn.disabled = true; err.style.display = "none"; try { await patch("/custom/projects/" + cid, { name: $("#cdName").value.trim(), origin: $("#cdOrigin").value.trim() || null, project_code: $("#cdCode").value.trim() || null, sign_date: $("#cdSign").value || null, office: $("#cdOffice").value.trim() || null, industry: $("#cdIndustry").value.trim() || null, man_days: $("#cdManDays").value === "" ? null : Number($("#cdManDays").value), amount: $("#cdAmount").value === "" ? null : Number($("#cdAmount").value), market_owner: $("#cdMarketOwner").value.trim() || null, project_status: $("#cdStatus").value.trim() || null, progress: $("#cdProgress").value === "" ? 0 : Number($("#cdProgress").value), accept_date: $("#cdAcceptQuarter").value.trim() || null, accept_plan: $("#cdAcceptPlan").value.trim() || null, risk_level: $("#cdRisk").value.trim() || null, accept_date_real: $("#cdAcceptReal").value || null, remark: $("#cdRemark").value, }); } catch (e) { btn.disabled = false; err.textContent = "保存失败:" + e.message; err.style.display = ""; return; } toast("已保存"); await afterWrite(); openCustomDrawer(cid, "view"); }); on("#cdKey", async () => { await patch("/custom/projects/" + cid, { is_key: !p.is_key }); await afterWrite(); openCustomDrawer(cid, mode); }); on("#cdArchive", () => archiveCustomProject(p, () => { closeDrawer(); afterWrite(); })); on("#cdDelete", () => deleteCustomProject(p, () => { closeDrawer(); afterWrite(); })); } /* ------------------------------------------------ 录入方式(两类项目共用) */ /** 录入方式分段控件:直接写入(第一位)/ AI 整理 */ function intakeModeSegHtml() { const mode = intakeMode(); return `
${mode === "ai" ? `AI 补出摘要 / 进度 / 状态 / 风险 / 下一步,正文保持原文,最终只写 1 条` : ""}
`; } function intakeMode() { return state.intakeMode === "ai" ? "ai" : "direct"; } /** 切换录入方式:保留已填的原文,重画当前录入页 */ function bindIntakeMode(rawSel, rerender) { document.querySelectorAll("#intakeModeSeg [data-imode]").forEach((b) => b.addEventListener("click", () => { if (intakeMode() === b.dataset.imode) return; const keep = $(rawSel) ? $(rawSel).value : ""; state.intakeMode = b.dataset.imode; Promise.resolve(rerender()).then(() => { const el = $(rawSel); if (el) el.value = keep; }); }) ); } /* ------------------------------------------------ 定制项目录入 */ async function renderCustomIntake(host) { const projects = await api("/custom/projects"); const weeks = state.periods.weeks.slice().reverse(); const curPeriodId = state.gran === "week" ? state.weekId : currentMonth() ? currentMonth().period_ids[currentMonth().period_ids.length - 1] : null; host.innerHTML = `
${intakeModeSegHtml()}

定制项目 · 交付记录录入

1选择定制项目与周期
2粘贴该项目的原始内容
${intakeMode() === "ai" ? `
` : `
`}
`; const sel = $("#cInProject"); host.querySelectorAll("[data-itype]").forEach((b) => b.addEventListener("click", () => { state.intakeType = b.dataset.itype; renderIntake(); }) ); sel.addEventListener("change", () => { customIntakeId = Number(sel.value); }); $("#btnNewCustomIntake").addEventListener("click", () => openNewCustomDialog((p) => { customIntakeId = p.id; renderCustomIntake(host); }) ); $("#cInPeriod").addEventListener("change", async (e) => { if (e.target.value === "__new") { await createNewPeriod(); await renderIntake(); return; } state.intakePeriodId = Number(e.target.value); }); $("#cInSearch").addEventListener("input", (e) => { const kw = e.target.value.trim(); sel.innerHTML = projects.filter((p) => !kw || p.name.includes(kw)) .map((p) => ``).join(""); }); bindIntakeMode("#cInRaw", () => renderCustomIntake(host)); const on = (sel2, fn) => { const el = $(sel2); if (el) el.addEventListener("click", fn); }; on("#btnCClear", () => { $("#cInRaw").value = ""; $("#cInPreview").innerHTML = ""; $("#cInMode").textContent = ""; }); on("#btnCParse", () => doCustomParse(true)); on("#btnCRule", () => doCustomParse(false)); on("#btnCDirect", commitCustomDirect); } /** 定制项目 · 直接写入:内容一字不改存成 1 条执行记录,阶段 / 金额 / 计收一律不动 */ async function commitCustomDirect() { const raw = $("#cInRaw").value.trim(); if (!raw) return toast("请先填写内容"); const id = Number($("#cInProject").value); if (!id) return toast("请选择定制项目"); const pid = Number($("#cInPeriod").value); if (!pid) return toast("请选择周期"); const btn = $("#btnCDirect"); btn.disabled = true; try { await post("/custom/updates", { custom_project_id: id, period_id: pid, content: raw, source: "manual", }); $("#cInRaw").value = ""; $("#cInPreview").innerHTML = `
写入成功:已按原文存入 1 条执行记录,未拆解、未改写。
`; toast("已写入 1 条执行记录"); await afterWrite(); } catch (e) { toast("写入失败:" + e.message); } finally { btn.disabled = false; } } let customIntakeId = null; let customIntakeParsed = null; let customIntakeRaw = ""; async function doCustomParse(useAI) { const raw = $("#cInRaw").value.trim(); if (!raw) return toast("请先粘贴内容"); const sel = $("#cInProject"); const id = Number(sel.value); if (!id) return toast("请选择定制项目"); const btn = useAI ? $("#btnCParse") : $("#btnCRule"); const old = btn.innerHTML; btn.disabled = true; btn.innerHTML = ` 整理中…`; try { const r = await post("/custom/ai/parse", { custom_project_id: id, project_name: null, period_id: Number($("#cInPeriod").value), raw_text: raw, use_ai: useAI, }); customIntakeId = r.project ? r.project.id : customIntakeId; customIntakeParsed = r.parsed; customIntakeRaw = raw; renderCustomPreview(r); } catch (e) { $("#cInPreview").innerHTML = `
整理失败:${esc(e.message)}
`; } finally { btn.disabled = false; btn.innerHTML = old; } } function renderCustomPreview(r) { const p = r.parsed; const stageOpts = state.customMeta.stages .map((s) => ``) .join(""); $("#cInMode").textContent = r.mode === "ai" ? `已由 ${r.parsed.model || "大模型"} 整理` : "本地规则整理"; $("#cInPreview").innerHTML = ` ${r.warning ? `
${esc(r.warning)}
` : ""} ${r.error ? `
AI 调用异常:${esc(r.error)}
` : ""}
整理结果 · 1 条记录(可直接修改后写入)
${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 (!canW("intake")) { host.innerHTML = `
「更新录入」是写操作,当前角色是只读权限,无法录入内容。
`; return; } if (!state.customMeta) state.customMeta = await api("/custom/meta"); if (state.intakeType === "custom") return renderCustomIntake(host); const projects = await api("/projects?archived=false"); const weeks = state.periods.weeks.slice().reverse(); const curPeriodId = state.gran === "week" ? state.weekId : (currentMonth() ? currentMonth().period_ids[currentMonth().period_ids.length - 1] : null); const mode = intakeMode(); const actionRow = mode === "ai" ? `
` : `
`; host.innerHTML = `
${intakeModeSegHtml()}

自研产品 · 执行记录录入

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

AI 整理配置

${state.meta.ai.enabled ? `
已启用 AI 整理(模型:${esc(state.meta.ai.model || "—")})
` : `
未配置 API Key,当前使用本地规则解析。填写下方配置后自动切换到大模型整理。
`}
${rw ? `` : `只读权限`}
兼容: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,切换方言不需要改业务代码。

周期管理

${rw ? `` : ""}
${weeks.map((w) => ` `).join("")}
周期常规记录定开记录
${esc(w.full_label)} (原标签:${esc(w.label)}) ${w.update_count || 0} ${w.custom_update_count || 0} ${rw ? `` : ""}
`; if (!rw) host.querySelectorAll("#cfgBase,#cfgKey,#cfgModel,#cfgTemp").forEach((i) => { i.disabled = true; }); const bap = $("#btnAddPeriod"); if (bap) bap.addEventListener("click", async () => { const id = await createNewPeriod(); if (id) renderSettings(); }); host.querySelectorAll("[data-delperiod]").forEach((b) => b.addEventListener("click", async () => { const id = Number(b.dataset.delperiod); const row = b.closest("tr"); const name = row.querySelector("b").textContent; if (!confirm(`确认删除周期「${name}」?`)) return; try { await del("/periods/" + id); toast("已删除周期 " + name); await loadPeriods(); renderSettings(); } catch (e) { toast("删除失败:" + e.message); } }) ); const bsc = $("#btnSaveCfg"); if (bsc) bsc.addEventListener("click", async () => { await put("/settings", { values: { ai_api_base: $("#cfgBase").value.trim(), ai_api_key: $("#cfgKey").value.trim(), ai_model: $("#cfgModel").value.trim(), ai_temperature: $("#cfgTemp").value.trim() || "0.2", }, }); state.meta = await api("/meta"); toast("配置已保存"); renderSettings(); }); } /* ---------------------------------------------------------------- drawer */ /** pid 详情抽屉;mode = "view" 只读详情(默认) / "edit" 编辑基础信息 */ async function openDrawer(pid, mode) { mode = mode === "edit" ? "edit" : "view"; if (!canW("projects")) mode = "view"; $("#drawerMask").classList.add("open"); $("#drawer").classList.add("open"); $("#drawerBody").innerHTML = `
加载中…
`; drawerActions(""); let d = await api("/projects/" + pid); state.drawerProject = d; // 弹窗里改完记录,只重画下面的周期反馈,抽屉不关闭、滚动位置不丢 const reloadFeed = async () => { d = await api("/projects/" + pid); state.drawerProject = d; renderFeed(); }; const p = d.project; $("#drawerTitle").innerHTML = `${p.is_key ? starMark(true) : ""}${esc(p.name)}`; $("#drawerSub").innerHTML = `${catPill(p.category)} ${statusPill(p.status)} 进度 ${Math.round(p.progress)}%`; const ro = (label, val) => `
${val}
`; // 基础信息:详情只读、修改才给表单,两档各自一条操作条,别和下面的附表混在一起 const baseSec = mode === "edit" ? `
项目信息
${peopleEditorHtml("dwOwners", "负责人", p.owners, { add: "负责人" })} ${peopleEditorHtml("dwMembers", "团队成员", p.members, { add: "成员" })}
%
` : `
项目信息
${ro("项目名称", esc(p.name) + (p.archived ? ` 已归档` : ""))} ${ro("分类", catPill(p.category))} ${ro("状态", statusPill(p.status))} ${ro("负责人", nameChips(p.owners))} ${ro("团队成员", nameChips(p.members))} ${ro("进度", Math.round(p.progress) + "%")} ${ro("标签", p.tags.length ? p.tags.map((t) => `${esc(t.name)}`).join(" ") : "—")} ${ro("记录", `${p.task_count} 个子任务 / ${p.update_count} 条执行记录`)}
`; // 基础信息的动作在标题栏右侧按钮条;附表的更新动作仍在它的列表上方,两类不混 drawerActions(mode === "edit" ? ` ` : ` `); const updItem = (u) => `
${esc(u.task_name || "整体进展")}${statusPill(u.status)}${u.progress !== null ? `${u.progress}%` : ""}${updOps("project", u)}
${nl2br(u.content || u.summary || "")}
${u.risk ? `
${Ico.svg("alert-circle")}
${nl2br(u.risk)}
` : ""} ${u.next_step ? `
${Ico.svg("arrow-right")}
${nl2br(u.next_step)}
` : ""}
`; // 把项目的 timeline 按「周」或「月」归成显示单元(timeline 已由后端按时间升序返回) function feedUnits() { if (state.dw.group === "week") { return d.timeline.map((t) => ({ label: t.period.full_label, count: t.updates.length, progress: t.progress, periods: [t], ids: [t.period.id], })); } const map = new Map(); d.timeline.forEach((t) => { const k = t.period.year + "-" + t.period.month; if (!map.has(k)) map.set(k, { label: t.period.year + "年" + t.period.month + "月", count: 0, progress: null, periods: [], ids: [] }); const u = map.get(k); u.periods.push(t); u.count += t.updates.length; u.ids.push(t.period.id); if (t.progress !== null) u.progress = u.progress === null ? t.progress : Math.max(u.progress, t.progress); }); return [...map.values()]; } const FEED_RANGES = { week: [[2, "近 2 周"], [0, "全部"]], month: [[1, "近 1 月"], [0, "全部"]], }; // 「添加执行记录」直接去「更新录入」页,不再弹录入窗 const gotoIntake = () => { state.intakeType = "general"; state.intakeMode = "direct"; state.intakePeriodId = state.weekId; intakeProjectId = pid; closeDrawer(); setView("intake"); }; // 范围以顶栏选中的周期为锚点,和看板其余部分保持一致 function renderFeed() { const host = $("#dwFeed"); const units = feedUnits(); const unitName = state.dw.group === "week" ? "周" : "个月"; /* 「7 月」会被读成七月,用「个月」 */ const scope = scopePeriodIds(); let anchor = -1; units.forEach((u, i) => { if (u.ids.some((id) => scope.indexOf(id) >= 0)) anchor = i; }); if (anchor < 0) anchor = units.length - 1; const n = state.dw.range; const step = n === 0 ? Math.max(1, anchor + 1) : n; const last = Math.max(0, anchor - step * state.dw.back); const first = n === 0 ? 0 : Math.max(0, last - n + 1); const win = units.slice(first, last + 1); const nUpd = win.reduce((a, u) => a + u.count, 0); const bar = `
${FEED_RANGES[state.dw.group].map(([v, l]) => ``).join("")}
`; // win 是按升序取的窗口,展示时倒过来:新的在前 const body = win.length ? win.slice().reverse().map((u) => `
${esc(u.label)}${u.progress !== null ? `进度 ${u.progress}%` : ""}${u.count} 条
${u.periods.slice().reverse().map((t) => ` ${state.dw.group === "month" ? `
${esc(t.period.full_label.replace(/^\d+年/, ""))}${t.progress !== null ? " · 进度 " + t.progress + "%" : ""} ${statusPill(t.status)}
` : ""} ${t.updates.map(updItem).join("")}`).join("")}`).join("") : `
还没有执行记录
`; host.innerHTML = `
执行记录
${bar}
${body}
`; host.querySelector("#dwAddUpd").addEventListener("click", () => gotoIntake()); bindUpdateOps(host, d.timeline.flatMap((t) => t.updates), reloadFeed); host.querySelectorAll("#dwGroup [data-g]").forEach((b) => b.addEventListener("click", () => { const g = b.dataset.g; if (state.dw.group === g) return; state.dw = { group: g, range: g === "week" ? 2 : 1, back: 0 }; renderFeed(); })); host.querySelectorAll(".feed-chips [data-r]").forEach((b) => b.addEventListener("click", () => { state.dw.range = Number(b.dataset.r); state.dw.back = 0; renderFeed(); })); host.querySelector("#dwOlder").addEventListener("click", () => { state.dw.back += 1; renderFeed(); }); host.querySelector("#dwNewer").addEventListener("click", () => { state.dw.back = Math.max(0, state.dw.back - 1); renderFeed(); }); } const timeline = d.timeline.slice().reverse().map((t) => `
${esc(t.period.full_label.replace(/^\d+年/, ""))}
${t.progress === null ? "—" : t.progress + "%"}
`).join(""); // 周期反馈单独渲染,切换范围/周期不必重新请求 state.dw = { group: "week", range: 2, back: 0 }; $("#drawerBody").innerHTML = (mode === "edit" ? `` : "") + baseSec + `
` + `
历史进度走势
${timeline || '
暂无
'}
`; renderFeed(); const on = (sel, fn) => { const el = $(sel); if (el) el.addEventListener("click", fn); }; on("#dwEdit", () => openDrawer(pid, "edit")); on("#dwCancel", () => openDrawer(pid, "view")); on("#dwSave", async () => { const btn = $("#dwSave"); const err = $("#dwErr"); btn.disabled = true; err.style.display = "none"; try { await patch("/projects/" + pid, { name: $("#dwName").value.trim(), category_id: $("#dwCat").value === "" ? null : Number($("#dwCat").value), status: $("#dwStatus").value, owners: peopleValues("dwOwners"), members: peopleValues("dwMembers"), progress: Number($("#dwProgress").value), tags: $("#dwTags").value.split(/[、,,;;\s]+/).filter(Boolean), }); } catch (e) { btn.disabled = false; err.textContent = "保存失败:" + e.message; err.style.display = ""; return; } toast("已保存"); await afterWrite(); openDrawer(pid, "view"); }); on("#dwKey", async () => { await patch("/projects/" + pid, { is_key: !p.is_key }); await afterWrite(); openDrawer(pid, mode); }); on("#dwArchive", async () => { await patch("/projects/" + pid, { archived: !p.archived }); toast(p.archived ? "已恢复" : "已归档"); closeDrawer(); await afterWrite(); }); on("#dwDelete", () => deleteProject(p)); } // 标题栏右侧的标准按钮条:按钮由当前打开的抽屉自己填,主按钮在最右 function drawerActions(html) { $("#drawerActions").innerHTML = html || ""; } function closeDrawer() { drawerActions(""); $("#drawer").classList.remove("open"); $("#drawerMask").classList.remove("open"); } /* ---------------------------------------------------------------- 人力资源库 */ function openStaffDialog(person, onDone) { const isNew = !person; const p = person || {}; const groups = (state.staffDict.groups || []).map( (g) => `` ).join(""); const affs = (state.staffDict.affiliations || []).map( (a) => `` ).join(""); openModal( ` ` ); $("#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 table = ` ${d.items.map((m) => ` `).join("")}
姓名归属小组人员归属说明 在管项目定开项目定开合同额 名下待计收最近更新操作
${canW("staff") ? `${esc(m.name)}` : `${esc(m.name)}`} ${m.archived ? `已归档` : ""}
${m.project_count ? `${m.project_count}${m.key_project_count ? ` ${starMark(true, false, "重点项目")}${m.key_project_count}` : ""}` : "—"} ${m.custom_count} ${m.custom_amount ? fen(m.custom_amount) + " 万" : "—"} ${m.custom_pending ? `${fen(m.custom_pending)} 万` : "—"} ${esc(m.last_update || "—")} ${rowOps({ edit: `data-sedit="${m.id}"`, archive: `data-sarch="${m.id}"`, archived: m.archived, remove: `data-sdel="${m.id}"`, })}
`; const body = listCard({ title: "人员花名册", count: d.items.length, fields: ` ${keywordInputHtml("stKeyword", "搜索姓名…", 160)}`, tools: `${canR("staff") ? `` : ""} ${canW("staff") ? `` : ""}`, rows: d.items, table: table, empty: state.keyword ? "没有匹配「" + esc(state.keyword) + "」的人员" : (state.staffGroup ? "「" + esc(state.staffGroup) + "」还没有人员" : "还没有人员"), }); host.innerHTML = kpi + body; bindKeyword("stKeyword"); $("#stArchived").addEventListener("change", (e) => { state.staffArchived = e.target.value; renderStaff(); }); $("#stGroup").addEventListener("change", (e) => { state.staffGroup = e.target.value; renderStaff(); }); const bn = $("#btnNewStaff"); if (bn) bn.addEventListener("click", () => openStaffDialog(null)); const bex = $("#btnExportStaff"); if (bex) bex.addEventListener("click", () => download("/api/export/staff?archived=" + state.staffArchived)); host.querySelectorAll("[data-sedit]").forEach((b) => b.addEventListener("click", () => { const m = d.items.find((x) => x.id === Number(b.dataset.sedit)); openStaffDialog(m); }) ); host.querySelectorAll("[data-sarch]").forEach((b) => b.addEventListener("click", async () => { const m = d.items.find((x) => x.id === Number(b.dataset.sarch)); await patch("/staff/" + m.id, { archived: !m.archived }); toast(m.archived ? "已恢复" : `已归档「${m.name}」,不再出现在负责人下拉里`); await afterWrite(); }) ); host.querySelectorAll("[data-sdel]").forEach((b) => b.addEventListener("click", () => deleteStaff(d.items.find((x) => x.id === Number(b.dataset.sdel))) ) ); host.querySelectorAll("[data-outside]").forEach((b) => b.addEventListener("click", () => openStaffDialog({ name: b.dataset.outside })) ); const roStaff = !canW("staff"); host.querySelectorAll("tr[data-sid]").forEach((tr) => { const id = Number(tr.dataset.sid); tr.querySelectorAll("[data-sf]").forEach((inp) => { if (roStaff) { inp.disabled = true; return; } inp.addEventListener("change", async () => { const f = inp.dataset.sf; try { await patch("/staff/" + id, { [f]: inp.value || null }); toast("已保存"); await afterWrite(); } catch (e) { toast("保存失败:" + e.message); } }); }); }); } /* ------------------------------------------------------ 权限管理(RBAC) */ /** 角色 = 一张「菜单 × 能改/只读」矩阵;用户挂一个角色。下拉改完即存,后端立刻生效。 */ async function renderRbac() { const host = $("#view-rbac"); host.innerHTML = `
加载中…
`; const [meta, roles, users] = await Promise.all([ api("/rbac/meta"), api("/rbac/roles"), api("/rbac/users"), ]); const rw = canW("rbac"); const menus = meta.menus; const save = async (fn) => { try { await fn(); } catch (e) { toast("保存失败:" + e.message); } await renderRbac(); }; const levelCell = (role, m) => { const cur = (role.menus || {})[m.key] || ""; return ``; }; const roleTable = ` ${menus.map((m) => ``).join("")} ${rw ? `` : ""} ${roles.map((r) => ` ${menus.map((m) => levelCell(r, m)).join("")} ${rw ? `` : ""} `).join("")}
角色 / 说明${esc(m.label)}用户操作
${esc(r.name)}${r.is_builtin ? ` 内置` : ""}${r.description ? `${esc(r.description)}` : ""}${r.user_count} ${r.is_builtin ? "" : ``}
`; const roleOpts = (sel) => roles.map((r) => ``).join(""); const userTable = ` ${rw ? `` : ""} ${users.map((u) => { const self = u.id === state.me.id; return ` ${rw ? `` : ""} `; }).join("")}
姓名登录账号角色可用菜单最近登录操作
${esc(u.name)}${self ? ` 我自己` : ""}${u.active ? "" : ` 已停用`} ${esc(u.username)} ${u.menus ? Object.keys(u.menus).length : 0} ${esc(u.last_login_at ? u.last_login_at.replace("T", " ").slice(0, 16) : "从未登录")} ${self ? "" : ` `}
`; host.innerHTML = listCard({ title: "角色权限", count: roles.length, rows: roles, table: roleTable, empty: "还没有角色", tools: rw ? `` : "", }) + `
` + listCard({ title: "用户", count: users.length, rows: users, table: userTable, empty: "还没有用户", tools: rw ? `` : "", }); // ---- 权限矩阵:一个角色的整行一起提交 ---- host.querySelectorAll("[data-rmenu]").forEach((sel) => sel.addEventListener("change", () => { const rid = Number(sel.dataset.rmenu); const mine = state.me.role_id === rid; const next = {}; sel.closest("tr").querySelectorAll("[data-rmenu]").forEach((x) => { if (x.value) next[x.dataset.menu] = x.value; }); save(() => patch("/rbac/roles/" + rid, { menus: next })).then(() => { if (mine) toast("已更新你所在角色的权限,刷新页面后生效"); }); }) ); const bnr = $("#btnNewRole"); if (bnr) bnr.addEventListener("click", () => openRoleDialog(null)); host.querySelectorAll("[data-redit]").forEach((b) => b.addEventListener("click", () => openRoleDialog(roles.find((x) => x.id === Number(b.dataset.redit)))) ); host.querySelectorAll("[data-rdel]").forEach((b) => b.addEventListener("click", async () => { const r = roles.find((x) => x.id === Number(b.dataset.rdel)); const ok = await confirmDialog({ title: "删除角色", danger: true, okText: "确认删除", body: `

确认删除角色 ${esc(r.name)}?该角色下不能还有用户。

` }); if (ok) save(() => del("/rbac/roles/" + r.id)); }) ); // ---- 用户 ---- const bnu = $("#btnNewUser"); if (bnu) bnu.addEventListener("click", () => openUserDialog(null, roles)); host.querySelectorAll("[data-urole]").forEach((sel) => sel.addEventListener("change", () => save(() => patch("/rbac/users/" + Number(sel.dataset.urole), { role_id: Number(sel.value) })) ) ); host.querySelectorAll("[data-upw]").forEach((b) => b.addEventListener("click", () => openPasswordReset(users.find((x) => x.id === Number(b.dataset.upw))) ) ); host.querySelectorAll("[data-uactive]").forEach((b) => b.addEventListener("click", () => { const on = b.dataset.on === "1"; save(() => patch("/rbac/users/" + Number(b.dataset.uactive), { active: !on })); }) ); host.querySelectorAll("[data-udel]").forEach((b) => b.addEventListener("click", async () => { const u = users.find((x) => x.id === Number(b.dataset.udel)); const ok = await confirmDialog({ title: "删除用户", danger: true, okText: "确认删除", body: `

确认删除用户 ${esc(u.name)}(${esc(u.username)})?该账号会立即下线。

` }); if (ok) save(() => del("/rbac/users/" + u.id)); }) ); } /** 角色只影响「能看哪些菜单」,名称说明在这里改,菜单授权在矩阵里勾 */ function openRoleDialog(role) { const isNew = !role; openModal( ` ` ); $("#mOk").addEventListener("click", async () => { const name = $("#rlName").value.trim(); if (!name) return modalError("请填写角色名"); const body = { name: name, description: $("#rlDesc").value.trim() }; try { if (isNew) await post("/rbac/roles", { name: body.name, description: body.description, menus: {} }); else await patch("/rbac/roles/" + role.id, body); closeModal(); toast(isNew ? "已新建角色,在矩阵里勾选菜单即可授权" : "已保存"); await renderRbac(); } catch (e) { modalError(e.message); } }); } function openUserDialog(user, roles) { const isNew = !user; const u = user || {}; openModal( ` ` ); $("#mOk").addEventListener("click", async () => { const name = $("#usName").value.trim(); if (!name) return modalError("请填写姓名"); const btn = $("#mOk"); btn.disabled = true; try { if (isNew) { const username = $("#usUser").value.trim(); const password = $("#usPass").value.trim(); if (!username) return void (btn.disabled = false, modalError("请填写登录账号")); await post("/rbac/users", { name: name, username: username, password: password, role_id: Number($("#usRole").value), active: true }); } else { await patch("/rbac/users/" + u.id, { name: name }); } closeModal(); toast(isNew ? `已新建用户「${name}」` : "已保存"); await renderRbac(); } catch (e) { btn.disabled = false; modalError(e.message); } }); } function openPasswordReset(user) { openModal( ` ` ); $("#mOk").addEventListener("click", async () => { try { await patch("/rbac/users/" + user.id, { password: $("#rsPass").value.trim() }); closeModal(); toast(`已重置「${user.name}」的密码`); } catch (e) { modalError(e.message); } }); } /* ---------------------------------------------------------------- shell */ async function loadCategories() { // 侧栏的分类筛选器已下线,这里只给「自研产品」子 header 上的分类下拉供数据 if (!canR("projects")) { state.categories = []; return; } state.categories = await api("/categories"); } function setView(view) { if (!can(view)) view = firstAllowedView(); if (!view) { hostNoPermission(); return; } state.view = view; saveUI(); $$("#nav .nav-item").forEach((b) => b.classList.toggle("active", b.dataset.view === view)); $$(".view").forEach((v) => v.classList.add("hidden")); $("#view-" + view).classList.remove("hidden"); refresh(); } async function refresh() { const v = state.view; try { if (v === "board") await renderBoard(); else if (v === "projects") await renderProjects(); else if (v === "custom") await renderCustom(); else if (v === "focus") await renderFocus(); else if (v === "staff") await renderStaff(); else if (v === "intake") await renderIntake(); else if (v === "settings") await renderSettings(); else if (v === "rbac") await renderRbac(); } catch (e) { $("#view-" + v).innerHTML = `
加载失败:${esc(e.message)}
`; } } /** 写操作统一收尾:刷新侧栏计数 + 当前视图。 录入页不重绘,否则会清空用户正在编辑的表单。 */ async function afterWrite() { await Promise.all([loadCategories(), loadStaff()]); if (state.view === "intake") return; await refresh(); } function bindShell() { $$("#nav .nav-item").forEach((b) => b.addEventListener("click", () => setView(b.dataset.view))); $$("#granSeg .seg-btn").forEach((b) => b.addEventListener("click", () => { state.gran = b.dataset.gran; $$("#granSeg .seg-btn").forEach((x) => x.classList.toggle("active", x === b)); renderPeriodSelect(); refresh(); }) ); $("#prevPeriod").addEventListener("click", () => shiftPeriod(-1)); $("#nextPeriod").addEventListener("click", () => shiftPeriod(1)); $("#periodSelect").addEventListener("change", (e) => { if (e.target.value === "__new") { renderPeriodSelect(); createNewPeriod().then((id) => { if (id) refresh(); }); return; } if (state.gran === "week") state.weekId = Number(e.target.value); else state.monthIdx = Number(e.target.value); renderPeriodSelect(); refresh(); }); $("#btnIntake").addEventListener("click", () => setView("intake")); $("#drawerClose").addEventListener("click", closeDrawer); bindDrawerPeople(); $("#drawerMask").addEventListener("click", closeDrawer); $("#modalMask").addEventListener("click", (e) => { if (e.target.id === "modalMask" || e.target.dataset.mclose !== undefined) closeModal(false); }); let t = null; $("#globalSearch").addEventListener("input", (e) => { clearTimeout(t); const v = e.target.value.trim(); t = setTimeout(() => { state.keyword = v; refresh(); }, 320); }); document.addEventListener("keydown", (e) => { if (e.key !== "Escape") return; if ($("#modalMask").classList.contains("open")) closeModal(false); else closeDrawer(); }); } /* ------------------------------------------- 界面状态(刷新后保持) */ const UI_KEY = "unis.ui.v1"; const UI_FIELDS = ["view", "gran", "weekId", "monthIdx", "categoryId", "keyword", "projOwner", "projIdleOnly", "showArchived", "focusFilter", "focusArchived", "customTab", "customArchived", "customRegion", "customOrigin", "customSort", "staffArchived", "staffGroup"]; function saveUI() { const o = {}; UI_FIELDS.forEach((k) => { o[k] = state[k]; }); try { sessionStorage.setItem(UI_KEY, JSON.stringify(o)); } catch (e) { /* 存不了就算了 */ } } function restoreUI() { let o = null; try { o = JSON.parse(sessionStorage.getItem(UI_KEY) || "null"); } catch (e) { o = null; } if (!o) return; UI_FIELDS.forEach((k) => { if (k in o) state[k] = o[k]; }); if (["board", "projects", "custom", "focus", "staff", "intake", "settings", "rbac"].indexOf(state.view) < 0) state.view = "board"; if (state.gran !== "month") state.gran = "week"; if (state.focusFilter === "later") state.focusFilter = "week"; } function hostNoPermission() { document.querySelector(".views").innerHTML = `
当前角色没有分配任何功能菜单,请联系管理员在「权限管理」里授权。
`; } async function main() { restoreUI(); bindAuthForm(); try { state.me = await api("/auth/me"); } catch (e) { if (e.status === 401) return showAuthGate(); // 首次访问:切到登录页但不显示报错 throw e; } if (!state.me.menus || !Object.keys(state.me.menus).length) { bindShell(); renderUserChip(); return hostNoPermission(); } state.meta = await api("/meta"); await loadPeriods(); await loadStaff(); await loadCategories(); bindShell(); renderUserChip(); applyNavPermissions(); // 恢复出来的视图状态要回写到顶栏/侧栏控件上 $$("#granSeg .seg-btn").forEach((x) => x.classList.toggle("active", x.dataset.gran === state.gran)); $("#globalSearch").value = state.keyword || ""; setView(state.view); } main().catch((e) => { document.body.insertAdjacentHTML("beforeend", `
初始化失败:${esc(e.message)}
`); }); })();