feat: 采集失败KOC补交截图闭环 + 任务中心分发详情弹窗

公开数据补交闭环(F1):
- distributions 新增 public_metrics_screenshot_key(mysql/0022 迁移)
- KOC 门户:链接已回填但采集失败时展示补交数据截图入口(kind=public-metrics)
- 运营端:回收队列"核对补录"支持公开互动数据(赞/藏/评),按补交截图补录后采集状态置成功
- 企微催办:采集失败纳入待办(公开数据待补交)
- task-result-image 支持 kind=publish 输出发布截图

门户文案(F2):
- 自动采集数据明确"由系统每天自动采集,无需你填写",仅识别失败时才手动填写曝光/阅读

任务中心分发详情:
- 任务中心/数据回收的分发表行可点击,弹窗查看详情
- 详情按任务类型适配:发布阶段/数据回收(自动采集)/创作者数据(第7天)/截图回收四区块三段式布局
This commit is contained in:
ABAPPLO
2026-10-09 17:02:46 +08:00
parent c686003cfb
commit c11afd5f99
15 changed files with 1250 additions and 67 deletions

View File

@@ -152,6 +152,7 @@ type Distribution = {
account_id: string | null; account_id: string | null;
publish_url: string | null; publish_url: string | null;
publish_time: string | null; publish_time: string | null;
publish_screenshot_key: string | null;
result_screenshot_key: string | null; result_screenshot_key: string | null;
result_submitted_at: string | null; result_submitted_at: string | null;
status: string; status: string;
@@ -173,6 +174,7 @@ type Distribution = {
latest_comments: number | null; latest_comments: number | null;
latest_collects: number | null; latest_collects: number | null;
latest_shares: number | null; latest_shares: number | null;
public_metrics_screenshot_key: string | null;
collection_status: string; collection_status: string;
collection_status_description: string | null; collection_status_description: string | null;
collection_updated_at: string | null; collection_updated_at: string | null;
@@ -548,6 +550,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
const [ownerLinksTask, setOwnerLinksTask] = useState<Task | null>(null); const [ownerLinksTask, setOwnerLinksTask] = useState<Task | null>(null);
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 [publishUrlModal, setPublishUrlModal] = useState<{ const [publishUrlModal, setPublishUrlModal] = useState<{
distribution: Distribution; distribution: Distribution;
value: string; value: string;
@@ -572,7 +575,13 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
const [taskFormProjects, setTaskFormProjects] = useState<Array<{ id: string; name: string }>>([]); const [taskFormProjects, setTaskFormProjects] = useState<Array<{ id: string; name: string }>>([]);
const [sourcePreview, setSourcePreview] = useState<FeishuPreview | null>(null); const [sourcePreview, setSourcePreview] = useState<FeishuPreview | null>(null);
const [sourceWorking, setSourceWorking] = useState(false); const [sourceWorking, setSourceWorking] = useState(false);
const [metricForm, setMetricForm] = useState({ exposure: "", views: "" }); const [metricForm, setMetricForm] = useState({
exposure: "",
views: "",
likes: "",
collects: "",
comments: "",
});
const [exportingTaskId, setExportingTaskId] = useState<string | null>(null); const [exportingTaskId, setExportingTaskId] = useState<string | null>(null);
const [exportingResources, setExportingResources] = useState(false); const [exportingResources, setExportingResources] = useState(false);
const [creatingPartnerId, setCreatingPartnerId] = useState<string | null>(null); const [creatingPartnerId, setCreatingPartnerId] = useState<string | null>(null);
@@ -1126,18 +1135,46 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
const submitManualMetrics = async (event: FormEvent) => { const submitManualMetrics = async (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
if (!metricModal) return; if (!metricModal) return;
const hasCreatorInput = Boolean(metricForm.exposure.trim() && metricForm.views.trim());
const hasPublicInput = Boolean(
metricForm.likes.trim() ||
metricForm.collects.trim() ||
metricForm.comments.trim(),
);
if (!hasCreatorInput && !hasPublicInput) {
setToast("请至少填写创作者数据或公开互动数据中的一项");
return;
}
const success = await runAction( const success = await runAction(
{ {
action: "manual_metrics", action: "manual_metrics",
distributionId: metricModal.id, distributionId: metricModal.id,
exposure: metricForm.exposure, ...(hasCreatorInput
views: metricForm.views, ? { exposure: metricForm.exposure, views: metricForm.views }
: {}),
...(metricForm.likes.trim()
? { publicLikes: metricForm.likes }
: {}),
...(metricForm.collects.trim()
? { publicCollects: metricForm.collects }
: {}),
...(metricForm.comments.trim()
? { publicComments: metricForm.comments }
: {}),
}, },
"创作者中心数据已确认", hasPublicInput && !hasCreatorInput
? "公开互动数据已按补交截图补录"
: "创作者中心数据已确认",
); );
if (success) { if (success) {
setMetricModal(null); setMetricModal(null);
setMetricForm({ exposure: "", views: "" }); setMetricForm({
exposure: "",
views: "",
likes: "",
collects: "",
comments: "",
});
} }
}; };
@@ -1146,6 +1183,9 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
setMetricForm({ setMetricForm({
exposure: distribution.exposure?.toString() || "", exposure: distribution.exposure?.toString() || "",
views: distribution.views?.toString() || "", views: distribution.views?.toString() || "",
likes: distribution.latest_likes?.toString() || "",
collects: distribution.latest_collects?.toString() || "",
comments: distribution.latest_comments?.toString() || "",
}); });
}; };
@@ -1360,6 +1400,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
); );
return result !== false; return result !== false;
}} }}
onOpenDetail={(distribution) => setDetailDistributionId(distribution.id)}
/> />
)} )}
{activeNav === "resources" && ( {activeNav === "resources" && (
@@ -1404,6 +1445,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
onExport={exportRecoveryData} onExport={exportRecoveryData}
onExportScreenshots={exportScreenshotResults} onExportScreenshots={exportScreenshotResults}
exportingTaskId={exportingTaskId} exportingTaskId={exportingTaskId}
onOpenDetail={(distribution) => setDetailDistributionId(distribution.id)}
/> />
)} )}
</> </>
@@ -1679,8 +1721,12 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
<form className="modal-card compact" onSubmit={submitManualMetrics} onMouseDown={(event) => event.stopPropagation()}> <form className="modal-card compact" onSubmit={submitManualMetrics} onMouseDown={(event) => event.stopPropagation()}>
<div className="modal-heading"> <div className="modal-heading">
<div> <div>
<p className="eyebrow">OCR异常兜底</p> <p className="eyebrow">
<h2>确认创作者数据</h2> {metricModal.collection_status === "failed"
? "采集失败兜底 · 数据核对补录"
: "OCR异常兜底"}
</p>
<h2>核对并补录数据</h2>
</div> </div>
<button type="button" onClick={() => setMetricModal(null)} aria-label="关闭">×</button> <button type="button" onClick={() => setMetricModal(null)} aria-label="关闭">×</button>
</div> </div>
@@ -1688,19 +1734,36 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
<strong>{metricModal.account_nickname || "待识别账号"}</strong> <strong>{metricModal.account_nickname || "待识别账号"}</strong>
<span>{metricModal.content_title}</span> <span>{metricModal.content_title}</span>
</div> </div>
<div className="form-grid two">
<label> <label>
<span>曝光量</span> <span>创作者数据(曝光 / 阅读,第7天截图数据)</span>
<input type="number" min="0" value={metricForm.exposure} onChange={(event) => setMetricForm({ ...metricForm, exposure: event.target.value })} required /> <div className="form-grid two" style={{ marginBottom: 0 }}>
</label> <input type="number" min="0" placeholder="曝光量" value={metricForm.exposure} onChange={(event) => setMetricForm({ ...metricForm, exposure: event.target.value })} />
<label> <input type="number" min="0" placeholder="阅读量" value={metricForm.views} onChange={(event) => setMetricForm({ ...metricForm, views: event.target.value })} />
<span>阅读量</span>
<input type="number" min="0" value={metricForm.views} onChange={(event) => setMetricForm({ ...metricForm, views: event.target.value })} required />
</label>
</div> </div>
<small>曝光 + 阅读齐了才算「回收完成」;本次不改可留空。</small>
</label>
<label>
<span>公开互动数据(按 KOC 补交的数据截图核对)</span>
<div className="form-grid two" style={{ marginBottom: 0 }}>
<input type="number" min="0" placeholder="点赞" value={metricForm.likes} onChange={(event) => setMetricForm({ ...metricForm, likes: event.target.value })} />
<input type="number" min="0" placeholder="收藏" value={metricForm.collects} onChange={(event) => setMetricForm({ ...metricForm, collects: event.target.value })} />
<input type="number" min="0" placeholder="评论" value={metricForm.comments} onChange={(event) => setMetricForm({ ...metricForm, comments: event.target.value })} />
</div>
<small>填写后采集状态会更新为「采集成功(运营已核对)」;不需要补录可留空。</small>
</label>
{metricModal.public_metrics_screenshot_key && (
<a
className="share-link-button"
href={`/api/creator-screenshot?distribution=${encodeURIComponent(metricModal.id)}&kind=public-metrics`}
target="_blank"
rel="noreferrer"
>
查看 KOC 补交的数据截图 ↗
</a>
)}
<div className="modal-actions"> <div className="modal-actions">
<button type="button" className="ghost-button" onClick={() => setMetricModal(null)}>取消</button> <button type="button" className="ghost-button" onClick={() => setMetricModal(null)}>取消</button>
<button className="primary-button" disabled={working}>确认数据</button> <button className="primary-button" disabled={working}>确认补录</button>
</div> </div>
</form> </form>
</div> </div>
@@ -1830,6 +1893,51 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
/> />
)} )}
{(() => {
// 详情按 id 实时取行:任何操作刷新数据后弹窗内容自动更新
const detailRow = detailDistributionId
? data.distributions.find((item) => item.id === detailDistributionId)
: null;
if (!detailRow) return null;
const detailTask = data.tasks.find((task) => task.id === detailRow.task_id);
return (
<DistributionDetailModal
item={detailRow}
recoveryEnabled={Number(detailTask?.recovery_enabled ?? 1) === 1}
working={working}
collectingDistributionIds={collectingDistributionIds}
canUpdatePublishUrl={isManager}
canRelease={isManager}
onClose={() => setDetailDistributionId(null)}
onCollect={collectMetrics}
onFallback={openMetricFallback}
onUpdatePublishUrl={(distribution) =>
setPublishUrlModal({
distribution,
value: distribution.publish_url || "",
})
}
onRelease={(distribution) => {
void (async () => {
if (
!window.confirm(
`确认释放“${distribution.content_title}”?释放后回到可领取池,不可撤销。`,
)
)
return;
const result = await runAction(
{
action: "release_distribution",
distributionId: distribution.id,
},
`“${distribution.content_title}”已释放,可重新领取`,
);
if (result !== false) setDetailDistributionId(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>
@@ -4580,6 +4688,7 @@ function TasksPage({
onBindCustomerChat, onBindCustomerChat,
onCreateRecovery, onCreateRecovery,
onRelease, onRelease,
onOpenDetail,
}: { }: {
tasks: Task[]; tasks: Task[];
distributions: Distribution[]; distributions: Distribution[];
@@ -4598,6 +4707,7 @@ function TasksPage({
onBindCustomerChat: (task: Task) => void; onBindCustomerChat: (task: Task) => void;
onCreateRecovery: (task: Task) => void; onCreateRecovery: (task: Task) => void;
onRelease: (distribution: Distribution) => Promise<boolean>; onRelease: (distribution: Distribution) => Promise<boolean>;
onOpenDetail: (distribution: Distribution) => 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");
@@ -4734,6 +4844,7 @@ function TasksPage({
working={working} working={working}
onRelease={onRelease} onRelease={onRelease}
onBack={() => setSelectedTaskId(null)} onBack={() => setSelectedTaskId(null)}
onOpenDetail={onOpenDetail}
/> />
); );
} }
@@ -5007,6 +5118,7 @@ function TaskDistributionDetail({
working, working,
onRelease, onRelease,
onBack, onBack,
onOpenDetail,
}: { }: {
task: Task; task: Task;
distributions: Distribution[]; distributions: Distribution[];
@@ -5014,6 +5126,7 @@ function TaskDistributionDetail({
working: boolean; working: boolean;
onRelease: (distribution: Distribution) => Promise<boolean>; onRelease: (distribution: Distribution) => Promise<boolean>;
onBack: () => void; onBack: () => void;
onOpenDetail: (distribution: Distribution) => void;
}) { }) {
const [releaseTarget, setReleaseTarget] = useState<Distribution | null>(null); const [releaseTarget, setReleaseTarget] = useState<Distribution | null>(null);
const taskDistributions = distributions.filter( const taskDistributions = distributions.filter(
@@ -5059,6 +5172,7 @@ function TaskDistributionDetail({
distributions={taskDistributions} distributions={taskDistributions}
onRelease={canRelease ? setReleaseTarget : undefined} onRelease={canRelease ? setReleaseTarget : undefined}
showRecovery={recoveryEnabled} showRecovery={recoveryEnabled}
onOpenDetail={onOpenDetail}
/> />
</section> </section>
{releaseTarget && ( {releaseTarget && (
@@ -5128,11 +5242,13 @@ function DistributionTable({
compact = false, compact = false,
onRelease, onRelease,
showRecovery = false, showRecovery = false,
onOpenDetail,
}: { }: {
distributions: Distribution[]; distributions: Distribution[];
compact?: boolean; compact?: boolean;
onRelease?: (distribution: Distribution) => void; onRelease?: (distribution: Distribution) => void;
showRecovery?: boolean; showRecovery?: boolean;
onOpenDetail?: (distribution: Distribution) => void;
}) { }) {
// 已发布但还没采到数据的行:有计划时按首个采集日显示,没计划时 // 已发布但还没采到数据的行:有计划时按首个采集日显示,没计划时
// 明说待设置,而不是写死「等待D2」误导运营。 // 明说待设置,而不是写死「等待D2」误导运营。
@@ -5170,7 +5286,12 @@ function DistributionTable({
</thead> </thead>
<tbody> <tbody>
{distributions.map((item) => ( {distributions.map((item) => (
<tr key={item.id}> <tr
key={item.id}
className={onOpenDetail ? "clickable" : ""}
onClick={onOpenDetail ? () => onOpenDetail(item) : undefined}
title={onOpenDetail ? "点击查看分发详情" : undefined}
>
<td> <td>
<div className="content-cell"> <div className="content-cell">
<span className="content-id">{item.content_id.split("-").at(-1)?.toUpperCase()}</span> <span className="content-id">{item.content_id.split("-").at(-1)?.toUpperCase()}</span>
@@ -5220,7 +5341,7 @@ function DistributionTable({
)} )}
<td><span className={`status-pill ${item.status}`}>{statusLabel(item.status)}</span></td> <td><span className={`status-pill ${item.status}`}>{statusLabel(item.status)}</span></td>
{onRelease && ( {onRelease && (
<td> <td onClick={(event) => event.stopPropagation()}>
{!item.publish_url && !item.result_submitted_at ? ( {!item.publish_url && !item.result_submitted_at ? (
<button <button
type="button" type="button"
@@ -6525,6 +6646,398 @@ function CreatorScreenshotPreview({
); );
} }
/* 发布截图预览(管理端核对发布凭证用) */
function PublishEvidencePreview({
distributionId,
title,
}: {
distributionId: string;
title: string;
}) {
const [open, setOpen] = useState(false);
const imageUrl = `/api/task-result-image?distribution=${encodeURIComponent(distributionId)}&kind=publish`;
return (
<>
<button
type="button"
className="detail-shot"
onClick={() => setOpen(true)}
title="查看发布截图大图"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={imageUrl} alt={`${title} 发布截图`} loading="lazy" />
<span>发布截图 · 点击查看</span>
</button>
<AdminImageLightbox
images={open ? [{ src: imageUrl, title: `${title} · 发布截图` }] : []}
activeIndex={0}
onClose={() => setOpen(false)}
/>
</>
);
}
/* ------------------------------------------------------------
分发详情弹窗:单条分发全量数据,按发布/回收双阶段自适应展示
------------------------------------------------------------ */
function DistributionDetailModal({
item,
recoveryEnabled,
working,
collectingDistributionIds,
canUpdatePublishUrl,
canRelease,
onClose,
onCollect,
onFallback,
onUpdatePublishUrl,
onRelease,
}: {
item: Distribution;
recoveryEnabled: boolean;
working: boolean;
collectingDistributionIds: ReadonlySet<string>;
canUpdatePublishUrl: boolean;
canRelease: boolean;
onClose: () => void;
onCollect: (distribution: Distribution) => void;
onFallback: (distribution: Distribution) => void;
onUpdatePublishUrl: (distribution: Distribution) => void;
onRelease: (distribution: Distribution) => void;
}) {
const [previewGallery, setPreviewGallery] = useState<{
images: AdminPreviewImage[];
activeIndex: number;
} | null>(null);
const metrics = latestPublicMetrics(item);
const noteUrl = publicPublishUrl(item.publish_url);
const collectionDays = parseCollectionDays(item.task_collection_days);
const resultKeys = parseResultScreenshotKeys(item.result_screenshot_key);
const collecting =
collectingDistributionIds.size > 0 && collectingDistributionIds.has(item.id);
const resolvedCollectionStatus =
item.collection_status || (metrics.likes !== null ? "success" : "pending");
return (
<div
className="modal-backdrop"
role="presentation"
onMouseDown={onClose}
>
<div
className="modal-card distribution-detail-modal"
role="dialog"
aria-modal="true"
aria-label={`分发详情:${item.content_title}`}
onMouseDown={(event) => event.stopPropagation()}
>
{/* 头部:标题 + 阶段徽章 + 任务元信息 */}
<div className="distribution-detail-header">
<div className="modal-heading" style={{ marginBottom: 0 }}>
<div>
<p className="eyebrow">分发详情</p>
<h2>{item.content_title}</h2>
</div>
<button type="button" onClick={onClose} aria-label="关闭">×</button>
</div>
<div className="detail-badges">
<span className={`status-pill ${item.status}`}>{statusLabel(item.status)}</span>
<PlatformBadge platform={item.task_platform} compact />
<span className="master-chip">{item.content_format === "video" ? "视频" : "图文"}</span>
<span className={`due-badge${dueBadgeState(item.due_at)}`}>
截止 {formatDate(item.due_at)}
</span>
</div>
<div className="detail-meta">
<span>{item.task_name}</span>
<span>· {item.task_brand}</span>
<span>· 合作方 {item.partner_name}</span>
{item.claimant_name && <span>· 领取人 {item.claimant_name}</span>}
</div>
</div>
{/* 内容:四个阶段区块(未开通回收的任务不展示截图回收) */}
<div className="detail-body">
<section className="detail-section">
<div className="detail-section-head">
<b>① 发布阶段</b>
<span className={`status-pill ${item.publish_url ? "published" : "claimed"}`}>
{item.publish_url ? "已发布" : "待发布"}
</span>
</div>
<div className="detail-section-body">
<div className="detail-kv-grid">
<div className="detail-kv">
<span>实际发布账号</span>
{item.account_nickname ? (
<strong>{item.account_nickname}</strong>
) : (
<span className="muted">回填链接后识别</span>
)}
</div>
<div className="detail-kv">
<span>发布时间</span>
<strong>{formatDate(item.publish_time, true) || "—"}</strong>
</div>
<div className="detail-kv">
<span>发布链接</span>
{noteUrl ? (
<a href={noteUrl} target="_blank" rel="noopener noreferrer">
打开作品 ↗
</a>
) : (
<span className="muted">待回填</span>
)}
</div>
<div className="detail-kv">
<span>领取时间</span>
<strong>{formatDate(item.claimed_at, true)}</strong>
</div>
</div>
<div className="detail-evidence">
<span className="detail-evidence-label">发布截图</span>
{item.publish_screenshot_key ? (
<PublishEvidencePreview
distributionId={item.id}
title={item.content_title}
/>
) : (
<span className="muted" style={{ fontSize: 12 }}>
待KOC上传
</span>
)}
</div>
</div>
</section>
<section className="detail-section">
<div className="detail-section-head">
<b>② 数据回收(自动采集)</b>
<span className={`collection-status ${resolvedCollectionStatus}`}>
{collectionStatusLabel(resolvedCollectionStatus, metrics.likes !== null)}
</span>
</div>
<div className="detail-section-body">
<div className="detail-metric-row">
<div className="detail-metric">
<span>点赞</span>
<strong>{formatNumber(metrics.likes)}</strong>
</div>
<div className="detail-metric">
<span>收藏</span>
<strong>{formatNumber(metrics.collects)}</strong>
</div>
{item.task_platform === "抖音" && (
<div className="detail-metric">
<span>转发</span>
<strong>{formatNumber(metrics.shares)}</strong>
</div>
)}
<div className="detail-metric">
<span>评论</span>
<strong>{formatNumber(metrics.comments)}</strong>
</div>
<div className="detail-metric">
<span>总互动</span>
<strong>{formatNumber(metrics.total)}</strong>
</div>
</div>
<div className="detail-kv-grid" style={{ marginTop: 11 }}>
<div className="detail-kv">
<span>数据更新时间</span>
<strong>
{formatDate(
item.collection_updated_at ||
(metrics.likes !== null ? item.updated_at : null),
true,
) || "—"}
</strong>
</div>
<div className="detail-kv">
<span>采集计划</span>
<strong>
{item.task_collection_start_date && collectionDays.length > 0
? `${item.task_collection_start_date} 起 D${collectionDays.join("/D")}`
: "待设置"}
</strong>
</div>
</div>
{(item.collection_status_description || item.public_metrics_screenshot_key) && (
<div className="detail-evidence">
{item.collection_status_description && (
<span
className={`detail-evidence-label${
resolvedCollectionStatus === "failed" ? " warn" : ""
}`}
>
{resolvedCollectionStatus === "failed" ? "⚠ " : ""}
{item.collection_status_description}
</span>
)}
{item.public_metrics_screenshot_key && (
<a
className="text-button"
href={`/api/creator-screenshot?distribution=${encodeURIComponent(item.id)}&kind=public-metrics`}
target="_blank"
rel="noreferrer"
>
KOC补交截图 ↗
</a>
)}
</div>
)}
</div>
</section>
<section className="detail-section">
<div className="detail-section-head">
<b>③ 创作者数据(第7天)</b>
<span className={`status-pill ${hasCreatorMetrics(item) ? "complete" : "claimed"}`}>
{hasCreatorMetrics(item) ? "已提交" : "待提交"}
</span>
</div>
<div className="detail-section-body">
<div className="detail-evidence" style={{ marginTop: 0 }}>
<span className="detail-evidence-label">创作者截图</span>
{item.screenshot_key ? (
<CreatorScreenshotPreview
distributionId={item.id}
title={item.content_title}
/>
) : (
<span className="muted" style={{ fontSize: 12 }}>
待KOC上传
</span>
)}
{hasCreatorMetrics(item) ? (
<span className="creator-data-summary">
<b>曝光 {formatNumber(item.exposure)}</b>
<b>阅读 {formatNumber(item.views)}</b>
</span>
) : item.screenshot_key ? (
<span className="creator-ocr-failed">
{item.ocr_status === "processing" ? "正在识别数据" : "识别失败,可手动填写"}
</span>
) : null}
</div>
</div>
</section>
{recoveryEnabled && (
<section className="detail-section">
<div className="detail-section-head">
<b>④ 截图回收</b>
<span
className={`status-pill ${item.result_submitted_at ? "complete" : "claimed"}`}
>
{item.result_submitted_at ? "已提交" : "待提交"}
</span>
</div>
<div className="detail-section-body">
<div className="detail-kv-grid">
<div className="detail-kv">
<span>提交时间</span>
<strong>
{item.result_submitted_at
? formatDate(item.result_submitted_at, true)
: "—"}
</strong>
</div>
<div className="detail-kv">
<span>结果截图</span>
{resultKeys.length > 0 ? (
<strong>{resultKeys.length} 张</strong>
) : (
<span className="muted">待KOC上传</span>
)}
</div>
</div>
{resultKeys.length > 0 && (
<div className="detail-evidence">
<div className="admin-result-gallery">
{resultKeys.map((_, index) => (
<button
type="button"
key={`${item.id}-result-${index}`}
title={`查看第${index + 1}张结果截图`}
onClick={() =>
setPreviewGallery({
images: resultKeys.map((__, imageIndex) => ({
src: `/api/task-result-image?distribution=${encodeURIComponent(item.id)}&index=${imageIndex + 1}`,
title: `${item.content_title} · 第${imageIndex + 1}张结果截图`,
})),
activeIndex: index,
})
}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`/api/task-result-image?distribution=${encodeURIComponent(item.id)}&index=${index + 1}`}
alt={`结果截图 ${index + 1}`}
loading="lazy"
/>
</button>
))}
</div>
</div>
)}
</div>
</section>
)}
</div>
{/* 底部操作条 */}
<div className="detail-actions">
{canUpdatePublishUrl && (
<button
className="publish-url-edit-button"
disabled={working}
onClick={() => onUpdatePublishUrl(item)}
>
{noteUrl ? "更新链接" : "填写链接"}
</button>
)}
{item.screenshot_key && !hasCreatorMetrics(item) ? (
<button className="text-button" onClick={() => onFallback(item)}>
手动填写
</button>
) : item.collection_status === "failed" ? (
<button className="text-button" onClick={() => onFallback(item)}>
核对补录
</button>
) : (
<button
className="collect-button"
disabled={!noteUrl || working || collecting}
onClick={() => onCollect(item)}
>
{noteUrl ? "立即采集" : "待填链接"}
</button>
)}
{canRelease && !item.publish_url && !item.result_submitted_at && (
<button
className="release-distribution-button"
disabled={working}
onClick={() => onRelease(item)}
>
释放
</button>
)}
</div>
</div>
<AdminImageLightbox
images={previewGallery?.images ?? []}
activeIndex={previewGallery?.activeIndex ?? 0}
onIndexChange={(activeIndex) =>
setPreviewGallery((current) =>
current ? { ...current, activeIndex } : current,
)
}
onClose={() => setPreviewGallery(null)}
/>
</div>
);
}
function RecoveryPage({ function RecoveryPage({
tasks, tasks,
distributions, distributions,
@@ -6542,6 +7055,7 @@ function RecoveryPage({
onExport, onExport,
onExportScreenshots, onExportScreenshots,
exportingTaskId, exportingTaskId,
onOpenDetail,
}: { }: {
tasks: Task[]; tasks: Task[];
distributions: Distribution[]; distributions: Distribution[];
@@ -6563,6 +7077,7 @@ function RecoveryPage({
onExport: (task: Task) => void; onExport: (task: Task) => void;
onExportScreenshots: (task: Task) => void; onExportScreenshots: (task: Task) => void;
exportingTaskId: string | null; exportingTaskId: string | null;
onOpenDetail: (distribution: Distribution) => void;
}) { }) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null); const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [recoverySort, setRecoverySort] = useState<{ const [recoverySort, setRecoverySort] = useState<{
@@ -6664,7 +7179,11 @@ function RecoveryPage({
const publishPending = taskDistributions.filter( const publishPending = taskDistributions.filter(
(item) => !item.publish_url && !item.result_submitted_at, (item) => !item.publish_url && !item.result_submitted_at,
).length; ).length;
const pendingCount = publishPending + (recoveryEnabled ? recoveryPending : 0); // 与催办口径一致:待发布 + 截图待交 + 公开数据采集失败(催 KOC 补交数据截图)
const pendingCount =
publishPending +
(recoveryEnabled ? recoveryPending : 0) +
failedCollectionCount;
return ( return (
<button <button
className="retry-failed-button" className="retry-failed-button"
@@ -6749,7 +7268,12 @@ function RecoveryPage({
item.collection_status || item.collection_status ||
(hasMetrics ? "success" : "pending"); (hasMetrics ? "success" : "pending");
return ( return (
<tr key={item.id}> <tr
key={item.id}
className="clickable"
onClick={() => onOpenDetail(item)}
title="点击查看分发详情"
>
<td> <td>
<div className="account-cell recovery-content"> <div className="account-cell recovery-content">
<div className={`avatar small ${avatarColor(item.content_title)}`}>{Array.from(item.content_title)[0] || "笔"}</div> <div className={`avatar small ${avatarColor(item.content_title)}`}>{Array.from(item.content_title)[0] || "笔"}</div>
@@ -6798,7 +7322,7 @@ function RecoveryPage({
{item.last_collection_day && <span>第{item.last_collection_day}天采集</span>} {item.last_collection_day && <span>第{item.last_collection_day}天采集</span>}
</div> </div>
</td> </td>
<td> <td onClick={(event) => event.stopPropagation()}>
<div className="collection-status-cell"> <div className="collection-status-cell">
<span className={`collection-status ${collectionStatus}`}> <span className={`collection-status ${collectionStatus}`}>
{collectionStatusLabel(collectionStatus, hasMetrics)} {collectionStatusLabel(collectionStatus, hasMetrics)}
@@ -6807,9 +7331,20 @@ function RecoveryPage({
{item.collection_status_description || {item.collection_status_description ||
(hasMetrics ? "成功" : "等待采集计划")} (hasMetrics ? "成功" : "等待采集计划")}
</small> </small>
{item.public_metrics_screenshot_key && (
<a
className="text-button"
href={`/api/creator-screenshot?distribution=${encodeURIComponent(item.id)}&kind=public-metrics`}
target="_blank"
rel="noreferrer"
title="查看 KOC 补交的公开数据截图"
>
KOC补交截图 ↗
</a>
)}
</div> </div>
</td> </td>
<td> <td onClick={(event) => event.stopPropagation()}>
<div className="creator-screenshot-cell"> <div className="creator-screenshot-cell">
{item.screenshot_key ? ( {item.screenshot_key ? (
<CreatorScreenshotPreview <CreatorScreenshotPreview
@@ -6860,10 +7395,12 @@ function RecoveryPage({
</div> </div>
</td> </td>
)} )}
<td> <td onClick={(event) => event.stopPropagation()}>
<div className="recovery-row-actions"> <div className="recovery-row-actions">
{item.screenshot_key && !hasCreatorMetrics(item) ? ( {item.screenshot_key && !hasCreatorMetrics(item) ? (
<button className="text-button" onClick={() => onFallback(item)}>手动填写</button> <button className="text-button" onClick={() => onFallback(item)}>手动填写</button>
) : item.collection_status === "failed" ? (
<button className="text-button" onClick={() => onFallback(item)}>核对补录</button>
) : ( ) : (
<button <button
className="collect-button" className="collect-button"

View File

@@ -656,19 +656,62 @@ export async function POST(request: Request) {
const forbidden = await distributionOwnerGate(body.distributionId); const forbidden = await distributionOwnerGate(body.distributionId);
if (forbidden) return forbidden; if (forbidden) return forbidden;
const distributionId = String(body.distributionId ?? ""); const distributionId = String(body.distributionId ?? "");
const exposure = Math.max(0, numberValue(body.exposure)); const hasCreatorMetrics =
const views = Math.max(0, numberValue(body.views)); body.exposure !== undefined &&
body.exposure !== null &&
body.views !== undefined &&
body.views !== null;
const exposure = hasCreatorMetrics
? Math.max(0, numberValue(body.exposure))
: null;
const views = hasCreatorMetrics ? Math.max(0, numberValue(body.views)) : null;
// 采集失败兜底:运营按 KOC 补交的数据截图核对补录公开互动数据
const hasPublicMetrics =
body.publicLikes !== undefined ||
body.publicCollects !== undefined ||
body.publicComments !== undefined;
const publicLikes = Math.max(0, numberValue(body.publicLikes));
const publicCollects = Math.max(0, numberValue(body.publicCollects));
const publicComments = Math.max(0, numberValue(body.publicComments));
await db await db
.prepare( .prepare(
`UPDATE distributions SET `UPDATE distributions SET
exposure = ?, exposure = CASE WHEN ? THEN ? ELSE exposure END,
views = ?, views = CASE WHEN ? THEN ? ELSE views END,
ocr_status = 'manual', ocr_status = CASE WHEN ? THEN 'manual' ELSE ocr_status END,
status = CASE WHEN exposure IS NOT NULL AND views IS NOT NULL THEN 'complete' ELSE status END, status = CASE WHEN exposure IS NOT NULL AND views IS NOT NULL THEN 'complete' ELSE status END,
latest_likes = CASE WHEN ? THEN ? ELSE latest_likes END,
latest_collects = CASE WHEN ? THEN ? ELSE latest_collects END,
latest_comments = CASE WHEN ? THEN ? ELSE latest_comments END,
collection_status = CASE WHEN ? THEN 'success' ELSE collection_status END,
collection_status_description = CASE
WHEN ? THEN '运营已按补交截图核对补录'
ELSE collection_status_description
END,
collection_updated_at = CASE
WHEN ? THEN CURRENT_TIMESTAMP
ELSE collection_updated_at
END,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = ?`, WHERE id = ?`,
) )
.bind(exposure, views, distributionId) .bind(
hasCreatorMetrics ? 1 : 0,
exposure,
hasCreatorMetrics ? 1 : 0,
views,
hasCreatorMetrics ? 1 : 0,
hasPublicMetrics ? 1 : 0,
publicLikes,
hasPublicMetrics ? 1 : 0,
publicCollects,
hasPublicMetrics ? 1 : 0,
publicComments,
hasPublicMetrics ? 1 : 0,
hasPublicMetrics ? 1 : 0,
hasPublicMetrics ? 1 : 0,
distributionId,
)
.run(); .run();
const ownerTask = await db const ownerTask = await db
.prepare("SELECT task_id FROM distributions WHERE id = ?") .prepare("SELECT task_id FROM distributions WHERE id = ?")

View File

@@ -10,20 +10,33 @@ export async function GET(request: Request) {
const isAdmin = await isAdminRequest(request); const isAdmin = await isAdminRequest(request);
try { try {
await ensureSchema(); await ensureSchema();
const distributionId = new URL(request.url).searchParams const url = new URL(request.url);
.get("distribution") const distributionId = url.searchParams.get("distribution")?.trim();
?.trim(); // kind=public-metrics 时返回 KOC 补交的公开数据截图(仅后台核对用)
const imageKind = url.searchParams.get("kind") === "public-metrics"
? "public-metrics"
: "creator";
if (!distributionId) { if (!distributionId) {
return Response.json({ error: "缺少分发记录" }, { status: 400 }); return Response.json({ error: "缺少分发记录" }, { status: 400 });
} }
const row = await getRawDb() const row = await getRawDb()
.prepare( .prepare(
`SELECT screenshot_key FROM distributions `SELECT screenshot_key, public_metrics_screenshot_key FROM distributions
WHERE id = ?`, WHERE id = ?`,
) )
.bind(distributionId) .bind(distributionId)
.first<{ screenshot_key: string | null }>(); .first<{
const mcpToken = new URL(request.url).searchParams.get("mcp_token") || ""; screenshot_key: string | null;
public_metrics_screenshot_key: string | null;
}>();
const mcpToken = url.searchParams.get("mcp_token") || "";
const key =
imageKind === "public-metrics"
? row?.public_metrics_screenshot_key ?? null
: row?.screenshot_key ?? null;
if (imageKind === "public-metrics" && !isAdmin) {
return adminForbidden();
}
if ( if (
!isAdmin && !isAdmin &&
(!row?.screenshot_key || (!row?.screenshot_key ||
@@ -36,18 +49,20 @@ export async function GET(request: Request) {
return adminForbidden(); return adminForbidden();
} }
if ( if (
!row?.screenshot_key || !key ||
!row.screenshot_key.startsWith("creator-center/") !key.startsWith(
imageKind === "public-metrics" ? "public-metrics/" : "creator-center/",
)
) { ) {
return Response.json({ error: "创作者截图不存在" }, { status: 404 }); return Response.json({ error: "截图不存在" }, { status: 404 });
} }
const object = await getUploadBucket().get(row.screenshot_key); const object = await getUploadBucket().get(key);
if (!object) { if (!object) {
return Response.json({ error: "创作者截图不存在" }, { status: 404 }); return Response.json({ error: "截图不存在" }, { status: 404 });
} }
const headers = new Headers({ const headers = new Headers({
"Cache-Control": "private, no-store", "Cache-Control": "private, no-store",
"Content-Disposition": 'inline; filename="creator-center-screenshot"', "Content-Disposition": `inline; filename="${imageKind}-screenshot"`,
"X-Content-Type-Options": "nosniff", "X-Content-Type-Options": "nosniff",
}); });
object.writeHttpMetadata(headers); object.writeHttpMetadata(headers);

View File

@@ -76,6 +76,7 @@ async function handleGet(request: Request) {
d.result_screenshot_key, d.result_screenshot_key,
d.publish_screenshot_key, d.publish_screenshot_key,
d.screenshot_key, d.screenshot_key,
d.public_metrics_screenshot_key,
t.recovery_example_images t.recovery_example_images
FROM distributions d FROM distributions d
JOIN contents c ON c.id = d.content_id JOIN contents c ON c.id = d.content_id
@@ -92,6 +93,7 @@ async function handleGet(request: Request) {
result_screenshot_key: string | null; result_screenshot_key: string | null;
publish_screenshot_key: string | null; publish_screenshot_key: string | null;
screenshot_key: string | null; screenshot_key: string | null;
public_metrics_screenshot_key: string | null;
recovery_example_images: string; recovery_example_images: string;
}>() }>()
: await getRawDb() : await getRawDb()
@@ -101,6 +103,7 @@ async function handleGet(request: Request) {
d.result_screenshot_key, d.result_screenshot_key,
d.publish_screenshot_key, d.publish_screenshot_key,
d.screenshot_key, d.screenshot_key,
d.public_metrics_screenshot_key,
t.recovery_example_images t.recovery_example_images
FROM distributions d FROM distributions d
JOIN contents c ON c.id = d.content_id JOIN contents c ON c.id = d.content_id
@@ -118,6 +121,7 @@ async function handleGet(request: Request) {
result_screenshot_key: string | null; result_screenshot_key: string | null;
publish_screenshot_key: string | null; publish_screenshot_key: string | null;
screenshot_key: string | null; screenshot_key: string | null;
public_metrics_screenshot_key: string | null;
recovery_example_images: string; recovery_example_images: string;
}>(); }>();
const asset = const asset =
@@ -142,6 +146,10 @@ async function handleGet(request: Request) {
? row?.screenshot_key?.startsWith("creator-center/") ? row?.screenshot_key?.startsWith("creator-center/")
? { index: 1, key: row.screenshot_key } ? { index: 1, key: row.screenshot_key }
: undefined : undefined
: imageKind === "public-metrics"
? row?.public_metrics_screenshot_key?.startsWith("public-metrics/")
? { index: 1, key: row.public_metrics_screenshot_key }
: undefined
: imageKind === "video" : imageKind === "video"
? row ? row
? findAsset(row.video_assets, imageIndex, ["content-videos/"]) ? findAsset(row.video_assets, imageIndex, ["content-videos/"])
@@ -196,7 +204,9 @@ async function handleGet(request: Request) {
const headers = new Headers(); const headers = new Headers();
object.writeHttpMetadata(headers); object.writeHttpMetadata(headers);
const isMutableEvidence = const isMutableEvidence =
imageKind === "publish" || imageKind === "creator"; imageKind === "publish" ||
imageKind === "creator" ||
imageKind === "public-metrics";
headers.set( headers.set(
"Cache-Control", "Cache-Control",
isMutableEvidence ? "private, no-store" : "private, max-age=3600", isMutableEvidence ? "private, no-store" : "private, max-age=3600",

View File

@@ -87,7 +87,12 @@ async function handlePost(request: Request) {
} }
const isCreatorCenter = upload.uploadKind === "creator-center"; const isCreatorCenter = upload.uploadKind === "creator-center";
const isTaskResult = upload.uploadKind === "task-result"; const isTaskResult = upload.uploadKind === "task-result";
if (!["publish", "creator-center", "task-result"].includes(upload.uploadKind)) { const isPublicMetrics = upload.uploadKind === "public-metrics";
if (
!["publish", "creator-center", "task-result", "public-metrics"].includes(
upload.uploadKind,
)
) {
return Response.json({ error: "不支持的截图类型" }, { status: 400 }); return Response.json({ error: "不支持的截图类型" }, { status: 400 });
} }
const assignment = upload.delegationToken const assignment = upload.delegationToken
@@ -119,7 +124,7 @@ async function handlePost(request: Request) {
if (!assignment) { if (!assignment) {
return Response.json({ error: "笔记与领取凭证不匹配" }, { status: 403 }); return Response.json({ error: "笔记与领取凭证不匹配" }, { status: 403 });
} }
if (isCreatorCenter && !assignment.publish_url) { if ((isCreatorCenter || isPublicMetrics) && !assignment.publish_url) {
return Response.json( return Response.json(
{ error: "请先回填这篇笔记的发布信息" }, { error: "请先回填这篇笔记的发布信息" },
{ status: 409 }, { status: 409 },
@@ -143,6 +148,8 @@ async function handlePost(request: Request) {
? `task-results/${upload.distributionId}/${uid("shot")}.${extension}` ? `task-results/${upload.distributionId}/${uid("shot")}.${extension}`
: isCreatorCenter : isCreatorCenter
? `creator-center/${upload.distributionId}/${uid("shot")}.${extension}` ? `creator-center/${upload.distributionId}/${uid("shot")}.${extension}`
: isPublicMetrics
? `public-metrics/${upload.distributionId}/${uid("shot")}.${extension}`
: `publish-evidence/${upload.distributionId}/${uid("shot")}.${extension}`; : `publish-evidence/${upload.distributionId}/${uid("shot")}.${extension}`;
await getUploadBucket().put(key, upload.fileBytes, { await getUploadBucket().put(key, upload.fileBytes, {
httpMetadata: { contentType: upload.fileType }, httpMetadata: { contentType: upload.fileType },
@@ -160,6 +167,18 @@ async function handlePost(request: Request) {
upload.distributionId, upload.distributionId,
) )
.run(); .run();
} else if (isPublicMetrics) {
// 公开数据自动采集失败时的兜底凭证:运营据此核对补录互动数据
await getRawDb()
.prepare(
`UPDATE distributions SET
public_metrics_screenshot_key = ?,
collection_status_description = 'KOC已补交数据截图,待运营核对',
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(key, upload.distributionId)
.run();
} else if (isCreatorCenter) { } else if (isCreatorCenter) {
await getRawDb() await getRawDb()
.prepare( .prepare(
@@ -226,6 +245,8 @@ async function handlePost(request: Request) {
? "task-result" ? "task-result"
: isCreatorCenter : isCreatorCenter
? "creator-center" ? "creator-center"
: isPublicMetrics
? "public-metrics"
: "publish", : "publish",
ocrStatus: isCreatorCenter ? "failed" : undefined, ocrStatus: isCreatorCenter ? "failed" : undefined,
}); });

View File

@@ -326,6 +326,12 @@ async function handleGet(request: Request) {
d.ocr_status, d.ocr_status,
d.exposure, d.exposure,
d.views, d.views,
d.collection_status,
d.latest_likes,
d.latest_comments,
d.latest_collects,
d.collection_updated_at,
d.public_metrics_screenshot_key,
c.title, c.title,
c.body, c.body,
c.source_row, c.source_row,
@@ -400,6 +406,12 @@ async function handleGet(request: Request) {
d.ocr_status, d.ocr_status,
d.exposure, d.exposure,
d.views, d.views,
d.collection_status,
d.latest_likes,
d.latest_comments,
d.latest_collects,
d.collection_updated_at,
d.public_metrics_screenshot_key,
c.title, c.title,
c.body, c.body,
c.source_row, c.source_row,

View File

@@ -7,9 +7,8 @@ export async function GET(request: Request) {
if (!(await isAdminRequest(request))) return adminForbidden(); if (!(await isAdminRequest(request))) return adminForbidden();
try { try {
await ensureSchema(); await ensureSchema();
const distributionId = new URL(request.url).searchParams const url = new URL(request.url);
.get("distribution") const distributionId = url.searchParams.get("distribution")?.trim();
?.trim();
const scope = await getOwnerScope(request); const scope = await getOwnerScope(request);
if ( if (
scope.kind === "owner" && scope.kind === "owner" &&
@@ -17,21 +16,29 @@ export async function GET(request: Request) {
) { ) {
return ownerForbidden(); return ownerForbidden();
} }
// kind=publish 时返回 KOC 上传的发布截图(分发详情页核对用)
const imageKind = url.searchParams.get("kind") === "publish" ? "publish" : "result";
const imageIndex = Math.max( const imageIndex = Math.max(
1, 1,
Number(new URL(request.url).searchParams.get("index") || 1), Number(url.searchParams.get("index") || 1),
); );
if (!distributionId) { if (!distributionId) {
return Response.json({ error: "缺少任务记录" }, { status: 400 }); return Response.json({ error: "缺少任务记录" }, { status: 400 });
} }
const row = await getRawDb() const row = await getRawDb()
.prepare( .prepare(
`SELECT result_screenshot_key FROM distributions `SELECT result_screenshot_key, publish_screenshot_key FROM distributions
WHERE id = ?`, WHERE id = ?`,
) )
.bind(distributionId) .bind(distributionId)
.first<{ result_screenshot_key: string | null }>(); .first<{
const screenshotKey = row result_screenshot_key: string | null;
publish_screenshot_key: string | null;
}>();
const screenshotKey =
imageKind === "publish"
? row?.publish_screenshot_key ?? null
: row
? parseResultScreenshotKeys(row.result_screenshot_key)[imageIndex - 1] ? parseResultScreenshotKeys(row.result_screenshot_key)[imageIndex - 1]
: undefined; : undefined;
if (!screenshotKey) { if (!screenshotKey) {

View File

@@ -1295,6 +1295,208 @@ a {
background: #fbfcfa; background: #fbfcfa;
} }
/* 行可点击查看分发详情 */
.data-table tbody tr.clickable {
cursor: pointer;
}
.data-table tbody tr.clickable:hover td {
background: #f4f5fb;
}
/* 分发详情弹窗:结构化布局(头部 / 滚动内容 / 底部操作条) */
.distribution-detail-modal {
width: min(660px, 100%);
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
padding: 20px 0 0;
}
.distribution-detail-header {
padding: 0 22px 14px;
border-bottom: 1px solid var(--line);
}
.detail-badges {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-top: 7px;
}
.detail-meta {
display: flex;
align-items: center;
gap: 6px 14px;
flex-wrap: wrap;
margin-top: 10px;
padding: 9px 13px;
border-radius: 10px;
background: #f5f7f5;
border: 1px solid var(--line);
color: var(--ink-soft);
font-size: 12px;
}
.detail-body {
flex: 1;
overflow-y: auto;
padding: 14px 22px 8px;
}
.detail-section {
border: 1px solid var(--line);
border-radius: 12px;
background: var(--panel);
overflow: hidden;
}
.detail-section + .detail-section {
margin-top: 11px;
}
.detail-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 13px;
background: #f3f6f3;
border-bottom: 1px solid var(--line);
}
.detail-section-head b {
font-size: 12.5px;
font-weight: 680;
color: var(--ink-soft);
}
.detail-section-body {
padding: 12px 13px;
}
.detail-kv-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 18px;
}
.detail-kv {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.detail-kv > span {
font-size: 11px;
color: var(--muted);
}
.detail-kv > strong,
.detail-kv > a,
.detail-kv > code {
font-size: 12.5px;
font-weight: 620;
overflow-wrap: anywhere;
}
.detail-metric-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(92px, 1fr));
gap: 8px;
}
.detail-metric {
padding: 8px 11px;
border-radius: 9px;
background: #f5f7f5;
border: 1px solid var(--line);
}
.detail-metric span {
display: block;
font-size: 10.5px;
color: var(--muted);
}
.detail-metric strong {
font-size: 15px;
font-weight: 720;
font-variant-numeric: tabular-nums;
}
.detail-evidence {
display: flex;
align-items: center;
gap: 10px;
margin-top: 11px;
flex-wrap: wrap;
}
.detail-evidence-label {
font-size: 11.5px;
color: var(--muted);
flex: none;
}
.detail-shot {
display: flex;
flex-direction: column;
gap: 4px;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
width: 128px;
flex: none;
}
.detail-shot img {
width: 128px;
height: 88px;
object-fit: cover;
border-radius: 9px;
border: 1px solid var(--line);
display: block;
}
.detail-shot:hover img {
border-color: #9fc3b4;
}
.detail-shot span {
font-size: 11px;
color: var(--muted);
text-align: center;
}
.detail-evidence-label.warn {
color: var(--orange);
font-weight: 640;
}
.detail-metric {
padding: 8px 11px;
border-radius: 9px;
background: #f5f7f5;
border: 1px solid var(--line);
}
.detail-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 9px;
flex-wrap: wrap;
padding: 12px 22px;
border-top: 1px solid var(--line);
background: #fafbfa;
border-radius: 0 0 17px 17px;
}
.content-cell, .content-cell,
.account-cell { .account-cell {
display: flex; display: flex;

View File

@@ -209,6 +209,7 @@ export const distributions = mysqlTable("distributions", {
latestComments: int("latest_comments"), latestComments: int("latest_comments"),
latestCollects: int("latest_collects"), latestCollects: int("latest_collects"),
latestShares: int("latest_shares"), latestShares: int("latest_shares"),
publicMetricsScreenshotKey: text("public_metrics_screenshot_key"),
collectionStatus: varchar("collection_status", { length: 32 }) collectionStatus: varchar("collection_status", { length: 32 })
.notNull() .notNull()
.default("pending"), .default("pending"),

View File

@@ -1683,6 +1683,28 @@ footer {
line-height: 1.65; line-height: 1.65;
} }
/* 公开数据自动采集状态行:让 KOC 明白数据不用每天手动填 */
.auto-collect-line {
margin: 10px 0 0;
padding: 8px 11px;
border-radius: 8px;
background: #e9f5f0;
border: 1px solid #cdeade;
color: #27775b;
font-size: 8px;
line-height: 1.6;
}
/* 公开数据补交(采集失败兜底)区块 */
.metrics-fallback {
border-top-color: #f3c6cf;
}
.metrics-fallback .creator-recovery-heading > span {
background: #ffeef1;
color: #be123c;
}
.screenshot-picker.compact { .screenshot-picker.compact {
min-height: 96px; min-height: 96px;
} }

View File

@@ -23,6 +23,12 @@ type Assignment = {
ocr_status: string; ocr_status: string;
exposure: number | null; exposure: number | null;
views: number | null; views: number | null;
collection_status?: string | null;
latest_likes?: number | null;
latest_comments?: number | null;
latest_collects?: number | null;
collection_updated_at?: string | null;
public_metrics_screenshot_key?: string | null;
title: string; title: string;
body: string; body: string;
source_row: number | null; source_row: number | null;
@@ -460,6 +466,7 @@ export default function Home() {
const [creatorScreenshot, setCreatorScreenshot] = useState<File | null>(null); const [creatorScreenshot, setCreatorScreenshot] = useState<File | null>(null);
const [creatorExposure, setCreatorExposure] = useState(""); const [creatorExposure, setCreatorExposure] = useState("");
const [creatorViews, setCreatorViews] = useState(""); const [creatorViews, setCreatorViews] = useState("");
const [publicMetricsShot, setPublicMetricsShot] = useState<File | null>(null);
const [entryMode, setEntryMode] = useState<"claim" | "recover">("claim"); const [entryMode, setEntryMode] = useState<"claim" | "recover">("claim");
const [recoveredClaims, setRecoveredClaims] = useState<RecoveredClaim[]>([]); const [recoveredClaims, setRecoveredClaims] = useState<RecoveredClaim[]>([]);
const [wecomIdentity, setWecomIdentity] = useState<WecomPortalIdentity | null>( const [wecomIdentity, setWecomIdentity] = useState<WecomPortalIdentity | null>(
@@ -485,6 +492,7 @@ export default function Home() {
const batchWorkbookInputRef = useRef<HTMLInputElement | null>(null); const batchWorkbookInputRef = useRef<HTMLInputElement | null>(null);
const publishScreenshotPreview = useFilePreview(screenshot); const publishScreenshotPreview = useFilePreview(screenshot);
const creatorScreenshotPreview = useFilePreview(creatorScreenshot); const creatorScreenshotPreview = useFilePreview(creatorScreenshot);
const publicMetricsPreview = useFilePreview(publicMetricsShot);
const taskResultPreviews = useMemo( const taskResultPreviews = useMemo(
() => () =>
taskResultScreenshots.map((file) => ({ taskResultScreenshots.map((file) => ({
@@ -678,6 +686,7 @@ export default function Home() {
setScreenshot(null); setScreenshot(null);
setTaskResultScreenshots([]); setTaskResultScreenshots([]);
setCreatorScreenshot(null); setCreatorScreenshot(null);
setPublicMetricsShot(null);
setCreatorExposure( setCreatorExposure(
selected.exposure === null ? "" : String(selected.exposure), selected.exposure === null ? "" : String(selected.exposure),
); );
@@ -844,6 +853,36 @@ export default function Home() {
return `${adminOrigin}/api/partner-image?${params}`; return `${adminOrigin}/api/partner-image?${params}`;
}; };
const publicMetricsImageUrl = () => {
if (!selected) return "";
const params = new URLSearchParams({
distribution: selected.id,
index: "1",
kind: "public-metrics",
});
if (delegationToken) params.set("share", delegationToken);
else {
params.set("task", taskToken);
params.set("claim", claimToken);
}
return `${adminOrigin}/api/partner-image?${params}`;
};
const submitPublicMetricsShot = async () => {
if (!selected || !publicMetricsShot) return;
try {
setWorking(true);
await uploadEvidence(selected, publicMetricsShot, "public-metrics");
setPublicMetricsShot(null);
await loadTask(taskToken, claimToken, delegationToken);
setToast("数据截图已补交,运营核对后会补录公开数据");
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "数据截图上传失败");
} finally {
setWorking(false);
}
};
const evidenceImageUrl = ( const evidenceImageUrl = (
item: Assignment, item: Assignment,
kind: "publish" | "creator", kind: "publish" | "creator",
@@ -1207,7 +1246,7 @@ export default function Home() {
const uploadEvidence = async ( const uploadEvidence = async (
selectedItem: Assignment, selectedItem: Assignment,
file: File, file: File,
kind: "publish" | "creator-center" | "task-result", kind: "publish" | "creator-center" | "task-result" | "public-metrics",
): Promise<{ exposure?: number | null; views?: number | null; ocrStatus?: string }> => { ): Promise<{ exposure?: number | null; views?: number | null; ocrStatus?: string }> => {
const compressed = await compressScreenshot(file); const compressed = await compressScreenshot(file);
const headers: Record<string, string> = { const headers: Record<string, string> = {
@@ -1678,6 +1717,85 @@ export default function Home() {
{selected.publish_time && ( {selected.publish_time && (
<p className="submitted-at">上次回填:{formatDate(selected.publish_time, true)}</p> <p className="submitted-at">上次回填:{formatDate(selected.publish_time, true)}</p>
)} )}
{selected.publish_url && selected.collection_status === "failed" && (
<section className="creator-recovery metrics-fallback">
<div className="creator-recovery-heading">
<div>
<p className="micro">公开数据补交</p>
<h3>自动采集失败,请补交数据截图</h3>
</div>
<span className={selected.public_metrics_screenshot_key ? "done" : ""}>
{selected.public_metrics_screenshot_key ? "已补交,待核对" : "待补交"}
</span>
</div>
<p className="creator-recovery-tip">
系统自动采集这篇笔记的公开数据失败了。请打开笔记页面,
截一张包含点赞、收藏、评论数字的截图上传,运营核对后会补录数据。
</p>
<div className="evidence-field compact-field">
<span>数据截图</span>
<div
className={`screenshot-picker compact ${
publicMetricsShot || selected.public_metrics_screenshot_key
? "has-file preview-mode"
: ""
}`}
>
{publicMetricsShot || selected.public_metrics_screenshot_key ? (
<button
type="button"
className="evidence-preview-button compact-preview"
onClick={() =>
setPreviewImage({
src:
publicMetricsPreview ||
publicMetricsImageUrl(),
alt: "公开数据截图",
})
}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={publicMetricsPreview || publicMetricsImageUrl()}
alt="公开数据截图"
/>
<span>点击查看大图</span>
</button>
) : (
<div className="evidence-empty-state">
<b>+</b>
<strong>点击上传笔记数据页截图</strong>
<small>需包含点赞、收藏、评论数字,最大8MB</small>
</div>
)}
<label className="evidence-upload-action">
<input
type="file"
accept="image/*"
onChange={(event) =>
setPublicMetricsShot(event.target.files?.[0] ?? null)
}
/>
{publicMetricsShot || selected.public_metrics_screenshot_key
? "重新选择"
: "选择图片"}
</label>
</div>
</div>
<button
type="button"
className="creator-submit"
disabled={!publicMetricsShot || working}
onClick={() => void submitPublicMetricsShot()}
>
{working
? "正在上传…"
: selected.public_metrics_screenshot_key
? "更新数据截图"
: "提交数据截图"}
</button>
</section>
)}
{selected.publish_url && ( {selected.publish_url && (
<section className="creator-recovery"> <section className="creator-recovery">
<div className="creator-recovery-heading"> <div className="creator-recovery-heading">
@@ -1689,8 +1807,23 @@ export default function Home() {
{creatorDataReady ? "已提交" : "待提交"} {creatorDataReady ? "已提交" : "待提交"}
</span> </span>
</div> </div>
{selected.latest_likes !== null &&
selected.latest_likes !== undefined ? (
<p className="auto-collect-line">
公开数据系统自动采集中:赞 {selected.latest_likes ?? "—"} · 藏{" "}
{selected.latest_collects ?? "—"} · 评 {selected.latest_comments ?? "—"}
{selected.collection_updated_at
? `(${formatDate(selected.collection_updated_at, true)})`
: ""}
,无需你填写。
</p>
) : (
<p className="auto-collect-line">
点赞、收藏、评论等公开数据由系统每天自动采集,无需你填写。
</p>
)}
<p className="creator-recovery-tip"> <p className="creator-recovery-tip">
请在发布满7天后,上传创作者中心数据页完整截图,并填写页面显示的数据。 这一步只需做一次:发布满7天后,上传创作者中心数据页截图,系统自动识别曝光量和阅读量;仅当识别失败时才需要手动填写这两个数字。
{creatorDueAt && ` 建议上传日期:${formatDate(creatorDueAt)}。`} {creatorDueAt && ` 建议上传日期:${formatDate(creatorDueAt)}。`}
</p> </p>
<div className="evidence-field compact-field"> <div className="evidence-field compact-field">
@@ -1772,6 +1905,9 @@ export default function Home() {
/> />
</label> </label>
</div> </div>
<small className="task-result-hint">
两个数字一般由截图自动识别填入;只有识别失败时才需要手动修改。
</small>
<button <button
type="button" type="button"
className="creator-submit" className="creator-submit"

View File

@@ -27,21 +27,27 @@ type DueSoonRow = {
wecom_external_user_id: string | null; wecom_external_user_id: string | null;
task_id: string; task_id: string;
task_name: string; task_name: string;
pending_phase: "publish" | "recovery"; pending_phase: "publish" | "recovery" | "metrics";
pending_due_at: string; pending_due_at: string;
content_title: string; content_title: string;
}; };
function pendingActionLabel(pendingPhase: string) { function pendingActionLabel(pendingPhase: string) {
return pendingPhase === "recovery" ? "截图待提交" : "待发布"; if (pendingPhase === "recovery") return "截图待提交";
if (pendingPhase === "metrics") return "公开数据待补交";
return "待发布";
} }
function pendingSummary(rows: DueSoonRow[]) { function pendingSummary(rows: DueSoonRow[]) {
const shot = rows.filter((row) => row.pending_phase === "recovery").length; const shot = rows.filter((row) => row.pending_phase === "recovery").length;
const publish = rows.length - shot; const metrics = rows.filter((row) => row.pending_phase === "metrics").length;
if (shot === 0) return `${rows.length} 条笔记待发布`; const publish = rows.length - shot - metrics;
if (publish === 0) return `${rows.length} 份截图待提交`; const parts: string[] = [];
return `${publish} 条笔记待发布、${shot} 份截图待提交`; if (publish > 0) parts.push(`${publish} 条笔记待发布`);
if (shot > 0) parts.push(`${shot} 份截图待提交`);
if (metrics > 0) parts.push(`${metrics} 条公开数据待补交`);
if (parts.length === 0) return `${rows.length} 条待跟进`;
return parts.join("、");
} }
export function computeDueCutoff(today: string, dueDays: number) { export function computeDueCutoff(today: string, dueDays: number) {
@@ -66,7 +72,8 @@ export async function runDueSoonWecomNotifications(
const portalUrl = bindingValue(getRuntimeEnv().KOC_PORTAL_URL); const portalUrl = bindingValue(getRuntimeEnv().KOC_PORTAL_URL);
const taskFilter = String(taskId ?? "").trim(); const taskFilter = String(taskId ?? "").trim();
// 双阶段催办:发布阶段(未回填链接)看 due_at;回收阶段(已发布未收截图)看 recovery_due_at // 双阶段催办:发布阶段(未回填链接)看 due_at;回收阶段(已发布未收截图)看 recovery_due_at;
// 采集失败兜底:已发布但公开数据采集失败时催 KOC 补交数据截图(不限日期,尽快补)
const dueSoonBase = `SELECT const dueSoonBase = `SELECT
d.id AS distribution_id, d.id AS distribution_id,
d.partner_id, d.partner_id,
@@ -74,8 +81,17 @@ export async function runDueSoonWecomNotifications(
p.wecom_external_user_id, p.wecom_external_user_id,
t.id AS task_id, t.id AS task_id,
t.name AS task_name, t.name AS task_name,
CASE WHEN d.publish_url IS NULL OR d.publish_url = '' THEN 'publish' ELSE 'recovery' END AS pending_phase, CASE
CASE WHEN d.publish_url IS NULL OR d.publish_url = '' THEN t.due_at ELSE t.recovery_due_at END AS pending_due_at, WHEN d.publish_url IS NULL OR d.publish_url = '' THEN 'publish'
WHEN d.collection_status = 'failed' THEN 'metrics'
ELSE 'recovery'
END AS pending_phase,
CASE
WHEN d.publish_url IS NULL OR d.publish_url = '' THEN t.due_at
WHEN d.collection_status = 'failed'
THEN COALESCE(NULLIF(t.recovery_due_at, ''), t.due_at)
ELSE t.recovery_due_at
END AS pending_due_at,
c.title AS content_title c.title AS content_title
FROM distributions d FROM distributions d
JOIN tasks t ON t.id = d.task_id JOIN tasks t ON t.id = d.task_id
@@ -89,6 +105,8 @@ export async function runDueSoonWecomNotifications(
AND d.result_submitted_at IS NULL AND d.result_submitted_at IS NULL
AND t.recovery_due_at IS NOT NULL AND t.recovery_due_at != '' AND t.recovery_due_at IS NOT NULL AND t.recovery_due_at != ''
AND t.recovery_due_at <= ?) AND t.recovery_due_at <= ?)
OR (d.publish_url IS NOT NULL AND d.publish_url != ''
AND d.collection_status = 'failed')
)`; )`;
const statement = db const statement = db
.prepare( .prepare(

View File

@@ -0,0 +1,2 @@
-- 公开数据采集失败兜底:KOC 补交数据截图,运营核对后补录
ALTER TABLE distributions ADD COLUMN public_metrics_screenshot_key TEXT;

View File

@@ -0,0 +1,56 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
// 任务中心优化(2026-10-09):内容分发表的行可点击查看分发详情;
// 详情弹窗按单任务双阶段自适应(发布 / 数据回收 / 创作者数据 / 截图回收)。
test("distribution rows open a detail modal from both task center and recovery queue", async () => {
const [adminApp, globalCss, resultImageRoute] = await Promise.all([
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/globals.css", import.meta.url), "utf8"),
readFile(new URL("../app/api/task-result-image/route.ts", import.meta.url), "utf8"),
]);
// 弹窗组件 + 两处入口(分发明细表 / 回收队列表)行可点击
assert.match(adminApp, /function DistributionDetailModal/);
assert.match(adminApp, /const \[detailDistributionId, setDetailDistributionId\]/);
assert.match(adminApp, /title=\{onOpenDetail \? "点击查看分发详情" : undefined\}/);
assert.match(adminApp, /className=\{onOpenDetail \? "clickable" : ""\}/);
assert.match(adminApp, /className="clickable"\s*\n\s*onClick=\{\(\) => onOpenDetail\(item\)\}/);
// 行内操作(释放/立即采集/核对补录)不冒泡误开详情
assert.match(adminApp, /<td onClick=\{\(event\) => event\.stopPropagation\(\)\}>/);
assert.match(globalCss, /\.data-table tbody tr\.clickable\s*\{/);
// 详情数据实时刷新:任何操作重拉数据后弹窗按 id 重新取行
assert.match(adminApp, /data\.distributions\.find\(\(item\) => item\.id === detailDistributionId\)/);
// 发布截图:管理端通过 task-result-image?kind=publish 核对发布凭证
assert.match(resultImageRoute, /get\("kind"\) === "publish" \? "publish" : "result"/);
assert.match(resultImageRoute, /publish_screenshot_key/);
assert.match(adminApp, /kind=publish/);
assert.match(adminApp, /function PublishEvidencePreview/);
});
test("detail modal sections adapt to the dual-phase task model", async () => {
const adminApp = await readFile(
new URL("../app/admin-app.tsx", import.meta.url),
"utf8",
);
// 四个区块按阶段组织;未开通回收的任务不展示截图回收区块
assert.match(adminApp, /① 发布阶段/);
assert.match(adminApp, /② 数据回收(自动采集)/);
assert.match(adminApp, /③ 创作者数据(第7天)/);
assert.match(adminApp, /④ 截图回收/);
assert.match(adminApp, /recoveryEnabled && \(\s*<section className="detail-section">/);
// 抖音任务多展示转发,口径与回收队列一致
assert.match(adminApp, /item\.task_platform === "抖音" && \(\s*<div className="detail-metric">\s*<span>转发<\/span>/);
// 详情内集成兜底闭环:补交截图直达 + 核对补录入口
assert.match(adminApp, /KOC补交截图 ↗/);
assert.match(adminApp, /核对补录/);
// 详情内可执行的动作:更新/填写链接、立即采集、手动填写、释放(带确认)
assert.match(adminApp, /更新链接" : "填写链接"/);
assert.match(adminApp, /确认释放“\$\{distribution\.content_title\}”/);
// 采集计划随详情展示
assert.match(adminApp, /起 D\$\{collectionDays\.join\("\/D"\)\}/);
});

View File

@@ -0,0 +1,101 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
// 公开数据采集失败兜底闭环(2026-10-09):
// 自动采集优先 → 采集失败时催 KOC 补交数据截图 → 运营按截图核对补录互动数据。
// 同时修正门户创作者数据区块的表述:公开数据系统自动采集,KOC 不需要每天手动填。
test("koc can submit a public-metrics screenshot when auto collection fails", async () => {
const [uploadRoute, imageRoute, partnerRoute, page, schema, migration] =
await Promise.all([
readFile(new URL("../app/api/partner-upload/route.ts", import.meta.url), "utf8"),
readFile(new URL("../app/api/partner-image/route.ts", import.meta.url), "utf8"),
readFile(new URL("../app/api/partner/route.ts", import.meta.url), "utf8"),
readFile(new URL("../koc-portal/app/page.tsx", import.meta.url), "utf8"),
readFile(new URL("../db/schema.ts", import.meta.url), "utf8"),
readFile(new URL("../mysql/0022_public_metrics_screenshot.sql", import.meta.url), "utf8"),
]);
assert.match(migration, /ALTER TABLE distributions ADD COLUMN public_metrics_screenshot_key TEXT/);
assert.match(schema, /publicMetricsScreenshotKey/);
// 上传通道:仅发布后可补交,落 public-metrics/ 目录并标记待核对
assert.match(uploadRoute, /"public-metrics"/);
assert.match(uploadRoute, /\(isCreatorCenter \|\| isPublicMetrics\) && !assignment\.publish_url/);
assert.match(uploadRoute, /public-metrics\/\$\{upload\.distributionId\}/);
assert.match(uploadRoute, /public_metrics_screenshot_key = \?/);
assert.match(uploadRoute, /KOC已补交数据截图,待运营核对/);
// 领取载荷:门户能感知采集状态、已采集到的公开数据与补交截图
assert.match(partnerRoute, /d\.collection_status,/);
assert.match(partnerRoute, /d\.latest_likes,/);
assert.match(partnerRoute, /d\.public_metrics_screenshot_key,/);
// 图片服务:KOC 可查看自己补交的数据截图(可变凭证不缓存)
assert.match(imageRoute, /imageKind === "public-metrics"/);
assert.match(imageRoute, /d\.public_metrics_screenshot_key/);
assert.match(imageRoute, /imageKind === "public-metrics"/);
// 门户:仅采集失败且已发布时展示补交入口
assert.match(page, /collection_status === "failed"/);
assert.match(page, /"public-metrics"/);
assert.match(page, /submitPublicMetricsShot/);
assert.match(page, /自动采集失败,请补交数据截图/);
assert.match(page, /运营核对后会补录数据/);
});
test("creator data section reads as one-time d7 step, not a daily manual form", async () => {
const [page, styles] = await Promise.all([
readFile(new URL("../koc-portal/app/page.tsx", import.meta.url), "utf8"),
readFile(new URL("../koc-portal/app/globals.css", import.meta.url), "utf8"),
]);
// 自动采集状态行:有数据时直接展示最近采到的赞藏评
assert.match(page, /auto-collect-line/);
assert.match(page, /公开数据系统自动采集中/);
assert.match(page, /由系统每天自动采集,无需你填写/);
// 一次性表述 + 仅识别失败才手动填
assert.match(page, /这一步只需做一次/);
assert.match(page, /仅当识别失败时才需要手动填写这两个数字/);
assert.match(page, /只有识别失败时才需要手动修改/);
assert.match(styles, /\.auto-collect-line/);
assert.match(styles, /\.metrics-fallback/);
});
test("admin reviews the fallback screenshot and records engagement metrics", async () => {
const [actionRoute, adminApp, creatorRoute] = await Promise.all([
readFile(new URL("../app/api/action/route.ts", import.meta.url), "utf8"),
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/api/creator-screenshot/route.ts", import.meta.url), "utf8"),
]);
// manual_metrics:曝光/阅读可留空;补录互动数据并把采集状态置成功
assert.match(actionRoute, /publicLikes/);
assert.match(actionRoute, /latest_likes = CASE WHEN \? THEN \? ELSE latest_likes END/);
assert.match(actionRoute, /collection_status = CASE WHEN \? THEN 'success' ELSE collection_status END/);
assert.match(actionRoute, /运营已按补交截图核对补录/);
// 后台:采集失败行出现「核对补录」入口,状态列可直达补交截图
assert.match(adminApp, /核对补录/);
assert.match(adminApp, /KOC补交截图/);
assert.match(adminApp, /kind=public-metrics/);
assert.match(adminApp, /请至少填写创作者数据或公开互动数据中的一项/);
// 截图服务:kind=public-metrics 仅管理端可读
assert.match(creatorRoute, /"public-metrics"/);
assert.match(creatorRoute, /public_metrics_screenshot_key/);
});
test("due-soon reminders include failed collections as a metrics phase", async () => {
const [notifier, adminApp] = await Promise.all([
readFile(new URL("../lib/wecom-notifier-service.ts", import.meta.url), "utf8"),
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
]);
// 催办查询纳入采集失败(不限日期),文案区分「公开数据待补交」
assert.match(notifier, /WHEN d\.collection_status = 'failed' THEN 'metrics'/);
assert.match(notifier, /AND d\.collection_status = 'failed'\)/);
assert.match(notifier, /公开数据待补交/);
// 回收队列的「催办未完成」计数与催办口径对齐(含采集失败行)
assert.match(adminApp, /\(recoveryEnabled \? recoveryPending : 0\) \+/);
assert.match(adminApp, /\+\s*failedCollectionCount/);
});