Files
opc-manager/static/app.js

517 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const state = {
active: "home",
data: null,
opFilter: "all",
chart: null,
chart2: null,
productPlatform: "all",
};
const money = (value) => `${Number(value || 0).toLocaleString("zh-CN")}`;
const text = (value) => value === undefined || value === null || value === "" ? "—" : value;
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;
}
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");
render();
}
function switchTab(tab) {
state.active = 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();
// Decode and render rich HTML content in followup records
drawer.querySelectorAll(".rich-content").forEach((el) => {
const html = el.dataset.html;
if (html) el.innerHTML = decodeURIComponent(html);
});
// Initialize Squire editor
const squireDiv = drawer.querySelector(".squire-editor");
if (squireDiv && window.Squire) {
const id = squireDiv.id;
if (window.squireInstances[id]) window.squireInstances[id].destroy();
const sq = new Squire(squireDiv, { blockTag: "P" });
sq.addEventListener("input", () => {
const form = squireDiv.closest("form");
const btn = form.querySelector(".comment-submit");
});
window.squireInstances[id] = sq;
// Handle placeholder
squireDiv.addEventListener("focus", () => squireDiv.classList.add("focused"));
squireDiv.addEventListener("blur", () => {
if (!squireDiv.textContent.trim()) squireDiv.classList.remove("focused");
});
}
}
function renderHome() {
const { summary, financeMonthly } = state.data;
const m = summary.metrics;
document.querySelector("#home").innerHTML = `
<div class="grid gap-5">
<div class="grid grid-cols-4 gap-3">
${[
["P0 客户数", m.p0_customers, "projects"],
["跟进中销售机会", m.active_sales, "projects"],
["已签约执行项目", m.execution_projects, "projects"],
["有风险项目", m.risk_projects, "projects"],
["本月收入", money(m.monthly_revenue), "finance"],
["本月净利", money(m.monthly_net_profit), "finance"],
["即将上线版本", m.upcoming_products, "products"],
["已签约未执行", money(m.signed_not_executed), "finance"],
].map(([label, value, tab]) => `<button class="metric-card" onclick="switchTab('${tab}')"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="gauge"></i>${label}</span><strong class="mt-2 block text-2xl">${value}</strong></button>`).join("")}
</div>
<div class="grid grid-cols-4 gap-3">
${[
["已签约合同总额", money(m.signed_amount), "projects"],
["合同流程中", money(m.pipeline_amount), "projects"],
["年度累计确收", money(m.revenue_annual), "finance"],
["Q2 累计确收", money(m.revenue_q2), "finance"],
["年度累计毛利", money(m.gross_annual), "finance"],
["Q2 累计毛利", money(m.gross_q2), "finance"],
].map(([label, value, tab]) => `<button class="metric-card" onclick="switchTab('${tab}')"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="trending-up"></i>${label}</span><strong class="mt-2 block text-2xl">${value}</strong></button>`).join("")}
</div>
<div class="grid grid-cols-2 gap-5">
${card(`<div class="mb-3 flex items-center justify-between"><h2 class="text-sm font-bold text-slate-600">财务趋势</h2>${badge("YYYY-MM")}</div><div style="position:relative;height:140px"><canvas id="financeChart"></canvas></div>`, "p-4")}
${card(`<h2 class="text-lg font-bold">风险提醒</h2><div class="mt-3 grid gap-2">${(summary.risks.length ? summary.risks : [{ title: "暂无高风险", content: "当前无明确阻塞,按周更新即可。" }]).map((r) => `<div class="rounded-md border border-amber-200 bg-amber-50 p-3"><p class="font-bold text-amber-900">${r.title}</p><p class="mt-1 text-sm text-amber-800 break-words">${r.content}</p></div>`).join("")}</div>`, "p-5")}
</div>
${card(`<h2 class="text-lg font-bold">近期动态</h2><div class="mt-4 grid gap-2">${summary.recent.map((r) => `<div class="flex justify-between rounded-md bg-slate-50 px-3 py-2 text-sm"><span class="break-words">${r.content}</span><span class="text-slate-500">${r.followed_at}</span></div>`).join("")}</div>`, "p-5")}
</div>
`;
renderChart(financeMonthly);
}
function renderChart(data) {
const canvas = document.querySelector("#financeChart");
if (!canvas || !window.Chart) return;
if (state.chart) state.chart.destroy();
state.chart = new Chart(canvas, {
type: "line",
data: {
labels: data.map((x) => x.month),
datasets: [
{ label: "收入", data: data.map((x) => x.revenue), borderColor: "#2563eb", tension: 0.3 },
{ label: "毛利", data: data.map((x) => x.gross_profit), borderColor: "#059669", tension: 0.3 },
{ label: "成本/费用", data: data.map((x) => x.cost_expense), borderColor: "#d97706", tension: 0.3 },
{ label: "净利", data: data.map((x) => x.net_profit), borderColor: "#7c3aed", tension: 0.3 },
],
},
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: "bottom", labels: { boxWidth: 12, font: { size: 11 } } } }, scales: { x: { ticks: { font: { size: 11 } }, grid: { display: false } }, y: { ticks: { font: { size: 11 } } } } },
});
}
function formHtml(fields, button) {
return `<form class="inline-form flex flex-wrap items-end gap-3" onsubmit="${button.handler}(event)">
${fields.map((f) => `<label class="grid gap-1 text-sm"><span class="font-bold text-slate-600">${f.label}</span>${f.input}</label>`).join("")}
<button class="btn btn-primary" type="submit">${button.text}</button>
</form>`;
}
async function createResource(event, resource) {
event.preventDefault();
const form = event.currentTarget;
const data = Object.fromEntries(new FormData(form).entries());
try {
await api(`/api/${resource}`, { method: "POST", body: JSON.stringify({ data }) });
form.reset();
await load();
} catch (error) {
alert("创建失败:" + error.message);
}
}
window.createSales = (event) => createResource(event, "sales");
window.createProposal = (event) => createResource(event, "proposals");
window.createOperation = (event) => createResource(event, "operations");
window.createProduct = (event) => createResource(event, "products");
window.createFinance = (event) => createResource(event, "finance");
window.switchTab = switchTab;
function renderProjects() {
// Merge sales_leads and operation_projects into one table
const salesItems = state.data.sales.map((x) => ({
name: x.target_customer,
version: "",
type: "opportunity",
status: x.status,
amount: 0,
stage: "",
files: 0,
followup: x.latest_follow_up_record,
resource: "sales",
id: x.id,
}));
const opItems = state.data.operations.map((x) => ({
name: x.project_name,
version: x.project_version,
type: x.project_type,
status: x.project_status,
amount: x.expected_contract_amount || 0,
stage: x.current_stage || x.sop_stage,
files: x.files.length,
followup: x.latest_follow_up_record,
resource: "operations",
id: x.id,
}));
const allItems = [...salesItems, ...opItems];
const items = state.opFilter === "all" ? allItems : allItems.filter((x) => x.type === state.opFilter || (state.opFilter === "opportunity" && x.type === "opportunity"));
const rows = items.map((x) => [`<strong>${x.name}</strong>${x.version ? `<p class="text-xs text-slate-500">${x.version}</p>` : ""}`, badge(x.type), badge(x.status), x.amount ? money(x.amount) : "—", text(x.stage), text(x.followup)]);
const clicks = items.map((x) => ({ resource: x.resource, id: x.id }));
document.querySelector("#projects").innerHTML = `<div class="grid gap-4">
${card(formHtml([
{ label: "业务机会", input: `<input name="target_customer" required placeholder="客户名称">` },
{ label: "优先级", input: `<select name="priority"><option>P0</option><option selected>P1</option><option>P2</option><option>P3</option></select>` },
{ label: "状态", input: `<select name="status"><option>待跟进</option><option>跟进中</option><option>方案中</option><option>商务谈判</option><option>已签约</option><option>暂缓</option><option>已丢单</option></select>` },
], { handler: "createSales", text: `<i data-lucide="plus"></i>新增业务机会` }), "p-4")}
${card(formHtml([
{ label: "项目名称", input: `<input name="project_name" required>` },
{ label: "项目版本", input: `<input name="project_version" value="v1.0">` },
{ label: "项目类型", input: `<select name="project_type"><option value="opportunity">业务机会项目</option><option value="execution">已签约执行项目</option></select>` },
{ label: "状态", input: `<input name="project_status" value="线索发现">` },
], { handler: "createOperation", text: "新增项目" }), "p-4")}
<div class="flex gap-2">${[["all","全部"],["opportunity","业务机会"],["execution","已签约执行"]].map(([k,v]) => `<button class="btn ${state.opFilter === k ? "btn-primary" : "btn-ghost"}" onclick="state.opFilter='${k}'; renderProjects()">${v}</button>`).join("")}</div>
${renderTable(["项目/客户", "类型", "状态", "金额", "当前阶段", "最新跟进"], rows, clicks)}
</div>`;
}
function renderProposals() {
const proposalRows = state.data.proposals.map((p) => [p.customer_or_project_name, p.version, badge(p.status), p.files.length + " 个"]);
const proposalClicks = state.data.proposals.map((p) => ({ resource: "proposals", id: p.id }));
document.querySelector("#proposals").innerHTML = `<div class="grid gap-4">
${card(formHtml([
{ label: "客户/项目", input: `<input name="customer_or_project_name" required placeholder="如:信达生物">` },
{ label: "版本号", input: `<input name="version" required placeholder="v1.0">` },
{ label: "状态", input: `<select name="status"><option>草稿</option><option></option><option selected></option><option></option><option></option><option></option></select>` },
], { handler: "createProposal", text: "新增版本" }), "p-4")}
${renderTable(["客户/项目", "版本号", "状态", "文件数"], proposalRows, proposalClicks)}
</div>`;
}
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">${file.file_name}</p><div class="mt-0.5 flex gap-3"><a class="file-link inline-flex items-center gap-1" target="_blank" href="/api/files/${file.id}/content?inline=true"><i data-lucide="eye"></i>预览</a><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" });
await load();
closeDrawer();
};
window.uploadFile = async (event, module, ownerId, version, category) => {
const file = event.target.files[0];
if (!file) return;
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);
await api("/api/files/upload", { method: "POST", body: form });
await load();
};
function renderProducts() {
const items = state.productPlatform === "all" ? state.data.products : state.data.products.filter((x) => (x.platform || "") === state.productPlatform);
document.querySelector("#products").innerHTML = `<div class="grid gap-4">
${card(formHtml([
{ label: "产品名称", input: `<input name="product_name" required>` },
{ label: "版本号", input: `<input name="version" required>` },
{ label: "平台", input: `<select name="platform"><option value=""></option><option></option><option></option><option></option></select>` },
{ label: "上线日期", input: `<input name="launch_date" placeholder="2026-Q3">` },
{ label: "状态", input: `<select name="status"><option>规划中</option><option>设计中</option><option>开发中</option><option>测试中</option><option>已上线</option><option>已延期</option><option>已取消</option></select>` },
], { handler: "createProduct", text: "新增版本" }), "p-4")}
<div class="flex gap-2">${[["all","全部"],["真研平台","真研"],["科普平台","科普"],["关爱平台","关爱"]].map(([k,v]) => `<button class="btn ${state.productPlatform === k ? "btn-primary" : "btn-ghost"}" onclick="state.productPlatform='${k}'; renderProducts()">${v}</button>`).join("")}</div>
${renderTable(["产品名称", "版本号", "版本目标", "核心功能", "平台", "上线日期", "状态"], items.map((p) => [p.product_name, p.version, text(p.version_goal), text(p.feature_list), text(p.platform), text(p.launch_date), badge(p.status)]), items.map((p) => ({ resource: "products", id: p.id })))}
</div>`;
}
function renderFinance() {
const rows = state.data.finance.map((x) => [x.month, badge(x.record_type === "revenue" ? "收入" : "成本/费用"), x.category, money(x.amount), x.occurred_date, text(x.notes)]);
document.querySelector("#finance").innerHTML = `<div class="grid gap-4">
${card(`<h2 class="mb-4 text-lg font-bold">收入、毛利、成本/费用、净利月度曲线</h2><div style="position:relative;height:300px"><canvas id="financeChart2"></canvas></div>`, "p-5")}
${card(formHtml([
{ label: "月份", input: `<input name="month" required placeholder="YYYY-MM" pattern="\\d{4}-\\d{2}">` },
{ label: "类型", input: `<select name="record_type"><option value="revenue">收入</option><option value="cost_expense">/</option></select>` },
{ label: "分类", input: `<input name="category" required>` },
{ label: "金额/万", input: `<input name="amount" type="number" step="0.01" required>` },
{ label: "发生日期", input: `<input name="occurred_date" type="date">` },
], { handler: "createFinance", text: "新增明细" }), "p-4")}
${renderTable(["月份", "类型", "分类", "金额", "发生日期", "备注"], rows)}
</div>`;
renderChartOn("financeChart2", state.data.financeMonthly);
}
function renderChartOn(id, data) {
const canvas = document.querySelector(`#${id}`);
if (!canvas || !window.Chart) return;
if (state.chart2) state.chart2.destroy();
state.chart2 = new Chart(canvas, {
type: "line",
data: {
labels: data.map((x) => x.month),
datasets: [
{ label: "收入", data: data.map((x) => x.revenue), borderColor: "#2563eb", tension: 0.3 },
{ label: "毛利", data: data.map((x) => x.gross_profit), borderColor: "#059669", tension: 0.3 },
{ label: "成本/费用", data: data.map((x) => x.cost_expense), borderColor: "#d97706", tension: 0.3 },
{ label: "净利", data: data.map((x) => x.net_profit), borderColor: "#7c3aed", tension: 0.3 },
],
},
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: "bottom", labels: { boxWidth: 12, font: { size: 11 } } } }, scales: { x: { ticks: { font: { size: 11 } }, grid: { display: false } }, y: { ticks: { font: { size: 11 } } } } },
});
}
function drawerField(icon, label, name, value, multiline = false) {
const initialValue = text(value);
const control = multiline
? `<textarea name="${name}" rows="2" class="drawer-value drawer-textarea" data-original="${initialValue}">${initialValue}</textarea>`
: `<input name="${name}" value="${initialValue}" class="drawer-value" data-original="${initialValue}">`;
return `<div class="drawer-field">
<div class="drawer-field-label"><i data-lucide="${icon}"></i><span>${label}</span></div>
<div class="drawer-field-control">${control}</div>
</div>`;
}
function openDrawer(resource, id) {
const list = resource === "sales" ? state.data.sales : resource === "operations" ? state.data.operations : resource === "proposals" ? state.data.proposals : state.data.products;
const item = list.find((x) => x.id === id);
const drawer = document.querySelector("#drawer");
const fields = resource === "sales"
? [["target_customer","业务机会"],["priority","优先级"],["status","状态"]]
: resource === "operations"
? [["project_name","项目名称"],["project_version","项目版本"],["project_status","项目状态"],["current_stage","当前阶段"],["target_customer","业务机会"],["customer_need","客户需求"],["expected_contract_amount","预计签约金额"],["expected_sign_date","预计签约时间"],["sign_probability","签约概率"],["sop_stage","SOP 阶段"],["execution_progress","执行进度"],["current_deliverable","当前交付物"],["risks","风险与阻塞"],["next_action","下一步动作"]]
: resource === "proposals"
? [["customer_or_project_name","客户/项目"],["version","版本号"],["description","版本说明"],["created_date","创建日期"],["status","状态"]]
: [["product_name","产品名称"],["version","版本号"],["version_goal","版本目标"],["feature_list","核心功能清单"],["platform","平台"],["launch_date","上线日期"],["status","当前状态"],["notes","备注"]];
const fieldIcons = {
target_customer: "user", priority: "flag", status: "circle-dot",
project_name: "briefcase-business", project_version: "git-branch", project_status: "circle-dot", current_stage: "map-pin",
customer_need: "file-text", expected_contract_amount: "banknote", expected_sign_date: "calendar",
sign_probability: "percent", sop_stage: "list-checks", execution_progress: "activity",
current_deliverable: "package", risks: "alert-triangle", next_action: "arrow-right",
product_name: "box", version: "tag", version_goal: "target", feature_list: "list", platform: "layers",
launch_date: "calendar", notes: "sticky-note"
};
const multilineFields = ["customer_need", "current_deliverable", "risks", "next_action", "version_goal", "feature_list", "notes"];
const followupTarget = resource === "sales" ? "sales" : resource === "proposals" ? "proposal" : resource === "operations" ? "operation" : resource === "products" ? "product" : "";
const title = item.target_customer || item.project_name || item.customer_or_project_name || item.product_name;
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">Detail Drawer</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>
<form id="drawerForm" class="drawer-fields">${fields.map(([key,label]) => drawerField(fieldIcons[key] || "circle", label, key, item[key], multilineFields.includes(key))).join("")}</form>
</section>
${resource === "proposals" ? `<section><h3 class="drawer-section-title">方案文件</h3><div class="grid gap-2">${["方案","成本","SOP","财务流程"].map((cat) => fileGroup("proposal", item.id, item.version, cat, item.files.filter((f) => f.file_category === cat))).join("")}</div></section>` : ""}
${resource === "operations" ? (() => {
const tasks = (state.data.tasks || []).filter((t) => t.project_id === id);
if (!tasks.length) return "";
const phases = [...new Set(tasks.map((t) => t.phase))];
return `<section><h3 class="drawer-section-title">项目任务</h3><div class="grid gap-3">${phases.map((phase) => {
const pt = tasks.filter((t) => t.phase === phase);
return `<div class="rounded-md border border-slate-200 p-3"><p class="text-[13px] font-semibold text-slate-700 mb-2"><i data-lucide="layers" style="width:14px;height:14px;display:inline;vertical-align:-2px;margin-right:4px"></i>${phase}</p><div class="grid gap-1.5">${pt.map((t) => {
const due = t.due_date ? `<span class="text-[11px] text-slate-400 ml-1">📅 ${t.due_date}</span>` : "";
const owner = t.owner ? `<span class="text-[11px] text-slate-500 ml-1">👤 ${t.owner}</span>` : "";
const blocker = t.blockers ? `<p class="text-[11px] text-red-600 mt-0.5">⚠ ${t.blockers}</p>` : "";
return `<div class="rounded bg-slate-50 px-2.5 py-1.5"><p class="text-[12px] text-slate-800"><strong>${t.milestone ? t.milestone + "": ""}</strong>${t.task}${due}${owner}</p>${blocker}</div>`;
}).join("")}</div></div>`;
}).join("")}</div></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>${f.follower} · ${f.follow_up_method}</span><span>${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}, '${resource}', ${item.id})" title="删除评论"><i data-lucide="trash-2"></i></button></div>`).join("")}</div>
<form class="comment-box mt-3" onsubmit="submitComment(event,'${followupTarget}',${item.id},'${resource}')">
<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_${resource}_${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></div>`;
drawer.classList.add("open");
bindDrawerAutosave(resource, item.id, item);
if (window.lucide) window.lucide.createIcons();
// Decode and render rich HTML content in followup records
drawer.querySelectorAll(".rich-content").forEach((el) => {
const html = el.dataset.html;
if (html) el.innerHTML = decodeURIComponent(html);
});
// Initialize Squire editor
const squireDiv = drawer.querySelector(".squire-editor");
if (squireDiv && window.Squire) {
const id = squireDiv.id;
if (window.squireInstances[id]) window.squireInstances[id].destroy();
const sq = new Squire(squireDiv, { blockTag: "P" });
sq.addEventListener("input", () => {
const form = squireDiv.closest("form");
const btn = form.querySelector(".comment-submit");
});
window.squireInstances[id] = sq;
// Handle placeholder
squireDiv.addEventListener("focus", () => squireDiv.classList.add("focused"));
squireDiv.addEventListener("blur", () => {
if (!squireDiv.textContent.trim()) squireDiv.classList.remove("focused");
});
}
}
function setDrawerSaveStatus(message, tone = "muted") {
const el = document.querySelector("#drawerSaveStatus");
if (!el) return;
el.textContent = message;
el.dataset.tone = tone;
}
function bindDrawerAutosave(resource, id, item) {
document.querySelectorAll("#drawerForm .drawer-value").forEach((field) => {
field.addEventListener("keydown", (event) => {
if (event.key === "Enter" && field.tagName !== "TEXTAREA") field.blur();
});
field.addEventListener("blur", async () => {
const value = field.value;
if (value === field.dataset.original) return;
const previous = field.dataset.original;
field.dataset.original = value;
setDrawerSaveStatus("保存中…");
try {
await api(`/api/${resource}/${id}`, { method: "PUT", body: JSON.stringify({ data: { [field.name]: value } }) });
item[field.name] = value;
const titleValue = item.target_customer || item.project_name || item.customer_or_project_name || item.product_name;
const titleEl = document.querySelector(".drawer-title");
if (titleEl) titleEl.textContent = titleValue;
render();
setDrawerSaveStatus("已保存", "success");
setTimeout(() => setDrawerSaveStatus(""), 1200);
} catch (error) {
field.dataset.original = previous;
setDrawerSaveStatus("保存失败", "danger");
alert(`自动保存失败:${error.message}`);
}
});
});
}
window.openDrawer = openDrawer;
window.closeDrawer = () => document.querySelector("#drawer").classList.remove("open");
window.squireInstances = {};
window.squireCmd = (cmd) => {
const currentEditor = document.querySelector(".squire-editor");
if (!currentEditor) return;
const id = currentEditor.id;
const sq = window.squireInstances[id];
if (!sq) return;
sq.focus();
setTimeout(() => {
if (cmd === "bold") {
sq.hasFormat("b") || sq.hasFormat("strong") ? sq.removeBold() : sq.bold();
} else if (cmd === "italic") {
sq.hasFormat("i") || sq.hasFormat("em") ? sq.removeItalic() : sq.italic();
} else if (cmd === "underline") {
sq.hasFormat("u") ? sq.changeFormat(null, { tag: "u" }, null) : sq.changeFormat({ tag: "u" }, null, null);
} else if (cmd === "strikethrough") {
sq.hasFormat("s") || sq.hasFormat("del") || sq.hasFormat("strike") ? sq.changeFormat(null, { tag: "s" }, null) : sq.changeFormat({ tag: "s" }, null, null);
} else {
sq[cmd]();
}
}, 10);
};
window.submitComment = async (event, targetType, targetId, resource) => {
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/${targetType}/${targetId}`, { method: "POST", body: JSON.stringify({ data: { content } }) });
await load();
openDrawer(resource, targetId);
};
window.deleteFollowup = async (event, followupId, resource, targetId) => {
event.stopPropagation();
if (!confirm("确认删除这条评论?")) return;
await api(`/api/followups/${followupId}`, { method: "DELETE" });
await load();
openDrawer(resource, targetId);
};
document.querySelector("#tabs").addEventListener("click", (event) => {
const button = event.target.closest("button[data-tab]");
if (button) switchTab(button.dataset.tab);
});
document.querySelector("#refreshBtn").addEventListener("click", load);
load().catch((error) => {
document.querySelector("main").innerHTML = `<section class="card p-6 text-red-700">加载失败:${error.message}</section>`;
});