Files
koc-loop/app/admin-app.tsx

1933 lines
67 KiB
TypeScript
Raw Normal View History

2026-07-30 12:06:41 +08:00
"use client";
import {
ChangeEvent,
FormEvent,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import {
formatShanghaiDate as formatDate,
formatShanghaiToday,
} from "../lib/date-utils";
2026-07-30 12:06:41 +08:00
type Partner = {
id: string;
name: string;
wecom_name: string;
owner: string;
claimed_total: number;
completed_total: number;
};
type Task = {
id: string;
name: string;
brand: string;
quantity: number;
claimed_quantity: number;
due_at: string;
status: string;
source_url: string;
source_sheet_id: string;
source_sheet_name: string;
source_synced_at: string | null;
share_token: string;
collection_start_date: string | null;
collection_days: string;
collection_schedule_updated_at: string | null;
};
type Account = {
id: string;
platform: string;
platform_uid: string;
public_account_id: string;
nickname: string;
profile_url: string;
ip_location: string;
followers: number;
post_count: number;
avg_views: number;
first_seen_at: string;
last_seen_at: string;
};
type Distribution = {
id: string;
task_id: string;
content_id: string;
partner_id: string;
delegation_bundle_id: string | null;
account_id: string | null;
publish_url: string | null;
publish_time: string | null;
status: string;
claimed_at: string;
screenshot_key: string | null;
ocr_status: string;
exposure: number | null;
views: number | null;
d2_likes: number | null;
d2_comments: number | null;
d2_collects: number | null;
d5_likes: number | null;
d5_comments: number | null;
d5_collects: number | null;
d7_likes: number | null;
d7_comments: number | null;
d7_collects: number | null;
latest_likes: number | null;
latest_comments: number | null;
latest_collects: number | null;
collection_status: string;
collection_status_description: string | null;
collection_updated_at: string | null;
last_collection_day: number | null;
updated_at: string;
content_title: string;
partner_name: string;
account_nickname: string | null;
account_platform: string | null;
task_name: string;
task_brand: string;
due_at: string;
};
type DashboardData = {
partners: Partner[];
tasks: Task[];
accounts: Account[];
distributions: Distribution[];
portal_url: string;
};
type FeishuPreview = {
sheetId: string;
sheetName: string;
syncedAt: string;
rowCount: number;
columns: string[];
preview: Array<{
sourceRow: number;
title: string;
body: string;
}>;
};
type NavKey =
| "overview"
| "tasks"
| "distributions"
| "resources"
| "recovery";
const NAV_ITEMS: Array<{ key: NavKey; label: string; mark: string }> = [
{ key: "overview", label: "工作台", mark: "⌂" },
{ key: "tasks", label: "内容任务", mark: "▤" },
{ key: "distributions", label: "内容分发", mark: "↗" },
{ key: "resources", label: "KOC资源", mark: "◎" },
{ key: "recovery", label: "数据回收", mark: "◫" },
];
const EMPTY_DATA: DashboardData = {
partners: [],
tasks: [],
accounts: [],
distributions: [],
portal_url: "",
};
async function readApiResponse<T>(response: Response, fallback: string) {
const text = await response.text();
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.includes("json")) {
if (response.status === 403) {
throw new Error("请求被安全网关拦截,请刷新页面后重试");
}
throw new Error(`${fallback}服务返回异常HTTP ${response.status}`);
}
try {
return JSON.parse(text) as T & { error?: string };
} catch {
throw new Error(`${fallback}(服务返回的数据无法解析)`);
}
}
function formatNumber(value: number | null | undefined) {
if (value === null || value === undefined) return "—";
if (value >= 10000) return `${(value / 10000).toFixed(value >= 100000 ? 0 : 1)}`;
return new Intl.NumberFormat("zh-CN").format(value);
}
function statusLabel(status: string) {
const labels: Record<string, string> = {
claimed: "待发布",
published: "已发布",
collecting: "采集中",
complete: "已完成",
active: "分发中",
closed: "已结束",
};
return labels[status] || status;
}
function latestPublicMetrics(distribution: Distribution) {
const legacyDay =
distribution.d7_likes !== null
? 7
: distribution.d5_likes !== null
? 5
: distribution.d2_likes !== null
? 2
: null;
const likes =
distribution.latest_likes ??
(legacyDay ? distribution[`d${legacyDay}_likes`] : null);
const comments =
distribution.latest_comments ??
(legacyDay ? distribution[`d${legacyDay}_comments`] : null);
const collects =
distribution.latest_collects ??
(legacyDay ? distribution[`d${legacyDay}_collects`] : null);
return {
likes,
comments,
collects,
total:
likes === null
? null
: likes + (comments ?? 0) + (collects ?? 0),
};
}
function hasCreatorMetrics(distribution: Distribution) {
return distribution.exposure !== null && distribution.views !== null;
}
function parseCollectionDays(value: string | null | undefined) {
try {
const parsed = JSON.parse(value || "[]") as unknown;
if (!Array.isArray(parsed)) return [];
return [...new Set(parsed.map(Number))]
.filter((day) => Number.isInteger(day) && day >= 1 && day <= 7)
.sort((a, b) => a - b);
} catch {
return [];
}
}
function todayInputValue() {
const parts = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(new Date());
const value = Object.fromEntries(
parts.map((part) => [part.type, part.value]),
);
return `${value.year}-${value.month}-${value.day}`;
}
function addCalendarDays(value: string, days: number) {
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return "";
const date = new Date(
Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]) + days),
);
return date.toISOString().slice(0, 10);
}
function xhsPublishUrl(value: string | null) {
if (!value) return null;
try {
const url = new URL(value);
const hostname = url.hostname.toLowerCase();
const isXhsHost =
hostname === "xiaohongshu.com" ||
hostname.endsWith(".xiaohongshu.com") ||
hostname === "xhslink.com" ||
hostname.endsWith(".xhslink.com");
return ["http:", "https:"].includes(url.protocol) && isXhsHost
? url.toString()
: null;
} catch {
return null;
}
}
function collectionStatusLabel(status: string, hasMetrics: boolean) {
if (hasMetrics && (!status || status === "pending")) return "采集成功";
const labels: Record<string, string> = {
pending: "待设置",
scheduled: "已计划",
collecting: "采集中",
success: "采集成功",
failed: "采集失败",
};
return labels[status] || status || "待设置";
}
function avatarColor(value: string) {
const colors = ["coral", "cyan", "violet", "amber", "mint"];
const index = [...value].reduce((sum, char) => sum + char.charCodeAt(0), 0);
return colors[index % colors.length];
}
async function compressScreenshot(file: File) {
if (file.size < 850_000 || !file.type.startsWith("image/")) return file;
const bitmap = await createImageBitmap(file);
const maxEdge = 1600;
const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height));
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
const context = canvas.getContext("2d");
if (!context) {
bitmap.close();
return file;
}
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
bitmap.close();
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/jpeg", 0.82),
);
if (!blob) return file;
return new File([blob], `${file.name.replace(/\.[^.]+$/, "")}.jpg`, {
type: "image/jpeg",
});
}
export default function Home() {
const [data, setData] = useState<DashboardData>(EMPTY_DATA);
const [activeNav, setActiveNav] = useState<NavKey>("overview");
const [loading, setLoading] = useState(true);
const [working, setWorking] = useState(false);
const [error, setError] = useState("");
const [toast, setToast] = useState("");
const [menuOpen, setMenuOpen] = useState(false);
const [taskModalOpen, setTaskModalOpen] = useState(false);
const [metricModal, setMetricModal] = useState<Distribution | null>(null);
const [taskForm, setTaskForm] = useState({
name: "",
brand: "",
dueAt: "2026-08-12",
feishuUrl: "",
});
const [sourcePreview, setSourcePreview] = useState<FeishuPreview | null>(null);
const [sourceWorking, setSourceWorking] = useState(false);
const [metricForm, setMetricForm] = useState({ exposure: "", views: "" });
const [exportingTaskId, setExportingTaskId] = useState<string | null>(null);
2026-08-06 10:18:23 +08:00
const [exportingResources, setExportingResources] = useState(false);
2026-07-30 12:06:41 +08:00
const loadData = useCallback(async () => {
try {
setLoading(true);
const response = await fetch("/api/bootstrap", { cache: "no-store" });
const result = await readApiResponse<DashboardData>(response, "加载失败");
if (!response.ok) throw new Error(result.error || "加载失败");
setData(result);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "加载失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
const timer = window.setTimeout(() => {
void loadData();
}, 0);
return () => window.clearTimeout(timer);
}, [loadData]);
useEffect(() => {
if (!toast) return;
const timer = window.setTimeout(() => setToast(""), 2600);
return () => window.clearTimeout(timer);
}, [toast]);
const runAction = async (
payload: Record<string, unknown>,
successMessage: string,
) => {
try {
setWorking(true);
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await readApiResponse<DashboardData>(response, "操作失败");
if (!response.ok) throw new Error(result.error || "操作失败");
setData(result);
setToast(successMessage);
return true;
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "操作失败");
return false;
} finally {
setWorking(false);
}
};
const stats = useMemo(() => {
const published = data.distributions.filter((item) =>
["published", "collecting", "complete"].includes(item.status),
).length;
const completed = data.distributions.filter(
(item) => item.status === "complete",
).length;
const pendingRecovery = data.distributions.filter(
(item) =>
item.publish_url &&
(latestPublicMetrics(item).likes === null ||
!hasCreatorMetrics(item)),
).length;
return {
resources: data.accounts.length,
activeTasks: data.tasks.filter((task) => task.status === "active").length,
published,
completed,
pendingRecovery,
};
}, [data]);
const recentDistributions = data.distributions.slice(0, 6);
const navigate = (key: NavKey) => {
setActiveNav(key);
setMenuOpen(false);
};
const submitTask = async (event: FormEvent) => {
event.preventDefault();
if (!sourcePreview) {
setToast("请先读取飞书表格");
return;
}
const success = await runAction(
{ action: "create_task", ...taskForm },
`任务已创建,${sourcePreview.rowCount} 篇内容进入可领取池`,
);
if (success) {
setTaskModalOpen(false);
setSourcePreview(null);
setTaskForm({
name: "",
brand: "",
dueAt: "2026-08-12",
feishuUrl: "",
});
}
};
const inspectFeishu = async () => {
if (!taskForm.feishuUrl.trim()) {
setToast("请先粘贴飞书表格链接");
return;
}
try {
setSourceWorking(true);
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "inspect_feishu",
feishuUrl: taskForm.feishuUrl,
}),
});
const result = await readApiResponse<FeishuPreview>(
response,
"读取飞书表格失败",
);
if (!response.ok) throw new Error(result.error || "读取飞书表格失败");
setSourcePreview(result);
setTaskForm((current) => ({
...current,
name: current.name || result.sheetName.replace(/500篇$/, "分发任务"),
}));
setToast(`已读取 ${result.rowCount} 篇有效内容`);
} catch (reason) {
setSourcePreview(null);
setToast(reason instanceof Error ? reason.message : "读取飞书表格失败");
} finally {
setSourceWorking(false);
}
};
const copyTaskShareLink = async (task: Task) => {
if (!data.portal_url || !task.share_token) {
setToast("KOC外部站点尚未发布");
return;
}
const url = `${data.portal_url.replace(/\/$/, "")}/?task=${task.share_token}`;
try {
await navigator.clipboard.writeText(url);
setToast("KOC领取链接已复制");
} catch {
window.prompt("复制KOC领取链接", url);
}
};
const collectMetrics = async (distribution: Distribution) => {
await runAction(
{ action: "collect_now", distributionId: distribution.id },
"公开数据已更新",
);
};
const retryFailedMetrics = async (taskId: string, failedCount: number) => {
if (failedCount === 0) {
setToast("当前没有采集异常的笔记");
return;
}
await runAction(
{ action: "retry_failed_collections", taskId },
`已完成 ${failedCount} 篇异常笔记的一键补采`,
);
};
const saveCollectionSchedule = async (
taskId: string,
startDate: string,
days: number[],
) => {
return runAction(
{
action: "save_collection_schedule",
taskId,
startDate,
days,
},
`采集任务已创建将在所选日期10:00自动执行`,
);
};
const exportRecoveryData = async (task: Task) => {
try {
setExportingTaskId(task.id);
const response = await fetch(
`/api/recovery-export?task=${encodeURIComponent(task.id)}`,
{ cache: "no-store" },
);
if (!response.ok) {
const result = await readApiResponse<{ error?: string }>(
response,
"导出失败",
);
throw new Error(result.error || "导出失败");
}
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = `${task.name}-数据回收.xlsx`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
setToast("Excel 已导出,图片和截图已嵌入表格");
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "导出失败");
} finally {
setExportingTaskId(null);
}
};
2026-08-06 10:18:23 +08:00
const exportResources = async (accountIds: string[]) => {
if (accountIds.length === 0) {
setToast("当前筛选结果为空");
return;
}
try {
setExportingResources(true);
const response = await fetch("/api/resources-export", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ accountIds }),
});
if (!response.ok) {
const result = await readApiResponse<{ error?: string }>(
response,
"导出失败",
);
throw new Error(result.error || "导出失败");
}
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = `KOC资源库-${todayInputValue().replaceAll("-", "")}.xlsx`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
setToast(`已导出 ${accountIds.length} 个KOC账号`);
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "导出失败");
} finally {
setExportingResources(false);
}
};
2026-07-30 12:06:41 +08:00
const uploadScreenshot = async (
distribution: Distribution,
event: ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!file) return;
try {
setWorking(true);
const uploadFile = await compressScreenshot(file);
const form = new FormData();
form.append("distributionId", distribution.id);
form.append("file", uploadFile);
const response = await fetch("/api/upload", { method: "POST", body: form });
const result = await readApiResponse<DashboardData>(response, "上传失败");
if (!response.ok) throw new Error(result.error || "上传失败");
setData(result);
setToast("创作者中心截图已收回");
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "上传失败");
} finally {
setWorking(false);
event.target.value = "";
}
};
const submitManualMetrics = async (event: FormEvent) => {
event.preventDefault();
if (!metricModal) return;
const success = await runAction(
{
action: "manual_metrics",
distributionId: metricModal.id,
exposure: metricForm.exposure,
views: metricForm.views,
},
"创作者中心数据已确认",
);
if (success) {
setMetricModal(null);
setMetricForm({ exposure: "", views: "" });
}
};
const openMetricFallback = (distribution: Distribution) => {
setMetricModal(distribution);
setMetricForm({
exposure: distribution.exposure?.toString() || "",
views: distribution.views?.toString() || "",
});
};
const pageTitle: Record<NavKey, { title: string; subtitle: string }> = {
overview: {
title: "分发工作台",
subtitle: "用最少动作,跑完内容领取、发布与数据回收。",
},
tasks: {
title: "内容任务",
subtitle: "从飞书同步内容,按数量开放给合作方领取。",
},
distributions: {
title: "内容分发",
subtitle: "先按任务查看进度,再进入单个任务处理每篇笔记。",
},
resources: {
title: "KOC资源",
subtitle: "只沉淀真实发布过的账号,不要求提前登记。",
},
recovery: {
title: "数据回收",
subtitle: "按任务设置自动采集日,集中查看最新公开数据与创作者截图。",
},
};
return (
<div className="app-shell">
<aside className={`sidebar ${menuOpen ? "open" : ""}`}>
<div className="brand-lockup">
<div className="brand-symbol"><span>K</span></div>
<div>
<strong>KOC LOOP</strong>
<span></span>
</div>
</div>
<nav className="main-nav" aria-label="主导航">
<p className="nav-caption"></p>
{NAV_ITEMS.map((item) => (
<button
key={item.key}
className={activeNav === item.key ? "active" : ""}
onClick={() => navigate(item.key)}
>
<span className="nav-mark">{item.mark}</span>
{item.label}
{item.key === "recovery" && stats.pendingRecovery > 0 && (
<span className="nav-badge">{stats.pendingRecovery}</span>
)}
</button>
))}
</nav>
<div className="sidebar-summary">
<div className="pulse-dot" />
<div>
<strong></strong>
<span>{stats.activeTasks} </span>
</div>
</div>
<div className="sidebar-user">
<div className="avatar mint"></div>
<div>
<strong></strong>
<span></span>
</div>
<span className="chevron"></span>
</div>
</aside>
{menuOpen && <button className="sidebar-scrim" onClick={() => setMenuOpen(false)} aria-label="关闭菜单" />}
<main className="main-area">
<header className="topbar">
<button className="menu-button" onClick={() => setMenuOpen(true)} aria-label="打开菜单">
</button>
<div className="breadcrumb">
<span>KOC LOOP</span>
<i>/</i>
<strong>{pageTitle[activeNav].title}</strong>
</div>
<div className="top-actions">
<span className="sync-state"><i /></span>
<button className="ghost-button" onClick={() => setActiveNav("tasks")}>
KOC领取链接
</button>
<button className="primary-button" onClick={() => setTaskModalOpen(true)}>
<span></span>
</button>
</div>
</header>
<section className="content-frame">
<div className="page-heading">
<div>
<p className="eyebrow">MVP · </p>
<h1>{pageTitle[activeNav].title}</h1>
<p>{pageTitle[activeNav].subtitle}</p>
</div>
{activeNav === "overview" && (
<div className="date-chip">
<span></span>
<strong>{formatShanghaiToday()}</strong>
2026-07-30 12:06:41 +08:00
</div>
)}
</div>
{loading ? (
<LoadingState />
) : error ? (
<div className="empty-state">
<div className="empty-icon">!</div>
<h3></h3>
<p>{error}</p>
<button className="primary-button" onClick={() => void loadData()}>
</button>
</div>
) : (
<>
{activeNav === "overview" && (
<OverviewPage
stats={stats}
distributions={recentDistributions}
tasks={data.tasks}
onNavigate={navigate}
/>
)}
{activeNav === "tasks" && (
<TasksPage
tasks={data.tasks}
portalUrl={data.portal_url}
onCreate={() => setTaskModalOpen(true)}
onCopyShare={copyTaskShareLink}
/>
)}
{activeNav === "distributions" && (
<DistributionPage
tasks={data.tasks}
distributions={data.distributions}
/>
)}
{activeNav === "resources" && (
<ResourcesPage
accounts={data.accounts}
distributions={data.distributions}
2026-08-06 10:18:23 +08:00
exporting={exportingResources}
onExport={exportResources}
2026-07-30 12:06:41 +08:00
/>
)}
{activeNav === "recovery" && (
<RecoveryPage
tasks={data.tasks}
distributions={data.distributions.filter((item) => item.publish_url)}
working={working}
onCollect={collectMetrics}
onSaveSchedule={saveCollectionSchedule}
onUpload={uploadScreenshot}
onFallback={openMetricFallback}
onRetryFailed={retryFailedMetrics}
onExport={exportRecoveryData}
exportingTaskId={exportingTaskId}
2026-07-30 12:06:41 +08:00
/>
)}
</>
)}
</section>
</main>
{taskModalOpen && (
<div className="modal-backdrop" role="presentation" onMouseDown={() => setTaskModalOpen(false)}>
<form className="modal-card" onSubmit={submitTask} onMouseDown={(event) => event.stopPropagation()}>
<div className="modal-heading">
<div>
<p className="eyebrow"></p>
<h2></h2>
</div>
<button type="button" onClick={() => setTaskModalOpen(false)} aria-label="关闭">×</button>
</div>
<label>
<span></span>
<div className="source-url-row">
<input
value={taskForm.feishuUrl}
onChange={(event) => {
setTaskForm({ ...taskForm, feishuUrl: event.target.value });
setSourcePreview(null);
}}
placeholder="粘贴飞书 Wiki / Sheets 链接"
required
/>
<button
type="button"
className="ghost-button source-read-button"
disabled={sourceWorking || !taskForm.feishuUrl.trim()}
onClick={() => void inspectFeishu()}
>
{sourceWorking ? "读取中…" : "读取表格"}
</button>
</div>
<small>/</small>
</label>
{sourcePreview && (
<div className="source-preview">
<div className="source-preview-head">
<div>
<span className="source-check"></span>
<div>
<strong>{sourcePreview.sheetName}</strong>
<small> {sourcePreview.sheetId} · {sourcePreview.columns.length} </small>
</div>
</div>
<b>{sourcePreview.rowCount} </b>
</div>
<div className="source-columns">
{sourcePreview.columns.map((column) => <span key={column}>{column}</span>)}
</div>
<div className="source-samples">
{sourcePreview.preview.map((row) => (
<div key={row.sourceRow}>
<span>{row.sourceRow}</span>
<strong>{row.title}</strong>
</div>
))}
</div>
</div>
)}
<div className="form-grid two">
<label>
<span></span>
<input
value={taskForm.name}
onChange={(event) => setTaskForm({ ...taskForm, name: event.target.value })}
placeholder="例如:夏日轻盈计划"
required
/>
</label>
<label>
<span>/</span>
<input
value={taskForm.brand}
onChange={(event) => setTaskForm({ ...taskForm, brand: event.target.value })}
placeholder="例如:青柠实验室"
required
/>
</label>
</div>
<label>
<span></span>
<input
type="date"
value={taskForm.dueAt}
onChange={(event) => setTaskForm({ ...taskForm, dueAt: event.target.value })}
required
/>
</label>
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={() => setTaskModalOpen(false)}></button>
<button className="primary-button" disabled={working || !sourcePreview}>{working ? "创建中…" : "创建并开放领取"}</button>
</div>
</form>
</div>
)}
{metricModal && (
<div className="modal-backdrop" role="presentation" onMouseDown={() => setMetricModal(null)}>
<form className="modal-card compact" onSubmit={submitManualMetrics} onMouseDown={(event) => event.stopPropagation()}>
<div className="modal-heading">
<div>
<p className="eyebrow">OCR异常兜底</p>
<h2></h2>
</div>
<button type="button" onClick={() => setMetricModal(null)} aria-label="关闭">×</button>
</div>
<div className="context-strip">
<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>
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={() => setMetricModal(null)}></button>
<button className="primary-button" disabled={working}></button>
</div>
</form>
</div>
)}
{toast && <div className="toast"><span></span>{toast}</div>}
{working && <div className="working-bar" />}
</div>
);
}
function LoadingState() {
return (
<div className="loading-layout" aria-label="正在加载">
<div className="loading-card wide" />
<div className="loading-card" />
<div className="loading-card" />
<div className="loading-table" />
</div>
);
}
function OverviewPage({
stats,
distributions,
tasks,
onNavigate,
}: {
stats: {
resources: number;
activeTasks: number;
published: number;
completed: number;
pendingRecovery: number;
};
distributions: Distribution[];
tasks: Task[];
onNavigate: (key: NavKey) => void;
}) {
const cards = [
{
label: "已沉淀账号资源",
value: stats.resources,
hint: "均来自真实发布",
tone: "ink",
},
{
label: "正在分发任务",
value: stats.activeTasks,
hint: `${tasks.reduce((sum, task) => sum + task.claimed_quantity, 0)} 篇已被领取`,
tone: "green",
},
{
label: "已回填发布",
value: stats.published,
hint: "链接自动识别账号",
tone: "blue",
},
{
label: "待完成数据回收",
value: stats.pendingRecovery,
hint: stats.pendingRecovery > 0 ? "需要跟进" : "全部按时完成",
tone: "orange",
},
];
return (
<>
<div className="stat-grid">
{cards.map((card) => (
<article className="stat-card" key={card.label}>
<div className={`stat-icon ${card.tone}`}><span /></div>
<p>{card.label}</p>
<div className="stat-value">
<strong>{card.value}</strong>
<span></span>
</div>
<small>{card.hint}</small>
</article>
))}
</div>
<div className="overview-grid">
<section className="panel workflow-panel">
<div className="panel-heading">
<div>
<h2></h2>
<p>沿</p>
</div>
<button className="text-button" onClick={() => onNavigate("distributions")}></button>
</div>
<div className="workflow-track">
<div className="workflow-step done">
<span>01</span>
<div><strong></strong><small>{tasks.reduce((sum, task) => sum + task.quantity, 0)} </small></div>
</div>
<i />
<div className="workflow-step active">
<span>02</span>
<div><strong>KOC领取</strong><small>{tasks.reduce((sum, task) => sum + task.claimed_quantity, 0)} </small></div>
</div>
<i />
<div className="workflow-step">
<span>03</span>
<div><strong></strong><small>{stats.published} </small></div>
</div>
<i />
<div className="workflow-step">
<span>04</span>
<div><strong></strong><small>{stats.completed} </small></div>
</div>
</div>
<div className="focus-callout">
<div className="callout-ring"><strong>{stats.pendingRecovery}</strong><span></span></div>
<div>
<p className="eyebrow"></p>
<h3></h3>
<p>系统在当天10:00更新点赞</p>
</div>
<button className="primary-button soft" onClick={() => onNavigate("recovery")}></button>
</div>
</section>
<section className="panel task-mini-panel">
<div className="panel-heading">
<div><h2></h2><p></p></div>
</div>
<div className="task-mini-list">
{tasks.map((task) => {
const progress = Math.round((task.claimed_quantity / task.quantity) * 100);
return (
<article key={task.id}>
<div className="task-mini-top">
<div className={`avatar ${avatarColor(task.name)}`}>{task.brand.slice(0, 1)}</div>
<div><strong>{task.name}</strong><span>{task.brand}</span></div>
<b>{progress}%</b>
</div>
<div className="progress"><span style={{ width: `${progress}%` }} /></div>
<div className="task-mini-meta">
<span>{task.claimed_quantity}/{task.quantity} </span>
<span>{formatDate(task.due_at)}</span>
</div>
</article>
);
})}
</div>
<button className="full-ghost-button" onClick={() => onNavigate("tasks")}></button>
</section>
</div>
<section className="panel table-panel">
<div className="panel-heading">
<div><h2></h2><p></p></div>
<button className="text-button" onClick={() => onNavigate("distributions")}></button>
</div>
<DistributionTable distributions={distributions} compact />
</section>
</>
);
}
function TasksPage({
tasks,
portalUrl,
onCreate,
onCopyShare,
}: {
tasks: Task[];
portalUrl: string;
onCreate: () => void;
onCopyShare: (task: Task) => void;
}) {
return (
<div className="stack">
<section className="task-hero">
<div>
<p className="eyebrow"></p>
<h2></h2>
<p></p>
</div>
<button className="primary-button light" onClick={onCreate}> </button>
</section>
<section className="panel table-panel">
<div className="panel-heading">
<div><h2></h2><p>{tasks.length} </p></div>
</div>
<div className="task-list">
{tasks.map((task) => {
const progress = Math.round((task.claimed_quantity / task.quantity) * 100);
return (
<article className="task-row" key={task.id}>
<div className={`avatar large ${avatarColor(task.name)}`}>{task.brand.slice(0, 1)}</div>
<div className="task-row-main">
<div><h3>{task.name}</h3><span>{task.brand}</span></div>
<small>{task.source_sheet_name ? `飞书 · ${task.source_sheet_name}` : "历史示例内容表"}</small>
<div className="progress"><span style={{ width: `${progress}%` }} /></div>
</div>
<div className="task-number"><strong>{task.claimed_quantity}</strong><span>/ {task.quantity} </span></div>
<div className="task-date"><span></span><strong>{formatDate(task.due_at)}</strong></div>
<span className={`status-pill ${task.status}`}>{statusLabel(task.status)}</span>
<div className="task-share-actions">
<button
className="share-link-button"
onClick={() => onCopyShare(task)}
disabled={!portalUrl || !task.share_token}
>
</button>
{portalUrl && task.share_token && (
<a
className="round-arrow"
href={`${portalUrl.replace(/\/$/, "")}/?task=${task.share_token}`}
target="_blank"
rel="noreferrer"
aria-label="打开外部KOC领取页"
>
</a>
)}
</div>
</article>
);
})}
</div>
</section>
</div>
);
}
function TaskScopeList({
tasks,
distributions,
mode,
onOpen,
}: {
tasks: Task[];
distributions: Distribution[];
mode: "distribution" | "recovery";
onOpen: (taskId: string) => void;
}) {
return (
<section className="task-scope-grid">
{tasks.map((task) => {
const taskDistributions = distributions.filter(
(item) => item.task_id === task.id,
);
const published = taskDistributions.filter((item) => item.publish_url);
const publicDataComplete = published.filter(
(item) => latestPublicMetrics(item).likes !== null,
).length;
const creatorComplete = published.filter(
(item) => hasCreatorMetrics(item),
).length;
const progress = task.quantity
? Math.round((task.claimed_quantity / task.quantity) * 100)
: 0;
return (
<article className="task-scope-card" key={task.id}>
<div className="task-scope-head">
<div className={`avatar large ${avatarColor(task.name)}`}>
{task.brand.slice(0, 1)}
</div>
<div>
<h3>{task.name}</h3>
<span>{task.brand} · {formatDate(task.due_at)} </span>
</div>
<span className={`status-pill ${task.status}`}>{statusLabel(task.status)}</span>
</div>
<div className="task-source-line">
<span></span>
{task.source_url ? (
<a href={task.source_url} target="_blank" rel="noreferrer">
{task.source_sheet_name || `工作表 ${task.source_sheet_id}`}
</a>
) : (
<strong></strong>
)}
</div>
{mode === "distribution" ? (
<>
<div className="task-scope-metrics">
<div><span></span><strong>{task.quantity}</strong></div>
<div><span></span><strong>{task.claimed_quantity}</strong></div>
<div><span></span><strong>{published.length}</strong></div>
</div>
<div className="progress task-scope-progress">
<span style={{ width: `${progress}%` }} />
</div>
</>
) : (
<div className="task-scope-metrics">
<div><span></span><strong>{published.length}</strong></div>
<div><span></span><strong>{publicDataComplete}</strong></div>
<div><span></span><strong>{creatorComplete}</strong></div>
</div>
)}
<button className="task-scope-open" onClick={() => onOpen(task.id)}>
<span>{mode === "distribution" ? "查看分发明细" : "进入回收队列"}</span>
<b></b>
</button>
</article>
);
})}
</section>
);
}
function TaskDetailHeader({
task,
label,
onBack,
}: {
task: Task;
label: string;
onBack: () => void;
}) {
return (
<section className="task-detail-header">
<button className="detail-back" onClick={onBack}> </button>
<div className={`avatar large ${avatarColor(task.name)}`}>{task.brand.slice(0, 1)}</div>
<div>
<p>{label}</p>
<h2>{task.name}</h2>
<span>{task.brand} · {task.quantity} · {formatDate(task.due_at)} </span>
</div>
{task.source_url && (
<a href={task.source_url} target="_blank" rel="noreferrer">
</a>
)}
</section>
);
}
function DistributionPage({
tasks,
distributions,
}: {
tasks: Task[];
distributions: Distribution[];
}) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const selectedTask = tasks.find((task) => task.id === selectedTaskId);
if (!selectedTask) {
return (
<div className="stack">
<div className="section-intro">
<div><p className="eyebrow"></p><h2></h2></div>
<p></p>
</div>
<TaskScopeList
tasks={tasks}
distributions={distributions}
mode="distribution"
onOpen={setSelectedTaskId}
/>
</div>
);
}
const taskDistributions = distributions.filter(
(item) => item.task_id === selectedTask.id,
);
const published = taskDistributions.filter((item) => item.publish_url).length;
return (
<div className="stack">
<TaskDetailHeader
task={selectedTask}
label="内容分发"
onBack={() => setSelectedTaskId(null)}
/>
<div className="inline-summary">
<div><span></span><strong>{taskDistributions.length}</strong></div>
<i />
<div><span></span><strong>{published}</strong></div>
<i />
<div><span></span><strong>{taskDistributions.length - published}</strong></div>
<div className="summary-note"><span className="pulse-dot" />{selectedTask.name}</div>
</div>
<section className="panel table-panel">
<div className="panel-heading">
<div><h2></h2><p></p></div>
<div className="filter-chips"><button className="active"></button><button></button><button></button></div>
</div>
<DistributionTable distributions={taskDistributions} />
</section>
</div>
);
}
function DistributionTable({
distributions,
compact = false,
}: {
distributions: Distribution[];
compact?: boolean;
}) {
return (
<div className="table-scroll">
<table className="data-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{distributions.map((item) => (
<tr key={item.id}>
<td>
<div className="content-cell">
<span className="content-id">{item.content_id.split("-").at(-1)?.toUpperCase()}</span>
<div><strong>{item.content_title}</strong><span>{item.task_name} · {item.task_brand}</span></div>
</div>
</td>
<td><strong className="plain-strong">{item.partner_name}</strong></td>
<td>
{item.account_nickname ? (
<div className="account-cell">
<div className={`avatar small ${avatarColor(item.account_nickname)}`}>{item.account_nickname.slice(0, 1)}</div>
<div><strong>{item.account_nickname}</strong><span>{item.account_platform}</span></div>
</div>
) : (
<span className="muted"></span>
)}
</td>
<td><span className="date-value">{formatDate(item.publish_time, true)}</span></td>
<td>
{item.d7_likes !== null ? (
<span className="metric-ready">D7 </span>
) : item.d2_likes !== null ? (
<span className="metric-waiting"></span>
) : (
<span className="muted">{item.publish_url ? "等待D2" : "尚未发布"}</span>
)}
</td>
<td><span className={`status-pill ${item.status}`}>{statusLabel(item.status)}</span></td>
</tr>
))}
</tbody>
</table>
{!compact && distributions.length === 0 && <div className="table-empty"></div>}
</div>
);
}
function ResourcesPage({
accounts,
distributions,
2026-08-06 10:18:23 +08:00
exporting,
onExport,
2026-07-30 12:06:41 +08:00
}: {
accounts: Account[];
distributions: Distribution[];
2026-08-06 10:18:23 +08:00
exporting: boolean;
onExport: (accountIds: string[]) => void;
2026-07-30 12:06:41 +08:00
}) {
2026-08-06 10:18:23 +08:00
const [query, setQuery] = useState("");
const [platformFilter, setPlatformFilter] = useState("全部平台");
2026-08-06 11:48:10 +08:00
const [ipFilter, setIpFilter] = useState("");
const [sourceFilter, setSourceFilter] = useState("");
2026-08-06 10:18:23 +08:00
const resourceAccounts = useMemo(() => {
const deliveriesByAccount = new Map<string, Distribution[]>();
distributions.forEach((distribution) => {
if (!distribution.account_id) return;
const current = deliveriesByAccount.get(distribution.account_id) ?? [];
current.push(distribution);
deliveriesByAccount.set(distribution.account_id, current);
});
return accounts.map((account) => {
const deliveries = deliveriesByAccount.get(account.id) ?? [];
return {
account,
sources: [
...new Set(
deliveries.map((item) => item.partner_name).filter(Boolean),
),
].sort((left, right) => left.localeCompare(right, "zh-CN")),
partnerManagedOnly:
deliveries.some((item) => item.delegation_bundle_id) &&
deliveries.every((item) => item.delegation_bundle_id),
};
});
}, [accounts, distributions]);
const platformOptions = useMemo(
() =>
[...new Set(accounts.map((account) => account.platform).filter(Boolean))]
.sort((left, right) => left.localeCompare(right, "zh-CN")),
[accounts],
);
const ipOptions = useMemo(
() =>
[
...new Set(
accounts.map((account) => account.ip_location || "待识别"),
),
].sort((left, right) => {
if (left === "待识别") return 1;
if (right === "待识别") return -1;
return left.localeCompare(right, "zh-CN");
}),
[accounts],
);
const sourceOptions = useMemo(
() =>
[...new Set(resourceAccounts.flatMap((item) => item.sources))].sort(
(left, right) => left.localeCompare(right, "zh-CN"),
),
[resourceAccounts],
);
const filteredAccounts = useMemo(() => {
const keyword = query.trim().toLocaleLowerCase("zh-CN");
2026-08-06 11:48:10 +08:00
const ipKeyword = ipFilter.trim().toLocaleLowerCase("zh-CN");
const sourceKeyword = sourceFilter.trim().toLocaleLowerCase("zh-CN");
2026-08-06 10:18:23 +08:00
return resourceAccounts.filter(({ account, sources }) => {
const searchable = `${account.nickname} ${account.public_account_id}`.toLocaleLowerCase(
"zh-CN",
);
2026-08-06 11:48:10 +08:00
const ipLocation = (account.ip_location || "待识别").toLocaleLowerCase(
"zh-CN",
);
2026-08-06 10:18:23 +08:00
return (
(!keyword || searchable.includes(keyword)) &&
(platformFilter === "全部平台" ||
account.platform === platformFilter) &&
2026-08-06 11:48:10 +08:00
(!ipKeyword || ipLocation.includes(ipKeyword)) &&
(!sourceKeyword ||
sources.some((source) =>
source.toLocaleLowerCase("zh-CN").includes(sourceKeyword),
))
2026-08-06 10:18:23 +08:00
);
});
}, [ipFilter, platformFilter, query, resourceAccounts, sourceFilter]);
const hasActiveFilters =
Boolean(query.trim()) ||
platformFilter !== "全部平台" ||
2026-08-06 11:48:10 +08:00
Boolean(ipFilter.trim()) ||
Boolean(sourceFilter.trim());
2026-08-06 10:18:23 +08:00
const clearFilters = () => {
setQuery("");
setPlatformFilter("全部平台");
2026-08-06 11:48:10 +08:00
setIpFilter("");
setSourceFilter("");
2026-07-30 12:06:41 +08:00
};
return (
<div className="stack">
<section className="resource-intro">
<div className="resource-figure">
<strong>{accounts.length}</strong>
<span></span>
</div>
<div>
<h2></h2>
<p>KOC索要名单</p>
</div>
<div className="resource-rule">
<span></span>
<strong> + </strong>
</div>
</section>
<section className="panel table-panel">
<div className="panel-heading">
<div><h2></h2><p></p></div>
2026-08-06 10:18:23 +08:00
<div className="filter-chips">
<button
className={platformFilter === "全部平台" ? "active" : ""}
onClick={() => setPlatformFilter("全部平台")}
>
</button>
{platformOptions.map((platform) => (
<button
className={platformFilter === platform ? "active" : ""}
key={platform}
onClick={() => setPlatformFilter(platform)}
>
{platform}
</button>
))}
</div>
</div>
<div className="resource-toolbar">
2026-08-06 11:48:10 +08:00
<div className="resource-search">
2026-08-06 10:18:23 +08:00
<span aria-hidden="true"></span>
<input
aria-label="搜索账号名称或账号ID"
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索账号名称 / 账号ID"
type="search"
value={query}
/>
2026-08-06 11:48:10 +08:00
</div>
<div className="resource-filter-search">
<span aria-hidden="true"></span>
<input
aria-label="模糊搜索IP地区"
list="resource-ip-options"
onChange={(event) => setIpFilter(event.target.value)}
placeholder="搜索IP地区"
type="search"
value={ipFilter}
/>
<datalist id="resource-ip-options">
{ipOptions.map((ip) => <option key={ip} value={ip} />)}
</datalist>
</div>
<div className="resource-filter-search">
<span aria-hidden="true"></span>
<input
aria-label="模糊搜索合作来源"
list="resource-source-options"
onChange={(event) => setSourceFilter(event.target.value)}
placeholder="搜索合作来源"
type="search"
value={sourceFilter}
/>
<datalist id="resource-source-options">
{sourceOptions.map((source) => (
<option key={source} value={source} />
))}
</datalist>
</div>
2026-08-06 10:18:23 +08:00
<button
className="resource-clear-button"
disabled={!hasActiveFilters}
onClick={clearFilters}
>
</button>
<div className="resource-toolbar-summary">
<span> <b>{filteredAccounts.length}</b> </span>
<button
className="export-data-button"
disabled={exporting || filteredAccounts.length === 0}
onClick={() =>
onExport(filteredAccounts.map((item) => item.account.id))
}
>
{exporting ? "正在导出…" : "导出筛选结果"}
</button>
</div>
2026-07-30 12:06:41 +08:00
</div>
<div className="resource-grid">
2026-08-06 10:18:23 +08:00
{filteredAccounts.map(({ account, sources, partnerManagedOnly }) => (
2026-07-30 12:06:41 +08:00
<article className="resource-card" key={account.id}>
<div className="resource-card-head">
<div className={`avatar xlarge ${avatarColor(account.nickname)}`}>{account.nickname.slice(0, 1)}</div>
<div><h3>{account.nickname}</h3><span>{account.platform} · {account.ip_location}</span></div>
<span className="verified-dot"></span>
</div>
<div className="resource-account-id">
<span>{account.platform === "小红书" ? "小红书号" : account.platform === "抖音" ? "抖音号" : "账号"}</span>
<strong>{account.public_account_id || "待识别"}</strong>
</div>
<div className="resource-metrics">
<div><span></span><strong>{formatNumber(account.followers)}</strong></div>
<div><span></span><strong>{account.post_count}</strong></div>
</div>
<div className="resource-source">
<span></span>
<div>
2026-08-06 10:18:23 +08:00
{sources.map((source) => <b key={source}>{source}</b>)}
{partnerManagedOnly && (
2026-07-30 12:06:41 +08:00
<b className="partner-managed"> · </b>
)}
</div>
</div>
<div className="resource-card-foot">
<span> {formatDate(account.last_seen_at)}</span>
{account.profile_url && <a href={account.profile_url} target="_blank" rel="noreferrer"> </a>}
</div>
</article>
))}
</div>
2026-08-06 10:18:23 +08:00
{filteredAccounts.length === 0 && (
<div className="resource-empty">
<strong>KOC</strong>
<span></span>
<button className="ghost-button" onClick={clearFilters}>
</button>
</div>
)}
2026-07-30 12:06:41 +08:00
</section>
</div>
);
}
function RecoveryPage({
tasks,
distributions,
working,
onCollect,
onSaveSchedule,
onUpload,
onFallback,
onRetryFailed,
onExport,
exportingTaskId,
2026-07-30 12:06:41 +08:00
}: {
tasks: Task[];
distributions: Distribution[];
working: boolean;
onCollect: (distribution: Distribution) => void;
onSaveSchedule: (
taskId: string,
startDate: string,
days: number[],
) => Promise<boolean>;
onUpload: (distribution: Distribution, event: ChangeEvent<HTMLInputElement>) => void;
onFallback: (distribution: Distribution) => void;
onRetryFailed: (taskId: string, failedCount: number) => void;
onExport: (task: Task) => void;
exportingTaskId: string | null;
2026-07-30 12:06:41 +08:00
}) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const selectedTask = tasks.find((task) => task.id === selectedTaskId);
if (!selectedTask) {
return (
<div className="stack">
<div className="section-intro">
<div><p className="eyebrow"></p><h2></h2></div>
<p></p>
</div>
<TaskScopeList
tasks={tasks}
distributions={distributions}
mode="recovery"
onOpen={setSelectedTaskId}
/>
</div>
);
}
const taskDistributions = distributions.filter(
(item) => item.task_id === selectedTask.id,
);
const failedCollectionCount = taskDistributions.filter(
(item) => item.collection_status === "failed",
).length;
return (
<div className="stack">
<TaskDetailHeader
task={selectedTask}
label="数据回收"
onBack={() => setSelectedTaskId(null)}
/>
<CollectionScheduleCard
key={`${selectedTask.id}:${selectedTask.collection_start_date}:${selectedTask.collection_days}`}
task={selectedTask}
working={working}
onSave={onSaveSchedule}
/>
<section className="panel table-panel recovery-panel">
<div className="panel-heading">
<div>
<h2></h2>
<p>{taskDistributions.length} · </p>
</div>
<div className="recovery-panel-actions">
<button
className="export-data-button"
disabled={working || exportingTaskId === selectedTask.id}
onClick={() => onExport(selectedTask)}
>
{exportingTaskId === selectedTask.id
? "正在生成 Excel…"
: "导出全部数据"}
</button>
<button
className="retry-failed-button"
disabled={working || failedCollectionCount === 0}
onClick={() =>
onRetryFailed(selectedTask.id, failedCollectionCount)
}
>
{failedCollectionCount > 0 && <b>{failedCollectionCount}</b>}
</button>
</div>
2026-07-30 12:06:41 +08:00
</div>
<div className="table-scroll">
<table className="data-table recovery-table">
<thead>
<tr>
<th> / </th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{taskDistributions.map((item) => {
const metrics = latestPublicMetrics(item);
const noteUrl = xhsPublishUrl(item.publish_url);
const hasMetrics = metrics.likes !== null;
const collectionStatus =
item.collection_status ||
(hasMetrics ? "success" : "pending");
return (
<tr key={item.id}>
<td>
<div className="account-cell recovery-content">
<div className={`avatar small ${avatarColor(item.content_title)}`}>{Array.from(item.content_title)[0] || "笔"}</div>
<div>
{noteUrl ? (
<a
className="recovery-title-link"
href={noteUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={`打开小红书笔记:${item.content_title}`}
title="打开小红书笔记"
>
<strong>{item.content_title}</strong>
</a>
) : (
<strong>{item.content_title}</strong>
)}
<span>
{item.account_nickname || "待识别账号"}
{item.account_platform ? ` · ${item.account_platform}` : ""}
</span>
</div>
</div>
</td>
<td>
<div className="metric-cell publish-time">
<strong>{formatDate(item.publish_time, true)}</strong>
</div>
</td>
<td><div className="metric-cell"><strong>{formatNumber(metrics.likes)}</strong></div></td>
<td><div className="metric-cell"><strong>{formatNumber(metrics.collects)}</strong></div></td>
<td><div className="metric-cell"><strong>{formatNumber(metrics.comments)}</strong></div></td>
<td>
<div className="metric-cell total">
<strong>{formatNumber(metrics.total)}</strong>
<span> + + </span>
</div>
</td>
<td>
<div className="metric-cell update-time">
<strong>{formatDate(item.collection_updated_at || (hasMetrics ? item.updated_at : null), true)}</strong>
{item.last_collection_day && <span>{item.last_collection_day}</span>}
</div>
</td>
<td>
<div className="collection-status-cell">
<span className={`collection-status ${collectionStatus}`}>
{collectionStatusLabel(collectionStatus, hasMetrics)}
</span>
<small>
{item.collection_status_description ||
(hasMetrics ? "成功" : "等待采集计划")}
</small>
</div>
</td>
<td>
<div className="creator-screenshot-cell">
{item.screenshot_key ? (
<a
className="creator-screenshot-link"
href={`/api/creator-screenshot?distribution=${encodeURIComponent(item.id)}`}
target="_blank"
rel="noopener noreferrer"
>
</a>
) : (
<span className="creator-screenshot-pending">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">
KOC填写数据
</span>
) : null}
{item.screenshot_key &&
!hasCreatorMetrics(item) && (
<label className="upload-button retry-upload">
<input
type="file"
accept="image/*"
onChange={(event) => onUpload(item, event)}
/>
</label>
)}
</div>
</td>
<td>
{item.screenshot_key && !hasCreatorMetrics(item) ? (
<button className="text-button" onClick={() => onFallback(item)}></button>
) : (
<button
className="collect-button"
disabled={working}
onClick={() => onCollect(item)}
>
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
{taskDistributions.length === 0 && (
<div className="table-empty compact"></div>
)}
</div>
</section>
</div>
);
}
function CollectionScheduleCard({
task,
working,
onSave,
}: {
task: Task;
working: boolean;
onSave: (
taskId: string,
startDate: string,
days: number[],
) => Promise<boolean>;
}) {
const [startDate, setStartDate] = useState(
task.collection_start_date || todayInputValue(),
);
const [days, setDays] = useState<number[]>(
parseCollectionDays(task.collection_days),
);
const toggleDay = (day: number) => {
setDays((current) =>
current.includes(day)
? current.filter((item) => item !== day)
: [...current, day].sort((a, b) => a - b),
);
};
return (
<section className="panel collection-schedule-panel">
<div className="collection-schedule-heading">
<div>
<p className="eyebrow"></p>
<h2></h2>
<span>17所选日期均在北京时间10:00自动采集</span>
</div>
<div className="schedule-save-area">
<span> {days.length} </span>
<button
className="primary-button"
disabled={working || !startDate || days.length === 0}
onClick={() => void onSave(task.id, startDate, days)}
>
{working ? "保存中…" : "保存采集计划"}
</button>
</div>
</div>
<div className="schedule-grid">
<label className="schedule-start">
<span></span>
<input
type="date"
value={startDate}
onChange={(event) => setStartDate(event.target.value)}
/>
</label>
<div className="schedule-days" role="group" aria-label="选择自动采集日">
{Array.from({ length: 7 }, (_, index) => index + 1).map((day) => {
const selected = days.includes(day);
const date = addCalendarDays(startDate, day - 1);
return (
<button
key={day}
type="button"
className={selected ? "selected" : ""}
aria-pressed={selected}
onClick={() => toggleDay(day)}
>
<span>{day}</span>
<b aria-hidden="true">{selected ? "✓" : ""}</b>
<small>{date ? date.slice(5).replace("-", "/") : "—"}</small>
</button>
);
})}
</div>
<span className="schedule-mobile-hint">17</span>
</div>
{task.collection_schedule_updated_at && (
<p className="schedule-updated">
{formatDate(task.collection_schedule_updated_at, true)}
</p>
)}
</section>
);
}