// products.js — 产品迭代模块 function formHtml(fields, button) { return `
${fields.map((f) => ``).join("")}
`; } 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 = `
新增产品版本
`; 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 = `${newValue || '—'}`; } catch (e) { toast("修改失败:" + e.message, "error"); } }); input.addEventListener("blur", () => { td.innerHTML = `${currentValue || '—'}`; }); td.innerHTML = ""; td.appendChild(input); input.focus(); }; window.addNewFeature = () => {}; window.removeNewFeature = () => {}; window.addFeature = () => {}; window.removeFeature = () => {}; window.saveFeatureList = () => {}; window.submitProductDrawer = async (event) => { event.preventDefault(); const form = event.currentTarget; const data = Object.fromEntries(new FormData(form).entries()); // 约束:4 个时间不能早于启动时间 const startDate = data.start_date; if (startDate) { for (const f of ['plan_date', 'dev_done_date', 'test_date', 'launch_date']) { if (data[f] && data[f] < startDate) { toast("「" + ({plan_date:'产品方案',dev_done_date:'研发完成',test_date:'测试完成',launch_date:'上线时间'}[f]) + "」不能早于启动时间", "error"); return; } } } 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 = () => {}; window.removeFeature = () => {}; window.saveFeatureList = () => {}; // 排序状态 let productSort = { field: null, dir: 1 }; window.sortProducts = (field) => { if (productSort.field === field) { productSort.dir = -productSort.dir; } else { productSort.field = field; productSort.dir = 1; } renderProducts(); }; function sortItems(items) { if (!productSort.field) return items; const f = productSort.field; const d = productSort.dir; const priorityOrder = { P0: 0, P1: 1, P2: 2, P3: 3 }; return [...items].sort((a, b) => { let va = a[f] || '', vb = b[f] || ''; if (f === 'priority') { va = priorityOrder[va] ?? 9; vb = priorityOrder[vb] ?? 9; } if (va < vb) return -1 * d; if (va > vb) return 1 * d; return 0; }); } function renderProducts() { const rawItems = state.data.products || []; const items = sortItems(rawItems); const priorityColor = { P0: "bg-red-100 text-red-700", P1: "bg-orange-100 text-orange-700", P2: "bg-blue-100 text-blue-700", P3: "bg-slate-100 text-slate-600" }; const sortIcon = (f) => { if (productSort.field !== f) return ''; return productSort.dir > 0 ? '' : ''; }; const sortTh = (f, label, extra='') => `${label}${sortIcon(f)}${extra}`; document.querySelector("#products").innerHTML = `
${sortTh('version','版本号')} ${sortTh('priority','优先级')} ${sortTh('product_name','版本名称')} ${sortTh('status','状态')} ${sortTh('start_date','启动时间')} ${sortTh('plan_date','产品方案')} ${sortTh('dev_done_date','研发完成')} ${sortTh('test_date','测试完成')} ${sortTh('launch_date','上线时间')} ${items.length === 0 ? `` : items.map((p) => { const days = (s, e) => { if (!s || !e) return '-'; const d = Math.round((new Date(e) - new Date(s)) / 86400000); return d >= 0 ? d + '天' : '-'; }; return ` `; }).join("")}
总耗时
暂无产品版本,点击右上角新增
${esc(p.version) || '—'} ${esc(p.priority) || 'P2'} ${esc(p.product_name) || '—'} ${esc(p.status) || '规划中'} ${days(p.start_date, p.launch_date)}
`; if (window.lucide) window.lucide.createIcons(); } window.saveProductDate = async (input) => { const id = Number(input.dataset.id); const field = input.dataset.field; const value = input.value; // 直接从同一行 DOM 读取启动时间(不依赖 state 缓存) const row = input.closest('tr'); const startDateInput = row && row.querySelector('[data-field="start_date"]'); const startDate = startDateInput ? startDateInput.value : ''; const product = (state.data.products || []).find(x => x.id === id); if (!product) return; // 启动时间必填 if (field === 'start_date' && !value) { toast("启动时间为必填项", "error"); input.value = product.start_date || ''; input.focus(); return; } // 约束:除启动时间外的 4 个时间不能早于启动时间 if (field !== 'start_date' && value && startDate && value < startDate) { toast("该时间不能早于启动时间(" + startDate + ")", "error"); input.value = product[field] || ''; input.focus(); return; } // 启动时间变更后,如果其他时间早于新启动时间,清空 if (field === 'start_date' && value) { const fields = ['plan_date', 'dev_done_date', 'test_date', 'launch_date']; const toClear = {}; for (const f of fields) { if (product[f] && product[f] < value) { toClear[f] = ''; } } if (Object.keys(toClear).length > 0) { try { await api(`/api/products/${id}`, { method: "PUT", body: JSON.stringify({ data: { ...toClear, start_date: value } }) }); Object.assign(product, toClear, { start_date: value }); toast("启动时间已更新," + Object.keys(toClear).length + " 个早于启动时间的日期已清空", "info"); renderProducts(); return; } catch (e) { toast("修改失败:" + e.message, "error"); return; } } } try { await api(`/api/products/${id}`, { method: "PUT", body: JSON.stringify({ data: { [field]: value } }) }); product[field] = value; if (field === 'start_date' || field === 'launch_date') { renderProducts(); } } catch (e) { toast("修改失败:" + e.message, "error"); } }; window.editProductDateInline = (event, id, field) => { event.stopPropagation(); const products = state.data.products || []; const product = products.find(x => x.id === id); if (!product) return; const td = event.currentTarget; const currentValue = product[field] || ""; const input = document.createElement("input"); input.type = "date"; input.className = "form-ctrl form-ctrl-sm w-full text-xs"; input.value = currentValue; let saved = false; const save = async () => { if (saved) return; saved = true; const newValue = input.value; try { await api(`/api/products/${id}`, { method: "PUT", body: JSON.stringify({ data: { [field]: newValue } }) }); product[field] = newValue; td.innerHTML = newValue || '—'; } catch (e) { toast("修改失败:" + e.message, "error"); td.innerHTML = currentValue || '—'; } }; input.addEventListener("change", save); input.addEventListener("blur", () => { if (!saved) td.innerHTML = currentValue || '—'; }); td.innerHTML = ""; td.appendChild(input); input.focus(); if (input.showPicker) { try { input.showPicker(); } catch (e) {} } }; window.cyclePriority = async (id) => { const products = state.data.products || []; const product = products.find(x => x.id === id); if (!product) return; const levels = ["P0", "P1", "P2", "P3"]; const cur = levels.indexOf(product.priority) >= 0 ? product.priority : "P2"; const next = levels[(levels.indexOf(cur) + 1) % levels.length]; try { await api(`/api/products/${id}`, { method: "PUT", body: JSON.stringify({ data: { priority: next } }) }); product.priority = next; renderProducts(); } catch (e) { toast("更新失败:" + e.message, "error"); } };