feat: KOC详情弹窗 + 任务中心单入口改版 + 资源池默认合作方/采集兜底

KOC 详情弹窗(合作方/数据回收/工作台三处入口):
- 新增 KocDetailModal:基本信息 / 绑定KOC账号画像 / 参与任务记录 / 履约漏斗
- 工作台最近分发、回收队列账号名、截图回收合作方名、合作方页名称、分发详情头部合作方均可点开
- 分发表行点击仍打开分发详情(stopPropagation 隔离)

任务中心单入口改版(对齐"单任务双阶段"模型):
- 任务卡发布/回收面板整块可点,全卡仅保留一个「查看明细」,回收面板重复按钮移除
- 详情页自述身份:动态 label(任务详情·分发与回收一体/仅分发)+ 阶段徽章(含回收·截止/未开通回收)
- 内容分发表新增阶段筛选 chips(全部/待发布/已发布待数据/待收截图,未开通回收不显示截图项),筛选落空给专用空态文案
- 数据回收页入口文案改为「选择一个任务进入回收队列」,全站不再区分发布/回收两种任务类型

资源池 KOC 默认加入合作方 + 采集兜底:
- partners 新增 released_account_id(mysql/0023):显式解绑/被顶替的账号不再被默认合作方同步回填
- 任务未设置采集计划时,默认按发布时间起连续采集 7 天,等待文案显示「等待D1 · 默认7天」
This commit is contained in:
ABAPPLO
2026-10-12 11:11:31 +08:00
parent c11afd5f99
commit 87f4d4066d
20 changed files with 1472 additions and 195 deletions

View File

