345 lines
17 KiB
JavaScript
345 lines
17 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>
|
||
<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"); }
|
||
};
|