From e2a93e22cfeb6840a2509dd106f16021524a1ae0 Mon Sep 17 00:00:00 2001 From: ABAPPLO <1134226402@qq.com> Date: Thu, 24 Sep 2026 15:52:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BB=BB=E5=8A=A1=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E7=89=A9=E7=90=86=E5=90=88=E5=B9=B6=EF=BC=8C=E5=8F=91=E5=B8=83?= =?UTF-8?q?+=E5=9B=9E=E6=94=B6=E5=B9=B6=E4=B8=BA=E5=8D=95=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E5=8F=8C=E9=98=B6=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tasks 新增 recovery_enabled/recovery_due_at/recovery_instructions/recovery_example_images,回收挂自己名下(先发布后交截图),不再置 complete - 存量 screenshot_collect 迁移并入父任务后删除(mysql/0020 含 DELETE,生产执行前务必备份) - 新 action task_enable_recovery:ghost 面板一键开通回收,截止默认=发布截止 - 企微催办双阶段(待发布/截图待提交);超期自动释放仅发布阶段 - admin 删 groupTaskMasters 聚合改单卡双面板;portal 详情页回收截图区块+批次回收进度 - 测试 309 全绿 + Playwright E2E 20/20 --- app/admin-app.tsx | 1411 ++++++++++------------- app/api/action/route.ts | 47 +- app/api/partner-image/route.ts | 13 +- app/api/partner-upload/route.ts | 15 +- app/api/partner/route.ts | 65 +- app/api/recovery-export/route.ts | 53 +- app/api/screenshot-task-export/route.ts | 10 +- app/globals.css | 570 ++++++++- db/schema.ts | 8 + koc-portal/app/globals.css | 13 + koc-portal/app/page.tsx | 530 +++++---- lib/distribution-release-service.ts | 4 - lib/mvp-db.ts | 12 +- lib/project-push-service.ts | 7 +- lib/project-service.ts | 153 +-- lib/task-service.ts | 444 +++---- lib/wecom-group-push-service.ts | 7 +- lib/wecom-notifier-service.ts | 109 +- mysql/0019_project_drop_parent.sql | 1 + mysql/0020_task_recovery_merge.sql | 125 ++ tests/owner-permissions.test.mjs | 11 +- tests/partner-wecom-claim.test.mjs | 2 +- tests/project-service.test.mjs | 143 +-- tests/rendered-html.test.mjs | 16 +- tests/task-pairing.test.mjs | 95 ++ tests/task-update.test.mjs | 429 +++---- tests/wecom-escalation.test.mjs | 108 +- tests/wecom-group-push.test.mjs | 9 +- tests/wecom-notifier.test.mjs | 4 +- 29 files changed, 2219 insertions(+), 2195 deletions(-) create mode 100644 mysql/0019_project_drop_parent.sql create mode 100644 mysql/0020_task_recovery_merge.sql create mode 100644 tests/task-pairing.test.mjs diff --git a/app/admin-app.tsx b/app/admin-app.tsx index c91f9ac..6b6cd87 100644 --- a/app/admin-app.tsx +++ b/app/admin-app.tsx @@ -52,7 +52,6 @@ type Task = { claimed_quantity: number; due_at: string; status: string; - task_type: "content_publish" | "screenshot_collect"; platform: "小红书" | "抖音"; content_format: "image_text" | "video"; project_id?: string; @@ -64,14 +63,9 @@ type Task = { join_qr_code?: string; keyword?: string; instructions?: string; - related_task_id?: string; - related_task_name?: string | null; - related_dist_total?: number; - related_dist_complete?: number; - reverse_related_task_id?: string | null; - reverse_related_task_name?: string | null; - reverse_dist_total?: number; - reverse_dist_submitted?: number; + recovery_enabled?: number; + recovery_due_at?: string; + recovery_instructions?: string | null; dist_total?: number; dist_complete?: number; dist_submitted?: number; @@ -185,15 +179,12 @@ type Distribution = { last_collection_day: number | null; updated_at: string; content_title: string; - related_content_id?: string; - related_content_title?: string | null; partner_name: string; claimant_name: string | null; account_nickname: string | null; account_platform: string | null; task_name: string; task_brand: string; - task_type: "content_publish" | "screenshot_collect"; task_platform: "小红书" | "抖音"; content_format: "image_text" | "video"; due_at: string; @@ -336,6 +327,13 @@ function statusLabel(status: string) { return labels[status] || status; } +function dueBadgeState(dueAt: string) { + const time = new Date(dueAt).getTime(); + if (Number.isNaN(time)) return ""; + if (time < Date.now()) return " overdue"; + return time - Date.now() <= 3 * 24 * 60 * 60 * 1000 ? " soon" : ""; +} + function latestPublicMetrics(distribution: Distribution) { const legacyDay = distribution.d7_likes !== null @@ -550,19 +548,17 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { const [metricModal, setMetricModal] = useState(null); const [taskForm, setTaskForm] = useState({ id: "", - taskType: "content_publish" as "content_publish" | "screenshot_collect", platform: "小红书" as "小红书" | "抖音", contentFormat: "image_text" as "image_text" | "video", name: "", brand: "", dueAt: "2026-08-12", feishuUrl: "", - relatedTaskId: "", - keyword: "", - instructions: "在小红书搜索指定关键词,打开 AI 总结并上传完整截图。", - quantity: 1, projectId: "", ownerUserid: "", + recoveryEnabled: true, + recoveryDueAt: "", + recoveryInstructions: "", }); const [taskOwnerPickerOpen, setTaskOwnerPickerOpen] = useState(false); const [taskExampleImage, setTaskExampleImage] = useState(null); @@ -663,7 +659,6 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { const stats = useMemo(() => { const published = data.distributions.filter((item) => - item.task_type !== "screenshot_collect" && ["published", "collecting", "complete"].includes(item.status), ).length; const completed = data.distributions.filter( @@ -675,20 +670,19 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { (latestPublicMetrics(item).likes === null || !hasCreatorMetrics(item)), ).length; - const screenshotPending = data.distributions.filter( - (item) => - item.task_type === "screenshot_collect" && !item.result_submitted_at, - ).length; - const pairedTotals = data.tasks.reduce( + const recoveryTotals = data.tasks.reduce( (acc, task) => { - if (!task.reverse_related_task_id) return acc; + if (Number(task.recovery_enabled ?? 1) !== 1) return acc; return { - submitted: acc.submitted + (task.reverse_dist_submitted ?? 0), - total: acc.total + (task.reverse_dist_total ?? 0), + submitted: acc.submitted + (task.dist_submitted ?? 0), + total: acc.total + (task.dist_total ?? 0), }; }, { submitted: 0, total: 0 }, ); + const screenshotPending = data.distributions.filter( + (item) => item.publish_url && !item.result_submitted_at, + ).length; return { resources: data.accounts.length, activeTasks: data.tasks.filter((task) => task.status === "active").length, @@ -698,8 +692,8 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { completedTasks: data.tasks.filter((task) => task.status === "completed") .length, screenshotPending, - pairedSubmitted: pairedTotals.submitted, - pairedTotal: pairedTotals.total, + pairedSubmitted: recoveryTotals.submitted, + pairedTotal: recoveryTotals.total, }; }, [data]); @@ -722,21 +716,20 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { setToast("请选择所属项目"); return; } - if (!editing && taskForm.taskType === "content_publish" && !sourcePreview) { + if (!editing && !sourcePreview) { setToast("请先读取飞书表格"); return; } if ( !editing && - taskForm.taskType === "content_publish" && taskForm.contentFormat === "video" && (sourcePreview?.videoCount ?? 0) === 0 ) { setToast("当前飞书表格没有识别到视频附件"); return; } - let exampleImageKey = ""; - if (taskForm.taskType === "screenshot_collect" && taskExampleImage) { + let recoveryExampleImageKey = ""; + if (taskForm.recoveryEnabled && taskExampleImage) { try { setWorking(true); const form = new FormData(); @@ -752,63 +745,41 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { if (!uploadResponse.ok || !uploadResult.key) { throw new Error(uploadResult.error || "示例截图上传失败"); } - exampleImageKey = uploadResult.key; + recoveryExampleImageKey = uploadResult.key; } catch (reason) { setToast(reason instanceof Error ? reason.message : "示例截图上传失败"); setWorking(false); return; } } - const sharedFields = { + const fields = { name: taskForm.name, brand: taskForm.brand, dueAt: taskForm.dueAt, projectId: taskForm.projectId, ownerUserid: taskForm.ownerUserid, - }; - const screenshotFields = { - ...sharedFields, - keyword: taskForm.keyword, - instructions: taskForm.instructions, - quantity: taskForm.quantity, - exampleImageKey, - }; - const relatedSourceTask = data.tasks.find( - (task) => task.id === taskForm.relatedTaskId, - ); - const createScreenshotFields = { - name: taskForm.name, - relatedTaskId: taskForm.relatedTaskId, - dueAt: taskForm.dueAt, - instructions: taskForm.instructions, - exampleImageKey, - projectId: taskForm.projectId, - ownerUserid: taskForm.ownerUserid, - }; - const publishFields = { - ...sharedFields, platform: taskForm.platform, contentFormat: taskForm.contentFormat, feishuUrl: taskForm.feishuUrl, + recoveryEnabled: taskForm.recoveryEnabled, + recoveryDueAt: taskForm.recoveryEnabled + ? taskForm.recoveryDueAt || taskForm.dueAt + : "", + recoveryInstructions: taskForm.recoveryInstructions, + ...(recoveryExampleImageKey ? { recoveryExampleImageKey } : {}), }; const success = editing ? await runAction( { action: "task_update", taskId: taskForm.id, - ...(taskForm.taskType === "screenshot_collect" - ? screenshotFields - : publishFields), + ...fields, }, "任务已更新", ) : await runAction( - taskForm.taskType === "screenshot_collect" - ? { action: "create_screenshot_task", ...createScreenshotFields } - : { action: "create_task", ...publishFields }, - taskForm.taskType === "screenshot_collect" - ? `截图回收任务已创建,${relatedSourceTask?.quantity ?? 0} 份任务(与「${relatedSourceTask?.name ?? "分发任务"}」的笔记一一对应)进入可领取池` - : `任务已创建,${sourcePreview?.rowCount ?? 0} 篇内容进入可领取池`, + { action: "create_task", ...fields }, + `任务已创建,${sourcePreview?.rowCount ?? 0} 篇内容进入可领取池`, ); if (success) { setTaskModalOpen(false); @@ -816,19 +787,17 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { setTaskExampleImage(null); setTaskForm({ id: "", - taskType: "content_publish", platform: "小红书", contentFormat: "image_text", name: "", brand: "", dueAt: "2026-08-12", feishuUrl: "", - relatedTaskId: "", - keyword: "", - instructions: "在小红书搜索指定关键词,打开 AI 总结并上传完整截图。", - quantity: 1, projectId: "", ownerUserid: "", + recoveryEnabled: true, + recoveryDueAt: "", + recoveryInstructions: "", }); } }; @@ -1341,19 +1310,17 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { setTaskExampleImage(null); setTaskForm({ id: task.id, - taskType: task.task_type, platform: task.platform, contentFormat: task.content_format, name: task.name, brand: task.brand, dueAt: task.due_at, feishuUrl: "", - relatedTaskId: task.related_task_id ?? "", - keyword: task.keyword ?? "", - instructions: task.instructions ?? "", - quantity: task.quantity, projectId: task.project_id ?? "", ownerUserid: task.owner_userid ?? "", + recoveryEnabled: Number(task.recovery_enabled ?? 1) === 1, + recoveryDueAt: task.recovery_due_at || task.due_at, + recoveryInstructions: task.recovery_instructions ?? "", }); setTaskModalOpen(true); }} @@ -1361,6 +1328,19 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { onPushToGroups={(task) => setGroupPushTask(task)} onOwnerLinks={(task) => setOwnerLinksTask(task)} onBindCustomerChat={(task) => setCustomerChatTask(task)} + onCreateRecovery={async (task) => { + await runAction( + { + action: "task_enable_recovery", + taskId: task.id, + recoveryDueAt: task.recovery_due_at || task.due_at, + recoveryInstructions: + task.recovery_instructions || "请提交发布后的笔记截图", + recoveryExampleImageKey: "", + }, + "回收已开通", + ); + }} onRelease={async (distribution) => { const result = await runAction( { @@ -1434,32 +1414,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { -
- - -
- {taskForm.taskType === "content_publish" ? ( - <> - {!taskForm.id && ( + {!taskForm.id && (
)} - - ) : ( -
- {!taskForm.id ? ( - <> - - {(() => { - const related = data.tasks.find( - (task) => task.id === taskForm.relatedTaskId, - ); - return related ? ( -

- 将为「{related.name}」生成 {related.quantity} 份回收任务 -

- ) : null; - })()} - - ) : taskForm.relatedTaskId ? ( -
- - -
- ) : ( -
- - -
- )} -