Files
koc-loop/app/admin-app.tsx
2026-08-15 03:53:09 +08:00

3258 lines
120 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
ChangeEvent,
FormEvent,
useCallback,
useEffect,
useMemo,
useRef,
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 {
sortWithNullsLast,
type SortDirection,
} from "../lib/sort-utils";
import UsersPage from "./users-page";
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";
platform: "小红书" | "抖音";
content_format: "image_text" | "video";
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;
latest_publish_url: string;
ip_location: string;
followers: number;
gender: string;
bio: string;
tags: string;
post_count: number;
avg_views: number;
cooperation_source: string;
current_contact: string;
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;
gender: string;
bio: string;
tags: string[];
cooperationSource: string;
action: "create" | "update" | "error";
errors: string[];
}>;
truncated: boolean;
deferredEnrichment?: number;
maxRows?: number;
};
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;
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;
latest_shares: 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;
claimant_name: string | null;
account_nickname: string | null;
account_platform: string | null;
task_name: string;
task_brand: string;
task_type: "content_publish" | "screenshot_collect";
task_platform: "小红书" | "抖音";
content_format: "image_text" | "video";
due_at: string;
};
type RecoverySortKey =
| "publish_time"
| "likes"
| "collects"
| "shares"
| "comments"
| "total"
| "collection_updated_at";
type DashboardData = {
partners: Partner[];
tasks: Task[];
accounts: Account[];
distributions: Distribution[];
portal_url: string;
};
type FeishuPreview = {
sheetId: string;
sheetName: string;
syncedAt: string;
rowCount: number;
imageCount: number;
videoCount: number;
columns: string[];
preview: Array<{
sourceRow: number;
title: string;
body: string;
}>;
};
type NavKey =
| "overview"
| "tasks"
| "distributions"
| "resources"
| "recovery"
| "users";
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: "◫" },
{ key: "users", 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 resourceProfileLink(account: Account) {
const storedProfileUrl = account.profile_url?.trim();
if (account.platform !== "抖音") {
return storedProfileUrl
? { href: storedProfileUrl, label: "查看主页 ↗" }
: null;
}
const publicAccountId = account.public_account_id?.trim();
const hasResolvedPublicId = Boolean(
publicAccountId && publicAccountId !== "待识别",
);
if (storedProfileUrl && hasResolvedPublicId) {
return { href: storedProfileUrl, label: "查看主页 ↗" };
}
const latestPublishUrl = account.latest_publish_url?.trim();
if (latestPublishUrl) {
return {
href: latestPublishUrl,
label: "通过作品查看主页 ↗",
};
}
const keyword = hasResolvedPublicId ? publicAccountId : account.nickname.trim();
return keyword
? {
href: `https://www.douyin.com/search/${encodeURIComponent(keyword)}?type=user`,
label: "搜索主页 ↗",
}
: null;
}
function PlatformBadge({
platform,
compact = false,
}: {
platform: string | null | undefined;
compact?: boolean;
}) {
const normalized = platform === "抖音" ? "抖音" : platform === "小红书" ? "小红书" : "其他";
return (
<span className={`platform-badge ${compact ? "compact" : ""}`}>
<span className={`platform-logo ${normalized === "抖音" ? "douyin" : normalized === "小红书" ? "xiaohongshu" : "other"}`} aria-hidden="true">
{normalized === "抖音" ? (
<svg viewBox="0 0 24 24" focusable="false">
<path className="douyin-cyan" d="M14.2 3.2v9.5a4.7 4.7 0 1 1-3.4-4.5v2.7a2.1 2.1 0 1 0 .8 1.7V3.2h2.6Zm0 0c.4 2.4 2 3.8 4.3 4.1V10c-1.7-.1-3.2-.7-4.3-1.7V3.2Z" />
<path className="douyin-red" d="M15.2 2.5V12a4.7 4.7 0 1 1-3.4-4.5v2.7a2.1 2.1 0 1 0 .8 1.7V2.5h2.6Zm0 0c.4 2.4 2 3.8 4.3 4.1v2.7c-1.7-.1-3.2-.7-4.3-1.7V2.5Z" />
<path className="douyin-white" d="M14.7 2.9v9.5a4.7 4.7 0 1 1-3.4-4.5v2.7a2.1 2.1 0 1 0 .8 1.7V2.9h2.6Zm0 0c.4 2.4 2 3.8 4.3 4.1v2.7c-1.7-.1-3.2-.7-4.3-1.7V2.9Z" />
</svg>
) : normalized === "小红书" ? (
<b></b>
) : (
<b></b>
)}
</span>
<span>{platform || "未知平台"}</span>
</span>
);
}
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);
const shares = distribution.latest_shares ?? 0;
return {
likes,
comments,
collects,
shares,
total:
likes === null
? null
: likes + (comments ?? 0) + (collects ?? 0) + shares,
};
}
function recoverySortValue(
distribution: Distribution,
key: RecoverySortKey,
): number | null {
const metrics = latestPublicMetrics(distribution);
if (key === "likes") return metrics.likes;
if (key === "collects") return metrics.collects;
if (key === "shares") return metrics.shares;
if (key === "comments") return metrics.comments;
if (key === "total") return metrics.total;
const value =
key === "publish_time"
? distribution.publish_time
: distribution.collection_updated_at ||
(metrics.likes !== null ? distribution.updated_at : null);
if (!value) return null;
const timestamp = Date.parse(value);
return Number.isNaN(timestamp) ? null : timestamp;
}
function sortRecoveryDistributions(
distributions: Distribution[],
key: RecoverySortKey,
direction: SortDirection,
) {
return sortWithNullsLast(
distributions,
(distribution) => recoverySortValue(distribution, key),
direction,
);
}
function SortableRecoveryHeader({
label,
column,
activeColumn,
direction,
onSort,
}: {
label: string;
column: RecoverySortKey;
activeColumn: RecoverySortKey | null;
direction: SortDirection;
onSort: (column: RecoverySortKey) => void;
}) {
const active = activeColumn === column;
const arrow = active ? (direction === "asc" ? "↑" : "↓") : "↕";
return (
<th aria-sort={active ? (direction === "asc" ? "ascending" : "descending") : "none"}>
<button
type="button"
className={`recovery-sort-button${active ? " active" : ""}`}
onClick={() => onSort(column)}
title={`${label}${active && direction === "desc" ? "正序" : "倒序"}排列`}
>
<span>{label}</span>
<b aria-hidden="true">{arrow}</b>
</button>
</th>
);
}
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 publicPublishUrl(value: string | null) {
if (!value) return null;
try {
const url = new URL(value);
const hostname = url.hostname.toLowerCase();
const isSupportedHost =
hostname === "xiaohongshu.com" ||
hostname.endsWith(".xiaohongshu.com") ||
hostname === "xhslink.cn" ||
hostname.endsWith(".xhslink.cn") ||
hostname === "xhslink.com" ||
hostname.endsWith(".xhslink.com") ||
hostname === "douyin.com" ||
hostname.endsWith(".douyin.com");
return ["http:", "https:"].includes(url.protocol) && isSupportedHost
? 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 }) {
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",
platform: "小红书" as "小红书" | "抖音",
contentFormat: "image_text" as "image_text" | "video",
name: "",
brand: "",
dueAt: "2026-08-12",
feishuUrl: "",
keyword: "",
instructions: "在小红书搜索指定关键词,打开 AI 总结并上传完整截图。",
quantity: 1,
});
const [taskExampleImage, setTaskExampleImage] = useState<File | null>(null);
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);
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;
});
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" &&
["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);
const navigate = (key: NavKey) => {
if (key === "resources" && currentUser.role === "user") return;
if (key === "users" && !isManager) return;
setActiveNav(key);
setMenuOpen(false);
};
const submitTask = async (event: FormEvent) => {
event.preventDefault();
if (taskForm.taskType === "content_publish" && !sourcePreview) {
setToast("请先读取飞书表格");
return;
}
if (
taskForm.taskType === "content_publish" &&
taskForm.contentFormat === "video" &&
(sourcePreview?.videoCount ?? 0) === 0
) {
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;
}
}
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} 篇内容进入可领取池`,
);
if (success) {
setTaskModalOpen(false);
setSourcePreview(null);
setTaskExampleImage(null);
setTaskForm({
taskType: "content_publish",
platform: "小红书",
contentFormat: "image_text",
name: "",
brand: "",
dueAt: "2026-08-12",
feishuUrl: "",
keyword: "",
instructions: "在小红书搜索指定关键词,打开 AI 总结并上传完整截图。",
quantity: 1,
});
}
};
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} 条有效内容,识别到 ${result.videoCount} 个视频`,
);
} 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);
}
};
const collectMetrics = async (distribution: Distribution) => {
await runAction(
{ action: "collect_now", distributionId: distribution.id },
"公开数据已更新",
);
};
const updateDistributionPublishUrl = async (
distribution: Distribution,
) => {
const publishInput = window.prompt(
distribution.publish_url
? "更新小红书笔记链接(支持长链、短链或完整分享文案)"
: "填写小红书笔记链接(支持长链、短链或完整分享文案)",
distribution.publish_url || "",
);
if (publishInput === null) return;
if (!publishInput.trim()) {
setToast("请填写笔记链接");
return;
}
await runAction(
{
action: "update_distribution_publish_url",
distributionId: distribution.id,
publishUrl: publishInput.trim(),
},
distribution.publish_url
? "笔记链接已更新,旧公开采集数据已清空"
: "笔记链接已补充,可开始数据采集",
);
};
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,
},
`采集任务已创建将在所选日期09: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);
}
};
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);
};
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");
};
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) => (
<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>
<div>
<strong>{currentUser.username}</strong>
<span>{currentUser.role === "super_admin" ? "超级管理员" : currentUser.role === "admin" ? "管理员" : "普通用户"}</span>
</div>
<span className="chevron">退</span>
</button>
</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>
</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"}
/>
)}
{activeNav === "tasks" && (
<TasksPage
tasks={data.tasks}
portalUrl={data.portal_url}
onCreate={() => setTaskModalOpen(true)}
onCopyShare={copyTaskShareLink}
/>
)}
{activeNav === "distributions" && (
<DistributionPage
tasks={data.tasks}
distributions={data.distributions}
canRelease={isManager}
working={working}
onRelease={async (distribution) =>
runAction(
{
action: "release_distribution",
distributionId: distribution.id,
},
`${distribution.content_title}”已释放,可重新领取`,
)
}
/>
)}
{activeNav === "resources" && (
<ResourcesPage
accounts={data.accounts}
distributions={data.distributions}
exporting={exportingResources}
onExport={exportResources}
onImported={completeResourceImport}
/>
)}
{activeNav === "users" && isManager && (
<UsersPage currentUser={currentUser} />
)}
{activeNav === "recovery" && (
<RecoveryPage
tasks={data.tasks}
distributions={data.distributions}
working={working}
canUpdatePublishUrl={isManager}
onCollect={collectMetrics}
onUpdatePublishUrl={updateDistributionPublishUrl}
onSaveSchedule={saveCollectionSchedule}
onUpload={uploadScreenshot}
onFallback={openMetricFallback}
onRetryFailed={retryFailedMetrics}
onExport={exportRecoveryData}
onExportScreenshots={exportScreenshotResults}
exportingTaskId={exportingTaskId}
/>
)}
</>
)}
</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>
<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" ? (
<>
<div className="form-grid two">
<label>
<span className="platform-meta-line">
<span></span>
<PlatformBadge platform={taskForm.platform} compact />
</span>
<select
value={taskForm.platform}
onChange={(event) =>
setTaskForm({
...taskForm,
platform: event.target.value as "小红书" | "抖音",
})
}
>
<option value="小红书"></option>
<option value="抖音"></option>
</select>
</label>
<label>
<span></span>
<select
value={taskForm.contentFormat}
onChange={(event) => {
setTaskForm({
...taskForm,
contentFormat: event.target.value as "image_text" | "video",
});
setSourcePreview(null);
}}
>
<option value="image_text"></option>
<option value="video"></option>
</select>
</label>
</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>
{taskForm.contentFormat === "video"
? "自动读取标题、正文和视频附件;空标题或无视频的行不会进入任务。"
: "自动读取标题、正文/笔记内容、标签和全部配图;空标题行不会进入任务。"}
</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} · {taskForm.contentFormat === "video"
? `${sourcePreview.videoCount} 个视频`
: `${sourcePreview.imageCount} 张图片`}
</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="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>
</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>
</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>
<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>
</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,
}: {
stats: {
resources: number;
activeTasks: number;
published: number;
completed: number;
pendingRecovery: number;
};
distributions: Distribution[];
tasks: Task[];
onNavigate: (key: NavKey) => void;
showResources: boolean;
}) {
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 !== "已沉淀账号资源");
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>系统在当天09: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 className="platform-meta-line">
<span>{task.brand}</span>
<PlatformBadge platform={task.platform} compact />
<span>{task.content_format === "video" ? "视频" : "图文"}</span>
</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 className="platform-meta-line">
{task.task_type === "screenshot_collect" ? (
<span> · </span>
) : (
<>
<PlatformBadge platform={task.platform} compact />
<span>{task.content_format === "video" ? "视频" : "图文"}</span>
<span>{task.source_sheet_name ? `飞书 ${task.source_sheet_name}` : "历史示例内容表"}</span>
</>
)}
</small>
<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>
<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";
const published = taskDistributions.filter((item) => item.publish_url);
const submitted = taskDistributions.filter(
(item) => item.result_submitted_at,
);
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>
<div className="task-scope-subline">
<span>{task.brand} · {formatDate(task.due_at)} </span>
<PlatformBadge platform={task.platform} compact />
<span className={`content-format-badge ${isScreenshotTask ? "screenshot" : task.content_format === "video" ? "video" : "image-text"}`}>
{isScreenshotTask ? "截图回收" : task.content_format === "video" ? "视频" : "图文"}
</span>
</div>
</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 ? (
<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>
<div><span></span><strong>{task.claimed_quantity}</strong></div>
<div><span>{isScreenshotTask ? "已提交" : "已发布"}</span><strong>{isScreenshotTask ? submitted.length : published.length}</strong></div>
</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>
</>
)}
</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 className="platform-meta-line">
<span>{task.brand}</span>
{task.task_type === "screenshot_collect" ? (
<span></span>
) : (
<>
<PlatformBadge platform={task.platform} compact />
<span>{task.content_format === "video" ? "视频" : "图文"}</span>
</>
)}
<span>{task.quantity} {task.task_type === "screenshot_collect" ? "份" : "篇"}</span>
<span>{formatDate(task.due_at)} </span>
</span>
</div>
{task.task_type !== "screenshot_collect" && task.source_url && (
<a href={task.source_url} target="_blank" rel="noreferrer">
</a>
)}
</section>
);
}
function DistributionPage({
tasks,
distributions,
canRelease,
working,
onRelease,
}: {
tasks: Task[];
distributions: Distribution[];
canRelease: boolean;
working: boolean;
onRelease: (distribution: Distribution) => Promise<boolean>;
}) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [taskQuery, setTaskQuery] = useState("");
const [brandFilter, setBrandFilter] = useState("");
const [contentTypeFilter, setContentTypeFilter] = useState("");
const [platformFilter, setPlatformFilter] = useState("");
const [openTaskFilter, setOpenTaskFilter] = useState<"brand" | "content" | "platform" | null>(null);
const taskFiltersRef = useRef<HTMLDivElement | null>(null);
const brandOptions = useMemo(
() => Array.from(new Set(tasks.map((task) => task.brand).filter(Boolean))).sort((a, b) => a.localeCompare(b, "zh-CN")),
[tasks],
);
const brandFilterOptions = useMemo(
() => [{ value: "", label: "全部品牌/项目" }, ...brandOptions.map((brand) => ({ value: brand, label: brand }))],
[brandOptions],
);
const contentTypeOptions = [
{ value: "", label: "全部内容类型" },
{ value: "图文", label: "图文" },
{ value: "视频", label: "视频" },
{ value: "截图回收", label: "截图回收" },
];
const platformOptions = [
{ value: "", label: "全部平台" },
{ value: "小红书", label: "小红书" },
{ value: "抖音", label: "抖音" },
];
const taskFilterControls = [
{ key: "brand" as const, label: "品牌/项目", value: brandFilter, options: brandFilterOptions },
{ key: "content" as const, label: "内容类型", value: contentTypeFilter, options: contentTypeOptions },
{ key: "platform" as const, label: "平台", value: platformFilter, options: platformOptions },
];
useEffect(() => {
const closeOnOutsideClick = (event: MouseEvent) => {
if (!taskFiltersRef.current?.contains(event.target as Node)) {
setOpenTaskFilter(null);
}
};
document.addEventListener("mousedown", closeOnOutsideClick);
return () => document.removeEventListener("mousedown", closeOnOutsideClick);
}, []);
const setTaskFilterValue = (key: "brand" | "content" | "platform", value: string) => {
if (key === "brand") setBrandFilter(value);
if (key === "content") setContentTypeFilter(value);
if (key === "platform") setPlatformFilter(value);
};
const selectTaskFilter = (key: "brand" | "content" | "platform", value: string) => {
setTaskFilterValue(key, value);
setOpenTaskFilter(null);
};
const filteredTasks = useMemo(() => {
const query = taskQuery.trim().toLocaleLowerCase("zh-CN");
const brandQuery = brandFilter.trim().toLocaleLowerCase("zh-CN");
const contentTypeQuery = contentTypeFilter.trim().toLocaleLowerCase("zh-CN");
const platformQuery = platformFilter.trim().toLocaleLowerCase("zh-CN");
return tasks.filter((task) => {
const taskContentType = task.task_type === "screenshot_collect"
? "截图回收"
: task.content_format === "video" ? "视频" : "图文";
return (
(!query || task.name.toLocaleLowerCase("zh-CN").includes(query)) &&
(!brandQuery || task.brand.toLocaleLowerCase("zh-CN").includes(brandQuery)) &&
(!contentTypeQuery || taskContentType.toLocaleLowerCase("zh-CN").includes(contentTypeQuery)) &&
(!platformQuery || task.platform.toLocaleLowerCase("zh-CN").includes(platformQuery))
);
});
}, [brandFilter, contentTypeFilter, platformFilter, taskQuery, tasks]);
const hasTaskFilters = Boolean(
taskQuery.trim() || brandFilter.trim() || contentTypeFilter.trim() || platformFilter.trim(),
);
const [releaseTarget, setReleaseTarget] = useState<Distribution | 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>
<section className="distribution-task-toolbar" aria-label="筛选分发任务">
<label className="distribution-task-search">
<span aria-hidden="true"></span>
<input
type="search"
value={taskQuery}
onChange={(event) => setTaskQuery(event.target.value)}
placeholder="搜索任务名称"
aria-label="按任务名称模糊搜索"
/>
</label>
<div className="distribution-task-filter-group" ref={taskFiltersRef}>
{taskFilterControls.map((filter) => {
const normalizedFilterValue = filter.value.trim().toLocaleLowerCase("zh-CN");
const visibleOptions = filter.options.filter((option) => (
!normalizedFilterValue ||
option.label.toLocaleLowerCase("zh-CN").includes(normalizedFilterValue)
));
return (
<div
className={`distribution-task-filter-combobox ${filter.key === "brand" ? "brand" : ""}`}
key={filter.key}
>
<div className="distribution-task-filter-input">
<span aria-hidden="true"></span>
<input
type="search"
role="combobox"
aria-autocomplete="list"
aria-expanded={openTaskFilter === filter.key}
aria-controls={`distribution-task-${filter.key}-menu`}
value={filter.value}
placeholder={`搜索${filter.label}`}
aria-label={`模糊搜索${filter.label}`}
onFocus={() => setOpenTaskFilter(filter.key)}
onChange={(event) => {
setTaskFilterValue(filter.key, event.target.value);
setOpenTaskFilter(filter.key);
}}
onKeyDown={(event) => {
if (event.key === "Escape") setOpenTaskFilter(null);
}}
/>
</div>
{openTaskFilter === filter.key ? (
<div
id={`distribution-task-${filter.key}-menu`}
className="distribution-task-filter-menu"
role="listbox"
aria-label={`选择${filter.label}`}
>
<div className="distribution-task-filter-menu-options">
{visibleOptions.map((option) => (
<button
key={option.value}
type="button"
role="option"
aria-selected={filter.value === option.value}
className={filter.value === option.value ? "selected" : ""}
onClick={() => selectTaskFilter(filter.key, option.value)}
>
<span>{option.label}</span>
{filter.value === option.value ? <small></small> : null}
</button>
))}
{visibleOptions.length === 0 ? (
<span className="distribution-task-filter-empty"></span>
) : null}
</div>
</div>
) : null}
</div>
);
})}
</div>
<button
type="button"
className="distribution-task-clear"
disabled={!hasTaskFilters}
onClick={() => {
setTaskQuery("");
setBrandFilter("");
setContentTypeFilter("");
setPlatformFilter("");
setOpenTaskFilter(null);
}}
>
</button>
<span className="distribution-task-count"> <b>{filteredTasks.length}</b> </span>
</section>
{filteredTasks.length > 0 ? (
<TaskScopeList
tasks={filteredTasks}
distributions={distributions}
mode="distribution"
onOpen={setSelectedTaskId}
/>
) : (
<section className="distribution-task-empty">
<strong></strong>
<span></span>
</section>
)}
</div>
);
}
const taskDistributions = distributions.filter(
(item) => item.task_id === selectedTask.id,
);
const isScreenshotTask = selectedTask.task_type === "screenshot_collect";
const published = taskDistributions.filter((item) => item.publish_url).length;
const submitted = taskDistributions.filter((item) => item.result_submitted_at).length;
return (
<div className="stack">
<TaskDetailHeader
task={selectedTask}
label={isScreenshotTask ? "截图任务分发" : "内容分发"}
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>
<i />
<div><span>{isScreenshotTask ? "待提交" : "待发布"}</span><strong>{taskDistributions.length - (isScreenshotTask ? submitted : 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>{isScreenshotTask ? "截图任务分发表" : "内容分发表"}</h2><p>{isScreenshotTask ? "每份任务与结果截图一一对应" : "实际发布账号在链接回填后自动补齐"}</p></div>
</div>
{isScreenshotTask ? (
<ScreenshotTaskTable distributions={taskDistributions} />
) : (
<DistributionTable
distributions={taskDistributions}
onRelease={canRelease ? setReleaseTarget : undefined}
/>
)}
</section>
{releaseTarget && (
<div
className="modal-backdrop"
role="presentation"
onMouseDown={() => { if (!working) setReleaseTarget(null); }}
>
<div
className="modal-card compact release-distribution-modal"
role="alertdialog"
aria-modal="true"
aria-labelledby="release-distribution-title"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="modal-heading">
<div>
<p className="eyebrow warning"></p>
<h2 id="release-distribution-title"></h2>
</div>
<button
type="button"
onClick={() => setReleaseTarget(null)}
aria-label="关闭"
disabled={working}
>
×
</button>
</div>
<div className="release-distribution-summary">
<strong>{releaseTarget.content_title}</strong>
<span>{releaseTarget.partner_name}</span>
</div>
<p className="release-distribution-warning">
KOC
</p>
<div className="modal-actions">
<button
type="button"
className="ghost-button"
onClick={() => setReleaseTarget(null)}
disabled={working}
>
</button>
<button
type="button"
className="release-confirm-button"
onClick={async () => {
const released = await onRelease(releaseTarget);
if (released) setReleaseTarget(null);
}}
disabled={working}
>
{working ? "释放中…" : "确认释放"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
function DistributionTable({
distributions,
compact = false,
onRelease,
}: {
distributions: Distribution[];
compact?: boolean;
onRelease?: (distribution: Distribution) => void;
}) {
return (
<div className="table-scroll">
<table className="data-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
{onRelease && <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><PlatformBadge platform={item.account_platform} compact /></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>
{onRelease && (
<td>
{!item.publish_url && !item.result_submitted_at ? (
<button
type="button"
className="release-distribution-button"
onClick={() => onRelease(item)}
>
</button>
) : (
<span className="muted"></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)}
/>
</>
);
}
function ResourcesPage({
accounts,
distributions,
exporting,
onExport,
onImported,
}: {
accounts: Account[];
distributions: Distribution[];
exporting: boolean;
onExport: (accountIds: string[]) => void;
onImported: (message: string) => Promise<void>;
}) {
const [query, setQuery] = useState("");
const [platformFilter, setPlatformFilter] = useState("全部平台");
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("");
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
.filter(
(item) =>
!item.claimant_name ||
item.partner_name !== item.claimant_name,
)
.map((item) => item.partner_name),
...(account.cooperation_source || "")
.split(/[、,;|]/)
.map((item) => item.trim()),
].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))]
.filter((platform) => platform !== "小红书" && platform !== "抖音")
.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");
const ipKeyword = ipFilter.trim().toLocaleLowerCase("zh-CN");
const sourceKeyword = sourceFilter.trim().toLocaleLowerCase("zh-CN");
return resourceAccounts.filter(({ account, sources }) => {
const searchable = `${account.nickname} ${account.public_account_id} ${account.current_contact || ""} ${account.bio || ""} ${account.tags || ""}`.toLocaleLowerCase(
"zh-CN",
);
const ipLocation = (account.ip_location || "待识别").toLocaleLowerCase(
"zh-CN",
);
return (
(!keyword || searchable.includes(keyword)) &&
(platformFilter === "全部平台" ||
account.platform === platformFilter) &&
(!ipKeyword || ipLocation.includes(ipKeyword)) &&
(!sourceKeyword ||
sources.some((source) =>
source.toLocaleLowerCase("zh-CN").includes(sourceKeyword),
))
);
});
}, [ipFilter, platformFilter, query, resourceAccounts, sourceFilter]);
const hasActiveFilters =
Boolean(query.trim()) ||
platformFilter !== "全部平台" ||
Boolean(ipFilter.trim()) ||
Boolean(sourceFilter.trim());
const clearFilters = () => {
setQuery("");
setPlatformFilter("全部平台");
setIpFilter("");
setSourceFilter("");
};
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);
}
};
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>
<div className="resource-heading-actions">
<a className="ghost-button" download href="/KOC资源导入模板.xlsx">
</a>
<button className="primary-button soft" onClick={() => setImportOpen(true)}>
KOC资源
</button>
<div className="filter-chips">
<button
className={platformFilter === "全部平台" ? "active" : ""}
onClick={() => setPlatformFilter("全部平台")}
>
</button>
{platformOptions.map((platform) => (
<button
className={platformFilter === platform ? "active" : ""}
key={platform}
onClick={() => setPlatformFilter(platform)}
>
<PlatformBadge platform={platform} compact />
</button>
))}
</div>
</div>
</div>
<div className="resource-toolbar">
<div className="resource-search">
<span aria-hidden="true"></span>
<input
aria-label="搜索账号名称、账号ID或当前联系人"
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索账号名称 / 账号ID / 当前联系人 / 标签"
type="search"
value={query}
/>
</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>
<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>
</div>
<div className="resource-grid">
{filteredAccounts.map(({ account, sources, partnerManagedOnly }) => {
const profileLink = resourceProfileLink(account);
const accountTags = (account.tags || "")
.split(/[,,、;|]/)
.map((tag) => tag.trim())
.filter(Boolean)
.slice(0, 5);
const rawBio = (account.bio || "").trim();
const hasBio = Boolean(
rawBio &&
!/^(||||)$/u.test(rawBio),
);
const accountIdLabel =
account.platform === "小红书"
? "小红书号"
: account.platform === "抖音"
? "抖音号"
: "账号";
return (
<article
className={`resource-card ${account.platform === "小红书" ? "platform-xhs" : account.platform === "抖音" ? "platform-douyin" : "platform-other"}`}
key={account.id}
>
<div className="resource-card-head">
<div className={`avatar xlarge resource-profile-avatar ${avatarColor(account.nickname)}`}>{account.nickname.slice(0, 1)}</div>
<div className="resource-profile-main">
<div className="resource-name-line">
<h3>{account.nickname}</h3>
{account.gender === "男" && (
<span className="gender-icon male" aria-label="男性" title="男性"></span>
)}
{account.gender === "女" && (
<span className="gender-icon female" aria-label="女性" title="女性"></span>
)}
</div>
<div className="resource-account-number">
<span>{accountIdLabel}</span>
<strong>{account.public_account_id || "待识别"}</strong>
</div>
<div className="resource-platform-line">
<PlatformBadge platform={account.platform} compact />
<span className="resource-location">IP属地 · {account.ip_location || "待识别"}</span>
</div>
<div className="resource-current-contact">
<span></span>
<strong>{account.current_contact || "待补充"}</strong>
</div>
</div>
</div>
<p className={`resource-bio ${hasBio ? "" : "empty"}`} title={hasBio ? rawBio : undefined}>
{hasBio ? rawBio : "暂无简介"}
</p>
<div className={`resource-tags ${accountTags.length === 0 ? "empty" : ""}`}>
<div>
{accountTags.length > 0
? accountTags.map((tag) => <b key={tag}>{tag}</b>)
: <b></b>}
</div>
</div>
<div className="resource-metrics" aria-label="账号数据">
<div><span></span><strong>{formatNumber(account.followers)}</strong></div>
<div><span></span><strong>{account.post_count}</strong></div>
<div className="resource-latest"><span></span><strong>{formatDate(account.last_seen_at)}</strong></div>
</div>
<div className="resource-card-foot">
<div className="resource-source">
<span></span>
<div>
{sources.map((source) => <b key={source}>{source}</b>)}
{partnerManagedOnly && (
<b className="partner-managed"> · </b>
)}
{sources.length === 0 && !partnerManagedOnly && <b className="empty"></b>}
</div>
</div>
{profileLink && <a href={profileLink.href} target="_blank" rel="noreferrer">{profileLink.label}</a>}
</div>
</article>
);
})}
</div>
{filteredAccounts.length === 0 && (
<div className="resource-empty">
<strong>KOC</strong>
<span></span>
<button className="ghost-button" onClick={clearFilters}>
</button>
</div>
)}
</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> 10,000 20MB</span>
</label>
{!importPreview && (
<div className="import-template-note">
<div><strong></strong><span> 5 </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><PlatformBadge platform={row.platform} compact /></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>
)}
{importPreview.summary.error > 0 && (
<p className="import-preview-warning">
{importPreview.summary.error} {importPreview.summary.create + importPreview.summary.update}
</p>
)}
{Boolean(importPreview.deferredEnrichment) && (
<p className="import-preview-more">
{importPreview.deferredEnrichment}
</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.create + importPreview.summary.update === 0 || importWorking}
onClick={() => void submitImport("commit")}
>
{importWorking ? "导入中…" : `确认导入 ${importPreview.summary.create + importPreview.summary.update} 个账号`}
</button>
)}
</div>
</div>
</div>
)}
</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)}
/>
</>
);
}
function RecoveryPage({
tasks,
distributions,
working,
canUpdatePublishUrl,
onCollect,
onUpdatePublishUrl,
onSaveSchedule,
onUpload,
onFallback,
onRetryFailed,
onExport,
onExportScreenshots,
exportingTaskId,
}: {
tasks: Task[];
distributions: Distribution[];
working: boolean;
canUpdatePublishUrl: boolean;
onCollect: (distribution: Distribution) => void;
onUpdatePublishUrl: (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;
}) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [recoverySort, setRecoverySort] = useState<{
column: RecoverySortKey | null;
direction: SortDirection;
}>({ column: null, direction: "desc" });
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>
);
}
const failedCollectionCount = taskDistributions.filter(
(item) => item.collection_status === "failed",
).length;
const sortedTaskDistributions = recoverySort.column
? sortRecoveryDistributions(
taskDistributions,
recoverySort.column,
recoverySort.direction,
)
: taskDistributions;
const changeRecoverySort = (column: RecoverySortKey) => {
setRecoverySort((current) =>
current.column === column
? {
column,
direction: current.direction === "desc" ? "asc" : "desc",
}
: { column, direction: "desc" },
);
};
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>
</div>
<div className="table-scroll">
<table className="data-table recovery-table">
<thead>
<tr>
<th> / </th>
<SortableRecoveryHeader
label="发布时间"
column="publish_time"
activeColumn={recoverySort.column}
direction={recoverySort.direction}
onSort={changeRecoverySort}
/>
<SortableRecoveryHeader
label="点赞"
column="likes"
activeColumn={recoverySort.column}
direction={recoverySort.direction}
onSort={changeRecoverySort}
/>
<SortableRecoveryHeader
label="收藏"
column="collects"
activeColumn={recoverySort.column}
direction={recoverySort.direction}
onSort={changeRecoverySort}
/>
{selectedTask.platform === "抖音" && (
<SortableRecoveryHeader
label="转发"
column="shares"
activeColumn={recoverySort.column}
direction={recoverySort.direction}
onSort={changeRecoverySort}
/>
)}
<SortableRecoveryHeader
label="评论"
column="comments"
activeColumn={recoverySort.column}
direction={recoverySort.direction}
onSort={changeRecoverySort}
/>
<SortableRecoveryHeader
label="总互动"
column="total"
activeColumn={recoverySort.column}
direction={recoverySort.direction}
onSort={changeRecoverySort}
/>
<SortableRecoveryHeader
label="数据更新时间"
column="collection_updated_at"
activeColumn={recoverySort.column}
direction={recoverySort.direction}
onSort={changeRecoverySort}
/>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{sortedTaskDistributions.map((item) => {
const metrics = latestPublicMetrics(item);
const noteUrl = publicPublishUrl(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={`打开${selectedTask.platform}作品:${item.content_title}`}
title={`打开${selectedTask.platform}作品`}
>
<strong>{item.content_title}</strong>
</a>
) : (
<strong>{item.content_title}</strong>
)}
<span className="platform-meta-line">
<span>{item.account_nickname || "待识别账号"}</span>
{item.account_platform && <PlatformBadge platform={item.account_platform} compact />}
</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>
{selectedTask.platform === "抖音" && (
<td><div className="metric-cell"><strong>{formatNumber(metrics.shares)}</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>{selectedTask.platform === "抖音" ? "赞 + 藏 + 转 + 评" : "赞 + 藏 + 评"}</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}
/>
) : (
<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>
<div className="recovery-row-actions">
{item.screenshot_key && !hasCreatorMetrics(item) ? (
<button className="text-button" onClick={() => onFallback(item)}></button>
) : (
<button
className="collect-button"
disabled={working || !noteUrl}
onClick={() => onCollect(item)}
>
{noteUrl ? "立即采集" : "待填链接"}
</button>
)}
{canUpdatePublishUrl && (
<button
className="publish-url-edit-button"
disabled={working}
onClick={() => onUpdatePublishUrl(item)}
>
{noteUrl ? "更新链接" : "填写链接"}
</button>
)}
</div>
</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所选日期均在北京时间09: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>
);
}