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

3
.gitignore vendored
View File

@@ -58,3 +58,6 @@ next-env.d.ts
/.wrangler/ /.wrangler/
/outputs/ /outputs/
/work/ /work/
# local runtime data (self-hosted uploads)
/.data/

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,6 @@ import {
ensureSchema, ensureSchema,
getDashboardData, getDashboardData,
getRawDb, getRawDb,
getTaskReportData,
uid, uid,
} from "../../../lib/mvp-db"; } from "../../../lib/mvp-db";
import { import {
@@ -61,7 +60,6 @@ import {
} from "../../../lib/wecom-group-push-service"; } from "../../../lib/wecom-group-push-service";
import { import {
bindTaskCustomerChat, bindTaskCustomerChat,
listOwnerCustomerChats,
unbindTaskCustomerChat, unbindTaskCustomerChat,
} from "../../../lib/task-customer-chat-service"; } from "../../../lib/task-customer-chat-service";
import { isManagerRequest, managerForbidden } from "../../../lib/user-auth"; import { isManagerRequest, managerForbidden } from "../../../lib/user-auth";
@@ -694,6 +692,89 @@ export async function POST(request: Request) {
wecomExternalUserId: externalId || null, wecomExternalUserId: externalId || null,
wecomName, 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") { } else if (body.action === "send_test_wecom") {
const forbidden = managerGate(); const forbidden = managerGate();
if (forbidden) return forbidden; if (forbidden) return forbidden;
@@ -858,6 +939,7 @@ export async function POST(request: Request) {
mainGroupMemberIds: body.mainGroupMemberIds, mainGroupMemberIds: body.mainGroupMemberIds,
ownerUserid: nextOwnerUserid, ownerUserid: nextOwnerUserid,
note: body.note, note: body.note,
parentProjectId: body.parentProjectId,
}, },
resolveWecomConfig(env as unknown as WecomBindings), resolveWecomConfig(env as unknown as WecomBindings),
); );
@@ -970,10 +1052,6 @@ export async function POST(request: Request) {
appFailedCount: appFailed.length, appFailedCount: appFailed.length,
hint: "任务群消息已直接发送至任务内部群(负责人与领取人即时可见)", hint: "任务群消息已直接发送至任务内部群(负责人与领取人即时可见)",
}); });
} else if (body.action === "task_report_list") {
return Response.json({
reports: await getTaskReportData(db, ownerScopeUserid(scope)),
});
} else if (body.action === "refresh_account_fields") { } else if (body.action === "refresh_account_fields") {
const forbidden = managerGate(); const forbidden = managerGate();
if (forbidden) return forbidden; if (forbidden) return forbidden;
@@ -1042,28 +1120,24 @@ export async function POST(request: Request) {
const forbidden = await taskOwnerGate(taskId, { strict: true }); const forbidden = await taskOwnerGate(taskId, { strict: true });
if (forbidden) return forbidden; if (forbidden) return forbidden;
const task = await db const task = await db
.prepare( .prepare("SELECT id, customer_chat_id FROM tasks WHERE id = ?")
"SELECT id, owner_userid, customer_chat_id FROM tasks WHERE id = ?",
)
.bind(taskId) .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 }); 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 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({ return Response.json({
ownerUserid, chats: rows.map((row) => ({
chats, 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 ?? ""), boundChatId: String(task.customer_chat_id ?? ""),
}); });
} else if (body.action === "task_bind_customer_chat") { } 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 }); const forbidden = await taskOwnerGate(taskId, { strict: true });
if (forbidden) return forbidden; if (forbidden) return forbidden;
const task = await db const task = await db
.prepare( .prepare("SELECT id, customer_chat_id FROM tasks WHERE id = ?")
"SELECT id, owner_userid, customer_chat_id FROM tasks WHERE id = ?",
)
.bind(taskId) .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 }); 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(); const chatId = String(body.chatId ?? "").trim();
if (chatId && !ownerUserid) {
return Response.json(
{ error: "请先设置任务负责人,再绑定客户群" },
{ status: 400 },
);
}
const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings); const wecomConfig = resolveWecomConfig(env as unknown as WecomBindings);
if (chatId) { if (chatId) {
const chatName = String(body.chatName ?? "").trim(); const chatName = String(body.chatName ?? "").trim();

View File

@@ -1,33 +0,0 @@
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
import { buildTaskReportCsv } from "../../../lib/task-report-csv";
import {
ensureSchema,
getRawDb,
getTaskReportData,
} from "../../../lib/mvp-db";
import { getOwnerScope, ownerScopeUserid } from "../../../lib/owner-scope";
export async function GET(request: Request) {
try {
if (!(await isAdminRequest(request))) return adminForbidden();
await ensureSchema();
const rows = await getTaskReportData(
getRawDb(),
ownerScopeUserid(await getOwnerScope(request)),
);
const fileName = `任务复盘报表.csv`;
return new Response(buildTaskReportCsv(rows), {
headers: {
"Cache-Control": "private, no-store",
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="koc-task-report.csv"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
"X-Content-Type-Options": "nosniff",
},
});
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "导出失败" },
{ status: 500 },
);
}
}

View File

