## 安全与性能 - .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
336 lines
18 KiB
JavaScript
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();
|
|
};
|