Files
opc-manager/static/modules/proposals.js
mac 9b6257ff19 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
2026-06-23 15:54:03 +08:00

336 lines
18 KiB
JavaScript

// proposals.js — 业务方案 + 文件管理
// 标准资料库固定 7 项
const STANDARD_PROPOSALS = [
"业务方案-医生版",
"业务方案-药企版",
"服务清单与报价单",
"患者服务清单",
"医生项目清单与劳务报价",
"项目执行 SOP",
"财务结算流程",
];
// 确保标准资料库已初始化(首次进入时创建)
async function ensureStandardProposals() {
const existing = (state.data.proposals || []).filter(p => p.proposal_type === "标准资料");
const missing = STANDARD_PROPOSALS.filter(name => !existing.find(p => p.customer_or_project_name === name));
if (missing.length === 0) return;
for (const name of missing) {
try {
await api("/api/proposals", {
method: "POST",
body: JSON.stringify({ data: {
customer_or_project_name: name,
proposal_type: "标准资料",
notes: "",
version: "v1.0",
status: "已归档",
created_date: new Date().toISOString().slice(0, 10),
tenant: state.tenant,
}}),
});
} catch (e) { /* ignore */ }
}
await load();
}
window.switchProposalTab = (tab) => {
state.proposalTab = tab;
renderProposals();
};
function renderProposals() {
const items = state.data.proposals || [];
const standardItems = items.filter(p => p.proposal_type === "标准资料");
const otherItems = items.filter(p => p.proposal_type !== "标准资料");
const isStandard = state.proposalTab === "standard";
document.querySelector("#proposals").innerHTML = `<div class="grid gap-4">
<div class="flex items-center justify-between">
<div class="flex gap-1" id="proposalTabToggle">
<button class="btn btn-sm ${isStandard ? 'btn-primary' : 'btn-ghost'}" onclick="switchProposalTab('standard')">标准资料库</button>
<button class="btn btn-sm ${!isStandard ? 'btn-primary' : 'btn-ghost'}" onclick="switchProposalTab('other')">其他资料</button>
</div>
${!isStandard ? `<button class="btn btn-primary btn-sm" onclick="openProposalModal()"><i data-lucide="plus"></i>新增方案</button>` : ''}
</div>
${isStandard ? `<div class="flex items-start gap-2 rounded-lg bg-blue-50 border border-blue-100 px-4 py-3 text-sm text-blue-700"><i data-lucide="info" style="width:16px;height:16px;flex-shrink:0;margin-top:1px"></i><span>这是每一条 OPC 线,必须要梳理清楚的 7 份资料,项目不可以删除,只可以更新附件,请大家将最新的材料上传</span></div>` : `<div class="flex items-start gap-2 rounded-lg bg-emerald-50 border border-emerald-100 px-4 py-3 text-sm text-emerald-700"><i data-lucide="lightbulb" style="width:16px;height:16px;flex-shrink:0;margin-top:1px"></i><span>在这里新建,并且上传您希望与团队其他成员共享的资料</span></div>`}
${isStandard ? renderStandardTable(standardItems) : renderOtherTable(otherItems)}
</div>
<div id="proposalModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeProposalModal()">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4" onclick="event.stopPropagation()">
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-100">
<h3 class="text-lg font-semibold text-slate-800">新增方案</h3>
<button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closeProposalModal()"><i data-lucide="x"></i></button>
</div>
<form onsubmit="submitProposal(event)" class="p-6 grid gap-4">
<label class="block"><span class="text-xs font-medium text-slate-500">方案名称</span><input name="customer_or_project_name" required class="form-ctrl mt-1"></label>
<label class="block"><span class="text-xs font-medium text-slate-500">方案类型</span><select name="proposal_type" class="form-ctrl mt-1">${["业务方案","报价与成本","SOP","PRD","设计稿","财务流程","其他"].map(t => `<option>${t}</option>`).join("")}</select></label>
<label class="block"><span class="text-xs font-medium text-slate-500">方案说明</span><textarea name="notes" rows="3" class="form-ctrl mt-1"></textarea></label>
<div class="flex justify-end gap-3 pt-3 border-t border-slate-100">
<button type="button" class="btn btn-ghost btn-sm" onclick="closeProposalModal()">取消</button>
<button type="submit" class="btn btn-primary btn-sm">创建</button>
</div>
</form>
</div>
</div>`;
if (window.lucide) window.lucide.createIcons();
}
// 标准资料库:按固定顺序排序,点击行打开附件抽屉(不含删除按钮)
function renderStandardTable(items) {
const sorted = STANDARD_PROPOSALS.map(name => items.find(p => p.customer_or_project_name === name)).filter(Boolean);
const rows = sorted.map((p) => {
const fileCount = (p.files || []).length;
return `<tr class="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" onclick="openStandardProposalDrawer(${p.id})">
<td class="p-3 text-sm font-medium text-slate-800">${esc(p.customer_or_project_name)}</td>
<td class="p-3 text-sm text-slate-500 text-center">${fileCount} 个文件</td>
<td class="p-3 text-sm text-slate-500 text-center">${(p.created_at || "").slice(0,10) || "—"}</td>
</tr>`;
}).join("");
return `<div class="bg-white rounded-lg border border-slate-200 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead><tr class="bg-slate-50 border-b border-slate-200">
<th class="p-3 text-left font-semibold text-slate-600">资料名称</th>
<th class="p-3 text-center font-semibold text-slate-600">附件</th>
<th class="p-3 text-center font-semibold text-slate-600">创建日期</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
</div>`;
}
// 其他资料:原有表格 + 行点击打开抽屉
function renderOtherTable(items) {
const rows = items.map((p) => [
`<strong>${esc(p.customer_or_project_name)}</strong>`,
p.proposal_type || "业务方案",
text(p.notes || ""),
(p.created_at || "").slice(0, 10) || "\u2014",
]);
return renderTable(["方案名称", "方案类型", "方案说明", "日期"], rows, items.map((p) => ({ resource: "proposals", id: p.id })));
}
// 标准资料专用抽屉(附件管理 + 评论,不能编辑字段、不能删除项目)
window.openStandardProposalDrawer = (id) => {
const item = (state.data.proposals || []).find(p => p.id === id);
if (!item) return;
const drawer = document.querySelector("#drawer");
const title = esc(item.customer_or_project_name);
const followupTarget = "proposal";
drawer.innerHTML = `<div class="drawer-panel"><div class="sticky top-0 z-10 flex items-center justify-between border-b border-slate-200 bg-white/95 px-5 py-3 backdrop-blur"><div><p class="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-400">标准资料</p><div class="flex items-center gap-2"><h2 class="drawer-title text-[17px] font-semibold leading-6 text-slate-900">${title}</h2><span id="drawerSaveStatus" class="save-status"></span></div></div><button class="btn btn-ghost btn-sm" onclick="closeDrawer()">关闭</button></div><div class="grid gap-5 p-5">
<section>
<h3 class="drawer-section-title">附件管理</h3>
${fileGroup("proposal", item.id, "", "附件", item.files || [])}
</section>
${followupTarget ? `<section>
<h3 class="drawer-section-title">活动 / 跟进</h3>
<div class="grid gap-2">${(item.followups || []).map((f) => `<div class="activity-item"><div class="activity-icon"><i data-lucide="message-square"></i></div><div class="min-w-0 flex-1"><div class="flex justify-between gap-3 text-[12px] text-slate-500"><span>${esc(f.follower)} · ${esc(f.follow_up_method)}</span><span>${esc(f.followed_at)}</span></div><div class="mt-1 leading-5 text-slate-800 rich-content" data-html="${encodeURIComponent(f.content || '')}"></div>${f.next_action ? `<p class="mt-1 leading-5 text-blue-700">下一步:${text(f.next_action)}</p>` : ""}</div><button class="activity-delete" onclick="deleteFollowup(event, ${f.id}, 'proposals', ${item.id})" title="删除评论"><i data-lucide="trash-2"></i></button></div>`).join("")}</div>
<form class="comment-box mt-3" onsubmit="submitStandardComment(event, ${item.id})">
<div class="squire-toolbar">
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('bold')" title="加粗"><i data-lucide="bold"></i></button>
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('italic')" title="斜体"><i data-lucide="italic"></i></button>
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('underline')" title="下划线"><i data-lucide="underline"></i></button>
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('strikethrough')" title="删除线"><i data-lucide="strikethrough"></i></button>
<span class="squire-sep"></span>
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('makeUnorderedList')" title="无序列表"><i data-lucide="list"></i></button>
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('makeOrderedList')" title="有序列表"><i data-lucide="list-ordered"></i></button>
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('blockquote')" title="引用"><i data-lucide="quote"></i></button>
<span class="squire-sep"></span>
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('undo')" title="撤销"><i data-lucide="undo"></i></button>
<button type="button" class="squire-btn" onmousedown="event.preventDefault();squireCmd('redo')" title="重做"><i data-lucide="redo"></i></button>
</div>
<div class="squire-editor" id="squire_standard_${item.id}" placeholder="添加评论"></div>
<div class="comment-toolbar">
<span class="comment-hint">支持富文本编辑</span>
<button class="btn btn-primary btn-sm comment-submit" type="submit">评论</button>
</div>
</form>
</section>` : ""}
<div id="uploadTaskList"></div>
</div></div>`;
drawer.classList.add("open");
if (window.lucide) window.lucide.createIcons();
renderUploadTasks();
// 渲染富文本评论内容
drawer.querySelectorAll(".rich-content").forEach((el) => {
const html = el.dataset.html;
if (html) el.innerHTML = decodeURIComponent(html);
});
// 初始化 Squire 编辑器
const squireDiv = drawer.querySelector(".squire-editor");
if (squireDiv && window.Squire) {
const sid = squireDiv.id;
if (window.squireInstances[sid]) window.squireInstances[sid].destroy();
const sq = new Squire(squireDiv, { blockTag: "P" });
window.squireInstances[sid] = sq;
squireDiv.addEventListener("focus", () => squireDiv.classList.add("focused"));
squireDiv.addEventListener("blur", () => {
if (!squireDiv.textContent.trim()) squireDiv.classList.remove("focused");
});
}
};
// 标准资料评论提交(提交后重新打开标准资料抽屉)
window.submitStandardComment = async (event, targetId) => {
event.preventDefault();
const form = event.currentTarget;
const editorDiv = form.querySelector(".squire-editor");
const sq = window.squireInstances[editorDiv.id];
const content = sq ? sq.getHTML().trim() : "";
if (!content || content === "<div><br></div>" || content === "<p><br></p>") return;
const button = form.querySelector(".comment-submit");
button.disabled = true;
button.textContent = "发送中…";
await api(`/api/followups/proposal/${targetId}`, { method: "POST", body: JSON.stringify({ data: { content } }) });
await load();
openStandardProposalDrawer(targetId);
};
window.openProposalModal = () => {
document.querySelector("#proposalModal").classList.remove("hidden");
};
window.closeProposalModal = () => {
document.querySelector("#proposalModal").classList.add("hidden");
};
window.submitProposal = async (event) => {
event.preventDefault();
const form = event.currentTarget;
const data = Object.fromEntries(new FormData(form).entries());
data.tenant = state.tenant;
if (!data.version) data.version = "v1.0";
if (!data.description) data.description = "";
if (!data.status) data.status = "草稿";
if (!data.created_date) data.created_date = new Date().toISOString().slice(0, 10);
try {
const result = await api("/api/proposals", { method: "POST", body: JSON.stringify({ data }) });
if (result.id && data.customer_or_project_name) logActivity("proposal", result.id, "创建了方案「" + data.customer_or_project_name + "」");
form.reset();
closeProposalModal();
await load();
} catch (error) {
toast("保存失败:" + error.message, "error");
}
};
// 文件管理
function fileGroup(module, ownerId, version, category, files) {
return `<div class="rounded-md border border-slate-200 px-3 py-2">
<div class="flex items-center justify-between gap-3"><p class="text-[13px] font-semibold text-slate-800">${category}</p><label class="inline-flex cursor-pointer items-center gap-1 rounded-md border border-slate-200 px-2 py-1 text-[12px] font-medium text-slate-600 hover:bg-slate-50"><i data-lucide="upload"></i>上传<input class="hidden" type="file" onchange="uploadFile(event,'${module}',${ownerId},'${version}','${category}')"></label></div>
<div class="mt-2 grid gap-1.5">${files.length ? files.map(fileItem).join("") : `<p class="text-[12px] text-slate-400">暂无文件</p>`}</div>
</div>`;
}
function fileItem(file) {
return `<div class="flex items-center justify-between gap-2 rounded-md bg-slate-50 px-2 py-1.5 text-[13px]"><div class="min-w-0 flex-1"><p class="truncate font-medium text-slate-800">${esc(file.file_name)}</p><div class="mt-0.5 flex gap-3"><a class="file-link inline-flex items-center gap-1 text-slate-600" href="/api/files/${file.id}/content?inline=false"><i data-lucide="download"></i>下载</a></div></div><button class="btn btn-ghost btn-sm text-red-600" onclick="deleteFile(${file.id})" title="删除"><i data-lucide="trash-2"></i></button></div>`;
}
window.deleteFile = async (fileId) => {
if (!confirm("确认删除此文件?")) return;
await api(`/api/files/${fileId}`, { method: "DELETE" });
// 优先在当前打开的抽屉中查找并刷新
const drawer = document.querySelector("#drawer.open");
if (drawer) {
const uploadList = drawer.querySelector("#uploadTaskList");
// 通过 file.id 反查所属 item
for (const listKey of ["proposals", "operations", "sales", "products"]) {
if (!state.data[listKey]) continue;
for (const item of state.data[listKey]) {
if (!item.files) continue;
const idx = item.files.findIndex(f => f.id === fileId);
if (idx !== -1) {
item.files.splice(idx, 1);
// 判断是标准资料还是普通抽屉
if (item.proposal_type === "标准资料") {
openStandardProposalDrawer(item.id);
} else {
openDrawer(listKey, item.id);
}
return;
}
}
}
}
};
window.uploadFile = (event, module, ownerId, version, category) => {
const file = event.target.files[0];
if (!file) return;
const taskId = Date.now();
const task = { id: taskId, name: file.name, progress: 0, xhr: null };
state.uploadTasks.push(task);
renderUploadTasks();
const form = new FormData();
form.append("module", module);
form.append("owner_id", ownerId);
form.append("owner_version", version);
form.append("file_category", category);
form.append("file", file);
const xhr = new XMLHttpRequest();
task.xhr = xhr;
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
task.progress = Math.round((e.loaded / e.total) * 100);
renderUploadTasks();
}
});
xhr.addEventListener("load", () => {
if (xhr.status === 200) {
task.progress = 100;
renderUploadTasks();
const result = JSON.parse(xhr.responseText);
const resourceMap = { proposal: "proposals", operation: "operations", sales: "sales", product: "products" };
const listKey = resourceMap[module];
if (listKey && state.data[listKey]) {
const item = state.data[listKey].find(x => x.id === ownerId);
if (item) {
if (!item.files) item.files = [];
item.files.push({ id: result.id, file_name: file.name, file_category: category });
// 刷新当前抽屉
if (item.proposal_type === "标准资料") {
openStandardProposalDrawer(item.id);
} else if (document.querySelector("#drawer.open")) {
openDrawer(listKey, item.id);
}
}
}
setTimeout(() => {
state.uploadTasks = state.uploadTasks.filter(t => t.id !== taskId);
renderUploadTasks();
}, 1500);
}
});
xhr.addEventListener("error", () => {
toast("上传失败:" + file.name, "error");
state.uploadTasks = state.uploadTasks.filter(t => t.id !== taskId);
renderUploadTasks();
});
xhr.open("POST", "/api/files/upload");
xhr.send(form);
};
window.cancelUpload = (taskId) => {
const task = state.uploadTasks.find(t => t.id === taskId);
if (task && task.xhr) task.xhr.abort();
state.uploadTasks = state.uploadTasks.filter(t => t.id !== taskId);
renderUploadTasks();
};
window.renderUploadTasks = () => {
const el = document.querySelector("#uploadTaskList");
if (!el) return;
el.innerHTML = state.uploadTasks.map(t => `
<div class="upload-task">
<span class="upload-task-name">${esc(t.name)}</span>
<div class="upload-task-bar"><div class="upload-task-fill" style="width:${t.progress}%"></div></div>
<span class="upload-task-pct">${t.progress}%</span>
<button class="upload-task-cancel" onclick="cancelUpload(${t.id})"><i data-lucide="x"></i></button>
</div>
`).join("");
if (window.lucide) window.lucide.createIcons();
};