diff --git a/static/app.js b/static/app.js
index f2c89bb..4cf0f2d 100644
--- a/static/app.js
+++ b/static/app.js
@@ -6,7 +6,7 @@ applyUserTenants().then(() => {
return load();
}).then(() => {
const savedTab = localStorage.getItem("opc-active-tab");
- const validTabs = ["plan", "finance", "projects", "proposals", "products", "performance", "overview_plan", "overview_finance"];
+ const validTabs = ["plan", "finance", "projects", "proposals", "products", "my", "performance", "overview_plan", "overview_finance"];
if (savedTab && validTabs.includes(savedTab) && document.getElementById(savedTab)) {
switchTab(savedTab);
} else {
diff --git a/static/modules/my.js b/static/modules/my.js
new file mode 100644
index 0000000..e8be1fa
--- /dev/null
+++ b/static/modules/my.js
@@ -0,0 +1,221 @@
+// my.js — 我的模块(绩效 + 预算月报 + 已发生月报)
+window.renderMy = () => {
+ const data = state.data;
+ if (!data) return;
+
+ // 确保数据已加载
+ if (!data._myDataLoaded) {
+ loadMyData().then(() => { data._myDataLoaded = true; renderMy(); });
+ return;
+ }
+
+ // ── 状态初始化 ──
+ if (state.planYear == null) state.planYear = new Date().getFullYear();
+ const planYear = state.planYear;
+ if (!['annual','quarterly','monthly'].includes(state.perfView)) state.perfView = 'quarterly';
+ if (state.perfQuarter === undefined) state.perfQuarter = Math.floor(new Date().getMonth() / 3);
+ const nowD = new Date();
+ const activeQ = state.perfQuarter;
+ const qRanges = [[1,2,3],[4,5,6],[7,8,9],[10,11,12]];
+ const activeMonths = qRanges[activeQ];
+ const qLabels = ['Q1','Q2','Q3','Q4'];
+
+ let aggMonths = [];
+ if (state.perfView === 'annual') { for (var mi = 1; mi <= 12; mi++) aggMonths.push(mi); }
+ else if (state.perfView === 'quarterly') { aggMonths = qRanges[activeQ]; }
+ else if (state.perfView === 'monthly') { const m = state.perfMonth; aggMonths = m ? [parseInt(m.substring(5))] : []; }
+
+ if (state.perfView === 'monthly' && !state.perfMonth) {
+ state.perfMonth = planYear + '-' + String(nowD.getMonth() + 1).padStart(2, '0');
+ }
+
+ const pfs = (data.projectFinances || []);
+ const planPfs = (data.planFinances || []);
+
+ // ── 1. 绩效 ──
+ function sumByMonths(items, field, useSign, months) {
+ let total = 0;
+ items.forEach(p => {
+ if (useSign) {
+ const sm = p.sign_month || '';
+ const smY = parseInt(sm.substring(0, 4)) || 0;
+ const smM = parseInt(sm.substring(5)) || 0;
+ if (!months.includes(smM)) return;
+ if (planYear !== 0 && smY && smY !== planYear) return;
+ total += parseFloat(p.sign_amount || 0);
+ } else {
+ let bd = [];
+ try { bd = JSON.parse(p.budget_data || '[]'); } catch (e) {}
+ bd.forEach(b => {
+ const m = parseInt((b.month || '').substring(5)) || 0;
+ const y = parseInt((b.month || '').substring(0, 4)) || 0;
+ if (!months.includes(m)) return;
+ if (planYear !== 0 && y && y !== planYear) return;
+ total += parseFloat(b[field] || 0);
+ });
+ }
+ });
+ return Math.round(total);
+ }
+
+ const perfRows = [
+ { key: 'sign', label: '签约', complete: sumByMonths(pfs, '', true, aggMonths), plan: sumByMonths(planPfs, '', true, aggMonths), icon: 'file-text', defWeight: 10 },
+ { key: 'rev', label: '收入', complete: sumByMonths(pfs, 'rev', false, aggMonths), plan: sumByMonths(planPfs, 'rev', false, aggMonths), icon: 'dollar-sign', defWeight: 20 },
+ { key: 'gross', label: '毛利', complete: sumByMonths(pfs, 'gross', false, aggMonths), plan: sumByMonths(planPfs, 'gross', false, aggMonths), icon: 'trending-up', defWeight: 50 },
+ { key: 'payment', label: '回款', complete: sumByMonths(pfs, 'payment', false, aggMonths), plan: sumByMonths(planPfs, 'payment', false, aggMonths), icon: 'coins', defWeight: 20 },
+ ];
+
+ let storageKey = 'opc-perf-t';
+ if (state.perfView === 'quarterly') storageKey = 'opc-perf-Q' + (activeQ + 1);
+ else if (state.perfView === 'monthly') storageKey = 'opc-perf-M' + (state.perfMonth || '');
+ const saved = JSON.parse(localStorage.getItem(storageKey) || '{}');
+
+ function val(key, field, def) { const r = saved[key] || {}; return r[field] !== undefined && r[field] !== '' ? Number(r[field]) : def; }
+ function moneyWan(v) { if (Math.abs(v) >= 10000) return (v / 10000).toFixed(1) + '万'; return v.toLocaleString(); }
+ function moneyIntL(v) { return Math.round(Number(v || 0)).toLocaleString('zh-CN'); }
+
+ let totalScore = 0;
+ const perfHTML = perfRows.map(r => {
+ const target = r.plan; const weight = val(r.key, 'weight', r.defWeight);
+ const score = target > 0 ? Math.round((r.complete / target) * weight * 10) / 10 : 0;
+ const rate = target > 0 ? Math.round((r.complete / target) * 100) : 0;
+ totalScore += score;
+ return `
+ | ${r.label} |
+ ${moneyWan(target)} |
+ ${moneyWan(r.complete)} |
+ ${rate}% |
+ |
+ ${score.toFixed(1)} |
`;
+ }).join('');
+
+ // ── 绩效 Header ──
+ const yearOpts = [2020,2021,2022,2023,2024,2025,2026,2027];
+ const yearSelect = `年份:`;
+ const viewBtns = ``;
+ const quarterBtns = qLabels.map((ql,i) => ``).join('');
+ const monthBtns = activeMonths.map(m => { const ms = planYear+'-'+String(m).padStart(2,'0'); return ``; }).join('');
+ const perfHeader = `${yearSelect}|${viewBtns}|季度:${quarterBtns}|月度:${monthBtns}
`;
+
+ // ── 2. 预算月报 ──
+ const planFm = computePlanMonthly(planPfs, planYear);
+ const mLbl = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
+ const pLabels = ['合同金额','确收金额','确收毛利','成本','支出','回款','项目现金流'];
+ const pFields = ['sign','revenue','gross','cost','paid','payment',null];
+ var pAnnual = { sign:0, revenue:0, gross:0, cost:0, paid:0, payment:0 };
+ for (var i = 0; i < planFm.length; i++) { pAnnual.sign += planFm[i].sign; pAnnual.revenue += planFm[i].revenue; pAnnual.gross += planFm[i].gross; pAnnual.cost += planFm[i].cost; pAnnual.paid += planFm[i].paid; pAnnual.payment += planFm[i].payment; }
+ var planThead = '| 指标 | 年度累计 | ';
+ for (var mi2 = 0; mi2 < 12; mi2++) { planThead += '' + mLbl[mi2] + ' | '; }
+ planThead += '
';
+ var planTbody = '';
+ for (var ri = 0; ri < pLabels.length; ri++) {
+ var label = pLabels[ri];
+ var fKey = pFields[ri];
+ var annualVal = fKey ? pAnnual[fKey] : pAnnual.payment - pAnnual.paid;
+ planTbody += '| ' + label + ' | ' + moneyIntL(annualVal) + ' | ';
+ for (var mi3 = 0; mi3 < 12; mi3++) {
+ if (fKey) { planTbody += '' + moneyIntL(planFm[mi3][fKey]) + ' | '; }
+ else { planTbody += '' + moneyIntL(planFm[mi3].payment - planFm[mi3].paid) + ' | '; }
+ }
+ planTbody += '
';
+ }
+ var planOverviewHTML = ``;
+
+ // ── 3. 已发生月报 ──
+ const finPfs = (data.projectFinances || []);
+ const finFm = computePlanMonthly(finPfs, planYear);
+ var fAnnual = { sign:0, revenue:0, gross:0, cost:0, paid:0, payment:0 };
+ for (var fi = 0; fi < finFm.length; fi++) { fAnnual.sign += finFm[fi].sign; fAnnual.revenue += finFm[fi].revenue; fAnnual.gross += finFm[fi].gross; fAnnual.cost += finFm[fi].cost; fAnnual.paid += finFm[fi].paid; fAnnual.payment += finFm[fi].payment; }
+ var finThead = '| 指标 | 年度累计 | ';
+ for (var mi4 = 0; mi4 < 12; mi4++) { finThead += '' + mLbl[mi4] + ' | '; }
+ finThead += '
';
+ var finTbody = '';
+ var fLabels2 = ['合同金额','确收金额','确收毛利','成本','支出','回款','项目现金流'];
+ for (var ri2 = 0; ri2 < fLabels2.length; ri2++) {
+ var fKey2 = pFields[ri2];
+ var aVal = fKey2 ? fAnnual[fKey2] : fAnnual.payment - fAnnual.paid;
+ finTbody += '| ' + fLabels2[ri2] + ' | ' + moneyIntL(aVal) + ' | ';
+ for (var mi5 = 0; mi5 < 12; mi5++) {
+ if (fKey2) { finTbody += '' + moneyIntL(finFm[mi5][fKey2]) + ' | '; }
+ else { finTbody += '' + moneyIntL(finFm[mi5].payment - finFm[mi5].paid) + ' | '; }
+ }
+ finTbody += '
';
+ }
+ var finOverviewHTML = ``;
+
+ // ── 组装页面 ──
+ document.querySelector("#my").innerHTML = `
+
+
+
绩效
+ ${perfHeader}
+
+
+
+ | 指标 |
+ 目标(万) |
+ 完成情况 |
+ 完成率 |
+ 权重 |
+ 评分 |
+
+ ${perfHTML}
+
+ | 合计 | — | — | — | 100 | ${totalScore.toFixed(1)} |
+
+
+
+
预算月报
+ ${planOverviewHTML}
+
已发生月报
+ ${finOverviewHTML}
+
重点项目
+
+ ${renderMyProjects()}
+
+
`;
+
+// ── 重点项目列表 ──
+function renderMyProjects() {
+ const ops = state.data.operations || [];
+ if (!ops.length) return '暂无项目
';
+ return '';
+}
+
+ if (window.lucide) window.lucide.createIcons();
+};
+
+// ── 筛选切换 ──
+window.setMyYear = (y) => { state.planYear = parseInt(y); renderMy(); };
+window.setMyView = (v) => { state.perfView = v; renderMy(); };
+window.setMyQuarter = (q) => { state.perfView = 'quarterly'; state.perfQuarter = q; renderMy(); };
+window.setMyMonth = (m) => { state.perfView = 'monthly'; state.perfMonth = m; renderMy(); };
+window.updateMyScore = (key, field, value) => {
+ let storageKey = 'opc-perf-t';
+ if (state.perfView === 'quarterly') storageKey = 'opc-perf-Q' + ((state.perfQuarter || 0) + 1);
+ else if (state.perfView === 'monthly') storageKey = 'opc-perf-M' + (state.perfMonth || '');
+ const saved = JSON.parse(localStorage.getItem(storageKey) || '{}');
+ saved[key] = saved[key] || {};
+ saved[key][field] = value;
+ localStorage.setItem(storageKey, JSON.stringify(saved));
+ renderMy();
+};
+
+// ── 数据加载 ──
+async function loadMyData() {
+ for (var i = 0; i < 3; i++) {
+ var r = i === 0 ? 'planFinances' : i === 1 ? 'projectFinances' : 'operations';
+ try { state.data[r] = await api("/api/" + r + "/list?tenant=" + encodeURIComponent(state.tenant)); }
+ catch(e) { console.error('loadMyData failed:', r, e.message); }
+ }
+}
diff --git a/static/modules/utils.js b/static/modules/utils.js
index bc45bc0..b5dc6a6 100644
--- a/static/modules/utils.js
+++ b/static/modules/utils.js
@@ -1,7 +1,7 @@
// utils.js — 全局工具函数与共享状态
const state = {
- active: "plan",
+ active: "my",
data: null,
tenant: localStorage.getItem("opc-active-tenant") || "科普·无界",
opFilter: "all",
@@ -126,6 +126,7 @@ var _loadingSteps = [
{ fn: 'renderProposals', label: '正在加载业务方案列表' },
{ fn: 'renderProducts', label: '正在加载产品迭代列表' },
{ fn: 'renderPerformance', label: '正在加载绩效管理' },
+ { fn: 'renderMy', label: '正在加载我的模块' },
{ fn: 'renderFinance', label: '正在加载已发生模块(表格+总览+图表)' },
{ fn: 'renderPlan', label: '正在加载预算模块' },
];
@@ -234,7 +235,7 @@ var _tabLoaded = {};
var _proposalsInitialized = false;
async function ensureTabData(tab) {
- if (tab === 'performance' || tab === 'overview_plan' || tab === 'overview_finance') return;
+ if (tab === 'my' || tab === 'performance' || tab === 'overview_plan' || tab === 'overview_finance') return;
var resource = _tabResourceMap[tab];
if (!resource) return;
var dataKey = resource;
@@ -266,7 +267,7 @@ function switchTab(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 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 工作台';
@@ -298,6 +299,7 @@ function renderActive() {
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();
diff --git a/templates/index.html b/templates/index.html
index 4e0e5e9..4514c34 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -63,6 +63,10 @@
@@ -152,7 +152,7 @@
-
+