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

- 卡片列表改为表格列表(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

@@ -856,7 +856,7 @@ TABLES = {
"sales": ("sales_leads", ["target_customer", "priority", "status", "tenant"]),
"proposals": ("business_proposals", ["customer_or_project_name", "version", "description", "status", "created_date", "proposal_type", "notes", "tenant"]),
"operations": ("operation_projects", ["project_name", "project_version", "project_type", "project_status", "current_stage", "owner", "target_customer", "customer_need", "expected_contract_amount", "expected_sign_date", "sign_probability", "next_action", "sop_stage", "execution_progress", "current_deliverable", "risks", "notes", "tenant"]),
"products": ("product_versions", ["product_name", "version", "version_goal", "feature_list", "launch_date", "status", "platform", "notes", "tenant"]),
"products": ("product_versions", ["product_name", "version", "version_goal", "priority", "start_date", "plan_date", "dev_done_date", "test_date", "launch_date", "status", "notes", "tenant"]),
"finance": ("finance_records", ["month", "project_name", "record_type", "category", "amount", "occurred_date", "notes", "tenant"]),
"tasks": ("project_tasks", ["project_id", "phase", "milestone", "task", "owner", "due_date", "blockers", "notes", "status", "sort_order", "priority", "tenant"]),
"projectFinances": ("project_finances", ["project_id", "tenant", "business_type", "customer_name", "sign_amount", "sign_month", "status", "sales_person", "owner", "total_rev", "total_gross", "budget_data"]),
@@ -913,6 +913,22 @@ def update_resource(resource, item_id):
valid_statuses = ["未开始", "进行中", "已结束"]
if not payload["status"] or payload["status"] not in valid_statuses:
payload["status"] = "未开始"
# 产品日期约束4 个时间不能早于启动时间;启动时间不能清空
if resource == "products":
# 查当前记录的 start_date
cur = _exec(conn, f"SELECT start_date FROM {table} WHERE id=?", (item_id,))
row = cur.fetchone()
cur.close()
current_start = (row or {}).get("start_date", "") or ""
new_start = payload.get("start_date", current_start)
# 启动时间必填
if "start_date" in payload and not new_start:
return jsonify({"error": "启动时间为必填项"}), 400
date_fields = ["plan_date", "dev_done_date", "test_date", "launch_date"]
for f in date_fields:
if f in payload and payload[f] and new_start and payload[f] < new_start:
labels = {"plan_date": "产品方案", "dev_done_date": "研发完成", "test_date": "测试完成", "launch_date": "上线时间"}
return jsonify({"error": f"{labels[f]}不能早于启动时间({new_start}"}), 400
update_cols = [col for col in cols if col in payload]
if update_cols:
_exec(conn,

View File

@@ -17,12 +17,13 @@ def run_migrations():
"""
from migrations.tables import migrate_create_tables
from migrations.columns import migrate_add_columns
from migrations.data_fixes import migrate_fix_task_status, migrate_rename_tenant
from migrations.data_fixes import migrate_fix_task_status, migrate_rename_tenant, migrate_drop_product_fields
from migrations.seed import migrate_seed_users, migrate_seed_demo_data
migrate_create_tables()
migrate_add_columns()
migrate_fix_task_status()
migrate_rename_tenant()
migrate_drop_product_fields()
migrate_seed_users()
migrate_seed_demo_data()

View File

@@ -37,8 +37,20 @@ def migrate_add_columns():
"ALTER TABLE business_proposals ADD COLUMN notes VARCHAR(2000) NOT NULL DEFAULT ''")
# product_versions 扩展字段
_add_column_if_missing(conn, "product_versions", "platform",
"ALTER TABLE product_versions ADD COLUMN platform VARCHAR(100) NOT NULL DEFAULT ''")
_add_column_if_missing(conn, "product_versions", "priority",
"ALTER TABLE product_versions ADD COLUMN priority VARCHAR(10) NOT NULL DEFAULT 'P2'")
_add_column_if_missing(conn, "product_versions", "start_date",
"ALTER TABLE product_versions ADD COLUMN start_date VARCHAR(30) NOT NULL DEFAULT ''")
_add_column_if_missing(conn, "product_versions", "plan_date",
"ALTER TABLE product_versions ADD COLUMN plan_date VARCHAR(30) NOT NULL DEFAULT ''")
_add_column_if_missing(conn, "product_versions", "dev_done_date",
"ALTER TABLE product_versions ADD COLUMN dev_done_date VARCHAR(30) NOT NULL DEFAULT ''")
_add_column_if_missing(conn, "product_versions", "test_date",
"ALTER TABLE product_versions ADD COLUMN test_date VARCHAR(30) NOT NULL DEFAULT ''")
_add_column_if_missing(conn, "product_versions", "devs",
"ALTER TABLE product_versions ADD COLUMN devs VARCHAR(500) NOT NULL DEFAULT ''")
_add_column_if_missing(conn, "product_versions", "testers",
"ALTER TABLE product_versions ADD COLUMN testers VARCHAR(500) NOT NULL DEFAULT ''")
# project_tasks 扩展字段
_add_column_if_missing(conn, "project_tasks", "status",

View File

@@ -47,3 +47,27 @@ def migrate_rename_tenant():
conn.commit()
finally:
conn.close()
def migrate_drop_product_fields():
"""删除 product_versions 表的 owner / platform / feature_list 字段"""
from flask_app import db, mysql
conn = db()
try:
for col in ["owner", "platform", "feature_list"]:
cur = conn.cursor(dictionary=True)
cur.execute("SHOW COLUMNS FROM product_versions LIKE %s", (col,))
exists = cur.fetchone()
cur.close()
if exists:
try:
cur = conn.cursor()
cur.execute(f"ALTER TABLE product_versions DROP COLUMN {col}")
cur.close()
conn.commit()
print(f"[migrate] product_versions.{col} 列已删除")
except mysql.connector.Error as e:
print(f"[migrate] 删除 {col} 失败: {e}")
finally:
conn.close()

View File

@@ -23,7 +23,7 @@ function openDrawer(resource, id) {
? [["project_name","项目名称"],["owner","负责人"],["expected_sign_date","截止时间"],["expected_contract_amount","金额"],["notes","项目说明"]]
: resource === "proposals"
? [["customer_or_project_name","客户/项目"],["proposal_type","方案类型"],["notes","备注"]]
: [["product_name","产品名称"],["version","版本号"],["version_goal","版本目标"],["feature_list","核心功能"],["launch_date","上线日期"],["status","状态"],["notes","备注"]];
: [["product_name","版本名称"],["version","版本号"],["priority","优先级"],["version_goal","版本目标"],["start_date","启动时间"],["plan_date","产品方案"],["dev_done_date","研发完成"],["test_date","测试完成"],["launch_date","上线时间"],["notes","进展备注"]];
const fieldIcons = {
target_customer: "user", priority: "flag", status: "circle-dot",
project_name: "briefcase-business", project_version: "git-branch", project_status: "circle-dot", current_stage: "map-pin",
@@ -31,29 +31,38 @@ function openDrawer(resource, id) {
sign_probability: "percent", sop_stage: "list-checks", execution_progress: "activity",
current_deliverable: "package", risks: "alert-triangle", next_action: "arrow-right",
product_name: "box", version: "tag", version_goal: "target", feature_list: "list", platform: "layers",
launch_date: "calendar", notes: "sticky-note", proposal_type: "tag", customer_or_project_name: "building"
launch_date: "calendar", notes: "sticky-note", proposal_type: "tag", customer_or_project_name: "building",
priority: "flag", owner: "user", start_date: "play", plan_date: "file-text", dev_done_date: "check-square",
test_date: "bug", devs: "users", testers: "shield-check"
};
const multilineFields = ["customer_need", "current_deliverable", "risks", "next_action", "version_goal", "feature_list", "notes"];
const followupTarget = resource === "sales" ? "sales" : resource === "proposals" ? "proposal" : resource === "operations" ? "operation" : resource === "products" ? "product" : "";
const title = esc(item.target_customer || item.project_name || (item.customer_or_project_name ? `${item.customer_or_project_name} · ${item.proposal_type || ''}` : "") || item.product_name);
const titleForAttr = esc(item.target_customer || item.project_name || (item.customer_or_project_name ? `${item.customer_or_project_name} · ${item.proposal_type || ''}` : "") || item.product_name);
drawer.innerHTML = `<div class="drawer-panel"><div class="sticky top-0 z-10 flex items-center justify-between border-b border-slate-200 bg-white/95 px-5 py-3 backdrop-blur"><div><p class="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-400">Detail Drawer</p><div class="flex items-center gap-2"><h2 class="drawer-title text-[17px] font-semibold leading-6 text-slate-900">${title}</h2><span id="drawerSaveStatus" class="save-status"></span></div></div><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hover:bg-red-50" onclick="deleteDrawerItem('${resource}', ${id})"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm" onclick="closeDrawer()">关闭</button></div></div><div class="grid gap-5 p-5">
${resource === "products" ? (() => {
const dDays = (s, e) => { if (!s || !e) return '-'; const d = Math.round((new Date(e) - new Date(s)) / 86400000); return d >= 0 ? d + ' 天' : '-'; };
return `<section>
<h3 class="drawer-section-title">耗时统计</h3>
<div class="grid grid-cols-2 gap-3">
<div class="bg-slate-50 rounded-lg p-3"><p class="text-xs text-slate-500">总耗时上线启动</p><p class="text-lg font-semibold text-slate-800 mt-1">${dDays(item.start_date, item.launch_date)}</p></div>
<div class="bg-slate-50 rounded-lg p-3"><p class="text-xs text-slate-500">产品耗时方案启动</p><p class="text-lg font-semibold text-slate-800 mt-1">${dDays(item.start_date, item.plan_date)}</p></div>
<div class="bg-slate-50 rounded-lg p-3"><p class="text-xs text-slate-500">研发耗时研发完成方案</p><p class="text-lg font-semibold text-slate-800 mt-1">${dDays(item.plan_date, item.dev_done_date)}</p></div>
<div class="bg-slate-50 rounded-lg p-3"><p class="text-xs text-slate-500">测试耗时测试完成研发完成</p><p class="text-lg font-semibold text-slate-800 mt-1">${dDays(item.dev_done_date, item.test_date)}</p></div>
</div>
</section>`;
})() : ""}
<section>
<h3 class="drawer-section-title">属性</h3>
<form id="drawerForm" class="drawer-fields">
${resource === "operations" ? drawerField("map-pin", "当前阶段", "current_stage", "", false, `<select name="current_stage" class="form-ctrl" onchange="saveDrawerField(this,'${resource}',${id})">${["商务洽谈","系统上线","团队分工","项目交付","上线推广","结项验收"].map((s) => `<option ${s === item.current_stage ? "selected" : ""}>${s}</option>`).join("")}</select>`) : ""}
${fields.map(([key,label]) => {
if (resource === "products" && key === "feature_list") {
const features = (item[key] || "").split("\n").filter(Boolean);
if (features.length === 0) features.push("");
return `<div class="drawer-field"><div class="drawer-field-label"><i data-lucide="list"></i><span>${label}</span></div><div class="drawer-field-control" data-field="feature_list" data-id="${id}"><div class="feature-list" id="featureList_${id}">${features.map((f,i) => `<div class="feature-item"><span class="feature-num">${i+1}.</span><input class="form-ctrl" value="${f.replace(/"/g,'&quot;')}" onchange="saveFeatureList(${id})"><button class="feature-del" onclick="event.preventDefault();removeFeature(${id},${i})"><i data-lucide="x" style="width:12px;height:12px"></i></button></div>`).join("")}</div><button class="btn btn-ghost btn-sm text-blue-600 mt-1" onclick="event.preventDefault();addFeature(${id})"><i data-lucide="plus" style="width:14px;height:14px"></i>添加功能</button></div></div>`;
if (resource === "products" && key === "priority") {
return `<div class="drawer-field"><div class="grid grid-cols-2 gap-3"><div><div class="drawer-field-label"><i data-lucide="flag"></i><span>优先级</span></div><select name="priority" class="form-ctrl" onchange="saveDrawerField(this,'${resource}',${id})">${["P0","P1","P2","P3"].map((s) => `<option ${s === (item.priority||'P2') ? "selected" : ""}>${s}</option>`).join("")}</select></div><div><div class="drawer-field-label"><i data-lucide="circle-dot"></i><span>状态</span></div><select name="status" class="form-ctrl" onchange="saveDrawerField(this,'${resource}',${id})">${["未开始","规划中","开发中","测试中","已上线","已取消"].map((s) => `<option ${s === (item.status||'规划中') ? "selected" : ""}>${s}</option>`).join("")}</select></div></div></div>`;
}
if (resource === "products" && key === "launch_date") {
if (resource === "products" && (key === "start_date" || key === "plan_date" || key === "dev_done_date" || key === "test_date" || key === "launch_date")) {
return drawerField("calendar", label, key, item[key], false, `<input type="date" name="${key}" value="${item[key]||''}" class="form-ctrl" data-original="${item[key]||''}" onchange="saveDrawerField(this,'${resource}',${id})">`);
}
if (resource === "products" && key === "status") {
return drawerField("circle-dot", label, key, "", false, `<select name="status" class="form-ctrl" onchange="saveDrawerField(this,'${resource}',${id})">${["规划中","开发中","测试中","已上线","已取消"].map((s) => `<option ${s === (item.status||'规划中') ? "selected" : ""}>${s}</option>`).join("")}</select>`);
}
return drawerField(fieldIcons[key] || "circle", label, key, item[key], multilineFields.includes(key));
}).join("")}
</form>
@@ -228,6 +237,27 @@ window.deleteFollowup = async (event, followupId, resource, targetId) => {
window.saveDrawerField = async (el, resource, id) => {
const name = el.name;
const value = el.value;
// 产品日期约束
if (resource === "products") {
const listKey = "products";
const product = (state.data[listKey] || []).find(x => x.id === id);
if (product) {
// 启动时间必填
if (name === "start_date" && !value) {
toast("启动时间为必填项", "error");
el.value = product.start_date || '';
el.focus();
return;
}
// 其他 4 个时间不能早于启动时间
if (["plan_date","dev_done_date","test_date","launch_date"].includes(name) && value && product.start_date && value < product.start_date) {
toast("该时间不能早于启动时间(" + product.start_date + "", "error");
el.value = product[name] || '';
el.focus();
return;
}
}
}
try {
await api(`/api/${resource}/${id}`, { method: "PUT", body: JSON.stringify({ data: { [name]: value } }) });
const listKey = { sales: "sales", proposals: "proposals", operations: "operations", products: "products" }[resource];

View File

@@ -109,7 +109,7 @@ function renderFinance() {
<div class="grid grid-cols-5 gap-3">
${[["本月确收",moneyInt(thisMonthRev),"trending-up"],["本月毛利",moneyInt(thisMonthGross),"percent"],["本月回款",moneyInt(monthPayment),"wallet"],["本月费用",moneyInt(monthCost),"receipt"],["本月现金流",moneyInt(monthCashflow),"repeat"]].map(([l,v,icon]) => `<div class="metric-card"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="${icon}" style="width:14px;height:14px"></i>${l}</span><strong class="mt-2 block text-2xl">${v}</strong></div>`).join("")}
</div>
<div class="flex justify-between items-center"><div class="flex items-center gap-1" id="finViewToggle"><button class="btn btn-sm ${state.finView !== 'cashflow' ? 'btn-primary' : 'btn-ghost'} p-1.5" data-view="rev" onclick="setFinView('rev')" title="确收/毛利视图"><i data-lucide="trending-up" style="width:16px;height:16px"></i></button><button class="btn btn-sm ${state.finView === 'cashflow' ? 'btn-primary' : 'btn-ghost'} p-1.5" data-view="cashflow" onclick="setFinView('cashflow')" title="回款/费用视图"><i data-lucide="wallet" style="width:16px;height:16px"></i></button></div><button class="btn btn-primary btn-sm" onclick="openFinanceModal()"><i data-lucide="plus"></i>新增财务项目</button></div>
<div class="flex justify-between items-center"><div class="flex items-center gap-1" id="finViewToggle"><button class="btn btn-sm ${state.finView !== 'cashflow' ? 'btn-primary' : 'btn-ghost'} px-2 py-1.5" data-view="rev" onclick="setFinView('rev')" title="确收/毛利视图"><i data-lucide="trending-up" style="width:16px;height:16px"></i><span class="text-xs ml-1">确收/毛利</span></button><button class="btn btn-sm ${state.finView === 'cashflow' ? 'btn-primary' : 'btn-ghost'} px-2 py-1.5" data-view="cashflow" onclick="setFinView('cashflow')" title="回款/费用视图"><i data-lucide="wallet" style="width:16px;height:16px"></i><span class="text-xs ml-1">回款/费用</span></button></div><button class="btn btn-primary btn-sm" onclick="openFinanceModal()"><i data-lucide="plus"></i>新增财务项目</button></div>
<div id="financeModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeFinanceModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-5xl mx-4 max-h-[92vh] overflow-y-auto" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-8 py-5 flex items-center justify-between"><div><h3 class="text-xl font-bold text-slate-800" id="financeModalTitle">新增项目财务</h3><p class="text-xs text-slate-400 mt-0.5">填写项目财务信息与月度预算</p></div><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="financeDeleteBtn" onclick="deleteFinanceItem()"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closeFinanceModal()"><i data-lucide="x"></i></button></div></div>
<div class="finance-tabs">
<button class="finance-tab active" data-tab="info" onclick="switchFinanceTab('info')"><i data-lucide="info" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>基本信息</button>

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"); }
};

View File

@@ -682,6 +682,27 @@ textarea { min-height: 80px; height: auto; resize: vertical; }
height: 32px;
}
/* 产品表格内联日期选择器 */
.prod-date-input {
border: 1px solid transparent;
background: transparent;
font-size: 12px;
padding: 2px 4px;
border-radius: 4px;
width: 110px;
cursor: pointer;
color: #64748b;
}
.prod-date-input:hover {
border-color: #cbd5e1;
background: #f8fafc;
}
.prod-date-input:focus {
border-color: #3b82f6;
background: white;
outline: none;
}
table {
border-collapse: collapse;
width: 100%;