feat: KOC详情弹窗 + 任务中心单入口改版 + 资源池默认合作方/采集兜底
KOC 详情弹窗(合作方/数据回收/工作台三处入口): - 新增 KocDetailModal:基本信息 / 绑定KOC账号画像 / 参与任务记录 / 履约漏斗 - 工作台最近分发、回收队列账号名、截图回收合作方名、合作方页名称、分发详情头部合作方均可点开 - 分发表行点击仍打开分发详情(stopPropagation 隔离) 任务中心单入口改版(对齐"单任务双阶段"模型): - 任务卡发布/回收面板整块可点,全卡仅保留一个「查看明细」,回收面板重复按钮移除 - 详情页自述身份:动态 label(任务详情·分发与回收一体/仅分发)+ 阶段徽章(含回收·截止/未开通回收) - 内容分发表新增阶段筛选 chips(全部/待发布/已发布待数据/待收截图,未开通回收不显示截图项),筛选落空给专用空态文案 - 数据回收页入口文案改为「选择一个任务进入回收队列」,全站不再区分发布/回收两种任务类型 资源池 KOC 默认加入合作方 + 采集兜底: - partners 新增 released_account_id(mysql/0023):显式解绑/被顶替的账号不再被默认合作方同步回填 - 任务未设置采集计划时,默认按发布时间起连续采集 7 天,等待文案显示「等待D1 · 默认7天」
This commit is contained in:
@@ -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<Task | null>(null);
|
||||
const [metricModal, setMetricModal] = useState<Distribution | null>(null);
|
||||
const [detailDistributionId, setDetailDistributionId] = useState<string | null>(null);
|
||||
const [detailPartnerId, setDetailPartnerId] = useState<string | null>(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 (
|
||||
<KocDetailModal
|
||||
partner={partnerRow}
|
||||
account={boundAccount}
|
||||
history={partnerHistory}
|
||||
onClose={() => setDetailPartnerId(null)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{toast && <div className="toast"><span>✓</span>{toast}</div>}
|
||||
{working && <div className="working-bar" />}
|
||||
</div>
|
||||
@@ -1988,12 +2028,14 @@ function PartnersPage({
|
||||
working,
|
||||
onRefresh,
|
||||
setToast,
|
||||
onOpenPartner,
|
||||
}: {
|
||||
partners: Partner[];
|
||||
accounts: Account[];
|
||||
working: boolean;
|
||||
onRefresh: () => Promise<void>;
|
||||
setToast: (message: string) => void;
|
||||
onOpenPartner: (partner: Partner) => void;
|
||||
}) {
|
||||
const [status, setStatus] = useState<WecomStatus | null>(null);
|
||||
const [statusLoading, setStatusLoading] = useState(true);
|
||||
@@ -2328,6 +2370,25 @@ function PartnersPage({
|
||||
return map;
|
||||
}, [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 q = resourcePickerSearch.trim().toLowerCase();
|
||||
if (!q) return accounts;
|
||||
@@ -2456,7 +2517,7 @@ function PartnersPage({
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>合作方</h2>
|
||||
<p>群主型合作方由任务负责人自动生成(负责任务群分发);外部KOC由领取流程自动生成,绑定外部联系人后接收催办</p>
|
||||
<p>群主型合作方由任务负责人自动生成(负责任务群分发);资源池 KOC 默认加入外部KOC合作方,绑定外部联系人或领取任务后接收催办</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -2495,7 +2556,7 @@ function PartnersPage({
|
||||
{filteredPartners.length === 0 && (
|
||||
<div className="partner-row empty">
|
||||
{partners.length === 0
|
||||
? "暂无合作方:点击右上角新建群主合作方,或等待 KOC 领取任务后自动生成外部KOC记录"
|
||||
? "暂无合作方:新建群主合作方、在「KOC 资源」导入账号,或等待 KOC 领取任务后自动生成外部KOC记录"
|
||||
: "当前筛选下没有合作方"}
|
||||
</div>
|
||||
)}
|
||||
@@ -2505,7 +2566,11 @@ function PartnersPage({
|
||||
const isGroupOwner = partner.partner_type === "group_owner";
|
||||
return (
|
||||
<div className="partner-row" key={partner.id}>
|
||||
<span>
|
||||
<span
|
||||
className="partner-name-cell"
|
||||
title="点击查看 KOC 详情"
|
||||
onClick={() => onOpenPartner(partner)}
|
||||
>
|
||||
<strong>{partner.name}</strong>
|
||||
<em className={`partner-type-badge ${isGroupOwner ? "group-owner" : "external-koc"}`}>
|
||||
{isGroupOwner ? "群主" : "外部KOC"}
|
||||
@@ -2803,7 +2868,7 @@ function PartnersPage({
|
||||
<div className="modal-heading">
|
||||
<div>
|
||||
<h2>选择 {resourcePickerPartner.name} 的 KOC 资源</h2>
|
||||
<p>绑定后可在合作方列表直接查看该 KOC 的联系方式;每个资源账号只能绑定一个外部KOC合作方。</p>
|
||||
<p>绑定后可在合作方列表直接查看该 KOC 的联系方式;每个资源账号只能绑定一个外部KOC合作方,换绑时会自动顶替资源池默认生成的合作方。</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -2836,7 +2901,11 @@ function PartnersPage({
|
||||
) : (
|
||||
filteredResourceAccounts.map((account) => {
|
||||
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;
|
||||
return (
|
||||
<button
|
||||
@@ -2862,6 +2931,8 @@ function PartnersPage({
|
||||
<span className="wecom-picker-current">已绑定:{holderName}</span>
|
||||
) : isCurrent ? (
|
||||
<span className="wecom-picker-current">当前</span>
|
||||
) : heldByDefaultShell && holderName ? (
|
||||
<span className="wecom-picker-current">默认合作方:{holderName}</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
@@ -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({
|
||||
<DistributionTable
|
||||
distributions={distributions.slice(0, 6)}
|
||||
compact
|
||||
onOpenDetail={onOpenDetail}
|
||||
onOpenPartner={onOpenPartner}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
@@ -4607,7 +4684,11 @@ function TaskRowCard({
|
||||
</div>
|
||||
</div>
|
||||
<div className="master-panels">
|
||||
<section className="master-panel publish">
|
||||
<section
|
||||
className="master-panel publish clickable"
|
||||
title="查看任务明细(分发与回收一体)"
|
||||
onClick={() => onDetail(task)}
|
||||
>
|
||||
<div className="panel-head">
|
||||
<b>发布</b>
|
||||
<span className="panel-count">{task.quantity} 篇内容</span>
|
||||
@@ -4617,13 +4698,15 @@ function TaskRowCard({
|
||||
{panelChat(task, "任务群")}
|
||||
</div>
|
||||
<div className="panel-foot">
|
||||
<button type="button" className="panel-detail" onClick={() => onDetail(task)}>
|
||||
查看明细 ›
|
||||
</button>
|
||||
<span className="panel-detail">查看明细 ›</span>
|
||||
</div>
|
||||
</section>
|
||||
{recoveryEnabled ? (
|
||||
<section className="master-panel recovery">
|
||||
<section
|
||||
className="master-panel recovery clickable"
|
||||
title="查看任务明细(分发与回收一体)"
|
||||
onClick={() => onDetail(task)}
|
||||
>
|
||||
<div className="panel-head">
|
||||
<b>回收</b>
|
||||
<span className="panel-count">
|
||||
@@ -4635,11 +4718,6 @@ function TaskRowCard({
|
||||
<div className="panel-body">
|
||||
{panelMetric(task.dist_submitted ?? 0, task.dist_total ?? 0, "已收取", "recovery")}
|
||||
</div>
|
||||
<div className="panel-foot">
|
||||
<button type="button" className="panel-detail" onClick={() => onDetail(task)}>
|
||||
查看明细 ›
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section className="master-panel ghost">
|
||||
@@ -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<boolean>;
|
||||
onOpenDetail: (distribution: Distribution) => void;
|
||||
onOpenPartner: (partnerId: string) => void;
|
||||
}) {
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(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 (
|
||||
<section className="task-detail-header">
|
||||
<button className="detail-back" onClick={onBack}>← 返回任务列表</button>
|
||||
@@ -5100,6 +5188,7 @@ function TaskDetailHeader({
|
||||
<span>{task.content_format === "video" ? "视频" : "图文"}</span>
|
||||
<span>{task.quantity} 篇</span>
|
||||
<span>{formatDate(task.due_at)} 截止</span>
|
||||
<span className="tag-weak pair-tag">{pairTag}</span>
|
||||
</span>
|
||||
</div>
|
||||
{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<boolean>;
|
||||
onBack: () => void;
|
||||
onOpenDetail: (distribution: Distribution) => void;
|
||||
onOpenPartner: (partnerId: string) => void;
|
||||
}) {
|
||||
const [releaseTarget, setReleaseTarget] = useState<Distribution | null>(null);
|
||||
const [stageFilter, setStageFilter] = useState<StageFilterKey>("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 (
|
||||
<div className="stack">
|
||||
<TaskDetailHeader
|
||||
task={task}
|
||||
label="内容分发"
|
||||
label={recoveryEnabled ? "任务详情 · 分发与回收一体" : "任务详情 · 仅分发"}
|
||||
onBack={onBack}
|
||||
/>
|
||||
<div className="inline-summary">
|
||||
@@ -5166,13 +5289,31 @@ function TaskDistributionDetail({
|
||||
</div>
|
||||
<section className="panel table-panel">
|
||||
<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>
|
||||
<DistributionTable
|
||||
distributions={taskDistributions}
|
||||
distributions={visibleDistributions}
|
||||
emptyHint={
|
||||
taskDistributions.length > 0 && visibleDistributions.length === 0
|
||||
? "当前筛选下没有分发记录"
|
||||
: undefined
|
||||
}
|
||||
onRelease={canRelease ? setReleaseTarget : undefined}
|
||||
showRecovery={recoveryEnabled}
|
||||
onOpenDetail={onOpenDetail}
|
||||
onOpenPartner={onOpenPartner}
|
||||
/>
|
||||
</section>
|
||||
{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({
|
||||
<div><strong>{item.content_title}</strong><span>{item.task_name} · {item.task_brand}</span></div>
|
||||
</div>
|
||||
</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>
|
||||
{item.account_nickname ? (
|
||||
<div className="account-cell">
|
||||
@@ -5359,7 +5520,9 @@ function DistributionTable({
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!compact && distributions.length === 0 && <div className="table-empty">暂无分发记录</div>}
|
||||
{!compact && distributions.length === 0 && (
|
||||
<div className="table-empty">{emptyHint ?? "暂无分发记录"}</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<{
|
||||
images: AdminPreviewImage[];
|
||||
activeIndex: number;
|
||||
@@ -5476,7 +5645,23 @@ function ScreenshotTaskTable({ distributions }: { distributions: Distribution[]
|
||||
<div><strong>{item.content_title}</strong><span>{item.task_name}</span></div>
|
||||
</div>
|
||||
</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.publish_time, true)}</span></td>
|
||||
<td><span className="date-value">{formatDate(item.result_submitted_at, true)}</span></td>
|
||||
@@ -5958,7 +6143,7 @@ function ResourcesPage({
|
||||
</div>
|
||||
<div>
|
||||
<h2>资源从合作与历史资产中持续沉淀</h2>
|
||||
<p>发布链接会自动建档,已有KOC资源也可以使用标准模板批量导入。</p>
|
||||
<p>发布链接会自动建档,已有KOC资源也可以使用标准模板批量导入。资源池中的 KOC 会默认加入合作方列表。</p>
|
||||
</div>
|
||||
<div className="resource-rule">
|
||||
<span>唯一去重规则</span>
|
||||
@@ -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({
|
||||
<div className="detail-meta">
|
||||
<span>{item.task_name}</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>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -6857,7 +7058,7 @@ function DistributionDetailModal({
|
||||
<strong>
|
||||
{item.task_collection_start_date && collectionDays.length > 0
|
||||
? `${item.task_collection_start_date} 起 D${collectionDays.join("/D")}`
|
||||
: "待设置"}
|
||||
: "默认发布后7天"}
|
||||
</strong>
|
||||
</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({
|
||||
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<string | null>(null);
|
||||
const [recoverySort, setRecoverySort] = useState<{
|
||||
@@ -7089,8 +7475,8 @@ function RecoveryPage({
|
||||
return (
|
||||
<div className="stack">
|
||||
<div className="section-intro">
|
||||
<div><p className="eyebrow">按任务回收</p><h2>选择一个数据回收任务</h2></div>
|
||||
<p>进入任务后设置自动采集日期,统一查看每篇笔记的最新互动数据。</p>
|
||||
<div><p className="eyebrow">按任务回收</p><h2>选择一个任务进入回收队列</h2></div>
|
||||
<p>进入任务后可设置自动采集日期;未设置时默认自每篇发布时间起连续采集 7 天。</p>
|
||||
</div>
|
||||
<TaskScopeList
|
||||
tasks={tasks}
|
||||
@@ -7293,7 +7679,21 @@ function RecoveryPage({
|
||||
<strong>{item.content_title}</strong>
|
||||
)}
|
||||
<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 />}
|
||||
</span>
|
||||
</div>
|
||||
@@ -7471,7 +7871,10 @@ function RecoveryPage({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ScreenshotTaskTable distributions={taskDistributions} />
|
||||
<ScreenshotTaskTable
|
||||
distributions={taskDistributions}
|
||||
onOpenPartner={onOpenPartner}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
@@ -7495,9 +7898,11 @@ function CollectionScheduleCard({
|
||||
const [startDate, setStartDate] = useState(
|
||||
task.collection_start_date || todayInputValue(),
|
||||
);
|
||||
const [days, setDays] = useState<number[]>(
|
||||
parseCollectionDays(task.collection_days),
|
||||
);
|
||||
const [days, setDays] = useState<number[]>(() => {
|
||||
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({
|
||||
<div>
|
||||
<p className="eyebrow">自动采集计划</p>
|
||||
<h2>选择开始日期与采集日</h2>
|
||||
<span>勾选第1天到第7天,所选日期均在北京时间09:00自动采集。</span>
|
||||
<span>勾选第1天到第7天,所选日期均在北京时间09:00自动采集;未保存计划时默认连采 7 天。</span>
|
||||
</div>
|
||||
<div className="schedule-save-area">
|
||||
<span>已选择 {days.length} 天</span>
|
||||
|
||||
@@ -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<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) {
|
||||
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<PartnerAccountHolderRow>();
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
144
app/globals.css
144
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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<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));
|
||||
}
|
||||
}
|
||||
|
||||
5
mysql/0023_partner_released_account.sql
Normal file
5
mysql/0023_partner_released_account.sql
Normal 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`);
|
||||
@@ -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)),
|
||||
);
|
||||
});
|
||||
|
||||
90
tests/koc-detail.test.mjs
Normal file
90
tests/koc-detail.test.mjs
Normal 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);
|
||||
});
|
||||
@@ -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 资源/);
|
||||
});
|
||||
|
||||
126
tests/pool-partner-default.test.mjs
Normal file
126
tests/pool-partner-default.test.mjs
Normal 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/,
|
||||
"有领取或分发记录的占用方不可顶替,仍走人工解绑",
|
||||
);
|
||||
});
|
||||
@@ -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/);
|
||||
|
||||
57
tests/task-center-stage.test.mjs
Normal file
57
tests/task-center-stage.test.mjs
Normal 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, /选择一个数据回收任务/);
|
||||
});
|
||||
@@ -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"/);
|
||||
// 分发查询带上任务级采集计划字段
|
||||
|
||||
Reference in New Issue
Block a user