feat: 项目层级树+合作方绑定KOC资源+客户群全量可选+精简编辑表单,移除复盘报表

- 项目支持父子层级:树形折叠展示、详情面板内联管理总群成员、推送聚合子项目任务、环检测与子项目删除守卫
- 外部KOC合作方绑定 KOC 资源账号(唯一占用、换绑/解绑、资源页一键设为合作方),列表直显联系方式
- 绑定客户群改为全量外部群缓存(含各群主),同步按钮强刷;member_count 缺失以 member_list 兜底
- 任务/项目编辑模式隐藏不可变字段(平台/内容类型/品牌/总群成员),详情面板维护成员时保留 parent_id
- 删除复盘报表整栏(导航/页面/task_report_list/导出路由/CSV lib 及测试)
This commit is contained in:
ABAPPLO
2026-09-23 11:13:17 +08:00
parent 8f1ebf7697
commit ab78b187c0
20 changed files with 1354 additions and 958 deletions

View File

@@ -105,10 +105,11 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
brand: params[2],
main_group_member_ids: params[3],
owner_userid: params[4],
parent_id: params[5],
main_chat_id: "",
status: "active",
note: params[5],
created_at: params[6],
note: params[6],
created_at: params[7],
});
return 1;
}
@@ -118,7 +119,7 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
return row ? 1 : 0;
}
if (/^UPDATE projects/.test(normalized)) {
const row = projects.find((item) => item.id === params[5]);
const row = projects.find((item) => item.id === params[6]);
if (!row) return 0;
Object.assign(row, {
name: params[0],
@@ -126,6 +127,7 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
main_group_member_ids: params[2],
owner_userid: params[3],
note: params[4],
parent_id: params[5],
});
return 1;
}
@@ -170,6 +172,11 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
},
async first() {
executed.push({ sql, params });
if (/COUNT\(\*\) AS count FROM projects WHERE parent_id = \?/.test(sql)) {
return {
count: projects.filter((item) => item.parent_id === params[0]).length,
};
}
if (/FROM projects WHERE id = \?/.test(sql)) {
return projects.find((item) => item.id === params[0]) ?? null;
}
@@ -189,11 +196,23 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
},
async all() {
executed.push({ sql, params });
if (/^SELECT id, parent_id FROM projects/.test(sql.trim())) {
return {
results: projects.map((item) => ({
id: item.id,
parent_id: item.parent_id ?? "",
})),
};
}
if (/FROM projects p/.test(sql)) {
const ownerFiltered = /WHERE p\.owner_userid = \?/.test(sql);
const rows = ownerFiltered
? projects.filter((item) => item.owner_userid === params[0])
: projects;
const parentFiltered = /WHERE p\.parent_id = \?/.test(sql);
let rows = projects;
if (ownerFiltered) {
rows = rows.filter((item) => item.owner_userid === params[0]);
} else if (parentFiltered) {
rows = rows.filter((item) => item.parent_id === params[0]);
}
return {
results: rows.map((item) => ({
...item,
@@ -208,6 +227,18 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
.map((task) => ({ ...task })),
};
}
if (/FROM tasks t\s+LEFT JOIN projects pj/.test(sql)) {
const wanted = new Set(params);
return {
results: tasks
.filter((task) => wanted.has(task.project_id))
.map((task) => ({
...task,
project_name:
projects.find((item) => item.id === task.project_id)?.name ?? "",
})),
};
}
if (/FROM partners/.test(sql)) {
return { results: partners.map((item) => ({ ...item })) };
}
@@ -236,6 +267,7 @@ const projectRow = (overrides = {}) => ({
brand: "",
main_group_member_ids: "[]",
owner_userid: "",
parent_id: "",
main_chat_id: "",
status: "active",
note: "",
@@ -695,3 +727,101 @@ test("findOrCreateGroupOwnerPartner creates partner with userid fallback name",
assert.equal(created.wecom_userid, "owner-b");
assert.equal(db.partners.length, 1);
});
test("saveProject persists parentProjectId on create and update", async () => {
const db = fakeProjectDb({
projects: [projectRow({ id: "proj-root", name: "总项目" })],
});
await saveProject(db, {
name: "子项目",
brand: "b",
mainGroupMemberIds: [],
ownerUserid: "",
parentProjectId: "proj-root",
});
assert.equal(db.projects.length, 2);
const child = db.projects.find((item) => item.name === "子项目");
assert.equal(child.parent_id, "proj-root");
await saveProject(db, {
id: "proj-root",
name: "总项目",
mainGroupMemberIds: [],
parentProjectId: "",
});
assert.equal(db.projects.find((item) => item.id === "proj-root").parent_id, "");
});
test("saveProject rejects invalid parentProjectId (missing / self / descendant)", async () => {
const db = fakeProjectDb({
projects: [
projectRow({ id: "proj-1", name: "一级" }),
projectRow({ id: "proj-2", name: "二级", parent_id: "proj-1" }),
projectRow({ id: "proj-3", name: "三级", parent_id: "proj-2" }),
],
});
await assert.rejects(
() => saveProject(db, { id: "proj-1", name: "一级", parentProjectId: "proj-404" }),
(error) => error instanceof ProjectError && error.status === 404,
);
await assert.rejects(
() => saveProject(db, { id: "proj-1", name: "一级", parentProjectId: "proj-1" }),
(error) => error instanceof ProjectError && /自身/.test(error.message),
);
await assert.rejects(
() => saveProject(db, { id: "proj-1", name: "一级", parentProjectId: "proj-3" }),
(error) =>
error instanceof ProjectError && /上级项目不能是当前项目的子项目/.test(error.message),
);
// 兄弟项目/父项目合法
await saveProject(db, { id: "proj-3", name: "三级", parentProjectId: "proj-1" });
assert.equal(db.projects.find((item) => item.id === "proj-3").parent_id, "proj-1");
});
test("deleteProject refuses while child projects exist", async () => {
const db = fakeProjectDb({
projects: [
projectRow({ id: "proj-1", name: "一级" }),
projectRow({ id: "proj-2", name: "二级", parent_id: "proj-1" }),
],
});
await assert.rejects(
() => deleteProject(db, "proj-1"),
(error) =>
error instanceof ProjectError && /还有子项目/.test(error.message),
);
assert.equal(db.projects.length, 2);
await deleteProject(db, "proj-2");
await deleteProject(db, "proj-1");
assert.equal(db.projects.length, 0);
});
test("getProjectDetail returns subprojects and aggregated push tasks", async () => {
const db = fakeProjectDb({
projects: [
projectRow({ id: "proj-1", name: "一级" }),
projectRow({ id: "proj-2", name: "二级", parent_id: "proj-1", main_chat_id: "pgc2" }),
projectRow({ id: "proj-3", name: "三级", parent_id: "proj-2" }),
projectRow({ id: "proj-other", name: "无关项目" }),
],
tasks: [
{ id: "t1", name: "直属任务", project_id: "proj-1" },
{ id: "t2", name: "子项目任务", project_id: "proj-2" },
{ id: "t3", name: "孙项目任务", project_id: "proj-3" },
{ id: "t4", name: "无关任务", project_id: "proj-other" },
],
});
const detail = await getProjectDetail(db, "proj-1");
assert.equal(detail.project.parentProjectId, "");
assert.deepEqual(detail.tasks.map((task) => task.id), ["t1"]);
assert.deepEqual(
detail.subprojects.map((sub) => sub.id),
["proj-2"],
);
assert.equal(detail.subprojects[0].mainChatId, "pgc2");
assert.equal(detail.subprojects[0].taskCount, 1);
assert.deepEqual(
detail.pushTasks.map((task) => `${task.project_name}:${task.name}`).sort(),
["一级:直属任务", "三级:孙项目任务", "二级:子项目任务"],
);
});