@@ -1486,7 +1486,7 @@ a {
.partner-row { .partner-row {
display: grid; display: grid;
grid-template-columns: 1fr 1fr 0.6fr 1.4fr auto; grid-template-columns: 1fr 1fr 1.2fr 0.6fr 1.4fr auto;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 10px 12px; padding: 10px 12px;
@@ -1519,6 +1519,59 @@ a {
min-width: 0; min-width: 0;
} }
.partner-resource-cell {
display: flex;
min-width: 0;
flex-direction: column;
align-items: flex-start;
gap: 3px;
}
.partner-resource-cell strong {
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.partner-resource-contact {
max-width: 100%;
padding: 1px 7px;
border-radius: 5px;
background: #eef6f1;
color: #3f7d63;
font-size: 10px;
font-style: normal;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.partner-resource-contact.empty {
background: #f4f4f2;
color: #a8afa8;
}
.partner-resource-actions {
display: flex;
gap: 4px;
}
.partner-resource-actions .ghost-button {
min-height: 22px;
padding: 0 8px;
font-size: 10px;
}
.partner-resource-actions .ghost-button.danger {
border-color: rgb(180 84 63 / 0.35);
color: #b4543f;
}
.partner-resource-actions .ghost-button.danger:hover:not(:disabled) {
border-color: rgb(180 84 63 / 0.65);
}
.partner-external-cell input, .partner-external-cell input,
.partner-external-cell code { .partner-external-cell code {
width: 100%; width: 100%;
@@ -3176,6 +3229,46 @@ a {
white-space: nowrap; white-space: nowrap;
} }
.resource-foot-actions {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.resource-partner-button {
padding: 3px 10px;
border: 1px solid var(--green);
border-radius: 999px;
background: transparent;
color: var(--green);
font-size: 10px;
font-weight: 650;
cursor: pointer;
}
.resource-partner-button:hover:not(:disabled) {
background: #eaf6f0;
}
.resource-partner-button:disabled {
opacity: 0.6;
cursor: default;
}
.resource-partner-bound {
padding: 3px 10px;
border-radius: 999px;
background: #eef2ef;
color: #687773;
font-size: 10px;
font-weight: 620;
max-width: 140px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.resource-empty { .resource-empty {
display: flex; display: flex;
min-height: 240px; min-height: 240px;
@@ -5351,6 +5444,36 @@ label small {
padding: 18px 20px; padding: 18px 20px;
} }
.project-tree-node.project-panel + .project-tree-node.project-panel {
margin-top: 10px;
}
.project-tree-toggle {
flex: none;
width: 24px;
height: 24px;
border: 1px solid #dfe6e1;
border-radius: 6px;
background: #f7faf8;
color: #5b6b62;
font-size: 11px;
line-height: 1;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.project-tree-toggle:hover:not(:disabled) {
border-color: #b8c8be;
color: #2f3e36;
}
.project-tree-toggle.leaf {
opacity: 0.35;
cursor: default;
}
.ungrouped-panel { .ungrouped-panel {
padding: 18px 20px; padding: 18px 20px;
} }
@@ -5438,20 +5561,59 @@ label small {
font-size: 10px; font-size: 10px;
} }
.project-detail-block-actions {
display: flex;
align-items: center;
gap: 6px;
}
.project-member-list { .project-member-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px; gap: 6px;
} }
.project-member-list code { .project-member-list li {
padding: 3px 8px; display: inline-flex;
border-radius: 6px; align-items: center;
gap: 4px;
padding: 3px 6px 3px 4px;
border-radius: 7px;
background: #eef2ef; background: #eef2ef;
}
.project-member-list code {
padding: 0 4px;
color: #556861; color: #556861;
font-size: 11px; font-size: 11px;
} }
.project-member-remove {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 50%;
color: #8a958f;
background: #dfe6e2;
font-size: 12px;
line-height: 1;
cursor: pointer;
}
.project-member-remove:hover {
color: white;
background: #c9503f;
}
.project-member-remove:disabled {
opacity: 0.5;
cursor: default;
}
.project-empty-line { .project-empty-line {
color: #9aa39f; color: #9aa39f;
font-size: 11px; font-size: 11px;

View File

@@ -37,14 +37,18 @@ export function TaskCustomerChatModal({
const [selectedChatId, setSelectedChatId] = useState(task.customerChatId); const [selectedChatId, setSelectedChatId] = useState(task.customerChatId);
const [bindResult, setBindResult] = useState<BindResult | null>(null); const [bindResult, setBindResult] = useState<BindResult | null>(null);
const loadChats = useCallback(async () => { const loadChats = useCallback(async (options?: { sync?: boolean }) => {
setLoading(true); setLoading(true);
setError(""); setError("");
try { try {
const response = await fetch("/api/action", { const response = await fetch("/api/action", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "task_customer_chats", taskId: task.id }), body: JSON.stringify({
action: "task_customer_chats",
taskId: task.id,
sync: options?.sync === true,
}),
}); });
const parsed = await readApiResponse<{ const parsed = await readApiResponse<{
chats?: CustomerChat[]; chats?: CustomerChat[];
@@ -129,7 +133,7 @@ export function TaskCustomerChatModal({
<div> <div>
<h2>绑定客户群</h2> <h2>绑定客户群</h2>
<p> <p>
列出任务负责人名下的企业微信客户群,绑定后生成进群活码,认领人在领取页扫码进群。 列出全部成员名下的企业微信客户群(含各群主的外部群),绑定后生成进群活码,认领人在领取页扫码进群。
</p> </p>
</div> </div>
<button type="button" onClick={onClose} aria-label="关闭">×</button> <button type="button" onClick={onClose} aria-label="关闭">×</button>
@@ -142,10 +146,10 @@ export function TaskCustomerChatModal({
<button <button
type="button" type="button"
className="ghost-button" className="ghost-button"
onClick={() => void loadChats()} onClick={() => void loadChats({ sync: true })}
disabled={loading || saving} disabled={loading || saving}
> >
{loading ? "拉取中…" : "刷新列表"} {loading ? "同步中…" : "同步最新群列表"}
</button> </button>
</div> </div>
{error && <p className="form-error">{error}</p>} {error && <p className="form-error">{error}</p>}
@@ -158,11 +162,11 @@ export function TaskCustomerChatModal({
)} )}
<div className="wecom-picker-list"> <div className="wecom-picker-list">
{loading ? ( {loading ? (
<div className="wecom-picker-empty">正在拉取负责人名下客户群…</div> <div className="wecom-picker-empty">正在拉取客户群列表…</div>
) : chats.length === 0 ? ( ) : chats.length === 0 ? (
<div className="wecom-picker-empty"> <div className="wecom-picker-empty">
未拉取到客户群——请任务负责人先在企微客户端创建客户群(内外部成员都可拉入), 未拉取到客户群——请群主先在企微客户端创建客户群(内外部成员都可拉入,且群主需在应用可见范围内),
再点击「刷新列表」。 再点击「同步最新群列表」。
</div> </div>
) : ( ) : (
chats.map((chat) => { chats.map((chat) => {
@@ -174,7 +178,9 @@ export function TaskCustomerChatModal({
</span> </span>
<span className="wecom-picker-meta"> <span className="wecom-picker-meta">
<span className="wecom-picker-name">{chat.name || chat.chatId}</span> <span className="wecom-picker-name">{chat.name || chat.chatId}</span>
<span className="wecom-picker-sub">{chat.memberCount} 人</span> <span className="wecom-picker-sub">
群主 {chat.ownerUserId || "—"} · {chat.memberCount} 人
</span>
<code className="wecom-picker-id">{chat.chatId}</code> <code className="wecom-picker-id">{chat.chatId}</code>
</span> </span>
<input <input

View File

@@ -375,6 +375,11 @@ export async function ensureSchema(database?: DatabaseClient) {
"wecom_external_user_id", "wecom_external_user_id",
"wecom_external_user_id TEXT", "wecom_external_user_id TEXT",
); );
await ensureColumn(
"partners",
"account_id",
"account_id TEXT NOT NULL DEFAULT ''",
);
await ensureColumn( await ensureColumn(
"users", "users",
"wecom_userid", "wecom_userid",
@@ -902,7 +907,17 @@ export async function getDashboardData(ownerUserid?: string) {
const [partnersResult, tasksResult, accountsResult, distributionsResult] = const [partnersResult, tasksResult, accountsResult, distributionsResult] =
await Promise.all([ await Promise.all([
ownerFilter === null 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(), : emptyResult(),
ownerFilter ownerFilter
? db ? 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) { export function uid(prefix: string) {
return `${prefix}-${crypto.randomUUID().slice(0, 8)}`; return `${prefix}-${crypto.randomUUID().slice(0, 8)}`;
} }

View File

@@ -17,6 +17,7 @@ export type ProjectRow = {
main_group_member_ids: string; main_group_member_ids: string;
owner_userid: string; owner_userid: string;
main_chat_id: string; main_chat_id: string;
parent_id: string;
status: string; status: string;
note: string; note: string;
created_at: string; created_at: string;
@@ -32,6 +33,7 @@ export type ProjectSummary = {
mainGroupMemberIds: string[]; mainGroupMemberIds: string[];
ownerUserid: string; ownerUserid: string;
mainChatId: string; mainChatId: string;
parentProjectId: string;
taskCount: number; taskCount: number;
}; };
@@ -49,6 +51,16 @@ export type ProjectTaskRow = {
share_token: string | null; share_token: string | null;
owner_userid: string; owner_userid: string;
task_chat_id: 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 = { export type ProjectMainChat = {
@@ -60,6 +72,8 @@ export type ProjectDetail = {
project: ProjectSummary; project: ProjectSummary;
tasks: ProjectTaskRow[]; tasks: ProjectTaskRow[];
mainChat: ProjectMainChat; mainChat: ProjectMainChat;
subprojects: ProjectSubproject[];
pushTasks: ProjectTaskRow[];
}; };
export type MainChatEnsureReason = 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( export async function listProjects(
db: DatabaseClient, db: DatabaseClient,
ownerUserid?: string, ownerUserid?: string,
@@ -236,7 +303,7 @@ export async function listProjects(
const result = await (ownerFilter const result = await (ownerFilter
? db ? db
.prepare( .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 (SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count
FROM projects p FROM projects p
WHERE p.owner_userid = ? WHERE p.owner_userid = ?
@@ -245,7 +312,7 @@ export async function listProjects(
.bind(ownerFilter) .bind(ownerFilter)
: db : db
.prepare( .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 (SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count
FROM projects p FROM projects p
ORDER BY p.created_at DESC`, ORDER BY p.created_at DESC`,
@@ -261,6 +328,7 @@ export async function listProjects(
mainGroupMemberIds: parseJsonArray(row.main_group_member_ids), mainGroupMemberIds: parseJsonArray(row.main_group_member_ids),
ownerUserid: row.owner_userid ?? "", ownerUserid: row.owner_userid ?? "",
mainChatId: row.main_chat_id ?? "", mainChatId: row.main_chat_id ?? "",
parentProjectId: row.parent_id ?? "",
taskCount: Number(row.task_count), taskCount: Number(row.task_count),
})); }));
} }
@@ -272,7 +340,7 @@ export async function getProjectDetail(
const id = text(projectId); const id = text(projectId);
const project = await db const project = await db
.prepare( .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 = ?`, FROM projects WHERE id = ?`,
) )
.bind(id) .bind(id)
@@ -289,6 +357,33 @@ export async function getProjectDetail(
.bind(id) .bind(id)
.all<ProjectTaskRow>(); .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 { return {
project: { project: {
id: project.id, id: project.id,
@@ -300,6 +395,7 @@ export async function getProjectDetail(
mainGroupMemberIds: parseJsonArray(project.main_group_member_ids), mainGroupMemberIds: parseJsonArray(project.main_group_member_ids),
ownerUserid: project.owner_userid ?? "", ownerUserid: project.owner_userid ?? "",
mainChatId: project.main_chat_id ?? "", mainChatId: project.main_chat_id ?? "",
parentProjectId: project.parent_id ?? "",
taskCount: tasks.results.length, taskCount: tasks.results.length,
}, },
tasks: tasks.results, tasks: tasks.results,
@@ -310,6 +406,15 @@ export async function getProjectDetail(
project.owner_userid, 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; mainGroupMemberIds?: unknown;
ownerUserid?: unknown; ownerUserid?: unknown;
note?: unknown; note?: unknown;
parentProjectId?: unknown;
}; };
export async function saveProject( export async function saveProject(
@@ -333,19 +439,33 @@ export async function saveProject(
const brand = text(input.brand); const brand = text(input.brand);
const note = text(input.note ?? ""); const note = text(input.note ?? "");
const ownerUserid = text(input.ownerUserid).slice(0, 64); const ownerUserid = text(input.ownerUserid).slice(0, 64);
const parentProjectId = text(input.parentProjectId);
const memberIds = effectiveProjectMembers(input.mainGroupMemberIds, ownerUserid); const memberIds = effectiveProjectMembers(input.mainGroupMemberIds, ownerUserid);
if (!name) throw new ProjectError("请填写项目名称"); 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(); const now = new Date().toISOString();
let projectId = id; let projectId = id;
if (id) { if (id) {
const result = await db const result = await db
.prepare( .prepare(
`UPDATE projects `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 = ?`, WHERE id = ?`,
) )
.bind(name, brand, JSON.stringify(memberIds), ownerUserid, note, id) .bind(name, brand, JSON.stringify(memberIds), ownerUserid, note, parentProjectId, id)
.run(); .run();
if (result.meta.changes === 0) throw new ProjectError("项目不存在", 404); if (result.meta.changes === 0) throw new ProjectError("项目不存在", 404);
} else { } else {
@@ -353,8 +473,8 @@ export async function saveProject(
await db await db
.prepare( .prepare(
`INSERT INTO projects `INSERT INTO projects
(id, name, brand, main_group_member_ids, owner_userid, status, note, created_at) (id, name, brand, main_group_member_ids, owner_userid, parent_id, status, note, created_at)
VALUES (?, ?, ?, ?, ?, 'active', ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?)`,
) )
.bind( .bind(
projectId, projectId,
@@ -362,6 +482,7 @@ export async function saveProject(
brand, brand,
JSON.stringify(memberIds), JSON.stringify(memberIds),
ownerUserid, ownerUserid,
parentProjectId,
note, note,
now, now,
) )
@@ -384,6 +505,13 @@ export async function deleteProject(
): Promise<void> { ): Promise<void> {
const id = text(projectId); const id = text(projectId);
if (!id) throw new ProjectError("缺少项目 ID"); 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 const result = await db
.prepare("DELETE FROM projects WHERE id = ?") .prepare("DELETE FROM projects WHERE id = ?")
.bind(id) .bind(id)

View File

@@ -3,11 +3,8 @@ import {
addTaskJoinWay, addTaskJoinWay,
deleteJoinWay, deleteJoinWay,
getJoinWayQrCode, getJoinWayQrCode,
hasAppCredentials,
listCustomerGroupChats,
updateTaskJoinWay, updateTaskJoinWay,
type WecomConfig, type WecomConfig,
type WecomGroupChat,
} from "./wecom-client"; } from "./wecom-client";
type FetchLike = typeof fetch; type FetchLike = typeof fetch;
@@ -16,19 +13,6 @@ function text(value: unknown) {
return String(value ?? "").trim(); 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 = { export type BindTaskCustomerChatResult = {
customerChatId: string; customerChatId: string;
customerChatName: 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, "获取企业微信客户群详情失败"); const getPayload = await readEnvelope(getResp, "获取企业微信客户群详情失败");
if (!getResp.ok) continue; if (!getResp.ok) continue;
if (Number(getPayload.errcode ?? 0) !== 0) 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({ groups.push({
chatId, chatId,
name: bindingValue(detail?.name), name: bindingValue(detail?.name),
ownerUserId: bindingValue(detail?.owner), ownerUserId: bindingValue(detail?.owner),
memberCount: Number(detail?.member_count ?? 0), memberCount,
status, status,
}); });
} }

View File

@@ -0,0 +1,3 @@
ALTER TABLE `projects` ADD COLUMN `parent_id` VARCHAR(64) NOT NULL DEFAULT '';
-- statement-breakpoint
ALTER TABLE `projects` ADD INDEX `projects_parent_idx` (`parent_id`);

View File

@@ -0,0 +1,3 @@
ALTER TABLE `partners` ADD COLUMN `account_id` VARCHAR(64) NOT NULL DEFAULT '' AFTER wecom_userid;
-- statement-breakpoint
ALTER TABLE `partners` ADD INDEX `partners_account_idx` (`account_id`);

View File

@@ -10,10 +10,6 @@ const bootstrapRoute = readFileSync(
new URL("../app/api/bootstrap/route.ts", import.meta.url), new URL("../app/api/bootstrap/route.ts", import.meta.url),
"utf8", "utf8",
); );
const taskReportExportRoute = readFileSync(
new URL("../app/api/task-report-export/route.ts", import.meta.url),
"utf8",
);
const recoveryExportRoute = readFileSync( const recoveryExportRoute = readFileSync(
new URL("../app/api/recovery-export/route.ts", import.meta.url), new URL("../app/api/recovery-export/route.ts", import.meta.url),
"utf8", "utf8",
@@ -55,6 +51,8 @@ test("manager-only actions gate on owner scope", () => {
"backfill_account_profiles", "backfill_account_profiles",
"set_public_account_ids", "set_public_account_ids",
"bind_wecom_external_id", "bind_wecom_external_id",
"partner_bind_account",
"partner_create_from_account",
"send_test_wecom", "send_test_wecom",
"trigger_wecom_overdue_escalation", "trigger_wecom_overdue_escalation",
"wecom_list_external_contacts", "wecom_list_external_contacts",
@@ -185,15 +183,6 @@ test("task-scoped actions verify ownership for task owners", () => {
assert.match(manual, /distributionOwnerGate\(/); assert.match(manual, /distributionOwnerGate\(/);
}); });
test("task_report_list scopes reports by owner and drops the broken gate", () => {
const body = section(actionRoute, "task_report_list");
assert.match(body, /getTaskReportData\(db, ownerScopeUserid\(scope\)\)/);
assert.ok(
!/isManagerRequest/.test(body),
"task_report_list 不再是管理员专属(负责人按 scope 过滤)",
);
});
test("project_list opens to owner scope with server-side filtering", () => { test("project_list opens to owner scope with server-side filtering", () => {
const body = section(actionRoute, "project_list"); const body = section(actionRoute, "project_list");
assert.match( assert.match(
@@ -232,23 +221,37 @@ test("action fallthrough dashboard is owner-scoped", () => {
); );
}); });
test("customer chat actions require a task owner before binding", () => { test("customer chat actions list all owners' groups without owner prerequisite", () => {
const list = section(actionRoute, "task_customer_chats");
assert.match(
list,
/listGroupChatRows\(db\)/,
"task_customer_chats 读全量群缓存(所有群主的外部群)",
);
assert.match(
list,
/syncGroupChats\(db, wecomConfig\)/,
"缓存为空(或要求刷新)时自动全量同步",
);
assert.ok(
!/请先设置任务负责人/.test(list),
"列表不再要求任务先设负责人",
);
const bind = section(actionRoute, "task_bind_customer_chat");
assert.ok(
!/请先设置任务负责人/.test(bind),
"绑定活码用应用身份,不依赖任务负责人",
);
assert.match(bind, /bindTaskCustomerChat\(/);
assert.match(bind, /unbindTaskCustomerChat\(/);
for (const action of ["task_customer_chats", "task_bind_customer_chat"]) { for (const action of ["task_customer_chats", "task_bind_customer_chat"]) {
const body = section(actionRoute, action); const body = section(actionRoute, action);
assert.match(
body,
/请先设置任务负责人/,
`${action} 必须在无负责人时明确报错`,
);
assert.match(body, /Response\.json\(/); assert.match(body, /Response\.json\(/);
assert.ok( assert.ok(
!/getDashboardData/.test(body), !/getDashboardData/.test(body),
`${action} 显式返回,不能落兜底 dashboard`, `${action} 显式返回,不能落兜底 dashboard`,
); );
} }
const bind = section(actionRoute, "task_bind_customer_chat");
assert.match(bind, /bindTaskCustomerChat\(/);
assert.match(bind, /unbindTaskCustomerChat\(/);
}); });
test("partner claim detail exposes join qr only for claimed tasks", () => { test("partner claim detail exposes join qr only for claimed tasks", () => {
@@ -275,7 +278,7 @@ test("bootstrap scopes dashboard data by owner scope", () => {
test("getDashboardData filters tasks and distributions by owner", () => { test("getDashboardData filters tasks and distributions by owner", () => {
const dashboard = mvpDb.slice( const dashboard = mvpDb.slice(
mvpDb.indexOf("export async function getDashboardData"), mvpDb.indexOf("export async function getDashboardData"),
mvpDb.indexOf("export type TaskReportRow"), mvpDb.indexOf("export function uid(prefix: string)"),
); );
const occurrences = const occurrences =
dashboard.match( dashboard.match(
@@ -289,8 +292,6 @@ test("getDashboardData filters tasks and distributions by owner", () => {
}); });
test("export routes enforce owner scope", () => { test("export routes enforce owner scope", () => {
assert.match(taskReportExportRoute, /getOwnerScope\(request\)/);
assert.match(taskReportExportRoute, /ownerScopeUserid\(await getOwnerScope\(request\)\)/);
assert.match(recoveryExportRoute, /isTaskOwner\(/); assert.match(recoveryExportRoute, /isTaskOwner\(/);
assert.match(screenshotTaskExportRoute, /isTaskOwner\(/); assert.match(screenshotTaskExportRoute, /isTaskOwner\(/);
assert.match(taskResultImageRoute, /isDistributionOwner\(/); assert.match(taskResultImageRoute, /isDistributionOwner\(/);
@@ -301,3 +302,38 @@ test("users route supports wecom binding lifecycle", () => {
assert.match(usersRoute, /INSERT INTO users[\s\S]*?wecom_userid/); assert.match(usersRoute, /INSERT INTO users[\s\S]*?wecom_userid/);
assert.match(usersRoute, /SELECT id, username, role, wecom_userid, created_at/); assert.match(usersRoute, /SELECT id, username, role, wecom_userid, created_at/);
}); });
test("project detail exposes inline main-chat member management", () => {
const adminApp = readFileSync(
new URL("../app/admin-app.tsx", import.meta.url),
"utf8",
);
const globalsCss = readFileSync(
new URL("../app/globals.css", import.meta.url),
"utf8",
);
assert.match(adminApp, /memberPickerOpen/);
assert.match(adminApp, /mainGroupMemberIds: nextMembers/);
assert.match(adminApp, /project-member-remove/);
assert.match(adminApp, /userid !== detail\.project\.ownerUserid/);
assert.match(globalsCss, /\.project-member-remove/);
});
test("every project_save payload carries parentProjectId(改总群成员不得清空层级)", () => {
const adminApp = readFileSync(
new URL("../app/admin-app.tsx", import.meta.url),
"utf8",
);
const starts = [...adminApp.matchAll(/action: "project_save"/g)].map(
(match) => match.index,
);
assert.ok(starts.length >= 2, "至少应有两处 project_save 调用(编辑器/总群成员)");
for (const start of starts) {
const payload = adminApp.slice(start, start + 600);
assert.match(
payload,
/parentProjectId/,
"project_save 载荷缺 parentProjectId,会把 parent_id 重置为空",
);
}
});

View File

@@ -5,7 +5,6 @@ import {
isTaskOwner, isTaskOwner,
ownerScopeUserid, ownerScopeUserid,
} from "../lib/owner-scope.ts"; } from "../lib/owner-scope.ts";
import { getTaskReportData } from "../lib/mvp-db.ts";
function captureDb(rowsByPattern) { function captureDb(rowsByPattern) {
const captured = []; const captured = [];
@@ -121,29 +120,3 @@ test("ownerScopeUserid maps scope kinds to filter value", () => {
assert.equal(ownerScopeUserid({ kind: "owner", wecomUserId: "zhangsan" }), "zhangsan"); assert.equal(ownerScopeUserid({ kind: "owner", wecomUserId: "zhangsan" }), "zhangsan");
assert.equal(ownerScopeUserid({ kind: "owner", wecomUserId: "" }), ""); assert.equal(ownerScopeUserid({ kind: "owner", wecomUserId: "" }), "");
}); });
test("getTaskReportData filters by owner when ownerUserid given", async () => {
const { db, captured } = captureDb([[/FROM tasks t/, () => []]]);
await getTaskReportData(db, "zhangsan");
assert.equal(captured.length, 1);
assert.match(
captured[0].sql,
/WHERE \(t\.owner_userid = \? OR p\.owner_userid = \?\)/,
);
assert.deepEqual(captured[0].values, ["zhangsan", "zhangsan"]);
});
test("getTaskReportData keeps global aggregation without ownerUserid", async () => {
const { db, captured } = captureDb([[/FROM tasks t/, () => []]]);
await getTaskReportData(db);
assert.equal(captured.length, 1);
assert.ok(!/owner_userid = \?/.test(captured[0].sql));
assert.ok(!captured[0].values?.length);
});
test("getTaskReportData returns empty list for unbound owner (empty string scope)", async () => {
const { db, captured } = captureDb([[/FROM tasks t/, () => [{ id: "task-x" }]]]);
const rows = await getTaskReportData(db, "");
assert.deepEqual(rows, []);
assert.equal(captured.length, 0, "空范围不应执行查询(绑定 '' 会误配 owner_userid='' 的任务)");
});

View File

@@ -0,0 +1,91 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
const actionRoute = readFileSync(
new URL("../app/api/action/route.ts", import.meta.url),
"utf8",
);
const mvpDb = readFileSync(
new URL("../lib/mvp-db.ts", import.meta.url),
"utf8",
);
const adminApp = readFileSync(
new URL("../app/admin-app.tsx", import.meta.url),
"utf8",
);
const migration = readFileSync(
new URL("../mysql/0018_partner_resource_binding.sql", import.meta.url),
"utf8",
);
function section(source, action) {
const start = source.indexOf(`"${action}"`);
assert.ok(start >= 0, `action ${action} 不存在于路由源码`);
const next = source.indexOf("} else if (body.action ===", start);
return source.slice(start, next === -1 ? undefined : next);
}
test("partner_bind_account enforces one partner per resource account", () => {
const body = section(actionRoute, "partner_bind_account");
assert.match(
body,
/SELECT id, name FROM partners WHERE account_id = \? AND id != \? LIMIT 1/,
"绑定前必须检查资源账号是否已被其它合作方占用",
);
assert.match(body, /该 KOC 资源已绑定合作方/, "占用时返回可读错误");
assert.match(
body,
/UPDATE partners SET account_id = '' WHERE id = \?/,
"空 accountId 表示解绑",
);
assert.match(body, /群主合作方不绑定 KOC 资源/);
});
test("partner_create_from_account creates external_koc bound to account", () => {
const body = section(actionRoute, "partner_create_from_account");
assert.match(
body,
/SELECT id, name FROM partners WHERE account_id = \? LIMIT 1/,
"一键创建前必须确认资源未被绑定",
);
assert.match(
body,
/partner-koc-/,
"合作方 id 使用 partner-koc- 前缀,避免与领取流程冲突",
);
assert.match(body, /'external_koc'/);
assert.match(
body,
/INSERT INTO partners[\s\S]*account_id[\s\S]*VALUES/,
"INSERT 必须写入 account_id",
);
});
test("dashboard partners query joins accounts for contact display", () => {
assert.match(
mvpDb,
/FROM partners p\s+LEFT JOIN accounts a ON a\.id = p\.account_id/,
"partners 列表需带出资源昵称与联系方式",
);
assert.match(mvpDb, /a\.current_contact AS account_contact/);
assert.match(
mvpDb,
/ensureColumn\(\s*"partners",\s*"account_id"/,
"自托管路径也要补 account_id 列",
);
});
test("migration 0018 adds partners.account_id with index", () => {
assert.match(migration, /ADD COLUMN `account_id`/);
assert.match(migration, /partners_account_idx/);
});
test("admin app renders resource binding affordances", () => {
assert.match(adminApp, /partner_bind_account/);
assert.match(adminApp, /partner_create_from_account/);
assert.match(adminApp, /绑定资源/, "未绑合作方有绑定入口");
assert.match(adminApp, /换绑/, "已绑合作方可换绑");
assert.match(adminApp, /解绑中…/, "已绑合作方可解绑");
assert.match(adminApp, /设为合作方/, "资源页支持一键创建合作方");
});

View File

@@ -105,10 +105,11 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
brand: params[2], brand: params[2],
main_group_member_ids: params[3], main_group_member_ids: params[3],
owner_userid: params[4], owner_userid: params[4],
parent_id: params[5],
main_chat_id: "", main_chat_id: "",
status: "active", status: "active",
note: params[5], note: params[6],
created_at: params[6], created_at: params[7],
}); });
return 1; return 1;
} }
@@ -118,7 +119,7 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
return row ? 1 : 0; return row ? 1 : 0;
} }
if (/^UPDATE projects/.test(normalized)) { 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; if (!row) return 0;
Object.assign(row, { Object.assign(row, {
name: params[0], name: params[0],
@@ -126,6 +127,7 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
main_group_member_ids: params[2], main_group_member_ids: params[2],
owner_userid: params[3], owner_userid: params[3],
note: params[4], note: params[4],
parent_id: params[5],
}); });
return 1; return 1;
} }
@@ -170,6 +172,11 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
}, },
async first() { async first() {
executed.push({ sql, params }); 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)) { if (/FROM projects WHERE id = \?/.test(sql)) {
return projects.find((item) => item.id === params[0]) ?? null; return projects.find((item) => item.id === params[0]) ?? null;
} }
@@ -189,11 +196,23 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
}, },
async all() { async all() {
executed.push({ sql, params }); 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)) { if (/FROM projects p/.test(sql)) {
const ownerFiltered = /WHERE p\.owner_userid = \?/.test(sql); const ownerFiltered = /WHERE p\.owner_userid = \?/.test(sql);
const rows = ownerFiltered const parentFiltered = /WHERE p\.parent_id = \?/.test(sql);
? projects.filter((item) => item.owner_userid === params[0]) let rows = projects;
: 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 { return {
results: rows.map((item) => ({ results: rows.map((item) => ({
...item, ...item,
@@ -208,6 +227,18 @@ function fakeProjectDb({ projects = [], tasks = [], partners = [] } = {}) {
.map((task) => ({ ...task })), .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)) { if (/FROM partners/.test(sql)) {
return { results: partners.map((item) => ({ ...item })) }; return { results: partners.map((item) => ({ ...item })) };
} }
@@ -236,6 +267,7 @@ const projectRow = (overrides = {}) => ({
brand: "", brand: "",
main_group_member_ids: "[]", main_group_member_ids: "[]",
owner_userid: "", owner_userid: "",
parent_id: "",
main_chat_id: "", main_chat_id: "",
status: "active", status: "active",
note: "", note: "",
@@ -695,3 +727,101 @@ test("findOrCreateGroupOwnerPartner creates partner with userid fallback name",
assert.equal(created.wecom_userid, "owner-b"); assert.equal(created.wecom_userid, "owner-b");
assert.equal(db.partners.length, 1); 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(),
["一级:直属任务", "三级:孙项目任务", "二级:子项目任务"],
);
});

View File

@@ -2,7 +2,6 @@ import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { import {
bindTaskCustomerChat, bindTaskCustomerChat,
listOwnerCustomerChats,
unbindTaskCustomerChat, unbindTaskCustomerChat,
} from "../lib/task-customer-chat-service.ts"; } from "../lib/task-customer-chat-service.ts";
import { resolveWecomConfig } from "../lib/wecom-client.ts"; import { resolveWecomConfig } from "../lib/wecom-client.ts";
@@ -91,14 +90,6 @@ function callsTo(calls, suffix) {
return calls.filter((c) => c.url.pathname.endsWith(suffix)); return calls.filter((c) => c.url.pathname.endsWith(suffix));
} }
test("listOwnerCustomerChats rejects without app credentials and ignores empty owner", async () => {
const config = resolveWecomConfig({ ...baseBindings, WECOM_SECRET: "" });
await assert.rejects(() => listOwnerCustomerChats(config, "zhangsan"), /未配置/);
const { calls, fetchImpl } = fakeWecom();
assert.deepEqual(await listOwnerCustomerChats(resolveWecomConfig(baseBindings), " ", fetchImpl), []);
assert.equal(calls.length, 0, "empty owner short-circuits without API calls");
});
test("bindTaskCustomerChat creates join way when task has no config", async () => { test("bindTaskCustomerChat creates join way when task has no config", async () => {
const { db, captured } = captureDb({ const { db, captured } = captureDb({
id: "task-1", id: "task-1",

View File

@@ -1,192 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildTaskReportCsv } from "../lib/task-report-csv.ts";
import { getTaskReportData } from "../lib/mvp-db.ts";
const reportRow = {
id: "task-A",
name: "夏日种草计划",
task_type: "content_publish",
brand: "轻食品牌",
platform: "小红书",
status: "active",
due_at: "2026-08-20",
created_at: "2026-08-01 10:00:00",
quantity: 20,
project_name: "八月投放",
claimed_count: 10,
published_count: 8,
completed_count: 5,
exposure_total: 12000,
views_total: 9000,
d2_likes: 100,
d2_comments: 20,
d2_collects: 30,
d5_likes: 150,
d5_comments: 25,
d5_collects: 40,
d7_likes: 180,
d7_comments: 30,
d7_collects: 50,
latest_likes: 200,
latest_comments: 40,
latest_collects: 60,
latest_shares: 10,
related_task_id: "",
related_task_name: null,
reverse_related_task_id: null,
reverse_related_task_name: null,
submitted_count: 0,
};
test("buildTaskReportCsv writes BOM, headers and mapped rows", () => {
const csv = buildTaskReportCsv([reportRow]);
assert.ok(csv.startsWith(""), "starts with UTF-8 BOM");
const lines = csv.slice(1).split("\r\n");
assert.equal(lines.length, 2);
assert.equal(
lines[0],
[
"任务ID",
"任务名称",
"任务类型",
"品牌",
"平台",
"所属项目",
"任务状态",
"计划数量",
"领取数",
"发布数",
"完成数",
"完成率",
"截止日期",
"创建时间",
"曝光量合计",
"阅读量合计",
"D2点赞",
"D2评论",
"D2收藏",
"D5点赞",
"D5评论",
"D5收藏",
"D7点赞",
"D7评论",
"D7收藏",
"最新点赞",
"最新评论",
"最新收藏",
"最新转发",
"关联任务",
"截图提交数",
].join(","),
);
assert.equal(
lines[1],
[
"task-A",
"夏日种草计划",
"内容发布",
"轻食品牌",
"小红书",
"八月投放",
"进行中",
20,
10,
8,
5,
"50%",
"2026-08-20",
"2026-08-01 10:00:00",
12000,
9000,
100,
20,
30,
150,
25,
40,
180,
30,
50,
200,
40,
60,
10,
"",
"",
].join(","),
);
});
test("buildTaskReportCsv escapes commas and quotes, blanks null metrics", () => {
const csv = buildTaskReportCsv([
{
...reportRow,
name: '含,逗号"引号"任务',
project_name: null,
claimed_count: 0,
exposure_total: null,
views_total: null,
d2_likes: null,
d2_comments: null,
d2_collects: null,
d5_likes: null,
d5_comments: null,
d5_collects: null,
d7_likes: null,
d7_comments: null,
d7_collects: null,
latest_likes: null,
latest_comments: null,
latest_collects: null,
latest_shares: null,
},
]);
const rowLine = csv.slice(1).split("\r\n")[1];
assert.ok(rowLine.includes('"含,逗号""引号""任务"'));
const cells = rowLine.split(",");
assert.equal(cells.at(-1), "", "content publish row leaves screenshot count empty");
assert.equal(cells.at(-2), "", "no related task leaves empty");
assert.equal(cells.at(-20), "", "no claims means empty completion rate");
assert.equal(cells.at(-18), "2026-08-01 10:00:00");
});
test("buildTaskReportCsv writes related batch and screenshot submission for collect tasks", () => {
const csv = buildTaskReportCsv([
{
...reportRow,
task_type: "screenshot_collect",
related_task_id: "task-B",
related_task_name: "夏日种草计划",
submitted_count: 7,
claimed_count: 12,
},
]);
const cells = csv.slice(1).split("\r\n")[1].split(",");
assert.equal(cells.at(-2), "夏日种草计划");
assert.equal(cells.at(-1), "7");
});
test("getTaskReportData aggregates per task via distributions join", async () => {
let capturedSql = "";
const db = {
prepare(sql) {
capturedSql = sql;
return {
async all() {
return { results: [reportRow] };
},
};
},
};
const rows = await getTaskReportData(db);
assert.deepEqual(rows, [reportRow]);
assert.match(capturedSql, /FROM tasks t/);
assert.match(capturedSql, /LEFT JOIN distributions d ON d\.task_id = t\.id/);
assert.match(capturedSql, /LEFT JOIN projects p ON p\.id = t\.project_id/);
assert.match(capturedSql, /GROUP BY t\.id/);
assert.match(capturedSql, /publish_url/);
assert.match(capturedSql, /latest_shares/);
assert.match(capturedSql, /related_task_id/);
assert.match(capturedSql, /result_submitted_at/);
});

View File

@@ -539,6 +539,32 @@ test("listCustomerGroupChats paginates groupchat/list and keeps only normal grou
assert.equal(secondBody.cursor, "page-2"); assert.equal(secondBody.cursor, "page-2");
}); });
test("listCustomerGroupChats derives memberCount from member_list when member_count absent", async () => {
const { fetchImpl } = fakeGroupPush({
pages: [{ next_cursor: "", group_chat_list: [{ chat_id: "wr-normal-1", status: 0 }] }],
details: {
"wr-normal-1": {
name: "",
owner: "zhangsan",
member_list: [
{ type: 1, userid: "zhangsan" },
{ type: 1, userid: "lisi" },
{ type: 2, userid: "wm external 1" },
{ type: 2, userid: "wm external 2" },
{ type: 2, userid: "wm external 3" },
],
},
},
});
const groups = await listCustomerGroupChats(
resolveWecomConfig(baseBindings),
fetchImpl,
);
assert.equal(groups.length, 1);
assert.equal(groups[0].name, "");
assert.equal(groups[0].memberCount, 5, "member_count falls back to member_list length");
});
test("listCustomerGroupChats skips groups whose detail lookup fails", async () => { test("listCustomerGroupChats skips groups whose detail lookup fails", async () => {
const { fetchImpl } = fakeGroupPush({ const { fetchImpl } = fakeGroupPush({
pages: [ pages: [