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

2566 lines
93 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";
import type { AuthUser } from "../lib/user-auth";
import { parseResultScreenshotKeys } from "../lib/result-screenshots";
import UsersPage from "./users-page";
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;
task_type: "content_publish" | "screenshot_collect";
2026-07-30 12:06:41 +08:00
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;
cooperation_source: string;
2026-07-30 12:06:41 +08:00
first_seen_at: string;
last_seen_at: string;
};
type ResourceImportPreview = {
summary: { total: number; create: number; update: number; error: number };
rows: Array<{
rowNumber: number;
platform: string;
nickname: string;
publicAccountId: string;
ipLocation: string;
followers: number;
cooperationSource: string;
action: "create" | "update" | "error";
errors: string[];
}>;
truncated: boolean;
};
2026-07-30 12:06:41 +08:00
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;
result_screenshot_key: string | null;
result_submitted_at: string | null;
2026-07-30 12:06:41 +08:00
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;
task_type: "content_publish" | "screenshot_collect";
2026-07-30 12:06:41 +08:00
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"
| "users";
2026-07-30 12:06:41 +08:00
const NAV_ITEMS: Array<{ key: NavKey; label: string; mark: string }> = [
{ key: "overview", label: "工作台", mark: "⌂" },
{ key: "tasks", label: "任务中心", mark: "▤" },
{ key: "distributions", label: "任务分发", mark: "↗" },
2026-07-30 12:06:41 +08:00
{ key: "resources", label: "KOC资源", mark: "◎" },
{ key: "recovery", label: "数据回收", mark: "◫" },
{ key: "users", label: "用户管理", mark: "♙" },
2026-07-30 12:06:41 +08:00
];
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.cn" ||
hostname.endsWith(".xhslink.cn") ||
2026-07-30 12:06:41 +08:00
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({ currentUser }: { currentUser: AuthUser }) {
2026-07-30 12:06:41 +08:00
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({
taskType: "content_publish" as "content_publish" | "screenshot_collect",
2026-07-30 12:06:41 +08:00
name: "",
brand: "",
dueAt: "2026-08-12",
feishuUrl: "",
keyword: "",
instructions: "在小红书搜索指定关键词,打开 AI 总结并上传完整截图。",
quantity: 1,
2026-07-30 12:06:41 +08:00
});
const [taskExampleImage, setTaskExampleImage] = useState<File | null>(null);
2026-07-30 12:06:41 +08:00
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);
const isManager = currentUser.role === "super_admin" || currentUser.role === "admin";
const visibleNavItems = NAV_ITEMS.filter((item) => {
if (item.key === "resources" && currentUser.role === "user") return false;
if (item.key === "users" && !isManager) return false;
return true;
});
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) =>
item.task_type !== "screenshot_collect" &&
2026-07-30 12:06:41 +08:00
["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
.filter((item) => item.task_type !== "screenshot_collect")
.slice(0, 6);
2026-07-30 12:06:41 +08:00
const navigate = (key: NavKey) => {
if (key === "resources" && currentUser.role === "user") return;
if (key === "users" && !isManager) return;
2026-07-30 12:06:41 +08:00
setActiveNav(key);
setMenuOpen(false);
};
const submitTask = async (event: FormEvent) => {
event.preventDefault();
if (taskForm.taskType === "content_publish" && !sourcePreview) {
2026-07-30 12:06:41 +08:00
setToast("请先读取飞书表格");
return;
}
let exampleImageKey = "";
if (taskForm.taskType === "screenshot_collect" && taskExampleImage) {
try {
setWorking(true);
const form = new FormData();
form.set("file", await compressScreenshot(taskExampleImage));
const uploadResponse = await fetch("/api/task-example-upload", {
method: "POST",
body: form,
});
const uploadResult = await readApiResponse<{ key?: string }>(
uploadResponse,
"示例截图上传失败",
);
if (!uploadResponse.ok || !uploadResult.key) {
throw new Error(uploadResult.error || "示例截图上传失败");
}
exampleImageKey = uploadResult.key;
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "示例截图上传失败");
setWorking(false);
return;
}
}
2026-07-30 12:06:41 +08:00
const success = await runAction(
taskForm.taskType === "screenshot_collect"
? {
action: "create_screenshot_task",
name: taskForm.name,
brand: taskForm.brand,
dueAt: taskForm.dueAt,
keyword: taskForm.keyword,
instructions: taskForm.instructions,
quantity: taskForm.quantity,
exampleImageKey,
}
: { action: "create_task", ...taskForm },
taskForm.taskType === "screenshot_collect"
? `截图回收任务已创建,${taskForm.quantity} 份任务进入可领取池`
: `任务已创建,${sourcePreview?.rowCount ?? 0} 篇内容进入可领取池`,
2026-07-30 12:06:41 +08:00
);
if (success) {
setTaskModalOpen(false);
setSourcePreview(null);
setTaskExampleImage(null);
2026-07-30 12:06:41 +08:00
setTaskForm({
taskType: "content_publish",
2026-07-30 12:06:41 +08:00
name: "",
brand: "",
dueAt: "2026-08-12",
feishuUrl: "",
keyword: "",
instructions: "在小红书搜索指定关键词,打开 AI 总结并上传完整截图。",
quantity: 1,
2026-07-30 12:06:41 +08:00
});
}
};
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 exportScreenshotResults = async (task: Task) => {
try {
setExportingTaskId(task.id);
const response = await fetch(
`/api/screenshot-task-export?task=${encodeURIComponent(task.id)}`,
);
if (!response.ok) {
const result = await readApiResponse<Record<string, never>>(
response,
"截图打包失败",
);
throw new Error(result.error || "截图打包失败");
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${task.name}-截图回收.zip`;
anchor.click();
URL.revokeObjectURL(url);
setToast("任务截图和回收清单已打包下载");
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "截图打包失败");
} finally {
setExportingTaskId(null);
}
};
2026-07-30 12:06:41 +08:00
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);
}
};
const completeResourceImport = async (message: string) => {
await loadData();
setToast(message);
};
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: "按任务设置自动采集日,集中查看最新公开数据与创作者截图。",
},
users: {
title: "用户管理",
subtitle: "由后台统一创建账号和重置密码,不开放自助注册与个人改密。",
},
};
const logout = async () => {
await fetch("/api/auth/logout", { method: "POST" }).catch(() => null);
window.location.assign("/login");
2026-07-30 12:06:41 +08:00
};
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>
{visibleNavItems.map((item) => (
2026-07-30 12:06:41 +08:00
<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>
<button className="sidebar-user" onClick={() => void logout()} title="退出登录">
<div className="avatar mint">{currentUser.username.slice(0, 1).toUpperCase()}</div>
2026-07-30 12:06:41 +08:00
<div>
<strong>{currentUser.username}</strong>
<span>{currentUser.role === "super_admin" ? "超级管理员" : currentUser.role === "admin" ? "管理员" : "普通用户"}</span>
2026-07-30 12:06:41 +08:00
</div>
<span className="chevron">退</span>
</button>
2026-07-30 12:06:41 +08:00
</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}
showResources={currentUser.role !== "user"}
2026-07-30 12:06:41 +08:00
/>
)}
{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}
onImported={completeResourceImport}
2026-07-30 12:06:41 +08:00
/>
)}
{activeNav === "users" && isManager && (
<UsersPage currentUser={currentUser} />
)}
2026-07-30 12:06:41 +08:00
{activeNav === "recovery" && (
<RecoveryPage
tasks={data.tasks}
distributions={data.distributions}
2026-07-30 12:06:41 +08:00
working={working}
onCollect={collectMetrics}
onSaveSchedule={saveCollectionSchedule}
onUpload={uploadScreenshot}
onFallback={openMetricFallback}
onRetryFailed={retryFailedMetrics}
onExport={exportRecoveryData}
onExportScreenshots={exportScreenshotResults}
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>
2026-07-30 12:06:41 +08:00
</div>
<button type="button" onClick={() => setTaskModalOpen(false)} aria-label="关闭">×</button>
</div>
<div className="task-type-picker" role="group" aria-label="选择任务类型">
<button
type="button"
className={taskForm.taskType === "content_publish" ? "active" : ""}
onClick={() => setTaskForm({ ...taskForm, taskType: "content_publish" })}
>
<b></b>
<span></span>
</button>
<button
type="button"
className={taskForm.taskType === "screenshot_collect" ? "active" : ""}
onClick={() => {
setTaskForm({ ...taskForm, taskType: "screenshot_collect" });
setSourcePreview(null);
}}
>
<b></b>
<span></span>
</button>
</div>
{taskForm.taskType === "content_publish" ? (
<>
<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>
2026-07-30 12:06:41 +08:00
</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>
2026-07-30 12:06:41 +08:00
</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="screenshot-task-fields">
<div className="form-grid two">
<label>
<span></span>
<input
value={taskForm.keyword}
onChange={(event) => setTaskForm({ ...taskForm, keyword: event.target.value })}
placeholder="例如:暑期亲子酒店"
required
/>
</label>
<label>
<span></span>
<input
type="number"
min="1"
max="500"
value={taskForm.quantity}
onChange={(event) => setTaskForm({ ...taskForm, quantity: Number(event.target.value) })}
required
/>
</label>
2026-07-30 12:06:41 +08:00
</div>
<label>
<span></span>
<textarea
value={taskForm.instructions}
onChange={(event) => setTaskForm({ ...taskForm, instructions: event.target.value })}
placeholder="说明搜索步骤、截图范围和提交要求"
rows={4}
required
/>
</label>
<label>
<span></span>
<div className={`task-example-picker ${taskExampleImage ? "has-file" : ""}`}>
<input
type="file"
accept="image/*"
onChange={(event) => setTaskExampleImage(event.target.files?.[0] ?? null)}
/>
<strong>{taskExampleImage ? taskExampleImage.name : "点击上传示例截图"}</strong>
<small>KOC确认截图范围 JPGPNG8MB</small>
</div>
</label>
2026-07-30 12:06:41 +08:00
</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>{taskForm.taskType === "screenshot_collect" ? "提交截止日期" : "发布截止日期"}</span>
2026-07-30 12:06:41 +08:00
<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 ||
(taskForm.taskType === "content_publish" && !sourcePreview)
}
>
{working ? "创建中…" : "创建并开放领取"}
</button>
2026-07-30 12:06:41 +08:00
</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,
showResources,
2026-07-30 12:06:41 +08:00
}: {
stats: {
resources: number;
activeTasks: number;
published: number;
completed: number;
pendingRecovery: number;
};
distributions: Distribution[];
tasks: Task[];
onNavigate: (key: NavKey) => void;
showResources: boolean;
2026-07-30 12:06:41 +08:00
}) {
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",
},
].filter((card) => showResources || card.label !== "已沉淀账号资源");
2026-07-30 12:06:41 +08:00
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>
2026-07-30 12:06:41 +08:00
</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.task_type === "screenshot_collect"
? "截图回收 · 关键词任务"
: task.source_sheet_name
? `飞书 · ${task.source_sheet_name}`
: "历史示例内容表"}
</small>
2026-07-30 12:06:41 +08:00
<div className="progress"><span style={{ width: `${progress}%` }} /></div>
</div>
<div className="task-number"><strong>{task.claimed_quantity}</strong><span>/ {task.quantity} {task.task_type === "screenshot_collect" ? "份已领取" : "篇已领取"}</span></div>
2026-07-30 12:06:41 +08:00
<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 isScreenshotTask = task.task_type === "screenshot_collect";
2026-07-30 12:06:41 +08:00
const published = taskDistributions.filter((item) => item.publish_url);
const submitted = taskDistributions.filter(
(item) => item.result_submitted_at,
);
2026-07-30 12:06:41 +08:00
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>{isScreenshotTask ? "任务类型" : "飞书来源"}</span>
{isScreenshotTask ? (
<strong> · </strong>
) : task.source_url ? (
2026-07-30 12:06:41 +08:00
<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>{isScreenshotTask ? "任务总量" : "内容总量"}</span><strong>{task.quantity}</strong></div>
2026-07-30 12:06:41 +08:00
<div><span></span><strong>{task.claimed_quantity}</strong></div>
<div><span>{isScreenshotTask ? "已提交" : "已发布"}</span><strong>{isScreenshotTask ? submitted.length : published.length}</strong></div>
2026-07-30 12:06:41 +08:00
</div>
<div className="progress task-scope-progress">
<span style={{ width: `${progress}%` }} />
</div>
</>
) : (
<div className="task-scope-metrics">
{isScreenshotTask ? (
<>
<div><span></span><strong>{taskDistributions.length}</strong></div>
<div><span></span><strong>{submitted.length}</strong></div>
<div><span></span><strong>{taskDistributions.length - submitted.length}</strong></div>
</>
) : (
<>
<div><span></span><strong>{published.length}</strong></div>
<div><span></span><strong>{publicDataComplete}</strong></div>
<div><span></span><strong>{creatorComplete}</strong></div>
</>
)}
2026-07-30 12:06:41 +08:00
</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} {task.task_type === "screenshot_collect" ? "份" : "篇"} · {formatDate(task.due_at)} </span>
2026-07-30 12:06:41 +08:00
</div>
{task.task_type !== "screenshot_collect" && task.source_url && (
2026-07-30 12:06:41 +08:00
<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 isScreenshotTask = selectedTask.task_type === "screenshot_collect";
2026-07-30 12:06:41 +08:00
const published = taskDistributions.filter((item) => item.publish_url).length;
const submitted = taskDistributions.filter((item) => item.result_submitted_at).length;
2026-07-30 12:06:41 +08:00
return (
<div className="stack">
<TaskDetailHeader
task={selectedTask}
label={isScreenshotTask ? "截图任务分发" : "内容分发"}
2026-07-30 12:06:41 +08:00
onBack={() => setSelectedTaskId(null)}
/>
<div className="inline-summary">
<div><span></span><strong>{taskDistributions.length}</strong></div>
<i />
<div><span>{isScreenshotTask ? "已提交截图" : "已回填链接"}</span><strong>{isScreenshotTask ? submitted : published}</strong></div>
2026-07-30 12:06:41 +08:00
<i />
<div><span>{isScreenshotTask ? "待提交" : "待发布"}</span><strong>{taskDistributions.length - (isScreenshotTask ? submitted : published)}</strong></div>
2026-07-30 12:06:41 +08:00
<div className="summary-note"><span className="pulse-dot" />{selectedTask.name}</div>
</div>
<section className="panel table-panel">
<div className="panel-heading">
<div><h2>{isScreenshotTask ? "截图任务分发表" : "内容分发表"}</h2><p>{isScreenshotTask ? "每份任务与结果截图一一对应" : "实际发布账号在链接回填后自动补齐"}</p></div>
2026-07-30 12:06:41 +08:00
</div>
{isScreenshotTask ? (
<ScreenshotTaskTable distributions={taskDistributions} />
) : (
<DistributionTable distributions={taskDistributions} />
)}
2026-07-30 12:06:41 +08:00
</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>
);
}
type AdminPreviewImage = { src: string; title: string };
function AdminImageLightbox({
images,
activeIndex,
onIndexChange,
onClose,
}: {
images: AdminPreviewImage[];
activeIndex: number;
onIndexChange?: (index: number) => void;
onClose: () => void;
}) {
const image = images[activeIndex] ?? null;
const canNavigate = images.length > 1 && Boolean(onIndexChange);
const showPrevious = () => {
if (!canNavigate) return;
onIndexChange?.((activeIndex - 1 + images.length) % images.length);
};
const showNext = () => {
if (!canNavigate) return;
onIndexChange?.((activeIndex + 1) % images.length);
};
useEffect(() => {
if (!image) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
if (event.key === "ArrowLeft" && canNavigate) {
onIndexChange?.((activeIndex - 1 + images.length) % images.length);
}
if (event.key === "ArrowRight" && canNavigate) {
onIndexChange?.((activeIndex + 1) % images.length);
}
};
document.addEventListener("keydown", onKeyDown);
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = previousOverflow;
};
}, [activeIndex, canNavigate, image, images.length, onClose, onIndexChange]);
if (!image) return null;
return (
<div
className="admin-image-lightbox"
role="dialog"
aria-modal="true"
aria-label={image.title}
onMouseDown={onClose}
>
<div
className="admin-image-lightbox-card"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="admin-image-lightbox-heading">
<div>
<strong>{image.title}</strong>
{canNavigate && (
<span aria-live="polite">{activeIndex + 1} / {images.length}</span>
)}
</div>
<button type="button" onClick={onClose}></button>
</div>
<div className="admin-image-lightbox-stage">
{canNavigate && (
<button type="button" className="previous" onClick={showPrevious} aria-label="上一张">
</button>
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={image.src} alt={image.title} />
{canNavigate && (
<button type="button" className="next" onClick={showNext} aria-label="下一张">
</button>
)}
</div>
</div>
</div>
);
}
function ScreenshotTaskTable({ distributions }: { distributions: Distribution[] }) {
const [previewGallery, setPreviewGallery] = useState<{
images: AdminPreviewImage[];
activeIndex: number;
} | null>(null);
return (
<>
<div className="table-scroll">
<table className="data-table screenshot-task-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"></span>
<div><strong>{item.content_title}</strong><span>{item.task_name}</span></div>
</div>
</td>
<td><strong className="plain-strong">{item.partner_name}</strong></td>
<td><span className="date-value">{formatDate(item.claimed_at, true)}</span></td>
<td><span className="date-value">{formatDate(item.result_submitted_at, true)}</span></td>
<td>
{parseResultScreenshotKeys(item.result_screenshot_key).length > 0 ? (
<div className="admin-result-gallery">
{parseResultScreenshotKeys(item.result_screenshot_key).map((_, index, screenshotKeys) => {
return (
<button
type="button"
onClick={() =>
setPreviewGallery({
images: screenshotKeys.map((__, imageIndex) => ({
src: `/api/task-result-image?distribution=${encodeURIComponent(item.id)}&index=${imageIndex + 1}`,
title: `${item.content_title} · 第${imageIndex + 1}张结果截图`,
})),
activeIndex: index,
})
}
key={`${item.id}-${index}`}
title={`查看第${index + 1}张截图`}
>
<img
src={`/api/task-result-image?distribution=${encodeURIComponent(item.id)}&index=${index + 1}`}
alt={`任务结果截图 ${index + 1}`}
loading="lazy"
/>
</button>
)})}
<span>{parseResultScreenshotKeys(item.result_screenshot_key).length}</span>
</div>
) : (
<span className="muted">KOC上传</span>
)}
</td>
<td>
<span className={`status-pill ${item.result_submitted_at ? "complete" : "claimed"}`}>
{item.result_submitted_at ? "已提交" : "待提交"}
</span>
</td>
</tr>
))}
</tbody>
</table>
{distributions.length === 0 && <div className="table-empty"></div>}
</div>
<AdminImageLightbox
images={previewGallery?.images ?? []}
activeIndex={previewGallery?.activeIndex ?? 0}
onIndexChange={(activeIndex) =>
setPreviewGallery((current) => current ? { ...current, activeIndex } : current)
}
onClose={() => setPreviewGallery(null)}
/>
</>
);
}
2026-07-30 12:06:41 +08:00
function ResourcesPage({
accounts,
distributions,
2026-08-06 10:18:23 +08:00
exporting,
onExport,
onImported,
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;
onImported: (message: string) => Promise<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("");
const [importOpen, setImportOpen] = useState(false);
const [importFile, setImportFile] = useState<File | null>(null);
const [importPreview, setImportPreview] = useState<ResourceImportPreview | null>(null);
const [importWorking, setImportWorking] = useState(false);
const [importError, setImportError] = 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),
...(account.cooperation_source || "")
.split(/[、,;|]/)
.map((item) => item.trim()),
].filter(Boolean),
2026-08-06 10:18:23 +08:00
),
].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
};
const closeImport = () => {
if (importWorking) return;
setImportOpen(false);
setImportFile(null);
setImportPreview(null);
setImportError("");
};
const submitImport = async (mode: "preview" | "commit") => {
if (!importFile) {
setImportError("请先选择需要导入的 Excel 文件");
return;
}
try {
setImportWorking(true);
setImportError("");
const form = new FormData();
form.append("file", importFile);
form.append("mode", mode);
const response = await fetch("/api/resources-import", {
method: "POST",
body: form,
});
const result = await readApiResponse<
ResourceImportPreview & { error?: string; message?: string }
>(response, "导入失败");
if (!response.ok) throw new Error(result.error || "导入失败");
if (mode === "preview") setImportPreview(result);
else {
setImportOpen(false);
setImportFile(null);
setImportPreview(null);
await onImported(result.message || "KOC资源已导入");
}
} catch (reason) {
setImportError(reason instanceof Error ? reason.message : "导入失败");
} finally {
setImportWorking(false);
}
};
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>
2026-07-30 12:06:41 +08:00
</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>
<div className="resource-heading-actions">
<a className="ghost-button" download href="/KOC资源导入模板.xlsx">
</a>
<button className="primary-button soft" onClick={() => setImportOpen(true)}>
KOC资源
2026-08-06 10:18:23 +08:00
</button>
<div className="filter-chips">
2026-08-06 10:18:23 +08:00
<button
className={platformFilter === "全部平台" ? "active" : ""}
onClick={() => setPlatformFilter("全部平台")}
2026-08-06 10:18:23 +08:00
>
2026-08-06 10:18:23 +08:00
</button>
{platformOptions.map((platform) => (
<button
className={platformFilter === platform ? "active" : ""}
key={platform}
onClick={() => setPlatformFilter(platform)}
>
{platform}
</button>
))}
</div>
2026-08-06 10:18:23 +08:00
</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>
{importOpen && (
<div className="modal-backdrop" role="presentation" onMouseDown={closeImport}>
<div
className="modal-card resource-import-modal"
role="dialog"
aria-modal="true"
aria-labelledby="resource-import-title"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="modal-heading">
<div>
<p className="eyebrow"></p>
<h2 id="resource-import-title">KOC资源</h2>
</div>
<button type="button" onClick={closeImport} aria-label="关闭">×</button>
</div>
<div className="import-guide-strip">
<span>1</span><p></p>
<i />
<span>2</span><p></p>
<i />
<span>3</span><p></p>
</div>
<label className="resource-import-dropzone">
<input
accept=".xlsx,.csv"
type="file"
onChange={(event) => {
setImportFile(event.target.files?.[0] ?? null);
setImportPreview(null);
setImportError("");
}}
/>
<strong>{importFile ? importFile.name : "选择 Excel / CSV 文件"}</strong>
<span> 100 5MB</span>
</label>
{!importPreview && (
<div className="import-template-note">
<div><strong></strong><span>IP属地和粉丝数会自动解析</span></div>
<a className="ghost-button" download href="/KOC资源导入模板.xlsx"></a>
</div>
)}
{importPreview && (
<div className="resource-import-preview">
<div className="import-summary-grid">
<div><span></span><strong>{importPreview.summary.total}</strong></div>
<div className="create"><span></span><strong>{importPreview.summary.create}</strong></div>
<div className="update"><span></span><strong>{importPreview.summary.update}</strong></div>
<div className={importPreview.summary.error ? "error" : ""}><span></span><strong>{importPreview.summary.error}</strong></div>
</div>
<div className="import-preview-table">
<div className="import-preview-row head">
<span></span><span></span><span>ID</span><span>IP / </span><span></span>
</div>
{importPreview.rows.slice(0, 8).map((row) => (
<div className="import-preview-row" key={row.rowNumber}>
<span>{row.rowNumber}</span>
<span><strong>{row.nickname || "—"}</strong><small>{row.platform || "未填写平台"}</small></span>
<span>{row.publicAccountId || "主页识别"}</span>
<span><strong>{row.ipLocation}</strong><small>{row.cooperationSource || "未填写来源"}</small></span>
<span className={`import-result ${row.action}`}>
{row.action === "create" ? "新增" : row.action === "update" ? "更新" : row.errors.join("")}
</span>
</div>
))}
</div>
{(importPreview.summary.total > 8 || importPreview.truncated) && (
<p className="import-preview-more"> 8 </p>
)}
</div>
)}
{importError && <div className="form-error">{importError}</div>}
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={closeImport}></button>
{!importPreview ? (
<button
type="button"
className="primary-button"
disabled={!importFile || importWorking}
onClick={() => void submitImport("preview")}
>
{importWorking ? "校验中…" : "校验并预览"}
</button>
) : (
<button
type="button"
className="primary-button"
disabled={importPreview.summary.error > 0 || importWorking}
onClick={() => void submitImport("commit")}
>
{importWorking ? "导入中…" : `确认导入 ${importPreview.summary.create + importPreview.summary.update} 个账号`}
</button>
)}
</div>
</div>
</div>
)}
2026-07-30 12:06:41 +08:00
</div>
);
}
function CreatorScreenshotPreview({
distributionId,
title,
}: {
distributionId: string;
title: string;
}) {
const [open, setOpen] = useState(false);
const imageUrl = `/api/creator-screenshot?distribution=${encodeURIComponent(distributionId)}`;
return (
<>
<button
type="button"
className="creator-screenshot-link"
onClick={() => setOpen(true)}
>
{/* 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)}
/>
</>
);
}
2026-07-30 12:06:41 +08:00
function RecoveryPage({
tasks,
distributions,
working,
onCollect,
onSaveSchedule,
onUpload,
onFallback,
onRetryFailed,
onExport,
onExportScreenshots,
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;
onExportScreenshots: (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,
);
if (selectedTask.task_type === "screenshot_collect") {
const submitted = taskDistributions.filter(
(item) => item.result_submitted_at,
).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>{submitted}</strong></div>
<i />
<div><span></span><strong>{taskDistributions.length - submitted}</strong></div>
<div className="summary-note"><span className="pulse-dot" /></div>
</div>
<section className="panel table-panel recovery-panel screenshot-recovery-panel">
<div className="panel-heading">
<div>
<h2></h2>
<p></p>
</div>
<button
className="export-data-button"
disabled={exportingTaskId === selectedTask.id || submitted === 0}
onClick={() => onExportScreenshots(selectedTask)}
>
{exportingTaskId === selectedTask.id ? "正在打包…" : "批量下载截图"}
</button>
</div>
<ScreenshotTaskTable distributions={taskDistributions} />
</section>
</div>
);
}
2026-07-30 12:06:41 +08:00
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 ? (
<CreatorScreenshotPreview
distributionId={item.id}
title={item.content_title}
/>
2026-07-30 12:06:41 +08:00
) : (
<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>
);
}