272 lines
17 KiB
JavaScript
272 lines
17 KiB
JavaScript
// drawer.js — 详情抽屉 + 评论 + 删除
|
||
|
||
function drawerField(icon, label, name, value, multiline = false, customControl = null) {
|
||
const safeValue = esc(value || "");
|
||
const control = customControl
|
||
? customControl
|
||
: multiline
|
||
? `<textarea name="${name}" rows="2" class="form-ctrl" data-original="${safeValue}">${safeValue}</textarea>`
|
||
: `<input name="${name}" value="${safeValue}" class="form-ctrl" data-original="${safeValue}">`;
|
||
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","项目名称"],["owner","负责人"],["expected_sign_date","截止时间"],["expected_contract_amount","金额"],["notes","项目说明"]]
|
||
: resource === "proposals"
|
||
? [["customer_or_project_name","客户/项目"],["proposal_type","方案类型"],["notes","备注"]]
|
||
: [["product_name","版本名称"],["version","版本号"],["priority","优先级"],["version_goal","版本目标"],["start_date","启动时间"],["plan_date","产品方案"],["dev_done_date","研发完成"],["test_date","测试完成"],["launch_date","上线时间"],["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",
|
||
owner: "user", 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", proposal_type: "tag", customer_or_project_name: "building",
|
||
priority: "flag", owner: "user", start_date: "play", plan_date: "file-text", dev_done_date: "check-square",
|
||
test_date: "bug", devs: "users", testers: "shield-check"
|
||
};
|
||
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 = esc(item.target_customer || item.project_name || (item.customer_or_project_name ? `${item.customer_or_project_name} · ${item.proposal_type || ''}` : "") || item.product_name);
|
||
const titleForAttr = esc(item.target_customer || item.project_name || (item.customer_or_project_name ? `${item.customer_or_project_name} · ${item.proposal_type || ''}` : "") || 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><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hover:bg-red-50" onclick="deleteDrawerItem('${resource}', ${id})"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm" onclick="closeDrawer()">关闭</button></div></div><div class="grid gap-5 p-5">
|
||
${resource === "products" ? (() => {
|
||
const dDays = (s, e) => { if (!s || !e) return '-'; const d = Math.round((new Date(e) - new Date(s)) / 86400000); return d >= 0 ? d + ' 天' : '-'; };
|
||
return `<section>
|
||
<h3 class="drawer-section-title">耗时统计</h3>
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<div class="bg-slate-50 rounded-lg p-3"><p class="text-xs text-slate-500">总耗时(上线−启动)</p><p class="text-lg font-semibold text-slate-800 mt-1">${dDays(item.start_date, item.launch_date)}</p></div>
|
||
<div class="bg-slate-50 rounded-lg p-3"><p class="text-xs text-slate-500">产品耗时(方案−启动)</p><p class="text-lg font-semibold text-slate-800 mt-1">${dDays(item.start_date, item.plan_date)}</p></div>
|
||
<div class="bg-slate-50 rounded-lg p-3"><p class="text-xs text-slate-500">研发耗时(研发完成−方案)</p><p class="text-lg font-semibold text-slate-800 mt-1">${dDays(item.plan_date, item.dev_done_date)}</p></div>
|
||
<div class="bg-slate-50 rounded-lg p-3"><p class="text-xs text-slate-500">测试耗时(测试完成−研发完成)</p><p class="text-lg font-semibold text-slate-800 mt-1">${dDays(item.dev_done_date, item.test_date)}</p></div>
|
||
</div>
|
||
</section>`;
|
||
})() : ""}
|
||
<section>
|
||
<h3 class="drawer-section-title">属性</h3>
|
||
<form id="drawerForm" class="drawer-fields">
|
||
${resource === "operations" ? drawerField("map-pin", "当前阶段", "current_stage", "", false, `<select name="current_stage" class="form-ctrl" onchange="saveDrawerField(this,'${resource}',${id})">${["商务洽谈","系统上线","团队分工","项目交付","上线推广","结项验收"].map((s) => `<option ${s === item.current_stage ? "selected" : ""}>${s}</option>`).join("")}</select>`) : ""}
|
||
${fields.map(([key,label]) => {
|
||
if (resource === "products" && key === "priority") {
|
||
return `<div class="drawer-field"><div class="drawer-field-label"><i data-lucide="flag"></i><span>优先级 / 状态</span></div><div class="drawer-field-control grid grid-cols-2 gap-3"><select name="priority" class="form-ctrl" onchange="saveDrawerField(this,'${resource}',${id})">${["P0","P1","P2","P3"].map((s) => `<option ${s === (item.priority||'P2') ? "selected" : ""}>${s}</option>`).join("")}</select><select name="status" class="form-ctrl" onchange="saveDrawerField(this,'${resource}',${id})">${["未开始","规划中","开发中","测试中","已上线","已取消"].map((s) => `<option ${s === (item.status||'规划中') ? "selected" : ""}>${s}</option>`).join("")}</select></div></div>`;
|
||
}
|
||
if (resource === "products" && (key === "start_date" || key === "plan_date" || key === "dev_done_date" || key === "test_date" || key === "launch_date")) {
|
||
return drawerField("calendar", label, key, item[key], false, `<input type="date" name="${key}" value="${item[key]||''}" class="form-ctrl" data-original="${item[key]||''}" onchange="saveDrawerField(this,'${resource}',${id})">`);
|
||
}
|
||
return drawerField(fieldIcons[key] || "circle", label, key, item[key], multilineFields.includes(key));
|
||
}).join("")}
|
||
</form>
|
||
</section>
|
||
${resource === "proposals" ? `<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}, '${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 id="uploadTaskList"></div>
|
||
</div></div>`;
|
||
drawer.classList.add("open");
|
||
bindDrawerAutosave(resource, item.id, item);
|
||
if (window.lucide) window.lucide.createIcons();
|
||
renderUploadTasks();
|
||
drawer.querySelectorAll(".rich-content").forEach((el) => {
|
||
const html = el.dataset.html;
|
||
if (html) el.innerHTML = decodeURIComponent(html);
|
||
});
|
||
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;
|
||
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 .form-ctrl").forEach((field) => {
|
||
field.addEventListener("keydown", (event) => {
|
||
if (event.key === "Enter" && field.tagName !== "TEXTAREA") field.blur();
|
||
});
|
||
const doSave = 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;
|
||
renderActive();
|
||
setDrawerSaveStatus("已保存", "success");
|
||
setTimeout(() => setDrawerSaveStatus(""), 1200);
|
||
} catch (error) {
|
||
field.dataset.original = previous;
|
||
setDrawerSaveStatus("保存失败", "danger");
|
||
toast(`自动保存失败:${error.message}`, "error");
|
||
}
|
||
};
|
||
field.addEventListener("blur", doSave);
|
||
if (field.tagName === "SELECT") field.addEventListener("change", doSave);
|
||
});
|
||
}
|
||
|
||
window.openDrawer = openDrawer;
|
||
window.closeDrawer = () => document.querySelector("#drawer").classList.remove("open");
|
||
|
||
window.deleteDrawerItem = async (resource, id) => {
|
||
if (!confirm("确认删除?此操作不可撤销。")) return;
|
||
try {
|
||
const listKey = { sales: "sales", proposals: "proposals", operations: "operations", products: "products" }[resource];
|
||
let name = "";
|
||
if (listKey && state.data[listKey]) {
|
||
const item = state.data[listKey].find(x => x.id === id);
|
||
name = item ? (item.target_customer || item.project_name || item.customer_or_project_name || item.product_name || "") : "";
|
||
}
|
||
await api(`/api/${resource}/${id}`, { method: "DELETE" });
|
||
if (name) {
|
||
const resType = { sales: "sales", proposals: "proposal", operations: "operation", products: "product" }[resource] || resource;
|
||
logActivity(resType, id, "删除了「" + name + "」");
|
||
}
|
||
closeDrawer();
|
||
await load();
|
||
} catch (error) {
|
||
toast("删除失败:" + error.message, "error");
|
||
}
|
||
};
|
||
|
||
// Squire 富文本编辑器
|
||
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.deleteActivity = async (id) => {
|
||
if (!confirm("确认删除这条动态?")) return;
|
||
await api(`/api/followups/${id}`, { method: "DELETE" });
|
||
await load();
|
||
};
|
||
|
||
window.deleteFollowup = async (event, followupId, resource, targetId) => {
|
||
event.stopPropagation();
|
||
if (!confirm("确认删除这条评论?")) return;
|
||
await api(`/api/followups/${followupId}`, { method: "DELETE" });
|
||
await load();
|
||
openDrawer(resource, targetId);
|
||
};
|
||
|
||
window.saveDrawerField = async (el, resource, id) => {
|
||
const name = el.name;
|
||
const value = el.value;
|
||
// 产品日期约束
|
||
if (resource === "products") {
|
||
const listKey = "products";
|
||
const product = (state.data[listKey] || []).find(x => x.id === id);
|
||
if (product) {
|
||
// 启动时间必填
|
||
if (name === "start_date" && !value) {
|
||
toast("启动时间为必填项", "error");
|
||
el.value = product.start_date || '';
|
||
el.focus();
|
||
return;
|
||
}
|
||
// 其他 4 个时间不能早于启动时间
|
||
if (["plan_date","dev_done_date","test_date","launch_date"].includes(name) && value && product.start_date && value < product.start_date) {
|
||
toast("该时间不能早于启动时间(" + product.start_date + ")", "error");
|
||
el.value = product[name] || '';
|
||
el.focus();
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
try {
|
||
await api(`/api/${resource}/${id}`, { method: "PUT", body: JSON.stringify({ data: { [name]: value } }) });
|
||
const listKey = { sales: "sales", proposals: "proposals", operations: "operations", products: "products" }[resource];
|
||
if (listKey && state.data[listKey]) {
|
||
const item = state.data[listKey].find(x => x.id === id);
|
||
if (item) item[name] = value;
|
||
}
|
||
} catch (error) {
|
||
toast("保存失败:" + error.message, "error");
|
||
}
|
||
};
|