Files
opc-manager/static/modules/utils.js
mac 2e5704a615 feat: 方案→wiki + 项目详情简化 + 去除活动/跟进
- 侧边栏「方案」改为 wiki,移至最后
- 项目详情抽屉隐藏负责人、截止时间、金额
- 项目详情去除活动/跟进区域
2026-07-17 17:23:19 +08:00

433 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// utils.js — 全局工具函数与共享状态
const state = {
active: "plan",
data: null,
tenant: localStorage.getItem("opc-active-tenant") || "科普·无界",
opFilter: "all",
finFilter: "overview",
selectedProject: null,
taskQuery: "",
taskView: localStorage.getItem("opc-task-view") || "detail",
finView: "overview",
finSort: null, // { key: 'col', asc: true }
planFilter: "overview",
planYear: new Date().getFullYear(),
finYear: new Date().getFullYear(),
planStatusFilter: "all",
planView: "overview",
planSort: null,
overviewView: "monthly", // 总览视图: monthly | quarterly
overviewFilter: "plan", // 总览筛选: plan | finance
proposalTab: "standard",
chart: null,
chart2: null,
chart3: null,
chart4: null,
productPlatform: "all",
uploadTasks: [],
expenseFilter: "全部",
expenseView: "total",
expenseMonth: "",
expenseQuarter: "",
};
const money = (value) => `${Number(value || 0).toLocaleString("zh-CN")}`;
const text = (value) => value === undefined || value === null || value === "" ? "—" : esc(value);
function escapeHtml(str) { return String(str || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); }
const html = escapeHtml;
const esc = escapeHtml;
// Toast 通知
function toast(message, type = "info", duration = 3000) {
let container = document.querySelector(".toast-container");
if (!container) {
container = document.createElement("div");
container.className = "toast-container";
document.body.appendChild(container);
}
const el = document.createElement("div");
el.className = `toast toast-${type}`;
const icon = type === "success" ? "check-circle" : type === "error" ? "alert-circle" : "info";
el.innerHTML = `<i data-lucide="${icon}" style="width:16px;height:16px;flex-shrink:0"></i><span>${esc(message)}</span>`;
container.appendChild(el);
if (window.lucide) window.lucide.createIcons();
setTimeout(() => {
el.classList.add("fade-out");
setTimeout(() => el.remove(), 250);
}, duration);
}
window.toast = toast;
function monthOptions(selected = '') {
const startYear = 2020;
const endYear = 2027;
let options = selected ? '' : '<option value="">选择月份</option>';
for (let y = startYear; y <= endYear; y++) {
for (const m of ["01","02","03","04","05","06","07","08","09","10","11","12"]) {
const val = y + "-" + m;
const sel = val === selected ? " selected" : "";
options += `<option value="${val}"${sel}>${val}</option>`;
}
}
return options;
}
async function api(path, options = {}) {
const response = await fetch(path, {
headers: options.body instanceof FormData ? undefined : { "Content-Type": "application/json" },
...options,
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || "请求失败");
return data;
}
async function logActivity(targetType, targetId, content) {
try {
await api(`/api/followups/${targetType}/${targetId}`, {
method: "POST",
body: JSON.stringify({ data: { content, tenant: state.tenant } }),
});
} catch (e) { /* non-critical */ }
}
function badge(value) {
const val = String(value || "—");
let cls = "badge-slate";
if (["P0", "有风险", "已丢单", "已延期"].includes(val)) cls = "badge-red";
if (["P1", "方案中", "方案已提交", "商务谈判", "待客户确认"].includes(val)) cls = "badge-amber";
if (["已签约", "已上线", "已完成", "已归档"].includes(val)) cls = "badge-green";
if (["execution", "已签约执行项目"].includes(val)) cls = "badge-blue";
return `<span class="badge ${cls}">${val === "execution" ? "已签约执行项目" : val === "opportunity" ? "业务机会项目" : val}</span>`;
}
function card(content, cls = "") {
return `<section class="card ${cls}">${content}</section>`;
}
function renderTable(headers, rows, rowClicks) {
const trAttrs = (rowClicks || []).map((rc) => rc ? `onclick="openDrawer('${rc.resource}', ${rc.id})" class="clickable-row"` : "");
return card(`
<div class="overflow-x-auto">
<table>
<thead><tr>${headers.map((h) => `<th>${h}</th>`).join("")}</tr></thead>
<tbody>${rows.map((row, i) => `<tr ${trAttrs[i] || ""}>${row.map((c) => `<td>${c}</td>`).join("")}</tr>`).join("")}</tbody>
</table>
</div>
`);
}
var _loadingSteps = [
{ fn: 'renderOverviewPlan', label: '正在加载预算总览', skip: function() { return state.tenant !== '总览'; } },
{ fn: 'renderOverviewFinance', label: '正在加载发生总览', skip: function() { return state.tenant !== '总览'; } },
{ fn: 'renderProjects', label: '正在加载业务机会列表' },
{ fn: 'renderProposals', label: '正在加载业务方案列表' },
{ fn: 'renderProducts', label: '正在加载产品迭代列表' },
{ fn: 'renderPerformance', label: '正在加载绩效管理' },
{ fn: 'renderFinance', label: '正在加载已发生模块(表格+总览+图表)' },
{ fn: 'renderPlan', label: '正在加载预算模块' },
];
function showLoadingOverlay(steps) {
var existing = document.getElementById('_loadingOverlay');
if (existing) existing.remove();
var stepsHtml = steps.map(function(s, i) {
return '<div id="_loadingStep_' + i + '" class="flex items-center gap-3 py-1.5 text-sm ' + (i === 0 ? 'text-brand-600 font-medium' : 'text-slate-400') + '">' +
'<span class="w-4 h-4 flex items-center justify-center">' + (i === 0 ? '<svg class="animate-spin" width="14" height="14" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" opacity="0.25"/><path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg>' : '<span class="w-2 h-2 rounded-full bg-slate-300"></span>') + '</span>' +
'<span>' + s.label + '</span>' +
'</div>';
}).join('');
var overlay = document.createElement('div');
overlay.id = '_loadingOverlay';
overlay.className = 'fixed inset-0 z-[99999] flex items-center justify-center bg-black/50';
overlay.innerHTML = '<div class="bg-white rounded-2xl shadow-2xl px-8 py-6 w-full max-w-md">' +
'<h3 class="text-base font-bold text-slate-800 mb-1">正在加载工作台</h3>' +
'<p class="text-xs text-slate-400 mb-4">共 ' + steps.length + ' 个模块,请稍候…</p>' +
'<div id="_loadingSteps">' + stepsHtml + '</div>' +
'<div class="mt-4 h-1 bg-slate-100 rounded-full overflow-hidden"><div id="_loadingProgress" class="h-full bg-brand-600 transition-all duration-300" style="width:0%"></div></div>' +
'</div>';
document.body.appendChild(overlay);
}
function updateLoadingStep(idx, total) {
var stepEl = document.getElementById('_loadingStep_' + idx);
if (stepEl) {
stepEl.className = 'flex items-center gap-3 py-1.5 text-sm text-green-600 font-medium';
stepEl.querySelector('span:first-child').innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>';
}
var nextEl = document.getElementById('_loadingStep_' + (idx + 1));
if (nextEl) {
nextEl.className = 'flex items-center gap-3 py-1.5 text-sm text-brand-600 font-medium';
nextEl.querySelector('span:first-child').innerHTML = '<svg class="animate-spin" width="14" height="14" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" opacity="0.25"/><path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg>';
}
var progress = document.getElementById('_loadingProgress');
if (progress) progress.style.width = Math.round((idx + 1) / total * 100) + '%';
}
function hideLoadingOverlay() {
var overlay = document.getElementById('_loadingOverlay');
if (overlay) overlay.remove();
}
async function load() {
var allSteps = [{ label: '正在加载工作台数据查询10+张表+计算财务汇总)' }].concat(_loadingSteps);
showLoadingOverlay(allSteps);
state.data = await api(`/api/bootstrap?tenant=${encodeURIComponent(state.tenant)}`);
// 初始化空数据,按需加载
if (!state.data.sales) state.data.sales = [];
if (!state.data.proposals) state.data.proposals = [];
if (!state.data.operations) state.data.operations = [];
if (!state.data.products) state.data.products = [];
if (!state.data.finance) state.data.finance = [];
if (!state.data.tasks) state.data.tasks = [];
if (!state.data.projectFinances) state.data.projectFinances = [];
if (!state.data.expense) state.data.expense = [];
if (!state.data.planFinances) state.data.planFinances = [];
if (!state.data.planExpense) state.data.planExpense = [];
clearTabCache();
updateLoadingStep(0, allSteps.length);
// 按需加载当前 tab 的数据(必须在 ensureStandardProposals 之前,否则会被 return 跳过)
await ensureTabData(state.active);
// 首次加载时确保标准资料库 7 项已初始化(仅首次,避免每次切换工作台都触发)
if (!_proposalsInitialized && typeof ensureStandardProposals === "function" && state.tenant !== '总览') {
_proposalsInitialized = true;
try {
const proposalsData = await api("/api/proposals/list?tenant=" + encodeURIComponent(state.tenant));
state.data.proposals = proposalsData;
const existing = (proposalsData || []).filter(p => p.proposal_type === "标准资料");
const missingCount = 7 - existing.length;
if (missingCount > 0) {
hideLoadingOverlay();
await ensureStandardProposals();
return;
}
} catch(e) { /* non-critical */ }
}
// 逐步渲染
for (var i = 0; i < _loadingSteps.length; i++) {
var step = _loadingSteps[i];
if (step.skip && step.skip()) {
updateLoadingStep(i + 1, allSteps.length);
continue;
}
if (typeof window[step.fn] === 'function') {
window[step.fn]();
}
updateLoadingStep(i + 1, allSteps.length);
await new Promise(function(r) { setTimeout(r, 50); });
}
if (window.lucide) window.lucide.createIcons();
setTimeout(hideLoadingOverlay, 300);
}
var _tabResourceMap = {
projects: 'operations',
proposals: 'proposals',
products: 'products',
finance: 'projectFinances',
plan: 'planFinances',
};
var _tabLoaded = {};
var _proposalsInitialized = false;
async function ensureTabData(tab) {
if (tab === 'performance' || tab === 'overview_plan' || tab === 'overview_finance') return;
var resource = _tabResourceMap[tab];
if (!resource) return;
var dataKey = resource;
if (_tabLoaded[dataKey + '_' + state.tenant]) return;
_tabLoaded[dataKey + '_' + state.tenant] = true;
try {
state.data[dataKey] = await api("/api/" + resource + "/list?tenant=" + encodeURIComponent(state.tenant));
} catch(e) { console.error('ensureTabData failed:', resource, e.message); }
// Load related expense data for finance/plan tabs
if (tab === 'finance') {
try { state.data.expense = await api("/api/expense/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {}
try { state.data.finance = await api("/api/finance/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {}
try { state.data.tasks = await api("/api/tasks/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {}
} else if (tab === 'projects') {
try { state.data.tasks = await api("/api/tasks/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {}
} else if (tab === 'plan') {
try { state.data.planExpense = await api("/api/planExpense/list?tenant=" + encodeURIComponent(state.tenant)); } catch(e) {}
}
}
function clearTabCache() {
_tabLoaded = {};
}
function switchTab(tab) {
state.active = tab;
localStorage.setItem("opc-active-tab", tab);
document.querySelectorAll(".sidebar-tab").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === tab));
document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === tab));
// 更新顶部标题
const titles = { finance: '执行管理', plan: '预算管理', projects: '重点工作台账', proposals: 'wiki', products: '版本与运营', performance: '季度绩效考核', overview_plan: '预算总览', overview_finance: '发生总览' };
const titleEl = document.querySelector('#workspaceTitle');
if (titleEl) titleEl.textContent = titles[tab] || state.tenant + ' OPC 工作台';
// 按需加载 tab 数据
ensureTabData(tab).then(function() {
renderActive();
if (window.lucide) window.lucide.createIcons();
});
}
function updateSidebarTabs() {
const isOverview = state.tenant === "总览";
document.querySelectorAll(".sidebar-tab").forEach((btn) => {
const tab = btn.dataset.tab;
if (isOverview) {
btn.style.display = (tab === "overview_plan" || tab === "overview_finance") ? "" : "none";
} else {
btn.style.display = (tab === "overview_plan" || tab === "overview_finance") ? "none" : "";
}
});
}
function renderActive() {
if (!state.data) return;
const tab = state.active;
if (tab === "overview_plan") renderOverviewPlan();
else if (tab === "overview_finance") renderOverviewFinance();
else if (tab === "projects") renderProjects();
else if (tab === "proposals") renderProposals();
else if (tab === "products") renderProducts();
else if (tab === "performance") renderPerformance();
else if (tab === "finance") renderFinance();
else if (tab === "plan") renderPlan();
if (window.lucide) window.lucide.createIcons();
}
window.setTaskView = (view) => {
state.taskView = view;
localStorage.setItem("opc-task-view", view);
// 更新按钮选中状态
const toggle = document.querySelector("#taskViewToggle");
if (toggle) {
toggle.querySelectorAll("button").forEach((btn, i) => {
const isCompact = i === 0;
btn.className = `btn btn-sm ${(isCompact ? view === 'compact' : view !== 'compact') ? 'btn-primary' : 'btn-ghost'} p-1.5`;
});
}
if (state.selectedProject) {
const body = document.querySelector(".task-feed-body");
if (body) body.innerHTML = renderTaskListHTML(state.selectedProject);
if (window.lucide) window.lucide.createIcons();
}
};
window.switchTab = switchTab;
window.setFinView = (view) => {
state.finView = view;
renderFinance();
};
window.setFinQuarter = (q) => {
if (state.finView === 'quarterly' && state.finQuarter === q) { state.finView = 'overview'; }
else { state.finQuarter = q; state.finView = 'quarterly'; }
renderFinance();
};
window.setFinMonth = (month) => {
if (state.finView === 'monthly' && state.finMonth === month) { state.finView = 'overview'; }
else { state.finMonth = month; state.finView = 'monthly'; }
renderFinance();
};
window.setFinYear = (year) => { state.finYear = parseInt(year); renderFinance(); };
window.switchFinFilter = (filter) => {
state.finFilter = filter;
state.finSort = null;
renderFinance();
};
window.setFinSort = (key) => {
if (!state.finSort) state.finSort = { key: null, asc: true };
if (state.finSort.key === key) { state.finSort.asc = !state.finSort.asc; }
else { state.finSort.key = key; state.finSort.asc = true; }
renderFinance();
};
window.setPlanView = (view) => {
state.planView = view;
renderPlan();
};
window.setPlanQuarter = (q) => {
if (state.planView === 'quarterly' && state.planQuarter === q) { state.planView = 'overview'; }
else { state.planQuarter = q; state.planView = 'quarterly'; }
renderPlan();
};
window.setPlanMonth = (month) => {
if (state.planView === 'monthly' && state.planMonth === month) { state.planView = 'overview'; }
else { state.planMonth = month; state.planView = 'monthly'; }
renderPlan();
};
window.setPlanYear = (year) => { state.planYear = parseInt(year); renderPlan(); };
window.setPlanStatusFilter = (filter) => { state.planStatusFilter = filter; renderPlan(); };
window.switchPlanFilter = (filter) => {
state.planFilter = filter;
state.planSort = null;
renderPlan();
};
window.setPlanSort = (key) => {
if (!state.planSort) state.planSort = { key: null, asc: true };
if (state.planSort.key === key) { state.planSort.asc = !state.planSort.asc; }
else { state.planSort.key = key; state.planSort.asc = true; }
renderPlan();
};
window.switchTenant = async (tenant) => {
state.tenant = tenant;
state.selectedProject = null;
localStorage.setItem("opc-active-tenant", tenant);
document.querySelector("#workspaceTitle").textContent = tenant === "总览" ? "总览 OPC 工作台" : tenant.replace("·无界", "") + " OPC 工作台";
if (typeof updateTenantLabel === "function") updateTenantLabel();
state.planFilter = 'overview';
state.finFilter = 'overview';
updateSidebarTabs();
if (tenant === "总览") {
state.active = "overview_plan";
localStorage.setItem("opc-active-tab", "overview_plan");
document.querySelectorAll(".sidebar-tab").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === "overview_plan"));
document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === "overview_plan"));
} else if (state.active === 'overview_plan' || state.active === 'overview_finance') {
state.active = 'plan';
localStorage.setItem("opc-active-tab", "plan");
document.querySelectorAll(".sidebar-tab").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === "plan"));
document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === "plan"));
} else {
document.querySelectorAll(".sidebar-tab").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === state.active));
document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === state.active));
}
await load();
};
// 轻量刷新辅助函数
async function refreshResource(resource, renderFn) {
try {
var dataKey = resource;
state.data[dataKey] = await api("/api/" + resource + "/list?tenant=" + encodeURIComponent(state.tenant));
if (renderFn && typeof window[renderFn] === 'function') window[renderFn]();
} catch(e) { /* non-critical */ }
}
window.doLogout = async () => {
await api("/api/auth/logout", { method: "POST" });
location.href = "/login";
};
// 图表工具函数(曾被 home.js 中 plan/finance 模块复用)
const moneyTick = (v) => v >= 10000 ? (v / 10000).toFixed(0) + "万" : v;
const monthLabels = (data) => data.map((x) => parseInt(x.month.split("-")[1]) + "月");
function chartOptions(yCallback) {
return {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: "bottom", labels: { boxWidth: 12, font: { size: 11 } } } },
scales: {
x: { ticks: { font: { size: 10 } }, grid: { display: false } },
y: { ticks: { font: { size: 11 }, callback: yCallback } },
},
};
}