From ab78b187c0169426100a056cf94aaa5d3bcc09bb Mon Sep 17 00:00:00 2001 From: ABAPPLO <1134226402@qq.com> Date: Wed, 23 Sep 2026 11:13:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A1=B9=E7=9B=AE=E5=B1=82=E7=BA=A7?= =?UTF-8?q?=E6=A0=91+=E5=90=88=E4=BD=9C=E6=96=B9=E7=BB=91=E5=AE=9AKOC?= =?UTF-8?q?=E8=B5=84=E6=BA=90+=E5=AE=A2=E6=88=B7=E7=BE=A4=E5=85=A8?= =?UTF-8?q?=E9=87=8F=E5=8F=AF=E9=80=89+=E7=B2=BE=E7=AE=80=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E8=A1=A8=E5=8D=95=EF=BC=8C=E7=A7=BB=E9=99=A4=E5=A4=8D?= =?UTF-8?q?=E7=9B=98=E6=8A=A5=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 项目支持父子层级:树形折叠展示、详情面板内联管理总群成员、推送聚合子项目任务、环检测与子项目删除守卫 - 外部KOC合作方绑定 KOC 资源账号(唯一占用、换绑/解绑、资源页一键设为合作方),列表直显联系方式 - 绑定客户群改为全量外部群缓存(含各群主),同步按钮强刷;member_count 缺失以 member_list 兜底 - 任务/项目编辑模式隐藏不可变字段(平台/内容类型/品牌/总群成员),详情面板维护成员时保留 parent_id - 删除复盘报表整栏(导航/页面/task_report_list/导出路由/CSV lib 及测试) --- .gitignore | 3 + app/admin-app.tsx | 985 +++++++++++++--------- app/api/action/route.ts | 139 ++- app/api/task-report-export/route.ts | 33 - app/globals.css | 170 +++- app/task-customer-chat-modal.tsx | 24 +- lib/mvp-db.ts | 108 +-- lib/project-service.ts | 142 +++- lib/task-customer-chat-service.ts | 16 - lib/task-report-csv.ts | 98 --- lib/wecom-client.ts | 15 +- mysql/0017_project_parent.sql | 3 + mysql/0018_partner_resource_binding.sql | 3 + tests/owner-permissions.test.mjs | 86 +- tests/owner-scope.test.mjs | 27 - tests/partner-resource-binding.test.mjs | 91 ++ tests/project-service.test.mjs | 142 +++- tests/task-customer-chat-service.test.mjs | 9 - tests/task-report.test.mjs | 192 ----- tests/wecom-client.test.mjs | 26 + 20 files changed, 1354 insertions(+), 958 deletions(-) delete mode 100644 app/api/task-report-export/route.ts delete mode 100644 lib/task-report-csv.ts create mode 100644 mysql/0017_project_parent.sql create mode 100644 mysql/0018_partner_resource_binding.sql create mode 100644 tests/partner-resource-binding.test.mjs delete mode 100644 tests/task-report.test.mjs diff --git a/.gitignore b/.gitignore index 7ad855f..7078f83 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,6 @@ next-env.d.ts /.wrangler/ /outputs/ /work/ + +# local runtime data (self-hosted uploads) +/.data/ diff --git a/app/admin-app.tsx b/app/admin-app.tsx index 7c6332b..c91f9ac 100644 --- a/app/admin-app.tsx +++ b/app/admin-app.tsx @@ -37,6 +37,11 @@ type Partner = { wecom_external_user_id?: string | null; partner_type?: string; wecom_userid?: string; + account_id?: string | null; + account_nickname?: string | null; + account_public_id?: string | null; + account_contact?: string | null; + account_profile_url?: string | null; }; type Task = { @@ -232,7 +237,6 @@ type NavKey = | "resources" | "partners" | "recovery" - | "reports" | "users"; const NAV_ITEMS: Array<{ key: NavKey; label: string; mark: string }> = [ @@ -241,7 +245,6 @@ const NAV_ITEMS: Array<{ key: NavKey; label: string; mark: string }> = [ { key: "resources", label: "KOC资源", mark: "◎" }, { key: "partners", label: "合作方", mark: "Ⓒ" }, { key: "recovery", label: "数据回收", mark: "◫" }, - { key: "reports", label: "复盘报表", mark: "▣" }, { key: "users", label: "用户管理", mark: "♙" }, ]; @@ -569,6 +572,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { const [metricForm, setMetricForm] = useState({ exposure: "", views: "" }); const [exportingTaskId, setExportingTaskId] = useState(null); const [exportingResources, setExportingResources] = useState(false); + const [creatingPartnerId, setCreatingPartnerId] = useState(null); const isManager = currentUser.role === "super_admin" || currentUser.role === "admin"; const isTaskOwnerView = currentUser.role === "user"; const visibleNavItems = NAV_ITEMS.filter((item) => { @@ -1083,6 +1087,39 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { setToast(message); }; + const partnerNameByAccountId = useMemo(() => { + const map = new Map(); + for (const partner of data.partners) { + if ((partner.partner_type || "external_koc") !== "group_owner" && partner.account_id) { + map.set(partner.account_id, partner.name); + } + } + return map; + }, [data.partners]); + + const createPartnerFromAccount = async (account: Account) => { + if (creatingPartnerId) return; + setCreatingPartnerId(account.id); + try { + const response = await fetch("/api/action", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "partner_create_from_account", + accountId: account.id, + }), + }); + const result = await readApiResponse<{ error?: string }>(response, "创建合作方失败"); + if (!response.ok) throw new Error(result.error || "创建合作方失败"); + setToast(`已创建合作方「${account.nickname || account.public_account_id || account.id}」`); + await loadData(); + } catch (reason) { + setToast(reason instanceof Error ? reason.message : "创建合作方失败"); + } finally { + setCreatingPartnerId(null); + } + }; + const uploadScreenshot = async ( distribution: Distribution, event: ChangeEvent, @@ -1155,10 +1192,6 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { title: "数据回收", subtitle: "按任务设置自动采集日,集中查看最新公开数据与创作者截图。", }, - reports: { - title: "复盘报表", - subtitle: "按任务汇总领取、发布与互动数据,支持一键导出 CSV。", - }, users: { title: "用户管理", subtitle: "由后台统一创建账号和重置密码,不开放自助注册与个人改密。", @@ -1347,11 +1380,15 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { exporting={exportingResources} onExport={exportResources} onImported={completeResourceImport} + partnerNameByAccountId={partnerNameByAccountId} + onCreatePartner={createPartnerFromAccount} + creatingPartnerId={creatingPartnerId} /> )} {activeNav === "partners" && isManager && ( )} - {activeNav === "reports" && ( - - )} {activeNav === "recovery" && ( {taskForm.taskType === "content_publish" ? ( <> + {!taskForm.id && (
+ )} {!taskForm.id && ( - + {!taskForm.id && ( + + )} @@ -2801,6 +3024,7 @@ type ProjectSummary = { mainGroupMemberIds: string[]; ownerUserid: string; mainChatId: string; + parentProjectId: string; taskCount: number; }; @@ -2818,12 +3042,22 @@ type ProjectTaskItem = { share_token: string | null; owner_userid: string; task_chat_id: string; + project_name?: string; }; type ProjectDetailData = { project: ProjectSummary; tasks: ProjectTaskItem[]; mainChat: { chatId: string; members: string[] }; + subprojects?: Array<{ + id: string; + name: string; + brand: string; + ownerUserid: string; + mainChatId: string; + taskCount: number; + }>; + pushTasks?: ProjectTaskItem[]; }; async function postAction(payload: Record, fallback: string) { @@ -2844,16 +3078,19 @@ type ProjectEditorDraft = { owner: string; members: string; note: string; + parentId: string; }; -const EMPTY_PROJECT_DRAFT: ProjectEditorDraft = { id: "", name: "", brand: "", owner: "", members: "", note: "" }; +const EMPTY_PROJECT_DRAFT: ProjectEditorDraft = { id: "", name: "", brand: "", owner: "", members: "", note: "", parentId: "" }; function ProjectSection({ project, - tasks, + depth, + childrenOf, + tasksOf, + canManageProject, detailVersion, portalUrl, - canManage, setToast, onProjectsChanged, onEditProject, @@ -2867,13 +3104,15 @@ function ProjectSection({ onBindCustomerChat, }: { project: ProjectSummary; - tasks: Task[]; + depth: number; + childrenOf: (projectId: string) => ProjectSummary[]; + tasksOf: (projectId: string) => Task[]; + canManageProject: (project: ProjectSummary) => boolean; detailVersion: number; portalUrl: string; - canManage: boolean; setToast: (message: string) => void; onProjectsChanged: () => Promise; - onEditProject: (project: ProjectSummary) => void; + onEditProject: (project?: ProjectSummary, parentId?: string) => void; onDeleteProject: (project: ProjectSummary) => void; onCreateTask: (projectId: string) => void; onOpenTaskDetail: (taskId: string) => void; @@ -2883,12 +3122,18 @@ function ProjectSection({ onOwnerLinks: (task: Task) => void; onBindCustomerChat: (task: Task) => void; }) { + const tasks = tasksOf(project.id); + const childProjects = childrenOf(project.id); + const canManage = canManageProject(project); + const [treeOpen, setTreeOpen] = useState(false); const [expanded, setExpanded] = useState(false); const [detail, setDetail] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [ownerPickerTaskId, setOwnerPickerTaskId] = useState(null); const [ownerSaving, setOwnerSaving] = useState(false); const [mainChatWorking, setMainChatWorking] = useState(false); + const [memberPickerOpen, setMemberPickerOpen] = useState(false); + const [memberSaving, setMemberSaving] = useState(false); const [pushProject, setPushProject] = useState(null); const loadDetail = useCallback(async () => { @@ -2971,6 +3216,39 @@ function ProjectSection({ } }; + const saveMembers = async (nextMembers: string[]) => { + if (!detail || memberSaving) return; + setMemberSaving(true); + try { + const parsed = await postAction<{ + mainChat?: { chatId: string | null; warning?: string }; + }>( + { + action: "project_save", + id: detail.project.id, + name: detail.project.name, + brand: detail.project.brand, + ownerUserid: detail.project.ownerUserid, + mainGroupMemberIds: nextMembers, + note: detail.project.note, + parentProjectId: detail.project.parentProjectId, + }, + "项目总群成员更新失败", + ); + setToast( + parsed.mainChat?.warning + ? `总群成员已更新;总群同步失败:${parsed.mainChat.warning}` + : "项目总群成员已更新", + ); + await loadDetail(); + await onProjectsChanged(); + } catch (reason) { + setToast(reason instanceof Error ? reason.message : "项目总群成员更新失败"); + } finally { + setMemberSaving(false); + } + }; + const mainChatMissingHint = (memberCount: number) => { if (memberCount < 3) { return `未建群:成员不足 3 人(当前 ${memberCount} 人),编辑项目补充成员后自动创建`; @@ -2978,17 +3256,38 @@ function ProjectSection({ return "未建群:点击「重试建群」尝试创建(需企微已配置且成员在应用可见范围内)"; }; + const hasTreeChildren = childProjects.length > 0 || tasks.length > 0; + const subtreeTaskCount = + tasks.length + + childProjects.reduce((total, child) => total + child.taskCount, 0); + return ( -
+
0 ? { marginLeft: `${Math.min(depth, 4) * 24}px` } : undefined} + >
+
{(project.brand || project.name).slice(0, 1)}

{project.name}

- {project.brand || "未填品牌"} · {project.taskCount} 个任务 · 总群 {project.mainGroupMemberIds.length} 人{project.mainChatId ? "(已建群)" : ""} + {childProjects.length > 0 ? `${childProjects.length} 子项目 · ` : ""} + {subtreeTaskCount > tasks.length + ? `${tasks.length}(含子项目共 ${subtreeTaskCount})任务 · ` + : `${tasks.length} 个任务 · `} + 总群 {project.mainGroupMemberIds.length} 人{project.mainChatId ? "(已建群)" : ""}
@@ -2998,31 +3297,63 @@ function ProjectSection({ {expanded ? "收起详情" : "查看详情"} )} + {canManage && } {canManage && } {canManage && }
{project.note &&

{project.note}

} - {tasks.length === 0 ? ( -

项目下还没有任务,展开详情点击「新建任务」即可挂入该项目。

- ) : ( -
- {tasks.map((task) => ( - onOpenTaskDetail(task.id)} - onEdit={onEditTask} - onCopyShare={onCopyShare} - onPushToGroups={onPushToGroups} - onOwnerLinks={onOwnerLinks} - onBindCustomerChat={onBindCustomerChat} - /> - ))} -
+ {treeOpen && ( + <> + {tasks.length === 0 && childProjects.length === 0 ? ( +

项目下还没有任务或子项目,点击「+ 子项目」或「查看详情」里的「新建任务」。

+ ) : ( + <> + {tasks.length > 0 && ( +
+ {tasks.map((task) => ( + onOpenTaskDetail(task.id)} + onEdit={onEditTask} + onCopyShare={onCopyShare} + onPushToGroups={onPushToGroups} + onOwnerLinks={onOwnerLinks} + onBindCustomerChat={onBindCustomerChat} + /> + ))} +
+ )} + {childProjects.map((child) => ( + + ))} + + )} + )} {expanded && ( @@ -3035,31 +3366,83 @@ function ProjectSection({

项目总群({detail.mainChat.members.length} 人)

- +
+ {canManage && ( + + )} + +
{detail.mainChat.chatId ? ( - <> -

- 已建群 {detail.mainChat.chatId},推送任务直接群发进群。 -

-
    - {detail.mainChat.members.map((userid) => ( -
  • {userid}
  • - ))} -
- +

+ 已建群 {detail.mainChat.chatId},推送任务直接群发进群。 +

) : (

{mainChatMissingHint(detail.mainChat.members.length)}

)} +
    + {detail.mainChat.members.map((userid) => ( +
  • + {userid} + {canManage && userid !== detail.project.ownerUserid && ( + + )} +
  • + ))} +
+ {(detail.subprojects ?? []).length > 0 && ( +
+
+

子项目({detail.subprojects!.length})

+
+ + + + + + {detail.subprojects!.map((sub) => ( + + + + + + + ))} + +
子项目负责人总群任务数
{sub.name} + {sub.ownerUserid ? ( + {sub.ownerUserid} + ) : ( + 未分配 + )} + {sub.mainChatId ? {sub.mainChatId} : "未建群"}{sub.taskCount}
+ + 子项目可展开左侧树形列表查看其任务;推送任务时子项目任务会聚合在本项目的推送弹层里。 + +
+ )}

@@ -3114,15 +3497,15 @@ function ProjectSection({
- {detail.tasks.length === 0 ? ( + {(detail.pushTasks ?? detail.tasks).length === 0 ? ( 项目下没有可推送的任务 ) : ( - 可在推送弹层中选择项目下的具体任务 + 可在推送弹层中选择本项目及子项目下的具体任务 )}
@@ -3145,6 +3528,18 @@ function ProjectSection({ /> )} + {memberPickerOpen && detail && ( + setMemberPickerOpen(false)} + onConfirm={(userids) => { + setMemberPickerOpen(false); + void saveMembers(userids); + }} + /> + )} + {pushProject && ( ; onChange: (draft: ProjectEditorDraft) => void; onClose: () => void; onSave: () => void; @@ -3199,12 +3596,20 @@ function ProjectEditorModal({ />

{canEditOwner && ( @@ -3228,24 +3633,32 @@ function ProjectEditorModal({ 任务群创建时自动把负责人拉进群;请确保该成员在自建应用可见范围内。 )} -