Files
opc-manager/static/modules/products.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

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>
`;
}