## 安全与性能 - .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
205 lines
10 KiB
JavaScript
205 lines
10 KiB
JavaScript
// products.js — 产品迭代模块
|
|
|
|
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());
|
|
data.tenant = state.tenant;
|
|
try {
|
|
const result = await api(`/api/${resource}`, { method: "POST", body: JSON.stringify({ data }) });
|
|
const targetMap = { sales: "sales", proposals: "proposal", operations: "operation", products: "product" };
|
|
const resType = targetMap[resource] || resource;
|
|
const name = data.project_name || data.target_customer || data.customer_or_project_name || data.product_name || "";
|
|
if (result.id && name) logActivity(resType, result.id, "创建了" + name);
|
|
form.reset();
|
|
await load();
|
|
} catch (error) {
|
|
toast("创建失败:" + error.message, "error");
|
|
}
|
|
}
|
|
|
|
window.createSales = (event) => createResource(event, "sales");
|
|
window.createProposal = (event) => createResource(event, "proposals");
|
|
window.createOperation = async (event) => {
|
|
await createResource(event, "operations");
|
|
if (typeof closeNewProjectModal === "function") closeNewProjectModal();
|
|
};
|
|
|
|
window.openProductDrawer = () => {
|
|
const drawer = document.querySelector("#productDrawer");
|
|
drawer.innerHTML = `<div class="task-drawer-hd">
|
|
<span class="task-drawer-title">新增产品版本</span>
|
|
<button class="task-close" onclick="closeProductDrawer()"><i data-lucide="x"></i></button>
|
|
</div>
|
|
<form class="task-drawer-form" onsubmit="submitProductDrawer(event)">
|
|
<label class="task-field"><span>产品名称</span><input name="product_name" required class="form-ctrl"></label>
|
|
<label class="task-field"><span>版本号</span><input name="version" required class="form-ctrl"></label>
|
|
<label class="task-field"><span>版本目标</span><textarea name="version_goal" rows="2" class="form-ctrl"></textarea></label>
|
|
<label class="task-field"><span>核心功能</span><div class="feature-list" id="newFeatureList"><div class="feature-item"><span class="feature-num">1.</span><input class="form-ctrl" value=""><button class="feature-del" onclick="event.preventDefault();removeNewFeature(this)"><i data-lucide="x" style="width:12px;height:12px"></i></button></div></div><button class="btn btn-ghost btn-sm text-blue-600 mt-1" onclick="event.preventDefault();addNewFeature()"><i data-lucide="plus" style="width:14px;height:14px"></i>添加功能</button></label>
|
|
<label class="task-field"><span>上线日期</span><input name="launch_date" type="date" class="form-ctrl"></label>
|
|
<label class="task-field"><span>状态</span><select name="status" class="form-ctrl"><option>规划中</option><option>开发中</option><option>测试中</option><option>已上线</option><option>已取消</option></select></label>
|
|
<input type="hidden" name="feature_list" id="newFeatureListHidden">
|
|
<div class="flex justify-end gap-2 mt-4 pt-3 border-t border-slate-100">
|
|
<button type="button" class="btn btn-ghost btn-sm" onclick="closeProductDrawer()">取消</button>
|
|
<button type="submit" class="btn btn-primary btn-sm">确认新增</button>
|
|
</div>
|
|
</form>`;
|
|
drawer.classList.add("open");
|
|
if (window.lucide) window.lucide.createIcons();
|
|
};
|
|
|
|
window.closeProductDrawer = () => {
|
|
document.querySelector("#productDrawer").classList.remove("open");
|
|
};
|
|
|
|
window.cycleProductStatus = async (id) => {
|
|
const products = state.data.products || [];
|
|
const product = products.find(x => x.id === id);
|
|
if (!product) return;
|
|
const statuses = ["规划中", "开发中", "测试中", "已上线", "已取消"];
|
|
const current = statuses.indexOf(product.status) >= 0 ? product.status : "规划中";
|
|
const newStatus = statuses[(statuses.indexOf(current) + 1) % statuses.length];
|
|
try {
|
|
await api(`/api/products/${id}`, { method: "PUT", body: JSON.stringify({ data: { status: newStatus } }) });
|
|
product.status = newStatus;
|
|
renderProducts();
|
|
} catch (error) {
|
|
toast("更新失败:" + error.message, "error");
|
|
}
|
|
};
|
|
|
|
window.editProductDate = (event, id) => {
|
|
event.stopPropagation();
|
|
const products = state.data.products || [];
|
|
const product = products.find(x => x.id === id);
|
|
if (!product) return;
|
|
const span = event.currentTarget;
|
|
const td = span.parentElement;
|
|
const currentValue = product.launch_date || "";
|
|
const input = document.createElement("input");
|
|
input.type = "date";
|
|
input.className = "form-ctrl form-ctrl-sm w-full";
|
|
input.value = currentValue;
|
|
input.addEventListener("change", async () => {
|
|
const newValue = input.value;
|
|
try {
|
|
await api(`/api/products/${id}`, { method: "PUT", body: JSON.stringify({ data: { launch_date: newValue } }) });
|
|
product.launch_date = newValue;
|
|
td.innerHTML = `<span class="cursor-pointer hover:text-blue-600" onclick="editProductDate(event, ${id})">${newValue || '—'}</span>`;
|
|
} catch (e) { toast("修改失败:" + e.message, "error"); }
|
|
});
|
|
input.addEventListener("blur", () => {
|
|
td.innerHTML = `<span class="cursor-pointer hover:text-blue-600" onclick="editProductDate(event, ${id})">${currentValue || '—'}</span>`;
|
|
});
|
|
td.innerHTML = "";
|
|
td.appendChild(input);
|
|
input.focus();
|
|
};
|
|
|
|
window.addNewFeature = () => {
|
|
const list = document.querySelector("#newFeatureList");
|
|
if (!list) return;
|
|
const idx = list.children.length;
|
|
const div = document.createElement("div");
|
|
div.className = "feature-item";
|
|
div.innerHTML = `<span class="feature-num">${idx+1}.</span><input class="form-ctrl" value=""><button class="feature-del" onclick="event.preventDefault();removeNewFeature(this)"><i data-lucide="x" style="width:12px;height:12px"></i></button>`;
|
|
list.appendChild(div);
|
|
if (window.lucide) window.lucide.createIcons();
|
|
};
|
|
|
|
window.removeNewFeature = (btn) => {
|
|
const div = btn.closest(".feature-item");
|
|
if (!div) return;
|
|
div.remove();
|
|
const list = document.querySelector("#newFeatureList");
|
|
if (list) list.querySelectorAll(".feature-num").forEach((el, i) => { el.textContent = (i+1) + "."; });
|
|
};
|
|
|
|
window.submitProductDrawer = async (event) => {
|
|
event.preventDefault();
|
|
const form = event.currentTarget;
|
|
const data = Object.fromEntries(new FormData(form).entries());
|
|
const featureInputs = form.querySelectorAll("#newFeatureList input");
|
|
data.feature_list = [...featureInputs].map(el => el.value.trim()).filter(Boolean).join("\n");
|
|
data.platform = "";
|
|
data.tenant = state.tenant;
|
|
try {
|
|
const result = await api("/api/products", { method: "POST", body: JSON.stringify({ data }) });
|
|
form.reset();
|
|
closeProductDrawer();
|
|
if (result.id) logActivity("product", result.id, "创建了产品版本「" + data.product_name + " " + data.version + "」");
|
|
await load();
|
|
} catch (error) {
|
|
toast("创建失败:" + error.message, "error");
|
|
}
|
|
};
|
|
|
|
window.addFeature = (id) => {
|
|
const list = document.querySelector(`#featureList_${id}`);
|
|
if (!list) return;
|
|
const idx = list.children.length;
|
|
const div = document.createElement("div");
|
|
div.className = "feature-item";
|
|
div.innerHTML = `<span class="feature-num">${idx+1}.</span><input class="form-ctrl" value="" onchange="saveFeatureList(${id})"><button class="feature-del" onclick="event.preventDefault();removeFeature(${id},${idx})"><i data-lucide="x" style="width:12px;height:12px"></i></button>`;
|
|
list.appendChild(div);
|
|
if (window.lucide) window.lucide.createIcons();
|
|
saveFeatureList(id);
|
|
};
|
|
|
|
window.removeFeature = (id, idx) => {
|
|
const list = document.querySelector(`#featureList_${id}`);
|
|
if (!list) return;
|
|
const items = list.querySelectorAll(".feature-item");
|
|
if (items[idx]) items[idx].remove();
|
|
list.querySelectorAll(".feature-num").forEach((el, i) => { el.textContent = (i+1) + "."; });
|
|
saveFeatureList(id);
|
|
};
|
|
|
|
window.saveFeatureList = (id) => {
|
|
const list = document.querySelector(`#featureList_${id}`);
|
|
if (!list) return;
|
|
const values = [...list.querySelectorAll("input")].map(el => el.value.trim()).filter(Boolean);
|
|
const data = values.join("\n");
|
|
api(`/api/products/${id}`, { method: "PUT", body: JSON.stringify({ data: { feature_list: data } }) });
|
|
const product = (state.data.products || []).find(x => x.id === id);
|
|
if (product) product.feature_list = data;
|
|
};
|
|
|
|
function renderProducts() {
|
|
const items = state.data.products || [];
|
|
document.querySelector("#products").innerHTML = `
|
|
<div class="grid gap-4">
|
|
<div class="flex justify-end">
|
|
<button class="btn btn-primary btn-sm" onclick="openProductDrawer()"><i data-lucide="plus"></i>新增产品版本</button>
|
|
</div>
|
|
<div class="grid grid-cols-3 gap-4">
|
|
${items.map((p) => `
|
|
<div class="bg-white rounded-xl border border-slate-200 p-4 cursor-pointer hover:shadow-lg hover:border-blue-200 transition-all" onclick="openDrawer('products', ${p.id})">
|
|
<div class="flex items-start justify-between">
|
|
<h4 class="text-base font-semibold text-slate-800 leading-tight">${esc(p.product_name)}</h4>
|
|
<span class="status-badge status-${esc(p.status)}" onclick="event.stopPropagation(); cycleProductStatus(${p.id})" title="点击切换状态">${esc(p.status) || '规划中'}</span>
|
|
</div>
|
|
<div class="mt-2 flex items-center gap-2 text-xs text-slate-500">
|
|
<span class="font-medium">${esc(p.version)}</span>
|
|
<span>·</span>
|
|
<span class="cursor-pointer hover:text-blue-600 transition-colors" onclick="event.stopPropagation(); editProductDate(event, ${p.id})">${esc(p.launch_date) || '—'}</span>
|
|
</div>
|
|
<div class="mt-3 space-y-1">
|
|
<p class="text-sm text-slate-700 mt-1.5 leading-relaxed">${esc(p.version_goal) || '—'}</p>
|
|
<div class="text-sm text-slate-600 mt-1.5 leading-relaxed whitespace-pre-line">${esc(p.feature_list) || '—'}</div>
|
|
</div>
|
|
</div>
|
|
`).join("")}
|
|
</div>
|
|
</div>
|
|
<aside id="productDrawer" class="task-drawer"></aside>
|
|
`;
|
|
}
|