From a6f5a5fd4a3c3caab8cbf663bbeabf1066277dd3 Mon Sep 17 00:00:00 2001 From: ABAPPLO <1134226402@qq.com> Date: Mon, 31 Aug 2026 16:35:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=E2=80=94=E2=80=94=E6=8C=89=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E7=BB=84=E7=BB=87=E4=BB=BB=E5=8A=A1/=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E7=BE=A4/=E6=80=BB=E7=BE=A4=E5=B9=B6=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E6=8E=A8=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新表 projects / project_group_chats,partners 加 partner_type/wecom_userid,tasks 加 project_id(迁移 0010) - 项目 CRUD、总群成员配置、任务群人工映射(群主→合作方)、任务挂项目 - 推送:总群成员逐人应用消息 + 任务群按群主企业群发,结果分项回显 --- app/admin-app.tsx | 985 ++++++++++++++++++++++- app/api/action/route.ts | 69 +- app/globals.css | 258 ++++++ db/schema.ts | 30 + dev.vars_exp | 2 +- lib/project-push-service.ts | 160 ++++ lib/project-service.ts | 385 +++++++++ lib/task-service.ts | 14 +- mysql/0010_projects_and_partner_type.sql | 27 + tests/project-push.test.mjs | 264 ++++++ tests/project-service.test.mjs | 378 +++++++++ 11 files changed, 2557 insertions(+), 15 deletions(-) create mode 100644 lib/project-push-service.ts create mode 100644 lib/project-service.ts create mode 100644 mysql/0010_projects_and_partner_type.sql create mode 100644 tests/project-push.test.mjs create mode 100644 tests/project-service.test.mjs diff --git a/app/admin-app.tsx b/app/admin-app.tsx index aa0cbf4..28afe3c 100644 --- a/app/admin-app.tsx +++ b/app/admin-app.tsx @@ -30,6 +30,8 @@ type Partner = { claimed_total: number; completed_total: number; wecom_external_user_id?: string | null; + partner_type?: string; + wecom_userid?: string; }; type Task = { @@ -43,6 +45,7 @@ type Task = { task_type: "content_publish" | "screenshot_collect"; platform: "小红书" | "抖音"; content_format: "image_text" | "video"; + project_id?: string; source_url: string; source_sheet_id: string; source_sheet_name: string; @@ -199,6 +202,7 @@ type FeishuPreview = { type NavKey = | "overview" + | "projects" | "tasks" | "distributions" | "resources" @@ -208,6 +212,7 @@ type NavKey = const NAV_ITEMS: Array<{ key: NavKey; label: string; mark: string }> = [ { key: "overview", label: "工作台", mark: "⌂" }, + { key: "projects", label: "项目中心", mark: "▦" }, { key: "tasks", label: "任务中心", mark: "▤" }, { key: "distributions", label: "任务分发", mark: "↗" }, { key: "resources", label: "KOC资源", mark: "◎" }, @@ -540,8 +545,10 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { keyword: "", instructions: "在小红书搜索指定关键词,打开 AI 总结并上传完整截图。", quantity: 1, + projectId: "", }); const [taskExampleImage, setTaskExampleImage] = useState(null); + const [taskFormProjects, setTaskFormProjects] = useState>([]); const [sourcePreview, setSourcePreview] = useState(null); const [sourceWorking, setSourceWorking] = useState(false); const [metricForm, setMetricForm] = useState({ exposure: "", views: "" }); @@ -551,6 +558,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { const visibleNavItems = NAV_ITEMS.filter((item) => { if (item.key === "resources" && currentUser.role === "user") return false; if (item.key === "users" && !isManager) return false; + if (item.key === "projects" && !isManager) return false; return true; }); @@ -581,6 +589,29 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { return () => window.clearTimeout(timer); }, [toast]); + useEffect(() => { + if (!taskModalOpen) return; + const timer = window.setTimeout(() => { + void (async () => { + try { + const response = await fetch("/api/action", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "project_list" }), + }); + const result = await readApiResponse< + { projects?: Array<{ id: string; name: string }> } & { error?: string } + >(response, "读取项目列表失败"); + if (!response.ok) throw new Error(result.error || "读取项目列表失败"); + setTaskFormProjects(result.projects ?? []); + } catch { + setTaskFormProjects([]); + } + })(); + }, 0); + return () => window.clearTimeout(timer); + }, [taskModalOpen]); + const runAction = async ( payload: Record, successMessage: string, @@ -635,6 +666,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { const navigate = (key: NavKey) => { if (key === "resources" && currentUser.role === "user") return; if (key === "users" && !isManager) return; + if (key === "projects" && !isManager) return; setActiveNav(key); setMenuOpen(false); }; @@ -688,6 +720,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { instructions: taskForm.instructions, quantity: taskForm.quantity, exampleImageKey, + projectId: taskForm.projectId, } : { action: "create_task", ...taskForm }, taskForm.taskType === "screenshot_collect" @@ -709,6 +742,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { keyword: "", instructions: "在小红书搜索指定关键词,打开 AI 总结并上传完整截图。", quantity: 1, + projectId: "", }); } }; @@ -992,6 +1026,10 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { title: "分发工作台", subtitle: "用最少动作,跑完内容领取、发布与数据回收。", }, + projects: { + title: "项目中心", + subtitle: "按项目组织任务群与总群成员,一键把任务分发到项目各群。", + }, tasks: { title: "内容任务", subtitle: "从飞书同步内容,按数量开放给合作方领取。", @@ -1128,6 +1166,14 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { showResources={currentUser.role !== "user"} /> )} + {activeNav === "projects" && isManager && ( + setTaskModalOpen(true)} + /> + )} {activeNav === "tasks" && ( +
+
+
+ {([ + { key: "all", label: `全部(${partners.length})` }, + { key: "group_owner", label: `群主(${partners.filter((partner) => partner.partner_type === "group_owner").length})` }, + { key: "external_koc", label: `外部KOC(${partners.filter((partner) => (partner.partner_type || "external_koc") === "external_koc").length})` }, + ] as const).map((tab) => ( + + ))}
合作方 - 企业微信名 + 企业微信名 / userid 负责人 外部联系人 ID 操作
- {partners.length === 0 && ( -
暂无合作方,先在飞书表格或领取流程中产生
+ {filteredPartners.length === 0 && ( +
+ {partners.length === 0 + ? "暂无合作方:点击右上角新建群主合作方,或等待 KOC 领取任务后自动生成外部KOC记录" + : "当前筛选下没有合作方"} +
)} - {partners.map((partner) => { + {filteredPartners.map((partner) => { const editing = editingId === partner.id; const busy = partnerWorkingId === partner.id; + const isGroupOwner = partner.partner_type === "group_owner"; return (
- {partner.name} - {partner.wecom_name || "—"} + + {partner.name} + + {isGroupOwner ? "群主" : "外部KOC"} + + + {isGroupOwner ? {partner.wecom_userid || "—"} : partner.wecom_name || "—"} {partner.owner || "—"} - {editing ? ( + {isGroupOwner ? ( + — + ) : editing ? ( - {editing ? ( + {isGroupOwner ? ( + 用于项目任务群分发 + ) : editing ? ( <> +
+
+ + +
+

+ userid 在企业微信管理后台「通讯录」成员详情中查看。群主合作方是内部员工, + 用于在项目中心把任务群映射到负责人。 +

+
+ + +
+
+ + )} {pickerPartner && (
event.stopPropagation()}> @@ -2257,6 +2438,792 @@ function GroupPushModal({ ); } +type ProjectSummary = { + id: string; + name: string; + brand: string; + status: string; + note: string; + createdAt: string; + mainGroupMemberIds: string[]; + taskCount: number; + groupChatCount: number; +}; + +type ProjectTaskItem = { + id: string; + name: string; + brand: string; + quantity: number; + claimed_quantity: number; + due_at: string; + status: string; + task_type: "content_publish" | "screenshot_collect"; + platform: string; + content_format: string; + share_token: string | null; +}; + +type ProjectGroupChatItem = { + chatId: string; + name: string; + ownerUserId: string; + memberCount: number; + partnerId: string; + partnerName: string; + missing: boolean; +}; + +type ProjectDetailData = { + project: ProjectSummary; + tasks: ProjectTaskItem[]; + groupChats: ProjectGroupChatItem[]; +}; + +type GroupOwnerOption = { + id: string; + name: string; + wecom_userid: string; +}; + +function ProjectsPage({ + portalUrl, + setToast, + onTasksChanged, + onCreateTask, +}: { + portalUrl: string; + setToast: (message: string) => void; + onTasksChanged: () => Promise; + onCreateTask: () => void; +}) { + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + const [expandedId, setExpandedId] = useState(null); + const [detail, setDetail] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [editorOpen, setEditorOpen] = useState(false); + const [editorSaving, setEditorSaving] = useState(false); + const [editorDraft, setEditorDraft] = useState({ + id: "", + name: "", + brand: "", + members: "", + note: "", + }); + const [chatManaging, setChatManaging] = useState(false); + const [chatSaving, setChatSaving] = useState(false); + const [chatLoading, setChatLoading] = useState(false); + const [chatGroups, setChatGroups] = useState([]); + const [chatOwners, setChatOwners] = useState([]); + const [chatDraft, setChatDraft] = useState>([]); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteWorking, setDeleteWorking] = useState(false); + const [pushProject, setPushProject] = useState(null); + + const postAction = useCallback(async (payload: Record, fallback: string) => { + const response = await fetch("/api/action", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const parsed = await readApiResponse(response, fallback); + if (!response.ok) throw new Error(parsed.error || fallback); + return parsed; + }, []); + + const loadProjects = useCallback(async () => { + try { + const parsed = await postAction<{ projects?: ProjectSummary[] }>( + { action: "project_list" }, + "读取项目列表失败", + ); + setProjects(parsed.projects ?? []); + } catch (reason) { + setToast(reason instanceof Error ? reason.message : "读取项目列表失败"); + } finally { + setLoading(false); + } + }, [postAction, setToast]); + + useEffect(() => { + const timer = window.setTimeout(() => { + void loadProjects(); + }, 0); + return () => window.clearTimeout(timer); + }, [loadProjects]); + + const refreshDetail = useCallback( + async (projectId: string) => { + try { + const parsed = await postAction( + { action: "project_detail", projectId }, + "读取项目详情失败", + ); + setDetail(parsed); + } catch (reason) { + setToast(reason instanceof Error ? reason.message : "读取项目详情失败"); + } + }, + [postAction, setToast], + ); + + const toggleDetail = async (project: ProjectSummary) => { + if (expandedId === project.id) { + setExpandedId(null); + setDetail(null); + return; + } + setExpandedId(project.id); + setDetail(null); + setDetailLoading(true); + try { + await refreshDetail(project.id); + } finally { + setDetailLoading(false); + } + }; + + const openEditor = (project?: ProjectSummary) => { + setEditorDraft({ + id: project?.id ?? "", + name: project?.name ?? "", + brand: project?.brand ?? "", + members: (project?.mainGroupMemberIds ?? []).join("\n"), + note: project?.note ?? "", + }); + setEditorOpen(true); + }; + + const submitEditor = async () => { + if (!editorDraft.name.trim()) { + setToast("请填写项目名称"); + return; + } + setEditorSaving(true); + try { + await postAction( + { + action: "project_save", + id: editorDraft.id, + name: editorDraft.name, + brand: editorDraft.brand, + mainGroupMemberIds: editorDraft.members, + note: editorDraft.note, + }, + "保存项目失败", + ); + setEditorOpen(false); + setToast(editorDraft.id ? "项目已更新" : "项目已创建"); + await loadProjects(); + if (editorDraft.id && expandedId === editorDraft.id) { + await refreshDetail(editorDraft.id); + } + } catch (reason) { + setToast(reason instanceof Error ? reason.message : "保存项目失败"); + } finally { + setEditorSaving(false); + } + }; + + const confirmDelete = async () => { + if (!deleteTarget) return; + setDeleteWorking(true); + try { + await postAction( + { action: "project_delete", projectId: deleteTarget.id }, + "删除项目失败", + ); + setToast(`项目「${deleteTarget.name}」已删除,任务已解除关联`); + setDeleteTarget(null); + setExpandedId(null); + setDetail(null); + await loadProjects(); + await onTasksChanged(); + } catch (reason) { + setToast(reason instanceof Error ? reason.message : "删除项目失败"); + } finally { + setDeleteWorking(false); + } + }; + + const openChatManager = async () => { + if (!detail) return; + setChatManaging(true); + setChatLoading(true); + setChatDraft( + detail.groupChats + .filter((chat) => !chat.missing || chat.partnerId) + .map((chat) => ({ chatId: chat.chatId, partnerId: chat.partnerId })), + ); + try { + const [groupResult, ownerResult] = await Promise.all([ + postAction<{ groups?: WecomGroupChatRow[] }>( + { action: "wecom_group_chats" }, + "读取客户群列表失败", + ), + postAction<{ partners?: GroupOwnerOption[] }>( + { action: "partner_group_owners" }, + "读取群主合作方失败", + ), + ]); + setChatGroups(groupResult.groups ?? []); + setChatOwners(ownerResult.partners ?? []); + } catch (reason) { + setToast(reason instanceof Error ? reason.message : "读取群列表失败"); + } finally { + setChatLoading(false); + } + }; + + const toggleChat = (chatId: string) => { + setChatDraft((previous) => + previous.some((item) => item.chatId === chatId) + ? previous.filter((item) => item.chatId !== chatId) + : [...previous, { chatId, partnerId: "" }], + ); + }; + + const setChatPartner = (chatId: string, partnerId: string) => { + setChatDraft((previous) => + previous.map((item) => (item.chatId === chatId ? { ...item, partnerId } : item)), + ); + }; + + const saveChatManager = async () => { + if (!detail) return; + setChatSaving(true); + try { + const parsed = await postAction( + { + action: "project_set_group_chats", + projectId: detail.project.id, + items: chatDraft, + }, + "保存任务群关联失败", + ); + setDetail(parsed); + setChatManaging(false); + setToast("项目任务群已更新"); + await loadProjects(); + } catch (reason) { + setToast(reason instanceof Error ? reason.message : "保存任务群关联失败"); + } finally { + setChatSaving(false); + } + }; + + return ( +
+
+
+

项目 = 任务 + 任务群 + 总群

+

按项目组织分发链路

+

项目总群成员收到应用消息后,各自把任务群发到自己负责的客户群。

+
+
+ + +
+
+ + {loading ? ( +
+
…
+

正在读取项目

+
+ ) : projects.length === 0 ? ( +
+
▦
+

还没有项目

+

新建项目后,把任务挂到项目下,即可一次推送到项目总群与各任务群。

+ +
+ ) : ( + projects.map((project) => ( +
+
+
+
+ {(project.brand || project.name).slice(0, 1)} +
+
+

{project.name}

+ + {project.brand || "未填品牌"} · {project.taskCount} 个任务 · {project.groupChatCount} 个任务群 · 总群 {project.mainGroupMemberIds.length} 人 + +
+
+
+ + + +
+
+ {project.note &&

{project.note}

} + {expandedId === project.id && ( +
+ {detailLoading || !detail || detail.project.id !== project.id ? ( +
正在读取项目详情…
+ ) : ( + <> +
+
+
+

项目总群成员({detail.project.mainGroupMemberIds.length})

+ 推送任务时逐人发送应用消息 +
+ {detail.project.mainGroupMemberIds.length === 0 ? ( +

未配置,编辑项目可填写成员 userid

+ ) : ( +
    + {detail.project.mainGroupMemberIds.map((userid) => ( +
  • {userid}
  • + ))} +
+ )} +
+
+
+

任务群({detail.groupChats.length})

+ +
+ {detail.groupChats.length === 0 ? ( +

未关联任务群,点击「管理关联」从客户群中选择

+ ) : ( + + + + + + {detail.groupChats.map((chat) => ( + + + + + + + ))} + +
群名称群主 userid人数对应合作方
{chat.missing ? `${chat.name || chat.chatId}(群不存在)` : chat.name || "(未命名群)"}{chat.ownerUserId || "—"}{chat.missing ? "—" : chat.memberCount}{chat.partnerId ? chat.partnerName || "(已删除)" : 未映射}
+ )} +
+
+
+
+

项目任务({detail.tasks.length})

+ 在「任务中心」创建任务时选择所属项目 +
+ {detail.tasks.length === 0 ? ( +

项目下还没有任务

+ ) : ( + + + + + + {detail.tasks.map((task) => ( + + + + + + + + ))} + +
任务名称数量截止状态
{task.name}{task.claimed_quantity}/{task.quantity}{formatDate(task.due_at)}{statusLabel(task.status)} + +
+ )} +
+
+ + {detail.tasks.length === 0 && 项目下没有可推送的任务} +
+ + )} +
+ )} +
+ )) + )} + + {editorOpen && ( +
setEditorOpen(false)}> +
event.stopPropagation()}> +
+
+

项目中心

+

{editorDraft.id ? "编辑项目" : "新建项目"}

+
+ +
+
+ + +
+