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

@@ -10,7 +10,6 @@ import {
ensureSchema,
getDashboardData,
getRawDb,
getTaskReportData,
uid,
} from "../../../lib/mvp-db";
import {
@@ -61,7 +60,6 @@ import {
} from "../../../lib/wecom-group-push-service";
import {
bindTaskCustomerChat,
listOwnerCustomerChats,
unbindTaskCustomerChat,
} from "../../../lib/task-customer-chat-service";
import { isManagerRequest, managerForbidden } from "../../../lib/user-auth";
@@ -694,6 +692,89 @@ export async function POST(request: Request) {
wecomExternalUserId: externalId || null,
wecomName,
});
} else if (body.action === "partner_bind_account") {
const forbidden = managerGate();
if (forbidden) return forbidden;
const partnerId = String(body.partnerId ?? "").trim();
const accountId = String(body.accountId ?? "").trim();
if (!partnerId) {
return Response.json({ error: "缺少 partnerId" }, { status: 400 });
}
const partner = await db
.prepare("SELECT id, name, partner_type, account_id FROM partners WHERE id = ?")
.bind(partnerId)
.first<{ id: string; name: string; partner_type: string | null; account_id: string | null }>();
if (!partner) return Response.json({ error: "合作方不存在" }, { status: 404 });
if ((partner.partner_type ?? "external_koc") !== "external_koc") {
return Response.json({ error: "群主合作方不绑定 KOC 资源" }, { status: 400 });
}
if (!accountId) {
await db
.prepare("UPDATE partners SET account_id = '' WHERE id = ?")
.bind(partnerId)
.run();
return Response.json({ partnerId, accountId: "" });
}
const account = await db
.prepare("SELECT id, nickname FROM accounts WHERE id = ?")
.bind(accountId)
.first<{ id: string; nickname: string }>();
if (!account) return Response.json({ error: "KOC 资源不存在" }, { status: 404 });
const holder = await db
.prepare(
"SELECT id, name FROM partners WHERE account_id = ? AND id != ? LIMIT 1",
)
.bind(accountId, partnerId)
.first<{ id: string; name: string }>();
if (holder) {
return Response.json(
{ error: `该 KOC 资源已绑定合作方「${holder.name}」,请先在对方条目上解绑` },
{ status: 400 },
);
}
await db
.prepare("UPDATE partners SET account_id = ? WHERE id = ?")
.bind(accountId, partnerId)
.run();
return Response.json({ partnerId, accountId, nickname: account.nickname });
} else if (body.action === "partner_create_from_account") {
const forbidden = managerGate();
if (forbidden) return forbidden;
const accountId = String(body.accountId ?? "").trim();
if (!accountId) {
return Response.json({ error: "缺少 accountId" }, { status: 400 });
}
const account = await db
.prepare("SELECT id, nickname, current_contact FROM accounts WHERE id = ?")
.bind(accountId)
.first<{ id: string; nickname: string; current_contact: string }>();
if (!account) return Response.json({ error: "KOC 资源不存在" }, { status: 404 });
const holder = await db
.prepare("SELECT id, name FROM partners WHERE account_id = ? LIMIT 1")
.bind(accountId)
.first<{ id: string; name: string }>();
if (holder) {
return Response.json(
{ error: `该 KOC 已是合作方「${holder.name}」,无需重复创建` },
{ status: 400 },
);
}
const partnerId = `partner-koc-${crypto.randomUUID().slice(0, 12)}`;
await db
.prepare(
`INSERT INTO partners
(id, name, wecom_name, owner, claimed_total, completed_total,
partner_type, wecom_userid, account_id, created_at)
VALUES (?, ?, '', '运营组', 0, 0, 'external_koc', '', ?, ?)`,
)
.bind(partnerId, account.nickname, accountId, new Date().toISOString())
.run();
return Response.json({
partnerId,
accountId,
nickname: account.nickname,
contact: account.current_contact ?? "",
});
} else if (body.action === "send_test_wecom") {
const forbidden = managerGate();
if (forbidden) return forbidden;
@@ -858,6 +939,7 @@ export async function POST(request: Request) {
mainGroupMemberIds: body.mainGroupMemberIds,
ownerUserid: nextOwnerUserid,
note: body.note,
parentProjectId: body.parentProjectId,
},
resolveWecomConfig(env as unknown as WecomBindings),
);
@@ -970,10 +1052,6 @@ export async function POST(request: Request) {
appFailedCount: appFailed.length,
hint: "任务群消息已直接发送至任务内部群(负责人与领取人即时可见)",
});
} else if (body.action === "task_report_list") {
return Response.json({
reports: await getTaskReportData(db, ownerScopeUserid(scope)),
});
} else if (body.action === "refresh_account_fields") {
const forbidden = managerGate();
if (forbidden) return forbidden;
@@ -1042,28 +1120,24 @@ export async function POST(request: Request) {
const forbidden = await taskOwnerGate(taskId, { strict: true });
if (forbidden) return forbidden;
const task = await db
.prepare(
"SELECT id, owner_userid, customer_chat_id FROM tasks WHERE id = ?",
)
.prepare("SELECT id, customer_chat_id FROM tasks WHERE id = ?")
.bind(taskId)
.first<{ id: string; owner_userid: string; customer_chat_id: string }>();
.first<{ id: string; customer_chat_id: string }>();
if (!task) return Response.json({ error: "任务不存在" }, { status: 404 });
const ownerUserid = (
scope.kind === "full"
? String(task.owner_userid ?? "")
: scope.wecomUserId
).trim();
if (!ownerUserid) {
return Response.json(
{ error: "请先设置任务负责人,再绑定客户群" },
{ status: 400 },
);
}
const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings);
const chats = await listOwnerCustomerChats(wecomConfig, ownerUserid);
// 列表来自全量群缓存(所有群主的外部客户群),缓存为空或要求刷新时全量同步
let rows = body.sync === true ? [] : await listGroupChatRows(db);
if (rows.length === 0) {
rows = await syncGroupChats(db, wecomConfig);
}
return Response.json({
ownerUserid,
chats,
chats: rows.map((row) => ({
chatId: row.chat_id,
name: row.name,
ownerUserId: row.owner_user_id,
memberCount: row.member_count,
status: row.status,
})),
boundChatId: String(task.customer_chat_id ?? ""),
});
} else if (body.action === "task_bind_customer_chat") {
@@ -1071,24 +1145,11 @@ export async function POST(request: Request) {
const forbidden = await taskOwnerGate(taskId, { strict: true });
if (forbidden) return forbidden;
const task = await db
.prepare(
"SELECT id, owner_userid, customer_chat_id FROM tasks WHERE id = ?",
)
.prepare("SELECT id, customer_chat_id FROM tasks WHERE id = ?")
.bind(taskId)
.first<{ id: string; owner_userid: string; customer_chat_id: string }>();
.first<{ id: string; customer_chat_id: string }>();
if (!task) return Response.json({ error: "任务不存在" }, { status: 404 });
const ownerUserid = (
scope.kind === "full"
? String(task.owner_userid ?? "")
: scope.wecomUserId
).trim();
const chatId = String(body.chatId ?? "").trim();
if (chatId && !ownerUserid) {
return Response.json(
{ error: "请先设置任务负责人,再绑定客户群" },
{ status: 400 },
);
}
const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings);
if (chatId) {
const chatName = String(body.chatName ?? "").trim();