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

@@ -375,6 +375,11 @@ export async function ensureSchema(database?: DatabaseClient) {
"wecom_external_user_id",
"wecom_external_user_id TEXT",
);
await ensureColumn(
"partners",
"account_id",
"account_id TEXT NOT NULL DEFAULT ''",
);
await ensureColumn(
"users",
"wecom_userid",
@@ -902,7 +907,17 @@ export async function getDashboardData(ownerUserid?: string) {
const [partnersResult, tasksResult, accountsResult, distributionsResult] =
await Promise.all([
ownerFilter === null
? db.prepare("SELECT * FROM partners ORDER BY created_at DESC").all()
? db
.prepare(
`SELECT p.*, a.nickname AS account_nickname,
a.public_account_id AS account_public_id,
a.current_contact AS account_contact,
a.profile_url AS account_profile_url
FROM partners p
LEFT JOIN accounts a ON a.id = p.account_id
ORDER BY p.created_at DESC`,
)
.all()
: emptyResult(),
ownerFilter
? db
@@ -960,97 +975,6 @@ export async function getDashboardData(ownerUserid?: string) {
};
}
export type TaskReportRow = {
id: string;
name: string;
task_type: string;
brand: string;
platform: string;
status: string;
due_at: string;
created_at: string;
quantity: number;
project_name: string | null;
claimed_count: number;
published_count: number;
completed_count: number;
exposure_total: number | null;
views_total: number | null;
d2_likes: number | null;
d2_comments: number | null;
d2_collects: number | null;
d5_likes: number | null;
d5_comments: number | null;
d5_collects: number | null;
d7_likes: number | null;
d7_comments: number | null;
d7_collects: number | null;
latest_likes: number | null;
latest_comments: number | null;
latest_collects: number | null;
latest_shares: number | null;
related_task_id: string | null;
related_task_name: string | null;
reverse_related_task_id: string | null;
reverse_related_task_name: string | null;
submitted_count: number;
};
export async function getTaskReportData(db: DatabaseClient, ownerUserid?: string) {
// undefined=全量;""=未绑定企微的空范围(不能绑定 '',否则会匹配 owner_userid='' 的任务)
if (ownerUserid !== undefined && String(ownerUserid).trim() === "") {
return [] as TaskReportRow[];
}
const ownerFilter = String(ownerUserid ?? "").trim();
const base = `SELECT
t.id,
t.name,
t.task_type,
t.brand,
t.platform,
t.status,
t.due_at,
t.created_at,
t.quantity,
t.related_task_id,
rt.name AS related_task_name,
(SELECT rrt.id FROM tasks rrt WHERE rrt.related_task_id = t.id LIMIT 1) AS reverse_related_task_id,
(SELECT rrt.name FROM tasks rrt WHERE rrt.related_task_id = t.id LIMIT 1) AS reverse_related_task_name,
p.name AS project_name,
COUNT(d.id) AS claimed_count,
SUM(CASE WHEN TRIM(COALESCE(d.publish_url, '')) != '' THEN 1 ELSE 0 END) AS published_count,
SUM(CASE WHEN d.status = 'complete' THEN 1 ELSE 0 END) AS completed_count,
SUM(CASE WHEN d.result_submitted_at IS NOT NULL THEN 1 ELSE 0 END) AS submitted_count,
SUM(d.exposure) AS exposure_total,
SUM(d.views) AS views_total,
SUM(d.d2_likes) AS d2_likes,
SUM(d.d2_comments) AS d2_comments,
SUM(d.d2_collects) AS d2_collects,
SUM(d.d5_likes) AS d5_likes,
SUM(d.d5_comments) AS d5_comments,
SUM(d.d5_collects) AS d5_collects,
SUM(d.d7_likes) AS d7_likes,
SUM(d.d7_comments) AS d7_comments,
SUM(d.d7_collects) AS d7_collects,
SUM(d.latest_likes) AS latest_likes,
SUM(d.latest_comments) AS latest_comments,
SUM(d.latest_collects) AS latest_collects,
SUM(d.latest_shares) AS latest_shares
FROM tasks t
LEFT JOIN distributions d ON d.task_id = t.id
LEFT JOIN projects p ON p.id = t.project_id
LEFT JOIN tasks rt ON rt.id = t.related_task_id`;
const statement = ownerFilter
? db
.prepare(
`${base} WHERE (t.owner_userid = ? OR p.owner_userid = ?) GROUP BY t.id ORDER BY t.created_at DESC`,
)
.bind(ownerFilter, ownerFilter)
: db.prepare(`${base} GROUP BY t.id ORDER BY t.created_at DESC`);
const result = await statement.all<TaskReportRow>();
return result.results;
}
export function uid(prefix: string) {
return `${prefix}-${crypto.randomUUID().slice(0, 8)}`;
}

View File

@@ -17,6 +17,7 @@ export type ProjectRow = {
main_group_member_ids: string;
owner_userid: string;
main_chat_id: string;
parent_id: string;
status: string;
note: string;
created_at: string;
@@ -32,6 +33,7 @@ export type ProjectSummary = {
mainGroupMemberIds: string[];
ownerUserid: string;
mainChatId: string;
parentProjectId: string;
taskCount: number;
};
@@ -49,6 +51,16 @@ export type ProjectTaskRow = {
share_token: string | null;
owner_userid: string;
task_chat_id: string;
project_name?: string;
};
export type ProjectSubproject = {
id: string;
name: string;
brand: string;
ownerUserid: string;
mainChatId: string;
taskCount: number;
};
export type ProjectMainChat = {
@@ -60,6 +72,8 @@ export type ProjectDetail = {
project: ProjectSummary;
tasks: ProjectTaskRow[];
mainChat: ProjectMainChat;
subprojects: ProjectSubproject[];
pushTasks: ProjectTaskRow[];
};
export type MainChatEnsureReason =
@@ -224,6 +238,59 @@ export async function ensureProjectMainChat(
}
}
async function loadProjectParents(
db: DatabaseClient,
): Promise<Map<string, string>> {
const result = await db
.prepare("SELECT id, parent_id FROM projects")
.all<{ id: string; parent_id: string }>();
return new Map(result.results.map((row) => [row.id, text(row.parent_id)]));
}
// candidate 是否位于 ancestor 的子孙链上(沿 candidate 的祖先链向上找 ancestor)
function isDescendantProject(
parents: Map<string, string>,
ancestorId: string,
candidateId: string,
): boolean {
let current = candidateId;
const seen = new Set<string>();
while (current) {
if (current === ancestorId) return true;
if (seen.has(current)) return false;
seen.add(current);
current = parents.get(current) ?? "";
}
return false;
}
export async function collectDescendantIds(
db: DatabaseClient,
projectId: string,
): Promise<string[]> {
const parents = await loadProjectParents(db);
const childrenOf = new Map<string, string[]>();
for (const [id, parent] of parents) {
if (!parent) continue;
const list = childrenOf.get(parent) ?? [];
list.push(id);
childrenOf.set(parent, list);
}
const descendants: string[] = [];
const queue = [text(projectId)];
const visited = new Set(queue);
while (queue.length > 0) {
const current = queue.shift() as string;
for (const child of childrenOf.get(current) ?? []) {
if (visited.has(child)) continue;
visited.add(child);
descendants.push(child);
queue.push(child);
}
}
return descendants;
}
export async function listProjects(
db: DatabaseClient,
ownerUserid?: string,
@@ -236,7 +303,7 @@ export async function listProjects(
const result = await (ownerFilter
? db
.prepare(
`SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.owner_userid, p.main_chat_id, p.status, p.note, p.created_at,
`SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.owner_userid, p.main_chat_id, p.parent_id, p.status, p.note, p.created_at,
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count
FROM projects p
WHERE p.owner_userid = ?
@@ -245,7 +312,7 @@ export async function listProjects(
.bind(ownerFilter)
: db
.prepare(
`SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.owner_userid, p.main_chat_id, p.status, p.note, p.created_at,
`SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.owner_userid, p.main_chat_id, p.parent_id, p.status, p.note, p.created_at,
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count
FROM projects p
ORDER BY p.created_at DESC`,
@@ -261,6 +328,7 @@ export async function listProjects(
mainGroupMemberIds: parseJsonArray(row.main_group_member_ids),
ownerUserid: row.owner_userid ?? "",
mainChatId: row.main_chat_id ?? "",
parentProjectId: row.parent_id ?? "",
taskCount: Number(row.task_count),
}));
}
@@ -272,7 +340,7 @@ export async function getProjectDetail(
const id = text(projectId);
const project = await db
.prepare(
`SELECT id, name, brand, main_group_member_ids, owner_userid, main_chat_id, status, note, created_at
`SELECT id, name, brand, main_group_member_ids, owner_userid, main_chat_id, parent_id, status, note, created_at
FROM projects WHERE id = ?`,
)
.bind(id)
@@ -289,6 +357,33 @@ export async function getProjectDetail(
.bind(id)
.all<ProjectTaskRow>();
const subprojects = await db
.prepare(
`SELECT p.id, p.name, p.brand, p.owner_userid, p.main_chat_id,
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count
FROM projects p
WHERE p.parent_id = ?
ORDER BY p.created_at DESC`,
)
.bind(id)
.all<ProjectRow & { task_count: number }>();
const descendantIds = await collectDescendantIds(db, id);
const pushIds = [id, ...descendantIds];
const placeholders = pushIds.map(() => "?").join(", ");
const pushTasks = await db
.prepare(
`SELECT t.id, t.name, t.brand, t.quantity, t.claimed_quantity, t.due_at, t.status,
t.task_type, t.platform, t.content_format, t.share_token, t.owner_userid, t.task_chat_id,
pj.name AS project_name
FROM tasks t
LEFT JOIN projects pj ON pj.id = t.project_id
WHERE t.project_id IN (${placeholders})
ORDER BY t.created_at DESC`,
)
.bind(...pushIds)
.all<ProjectTaskRow>();
return {
project: {
id: project.id,
@@ -300,6 +395,7 @@ export async function getProjectDetail(
mainGroupMemberIds: parseJsonArray(project.main_group_member_ids),
ownerUserid: project.owner_userid ?? "",
mainChatId: project.main_chat_id ?? "",
parentProjectId: project.parent_id ?? "",
taskCount: tasks.results.length,
},
tasks: tasks.results,
@@ -310,6 +406,15 @@ export async function getProjectDetail(
project.owner_userid,
),
},
subprojects: subprojects.results.map((row) => ({
id: row.id,
name: row.name,
brand: row.brand,
ownerUserid: row.owner_userid ?? "",
mainChatId: row.main_chat_id ?? "",
taskCount: Number(row.task_count),
})),
pushTasks: pushTasks.results,
};
}
@@ -320,6 +425,7 @@ export type SaveProjectInput = {
mainGroupMemberIds?: unknown;
ownerUserid?: unknown;
note?: unknown;
parentProjectId?: unknown;
};
export async function saveProject(
@@ -333,19 +439,33 @@ export async function saveProject(
const brand = text(input.brand);
const note = text(input.note ?? "");
const ownerUserid = text(input.ownerUserid).slice(0, 64);
const parentProjectId = text(input.parentProjectId);
const memberIds = effectiveProjectMembers(input.mainGroupMemberIds, ownerUserid);
if (!name) throw new ProjectError("请填写项目名称");
if (parentProjectId) {
if (parentProjectId === id) {
throw new ProjectError("上级项目不能是项目自身");
}
const parents = await loadProjectParents(db);
if (!parents.has(parentProjectId)) {
throw new ProjectError("上级项目不存在", 404);
}
if (id && isDescendantProject(parents, id, parentProjectId)) {
throw new ProjectError("上级项目不能是当前项目的子项目");
}
}
const now = new Date().toISOString();
let projectId = id;
if (id) {
const result = await db
.prepare(
`UPDATE projects
SET name = ?, brand = ?, main_group_member_ids = ?, owner_userid = ?, note = ?
SET name = ?, brand = ?, main_group_member_ids = ?, owner_userid = ?, note = ?, parent_id = ?
WHERE id = ?`,
)
.bind(name, brand, JSON.stringify(memberIds), ownerUserid, note, id)
.bind(name, brand, JSON.stringify(memberIds), ownerUserid, note, parentProjectId, id)
.run();
if (result.meta.changes === 0) throw new ProjectError("项目不存在", 404);
} else {
@@ -353,8 +473,8 @@ export async function saveProject(
await db
.prepare(
`INSERT INTO projects
(id, name, brand, main_group_member_ids, owner_userid, status, note, created_at)
VALUES (?, ?, ?, ?, ?, 'active', ?, ?)`,
(id, name, brand, main_group_member_ids, owner_userid, parent_id, status, note, created_at)
VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?)`,
)
.bind(
projectId,
@@ -362,6 +482,7 @@ export async function saveProject(
brand,
JSON.stringify(memberIds),
ownerUserid,
parentProjectId,
note,
now,
)
@@ -384,6 +505,13 @@ export async function deleteProject(
): Promise<void> {
const id = text(projectId);
if (!id) throw new ProjectError("缺少项目 ID");
const childCount = await db
.prepare("SELECT COUNT(*) AS count FROM projects WHERE parent_id = ?")
.bind(id)
.first<{ count: number }>();
if (Number(childCount?.count ?? 0) > 0) {
throw new ProjectError("该项目下还有子项目,请先删除子项目或把它们移到其他项目下");
}
const result = await db
.prepare("DELETE FROM projects WHERE id = ?")
.bind(id)

View File

@@ -3,11 +3,8 @@ import {
addTaskJoinWay,
deleteJoinWay,
getJoinWayQrCode,
hasAppCredentials,
listCustomerGroupChats,
updateTaskJoinWay,
type WecomConfig,
type WecomGroupChat,
} from "./wecom-client";
type FetchLike = typeof fetch;
@@ -16,19 +13,6 @@ function text(value: unknown) {
return String(value ?? "").trim();
}
export async function listOwnerCustomerChats(
config: WecomConfig,
ownerUserid: string,
fetchImpl: FetchLike = fetch,
): Promise<WecomGroupChat[]> {
if (!hasAppCredentials(config)) {
throw new Error("企业微信自建应用未配置(WECOM_CORP_ID / WECOM_AGENT_ID / WECOM_SECRET)");
}
const owner = text(ownerUserid);
if (!owner) return [];
return listCustomerGroupChats(config, fetchImpl, [owner]);
}
export type BindTaskCustomerChatResult = {
customerChatId: string;
customerChatName: string;

View File

@@ -1,98 +0,0 @@
import type { TaskReportRow } from "./mvp-db";
const CSV_HEADERS = [
"任务ID",
"任务名称",
"任务类型",
"品牌",
"平台",
"所属项目",
"任务状态",
"计划数量",
"领取数",
"发布数",
"完成数",
"完成率",
"截止日期",
"创建时间",
"曝光量合计",
"阅读量合计",
"D2点赞",
"D2评论",
"D2收藏",
"D5点赞",
"D5评论",
"D5收藏",
"D7点赞",
"D7评论",
"D7收藏",
"最新点赞",
"最新评论",
"最新收藏",
"最新转发",
"关联任务",
"截图提交数",
];
function taskTypeLabel(value: string) {
return value === "screenshot_collect" ? "截图采集" : "内容发布";
}
function taskStatusLabel(value: string) {
return value === "active" ? "进行中" : value === "completed" ? "已完结" : value;
}
function completionRate(row: TaskReportRow) {
if (!row.claimed_count) return "";
return `${Math.round((row.completed_count / row.claimed_count) * 100)}%`;
}
function csvCell(value: string | number | null | undefined) {
const text = value === null || value === undefined ? "" : String(value);
if (/[",\r\n]/.test(text)) return `"${text.replaceAll('"', '""')}"`;
return text;
}
export function buildTaskReportCsv(rows: TaskReportRow[]) {
const lines = [CSV_HEADERS.join(",")];
for (const row of rows) {
lines.push(
[
row.id,
row.name,
taskTypeLabel(row.task_type),
row.brand,
row.platform,
row.project_name ?? "",
taskStatusLabel(row.status),
row.quantity,
row.claimed_count,
row.published_count,
row.completed_count,
completionRate(row),
row.due_at,
row.created_at,
row.exposure_total,
row.views_total,
row.d2_likes,
row.d2_comments,
row.d2_collects,
row.d5_likes,
row.d5_comments,
row.d5_collects,
row.d7_likes,
row.d7_comments,
row.d7_collects,
row.latest_likes,
row.latest_comments,
row.latest_collects,
row.latest_shares,
row.related_task_name ?? row.reverse_related_task_name ?? "",
row.task_type === "screenshot_collect" ? row.submitted_count : "",
]
.map(csvCell)
.join(","),
);
}
return `\uFEFF${lines.join("\r\n")}`;
}

View File

@@ -394,12 +394,23 @@ export async function listCustomerGroupChats(
const getPayload = await readEnvelope(getResp, "获取企业微信客户群详情失败");
if (!getResp.ok) continue;
if (Number(getPayload.errcode ?? 0) !== 0) continue;
const detail = getPayload.group_chat;
const detail = getPayload.group_chat as
| {
name?: unknown;
owner?: unknown;
member_count?: unknown;
member_list?: unknown[];
}
| undefined;
// 企微实际响应不带 member_count(只有 member_list),以成员列表长度为准
const memberCount = Array.isArray(detail?.member_list)
? detail.member_list.length
: Number(detail?.member_count ?? 0);
groups.push({
chatId,
name: bindingValue(detail?.name),
ownerUserId: bindingValue(detail?.owner),
memberCount: Number(detail?.member_count ?? 0),
memberCount,
status,
});
}