v1.1.0-beta: 安全/性能/架构优化 + 账号管理后台 + 视图切换
## 安全与性能 - .env 环境变量、debug=False、except 改 mysql.connector.Error+logging - attach_common 批量 IN 查询消除 N+1 - 批量 esc() XSS 转义 ## 架构 - app.js 拆分为 7 模块 + admin.js - .form-ctrl 统一表单控件 ## 经营管理 - 字段改名:客户名称→项目名称、销售人员→商务负责人 - 必填:项目名称/商务负责人/经营负责人/签约月份/签约金额>0 - 视图切换:确收/毛利 ↔ 回款/费用 ## 重点工作与台账 - 统计卡片样式与经营管理统一 - 任务状态简化 3 态 - 优先级点击切换、右键菜单(重命名/副本) - 修复新建任务绑定错误项目 bug ## 用户体系 - 新增工作台:MCN·无界、无界·无界 - 新增账号:mcn/wuji - 账号管理后台(admin 限定) - sidebar 顶部头像+显示名,点击弹菜单 - sidebar sticky 定位 ## 其他 - 登录页样式优化(参考 UOC 平台) - 首页财务趋势拆 3 图 - 业务方案标准资料库双 Tab
This commit is contained in:
188
static/modules/utils.js
Normal file
188
static/modules/utils.js
Normal file
@@ -0,0 +1,188 @@
|
||||
// utils.js — 全局工具函数与共享状态
|
||||
|
||||
const state = {
|
||||
active: "home",
|
||||
data: null,
|
||||
tenant: "科普·无界",
|
||||
opFilter: "all",
|
||||
finFilter: "已签约",
|
||||
selectedProject: null,
|
||||
taskQuery: "",
|
||||
taskView: localStorage.getItem("opc-task-view") || "detail",
|
||||
finView: localStorage.getItem("opc-fin-view") || "rev",
|
||||
proposalTab: "standard",
|
||||
chart: null,
|
||||
chart2: null,
|
||||
chart3: null,
|
||||
productPlatform: "all",
|
||||
uploadTasks: [],
|
||||
};
|
||||
|
||||
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 now = new Date();
|
||||
const startYear = now.getFullYear() - 1;
|
||||
const endYear = now.getFullYear() + 1;
|
||||
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>
|
||||
`);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
state.data = await api(`/api/bootstrap?tenant=${encodeURIComponent(state.tenant)}`);
|
||||
// 首次加载时确保标准资料库 7 项已初始化
|
||||
if (typeof ensureStandardProposals === "function") {
|
||||
const existing = (state.data.proposals || []).filter(p => p.proposal_type === "标准资料");
|
||||
const missingCount = 7 - existing.length;
|
||||
if (missingCount > 0) {
|
||||
await ensureStandardProposals();
|
||||
return; // ensureStandardProposals 内部会再次 render
|
||||
}
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function switchTab(tab) {
|
||||
state.active = tab;
|
||||
localStorage.setItem("opc-active-tab", tab);
|
||||
document.querySelectorAll("#tabs button").forEach((btn) => btn.classList.toggle("active", btn.dataset.tab === tab));
|
||||
document.querySelectorAll(".panel").forEach((panel) => panel.classList.toggle("active", panel.id === tab));
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!state.data) return;
|
||||
renderHome();
|
||||
renderProjects();
|
||||
renderProposals();
|
||||
renderProducts();
|
||||
renderFinance();
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
}
|
||||
|
||||
function renderActive() {
|
||||
if (!state.data) return;
|
||||
const tab = state.active;
|
||||
if (tab === "home") renderHome();
|
||||
else if (tab === "projects") renderProjects();
|
||||
else if (tab === "proposals") renderProposals();
|
||||
else if (tab === "products") renderProducts();
|
||||
else if (tab === "finance") renderFinance();
|
||||
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;
|
||||
localStorage.setItem("opc-fin-view", view);
|
||||
renderFinance();
|
||||
};
|
||||
window.switchTenant = (tenant) => {
|
||||
state.tenant = tenant;
|
||||
state.selectedProject = null;
|
||||
localStorage.setItem("opc-active-tenant", tenant);
|
||||
document.querySelector("#workspaceTitle").textContent = tenant.replace("·无界", "") + " OPC 工作台";
|
||||
document.querySelectorAll(".workspace-nav-item").forEach((el) => el.classList.toggle("active", el.dataset.tenant === tenant));
|
||||
load();
|
||||
};
|
||||
window.doLogout = async () => {
|
||||
await api("/api/auth/logout", { method: "POST" });
|
||||
location.href = "/login";
|
||||
};
|
||||
Reference in New Issue
Block a user