Files
opc-manager/static/modules/products.js
mac fbff2e5f24 产品迭代:表格排序 + 详情页优先级状态对齐
- 表头点击排序(正序/倒序切换),优先级按 P0-P3 逻辑序
- 排序图标与表头文字同行显示
- 详情页优先级/状态合并行改用标准 drawer-field 结构对齐
2026-07-02 14:50:45 +08:00

345 lines
17 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 = () => {};
// 排序状态
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 '<i data-lucide="chevrons-up-down" style="width:12px;height:12px;opacity:0.3"></i>';
return productSort.dir > 0 ? '<i data-lucide="chevron-up" style="width:12px;height:12px"></i>' : '<i data-lucide="chevron-down" style="width:12px;height:12px"></i>';
};
const sortTh = (f, label, extra='') => `<th class="px-3 py-2.5 font-semibold align-middle whitespace-nowrap cursor-pointer hover:text-blue-600 select-none" onclick="sortProducts('${f}')"><span class="inline-flex items-center gap-1">${label}${sortIcon(f)}</span>${extra}</th>`;
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">
${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','上线时间')}
<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"); }
};