- 侧边栏新增「我的」tab,排序第一,默认首页 - 模块内容:绩效 → 预算月报 → 已发生月报 → 重点项目 - 绩效:自动统计目标/完成/完成率/评分,年/季/月筛选 - 预算月报/已发生月报:12 月汇总表(7 项核心指标) - 重点项目:展示 Focus 项目清单,点击跳转定位 - 创建 my.js 模块文件,替换原 performance.js 入口 - 总览工作台不显示「我的」入口
435 lines
19 KiB
JavaScript
435 lines
19 KiB
JavaScript
// utils.js — 全局工具函数与共享状态
|
|
|
|
const state = {
|
|
active: "my",
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); }
|
|
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: 'renderMy', 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 === 'my' || 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: '季度绩效考核', my: '我的', 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 === "my") renderMy();
|
|
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 } },
|
|
},
|
|
};
|
|
}
|