产品迭代模块:卡片改表格 + 日期内联编辑 + 后端日期校验

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

View File

@@ -39,13 +39,25 @@ window.openProductDrawer = () => {
<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">
<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>
@@ -63,7 +75,7 @@ window.cycleProductStatus = async (id) => {
const products = state.data.products || [];
const product = products.find(x => x.id === id);
if (!product) return;
const statuses = ["规划中", "开发中", "测试中", "已上线", "已取消"];
const statuses = ["未开始", "规划中", "开发中", "测试中", "已上线", "已取消"];
const current = statuses.indexOf(product.status) >= 0 ? product.status : "规划中";
const newStatus = statuses[(statuses.indexOf(current) + 1) % statuses.length];
try {
@@ -103,32 +115,26 @@ window.editProductDate = (event, id) => {
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.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());
const featureInputs = form.querySelectorAll("#newFeatureList input");
data.feature_list = [...featureInputs].map(el => el.value.trim()).filter(Boolean).join("\n");
data.platform = "";
// 约束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 }) });
@@ -141,64 +147,165 @@ window.submitProductDrawer = async (event) => {
}
};
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;
};
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="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 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"); }
};