@@ -194,6 +194,12 @@ type Distribution = {
task_collection_start_date: string | null; task_collection_start_date: string | null;
}; };
type StageFilterKey =
| "all"
| "pending_publish"
| "awaiting_metrics"
| "awaiting_screenshot";
type RecoverySortKey = type RecoverySortKey =
| "publish_time" | "publish_time"
| "likes" | "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() { function todayInputValue() {
const parts = new Intl.DateTimeFormat("zh-CN", { const parts = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai", timeZone: "Asia/Shanghai",
@@ -551,6 +560,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
const [customerChatTask, setCustomerChatTask] = useState<Task | null>(null); const [customerChatTask, setCustomerChatTask] = useState<Task | null>(null);
const [metricModal, setMetricModal] = useState<Distribution | null>(null); const [metricModal, setMetricModal] = useState<Distribution | null>(null);
const [detailDistributionId, setDetailDistributionId] = useState<string | null>(null); const [detailDistributionId, setDetailDistributionId] = useState<string | null>(null);
const [detailPartnerId, setDetailPartnerId] = useState<string | null>(null);
const [publishUrlModal, setPublishUrlModal] = useState<{ const [publishUrlModal, setPublishUrlModal] = useState<{
distribution: Distribution; distribution: Distribution;
value: string; value: string;
@@ -1335,6 +1345,8 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
tasks={data.tasks} tasks={data.tasks}
onNavigate={navigate} onNavigate={navigate}
showResources={currentUser.role !== "user"} showResources={currentUser.role !== "user"}
onOpenDetail={(distribution) => setDetailDistributionId(distribution.id)}
onOpenPartner={(partnerId) => setDetailPartnerId(partnerId)}
/> />
)} )}
{activeNav === "tasks" && ( {activeNav === "tasks" && (
@@ -1401,6 +1413,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
return result !== false; return result !== false;
}} }}
onOpenDetail={(distribution) => setDetailDistributionId(distribution.id)} onOpenDetail={(distribution) => setDetailDistributionId(distribution.id)}
onOpenPartner={(partnerId) => setDetailPartnerId(partnerId)}
/> />
)} )}
{activeNav === "resources" && ( {activeNav === "resources" && (
@@ -1422,6 +1435,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
working={working} working={working}
onRefresh={loadData} onRefresh={loadData}
setToast={setToast} setToast={setToast}
onOpenPartner={(partner) => setDetailPartnerId(partner.id)}
/> />
)} )}
{activeNav === "users" && isManager && ( {activeNav === "users" && isManager && (
@@ -1446,6 +1460,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
onExportScreenshots={exportScreenshotResults} onExportScreenshots={exportScreenshotResults}
exportingTaskId={exportingTaskId} exportingTaskId={exportingTaskId}
onOpenDetail={(distribution) => setDetailDistributionId(distribution.id)} onOpenDetail={(distribution) => setDetailDistributionId(distribution.id)}
onOpenPartner={(partnerId) => setDetailPartnerId(partnerId)}
/> />
)} )}
</> </>
@@ -1909,6 +1924,10 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
canUpdatePublishUrl={isManager} canUpdatePublishUrl={isManager}
canRelease={isManager} canRelease={isManager}
onClose={() => setDetailDistributionId(null)} onClose={() => setDetailDistributionId(null)}
onOpenPartner={(partnerId) => {
setDetailDistributionId(null);
setDetailPartnerId(partnerId);
}}
onCollect={collectMetrics} onCollect={collectMetrics}
onFallback={openMetricFallback} onFallback={openMetricFallback}
onUpdatePublishUrl={(distribution) => 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 (
<KocDetailModal
partner={partnerRow}
account={boundAccount}
history={partnerHistory}
onClose={() => setDetailPartnerId(null)}
/>
);
})()}
{toast && <div className="toast"><span>✓</span>{toast}</div>} {toast && <div className="toast"><span>✓</span>{toast}</div>}
{working && <div className="working-bar" />} {working && <div className="working-bar" />}
</div> </div>
@@ -1988,12 +2028,14 @@ function PartnersPage({
working, working,
onRefresh, onRefresh,
setToast, setToast,
onOpenPartner,
}: { }: {
partners: Partner[]; partners: Partner[];
accounts: Account[]; accounts: Account[];
working: boolean; working: boolean;
onRefresh: () => Promise<void>; onRefresh: () => Promise<void>;
setToast: (message: string) => void; setToast: (message: string) => void;
onOpenPartner: (partner: Partner) => void;
}) { }) {
const [status, setStatus] = useState<WecomStatus | null>(null); const [status, setStatus] = useState<WecomStatus | null>(null);
const [statusLoading, setStatusLoading] = useState(true); const [statusLoading, setStatusLoading] = useState(true);
@@ -2328,6 +2370,25 @@ function PartnersPage({
return map; return map;
}, [partners]); }, [partners]);
// 资源池默认同步生成的空壳合作方:无企微绑定、无领取/完成记录,
// 换绑时服务端会自动顶替,选择器里这些资源账号保持可选。
const defaultShellAccountIds = useMemo(() => {
const ids = new Set<string>();
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 filteredResourceAccounts = useMemo(() => {
const q = resourcePickerSearch.trim().toLowerCase(); const q = resourcePickerSearch.trim().toLowerCase();
if (!q) return accounts; if (!q) return accounts;
@@ -2456,7 +2517,7 @@ function PartnersPage({
<div className="panel-heading"> <div className="panel-heading">
<div> <div>
<h2>合作方</h2> <h2>合作方</h2>
<p>群主型合作方由任务负责人自动生成(负责任务群分发);外部KOC由领取流程自动生成,绑定外部联系人后接收催办</p> <p>群主型合作方由任务负责人自动生成(负责任务群分发);资源池 KOC 默认加入外部KOC合作方,绑定外部联系人或领取任务后接收催办</p>
</div> </div>
<button <button
type="button" type="button"
@@ -2495,7 +2556,7 @@ function PartnersPage({
{filteredPartners.length === 0 && ( {filteredPartners.length === 0 && (
<div className="partner-row empty"> <div className="partner-row empty">
{partners.length === 0 {partners.length === 0
? "暂无合作方:点击右上角新建群主合作方,或等待 KOC 领取任务后自动生成外部KOC记录" ? "暂无合作方:新建群主合作方、在「KOC 资源」导入账号,或等待 KOC 领取任务后自动生成外部KOC记录"
: "当前筛选下没有合作方"} : "当前筛选下没有合作方"}
</div> </div>
)} )}
@@ -2505,7 +2566,11 @@ function PartnersPage({
const isGroupOwner = partner.partner_type === "group_owner"; const isGroupOwner = partner.partner_type === "group_owner";
return ( return (
<div className="partner-row" key={partner.id}> <div className="partner-row" key={partner.id}>
<span> <span
className="partner-name-cell"
title="点击查看 KOC 详情"
onClick={() => onOpenPartner(partner)}
>
<strong>{partner.name}</strong> <strong>{partner.name}</strong>
<em className={`partner-type-badge ${isGroupOwner ? "group-owner" : "external-koc"}`}> <em className={`partner-type-badge ${isGroupOwner ? "group-owner" : "external-koc"}`}>
{isGroupOwner ? "群主" : "外部KOC"} {isGroupOwner ? "群主" : "外部KOC"}
@@ -2803,7 +2868,7 @@ function PartnersPage({
<div className="modal-heading"> <div className="modal-heading">
<div> <div>
<h2>选择 {resourcePickerPartner.name} 的 KOC 资源</h2> <h2>选择 {resourcePickerPartner.name} 的 KOC 资源</h2>
<p>绑定后可在合作方列表直接查看该 KOC 的联系方式;每个资源账号只能绑定一个外部KOC合作方。</p> <p>绑定后可在合作方列表直接查看该 KOC 的联系方式;每个资源账号只能绑定一个外部KOC合作方,换绑时会自动顶替资源池默认生成的合作方。</p>
</div> </div>
<button <button
type="button" type="button"
@@ -2836,7 +2901,11 @@ function PartnersPage({
) : ( ) : (
filteredResourceAccounts.map((account) => { filteredResourceAccounts.map((account) => {
const holderName = partnerNameByAccountId.get(account.id); const holderName = partnerNameByAccountId.get(account.id);
const boundByOther = Boolean(holderName) && account.id !== resourcePickerPartner.account_id; const heldByDefaultShell = defaultShellAccountIds.has(account.id);
const boundByOther =
Boolean(holderName) &&
!heldByDefaultShell &&
account.id !== resourcePickerPartner.account_id;
const isCurrent = account.id === resourcePickerPartner.account_id; const isCurrent = account.id === resourcePickerPartner.account_id;
return ( return (
<button <button
@@ -2862,6 +2931,8 @@ function PartnersPage({
<span className="wecom-picker-current">已绑定:{holderName}</span> <span className="wecom-picker-current">已绑定:{holderName}</span>
) : isCurrent ? ( ) : isCurrent ? (
<span className="wecom-picker-current">当前</span> <span className="wecom-picker-current">当前</span>
) : heldByDefaultShell && holderName ? (
<span className="wecom-picker-current">默认合作方:{holderName}</span>
) : null} ) : null}
</button> </button>
); );
@@ -4123,6 +4194,8 @@ function OverviewPage({
tasks, tasks,
onNavigate, onNavigate,
showResources, showResources,
onOpenDetail,
onOpenPartner,
}: { }: {
stats: { stats: {
resources: number; resources: number;
@@ -4139,6 +4212,8 @@ function OverviewPage({
tasks: Task[]; tasks: Task[];
onNavigate: (key: NavKey) => void; onNavigate: (key: NavKey) => void;
showResources: boolean; showResources: boolean;
onOpenDetail: (distribution: Distribution) => void;
onOpenPartner: (partnerId: string) => void;
}) { }) {
const cards = [ const cards = [
{ {
@@ -4285,6 +4360,8 @@ function OverviewPage({
<DistributionTable <DistributionTable
distributions={distributions.slice(0, 6)} distributions={distributions.slice(0, 6)}
compact compact
onOpenDetail={onOpenDetail}
onOpenPartner={onOpenPartner}
/> />
</section> </section>
</> </>
@@ -4607,7 +4684,11 @@ function TaskRowCard({
</div> </div>
</div> </div>
<div className="master-panels"> <div className="master-panels">
<section className="master-panel publish"> <section
className="master-panel publish clickable"
title="查看任务明细(分发与回收一体)"
onClick={() => onDetail(task)}
>
<div className="panel-head"> <div className="panel-head">
<b>发布</b> <b>发布</b>
<span className="panel-count">{task.quantity} 篇内容</span> <span className="panel-count">{task.quantity} 篇内容</span>
@@ -4617,13 +4698,15 @@ function TaskRowCard({
{panelChat(task, "任务群")} {panelChat(task, "任务群")}
</div> </div>
<div className="panel-foot"> <div className="panel-foot">
<button type="button" className="panel-detail" onClick={() => onDetail(task)}> <span className="panel-detail">查看明细 ›</span>
查看明细 ›
</button>
</div> </div>
</section> </section>
{recoveryEnabled ? ( {recoveryEnabled ? (
<section className="master-panel recovery"> <section
className="master-panel recovery clickable"
title="查看任务明细(分发与回收一体)"
onClick={() => onDetail(task)}
>
<div className="panel-head"> <div className="panel-head">
<b>回收</b> <b>回收</b>
<span className="panel-count"> <span className="panel-count">
@@ -4635,11 +4718,6 @@ function TaskRowCard({
<div className="panel-body"> <div className="panel-body">
{panelMetric(task.dist_submitted ?? 0, task.dist_total ?? 0, "已收取", "recovery")} {panelMetric(task.dist_submitted ?? 0, task.dist_total ?? 0, "已收取", "recovery")}
</div> </div>
<div className="panel-foot">
<button type="button" className="panel-detail" onClick={() => onDetail(task)}>
查看明细 ›
</button>
</div>
</section> </section>
) : ( ) : (
<section className="master-panel ghost"> <section className="master-panel ghost">
@@ -4689,6 +4767,7 @@ function TasksPage({
onCreateRecovery, onCreateRecovery,
onRelease, onRelease,
onOpenDetail, onOpenDetail,
onOpenPartner,
}: { }: {
tasks: Task[]; tasks: Task[];
distributions: Distribution[]; distributions: Distribution[];
@@ -4708,6 +4787,7 @@ function TasksPage({
onCreateRecovery: (task: Task) => void; onCreateRecovery: (task: Task) => void;
onRelease: (distribution: Distribution) => Promise<boolean>; onRelease: (distribution: Distribution) => Promise<boolean>;
onOpenDetail: (distribution: Distribution) => void; onOpenDetail: (distribution: Distribution) => void;
onOpenPartner: (partnerId: string) => void;
}) { }) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null); const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"grouped" | "flat">("grouped"); const [viewMode, setViewMode] = useState<"grouped" | "flat">("grouped");
@@ -4845,6 +4925,7 @@ function TasksPage({
onRelease={onRelease} onRelease={onRelease}
onBack={() => setSelectedTaskId(null)} onBack={() => setSelectedTaskId(null)}
onOpenDetail={onOpenDetail} onOpenDetail={onOpenDetail}
onOpenPartner={onOpenPartner}
/> />
); );
} }
@@ -5087,6 +5168,13 @@ function TaskDetailHeader({
label: string; label: string;
onBack: () => void; 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 ( return (
<section className="task-detail-header"> <section className="task-detail-header">
<button className="detail-back" onClick={onBack}>← 返回任务列表</button> <button className="detail-back" onClick={onBack}>← 返回任务列表</button>
@@ -5100,6 +5188,7 @@ function TaskDetailHeader({
<span>{task.content_format === "video" ? "视频" : "图文"}</span> <span>{task.content_format === "video" ? "视频" : "图文"}</span>
<span>{task.quantity} 篇</span> <span>{task.quantity} 篇</span>
<span>{formatDate(task.due_at)} 截止</span> <span>{formatDate(task.due_at)} 截止</span>
<span className="tag-weak pair-tag">{pairTag}</span>
</span> </span>
</div> </div>
{task.source_url && ( {task.source_url && (
@@ -5119,6 +5208,7 @@ function TaskDistributionDetail({
onRelease, onRelease,
onBack, onBack,
onOpenDetail, onOpenDetail,
onOpenPartner,
}: { }: {
task: Task; task: Task;
distributions: Distribution[]; distributions: Distribution[];
@@ -5127,19 +5217,52 @@ function TaskDistributionDetail({
onRelease: (distribution: Distribution) => Promise<boolean>; onRelease: (distribution: Distribution) => Promise<boolean>;
onBack: () => void; onBack: () => void;
onOpenDetail: (distribution: Distribution) => void; onOpenDetail: (distribution: Distribution) => void;
onOpenPartner: (partnerId: string) => void;
}) { }) {
const [releaseTarget, setReleaseTarget] = useState<Distribution | null>(null); const [releaseTarget, setReleaseTarget] = useState<Distribution | null>(null);
const [stageFilter, setStageFilter] = useState<StageFilterKey>("all");
const taskDistributions = distributions.filter( const taskDistributions = distributions.filter(
(item) => item.task_id === task.id, (item) => item.task_id === task.id,
); );
const recoveryEnabled = Number(task.recovery_enabled ?? 1) === 1; const recoveryEnabled = Number(task.recovery_enabled ?? 1) === 1;
const published = taskDistributions.filter((item) => item.publish_url).length; const published = taskDistributions.filter((item) => item.publish_url).length;
const submitted = taskDistributions.filter((item) => item.result_submitted_at).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 ( return (
<div className="stack"> <div className="stack">
<TaskDetailHeader <TaskDetailHeader
task={task} task={task}
label="内容分发" label={recoveryEnabled ? "任务详情 · 分发与回收一体" : "任务详情 · 仅分发"}
onBack={onBack} onBack={onBack}
/> />
<div className="inline-summary"> <div className="inline-summary">
@@ -5166,13 +5289,31 @@ function TaskDistributionDetail({
</div> </div>
<section className="panel table-panel"> <section className="panel table-panel">
<div className="panel-heading"> <div className="panel-heading">
<div><h2>内容分发表</h2><p>实际发布账号在链接回填后自动补齐</p></div> <div><h2>内容分发表</h2><p>每行是一篇内容从领取到回收的完整状态,实际发布账号在链接回填后自动补齐</p></div>
</div>
<div className="stage-filter-tabs" role="group" aria-label="按阶段筛选分发明细">
{stageOptions.map((option) => (
<button
key={option.key}
type="button"
className={stageFilter === option.key ? "active" : ""}
onClick={() => setStageFilter(option.key)}
>
{option.label}({option.count})
</button>
))}
</div> </div>
<DistributionTable <DistributionTable
distributions={taskDistributions} distributions={visibleDistributions}
emptyHint={
taskDistributions.length > 0 && visibleDistributions.length === 0
? "当前筛选下没有分发记录"
: undefined
}
onRelease={canRelease ? setReleaseTarget : undefined} onRelease={canRelease ? setReleaseTarget : undefined}
showRecovery={recoveryEnabled} showRecovery={recoveryEnabled}
onOpenDetail={onOpenDetail} onOpenDetail={onOpenDetail}
onOpenPartner={onOpenPartner}
/> />
</section> </section>
{releaseTarget && ( {releaseTarget && (
@@ -5243,15 +5384,19 @@ function DistributionTable({
onRelease, onRelease,
showRecovery = false, showRecovery = false,
onOpenDetail, onOpenDetail,
onOpenPartner,
emptyHint,
}: { }: {
distributions: Distribution[]; distributions: Distribution[];
compact?: boolean; compact?: boolean;
onRelease?: (distribution: Distribution) => void; onRelease?: (distribution: Distribution) => void;
showRecovery?: boolean; showRecovery?: boolean;
onOpenDetail?: (distribution: Distribution) => void; onOpenDetail?: (distribution: Distribution) => void;
onOpenPartner?: (partnerId: string) => void;
emptyHint?: string;
}) { }) {
// 已发布但还没采到数据的行:有计划时按首个采集日显示,没计划时 // 已发布但还没采到数据的行:有计划时按首个采集日显示;没计划时
// 明说待设置,而不是写死「等待D2」误导运营。 // 走默认 7 天兜底(自发布日起),而不是写死「等待D2」误导运营。
const waitingCollectionLabel = (item: Distribution) => { const waitingCollectionLabel = (item: Distribution) => {
let days: number[] = []; let days: number[] = [];
try { try {
@@ -5265,7 +5410,7 @@ function DistributionTable({
days = []; days = [];
} }
if (!item.task_collection_start_date || days.length === 0) { if (!item.task_collection_start_date || days.length === 0) {
return "待设置采集计划"; return "等待D1 · 默认7天";
} }
return `等待D${Math.min(...days)}`; return `等待D${Math.min(...days)}`;
}; };
@@ -5298,7 +5443,23 @@ function DistributionTable({
<div><strong>{item.content_title}</strong><span>{item.task_name} · {item.task_brand}</span></div> <div><strong>{item.content_title}</strong><span>{item.task_name} · {item.task_brand}</span></div>
</div> </div>
</td> </td>
<td><strong className="plain-strong">{item.partner_name}</strong></td> <td>
{onOpenPartner ? (
<button
type="button"
className="koc-link"
title="点击查看 KOC 详情"
onClick={(event) => {
event.stopPropagation();
onOpenPartner(item.partner_id);
}}
>
{item.partner_name}
</button>
) : (
<strong className="plain-strong">{item.partner_name}</strong>
)}
</td>
<td> <td>
{item.account_nickname ? ( {item.account_nickname ? (
<div className="account-cell"> <div className="account-cell">
@@ -5359,7 +5520,9 @@ function DistributionTable({
))} ))}
</tbody> </tbody>
</table> </table>
{!compact && distributions.length === 0 && <div className="table-empty">暂无分发记录</div>} {!compact && distributions.length === 0 && (
<div className="table-empty">{emptyHint ?? "暂无分发记录"}</div>
)}
</div> </div>
); );
} }
@@ -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<{ const [previewGallery, setPreviewGallery] = useState<{
images: AdminPreviewImage[]; images: AdminPreviewImage[];
activeIndex: number; activeIndex: number;
@@ -5476,7 +5645,23 @@ function ScreenshotTaskTable({ distributions }: { distributions: Distribution[]
<div><strong>{item.content_title}</strong><span>{item.task_name}</span></div> <div><strong>{item.content_title}</strong><span>{item.task_name}</span></div>
</div> </div>
</td> </td>
<td><strong className="plain-strong">{item.partner_name}</strong></td> <td>
{onOpenPartner ? (
<button
type="button"
className="koc-link"
title="点击查看 KOC 详情"
onClick={(event) => {
event.stopPropagation();
onOpenPartner(item.partner_id);
}}
>
{item.partner_name}
</button>
) : (
<strong className="plain-strong">{item.partner_name}</strong>
)}
</td>
<td><span className="date-value">{formatDate(item.claimed_at, true)}</span></td> <td><span className="date-value">{formatDate(item.claimed_at, true)}</span></td>
<td><span className="date-value">{formatDate(item.publish_time, true)}</span></td> <td><span className="date-value">{formatDate(item.publish_time, true)}</span></td>
<td><span className="date-value">{formatDate(item.result_submitted_at, true)}</span></td> <td><span className="date-value">{formatDate(item.result_submitted_at, true)}</span></td>
@@ -5958,7 +6143,7 @@ function ResourcesPage({
</div> </div>
<div> <div>
<h2>资源从合作与历史资产中持续沉淀</h2> <h2>资源从合作与历史资产中持续沉淀</h2>
<p>发布链接会自动建档,已有KOC资源也可以使用标准模板批量导入。</p> <p>发布链接会自动建档,已有KOC资源也可以使用标准模板批量导入。资源池中的 KOC 会默认加入合作方列表。</p>
</div> </div>
<div className="resource-rule"> <div className="resource-rule">
<span>唯一去重规则</span> <span>唯一去重规则</span>
@@ -6692,6 +6877,7 @@ function DistributionDetailModal({
onFallback, onFallback,
onUpdatePublishUrl, onUpdatePublishUrl,
onRelease, onRelease,
onOpenPartner,
}: { }: {
item: Distribution; item: Distribution;
recoveryEnabled: boolean; recoveryEnabled: boolean;
@@ -6704,6 +6890,7 @@ function DistributionDetailModal({
onFallback: (distribution: Distribution) => void; onFallback: (distribution: Distribution) => void;
onUpdatePublishUrl: (distribution: Distribution) => void; onUpdatePublishUrl: (distribution: Distribution) => void;
onRelease: (distribution: Distribution) => void; onRelease: (distribution: Distribution) => void;
onOpenPartner?: (partnerId: string) => void;
}) { }) {
const [previewGallery, setPreviewGallery] = useState<{ const [previewGallery, setPreviewGallery] = useState<{
images: AdminPreviewImage[]; images: AdminPreviewImage[];
@@ -6750,7 +6937,21 @@ function DistributionDetailModal({
<div className="detail-meta"> <div className="detail-meta">
<span>{item.task_name}</span> <span>{item.task_name}</span>
<span>· {item.task_brand}</span> <span>· {item.task_brand}</span>
<span>· 合作方 {item.partner_name}</span> {onOpenPartner ? (
<span className="detail-meta-koc">
· 合作方{" "}
<button
type="button"
className="koc-link"
title="点击查看 KOC 详情"
onClick={() => onOpenPartner(item.partner_id)}
>
{item.partner_name}
</button>
</span>
) : (
<span>· 合作方 {item.partner_name}</span>
)}
{item.claimant_name && <span>· 领取人 {item.claimant_name}</span>} {item.claimant_name && <span>· 领取人 {item.claimant_name}</span>}
</div> </div>
</div> </div>
@@ -6857,7 +7058,7 @@ function DistributionDetailModal({
<strong> <strong>
{item.task_collection_start_date && collectionDays.length > 0 {item.task_collection_start_date && collectionDays.length > 0
? `${item.task_collection_start_date} 起 D${collectionDays.join("/D")}` ? `${item.task_collection_start_date} 起 D${collectionDays.join("/D")}`
: "待设置"} : "默认发布后7天"}
</strong> </strong>
</div> </div>
</div> </div>
@@ -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 (
<div
className="modal-backdrop"
role="presentation"
onMouseDown={onClose}
>
<div
className="modal-card distribution-detail-modal"
role="dialog"
aria-modal="true"
aria-label={`KOC详情:${partner.name}`}
onMouseDown={(event) => event.stopPropagation()}
>
{/* 头部:KOC 名称 + 类型徽章 + 概览 */}
<div className="distribution-detail-header">
<div className="modal-heading" style={{ marginBottom: 0 }}>
<div>
<p className="eyebrow">KOC 详情</p>
<h2>{partner.name}</h2>
</div>
<button type="button" onClick={onClose} aria-label="关闭">×</button>
</div>
<div className="detail-badges">
<span className="master-chip">{isGroupOwner ? "群主合作方" : "外部KOC"}</span>
<span className="status-pill published">累计领取 {partner.claimed_total}</span>
<span className="status-pill complete">累计完成 {partner.completed_total}</span>
</div>
<div className="detail-meta">
<span>{isGroupOwner ? partner.wecom_userid || "未填 userid" : partner.wecom_name || "未留企微名"}</span>
<span>· 负责人 {partner.owner || "—"}</span>
{account && <span>· 绑定账号 {account.nickname || partner.account_nickname}</span>}
</div>
</div>
<div className="detail-body">
<section className="detail-section">
<div className="detail-section-head">
<b>① 基本信息</b>
<span className="master-chip">{isGroupOwner ? "群主" : "外部KOC"}</span>
</div>
<div className="detail-section-body">
<div className="detail-kv-grid">
<div className="detail-kv"><span>合作方名称</span><strong>{partner.name}</strong></div>
<div className="detail-kv"><span>合作方类型</span><strong>{isGroupOwner ? "群主" : "外部KOC"}</strong></div>
<div className="detail-kv"><span>企业微信名</span><strong>{partner.wecom_name || "—"}</strong></div>
<div className="detail-kv"><span>企微 userid</span><strong>{partner.wecom_userid || "—"}</strong></div>
<div className="detail-kv"><span>负责人</span><strong>{partner.owner || "—"}</strong></div>
<div className="detail-kv"><span>外部联系人 ID</span><strong>{partner.wecom_external_user_id || "—"}</strong></div>
<div className="detail-kv"><span>累计领取</span><strong>{partner.claimed_total} 篇</strong></div>
<div className="detail-kv"><span>累计完成</span><strong>{partner.completed_total} 篇</strong></div>
</div>
</div>
</section>
<section className="detail-section">
<div className="detail-section-head">
<b>② 绑定 KOC 账号(资源)</b>
{account ? (
<span className="status-pill complete">已绑定</span>
) : (
<span className="status-pill claimed">未绑定</span>
)}
</div>
<div className="detail-section-body">
{account ? (
<div className="detail-kv-grid">
<div className="detail-kv"><span>账号昵称</span><strong>{account.nickname || "—"}</strong></div>
<div className="detail-kv"><span>平台</span><strong>{account.platform}</strong></div>
<div className="detail-kv"><span>账号ID</span><strong>{account.public_account_id || "—"}</strong></div>
<div className="detail-kv"><span>粉丝数</span><strong>{account.followers ? formatNumber(account.followers) : "—"}</strong></div>
<div className="detail-kv"><span>IP属地</span><strong>{account.ip_location || "—"}</strong></div>
<div className="detail-kv"><span>性别</span><strong>{account.gender || "—"}</strong></div>
<div className="detail-kv"><span>笔记数</span><strong>{account.post_count || "—"}</strong></div>
<div className="detail-kv"><span>平均观看</span><strong>{account.avg_views ? formatNumber(account.avg_views) : "—"}</strong></div>
<div className="detail-kv"><span>合作来源</span><strong>{account.cooperation_source || "—"}</strong></div>
<div className="detail-kv"><span>联系方式</span><strong>{account.current_contact || "—"}</strong></div>
<div className="detail-kv"><span>首次发现</span><strong>{account.first_seen_at ? formatDate(account.first_seen_at, true) : "—"}</strong></div>
<div className="detail-kv"><span>最近发现</span><strong>{account.last_seen_at ? formatDate(account.last_seen_at, true) : "—"}</strong></div>
{account.profile_url && (
<div className="detail-kv"><span>主页链接</span><a href={account.profile_url} target="_blank" rel="noopener noreferrer">打开主页 ↗</a></div>
)}
{account.tags && (
<div className="detail-kv detail-kv-wide"><span>标签</span><strong>{account.tags}</strong></div>
)}
{account.bio && (
<div className="detail-kv detail-kv-wide"><span>简介</span><strong>{account.bio}</strong></div>
)}
</div>
) : (
<span className="muted">
{isGroupOwner
? "群主合作方无需绑定 KOC 资源账号"
: "尚未绑定 KOC 资源账号,可在「合作方」页绑定后查看账号画像"}
</span>
)}
</div>
</section>
<section className="detail-section">
<div className="detail-section-head">
<b>③ 参与任务记录</b>
<span className="master-chip">
{history.length} 次领取 · {taskCount} 个任务 · 发布 {publishedCount}
</span>
</div>
<div className="detail-section-body">
{history.length === 0 ? (
<span className="muted">暂无领取记录</span>
) : (
<div className="koc-history-list">
{history.map((item) => {
const itemMetrics = latestPublicMetrics(item);
const itemHasMetrics = itemMetrics.likes !== null;
const itemCollectionStatus =
item.collection_status || (itemHasMetrics ? "success" : "pending");
return (
<div className="koc-history-row" key={item.id}>
<div className="koc-history-main">
<strong>{item.content_title}</strong>
<span>
{item.task_name} · {formatDate(item.claimed_at, true)}领取
{item.publish_url
? ` · ${formatDate(item.publish_time, true)}发布`
: " · 待发布"}
</span>
</div>
<div className="koc-history-side">
<span className={`status-pill ${item.status}`}>{statusLabel(item.status)}</span>
<span className={`collection-status ${itemCollectionStatus}`}>
{collectionStatusLabel(itemCollectionStatus, itemHasMetrics)}
</span>
{itemHasMetrics && (
<span className="koc-history-metrics">
赞 {formatNumber(itemMetrics.likes)} · 藏 {formatNumber(itemMetrics.collects)} · 评 {formatNumber(itemMetrics.comments)}
</span>
)}
{item.exposure !== null && item.views !== null && (
<span className="koc-history-metrics">
曝光 {formatNumber(item.exposure)} · 阅读 {formatNumber(item.views)}
</span>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</section>
{/* 漏斗概览:快速判断这个 KOC 的履约质量 */}
{history.length > 0 && (
<div className="koc-summary-strip">
<span>领取 <b>{history.length}</b></span>
<i>→</i>
<span>发布 <b>{publishedCount}</b></span>
<i>→</i>
<span>回收完成 <b>{completedCount}</b></span>
</div>
)}
</div>
</div>
</div>
);
}
function RecoveryPage({ function RecoveryPage({
tasks, tasks,
distributions, distributions,
@@ -7056,6 +7440,7 @@ function RecoveryPage({
onExportScreenshots, onExportScreenshots,
exportingTaskId, exportingTaskId,
onOpenDetail, onOpenDetail,
onOpenPartner,
}: { }: {
tasks: Task[]; tasks: Task[];
distributions: Distribution[]; distributions: Distribution[];
@@ -7078,6 +7463,7 @@ function RecoveryPage({
onExportScreenshots: (task: Task) => void; onExportScreenshots: (task: Task) => void;
exportingTaskId: string | null; exportingTaskId: string | null;
onOpenDetail: (distribution: Distribution) => void; onOpenDetail: (distribution: Distribution) => void;
onOpenPartner: (partnerId: string) => void;
}) { }) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null); const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [recoverySort, setRecoverySort] = useState<{ const [recoverySort, setRecoverySort] = useState<{
@@ -7089,8 +7475,8 @@ function RecoveryPage({
return ( return (
<div className="stack"> <div className="stack">
<div className="section-intro"> <div className="section-intro">
<div><p className="eyebrow">按任务回收</p><h2>选择一个数据回收任务</h2></div> <div><p className="eyebrow">按任务回收</p><h2>选择一个任务进入回收队列</h2></div>
<p>进入任务后设置自动采集日期,统一查看每篇笔记的最新互动数据。</p> <p>进入任务后可设置自动采集日期;未设置时默认自每篇发布时间起连续采集 7 天。</p>
</div> </div>
<TaskScopeList <TaskScopeList
tasks={tasks} tasks={tasks}
@@ -7293,7 +7679,21 @@ function RecoveryPage({
<strong>{item.content_title}</strong> <strong>{item.content_title}</strong>
)} )}
<span className="platform-meta-line"> <span className="platform-meta-line">
<span>{item.account_nickname || "待识别账号"}</span> {item.partner_id ? (
<button
type="button"
className="koc-link"
title="点击查看 KOC 详情"
onClick={(event) => {
event.stopPropagation();
onOpenPartner(item.partner_id);
}}
>
{item.account_nickname || "待识别账号"}
</button>
) : (
<span>{item.account_nickname || "待识别账号"}</span>
)}
{item.account_platform && <PlatformBadge platform={item.account_platform} compact />} {item.account_platform && <PlatformBadge platform={item.account_platform} compact />}
</span> </span>
</div> </div>
@@ -7471,7 +7871,10 @@ function RecoveryPage({
</button> </button>
</div> </div>
</div> </div>
<ScreenshotTaskTable distributions={taskDistributions} /> <ScreenshotTaskTable
distributions={taskDistributions}
onOpenPartner={onOpenPartner}
/>
</section> </section>
</> </>
)} )}
@@ -7495,9 +7898,11 @@ function CollectionScheduleCard({
const [startDate, setStartDate] = useState( const [startDate, setStartDate] = useState(
task.collection_start_date || todayInputValue(), task.collection_start_date || todayInputValue(),
); );
const [days, setDays] = useState<number[]>( const [days, setDays] = useState<number[]>(() => {
parseCollectionDays(task.collection_days), const parsed = parseCollectionDays(task.collection_days);
); // 未保存过计划时默认勾选 7 天,与服务端兜底逻辑保持一致。
return parsed.length > 0 ? parsed : [...DEFAULT_COLLECTION_DAYS];
});
const toggleDay = (day: number) => { const toggleDay = (day: number) => {
setDays((current) => setDays((current) =>
@@ -7513,7 +7918,7 @@ function CollectionScheduleCard({
<div> <div>
<p className="eyebrow">自动采集计划</p> <p className="eyebrow">自动采集计划</p>
<h2>选择开始日期与采集日</h2> <h2>选择开始日期与采集日</h2>
<span>勾选第1天到第7天,所选日期均在北京时间09:00自动采集。</span> <span>勾选第1天到第7天,所选日期均在北京时间09:00自动采集;未保存计划时默认连采 7 天。</span>
</div> </div>
<div className="schedule-save-area"> <div className="schedule-save-area">
<span>已选择 {days.length} 天</span> <span>已选择 {days.length} 天</span>

View File

@@ -15,6 +15,7 @@ import {
import { import {
collectDistributionMetrics, collectDistributionMetrics,
createCollectionRunTasks, createCollectionRunTasks,
parseCollectionDays,
retryFailedCollections, retryFailedCollections,
runDueScheduledCollections, runDueScheduledCollections,
shanghaiDateFromTimestamp, 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<typeof getRawDb>,
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) { export async function POST(request: Request) {
if (!(await isAdminRequest(request))) return adminForbidden(); if (!(await isAdminRequest(request))) return adminForbidden();
try { try {
@@ -338,20 +371,7 @@ export async function POST(request: Request) {
if (current.publish_url === publishUrl) { if (current.publish_url === publishUrl) {
return Response.json(await getDashboardData()); return Response.json(await getDashboardData());
} }
let collectionDays: number[] = []; const collectionDays = parseCollectionDays(current.collection_days);
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 isScheduled = Boolean( const isScheduled = Boolean(
current.collection_start_date && collectionDays.length > 0, current.collection_start_date && collectionDays.length > 0,
); );
@@ -384,10 +404,10 @@ export async function POST(request: Request) {
) )
.bind( .bind(
publishUrl, publishUrl,
isScheduled ? "scheduled" : "pending", "scheduled",
isScheduled isScheduled
? `管理员已更新链接,等待${collectionDays.length}个采集日` ? `管理员已更新链接,等待${collectionDays.length}个采集日`
: "管理员已更新链接,等待设置采集计划", : "管理员已更新链接,默认发布后7天自动采集",
distributionId, distributionId,
), ),
db db
@@ -412,19 +432,20 @@ export async function POST(request: Request) {
current.collection_start_date, current.collection_start_date,
collectionDays, 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( runInBackground(
enrichDistributionAccount( enrichDistributionAccount(
db, db,
@@ -772,8 +793,10 @@ export async function POST(request: Request) {
} }
if (!accountId) { if (!accountId) {
await db await db
.prepare("UPDATE partners SET account_id = '' WHERE id = ?") .prepare(
.bind(partnerId) "UPDATE partners SET account_id = '', released_account_id = ? WHERE id = ?",
)
.bind((partner.account_id ?? "").trim(), partnerId)
.run(); .run();
return Response.json({ partnerId, accountId: "" }); return Response.json({ partnerId, accountId: "" });
} }
@@ -784,21 +807,39 @@ export async function POST(request: Request) {
if (!account) return Response.json({ error: "KOC 资源不存在" }, { status: 404 }); if (!account) return Response.json({ error: "KOC 资源不存在" }, { status: 404 });
const holder = await db const holder = await db
.prepare( .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) .bind(accountId, partnerId)
.first<{ id: string; name: string }>(); .first<PartnerAccountHolderRow>();
let displacedShellName: string | null = null;
if (holder) { if (holder) {
return Response.json( if (!(await isShellAccountHolder(db, holder))) {
{ error: `该 KOC 资源已绑定合作方「${holder.name}」,请先在对方条目上解绑` }, return Response.json(
{ status: 400 }, { 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 await db
.prepare("UPDATE partners SET account_id = ? WHERE id = ?") .prepare(
"UPDATE partners SET account_id = ?, released_account_id = '' WHERE id = ?",
)
.bind(accountId, partnerId) .bind(accountId, partnerId)
.run(); .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") { } else if (body.action === "partner_create_from_account") {
const forbidden = managerGate(); const forbidden = managerGate();
if (forbidden) return forbidden; if (forbidden) return forbidden;

View File

@@ -14,6 +14,7 @@ import {
getRawDb, getRawDb,
seedIfEmpty, seedIfEmpty,
} from "../../../lib/mvp-db"; } from "../../../lib/mvp-db";
import { ensurePartnersForPoolAccounts } from "../../../lib/resource-write";
import { authForbidden, getRequestPrincipal } from "../../../lib/user-auth"; import { authForbidden, getRequestPrincipal } from "../../../lib/user-auth";
import { getOwnerScope, ownerScopeUserid } from "../../../lib/owner-scope"; import { getOwnerScope, ownerScopeUserid } from "../../../lib/owner-scope";
@@ -23,6 +24,13 @@ export async function GET(request: Request) {
try { try {
await ensureSchema(); await ensureSchema();
await seedIfEmpty(); 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 db = getRawDb();
const mcpConfig = resolveCollectionMcpConfig( const mcpConfig = resolveCollectionMcpConfig(
env as unknown as CollectionMcpBindings, env as unknown as CollectionMcpBindings,

View File

@@ -38,6 +38,7 @@ import {
import { enrichDistributionAccount } from "../../../lib/account-enrichment-service"; import { enrichDistributionAccount } from "../../../lib/account-enrichment-service";
import { import {
createCollectionRunTasks, createCollectionRunTasks,
parseCollectionDays,
runDueScheduledCollections, runDueScheduledCollections,
} from "../../../lib/collection-service"; } from "../../../lib/collection-service";
import { normalizeWorkbookImage } from "../../../lib/workbook-image"; import { normalizeWorkbookImage } from "../../../lib/workbook-image";
@@ -664,49 +665,48 @@ async function handlePost(request: Request) {
updatedRows += 1; updatedRows += 1;
} }
} }
if ( if (publishedCount > 0 || publishUrlChangedCount > 0) {
(publishedCount > 0 || publishUrlChangedCount > 0) && const collectionDays = parseCollectionDays(
access.task.collection_start_date && access.task.collection_days,
access.task.collection_days !== "[]" );
) { const isScheduled = Boolean(
let collectionDays: number[] = []; access.task.collection_start_date &&
try { collectionDays.length > 0,
const parsed = JSON.parse(access.task.collection_days); );
if (Array.isArray(parsed)) collectionDays = parsed.map(Number); await db
} catch { .prepare(
collectionDays = []; `UPDATE distributions SET collection_status = 'scheduled',
} collection_status_description = ?
if (collectionDays.length > 0) { WHERE task_id = ? AND publish_url IS NOT NULL AND publish_url != ''`,
await db )
.prepare( .bind(
`UPDATE distributions SET collection_status = 'scheduled', isScheduled
collection_status_description = ? ? `已安排${collectionDays.length}个采集日,每日09:00执行`
WHERE task_id = ? AND publish_url IS NOT NULL AND publish_url != ''`, : "默认发布后7天自动采集,每日09:00执行",
) access.task.id,
.bind( )
`已安排${collectionDays.length}个采集日,每日09:00执行`, .run();
access.task.id, if (isScheduled && access.task.collection_start_date) {
)
.run();
await createCollectionRunTasks( await createCollectionRunTasks(
db, db,
access.task.id, access.task.id,
access.task.collection_start_date, access.task.collection_start_date,
collectionDays, 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) { for (const enrichment of enrichments) {
runInBackground( runInBackground(

View File

@@ -6,6 +6,7 @@ const env = getRuntimeEnv();
import { enrichDistributionAccount } from "../../../lib/account-enrichment-service"; import { enrichDistributionAccount } from "../../../lib/account-enrichment-service";
import { import {
createCollectionRunTasks, createCollectionRunTasks,
parseCollectionDays,
runDueScheduledCollections, runDueScheduledCollections,
} from "../../../lib/collection-service"; } from "../../../lib/collection-service";
import { import {
@@ -1153,47 +1154,47 @@ async function handlePost(request: Request) {
collection_start_date: string | null; collection_start_date: string | null;
collection_days: string; collection_days: string;
}>(); }>();
if ( if (collectionSchedule) {
collectionSchedule?.collection_start_date && const collectionDays = parseCollectionDays(
collectionSchedule.collection_days !== "[]" collectionSchedule.collection_days,
) { );
let collectionDays: number[] = []; const isScheduled = Boolean(
try { collectionSchedule.collection_start_date &&
const parsed = JSON.parse(collectionSchedule.collection_days); collectionDays.length > 0,
if (Array.isArray(parsed)) collectionDays = parsed.map(Number); );
} catch { await db
collectionDays = []; .prepare(
} `UPDATE distributions SET collection_status = 'scheduled',
if (collectionDays.length > 0) { collection_status_description = ? WHERE id = ?`,
await db )
.prepare( .bind(
`UPDATE distributions SET collection_status = 'scheduled', isScheduled
collection_status_description = ? WHERE id = ?`, ? `已安排${collectionDays.length}个采集日,每日09:00执行`
) : "默认发布后7天自动采集,每日09:00执行",
.bind( assignment.id,
`已安排${collectionDays.length}个采集日,每日09:00执行`, )
assignment.id, .run();
) if (isScheduled && collectionSchedule.collection_start_date) {
.run();
await createCollectionRunTasks( await createCollectionRunTasks(
db, db,
task.id, task.id,
collectionSchedule.collection_start_date, collectionSchedule.collection_start_date,
collectionDays, 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( const enrichment = enrichDistributionAccount(
db, db,

View File

@@ -9,6 +9,7 @@ import {
} from "../../../lib/resource-import"; } from "../../../lib/resource-import";
import { import {
analyzeRows, analyzeRows,
ensurePartnersForPoolAccounts,
enrichRows, enrichRows,
loadAccounts, loadAccounts,
mergeExistingFields, mergeExistingFields,
@@ -137,6 +138,10 @@ export async function POST(request: Request) {
tagUntaggedAccounts(importableRows), tagUntaggedAccounts(importableRows),
"resource account tagging", "resource account tagging",
); );
runInBackground(
ensurePartnersForPoolAccounts(),
"pool partner sync after resource import",
);
if (deferredEnrichment > 0) { if (deferredEnrichment > 0) {
const importableSourceRows = rows.filter((row) => const importableSourceRows = rows.filter((row) =>
importableRowNumbers.has(row.rowNumber), importableRowNumbers.has(row.rowNumber),

View File

@@ -3,6 +3,7 @@ import { runInBackground } from "../../../lib/background";
import { buildManualRow, type ManualResourceInput } from "../../../lib/resource-import"; import { buildManualRow, type ManualResourceInput } from "../../../lib/resource-import";
import { import {
analyzeRows, analyzeRows,
ensurePartnersForPoolAccounts,
enrichRows, enrichRows,
loadAccounts, loadAccounts,
tagUntaggedAccounts, tagUntaggedAccounts,
@@ -54,6 +55,10 @@ export async function POST(request: Request) {
tagUntaggedAccounts(analyzed), tagUntaggedAccounts(analyzed),
"resource account tagging", "resource account tagging",
); );
runInBackground(
ensurePartnersForPoolAccounts(),
"pool partner sync after manual resource insert",
);
const result = analyzed[0]; const result = analyzed[0];
const outcome: InsertResult = { const outcome: InsertResult = {
action: result.action === "update" ? "update" : "create", action: result.action === "update" ? "update" : "create",

View File

@@ -1485,6 +1485,104 @@ a {
border: 1px solid var(--line); 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 { .detail-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2398,6 +2496,52 @@ a {
border-color: #f3e9d2; 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 { .master-panel.ghost {
background: transparent; background: transparent;
border: 1px dashed #cfddd6; border: 1px dashed #cfddd6;

View File

@@ -16,7 +16,8 @@ type DistributionForCollection = {
type ScheduledTask = { type ScheduledTask = {
id: string; id: string;
collection_start_date: string; task_type: string | null;
collection_start_date: string | null;
collection_days: string; collection_days: string;
}; };
@@ -63,9 +64,9 @@ function dateForScheduleDay(startDate: string, scheduleDay: number) {
.slice(0, 10); .slice(0, 10);
} }
function parseDays(value: string) { function parseDays(value: string | null | undefined) {
try { try {
const parsed = JSON.parse(value) as unknown; const parsed = JSON.parse(value ?? "[]") as unknown;
if (!Array.isArray(parsed)) return []; if (!Array.isArray(parsed)) return [];
return [...new Set(parsed.map(Number))] return [...new Set(parsed.map(Number))]
.filter((day) => Number.isInteger(day) && day >= 1 && day <= 7) .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) { export function shanghaiDateFromTimestamp(timestamp: number) {
return new Date(timestamp + 8 * 60 * 60 * 1000) return new Date(timestamp + 8 * 60 * 60 * 1000)
.toISOString() .toISOString()
.slice(0, 10); .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) { function shanghaiHourFromTimestamp(timestamp: number) {
return Number( return Number(
new Date(timestamp + 8 * 60 * 60 * 1000) 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( export async function runDueScheduledCollections(
db: DatabaseClient, db: DatabaseClient,
timestamp: number, timestamp: number,
@@ -402,12 +462,9 @@ export async function runDueScheduledCollections(
const currentDate = shanghaiDateFromTimestamp(timestamp); const currentDate = shanghaiDateFromTimestamp(timestamp);
const tasks = await db const tasks = await db
.prepare( .prepare(
`SELECT id, collection_start_date, collection_days `SELECT id, task_type, collection_start_date, collection_days
FROM tasks FROM tasks
WHERE collection_start_date IS NOT NULL WHERE COALESCE(task_type, 'content_publish') != 'screenshot_collect'
AND collection_start_date != ''
AND collection_days IS NOT NULL
AND collection_days != '[]'
AND (? IS NULL OR id = ?)`, AND (? IS NULL OR id = ?)`,
) )
.bind(onlyTaskId ?? null, onlyTaskId ?? null) .bind(onlyTaskId ?? null, onlyTaskId ?? null)
@@ -417,46 +474,25 @@ export async function runDueScheduledCollections(
let succeeded = 0; let succeeded = 0;
let failed = 0; let failed = 0;
for (const task of tasks.results) { const collectDue = async (
const schedules = dueSchedules( taskId: string,
task.collection_start_date, schedules: { scheduleDay: number; scheduledDate: string }[],
parseDays(task.collection_days), distributionId?: string,
timestamp, ) => {
);
for (const { scheduleDay, scheduledDate } of schedules) { for (const { scheduleDay, scheduledDate } of schedules) {
dueTasks += 1; dueTasks += 1;
const distributions = await db const ids = await distributionIdsNeedingCollection(
.prepare( db,
`SELECT d.id FROM distributions d taskId,
WHERE d.task_id = ? scheduledDate,
AND d.publish_url IS NOT NULL distributionId,
AND d.publish_url != '' );
AND NOT EXISTS ( for (const id of ids) {
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) {
attempted += 1; attempted += 1;
try { try {
await collectDistributionMetrics( await collectDistributionMetrics(
db, db,
distribution.id, id,
scheduledDate, scheduledDate,
scheduleDay, scheduleDay,
source, 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 }; return { currentDate, dueTasks, attempted, succeeded, failed };

View File

@@ -127,7 +127,7 @@ export function registerMcpOperationTools(server: McpServer, options: Options) {
"collection_plan_set", "collection_plan_set",
{ {
title: "设置自动采集计划", title: "设置自动采集计划",
description: "为任务设置开始日期及第1至第7天的自动采集日,系统在北京时间09:00执行。", description: "为任务设置开始日期及第1至第7天的自动采集日,系统在北京时间09:00执行;未设置时默认自每篇发布时间起连续采集7天。",
inputSchema: z.object({ inputSchema: z.object({
task_id: z.string().min(1), task_id: z.string().min(1),
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("北京时间开始日期 YYYY-MM-DD"), start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("北京时间开始日期 YYYY-MM-DD"),

View File

@@ -380,6 +380,11 @@ export async function ensureSchema(database?: DatabaseClient) {
"account_id", "account_id",
"account_id TEXT NOT NULL DEFAULT ''", "account_id TEXT NOT NULL DEFAULT ''",
); );
await ensureColumn(
"partners",
"released_account_id",
"released_account_id TEXT NOT NULL DEFAULT ''",
);
await ensureColumn( await ensureColumn(
"users", "users",
"wecom_userid", "wecom_userid",

View File

@@ -1,4 +1,5 @@
import { ensureSchema, getRawDb } from "./mvp-db"; import { type DatabaseClient } from "./database";
import { ensureSchema, getRawDb, hashText } from "./mvp-db";
import { import {
generateAccountTagsFromMcp, generateAccountTagsFromMcp,
resolveCollectionMcpConfig, resolveCollectionMcpConfig,
@@ -448,3 +449,130 @@ export async function writeAnalyzedRows(rows: AnalyzedRow[]) {
await db.batch(statements.slice(index, index + RESOURCE_IMPORT_DB_BATCH_SIZE)); 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<PoolAccountRow>();
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<ShellPartnerNameRow>();
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));
}
}

View File

@@ -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`);

View File

@@ -1,7 +1,13 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import test from "node:test"; 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"; import { sortWithNullsLast } from "../lib/sort-utils.ts";
test("runs the Beijing collection schedule from 09:00", () => { 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 行照实记录失败,保留可观测性 // run 行照实记录失败,保留可观测性
assert.match(source, /UPDATE collection_runs\s+SET status = 'failed'/); 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)),
);
});

90
tests/koc-detail.test.mjs Normal file
View File

@@ -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, /领取 <b>\{history\.length\}<\/b>/);
assert.match(body, /发布 <b>\{publishedCount\}<\/b>/);
assert.match(body, /回收完成 <b>\{completedCount\}<\/b>/);
// 绑定账号缺失时的兜底文案区分群主与外部KOC
assert.match(body, /群主合作方无需绑定 KOC 资源账号/);
});
test("工作台:按 id 挂载 KOC 详情,操作刷新后内容自动更新", () => {
assert.match(source, /const \[detailPartnerId, setDetailPartnerId\] = useState<string \| null>\(null\);/);
assert.match(
source,
/data\.partners\.find\(\(partner\) => partner\.id === detailPartnerId\)/,
);
assert.match(source, /item\.partner_id === partnerRow\.id/);
assert.match(source, /<KocDetailModal/);
});
test("工作台最近分发:行可点开分发详情,合作方可点开 KOC 详情", () => {
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, /<ScreenshotTaskTable\s+distributions=\{taskDistributions\}\s+onOpenPartner=\{onOpenPartner\}/);
});
test("合作方页:合作方名称单元格可点开 KOC 详情", () => {
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);
});

View File

@@ -30,14 +30,14 @@ test("partner_bind_account enforces one partner per resource account", () => {
const body = section(actionRoute, "partner_bind_account"); const body = section(actionRoute, "partner_bind_account");
assert.match( assert.match(
body, 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, /该 KOC 资源已绑定合作方/, "占用时返回可读错误");
assert.match( assert.match(
body, body,
/UPDATE partners SET account_id = '' WHERE id = \?/, /UPDATE partners SET account_id = '', released_account_id = \? WHERE id = \?/,
"空 accountId 表示解绑", "空 accountId 表示解绑,并记录释放的资源账号避免默认合作方同步回填",
); );
assert.match(body, /群主合作方不绑定 KOC 资源/); assert.match(body, /群主合作方不绑定 KOC 资源/);
}); });

View File

@@ -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/,
"有领取或分发记录的占用方不可顶替,仍走人工解绑",
);
});

View File

@@ -31,7 +31,7 @@ test("merges distribution detail into the task center", async () => {
assert.match(adminApp, /查看明细/); assert.match(adminApp, /查看明细/);
assert.match(adminApp, /function TaskDistributionDetail/); assert.match(adminApp, /function TaskDistributionDetail/);
assert.match(adminApp, /选择一个数据回收任务/); assert.match(adminApp, /选择一个任务进入回收队列/);
assert.doesNotMatch(adminApp, /"distributions"/); assert.doesNotMatch(adminApp, /"distributions"/);
assert.doesNotMatch(adminApp, /选择一个分发任务/); assert.doesNotMatch(adminApp, /选择一个分发任务/);
assert.doesNotMatch(adminApp, /distribution-task-/); 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); const snapshot = JSON.parse(snapshotText);
assert.match(adminApp, /选择一个数据回收任务/); assert.match(adminApp, /选择一个任务进入回收队列/);
assert.match(adminApp, /读取表格/); assert.match(adminApp, /读取表格/);
assert.match(adminApp, /当前仅展示/); assert.match(adminApp, /当前仅展示/);
assert.match(adminApp, /release_distribution/); assert.match(adminApp, /release_distribution/);

View File

@@ -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<StageFilterKey>\("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, /选择一个数据回收任务/);
});

View File

@@ -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"), readFile(new URL("../lib/mvp-db.ts", import.meta.url), "utf8"),
]); ]);
assert.match(adminApp, /waitingCollectionLabel/); assert.match(adminApp, /waitingCollectionLabel/);
assert.match(adminApp, /待设置采集计划/); // 未设置计划时走默认 7 天兜底,等待文案随之说明而不是停在“待设置”
assert.match(adminApp, /等待D1 · 默认7天/);
assert.match(adminApp, /等待D\$\{Math\.min\(\.\.\.days\)\}/); assert.match(adminApp, /等待D\$\{Math\.min\(\.\.\.days\)\}/);
assert.doesNotMatch(adminApp, /"等待D2"/); assert.doesNotMatch(adminApp, /"等待D2"/);
// 分发查询带上任务级采集计划字段 // 分发查询带上任务级采集计划字段