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

File diff suppressed because it is too large Load Diff

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();

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 {
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;
gap: 10px;
padding: 10px 12px;
@@ -1519,6 +1519,59 @@ a {
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 code {
width: 100%;
@@ -3176,6 +3229,46 @@ a {
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 {
display: flex;
min-height: 240px;
@@ -5351,6 +5444,36 @@ label small {
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 {
padding: 18px 20px;
}
@@ -5438,20 +5561,59 @@ label small {
font-size: 10px;
}
.project-detail-block-actions {
display: flex;
align-items: center;
gap: 6px;
}
.project-member-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.project-member-list code {
padding: 3px 8px;
border-radius: 6px;
.project-member-list li {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 6px 3px 4px;
border-radius: 7px;
background: #eef2ef;
}
.project-member-list code {
padding: 0 4px;
color: #556861;
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 {
color: #9aa39f;
font-size: 11px;

View File

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