Files
opc-manager/static/modules/products.js
mac 0eb9d69f1e 产品迭代模块:卡片改表格 + 日期内联编辑 + 后端日期校验
- 卡片列表改为表格列表(10列),参考用户运营中心产品台账
- 数据库新增 priority + 5 个日期字段(start/plan/dev_done/test/launch)
- 删除 owner/platform/feature_list 字段(migrate_drop_product_fields)
- 日期内联编辑:5个日期列直接渲染 date input
- 后端日期校验:4个时间不能早于启动时间;启动时间必填
- 详情页新增耗时统计区块(总/产品/研发/测试耗时)
- 优先级和状态合并同一行
- 新增'未开始'状态
- 表格垂直居中对齐
- renderProducts 后重新初始化 lucide 图标
2026-07-02 14:31:06 +08:00

312 lines
16 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.
// 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>
<div class="grid grid-cols-2 gap-3">
<label class="task-field"><span>版本号</span><input name="version" required class="form-ctrl"></label>
<label class="task-field"><span>优先级</span><select name="priority" class="form-ctrl"><option>P0</option><option selected>P1</option><option>P2</option><option>P3</option></select></label>
</div>
<label class="task-field"><span>版本目标</span><textarea name="version_goal" rows="3" class="form-ctrl"></textarea></label>
<div class="grid grid-cols-2 gap-3">
<label class="task-field"><span>启动时间 <span class="text-red-500">*</span></span><input name="start_date" type="date" required class="form-ctrl"></label>
<label class="task-field"><span>产品方案</span><input name="plan_date" type="date" class="form-ctrl"></label>
</div>
<div class="grid grid-cols-2 gap-3">
<label class="task-field"><span>研发完成</span><input name="dev_done_date" type="date" class="form-ctrl"></label>
<label class="task-field"><span>测试</span><input name="test_date" type="date" class="form-ctrl"></label>
</div>
<div class="grid grid-cols-2 gap-3">
<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><option>已取消</option></select></label>
</div>
<label class="task-field"><span>进展备注</span><textarea name="notes" rows="3" class="form-ctrl"></textarea></label>
<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 = () => {};
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 = () => {};
function renderProducts() {
const items = state.data.products || [];
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" };
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="card overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-slate-50 border-b border-slate-200">
<tr class="text-left text-xs text-slate-500 uppercase tracking-wider">
<th class="px-3 py-2.5 font-semibold align-middle whitespace-nowrap">版本号</th>
<th class="px-3 py-2.5 font-semibold align-middle whitespace-nowrap">优先级</th>
<th class="px-3 py-2.5 font-semibold align-middle">版本名称</th>
<th class="px-3 py-2.5 font-semibold align-middle whitespace-nowrap">状态</th>
<th class="px-3 py-2.5 font-semibold align-middle whitespace-nowrap">启动时间</th>
<th class="px-3 py-2.5 font-semibold align-middle whitespace-nowrap">产品方案</th>
<th class="px-3 py-2.5 font-semibold align-middle whitespace-nowrap">研发完成</th>
<th class="px-3 py-2.5 font-semibold align-middle whitespace-nowrap">测试完成</th>
<th class="px-3 py-2.5 font-semibold align-middle whitespace-nowrap">上线时间</th>
<th class="px-3 py-2.5 font-semibold align-middle whitespace-nowrap">总耗时</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
${items.length === 0 ? `<tr><td colspan="10" class="px-3 py-8 text-center text-slate-400">暂无产品版本,点击右上角新增</td></tr>` : 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 `
<tr class="hover:bg-slate-50 cursor-pointer transition-colors" onclick="openDrawer('products', ${p.id})">
<td class="px-3 py-2.5 whitespace-nowrap text-slate-600 align-middle">${esc(p.version) || '—'}</td>
<td class="px-3 py-2.5 whitespace-nowrap align-middle"><span class="inline-block px-2 py-0.5 rounded text-xs font-medium ${priorityColor[p.priority] || priorityColor.P2}" onclick="event.stopPropagation();cyclePriority(${p.id})">${esc(p.priority) || 'P2'}</span></td>
<td class="px-3 py-2.5 font-medium text-slate-800 max-w-[180px] truncate align-middle" title="${esc(p.product_name)}">${esc(p.product_name) || '—'}</td>
<td class="px-3 py-2.5 whitespace-nowrap align-middle"><span class="status-badge status-${esc(p.status)}" onclick="event.stopPropagation();cycleProductStatus(${p.id})">${esc(p.status) || '规划中'}</span></td>
<td class="px-3 py-2.5 whitespace-nowrap text-slate-500 text-xs align-middle" onclick="event.stopPropagation()"><input type="date" value="${esc(p.start_date) || ''}" data-id="${p.id}" data-field="start_date" class="prod-date-input" onchange="saveProductDate(this)"></td>
<td class="px-3 py-2.5 whitespace-nowrap text-slate-500 text-xs align-middle" onclick="event.stopPropagation()"><input type="date" value="${esc(p.plan_date) || ''}" data-id="${p.id}" data-field="plan_date" class="prod-date-input" onchange="saveProductDate(this)"></td>
<td class="px-3 py-2.5 whitespace-nowrap text-slate-500 text-xs align-middle" onclick="event.stopPropagation()"><input type="date" value="${esc(p.dev_done_date) || ''}" data-id="${p.id}" data-field="dev_done_date" class="prod-date-input" onchange="saveProductDate(this)"></td>
<td class="px-3 py-2.5 whitespace-nowrap text-slate-500 text-xs align-middle" onclick="event.stopPropagation()"><input type="date" value="${esc(p.test_date) || ''}" data-id="${p.id}" data-field="test_date" class="prod-date-input" onchange="saveProductDate(this)"></td>
<td class="px-3 py-2.5 whitespace-nowrap text-slate-600 text-xs align-middle" onclick="event.stopPropagation()"><input type="date" value="${esc(p.launch_date) || ''}" data-id="${p.id}" data-field="launch_date" class="prod-date-input" onchange="saveProductDate(this)"></td>
<td class="px-3 py-2.5 whitespace-nowrap text-slate-500 text-xs align-middle">${days(p.start_date, p.launch_date)}</td>
</tr>`;
}).join("")}
</tbody>
</table>
</div>
</div>
<aside id="productDrawer" class="task-drawer"></aside>
`;
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"); }
};