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:
@@ -152,6 +152,7 @@ type Distribution = {
|
||||
account_id: string | null;
|
||||
publish_url: string | null;
|
||||
publish_time: string | null;
|
||||
publish_screenshot_key: string | null;
|
||||
result_screenshot_key: string | null;
|
||||
result_submitted_at: string | null;
|
||||
status: string;
|
||||
@@ -173,6 +174,7 @@ type Distribution = {
|
||||
latest_comments: number | null;
|
||||
latest_collects: number | null;
|
||||
latest_shares: number | null;
|
||||
public_metrics_screenshot_key: string | null;
|
||||
collection_status: string;
|
||||
collection_status_description: 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 [customerChatTask, setCustomerChatTask] = useState<Task | null>(null);
|
||||
const [metricModal, setMetricModal] = useState<Distribution | null>(null);
|
||||
const [detailDistributionId, setDetailDistributionId] = useState<string | null>(null);
|
||||
const [publishUrlModal, setPublishUrlModal] = useState<{
|
||||
distribution: Distribution;
|
||||
value: string;
|
||||
@@ -572,7 +575,13 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
||||
const [taskFormProjects, setTaskFormProjects] = useState<Array<{ id: string; name: string }>>([]);
|
||||
const [sourcePreview, setSourcePreview] = useState<FeishuPreview | null>(null);
|
||||
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 [exportingResources, setExportingResources] = useState(false);
|
||||
const [creatingPartnerId, setCreatingPartnerId] = useState<string | null>(null);
|
||||
@@ -1126,18 +1135,46 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
||||
const submitManualMetrics = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
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(
|
||||
{
|
||||
action: "manual_metrics",
|
||||
distributionId: metricModal.id,
|
||||
exposure: metricForm.exposure,
|
||||
views: metricForm.views,
|
||||
...(hasCreatorInput
|
||||
? { 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) {
|
||||
setMetricModal(null);
|
||||
setMetricForm({ exposure: "", views: "" });
|
||||
setMetricForm({
|
||||
exposure: "",
|
||||
views: "",
|
||||
likes: "",
|
||||
collects: "",
|
||||
comments: "",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1146,6 +1183,9 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
||||
setMetricForm({
|
||||
exposure: distribution.exposure?.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;
|
||||
}}
|
||||
onOpenDetail={(distribution) => setDetailDistributionId(distribution.id)}
|
||||
/>
|
||||
)}
|
||||
{activeNav === "resources" && (
|
||||
@@ -1404,6 +1445,7 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
||||
onExport={exportRecoveryData}
|
||||
onExportScreenshots={exportScreenshotResults}
|
||||
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()}>
|
||||
<div className="modal-heading">
|
||||
<div>
|
||||
<p className="eyebrow">OCR异常兜底</p>
|
||||
<h2>确认创作者数据</h2>
|
||||
<p className="eyebrow">
|
||||
{metricModal.collection_status === "failed"
|
||||
? "采集失败兜底 · 数据核对补录"
|
||||
: "OCR异常兜底"}
|
||||
</p>
|
||||
<h2>核对并补录数据</h2>
|
||||
</div>
|
||||
<button type="button" onClick={() => setMetricModal(null)} aria-label="关闭">×</button>
|
||||
</div>
|
||||
@@ -1688,19 +1734,36 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
||||
<strong>{metricModal.account_nickname || "待识别账号"}</strong>
|
||||
<span>{metricModal.content_title}</span>
|
||||
</div>
|
||||
<div className="form-grid two">
|
||||
<label>
|
||||
<span>曝光量</span>
|
||||
<input type="number" min="0" value={metricForm.exposure} onChange={(event) => setMetricForm({ ...metricForm, exposure: event.target.value })} required />
|
||||
</label>
|
||||
<label>
|
||||
<span>阅读量</span>
|
||||
<input type="number" min="0" value={metricForm.views} onChange={(event) => setMetricForm({ ...metricForm, views: event.target.value })} required />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span>创作者数据(曝光 / 阅读,第7天截图数据)</span>
|
||||
<div className="form-grid two" style={{ marginBottom: 0 }}>
|
||||
<input type="number" min="0" placeholder="曝光量" value={metricForm.exposure} onChange={(event) => setMetricForm({ ...metricForm, exposure: event.target.value })} />
|
||||
<input type="number" min="0" placeholder="阅读量" value={metricForm.views} onChange={(event) => setMetricForm({ ...metricForm, views: event.target.value })} />
|
||||
</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">
|
||||
<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>
|
||||
</form>
|
||||
</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>}
|
||||
{working && <div className="working-bar" />}
|
||||
</div>
|
||||
@@ -4580,6 +4688,7 @@ function TasksPage({
|
||||
onBindCustomerChat,
|
||||
onCreateRecovery,
|
||||
onRelease,
|
||||
onOpenDetail,
|
||||
}: {
|
||||
tasks: Task[];
|
||||
distributions: Distribution[];
|
||||
@@ -4598,6 +4707,7 @@ function TasksPage({
|
||||
onBindCustomerChat: (task: Task) => void;
|
||||
onCreateRecovery: (task: Task) => void;
|
||||
onRelease: (distribution: Distribution) => Promise<boolean>;
|
||||
onOpenDetail: (distribution: Distribution) => void;
|
||||
}) {
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<"grouped" | "flat">("grouped");
|
||||
@@ -4734,6 +4844,7 @@ function TasksPage({
|
||||
working={working}
|
||||
onRelease={onRelease}
|
||||
onBack={() => setSelectedTaskId(null)}
|
||||
onOpenDetail={onOpenDetail}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -5007,6 +5118,7 @@ function TaskDistributionDetail({
|
||||
working,
|
||||
onRelease,
|
||||
onBack,
|
||||
onOpenDetail,
|
||||
}: {
|
||||
task: Task;
|
||||
distributions: Distribution[];
|
||||
@@ -5014,6 +5126,7 @@ function TaskDistributionDetail({
|
||||
working: boolean;
|
||||
onRelease: (distribution: Distribution) => Promise<boolean>;
|
||||
onBack: () => void;
|
||||
onOpenDetail: (distribution: Distribution) => void;
|
||||
}) {
|
||||
const [releaseTarget, setReleaseTarget] = useState<Distribution | null>(null);
|
||||
const taskDistributions = distributions.filter(
|
||||
@@ -5059,6 +5172,7 @@ function TaskDistributionDetail({
|
||||
distributions={taskDistributions}
|
||||
onRelease={canRelease ? setReleaseTarget : undefined}
|
||||
showRecovery={recoveryEnabled}
|
||||
onOpenDetail={onOpenDetail}
|
||||
/>
|
||||
</section>
|
||||
{releaseTarget && (
|
||||
@@ -5128,11 +5242,13 @@ function DistributionTable({
|
||||
compact = false,
|
||||
onRelease,
|
||||
showRecovery = false,
|
||||
onOpenDetail,
|
||||
}: {
|
||||
distributions: Distribution[];
|
||||
compact?: boolean;
|
||||
onRelease?: (distribution: Distribution) => void;
|
||||
showRecovery?: boolean;
|
||||
onOpenDetail?: (distribution: Distribution) => void;
|
||||
}) {
|
||||
// 已发布但还没采到数据的行:有计划时按首个采集日显示,没计划时
|
||||
// 明说待设置,而不是写死「等待D2」误导运营。
|
||||
@@ -5170,7 +5286,12 @@ function DistributionTable({
|
||||
</thead>
|
||||
<tbody>
|
||||
{distributions.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<tr
|
||||
key={item.id}
|
||||
className={onOpenDetail ? "clickable" : ""}
|
||||
onClick={onOpenDetail ? () => onOpenDetail(item) : undefined}
|
||||
title={onOpenDetail ? "点击查看分发详情" : undefined}
|
||||
>
|
||||
<td>
|
||||
<div className="content-cell">
|
||||
<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>
|
||||
{onRelease && (
|
||||
<td>
|
||||
<td onClick={(event) => event.stopPropagation()}>
|
||||
{!item.publish_url && !item.result_submitted_at ? (
|
||||
<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({
|
||||
tasks,
|
||||
distributions,
|
||||
@@ -6542,6 +7055,7 @@ function RecoveryPage({
|
||||
onExport,
|
||||
onExportScreenshots,
|
||||
exportingTaskId,
|
||||
onOpenDetail,
|
||||
}: {
|
||||
tasks: Task[];
|
||||
distributions: Distribution[];
|
||||
@@ -6563,6 +7077,7 @@ function RecoveryPage({
|
||||
onExport: (task: Task) => void;
|
||||
onExportScreenshots: (task: Task) => void;
|
||||
exportingTaskId: string | null;
|
||||
onOpenDetail: (distribution: Distribution) => void;
|
||||
}) {
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const [recoverySort, setRecoverySort] = useState<{
|
||||
@@ -6664,7 +7179,11 @@ function RecoveryPage({
|
||||
const publishPending = taskDistributions.filter(
|
||||
(item) => !item.publish_url && !item.result_submitted_at,
|
||||
).length;
|
||||
const pendingCount = publishPending + (recoveryEnabled ? recoveryPending : 0);
|
||||
// 与催办口径一致:待发布 + 截图待交 + 公开数据采集失败(催 KOC 补交数据截图)
|
||||
const pendingCount =
|
||||
publishPending +
|
||||
(recoveryEnabled ? recoveryPending : 0) +
|
||||
failedCollectionCount;
|
||||
return (
|
||||
<button
|
||||
className="retry-failed-button"
|
||||
@@ -6749,7 +7268,12 @@ function RecoveryPage({
|
||||
item.collection_status ||
|
||||
(hasMetrics ? "success" : "pending");
|
||||
return (
|
||||
<tr key={item.id}>
|
||||
<tr
|
||||
key={item.id}
|
||||
className="clickable"
|
||||
onClick={() => onOpenDetail(item)}
|
||||
title="点击查看分发详情"
|
||||
>
|
||||
<td>
|
||||
<div className="account-cell recovery-content">
|
||||
<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>}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<td onClick={(event) => event.stopPropagation()}>
|
||||
<div className="collection-status-cell">
|
||||
<span className={`collection-status ${collectionStatus}`}>
|
||||
{collectionStatusLabel(collectionStatus, hasMetrics)}
|
||||
@@ -6807,9 +7331,20 @@ function RecoveryPage({
|
||||
{item.collection_status_description ||
|
||||
(hasMetrics ? "成功" : "等待采集计划")}
|
||||
</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>
|
||||
</td>
|
||||
<td>
|
||||
<td onClick={(event) => event.stopPropagation()}>
|
||||
<div className="creator-screenshot-cell">
|
||||
{item.screenshot_key ? (
|
||||
<CreatorScreenshotPreview
|
||||
@@ -6860,10 +7395,12 @@ function RecoveryPage({
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
<td>
|
||||
<td onClick={(event) => event.stopPropagation()}>
|
||||
<div className="recovery-row-actions">
|
||||
{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"
|
||||
|
||||
@@ -656,19 +656,62 @@ export async function POST(request: Request) {
|
||||
const forbidden = await distributionOwnerGate(body.distributionId);
|
||||
if (forbidden) return forbidden;
|
||||
const distributionId = String(body.distributionId ?? "");
|
||||
const exposure = Math.max(0, numberValue(body.exposure));
|
||||
const views = Math.max(0, numberValue(body.views));
|
||||
const hasCreatorMetrics =
|
||||
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
|
||||
.prepare(
|
||||
`UPDATE distributions SET
|
||||
exposure = ?,
|
||||
views = ?,
|
||||
ocr_status = 'manual',
|
||||
exposure = CASE WHEN ? THEN ? ELSE exposure END,
|
||||
views = CASE WHEN ? THEN ? ELSE views END,
|
||||
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,
|
||||
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
|
||||
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();
|
||||
const ownerTask = await db
|
||||
.prepare("SELECT task_id FROM distributions WHERE id = ?")
|
||||
|
||||
@@ -10,20 +10,33 @@ export async function GET(request: Request) {
|
||||
const isAdmin = await isAdminRequest(request);
|
||||
try {
|
||||
await ensureSchema();
|
||||
const distributionId = new URL(request.url).searchParams
|
||||
.get("distribution")
|
||||
?.trim();
|
||||
const url = new URL(request.url);
|
||||
const distributionId = url.searchParams.get("distribution")?.trim();
|
||||
// kind=public-metrics 时返回 KOC 补交的公开数据截图(仅后台核对用)
|
||||
const imageKind = url.searchParams.get("kind") === "public-metrics"
|
||||
? "public-metrics"
|
||||
: "creator";
|
||||
if (!distributionId) {
|
||||
return Response.json({ error: "缺少分发记录" }, { status: 400 });
|
||||
}
|
||||
const row = await getRawDb()
|
||||
.prepare(
|
||||
`SELECT screenshot_key FROM distributions
|
||||
`SELECT screenshot_key, public_metrics_screenshot_key FROM distributions
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(distributionId)
|
||||
.first<{ screenshot_key: string | null }>();
|
||||
const mcpToken = new URL(request.url).searchParams.get("mcp_token") || "";
|
||||
.first<{
|
||||
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 (
|
||||
!isAdmin &&
|
||||
(!row?.screenshot_key ||
|
||||
@@ -36,18 +49,20 @@ export async function GET(request: Request) {
|
||||
return adminForbidden();
|
||||
}
|
||||
if (
|
||||
!row?.screenshot_key ||
|
||||
!row.screenshot_key.startsWith("creator-center/")
|
||||
!key ||
|
||||
!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) {
|
||||
return Response.json({ error: "创作者截图不存在" }, { status: 404 });
|
||||
return Response.json({ error: "截图不存在" }, { status: 404 });
|
||||
}
|
||||
const headers = new Headers({
|
||||
"Cache-Control": "private, no-store",
|
||||
"Content-Disposition": 'inline; filename="creator-center-screenshot"',
|
||||
"Content-Disposition": `inline; filename="${imageKind}-screenshot"`,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
object.writeHttpMetadata(headers);
|
||||
|
||||
@@ -76,6 +76,7 @@ async function handleGet(request: Request) {
|
||||
d.result_screenshot_key,
|
||||
d.publish_screenshot_key,
|
||||
d.screenshot_key,
|
||||
d.public_metrics_screenshot_key,
|
||||
t.recovery_example_images
|
||||
FROM distributions d
|
||||
JOIN contents c ON c.id = d.content_id
|
||||
@@ -92,6 +93,7 @@ async function handleGet(request: Request) {
|
||||
result_screenshot_key: string | null;
|
||||
publish_screenshot_key: string | null;
|
||||
screenshot_key: string | null;
|
||||
public_metrics_screenshot_key: string | null;
|
||||
recovery_example_images: string;
|
||||
}>()
|
||||
: await getRawDb()
|
||||
@@ -101,6 +103,7 @@ async function handleGet(request: Request) {
|
||||
d.result_screenshot_key,
|
||||
d.publish_screenshot_key,
|
||||
d.screenshot_key,
|
||||
d.public_metrics_screenshot_key,
|
||||
t.recovery_example_images
|
||||
FROM distributions d
|
||||
JOIN contents c ON c.id = d.content_id
|
||||
@@ -118,6 +121,7 @@ async function handleGet(request: Request) {
|
||||
result_screenshot_key: string | null;
|
||||
publish_screenshot_key: string | null;
|
||||
screenshot_key: string | null;
|
||||
public_metrics_screenshot_key: string | null;
|
||||
recovery_example_images: string;
|
||||
}>();
|
||||
const asset =
|
||||
@@ -142,6 +146,10 @@ async function handleGet(request: Request) {
|
||||
? row?.screenshot_key?.startsWith("creator-center/")
|
||||
? { index: 1, key: row.screenshot_key }
|
||||
: undefined
|
||||
: imageKind === "public-metrics"
|
||||
? row?.public_metrics_screenshot_key?.startsWith("public-metrics/")
|
||||
? { index: 1, key: row.public_metrics_screenshot_key }
|
||||
: undefined
|
||||
: imageKind === "video"
|
||||
? row
|
||||
? findAsset(row.video_assets, imageIndex, ["content-videos/"])
|
||||
@@ -196,7 +204,9 @@ async function handleGet(request: Request) {
|
||||
const headers = new Headers();
|
||||
object.writeHttpMetadata(headers);
|
||||
const isMutableEvidence =
|
||||
imageKind === "publish" || imageKind === "creator";
|
||||
imageKind === "publish" ||
|
||||
imageKind === "creator" ||
|
||||
imageKind === "public-metrics";
|
||||
headers.set(
|
||||
"Cache-Control",
|
||||
isMutableEvidence ? "private, no-store" : "private, max-age=3600",
|
||||
|
||||
@@ -87,7 +87,12 @@ async function handlePost(request: Request) {
|
||||
}
|
||||
const isCreatorCenter = upload.uploadKind === "creator-center";
|
||||
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 });
|
||||
}
|
||||
const assignment = upload.delegationToken
|
||||
@@ -119,7 +124,7 @@ async function handlePost(request: Request) {
|
||||
if (!assignment) {
|
||||
return Response.json({ error: "笔记与领取凭证不匹配" }, { status: 403 });
|
||||
}
|
||||
if (isCreatorCenter && !assignment.publish_url) {
|
||||
if ((isCreatorCenter || isPublicMetrics) && !assignment.publish_url) {
|
||||
return Response.json(
|
||||
{ error: "请先回填这篇笔记的发布信息" },
|
||||
{ status: 409 },
|
||||
@@ -143,6 +148,8 @@ async function handlePost(request: Request) {
|
||||
? `task-results/${upload.distributionId}/${uid("shot")}.${extension}`
|
||||
: isCreatorCenter
|
||||
? `creator-center/${upload.distributionId}/${uid("shot")}.${extension}`
|
||||
: isPublicMetrics
|
||||
? `public-metrics/${upload.distributionId}/${uid("shot")}.${extension}`
|
||||
: `publish-evidence/${upload.distributionId}/${uid("shot")}.${extension}`;
|
||||
await getUploadBucket().put(key, upload.fileBytes, {
|
||||
httpMetadata: { contentType: upload.fileType },
|
||||
@@ -160,6 +167,18 @@ async function handlePost(request: Request) {
|
||||
upload.distributionId,
|
||||
)
|
||||
.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) {
|
||||
await getRawDb()
|
||||
.prepare(
|
||||
@@ -226,7 +245,9 @@ async function handlePost(request: Request) {
|
||||
? "task-result"
|
||||
: isCreatorCenter
|
||||
? "creator-center"
|
||||
: "publish",
|
||||
: isPublicMetrics
|
||||
? "public-metrics"
|
||||
: "publish",
|
||||
ocrStatus: isCreatorCenter ? "failed" : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -326,6 +326,12 @@ async function handleGet(request: Request) {
|
||||
d.ocr_status,
|
||||
d.exposure,
|
||||
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.body,
|
||||
c.source_row,
|
||||
@@ -400,6 +406,12 @@ async function handleGet(request: Request) {
|
||||
d.ocr_status,
|
||||
d.exposure,
|
||||
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.body,
|
||||
c.source_row,
|
||||
|
||||
@@ -7,9 +7,8 @@ export async function GET(request: Request) {
|
||||
if (!(await isAdminRequest(request))) return adminForbidden();
|
||||
try {
|
||||
await ensureSchema();
|
||||
const distributionId = new URL(request.url).searchParams
|
||||
.get("distribution")
|
||||
?.trim();
|
||||
const url = new URL(request.url);
|
||||
const distributionId = url.searchParams.get("distribution")?.trim();
|
||||
const scope = await getOwnerScope(request);
|
||||
if (
|
||||
scope.kind === "owner" &&
|
||||
@@ -17,23 +16,31 @@ export async function GET(request: Request) {
|
||||
) {
|
||||
return ownerForbidden();
|
||||
}
|
||||
// kind=publish 时返回 KOC 上传的发布截图(分发详情页核对用)
|
||||
const imageKind = url.searchParams.get("kind") === "publish" ? "publish" : "result";
|
||||
const imageIndex = Math.max(
|
||||
1,
|
||||
Number(new URL(request.url).searchParams.get("index") || 1),
|
||||
Number(url.searchParams.get("index") || 1),
|
||||
);
|
||||
if (!distributionId) {
|
||||
return Response.json({ error: "缺少任务记录" }, { status: 400 });
|
||||
}
|
||||
const row = await getRawDb()
|
||||
.prepare(
|
||||
`SELECT result_screenshot_key FROM distributions
|
||||
`SELECT result_screenshot_key, publish_screenshot_key FROM distributions
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(distributionId)
|
||||
.first<{ result_screenshot_key: string | null }>();
|
||||
const screenshotKey = row
|
||||
? parseResultScreenshotKeys(row.result_screenshot_key)[imageIndex - 1]
|
||||
: undefined;
|
||||
.first<{
|
||||
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]
|
||||
: undefined;
|
||||
if (!screenshotKey) {
|
||||
return Response.json({ error: "任务截图不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
202
app/globals.css
202
app/globals.css
@@ -1295,6 +1295,208 @@ a {
|
||||
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,
|
||||
.account-cell {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user