From 87f4d4066d18315adb86322b06f9fb6c45d0c5b9 Mon Sep 17 00:00:00 2001 From: ABAPPLO <1134226402@qq.com> Date: Mon, 12 Oct 2026 11:11:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20KOC=E8=AF=A6=E6=83=85=E5=BC=B9=E7=AA=97?= =?UTF-8?q?=20+=20=E4=BB=BB=E5=8A=A1=E4=B8=AD=E5=BF=83=E5=8D=95=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E6=94=B9=E7=89=88=20+=20=E8=B5=84=E6=BA=90=E6=B1=A0?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E5=90=88=E4=BD=9C=E6=96=B9/=E9=87=87?= =?UTF-8?q?=E9=9B=86=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KOC 详情弹窗(合作方/数据回收/工作台三处入口): - 新增 KocDetailModal:基本信息 / 绑定KOC账号画像 / 参与任务记录 / 履约漏斗 - 工作台最近分发、回收队列账号名、截图回收合作方名、合作方页名称、分发详情头部合作方均可点开 - 分发表行点击仍打开分发详情(stopPropagation 隔离) 任务中心单入口改版(对齐"单任务双阶段"模型): - 任务卡发布/回收面板整块可点,全卡仅保留一个「查看明细」,回收面板重复按钮移除 - 详情页自述身份:动态 label(任务详情·分发与回收一体/仅分发)+ 阶段徽章(含回收·截止/未开通回收) - 内容分发表新增阶段筛选 chips(全部/待发布/已发布待数据/待收截图,未开通回收不显示截图项),筛选落空给专用空态文案 - 数据回收页入口文案改为「选择一个任务进入回收队列」,全站不再区分发布/回收两种任务类型 资源池 KOC 默认加入合作方 + 采集兜底: - partners 新增 released_account_id(mysql/0023):显式解绑/被顶替的账号不再被默认合作方同步回填 - 任务未设置采集计划时,默认按发布时间起连续采集 7 天,等待文案显示「等待D1 · 默认7天」 --- app/admin-app.tsx | 477 ++++++++++++++++++++++-- app/api/action/route.ts | 117 ++++-- app/api/bootstrap/route.ts | 8 + app/api/partner-batch-workbook/route.ts | 72 ++-- app/api/partner/route.ts | 69 ++-- app/api/resources-import/route.ts | 5 + app/api/resources-insert/route.ts | 5 + app/globals.css | 144 +++++++ lib/collection-service.ts | 161 +++++--- lib/mcp-tools.ts | 2 +- lib/mvp-db.ts | 5 + lib/resource-write.ts | 130 ++++++- mysql/0023_partner_released_account.sql | 5 + tests/collection-schedule.test.mjs | 181 ++++++++- tests/koc-detail.test.mjs | 90 +++++ tests/partner-resource-binding.test.mjs | 6 +- tests/pool-partner-default.test.mjs | 126 +++++++ tests/rendered-html.test.mjs | 4 +- tests/task-center-stage.test.mjs | 57 +++ tests/ui-walkthrough-fixes.test.mjs | 3 +- 20 files changed, 1472 insertions(+), 195 deletions(-) create mode 100644 mysql/0023_partner_released_account.sql create mode 100644 tests/koc-detail.test.mjs create mode 100644 tests/pool-partner-default.test.mjs create mode 100644 tests/task-center-stage.test.mjs diff --git a/app/admin-app.tsx b/app/admin-app.tsx index 0d27504..3f6a657 100644 --- a/app/admin-app.tsx +++ b/app/admin-app.tsx @@ -194,6 +194,12 @@ type Distribution = { task_collection_start_date: string | null; }; +type StageFilterKey = + | "all" + | "pending_publish" + | "awaiting_metrics" + | "awaiting_screenshot"; + type RecoverySortKey = | "publish_time" | "likes" @@ -448,6 +454,9 @@ function parseCollectionDays(value: string | null | undefined) { } } +// 与服务端 DEFAULT_COLLECTION_DAYS 保持一致:未设置采集日时默认连采 7 天。 +const DEFAULT_COLLECTION_DAYS = [1, 2, 3, 4, 5, 6, 7]; + function todayInputValue() { const parts = new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", @@ -551,6 +560,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { const [customerChatTask, setCustomerChatTask] = useState(null); const [metricModal, setMetricModal] = useState(null); const [detailDistributionId, setDetailDistributionId] = useState(null); + const [detailPartnerId, setDetailPartnerId] = useState(null); const [publishUrlModal, setPublishUrlModal] = useState<{ distribution: Distribution; value: string; @@ -1335,6 +1345,8 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { tasks={data.tasks} onNavigate={navigate} showResources={currentUser.role !== "user"} + onOpenDetail={(distribution) => setDetailDistributionId(distribution.id)} + onOpenPartner={(partnerId) => setDetailPartnerId(partnerId)} /> )} {activeNav === "tasks" && ( @@ -1401,6 +1413,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { return result !== false; }} onOpenDetail={(distribution) => setDetailDistributionId(distribution.id)} + onOpenPartner={(partnerId) => setDetailPartnerId(partnerId)} /> )} {activeNav === "resources" && ( @@ -1422,6 +1435,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { working={working} onRefresh={loadData} setToast={setToast} + onOpenPartner={(partner) => setDetailPartnerId(partner.id)} /> )} {activeNav === "users" && isManager && ( @@ -1446,6 +1460,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { onExportScreenshots={exportScreenshotResults} exportingTaskId={exportingTaskId} onOpenDetail={(distribution) => setDetailDistributionId(distribution.id)} + onOpenPartner={(partnerId) => setDetailPartnerId(partnerId)} /> )} @@ -1909,6 +1924,10 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { canUpdatePublishUrl={isManager} canRelease={isManager} onClose={() => setDetailDistributionId(null)} + onOpenPartner={(partnerId) => { + setDetailDistributionId(null); + setDetailPartnerId(partnerId); + }} onCollect={collectMetrics} onFallback={openMetricFallback} onUpdatePublishUrl={(distribution) => @@ -1938,6 +1957,27 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) { /> ); })()} + {(() => { + // KOC 详情按 id 实时取:操作刷新数据后弹窗内容自动更新 + const partnerRow = detailPartnerId + ? data.partners.find((partner) => partner.id === detailPartnerId) + : null; + if (!partnerRow) return null; + const boundAccount = partnerRow.account_id + ? (data.accounts.find((account) => account.id === partnerRow.account_id) ?? null) + : null; + const partnerHistory = data.distributions + .filter((item) => item.partner_id === partnerRow.id) + .sort((a, b) => (a.claimed_at < b.claimed_at ? 1 : -1)); + return ( + setDetailPartnerId(null)} + /> + ); + })()} {toast &&
✓{toast}
} {working &&
}
@@ -1988,12 +2028,14 @@ function PartnersPage({ working, onRefresh, setToast, + onOpenPartner, }: { partners: Partner[]; accounts: Account[]; working: boolean; onRefresh: () => Promise; setToast: (message: string) => void; + onOpenPartner: (partner: Partner) => void; }) { const [status, setStatus] = useState(null); const [statusLoading, setStatusLoading] = useState(true); @@ -2328,6 +2370,25 @@ function PartnersPage({ return map; }, [partners]); + // 资源池默认同步生成的空壳合作方:无企微绑定、无领取/完成记录, + // 换绑时服务端会自动顶替,选择器里这些资源账号保持可选。 + const defaultShellAccountIds = useMemo(() => { + const ids = new Set(); + for (const partner of partners) { + if ( + (partner.partner_type || "external_koc") === "external_koc" && + partner.account_id && + !(partner.wecom_external_user_id ?? "").trim() && + !(partner.wecom_userid ?? "").trim() && + Number(partner.claimed_total ?? 0) === 0 && + Number(partner.completed_total ?? 0) === 0 + ) { + ids.add(partner.account_id); + } + } + return ids; + }, [partners]); + const filteredResourceAccounts = useMemo(() => { const q = resourcePickerSearch.trim().toLowerCase(); if (!q) return accounts; @@ -2456,7 +2517,7 @@ function PartnersPage({

合作方

-

群主型合作方由任务负责人自动生成(负责任务群分发);外部KOC由领取流程自动生成,绑定外部联系人后接收催办

+

群主型合作方由任务负责人自动生成(负责任务群分发);资源池 KOC 默认加入外部KOC合作方,绑定外部联系人或领取任务后接收催办

)} @@ -2505,7 +2566,11 @@ function PartnersPage({ const isGroupOwner = partner.partner_type === "group_owner"; return (
- + onOpenPartner(partner)} + > {partner.name} {isGroupOwner ? "群主" : "外部KOC"} @@ -2803,7 +2868,7 @@ function PartnersPage({

选择 {resourcePickerPartner.name} 的 KOC 资源

-

绑定后可在合作方列表直接查看该 KOC 的联系方式;每个资源账号只能绑定一个外部KOC合作方。

+

绑定后可在合作方列表直接查看该 KOC 的联系方式;每个资源账号只能绑定一个外部KOC合作方,换绑时会自动顶替资源池默认生成的合作方。

); @@ -4123,6 +4194,8 @@ function OverviewPage({ tasks, onNavigate, showResources, + onOpenDetail, + onOpenPartner, }: { stats: { resources: number; @@ -4139,6 +4212,8 @@ function OverviewPage({ tasks: Task[]; onNavigate: (key: NavKey) => void; showResources: boolean; + onOpenDetail: (distribution: Distribution) => void; + onOpenPartner: (partnerId: string) => void; }) { const cards = [ { @@ -4285,6 +4360,8 @@ function OverviewPage({ @@ -4607,7 +4684,11 @@ function TaskRowCard({
-
+
onDetail(task)} + >
发布 {task.quantity} 篇内容 @@ -4617,13 +4698,15 @@ function TaskRowCard({ {panelChat(task, "任务群")}
- + 查看明细 ›
{recoveryEnabled ? ( -
+
onDetail(task)} + >
回收 @@ -4635,11 +4718,6 @@ function TaskRowCard({
{panelMetric(task.dist_submitted ?? 0, task.dist_total ?? 0, "已收取", "recovery")}
-
- -
) : (
@@ -4689,6 +4767,7 @@ function TasksPage({ onCreateRecovery, onRelease, onOpenDetail, + onOpenPartner, }: { tasks: Task[]; distributions: Distribution[]; @@ -4708,6 +4787,7 @@ function TasksPage({ onCreateRecovery: (task: Task) => void; onRelease: (distribution: Distribution) => Promise; onOpenDetail: (distribution: Distribution) => void; + onOpenPartner: (partnerId: string) => void; }) { const [selectedTaskId, setSelectedTaskId] = useState(null); const [viewMode, setViewMode] = useState<"grouped" | "flat">("grouped"); @@ -4845,6 +4925,7 @@ function TasksPage({ onRelease={onRelease} onBack={() => setSelectedTaskId(null)} onOpenDetail={onOpenDetail} + onOpenPartner={onOpenPartner} /> ); } @@ -5087,6 +5168,13 @@ function TaskDetailHeader({ label: string; onBack: () => void; }) { + // 页面自述:一个任务 = 分发 + 回收两阶段,回收是否开通直接说清,不产生两种任务的歧义 + const recoveryEnabled = Number(task.recovery_enabled ?? 1) === 1; + const pairTag = !recoveryEnabled + ? "未开通回收" + : task.recovery_due_at && task.recovery_due_at !== task.due_at + ? `含回收 · 回收截止 ${formatDate(task.recovery_due_at)}` + : "含回收 · 与发布同日截止"; return (
@@ -5100,6 +5188,7 @@ function TaskDetailHeader({ {task.content_format === "video" ? "视频" : "图文"} {task.quantity} 篇 {formatDate(task.due_at)} 截止 + {pairTag}
{task.source_url && ( @@ -5119,6 +5208,7 @@ function TaskDistributionDetail({ onRelease, onBack, onOpenDetail, + onOpenPartner, }: { task: Task; distributions: Distribution[]; @@ -5127,19 +5217,52 @@ function TaskDistributionDetail({ onRelease: (distribution: Distribution) => Promise; onBack: () => void; onOpenDetail: (distribution: Distribution) => void; + onOpenPartner: (partnerId: string) => void; }) { const [releaseTarget, setReleaseTarget] = useState(null); + const [stageFilter, setStageFilter] = useState("all"); const taskDistributions = distributions.filter( (item) => item.task_id === task.id, ); const recoveryEnabled = Number(task.recovery_enabled ?? 1) === 1; const published = taskDistributions.filter((item) => item.publish_url).length; const submitted = taskDistributions.filter((item) => item.result_submitted_at).length; + // 阶段筛选:单一列表按内容生命周期过滤,不再拆分发/回收两套列表 + const awaitingMetrics = (item: Distribution) => + Boolean(item.publish_url) && !(item.exposure !== null && item.views !== null); + const awaitingScreenshot = (item: Distribution) => + Boolean(item.publish_url) && !item.result_submitted_at; + const stageOptions: Array<{ key: StageFilterKey; label: string; count: number }> = [ + { key: "all", label: "全部", count: taskDistributions.length }, + { + key: "pending_publish", + label: "待发布", + count: taskDistributions.filter((item) => !item.publish_url).length, + }, + { + key: "awaiting_metrics", + label: "已发布待数据", + count: taskDistributions.filter(awaitingMetrics).length, + }, + ...(recoveryEnabled + ? [{ + key: "awaiting_screenshot" as const, + label: "待收截图", + count: taskDistributions.filter(awaitingScreenshot).length, + }] + : []), + ]; + const visibleDistributions = taskDistributions.filter((item) => { + if (stageFilter === "pending_publish") return !item.publish_url; + if (stageFilter === "awaiting_metrics") return awaitingMetrics(item); + if (stageFilter === "awaiting_screenshot") return awaitingScreenshot(item); + return true; + }); return (
@@ -5166,13 +5289,31 @@ function TaskDistributionDetail({
-

内容分发表

实际发布账号在链接回填后自动补齐

+

内容分发表

每行是一篇内容从领取到回收的完整状态,实际发布账号在链接回填后自动补齐

+
+
+ {stageOptions.map((option) => ( + + ))}
0 && visibleDistributions.length === 0 + ? "当前筛选下没有分发记录" + : undefined + } onRelease={canRelease ? setReleaseTarget : undefined} showRecovery={recoveryEnabled} onOpenDetail={onOpenDetail} + onOpenPartner={onOpenPartner} />
{releaseTarget && ( @@ -5243,15 +5384,19 @@ function DistributionTable({ onRelease, showRecovery = false, onOpenDetail, + onOpenPartner, + emptyHint, }: { distributions: Distribution[]; compact?: boolean; onRelease?: (distribution: Distribution) => void; showRecovery?: boolean; onOpenDetail?: (distribution: Distribution) => void; + onOpenPartner?: (partnerId: string) => void; + emptyHint?: string; }) { - // 已发布但还没采到数据的行:有计划时按首个采集日显示,没计划时 - // 明说待设置,而不是写死「等待D2」误导运营。 + // 已发布但还没采到数据的行:有计划时按首个采集日显示;没计划时 + // 走默认 7 天兜底(自发布日起),而不是写死「等待D2」误导运营。 const waitingCollectionLabel = (item: Distribution) => { let days: number[] = []; try { @@ -5265,7 +5410,7 @@ function DistributionTable({ days = []; } if (!item.task_collection_start_date || days.length === 0) { - return "待设置采集计划"; + return "等待D1 · 默认7天"; } return `等待D${Math.min(...days)}`; }; @@ -5298,7 +5443,23 @@ function DistributionTable({
{item.content_title}{item.task_name} · {item.task_brand}
- {item.partner_name} + + {onOpenPartner ? ( + + ) : ( + {item.partner_name} + )} + {item.account_nickname ? (
@@ -5359,7 +5520,9 @@ function DistributionTable({ ))} - {!compact && distributions.length === 0 &&
暂无分发记录
} + {!compact && distributions.length === 0 && ( +
{emptyHint ?? "暂无分发记录"}
+ )}
); } @@ -5447,7 +5610,13 @@ function AdminImageLightbox({ ); } -function ScreenshotTaskTable({ distributions }: { distributions: Distribution[] }) { +function ScreenshotTaskTable({ + distributions, + onOpenPartner, +}: { + distributions: Distribution[]; + onOpenPartner?: (partnerId: string) => void; +}) { const [previewGallery, setPreviewGallery] = useState<{ images: AdminPreviewImage[]; activeIndex: number; @@ -5476,7 +5645,23 @@ function ScreenshotTaskTable({ distributions }: { distributions: Distribution[]
{item.content_title}{item.task_name}
- {item.partner_name} + + {onOpenPartner ? ( + + ) : ( + {item.partner_name} + )} + {formatDate(item.claimed_at, true)} {formatDate(item.publish_time, true)} {formatDate(item.result_submitted_at, true)} @@ -5958,7 +6143,7 @@ function ResourcesPage({

资源从合作与历史资产中持续沉淀

-

发布链接会自动建档,已有KOC资源也可以使用标准模板批量导入。

+

发布链接会自动建档,已有KOC资源也可以使用标准模板批量导入。资源池中的 KOC 会默认加入合作方列表。

唯一去重规则 @@ -6692,6 +6877,7 @@ function DistributionDetailModal({ onFallback, onUpdatePublishUrl, onRelease, + onOpenPartner, }: { item: Distribution; recoveryEnabled: boolean; @@ -6704,6 +6890,7 @@ function DistributionDetailModal({ onFallback: (distribution: Distribution) => void; onUpdatePublishUrl: (distribution: Distribution) => void; onRelease: (distribution: Distribution) => void; + onOpenPartner?: (partnerId: string) => void; }) { const [previewGallery, setPreviewGallery] = useState<{ images: AdminPreviewImage[]; @@ -6750,7 +6937,21 @@ function DistributionDetailModal({
{item.task_name} · {item.task_brand} - · 合作方 {item.partner_name} + {onOpenPartner ? ( + + · 合作方{" "} + + + ) : ( + · 合作方 {item.partner_name} + )} {item.claimant_name && · 领取人 {item.claimant_name}}
@@ -6857,7 +7058,7 @@ function DistributionDetailModal({ {item.task_collection_start_date && collectionDays.length > 0 ? `${item.task_collection_start_date} 起 D${collectionDays.join("/D")}` - : "待设置"} + : "默认发布后7天"} @@ -7038,6 +7239,189 @@ function DistributionDetailModal({ ); } +function KocDetailModal({ + partner, + account, + history, + onClose, +}: { + partner: Partner; + account: Account | null; + history: Distribution[]; + onClose: () => void; +}) { + const isGroupOwner = partner.partner_type === "group_owner"; + const publishedCount = history.filter((item) => item.publish_url).length; + const completedCount = history.filter( + (item) => item.exposure !== null && item.views !== null, + ).length; + const taskCount = new Set(history.map((item) => item.task_id)).size; + return ( +
+
event.stopPropagation()} + > + {/* 头部:KOC 名称 + 类型徽章 + 概览 */} +
+
+
+

KOC 详情

+

{partner.name}

+
+ +
+
+ {isGroupOwner ? "群主合作方" : "外部KOC"} + 累计领取 {partner.claimed_total} + 累计完成 {partner.completed_total} +
+
+ {isGroupOwner ? partner.wecom_userid || "未填 userid" : partner.wecom_name || "未留企微名"} + · 负责人 {partner.owner || "—"} + {account && · 绑定账号 {account.nickname || partner.account_nickname}} +
+
+ +
+
+
+ ① 基本信息 + {isGroupOwner ? "群主" : "外部KOC"} +
+
+
+
合作方名称{partner.name}
+
合作方类型{isGroupOwner ? "群主" : "外部KOC"}
+
企业微信名{partner.wecom_name || "—"}
+
企微 userid{partner.wecom_userid || "—"}
+
负责人{partner.owner || "—"}
+
外部联系人 ID{partner.wecom_external_user_id || "—"}
+
累计领取{partner.claimed_total} 篇
+
累计完成{partner.completed_total} 篇
+
+
+
+ +
+
+ ② 绑定 KOC 账号(资源) + {account ? ( + 已绑定 + ) : ( + 未绑定 + )} +
+
+ {account ? ( +
+
账号昵称{account.nickname || "—"}
+
平台{account.platform}
+
账号ID{account.public_account_id || "—"}
+
粉丝数{account.followers ? formatNumber(account.followers) : "—"}
+
IP属地{account.ip_location || "—"}
+
性别{account.gender || "—"}
+
笔记数{account.post_count || "—"}
+
平均观看{account.avg_views ? formatNumber(account.avg_views) : "—"}
+
合作来源{account.cooperation_source || "—"}
+
联系方式{account.current_contact || "—"}
+
首次发现{account.first_seen_at ? formatDate(account.first_seen_at, true) : "—"}
+
最近发现{account.last_seen_at ? formatDate(account.last_seen_at, true) : "—"}
+ {account.profile_url && ( +
主页链接打开主页 ↗
+ )} + {account.tags && ( +
标签{account.tags}
+ )} + {account.bio && ( +
简介{account.bio}
+ )} +
+ ) : ( + + {isGroupOwner + ? "群主合作方无需绑定 KOC 资源账号" + : "尚未绑定 KOC 资源账号,可在「合作方」页绑定后查看账号画像"} + + )} +
+
+ +
+
+ ③ 参与任务记录 + + {history.length} 次领取 · {taskCount} 个任务 · 发布 {publishedCount} + +
+
+ {history.length === 0 ? ( + 暂无领取记录 + ) : ( +
+ {history.map((item) => { + const itemMetrics = latestPublicMetrics(item); + const itemHasMetrics = itemMetrics.likes !== null; + const itemCollectionStatus = + item.collection_status || (itemHasMetrics ? "success" : "pending"); + return ( +
+
+ {item.content_title} + + {item.task_name} · {formatDate(item.claimed_at, true)}领取 + {item.publish_url + ? ` · ${formatDate(item.publish_time, true)}发布` + : " · 待发布"} + +
+
+ {statusLabel(item.status)} + + {collectionStatusLabel(itemCollectionStatus, itemHasMetrics)} + + {itemHasMetrics && ( + + 赞 {formatNumber(itemMetrics.likes)} · 藏 {formatNumber(itemMetrics.collects)} · 评 {formatNumber(itemMetrics.comments)} + + )} + {item.exposure !== null && item.views !== null && ( + + 曝光 {formatNumber(item.exposure)} · 阅读 {formatNumber(item.views)} + + )} +
+
+ ); + })} +
+ )} +
+
+ + {/* 漏斗概览:快速判断这个 KOC 的履约质量 */} + {history.length > 0 && ( +
+ 领取 {history.length} + → + 发布 {publishedCount} + → + 回收完成 {completedCount} +
+ )} +
+
+
+ ); +} + function RecoveryPage({ tasks, distributions, @@ -7056,6 +7440,7 @@ function RecoveryPage({ onExportScreenshots, exportingTaskId, onOpenDetail, + onOpenPartner, }: { tasks: Task[]; distributions: Distribution[]; @@ -7078,6 +7463,7 @@ function RecoveryPage({ onExportScreenshots: (task: Task) => void; exportingTaskId: string | null; onOpenDetail: (distribution: Distribution) => void; + onOpenPartner: (partnerId: string) => void; }) { const [selectedTaskId, setSelectedTaskId] = useState(null); const [recoverySort, setRecoverySort] = useState<{ @@ -7089,8 +7475,8 @@ function RecoveryPage({ return (
-

按任务回收

选择一个数据回收任务

-

进入任务后设置自动采集日期,统一查看每篇笔记的最新互动数据。

+

按任务回收

选择一个任务进入回收队列

+

进入任务后可设置自动采集日期;未设置时默认自每篇发布时间起连续采集 7 天。

{item.content_title} )} - {item.account_nickname || "待识别账号"} + {item.partner_id ? ( + + ) : ( + {item.account_nickname || "待识别账号"} + )} {item.account_platform && }
@@ -7471,7 +7871,10 @@ function RecoveryPage({ - + )} @@ -7495,9 +7898,11 @@ function CollectionScheduleCard({ const [startDate, setStartDate] = useState( task.collection_start_date || todayInputValue(), ); - const [days, setDays] = useState( - parseCollectionDays(task.collection_days), - ); + const [days, setDays] = useState(() => { + const parsed = parseCollectionDays(task.collection_days); + // 未保存过计划时默认勾选 7 天,与服务端兜底逻辑保持一致。 + return parsed.length > 0 ? parsed : [...DEFAULT_COLLECTION_DAYS]; + }); const toggleDay = (day: number) => { setDays((current) => @@ -7513,7 +7918,7 @@ function CollectionScheduleCard({

自动采集计划

选择开始日期与采集日

- 勾选第1天到第7天,所选日期均在北京时间09:00自动采集。 + 勾选第1天到第7天,所选日期均在北京时间09:00自动采集;未保存计划时默认连采 7 天。
已选择 {days.length} 天 diff --git a/app/api/action/route.ts b/app/api/action/route.ts index aa6d69d..0ae7b8e 100644 --- a/app/api/action/route.ts +++ b/app/api/action/route.ts @@ -15,6 +15,7 @@ import { import { collectDistributionMetrics, createCollectionRunTasks, + parseCollectionDays, retryFailedCollections, runDueScheduledCollections, shanghaiDateFromTimestamp, @@ -118,6 +119,38 @@ async function assignOwnerQuietly(taskId: string, ownerUserid: unknown) { } } +type PartnerAccountHolderRow = { + id: string; + name: string; + partner_type: string | null; + wecom_external_user_id: string | null; + wecom_userid: string; +}; + +/** + * 占用方是否只是资源池默认同步生成的空壳合作方: + * 无企微绑定、无领取和分发记录。空壳可在换绑时直接让位。 + */ +async function isShellAccountHolder( + db: ReturnType, + holder: PartnerAccountHolderRow, +) { + if ((holder.partner_type ?? "external_koc") !== "external_koc") return false; + if ((holder.wecom_external_user_id ?? "").trim() || holder.wecom_userid.trim()) { + return false; + } + const claim = await db + .prepare("SELECT 1 FROM claims WHERE partner_id = ? LIMIT 1") + .bind(holder.id) + .first(); + if (claim) return false; + const distribution = await db + .prepare("SELECT 1 FROM distributions WHERE partner_id = ? LIMIT 1") + .bind(holder.id) + .first(); + return !distribution; +} + export async function POST(request: Request) { if (!(await isAdminRequest(request))) return adminForbidden(); try { @@ -338,20 +371,7 @@ export async function POST(request: Request) { if (current.publish_url === publishUrl) { return Response.json(await getDashboardData()); } - let collectionDays: number[] = []; - try { - const parsed = JSON.parse(current.collection_days || "[]"); - if (Array.isArray(parsed)) { - collectionDays = [...new Set(parsed.map(Number))] - .filter( - (day) => - Number.isInteger(day) && day >= 1 && day <= 7, - ) - .sort((a, b) => a - b); - } - } catch { - collectionDays = []; - } + const collectionDays = parseCollectionDays(current.collection_days); const isScheduled = Boolean( current.collection_start_date && collectionDays.length > 0, ); @@ -384,10 +404,10 @@ export async function POST(request: Request) { ) .bind( publishUrl, - isScheduled ? "scheduled" : "pending", + "scheduled", isScheduled ? `管理员已更新链接,等待${collectionDays.length}个采集日` - : "管理员已更新链接,等待设置采集计划", + : "管理员已更新链接,默认发布后7天自动采集", distributionId, ), db @@ -412,19 +432,20 @@ export async function POST(request: Request) { current.collection_start_date, collectionDays, ); - runInBackground( - runDueScheduledCollections( - db, - Date.now(), - resolveCollectionMcpConfig( - env as unknown as CollectionMcpBindings, - ), - "catchup", - current.task_id, - ).catch(() => undefined), - "collection catchup after publish URL update", - ); } + // 未设置计划的任务同样追采:调度器会按默认 7 天(自发布日起)执行。 + runInBackground( + runDueScheduledCollections( + db, + Date.now(), + resolveCollectionMcpConfig( + env as unknown as CollectionMcpBindings, + ), + "catchup", + current.task_id, + ).catch(() => undefined), + "collection catchup after publish URL update", + ); runInBackground( enrichDistributionAccount( db, @@ -772,8 +793,10 @@ export async function POST(request: Request) { } if (!accountId) { await db - .prepare("UPDATE partners SET account_id = '' WHERE id = ?") - .bind(partnerId) + .prepare( + "UPDATE partners SET account_id = '', released_account_id = ? WHERE id = ?", + ) + .bind((partner.account_id ?? "").trim(), partnerId) .run(); return Response.json({ partnerId, accountId: "" }); } @@ -784,21 +807,39 @@ export async function POST(request: Request) { 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", + `SELECT id, name, partner_type, wecom_external_user_id, wecom_userid + FROM partners WHERE account_id = ? AND id != ? LIMIT 1`, ) .bind(accountId, partnerId) - .first<{ id: string; name: string }>(); + .first(); + let displacedShellName: string | null = null; if (holder) { - return Response.json( - { error: `该 KOC 资源已绑定合作方「${holder.name}」,请先在对方条目上解绑` }, - { status: 400 }, - ); + if (!(await isShellAccountHolder(db, holder))) { + return Response.json( + { error: `该 KOC 资源已绑定合作方「${holder.name}」,请先在对方条目上解绑` }, + { status: 400 }, + ); + } + displacedShellName = holder.name; + await db + .prepare( + "UPDATE partners SET account_id = '', released_account_id = ? WHERE id = ?", + ) + .bind(accountId, holder.id) + .run(); } await db - .prepare("UPDATE partners SET account_id = ? WHERE id = ?") + .prepare( + "UPDATE partners SET account_id = ?, released_account_id = '' WHERE id = ?", + ) .bind(accountId, partnerId) .run(); - return Response.json({ partnerId, accountId, nickname: account.nickname }); + return Response.json({ + partnerId, + accountId, + nickname: account.nickname, + displacedShellName, + }); } else if (body.action === "partner_create_from_account") { const forbidden = managerGate(); if (forbidden) return forbidden; diff --git a/app/api/bootstrap/route.ts b/app/api/bootstrap/route.ts index 8aec982..633aacf 100644 --- a/app/api/bootstrap/route.ts +++ b/app/api/bootstrap/route.ts @@ -14,6 +14,7 @@ import { getRawDb, seedIfEmpty, } from "../../../lib/mvp-db"; +import { ensurePartnersForPoolAccounts } from "../../../lib/resource-write"; import { authForbidden, getRequestPrincipal } from "../../../lib/user-auth"; import { getOwnerScope, ownerScopeUserid } from "../../../lib/owner-scope"; @@ -23,6 +24,13 @@ export async function GET(request: Request) { try { await ensureSchema(); await seedIfEmpty(); + // 资源池 KOC 默认加入合作方:在返回面板数据前补齐,失败不阻塞看板 + await ensurePartnersForPoolAccounts().catch((error) => { + console.error( + "KOC pool partner sync failed", + error instanceof Error ? error.message : "unknown error", + ); + }); const db = getRawDb(); const mcpConfig = resolveCollectionMcpConfig( env as unknown as CollectionMcpBindings, diff --git a/app/api/partner-batch-workbook/route.ts b/app/api/partner-batch-workbook/route.ts index 8240257..9ba4108 100644 --- a/app/api/partner-batch-workbook/route.ts +++ b/app/api/partner-batch-workbook/route.ts @@ -38,6 +38,7 @@ import { import { enrichDistributionAccount } from "../../../lib/account-enrichment-service"; import { createCollectionRunTasks, + parseCollectionDays, runDueScheduledCollections, } from "../../../lib/collection-service"; import { normalizeWorkbookImage } from "../../../lib/workbook-image"; @@ -664,49 +665,48 @@ async function handlePost(request: Request) { updatedRows += 1; } } - if ( - (publishedCount > 0 || publishUrlChangedCount > 0) && - access.task.collection_start_date && - access.task.collection_days !== "[]" - ) { - let collectionDays: number[] = []; - try { - const parsed = JSON.parse(access.task.collection_days); - if (Array.isArray(parsed)) collectionDays = parsed.map(Number); - } catch { - collectionDays = []; - } - if (collectionDays.length > 0) { - await db - .prepare( - `UPDATE distributions SET collection_status = 'scheduled', - collection_status_description = ? - WHERE task_id = ? AND publish_url IS NOT NULL AND publish_url != ''`, - ) - .bind( - `已安排${collectionDays.length}个采集日,每日09:00执行`, - access.task.id, - ) - .run(); + if (publishedCount > 0 || publishUrlChangedCount > 0) { + const collectionDays = parseCollectionDays( + access.task.collection_days, + ); + const isScheduled = Boolean( + access.task.collection_start_date && + collectionDays.length > 0, + ); + await db + .prepare( + `UPDATE distributions SET collection_status = 'scheduled', + collection_status_description = ? + WHERE task_id = ? AND publish_url IS NOT NULL AND publish_url != ''`, + ) + .bind( + isScheduled + ? `已安排${collectionDays.length}个采集日,每日09:00执行` + : "默认发布后7天自动采集,每日09:00执行", + access.task.id, + ) + .run(); + if (isScheduled && access.task.collection_start_date) { await createCollectionRunTasks( db, access.task.id, access.task.collection_start_date, collectionDays, ); - runInBackground( - runDueScheduledCollections( - db, - Date.now(), - resolveCollectionMcpConfig( - env as unknown as CollectionMcpBindings, - ), - "catchup", - access.task.id, - ).catch(() => undefined), - "collection catchup after batch publish update", - ); } + // 未设置计划时调度器同样兜底:默认自发布日起 7 天自动采集。 + runInBackground( + runDueScheduledCollections( + db, + Date.now(), + resolveCollectionMcpConfig( + env as unknown as CollectionMcpBindings, + ), + "catchup", + access.task.id, + ).catch(() => undefined), + "collection catchup after batch publish update", + ); } for (const enrichment of enrichments) { runInBackground( diff --git a/app/api/partner/route.ts b/app/api/partner/route.ts index a29fec1..789bff0 100644 --- a/app/api/partner/route.ts +++ b/app/api/partner/route.ts @@ -6,6 +6,7 @@ const env = getRuntimeEnv(); import { enrichDistributionAccount } from "../../../lib/account-enrichment-service"; import { createCollectionRunTasks, + parseCollectionDays, runDueScheduledCollections, } from "../../../lib/collection-service"; import { @@ -1153,47 +1154,47 @@ async function handlePost(request: Request) { collection_start_date: string | null; collection_days: string; }>(); - if ( - collectionSchedule?.collection_start_date && - collectionSchedule.collection_days !== "[]" - ) { - let collectionDays: number[] = []; - try { - const parsed = JSON.parse(collectionSchedule.collection_days); - if (Array.isArray(parsed)) collectionDays = parsed.map(Number); - } catch { - collectionDays = []; - } - if (collectionDays.length > 0) { - await db - .prepare( - `UPDATE distributions SET collection_status = 'scheduled', - collection_status_description = ? WHERE id = ?`, - ) - .bind( - `已安排${collectionDays.length}个采集日,每日09:00执行`, - assignment.id, - ) - .run(); + if (collectionSchedule) { + const collectionDays = parseCollectionDays( + collectionSchedule.collection_days, + ); + const isScheduled = Boolean( + collectionSchedule.collection_start_date && + collectionDays.length > 0, + ); + await db + .prepare( + `UPDATE distributions SET collection_status = 'scheduled', + collection_status_description = ? WHERE id = ?`, + ) + .bind( + isScheduled + ? `已安排${collectionDays.length}个采集日,每日09:00执行` + : "默认发布后7天自动采集,每日09:00执行", + assignment.id, + ) + .run(); + if (isScheduled && collectionSchedule.collection_start_date) { await createCollectionRunTasks( db, task.id, collectionSchedule.collection_start_date, collectionDays, ); - runInBackground( - runDueScheduledCollections( - db, - Date.now(), - resolveCollectionMcpConfig( - env as unknown as CollectionMcpBindings, - ), - "catchup", - task.id, - ).catch(() => undefined), - "collection catchup after partner publish update", - ); } + // 未设置计划时调度器同样兜底:默认自发布日起 7 天自动采集。 + runInBackground( + runDueScheduledCollections( + db, + Date.now(), + resolveCollectionMcpConfig( + env as unknown as CollectionMcpBindings, + ), + "catchup", + task.id, + ).catch(() => undefined), + "collection catchup after partner publish update", + ); } const enrichment = enrichDistributionAccount( db, diff --git a/app/api/resources-import/route.ts b/app/api/resources-import/route.ts index 1319bbc..bb9dc72 100644 --- a/app/api/resources-import/route.ts +++ b/app/api/resources-import/route.ts @@ -9,6 +9,7 @@ import { } from "../../../lib/resource-import"; import { analyzeRows, + ensurePartnersForPoolAccounts, enrichRows, loadAccounts, mergeExistingFields, @@ -137,6 +138,10 @@ export async function POST(request: Request) { tagUntaggedAccounts(importableRows), "resource account tagging", ); + runInBackground( + ensurePartnersForPoolAccounts(), + "pool partner sync after resource import", + ); if (deferredEnrichment > 0) { const importableSourceRows = rows.filter((row) => importableRowNumbers.has(row.rowNumber), diff --git a/app/api/resources-insert/route.ts b/app/api/resources-insert/route.ts index b83064d..bd4c5f2 100644 --- a/app/api/resources-insert/route.ts +++ b/app/api/resources-insert/route.ts @@ -3,6 +3,7 @@ import { runInBackground } from "../../../lib/background"; import { buildManualRow, type ManualResourceInput } from "../../../lib/resource-import"; import { analyzeRows, + ensurePartnersForPoolAccounts, enrichRows, loadAccounts, tagUntaggedAccounts, @@ -54,6 +55,10 @@ export async function POST(request: Request) { tagUntaggedAccounts(analyzed), "resource account tagging", ); + runInBackground( + ensurePartnersForPoolAccounts(), + "pool partner sync after manual resource insert", + ); const result = analyzed[0]; const outcome: InsertResult = { action: result.action === "update" ? "update" : "create", diff --git a/app/globals.css b/app/globals.css index 20bc88e..e2a49fc 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1485,6 +1485,104 @@ a { border: 1px solid var(--line); } +/* KOC 详情弹窗(复用分发详情的三段式样式) */ +.koc-link { + border: 0; + background: transparent; + padding: 0; + font: inherit; + font-weight: 640; + color: var(--ink); + cursor: pointer; + border-bottom: 1px dashed #b9c6bd; +} + +.koc-link:hover { + color: var(--green); + border-bottom-color: var(--green); +} + +.partner-name-cell { + cursor: pointer; +} + +.partner-name-cell:hover strong { + color: var(--green); +} + +.detail-kv.detail-kv-wide { + grid-column: 1 / -1; +} + +.koc-history-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.koc-history-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; + padding: 9px 11px; + border: 1px solid var(--line); + border-radius: 9px; + background: #f7f9f7; +} + +.koc-history-main { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.koc-history-main strong { + font-size: 12.5px; + color: var(--ink); +} + +.koc-history-main span { + font-size: 11px; + color: var(--muted); +} + +.koc-history-side { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.koc-history-metrics { + font-size: 11px; + color: var(--ink-soft); + white-space: nowrap; +} + +.koc-summary-strip { + display: flex; + align-items: center; + gap: 8px; + margin-top: 11px; + padding: 10px 13px; + border: 1px dashed #b9c6bd; + border-radius: 10px; + font-size: 12px; + color: var(--ink-soft); +} + +.koc-summary-strip b { + color: var(--ink); +} + +.koc-summary-strip i { + color: var(--muted); + font-style: normal; +} + .detail-actions { display: flex; align-items: center; @@ -2398,6 +2496,52 @@ a { border-color: #f3e9d2; } +/* 任务卡两块阶段面板整块可点(回收面板无独立按钮) */ +.master-panel.clickable { + cursor: pointer; + border-color: var(--line); + transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease; +} + +.master-panel.clickable:hover { + border-color: #9fc3b4; + background: #f7faf8; +} + +.master-panel.clickable .panel-foot .panel-detail { + color: var(--green); + font-weight: 640; + font-size: 11.5px; +} + +/* 详情页阶段筛选 chips */ +.stage-filter-tabs { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-bottom: 10px; +} + +.stage-filter-tabs button { + padding: 5px 12px; + border: 1px solid #dfe6e1; + border-radius: 999px; + background: #fbfcfb; + color: #687773; + font-size: 11px; + cursor: pointer; +} + +.stage-filter-tabs button:hover { + border-color: #9fc3b4; +} + +.stage-filter-tabs button.active { + border-color: #205843; + background: #205843; + color: #fff; +} + .master-panel.ghost { background: transparent; border: 1px dashed #cfddd6; diff --git a/lib/collection-service.ts b/lib/collection-service.ts index 640004a..ba423de 100644 --- a/lib/collection-service.ts +++ b/lib/collection-service.ts @@ -16,7 +16,8 @@ type DistributionForCollection = { type ScheduledTask = { id: string; - collection_start_date: string; + task_type: string | null; + collection_start_date: string | null; collection_days: string; }; @@ -63,9 +64,9 @@ function dateForScheduleDay(startDate: string, scheduleDay: number) { .slice(0, 10); } -function parseDays(value: string) { +function parseDays(value: string | null | undefined) { try { - const parsed = JSON.parse(value) as unknown; + const parsed = JSON.parse(value ?? "[]") as unknown; if (!Array.isArray(parsed)) return []; return [...new Set(parsed.map(Number))] .filter((day) => Number.isInteger(day) && day >= 1 && day <= 7) @@ -75,12 +76,35 @@ function parseDays(value: string) { } } +export function parseCollectionDays(value: string | null | undefined) { + return parseDays(value); +} + +// 数据回收(自动采集)兜底:任务未设置采集日时默认连续采集 7 天。 +export const DEFAULT_COLLECTION_DAYS = [1, 2, 3, 4, 5, 6, 7]; + +export function effectiveCollectionDays(value: string | null | undefined) { + const days = parseDays(value); + return days.length > 0 ? days : [...DEFAULT_COLLECTION_DAYS]; +} + export function shanghaiDateFromTimestamp(timestamp: number) { return new Date(timestamp + 8 * 60 * 60 * 1000) .toISOString() .slice(0, 10); } +// publish_time 等列以 UTC 文本存储(CURRENT_TIMESTAMP),统一换算成上海日期。 +function shanghaiDateFromSqlTimestamp(value: string | null | undefined) { + if (!value) return null; + const normalized = value.includes("T") + ? value + : `${value.replace(" ", "T")}Z`; + const epoch = Date.parse(normalized); + if (Number.isNaN(epoch)) return null; + return shanghaiDateFromTimestamp(epoch); +} + function shanghaiHourFromTimestamp(timestamp: number) { return Number( new Date(timestamp + 8 * 60 * 60 * 1000) @@ -392,6 +416,42 @@ export async function runScheduledCollections( ); } +async function distributionIdsNeedingCollection( + db: DatabaseClient, + taskId: string, + scheduledDate: string, + distributionId?: string, +) { + const scoped = distributionId ? "AND d.id = ?" : ""; + const { results } = await db + .prepare( + `SELECT d.id FROM distributions d + WHERE d.task_id = ? + AND d.publish_url IS NOT NULL + AND d.publish_url != '' + ${scoped} + AND NOT EXISTS ( + SELECT 1 FROM collection_runs r + WHERE r.distribution_id = d.id + AND r.scheduled_date = ? + AND ( + r.status = 'success' + OR ( + r.status = 'collecting' + AND r.started_at >= datetime('now', '-30 minutes') + ) + OR ( + r.status = 'failed' + AND r.completed_at >= datetime('now', '-15 minutes') + ) + ) + )`, + ) + .bind(...(distributionId ? [taskId, distributionId, scheduledDate] : [taskId, scheduledDate])) + .all<{ id: string }>(); + return results.map((row) => row.id); +} + export async function runDueScheduledCollections( db: DatabaseClient, timestamp: number, @@ -402,12 +462,9 @@ export async function runDueScheduledCollections( const currentDate = shanghaiDateFromTimestamp(timestamp); const tasks = await db .prepare( - `SELECT id, collection_start_date, collection_days + `SELECT id, task_type, collection_start_date, collection_days FROM tasks - WHERE collection_start_date IS NOT NULL - AND collection_start_date != '' - AND collection_days IS NOT NULL - AND collection_days != '[]' + WHERE COALESCE(task_type, 'content_publish') != 'screenshot_collect' AND (? IS NULL OR id = ?)`, ) .bind(onlyTaskId ?? null, onlyTaskId ?? null) @@ -417,46 +474,25 @@ export async function runDueScheduledCollections( let succeeded = 0; let failed = 0; - for (const task of tasks.results) { - const schedules = dueSchedules( - task.collection_start_date, - parseDays(task.collection_days), - timestamp, - ); + const collectDue = async ( + taskId: string, + schedules: { scheduleDay: number; scheduledDate: string }[], + distributionId?: string, + ) => { for (const { scheduleDay, scheduledDate } of schedules) { dueTasks += 1; - const distributions = await db - .prepare( - `SELECT d.id FROM distributions d - WHERE d.task_id = ? - AND d.publish_url IS NOT NULL - AND d.publish_url != '' - AND NOT EXISTS ( - SELECT 1 FROM collection_runs r - WHERE r.distribution_id = d.id - AND r.scheduled_date = ? - AND ( - r.status = 'success' - OR ( - r.status = 'collecting' - AND r.started_at >= datetime('now', '-30 minutes') - ) - OR ( - r.status = 'failed' - AND r.completed_at >= datetime('now', '-15 minutes') - ) - ) - )`, - ) - .bind(task.id, scheduledDate) - .all<{ id: string }>(); - - for (const distribution of distributions.results) { + const ids = await distributionIdsNeedingCollection( + db, + taskId, + scheduledDate, + distributionId, + ); + for (const id of ids) { attempted += 1; try { await collectDistributionMetrics( db, - distribution.id, + id, scheduledDate, scheduleDay, source, @@ -468,6 +504,47 @@ export async function runDueScheduledCollections( } } } + }; + + for (const task of tasks.results) { + const parsedDays = parseDays(task.collection_days); + if (task.collection_start_date && parsedDays.length > 0) { + await collectDue( + task.id, + dueSchedules(task.collection_start_date, parsedDays, timestamp), + ); + continue; + } + // 未设置采集计划:默认按每篇作品的发布时间起连续采集 7 天。 + const days = effectiveCollectionDays(task.collection_days); + const published = await db + .prepare( + `SELECT d.id, COALESCE(d.publish_time, d.claimed_at) AS publish_anchor + FROM distributions d + WHERE d.task_id = ? + AND d.publish_url IS NOT NULL + AND d.publish_url != ''`, + ) + .bind(task.id) + .all<{ id: string; publish_anchor: string | null }>(); + for (const distribution of published.results) { + const anchorDate = shanghaiDateFromSqlTimestamp( + distribution.publish_anchor, + ); + if (!anchorDate) continue; + const windowEndDate = dateForScheduleDay( + anchorDate, + days[days.length - 1], + ); + // 默认窗口已过(发布超过 7 天且从未配置计划)不再补采历史天, + // 避免 D1 快照与真实发布时间脱节;确需补采时由管理员设置明确计划。 + if (!windowEndDate || currentDate > windowEndDate) continue; + await collectDue( + task.id, + dueSchedules(anchorDate, days, timestamp), + distribution.id, + ); + } } return { currentDate, dueTasks, attempted, succeeded, failed }; diff --git a/lib/mcp-tools.ts b/lib/mcp-tools.ts index cdff5d7..cbd93fd 100644 --- a/lib/mcp-tools.ts +++ b/lib/mcp-tools.ts @@ -127,7 +127,7 @@ export function registerMcpOperationTools(server: McpServer, options: Options) { "collection_plan_set", { title: "设置自动采集计划", - description: "为任务设置开始日期及第1至第7天的自动采集日,系统在北京时间09:00执行。", + description: "为任务设置开始日期及第1至第7天的自动采集日,系统在北京时间09:00执行;未设置时默认自每篇发布时间起连续采集7天。", inputSchema: z.object({ task_id: z.string().min(1), start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("北京时间开始日期 YYYY-MM-DD"), diff --git a/lib/mvp-db.ts b/lib/mvp-db.ts index 136ff75..ae26f72 100644 --- a/lib/mvp-db.ts +++ b/lib/mvp-db.ts @@ -380,6 +380,11 @@ export async function ensureSchema(database?: DatabaseClient) { "account_id", "account_id TEXT NOT NULL DEFAULT ''", ); + await ensureColumn( + "partners", + "released_account_id", + "released_account_id TEXT NOT NULL DEFAULT ''", + ); await ensureColumn( "users", "wecom_userid", diff --git a/lib/resource-write.ts b/lib/resource-write.ts index 55bf56b..7151127 100644 --- a/lib/resource-write.ts +++ b/lib/resource-write.ts @@ -1,4 +1,5 @@ -import { ensureSchema, getRawDb } from "./mvp-db"; +import { type DatabaseClient } from "./database"; +import { ensureSchema, getRawDb, hashText } from "./mvp-db"; import { generateAccountTagsFromMcp, resolveCollectionMcpConfig, @@ -448,3 +449,130 @@ export async function writeAnalyzedRows(rows: AnalyzedRow[]) { await db.batch(statements.slice(index, index + RESOURCE_IMPORT_DB_BATCH_SIZE)); } } + +const POOL_PARTNER_PLACEHOLDER_NICKNAME = "待识别账号"; +export const POOL_PARTNER_SYNC_BATCH_SIZE = 500; +const POOL_PARTNER_SYNC_MAX_ITERATIONS = 30; + +type PoolAccountRow = { + id: string; + platform: string; + platform_uid: string; + public_account_id: string; + nickname: string; +}; + +type ShellPartnerNameRow = PoolAccountRow & { + partner_id: string; + partner_name: string; +}; + +export function poolPartnerDisplayName(account: { + nickname?: string | null; + public_account_id?: string | null; + platform_uid?: string | null; + id: string; +}) { + const nickname = (account.nickname ?? "").trim(); + if (nickname && nickname !== POOL_PARTNER_PLACEHOLDER_NICKNAME) return nickname; + return ( + (account.public_account_id ?? "").trim() || + (account.platform_uid ?? "").trim() || + account.id + ); +} + +/** + * 资源池中的 KOC 默认加入合作方:为尚未绑定合作方的资源账号自动创建 + * external_koc 合作方。运营显式解绑或被换绑顶替过的账号 + * (partners.released_account_id)不回填,保留人工决定。 + * 合作方 id 由账号身份哈希派生,配合 INSERT OR IGNORE 保证并发下幂等。 + */ +export async function ensurePartnersForPoolAccounts(database?: DatabaseClient) { + const db = database ?? getRawDb(); + for ( + let iteration = 0; + iteration < POOL_PARTNER_SYNC_MAX_ITERATIONS; + iteration += 1 + ) { + const unbound = await db + .prepare( + `SELECT a.id, a.platform, a.platform_uid, a.public_account_id, a.nickname + FROM accounts a + WHERE NOT EXISTS (SELECT 1 FROM partners p WHERE p.account_id = a.id) + AND NOT EXISTS ( + SELECT 1 FROM partners r WHERE r.released_account_id = a.id + ) + ORDER BY a.first_seen_at, a.id + LIMIT ${POOL_PARTNER_SYNC_BATCH_SIZE}`, + ) + .all(); + if (unbound.results.length === 0) break; + const statements = unbound.results.map((account) => + db + .prepare( + `INSERT OR IGNORE 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( + `partner-koc-${hashText(`${account.platform}:${account.platform_uid}`)}`, + poolPartnerDisplayName(account), + account.id, + new Date().toISOString(), + ), + ); + for ( + let index = 0; + index < statements.length; + index += RESOURCE_IMPORT_DB_BATCH_SIZE + ) { + await db.batch(statements.slice(index, index + RESOURCE_IMPORT_DB_BATCH_SIZE)); + } + if (unbound.results.length < POOL_PARTNER_SYNC_BATCH_SIZE) break; + } + await refreshPoolPartnerNames(db); +} + +/** + * 补全默认合作方的展示名:仅处理无企微绑定、无领取/分发记录的空壳合作方, + * 昵称补全后把占位名(待识别账号)或账号 ID 兜底名更新为当前昵称, + * 不碰运营可能手工维护过含义的名称。 + */ +async function refreshPoolPartnerNames(db: DatabaseClient) { + const rows = await db + .prepare( + `SELECT p.id AS partner_id, p.name AS partner_name, + a.id, a.nickname, a.public_account_id, a.platform_uid + FROM partners p + JOIN accounts a ON a.id = p.account_id + WHERE p.partner_type = 'external_koc' + AND (p.wecom_external_user_id IS NULL OR p.wecom_external_user_id = '') + AND p.wecom_userid = '' + AND NOT EXISTS (SELECT 1 FROM claims c WHERE c.partner_id = p.id) + AND NOT EXISTS (SELECT 1 FROM distributions d WHERE d.partner_id = p.id) + AND ( + p.name IN ('', '${POOL_PARTNER_PLACEHOLDER_NICKNAME}') + OR (p.name IN (a.public_account_id, a.platform_uid) + AND a.nickname NOT IN ('', '${POOL_PARTNER_PLACEHOLDER_NICKNAME}')) + )`, + ) + .all(); + const updates = rows.results + .filter( + (row) => poolPartnerDisplayName(row) !== row.partner_name.trim(), + ) + .map((row) => + db + .prepare(`UPDATE partners SET name = ? WHERE id = ?`) + .bind(poolPartnerDisplayName(row), row.partner_id), + ); + for ( + let index = 0; + index < updates.length; + index += RESOURCE_IMPORT_DB_BATCH_SIZE + ) { + await db.batch(updates.slice(index, index + RESOURCE_IMPORT_DB_BATCH_SIZE)); + } +} diff --git a/mysql/0023_partner_released_account.sql b/mysql/0023_partner_released_account.sql new file mode 100644 index 0000000..797d573 --- /dev/null +++ b/mysql/0023_partner_released_account.sql @@ -0,0 +1,5 @@ +-- 资源池 KOC 默认加入合作方:released_account_id 记录运营显式解绑/被顶替的资源账号, +-- 自动同步 ensurePartnersForPoolAccounts 不再为这些账号回填默认合作方 +ALTER TABLE `partners` ADD COLUMN `released_account_id` VARCHAR(64) NOT NULL DEFAULT '' AFTER account_id; +-- statement-breakpoint +ALTER TABLE `partners` ADD INDEX `partners_released_account_idx` (`released_account_id`); diff --git a/tests/collection-schedule.test.mjs b/tests/collection-schedule.test.mjs index 5d25a50..99f1d41 100644 --- a/tests/collection-schedule.test.mjs +++ b/tests/collection-schedule.test.mjs @@ -1,7 +1,13 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; -import { isCollectionScheduleDue } from "../lib/collection-service.ts"; +import { + DEFAULT_COLLECTION_DAYS, + effectiveCollectionDays, + isCollectionScheduleDue, + parseCollectionDays, + runDueScheduledCollections, +} from "../lib/collection-service.ts"; import { sortWithNullsLast } from "../lib/sort-utils.ts"; test("runs the Beijing collection schedule from 09:00", () => { @@ -78,3 +84,176 @@ test("a failed collection keeps historically successful rows on success", async // run 行照实记录失败,保留可观测性 assert.match(source, /UPDATE collection_runs\s+SET status = 'failed'/); }); + +// —— 未设置采集计划时默认连采 7 天 —— + +test("effective collection days fall back to 7 days when unset", () => { + assert.deepEqual(effectiveCollectionDays(null), [1, 2, 3, 4, 5, 6, 7]); + assert.deepEqual(effectiveCollectionDays("[]"), DEFAULT_COLLECTION_DAYS); + assert.deepEqual(effectiveCollectionDays('{"bad":1}'), DEFAULT_COLLECTION_DAYS); + assert.deepEqual(effectiveCollectionDays("[0,9]"), DEFAULT_COLLECTION_DAYS); + assert.deepEqual(effectiveCollectionDays("[3,1,2,2]"), [1, 2, 3]); + assert.deepEqual(parseCollectionDays("[7,2]"), [2, 7]); +}); + +function fakeSchedulerDb({ tasks = [], published = [] } = {}) { + const executed = []; + const record = (sql, values) => executed.push({ sql, values }); + const db = { + prepare(sql) { + return { + bind(...values) { + return { + async first() { + record(sql, values); + return null; + }, + async run() { + record(sql, values); + return { meta: { changes: 0 } }; + }, + async all() { + record(sql, values); + if (/FROM tasks/.test(sql)) { + const scoped = tasks.filter( + (task) => !values[0] || task.id === values[0], + ); + return { + results: scoped.filter( + (task) => + (task.task_type ?? "content_publish") !== + "screenshot_collect", + ), + }; + } + if (/COALESCE\(d\.publish_time, d\.claimed_at\)/.test(sql)) { + return { + results: published.filter((row) => row.task_id === values[0]), + }; + } + // 无待采集行,避免触发真实 MCP 采集 + return { results: [] }; + }, + }; + }, + }; + }, + async batch() { + return []; + }, + }; + return { db, executed }; +} + +test("collects unplanned tasks on the default 7-day schedule from publish date", async () => { + const { db, executed } = fakeSchedulerDb({ + tasks: [ + { + id: "task-a", + task_type: "content_publish", + collection_start_date: null, + collection_days: "[]", + }, + ], + published: [ + // UTC 2026-10-09 01:30 → 上海 2026-10-09,D1–D4 在 10-12 09:00 前到期 + { id: "dist-a", task_id: "task-a", publish_anchor: "2026-10-09 01:30:00" }, + ], + }); + const summary = await runDueScheduledCollections( + db, + Date.parse("2026-10-12T01:00:00Z"), + {}, + ); + assert.equal(summary.dueTasks, 4); + const anchorQuery = executed.find((entry) => + /COALESCE\(d\.publish_time/.test(entry.sql), + ); + assert.ok(anchorQuery, "未设置计划时应查询每篇的发布时间"); + assert.equal(anchorQuery.values[0], "task-a"); + const scoped = executed.filter((entry) => + /SELECT d\.id FROM distributions d/.test(entry.sql), + ); + assert.equal(scoped.length, 4); + assert.ok(scoped.every((entry) => entry.values.includes("dist-a"))); +}); + +test("skips the default window once 7 days have passed since publish", async () => { + const { db, executed } = fakeSchedulerDb({ + tasks: [ + { + id: "task-old", + task_type: "content_publish", + collection_start_date: null, + collection_days: "[]", + }, + ], + published: [ + { id: "dist-old", task_id: "task-old", publish_anchor: "2026-09-01 00:00:00" }, + ], + }); + const summary = await runDueScheduledCollections( + db, + Date.parse("2026-10-12T01:00:00Z"), + {}, + ); + assert.equal(summary.dueTasks, 0); + assert.ok( + !executed.some((entry) => + /SELECT d\.id FROM distributions d/.test(entry.sql), + ), + ); +}); + +test("keeps the explicit task-level schedule when a plan is saved", async () => { + const { db, executed } = fakeSchedulerDb({ + tasks: [ + { + id: "task-b", + task_type: "content_publish", + collection_start_date: "2026-10-10", + collection_days: "[2]", + }, + ], + }); + const summary = await runDueScheduledCollections( + db, + Date.parse("2026-10-12T01:00:00Z"), + {}, + ); + assert.equal(summary.dueTasks, 1); + const needing = executed.filter((entry) => + /SELECT d\.id FROM distributions d/.test(entry.sql), + ); + assert.deepEqual(needing.map((entry) => entry.values), [ + ["task-b", "2026-10-11"], + ]); + assert.ok( + !executed.some((entry) => /COALESCE\(d\.publish_time/.test(entry.sql)), + ); +}); + +test("ignores screenshot collection tasks in the scheduler", async () => { + const { db, executed } = fakeSchedulerDb({ + tasks: [ + { + id: "task-shot", + task_type: "screenshot_collect", + collection_start_date: null, + collection_days: "[]", + }, + ], + published: [ + { id: "dist-shot", task_id: "task-shot", publish_anchor: "2026-10-11 00:00:00" }, + ], + }); + const summary = await runDueScheduledCollections( + db, + Date.parse("2026-10-12T01:00:00Z"), + {}, + ); + assert.equal(summary.dueTasks, 0); + assert.ok( + !executed.some((entry) => /COALESCE\(d\.publish_time/.test(entry.sql)), + ); +}); diff --git a/tests/koc-detail.test.mjs b/tests/koc-detail.test.mjs new file mode 100644 index 0000000..ad7034f --- /dev/null +++ b/tests/koc-detail.test.mjs @@ -0,0 +1,90 @@ +import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const source = readFileSync(new URL("../app/admin-app.tsx", import.meta.url), "utf8"); + +test("KOC 详情弹窗组件:三段式(基本信息 / 绑定账号 / 参与记录)", () => { + const start = source.indexOf("function KocDetailModal"); + assert.ok(start > 0, "应存在 KocDetailModal 组件"); + const body = source.slice(start, source.indexOf("function RecoveryPage")); + assert.match(body, /KOC详情:\$\{partner\.name\}/); + assert.match(body, /① 基本信息/); + assert.match(body, /② 绑定 KOC 账号(资源)/); + assert.match(body, /③ 参与任务记录/); + assert.match(body, /暂无领取记录/); + // 概览漏斗:领取 → 发布 → 回收完成 + assert.match(body, /领取 \{history\.length\}<\/b>/); + assert.match(body, /发布 \{publishedCount\}<\/b>/); + assert.match(body, /回收完成 \{completedCount\}<\/b>/); + // 绑定账号缺失时的兜底文案区分群主与外部KOC + assert.match(body, /群主合作方无需绑定 KOC 资源账号/); +}); + +test("工作台:按 id 挂载 KOC 详情,操作刷新后内容自动更新", () => { + assert.match(source, /const \[detailPartnerId, setDetailPartnerId\] = useState\(null\);/); + assert.match( + source, + /data\.partners\.find\(\(partner\) => partner\.id === detailPartnerId\)/, + ); + assert.match(source, /item\.partner_id === partnerRow\.id/); + assert.match(source, / { + const start = source.indexOf("function OverviewPage"); + const body = source.slice(start, source.indexOf("function OwnerLinkRow")); + assert.match(body, /onOpenDetail=\{onOpenDetail\}/); + assert.match(body, /onOpenPartner=\{onOpenPartner\}/); +}); + +test("分发表/截图回收表:合作方名为 KOC 详情入口且不触发行点击", () => { + const tableStart = source.indexOf("function DistributionTable"); + const tableBody = source.slice(tableStart, source.indexOf("type AdminPreviewImage")); + assert.match(tableBody, /onOpenPartner\?: \(partnerId: string\) => void;/); + assert.match(tableBody, /className="koc-link"/); + assert.match(tableBody, /event\.stopPropagation\(\);\s*onOpenPartner\(item\.partner_id\)/); + + const shotStart = source.indexOf("function ScreenshotTaskTable"); + const shotBody = source.slice(shotStart, source.indexOf("const RESOURCE_PAGE_SIZE")); + assert.match(shotBody, /onOpenPartner\?: \(partnerId: string\) => void;/); + assert.match(shotBody, /onOpenPartner\(item\.partner_id\)/); +}); + +test("数据回收页:回收队列账号名与截图回收合作方均可点开 KOC 详情", () => { + const start = source.indexOf("function RecoveryPage"); + const body = source.slice(start); + assert.match(body, /onOpenPartner: \(partnerId: string\) => void;/); + // 回收队列:账号名(待识别账号兜底)为入口,且阻断行点击冒泡 + assert.match(body, /\{item\.account_nickname \|\| "待识别账号"\}\s*<\/button>/); + assert.match( + body, + /event\.stopPropagation\(\);\s*onOpenPartner\(item\.partner_id\)/, + ); + // 截图回收表透传 + assert.match(body, / { + const start = source.indexOf("function PartnersPage"); + const body = source.slice(start, source.indexOf("function ResourcesPage")); + assert.match(body, /onOpenPartner: \(partner: Partner\) => void;/); + assert.match(body, /className="partner-name-cell"/); + assert.match(body, /onClick=\{\(\) => onOpenPartner\(partner\)\}/); +}); + +test("分发详情弹窗:头部合作方可跳转 KOC 详情", () => { + const start = source.indexOf("function DistributionDetailModal"); + const body = source.slice(start, source.indexOf("function KocDetailModal")); + assert.match(body, /onOpenPartner\?: \(partnerId: string\) => void;/); + assert.match(body, /detail-meta-koc/); + assert.match(body, /onOpenPartner\(item\.partner_id\)/); +}); + +test("KOC 详情样式:入口链接与历史记录列表", () => { + const css = readFileSync(new URL("../app/globals.css", import.meta.url), "utf8"); + assert.match(css, /^\.koc-link \{/m); + assert.match(css, /^\.partner-name-cell \{/m); + assert.match(css, /^\.koc-history-list \{/m); + assert.match(css, /^\.koc-summary-strip \{/m); +}); diff --git a/tests/partner-resource-binding.test.mjs b/tests/partner-resource-binding.test.mjs index 8b066d7..3647866 100644 --- a/tests/partner-resource-binding.test.mjs +++ b/tests/partner-resource-binding.test.mjs @@ -30,14 +30,14 @@ 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/, + /SELECT id, name, partner_type, wecom_external_user_id, wecom_userid\s+FROM partners WHERE account_id = \? AND id != \? LIMIT 1/, "绑定前必须检查资源账号是否已被其它合作方占用", ); assert.match(body, /该 KOC 资源已绑定合作方/, "占用时返回可读错误"); assert.match( body, - /UPDATE partners SET account_id = '' WHERE id = \?/, - "空 accountId 表示解绑", + /UPDATE partners SET account_id = '', released_account_id = \? WHERE id = \?/, + "空 accountId 表示解绑,并记录释放的资源账号避免默认合作方同步回填", ); assert.match(body, /群主合作方不绑定 KOC 资源/); }); diff --git a/tests/pool-partner-default.test.mjs b/tests/pool-partner-default.test.mjs new file mode 100644 index 0000000..bf2e5fd --- /dev/null +++ b/tests/pool-partner-default.test.mjs @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFileSync } from "node:fs"; + +const resourceWrite = readFileSync( + new URL("../lib/resource-write.ts", import.meta.url), + "utf8", +); +const bootstrapRoute = readFileSync( + new URL("../app/api/bootstrap/route.ts", import.meta.url), + "utf8", +); +const resourcesImport = readFileSync( + new URL("../app/api/resources-import/route.ts", import.meta.url), + "utf8", +); +const resourcesInsert = readFileSync( + new URL("../app/api/resources-insert/route.ts", import.meta.url), + "utf8", +); +const actionRoute = readFileSync( + new URL("../app/api/action/route.ts", import.meta.url), + "utf8", +); +const migration = readFileSync( + new URL("../mysql/0023_partner_released_account.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("ensurePartnersForPoolAccounts 为资源池未绑定账号创建默认合作方", () => { + assert.match(resourceWrite, /export async function ensurePartnersForPoolAccounts/); + assert.match( + resourceWrite, + /NOT EXISTS \(SELECT 1 FROM partners p WHERE p\.account_id = a\.id\)/, + "只为尚未绑定合作方的资源账号建档", + ); + assert.match( + resourceWrite, + /INSERT OR IGNORE INTO partners/, + "并发同步幂等:主键冲突直接忽略", + ); + assert.match(resourceWrite, /'external_koc'/, "默认合作方为外部KOC类型"); + assert.match( + resourceWrite, + /partner-koc-\$\{hashText/, + "合作方 id 由账号身份派生,与领取流程的前缀约定一致", + ); +}); + +test("默认合作方同步尊重运营显式解绑(released_account_id 不回填)", () => { + assert.match( + resourceWrite, + /NOT EXISTS \(\s*SELECT 1 FROM partners r WHERE r\.released_account_id = a\.id\s*\)/, + "被显式解绑/顶替过的资源账号不再自动加入合作方", + ); + assert.match(migration, /ADD COLUMN `released_account_id`/); + assert.match(migration, /partners_released_account_idx/); +}); + +test("空壳默认合作方的名称随昵称补全刷新且不碰有业绩合作方", () => { + assert.match(resourceWrite, /async function refreshPoolPartnerNames/); + assert.match( + resourceWrite, + /NOT EXISTS \(SELECT 1 FROM claims c WHERE c\.partner_id = p\.id\)/, + "有领取记录的合作方不刷新名称", + ); + assert.match( + resourceWrite, + /NOT EXISTS \(SELECT 1 FROM distributions d WHERE d\.partner_id = p\.id\)/, + "有分发记录的合作方不刷新名称", + ); + assert.match( + resourceWrite, + /p\.wecom_external_user_id IS NULL OR p\.wecom_external_user_id = ''/, + "已绑企微的合作方不刷新名称", + ); +}); + +test("bootstrap 在返回面板数据前同步默认合作方", () => { + const syncIndex = bootstrapRoute.indexOf("await ensurePartnersForPoolAccounts"); + const dashboardIndex = bootstrapRoute.indexOf("await getDashboardData("); + assert.ok(syncIndex >= 0, "bootstrap 需要调用默认合作方同步"); + assert.ok( + dashboardIndex > syncIndex, + "同步必须发生在 getDashboardData 之前,首次加载即可见", + ); +}); + +test("资源导入与手动新增后都会触发默认合作方同步", () => { + assert.match( + resourcesImport, + /runInBackground\(\s*ensurePartnersForPoolAccounts\(\)/, + "批量导入 commit 后同步", + ); + assert.match( + resourcesInsert, + /runInBackground\(\s*ensurePartnersForPoolAccounts\(\)/, + "手动新增后同步", + ); +}); + +test("partner_bind_account 换绑时可顶替空壳占用方", () => { + const body = section(actionRoute, "partner_bind_account"); + assert.match(body, /isShellAccountHolder/, "占用方为空壳时判断是否可顶替"); + assert.match( + body, + /UPDATE partners SET account_id = \?, released_account_id = '' WHERE id = \?/, + "绑定目标时清掉自身的释放标记", + ); + const helper = actionRoute.slice( + actionRoute.indexOf("async function isShellAccountHolder"), + ); + assert.match(helper, /SELECT 1 FROM claims WHERE partner_id = \? LIMIT 1/); + assert.match( + helper, + /SELECT 1 FROM distributions WHERE partner_id = \? LIMIT 1/, + "有领取或分发记录的占用方不可顶替,仍走人工解绑", + ); +}); diff --git a/tests/rendered-html.test.mjs b/tests/rendered-html.test.mjs index a59ca44..c12922b 100644 --- a/tests/rendered-html.test.mjs +++ b/tests/rendered-html.test.mjs @@ -31,7 +31,7 @@ test("merges distribution detail into the task center", async () => { assert.match(adminApp, /查看明细/); assert.match(adminApp, /function TaskDistributionDetail/); - assert.match(adminApp, /选择一个数据回收任务/); + assert.match(adminApp, /选择一个任务进入回收队列/); assert.doesNotMatch(adminApp, /"distributions"/); assert.doesNotMatch(adminApp, /选择一个分发任务/); assert.doesNotMatch(adminApp, /distribution-task-/); @@ -87,7 +87,7 @@ test("organizes distribution and recovery by task and imports the verified Feish ]); const snapshot = JSON.parse(snapshotText); - assert.match(adminApp, /选择一个数据回收任务/); + assert.match(adminApp, /选择一个任务进入回收队列/); assert.match(adminApp, /读取表格/); assert.match(adminApp, /当前仅展示/); assert.match(adminApp, /release_distribution/); diff --git a/tests/task-center-stage.test.mjs b/tests/task-center-stage.test.mjs new file mode 100644 index 0000000..66154f5 --- /dev/null +++ b/tests/task-center-stage.test.mjs @@ -0,0 +1,57 @@ +import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const adminApp = readFileSync(new URL("../app/admin-app.tsx", import.meta.url), "utf8"); +const adminCss = readFileSync(new URL("../app/globals.css", import.meta.url), "utf8"); + +test("任务卡单一入口:发布/回收面板整块可点,全卡只有一个「查看明细」", () => { + const start = adminApp.indexOf("function TaskRowCard"); + const end = adminApp.indexOf("function TasksPage"); + const body = adminApp.slice(start, end); + // 两块阶段面板都可整块点击进入任务明细 + assert.match(body, /className="master-panel publish clickable"/); + assert.match(body, /className="master-panel recovery clickable"/); + // 回收面板不再有独立的「查看明细」按钮:全卡仅一处 + assert.equal(body.match(/查看明细/g)?.length, 1); + // 未开通回收的面板保持不可点、保留开通入口 + assert.match(body, /className="master-panel ghost"/); + assert.match(body, /开通回收 ›/); +}); + +test("详情页自述身份:动态 label 说明任务阶段构成", () => { + assert.match(adminApp, /任务详情 · 分发与回收一体/); + assert.match(adminApp, /任务详情 · 仅分发/); + // 头部阶段徽章:含回收(带截止日或同日说明)/ 未开通回收 + assert.match(adminApp, /未开通回收"\s*: task\.recovery_due_at/); + assert.match(adminApp, /含回收 · 回收截止 \$\{formatDate\(task\.recovery_due_at\)\}/); + assert.match(adminApp, /含回收 · 与发布同日截止/); +}); + +test("单一列表按阶段筛选:不拆两套列表,用筛选 chips 过滤生命周期阶段", () => { + const start = adminApp.indexOf("function TaskDistributionDetail"); + const end = adminApp.indexOf("\nfunction ", start + 20); + const body = adminApp.slice(start, end); + assert.match(body, /const \[stageFilter, setStageFilter\] = useState\("all"\);/); + assert.match(body, /key: "pending_publish",\s*\n\s*label: "待发布"/); + assert.match(body, /label: "已发布待数据"/); + assert.match(body, /label: "待收截图"/); + // 待收截图仅在开通回收的任务出现 + assert.match(body, /\.\.\.\(recoveryEnabled\s*\?\s*\[\{/); + // 表格使用过滤后的列表;筛选落空时给出专用空态文案 + assert.match(body, /distributions=\{visibleDistributions\}/); + assert.match(body, /当前筛选下没有分发记录/); + assert.match(body, /stage-filter-tabs/); +}); + +test("阶段面板与筛选 chips 的样式", () => { + assert.match(adminCss, /^\.master-panel\.clickable \{/m); + assert.match(adminCss, /^\.master-panel\.clickable:hover \{/m); + assert.match(adminCss, /^\.stage-filter-tabs \{/m); + assert.match(adminCss, /^\.stage-filter-tabs button\.active \{/m); +}); + +test("数据回收页入口不再使用「数据回收任务」的说法", () => { + assert.match(adminApp, /选择一个任务进入回收队列/); + assert.doesNotMatch(adminApp, /选择一个数据回收任务/); +}); diff --git a/tests/ui-walkthrough-fixes.test.mjs b/tests/ui-walkthrough-fixes.test.mjs index b414cc9..5b096b8 100644 --- a/tests/ui-walkthrough-fixes.test.mjs +++ b/tests/ui-walkthrough-fixes.test.mjs @@ -46,7 +46,8 @@ test("admin waiting label follows the task collection plan", async () => { readFile(new URL("../lib/mvp-db.ts", import.meta.url), "utf8"), ]); assert.match(adminApp, /waitingCollectionLabel/); - assert.match(adminApp, /待设置采集计划/); + // 未设置计划时走默认 7 天兜底,等待文案随之说明而不是停在“待设置” + assert.match(adminApp, /等待D1 · 默认7天/); assert.match(adminApp, /等待D\$\{Math\.min\(\.\.\.days\)\}/); assert.doesNotMatch(adminApp, /"等待D2"/); // 分发查询带上任务级采集计划字段