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

4637 lines
171 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 { platformFromProfileUrl } from "../lib/resource-import";
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;
wecom_external_user_id?: string | null;
};
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 IdRefreshStatus = {
status: "running" | "done";
total: number;
processed: number;
updated: number;
failed: number;
skipped: number;
startedAt: string;
finishedAt: string | null;
};
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"
| "partners"
| "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: "partners", label: "合作方", 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 [collectingDistributionIds, setCollectingDistributionIds] = useState<Set<string>>(
new Set(),
);
const [error, setError] = useState("");
const [toast, setToast] = useState("");
const [menuOpen, setMenuOpen] = useState(false);
const [taskModalOpen, setTaskModalOpen] = useState(false);
const [groupPushTask, setGroupPushTask] = useState<Task | null>(null);
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) => {
setCollectingDistributionIds((current) => {
const next = new Set(current);
next.add(distribution.id);
return next;
});
try {
await runAction(
{ action: "collect_now", distributionId: distribution.id },
"公开数据已更新",
);
} finally {
setCollectingDistributionIds((current) => {
const next = new Set(current);
next.delete(distribution.id);
return next;
});
}
};
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: "只沉淀真实发布过的账号,不要求提前登记。",
},
partners: {
title: "合作方",
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}
onPushToGroups={(task) => setGroupPushTask(task)}
/>
)}
{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 === "partners" && isManager && (
<PartnersPage
partners={data.partners}
working={working}
onRefresh={loadData}
setToast={setToast}
/>
)}
{activeNav === "users" && isManager && (
<UsersPage currentUser={currentUser} />
)}
{activeNav === "recovery" && (
<RecoveryPage
tasks={data.tasks}
distributions={data.distributions}
working={working}
collectingDistributionIds={collectingDistributionIds}
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确认截图范围,支持 JPG、PNG,最大8MB</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>
)}
{groupPushTask && (
<GroupPushModal
task={groupPushTask}
portalUrl={data.portal_url}
onClose={() => setGroupPushTask(null)}
setToast={setToast}
/>
)}
{toast && <div className="toast"><span>✓</span>{toast}</div>}
{working && <div className="working-bar" />}
</div>
);
}
type WecomStatus = {
robotConfigured: boolean;
appConfigured: boolean;
dueDays: number;
};
type WecomContact = {
externalUserId: string;
name: string;
avatar: string;
corpName: string;
ownerUserId: string;
};
type WecomGroupChatRow = {
chat_id: string;
name: string;
owner_user_id: string;
member_count: number;
};
type WecomGroupPushResult = {
sender: string;
msgid: string;
chatCount: number;
failList: string[];
};
type WecomGroupPushRecord = {
id: string;
taskId: string;
msgid: string;
sender: string;
chatIds: string[];
failList: string[];
createdAt: string;
};
function PartnersPage({
partners,
working,
onRefresh,
setToast,
}: {
partners: Partner[];
working: boolean;
onRefresh: () => Promise<void>;
setToast: (message: string) => void;
}) {
const [status, setStatus] = useState<WecomStatus | null>(null);
const [statusLoading, setStatusLoading] = useState(true);
const [editingId, setEditingId] = useState<string | null>(null);
const [draftId, setDraftId] = useState("");
const [partnerWorkingId, setPartnerWorkingId] = useState<string | null>(null);
const [pickerPartner, setPickerPartner] = useState<Partner | null>(null);
const [pickerContacts, setPickerContacts] = useState<WecomContact[]>([]);
const [pickerFetchedAt, setPickerFetchedAt] = useState(0);
const [pickerLoading, setPickerLoading] = useState(false);
const [pickerError, setPickerError] = useState("");
const [pickerSearch, setPickerSearch] = useState("");
const [pickerBindingId, setPickerBindingId] = useState<string | null>(null);
const loadStatus = useCallback(async () => {
let response: Response;
try {
response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "wecom_status" }),
});
} catch {
setToast("读取企业微信配置失败");
setStatusLoading(false);
return;
}
const result = await readApiResponse<WecomStatus & { error?: string }>(
response,
"读取企业微信配置失败",
);
if (!response.ok) {
setToast(result.error || "读取企业微信配置失败");
setStatusLoading(false);
return;
}
setStatus({
robotConfigured: result.robotConfigured,
appConfigured: result.appConfigured,
dueDays: result.dueDays,
});
setStatusLoading(false);
}, [setToast]);
useEffect(() => {
const timer = window.setTimeout(() => {
void loadStatus();
}, 0);
return () => window.clearTimeout(timer);
}, [loadStatus]);
const refreshStatus = async () => {
setStatusLoading(true);
await loadStatus();
};
const startEdit = (partner: Partner) => {
setEditingId(partner.id);
setDraftId(partner.wecom_external_user_id ?? "");
};
const cancelEdit = () => {
setEditingId(null);
setDraftId("");
};
const saveEdit = async (partner: Partner) => {
const value = draftId.trim();
try {
setPartnerWorkingId(partner.id);
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "bind_wecom_external_id",
partnerId: partner.id,
wecomExternalUserId: value,
}),
});
const result = await readApiResponse<{
partnerId?: string;
wecomExternalUserId?: string | null;
error?: string;
}>(response, "保存失败");
if (!response.ok) throw new Error(result.error || "保存失败");
setEditingId(null);
setDraftId("");
await onRefresh();
setToast("企业微信外部联系人已保存");
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "保存失败");
} finally {
setPartnerWorkingId(null);
}
};
const testRobot = async () => {
try {
setPartnerWorkingId("robot");
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "send_test_wecom" }),
});
const result = await readApiResponse<{
robot?: "ok" | "skipped";
app?: "ok" | "skipped" | "failed";
error?: string;
}>(response, "测试失败");
if (!response.ok) throw new Error(result.error || "测试失败");
if (result.robot === "ok") setToast("群机器人测试消息已发送");
else if (result.robot === "skipped") setToast("群机器人未配置,请先填写 WECOM_ROBOT_WEBHOOK");
else setToast("群机器人测试失败");
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "测试失败");
} finally {
setPartnerWorkingId(null);
}
};
const triggerDueSoon = async () => {
try {
setPartnerWorkingId("due-soon");
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "trigger_wecom_due_soon" }),
});
const result = await readApiResponse<{
dueSoonAttempted?: number;
dueSoonSent?: number;
dueSoonFailed?: number;
dueSoonSkipped?: number;
digestSent?: boolean;
error?: string;
}>(response, "触发催办失败");
if (!response.ok) throw new Error(result.error || "触发催办失败");
const sent = result.dueSoonSent ?? 0;
const failed = result.dueSoonFailed ?? 0;
const skipped = result.dueSoonSkipped ?? 0;
const attempted = result.dueSoonAttempted ?? 0;
const digest = result.digestSent ? "群汇总已推送" : "群汇总未推送(无内容或未配置 webhook)";
setToast(
`催办完成:尝试 ${attempted} 个合作方,应用消息已发 ${sent}、跳过 ${skipped}、失败 ${failed};${digest}`,
);
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "触发催办失败");
} finally {
setPartnerWorkingId(null);
}
};
const testAppMessage = async (partner: Partner) => {
if (!partner.wecom_external_user_id) {
setToast("请先保存企业微信外部联系人 ID");
return;
}
try {
setPartnerWorkingId(partner.id);
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "send_test_wecom",
partnerId: partner.id,
}),
});
const result = await readApiResponse<{
robot?: "ok" | "skipped";
app?: "ok" | "skipped" | "failed";
error?: string;
}>(response, "测试失败");
if (!response.ok) throw new Error(result.error || "测试失败");
if (result.app === "ok") setToast(`已向 ${partner.name} 发送测试消息`);
else if (result.app === "skipped") setToast("应用消息未配置或未绑定外部联系人");
else setToast(`应用消息发送失败:${partner.name} 的外部联系人 ID 无效`);
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "测试失败");
} finally {
setPartnerWorkingId(null);
}
};
const loadContacts = useCallback(async () => {
const cacheFresh = pickerFetchedAt > 0 && Date.now() - pickerFetchedAt < 5 * 60 * 1000;
if (cacheFresh) return;
setPickerLoading(true);
setPickerError("");
let response: Response;
try {
response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "wecom_list_external_contacts" }),
});
} catch {
setPickerError("拉取企业微信外部联系人失败");
setPickerLoading(false);
return;
}
const result = await readApiResponse<{ contacts?: WecomContact[]; error?: string }>(
response,
"拉取企业微信外部联系人失败",
);
if (!response.ok) {
setPickerError(result.error || "拉取企业微信外部联系人失败");
setPickerLoading(false);
return;
}
setPickerContacts(result.contacts ?? []);
setPickerFetchedAt(Date.now());
setPickerLoading(false);
}, [pickerFetchedAt]);
const openPicker = (partner: Partner) => {
setPickerPartner(partner);
setPickerSearch("");
setPickerError("");
void loadContacts();
};
const closePicker = () => {
if (pickerBindingId) return;
setPickerPartner(null);
setPickerSearch("");
setPickerError("");
};
const filteredContacts = pickerContacts.filter((contact) => {
if (!pickerSearch.trim()) return true;
const q = pickerSearch.trim().toLowerCase();
return (
contact.name.toLowerCase().includes(q) ||
contact.corpName.toLowerCase().includes(q) ||
contact.externalUserId.toLowerCase().includes(q) ||
contact.ownerUserId.toLowerCase().includes(q)
);
});
const pickContact = async (contact: WecomContact) => {
if (!pickerPartner) return;
try {
setPickerBindingId(contact.externalUserId);
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "bind_wecom_external_id",
partnerId: pickerPartner.id,
wecomExternalUserId: contact.externalUserId,
wecomName: contact.name,
}),
});
const result = await readApiResponse<{ error?: string }>(response, "绑定失败");
if (!response.ok) throw new Error(result.error || "绑定失败");
setToast(`已为 ${pickerPartner.name} 绑定 ${contact.name}`);
setPickerPartner(null);
setPickerSearch("");
await onRefresh();
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "绑定失败");
} finally {
setPickerBindingId(null);
}
};
return (
<div className="stack">
<section className="panel wecom-status-panel">
<div className="panel-heading">
<div>
<h2>企业微信连通性</h2>
<p>临期催办与应用消息依赖以下配置;不显示 Secret。</p>
</div>
<button
type="button"
className="ghost-button"
disabled={statusLoading || working}
onClick={() => void refreshStatus()}
>
{statusLoading ? "刷新中…" : "刷新状态"}
</button>
</div>
<div className="wecom-status-grid">
<div className={`wecom-status-card ${status?.robotConfigured ? "ok" : "off"}`}>
<span>群机器人</span>
<strong>{status ? (status.robotConfigured ? "已配置" : "未配置") : "加载中"}</strong>
<small>用于当日催办汇总</small>
</div>
<div className={`wecom-status-card ${status?.appConfigured ? "ok" : "off"}`}>
<span>自建应用消息</span>
<strong>{status ? (status.appConfigured ? "已配置" : "未配置") : "加载中"}</strong>
<small>需要 corpId + agentId + secret</small>
</div>
<div className="wecom-status-card">
<span>临期阈值</span>
<strong>{status ? `${status.dueDays} 天` : "—"}</strong>
<small>WECOM_NOTIFY_DUE_DAYS</small>
</div>
</div>
<div className="wecom-status-actions">
<button
type="button"
className="primary-button soft"
disabled={partnerWorkingId === "robot" || working || statusLoading}
onClick={() => void testRobot()}
>
{partnerWorkingId === "robot" ? "发送中…" : "测试群机器人"}
</button>
<button
type="button"
className="ghost-button"
disabled={partnerWorkingId === "due-soon" || working || statusLoading}
onClick={() => void triggerDueSoon()}
>
{partnerWorkingId === "due-soon" ? "执行中…" : "立即跑一次催办"}
</button>
<p className="wecom-status-hint">
立即跑一次 daily 09:00 的催办链路:按 partner 聚合发应用消息,再向群机器人发当日汇总。
</p>
{(!status?.robotConfigured || !status?.appConfigured) && (
<p className="wecom-status-hint">
未配置项可在 <code>.dev.vars</code> 或部署环境变量里补齐后重启服务。
</p>
)}
</div>
</section>
<section className="panel table-panel">
<div className="panel-heading">
<div>
<h2>合作方</h2>
<p>每个合作方绑定一个企业微信外部联系人 ID,用于临期催办推送</p>
</div>
</div>
<div className="partner-list">
<div className="partner-row head">
<span>合作方</span>
<span>企业微信名</span>
<span>负责人</span>
<span>外部联系人 ID</span>
<span>操作</span>
</div>
{partners.length === 0 && (
<div className="partner-row empty">暂无合作方,先在飞书表格或领取流程中产生</div>
)}
{partners.map((partner) => {
const editing = editingId === partner.id;
const busy = partnerWorkingId === partner.id;
return (
<div className="partner-row" key={partner.id}>
<span><strong>{partner.name}</strong></span>
<span>{partner.wecom_name || "—"}</span>
<span>{partner.owner || "—"}</span>
<span className="partner-external-cell">
{editing ? (
<input
type="text"
value={draftId}
placeholder="企业微信外部联系人 external_userid"
onChange={(event) => setDraftId(event.target.value)}
disabled={busy}
/>
) : (
<code title={partner.wecom_external_user_id || "未绑定"}>
{partner.wecom_external_user_id || "未绑定"}
</code>
)}
</span>
<span className="partner-actions">
{editing ? (
<>
<button
type="button"
className="primary-button soft"
disabled={busy}
onClick={() => void saveEdit(partner)}
>
{busy ? "保存中…" : "保存"}
</button>
<button
type="button"
className="ghost-button"
disabled={busy}
onClick={cancelEdit}
>
取消
</button>
</>
) : (
<>
<button
type="button"
className="ghost-button"
disabled={busy || working}
onClick={() => startEdit(partner)}
>
{partner.wecom_external_user_id ? "修改" : "绑定"}
</button>
<button
type="button"
className="ghost-button"
disabled={busy || working || pickerLoading}
onClick={() => openPicker(partner)}
>
从企业微信导入
</button>
<button
type="button"
className="ghost-button"
disabled={busy || working || !partner.wecom_external_user_id}
onClick={() => void testAppMessage(partner)}
>
{busy ? "测试中…" : "测试发送"}
</button>
</>
)}
</span>
</div>
);
})}
</div>
</section>
{pickerPartner && (
<div className="modal-backdrop" role="presentation" onMouseDown={closePicker}>
<div className="modal-card wecom-picker-modal" onMouseDown={(event) => event.stopPropagation()}>
<div className="modal-heading">
<div>
<h2>选择 {pickerPartner.name} 的企业微信外部联系人</h2>
<p>从企业微信外部联系人列表中挑选一个,绑定后会自动写入 ID 与企业微信名。</p>
</div>
<button
type="button"
className="ghost-button"
onClick={closePicker}
disabled={!!pickerBindingId}
aria-label="关闭"
>
×
</button>
</div>
<div className="import-guide-strip">
<input
type="search"
className="wecom-picker-search"
placeholder="按名称 / 企业 / ID / 负责人 搜索"
value={pickerSearch}
onChange={(event) => setPickerSearch(event.target.value)}
disabled={pickerLoading || !!pickerBindingId}
/>
<button
type="button"
className="ghost-button"
onClick={() => void loadContacts()}
disabled={pickerLoading || !!pickerBindingId}
>
{pickerLoading ? "拉取中…" : "刷新列表"}
</button>
{pickerFetchedAt > 0 && !pickerLoading && (
<span className="wecom-picker-cache-hint">
缓存于 {new Date(pickerFetchedAt).toLocaleTimeString()}(5 分钟内复用)
</span>
)}
</div>
{pickerError && (
<p className="form-error">{pickerError}</p>
)}
<div className="wecom-picker-list">
{pickerLoading && pickerContacts.length === 0 ? (
<div className="wecom-picker-empty">正在拉取外部联系人…</div>
) : pickerContacts.length === 0 ? (
<div className="wecom-picker-empty">
{pickerError ? "拉取失败,请检查应用配置后重试" : "未拉取到任何外部联系人"}
</div>
) : filteredContacts.length === 0 ? (
<div className="wecom-picker-empty">没有匹配的联系人</div>
) : (
filteredContacts.map((contact) => (
<button
key={contact.externalUserId}
type="button"
className="wecom-picker-row"
onClick={() => void pickContact(contact)}
disabled={!!pickerBindingId}
>
{contact.avatar ? (
<img src={contact.avatar} alt="" className="wecom-picker-avatar" />
) : (
<span className="wecom-picker-avatar placeholder" aria-hidden="true">
{contact.name.slice(0, 1) || "?"}
</span>
)}
<span className="wecom-picker-meta">
<span className="wecom-picker-name">{contact.name || "(未命名)"}</span>
<span className="wecom-picker-sub">
{contact.corpName || "—"} · 负责人 {contact.ownerUserId || "—"}
</span>
<code className="wecom-picker-id">{contact.externalUserId}</code>
</span>
{pickerBindingId === contact.externalUserId ? (
<span className="wecom-picker-binding">绑定中…</span>
) : pickerPartner.wecom_external_user_id === contact.externalUserId ? (
<span className="wecom-picker-current">当前</span>
) : null}
</button>
))
)}
</div>
</div>
</div>
)}
</div>
);
}
function buildGroupPushDraft(task: Task, portalUrl: string) {
const portal = portalUrl.trim().replace(/\/$/, "");
const claimUrl = portal && task.share_token ? `${portal}/?task=${task.share_token}` : "";
const formatLabel =
task.task_type === "screenshot_collect"
? "截图回收"
: task.content_format === "video"
? "视频"
: "图文";
return [
`【新任务】${task.name}`,
`品牌:${task.brand}|数量:${task.quantity} 份|截止:${task.due_at}`,
`平台:${task.platform}|形式:${formatLabel}`,
claimUrl ? `领取链接:${claimUrl}` : "领取链接生成失败,请联系管理员",
].join("\n");
}
function GroupPushModal({
task,
portalUrl,
onClose,
setToast,
}: {
task: Task;
portalUrl: string;
onClose: () => void;
setToast: (message: string) => void;
}) {
const [groups, setGroups] = useState<WecomGroupChatRow[]>([]);
const [groupsLoading, setGroupsLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [selected, setSelected] = useState<string[]>([]);
const [draft, setDraft] = useState(() => buildGroupPushDraft(task, portalUrl));
const [sending, setSending] = useState(false);
const [result, setResult] = useState<{ groupCount: number; failCount: number; results: WecomGroupPushResult[] } | null>(null);
const [history, setHistory] = useState<WecomGroupPushRecord[]>([]);
const postAction = useCallback(async <T,>(payload: Record<string, unknown>, fallback: string) => {
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const parsed = await readApiResponse<T & { error?: string }>(response, fallback);
if (!response.ok) throw new Error(parsed.error || fallback);
return parsed;
}, []);
const loadHistory = useCallback(async () => {
try {
const parsed = await postAction<{ pushes: WecomGroupPushRecord[] }>(
{ action: "wecom_task_group_pushes", taskId: task.id },
"读取发送记录失败",
);
setHistory(parsed.pushes);
} catch {
// 历史加载失败不阻断弹窗
}
}, [postAction, task.id]);
const refreshGroups = useCallback(async (mode: "auto" | "sync") => {
if (mode === "auto") {
try {
const cached = await postAction<{ groups: WecomGroupChatRow[] }>(
{ action: "wecom_group_chats" },
"读取客户群列表失败",
);
if (cached.groups.length > 0) {
setGroups(cached.groups);
return;
}
} catch {
// 缓存读取失败时继续尝试同步
}
}
setSyncing(true);
try {
const parsed = await postAction<{ groups: WecomGroupChatRow[]; syncedCount: number }>(
{ action: "wecom_sync_group_chats" },
"同步客户群失败",
);
setGroups(parsed.groups);
setSelected((previous) => previous.filter((id) => parsed.groups.some((group) => group.chat_id === id)));
setToast(`已同步 ${parsed.syncedCount} 个客户群`);
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "同步客户群失败");
} finally {
setSyncing(false);
}
}, [postAction, setToast]);
useEffect(() => {
const timer = window.setTimeout(() => {
void refreshGroups("auto").finally(() => setGroupsLoading(false));
void loadHistory();
}, 0);
return () => window.clearTimeout(timer);
}, [refreshGroups, loadHistory]);
const toggleGroup = (chatId: string) => {
setSelected((previous) =>
previous.includes(chatId)
? previous.filter((id) => id !== chatId)
: [...previous, chatId],
);
};
const allSelected = groups.length > 0 && selected.length === groups.length;
const submitPush = async () => {
if (selected.length === 0) {
setToast("请选择要发送的客户群");
return;
}
setSending(true);
try {
const parsed = await postAction<{
results: WecomGroupPushResult[];
groupCount: number;
failCount: number;
}>(
{
action: "wecom_push_task_to_groups",
taskId: task.id,
chatIds: selected,
text: draft,
},
"创建企业群发失败",
);
setResult({ results: parsed.results, groupCount: parsed.groupCount, failCount: parsed.failCount });
setToast("群发任务已创建");
await loadHistory();
} catch (reason) {
setToast(reason instanceof Error ? reason.message : "创建企业群发失败");
} finally {
setSending(false);
}
};
return (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
<div className="modal-card group-push-modal" onMouseDown={(event) => event.stopPropagation()}>
<div className="modal-heading">
<div>
<p className="eyebrow">企业微信 · 企业群发</p>
<h2>发布「{task.name}」到客户群</h2>
</div>
<button type="button" onClick={onClose} aria-label="关闭">×</button>
</div>
<div className="group-push-toolbar">
<span className="group-push-count">
{groupsLoading ? "加载群列表…" : `${groups.length} 个客户群 · 已选 ${selected.length} 个`}
</span>
<div className="group-push-toolbar-actions">
<button type="button" className="ghost-button" onClick={() => setSelected(allSelected ? [] : groups.map((group) => group.chat_id))} disabled={groupsLoading || groups.length === 0}>
{allSelected ? "清空选择" : "全选"}
</button>
<button type="button" className="ghost-button" onClick={() => void refreshGroups("sync")} disabled={syncing || groupsLoading}>
{syncing ? "同步中…" : "同步群列表"}
</button>
</div>
</div>
<div className="wecom-picker-list group-push-list">
{groupsLoading ? (
<div className="wecom-picker-empty">正在读取客户群…</div>
) : groups.length === 0 ? (
<div className="wecom-picker-empty">暂无客户群,请先配置企业微信应用并点击「同步群列表」</div>
) : (
groups.map((group) => (
<label key={group.chat_id} className="group-push-row">
<input
type="checkbox"
checked={selected.includes(group.chat_id)}
onChange={() => toggleGroup(group.chat_id)}
disabled={sending}
/>
<span className="wecom-picker-meta">
<span className="wecom-picker-name">{group.name || "(未命名群)"}</span>
<span className="wecom-picker-sub">群主 {group.owner_user_id || "—"} · {group.member_count} 人</span>
</span>
<code className="wecom-picker-id">{group.chat_id}</code>
</label>
))
)}
</div>
<label className="group-push-text">
<span>群发文本(可编辑,发送前请确认)</span>
<textarea rows={7} value={draft} onChange={(event) => setDraft(event.target.value)} disabled={sending} />
</label>
{result && (
<div className="group-push-result">
<p>
已创建 {result.results.length} 个群发任务(覆盖 {result.groupCount} 个群)
{result.failCount > 0 ? `,${result.failCount} 个群发送失败` : ""}。
群主需在企微客户端「群发助手」点击发送后,消息才会送达客户群;每个群每月最多接收当月天数条群发。
</p>
<ul>
{result.results.map((item) => (
<li key={item.msgid || item.sender}>
群主 {item.sender} · {item.chatCount} 个群 · msgid {item.msgid || "—"}
{item.failList.length > 0 && (
<span className="group-push-fail">(失败群:{item.failList.join("、")})</span>
)}
</li>
))}
</ul>
</div>
)}
{history.length > 0 && (
<div className="group-push-history">
<p className="group-push-history-title">发送记录</p>
<ul>
{history.map((record) => (
<li key={record.id}>
{formatDate(record.createdAt, true)} · 群主 {record.sender} · {record.chatIds.length} 个群
{record.failList.length > 0 ? ` · 失败 ${record.failList.length} 个` : ""}
</li>
))}
</ul>
</div>
)}
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={onClose} disabled={sending}>关闭</button>
<button type="button" className="primary-button" onClick={() => void submitPush()} disabled={sending || groupsLoading || selected.length === 0}>
{sending ? "创建中…" : `创建群发(${selected.length} 个群)`}
</button>
</div>
</div>
</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,
onPushToGroups,
}: {
tasks: Task[];
portalUrl: string;
onCreate: () => void;
onCopyShare: (task: Task) => void;
onPushToGroups: (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>
<button
className="share-link-button"
onClick={() => onPushToGroups(task)}
>
发布到企微群
</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)}
/>
</>
);
}
const RESOURCE_PAGE_SIZE = 15;
function pageList(current: number, total: number): Array<number | "gap"> {
if (total <= 7) {
return Array.from({ length: total }, (_, index) => index + 1);
}
const items: Array<number | "gap"> = [1];
const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);
if (start > 2) items.push("gap");
for (let page = start; page <= end; page += 1) items.push(page);
if (end < total - 1) items.push("gap");
items.push(total);
return items;
}
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 [page, setPage] = useState(1);
const [importOpen, setImportOpen] = useState(false);
const [importMode, setImportMode] = useState<"bulk" | "single">("bulk");
const [importFile, setImportFile] = useState<File | null>(null);
const [importPreview, setImportPreview] = useState<ResourceImportPreview | null>(null);
const [importWorking, setImportWorking] = useState(false);
const [importError, setImportError] = useState("");
const [singleForm, setSingleForm] = useState({
profileUrl: "",
nickname: "",
publicAccountId: "",
ipLocation: "",
followers: "",
gender: "",
bio: "",
tags: "",
cooperationSource: "",
});
const [singleWorking, setSingleWorking] = useState(false);
const [singleError, setSingleError] = useState("");
const [editAccount, setEditAccount] = useState<Account | null>(null);
const [editAccountId, setEditAccountId] = useState("");
const [editWorking, setEditWorking] = useState(false);
const [editError, setEditError] = useState("");
const [idRefresh, setIdRefresh] = useState<IdRefreshStatus | null>(null);
const [idRefreshStarting, setIdRefreshStarting] = useState(false);
const idRefreshNotifiedRef = useRef(true);
const [idRefreshError, setIdRefreshError] = 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 totalPages = Math.max(
1,
Math.ceil(filteredAccounts.length / RESOURCE_PAGE_SIZE),
);
const currentPage = Math.min(page, totalPages);
const pagedAccounts = filteredAccounts.slice(
(currentPage - 1) * RESOURCE_PAGE_SIZE,
currentPage * RESOURCE_PAGE_SIZE,
);
const clearFilters = () => {
setQuery("");
setPlatformFilter("全部平台");
setIpFilter("");
setSourceFilter("");
setPage(1);
};
const closeImport = () => {
if (importWorking || singleWorking) return;
setImportOpen(false);
setImportFile(null);
setImportPreview(null);
setImportError("");
setSingleForm({
profileUrl: "",
nickname: "",
publicAccountId: "",
ipLocation: "",
followers: "",
gender: "",
bio: "",
tags: "",
cooperationSource: "",
});
setSingleError("");
};
const switchImportMode = (mode: "bulk" | "single") => {
if (importWorking || singleWorking || mode === importMode) return;
setImportMode(mode);
setImportFile(null);
setImportPreview(null);
setImportError("");
setSingleForm({
profileUrl: "",
nickname: "",
publicAccountId: "",
ipLocation: "",
followers: "",
gender: "",
bio: "",
tags: "",
cooperationSource: "",
});
setSingleError("");
};
const submitSingle = async () => {
if (!singleForm.profileUrl.trim()) {
setSingleError("请填写账号主页链接");
return;
}
try {
setSingleWorking(true);
setSingleError("");
const response = await fetch("/api/resources-insert", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(singleForm),
});
const result = await readApiResponse<{ action: string; message?: string; error?: string }>(
response,
"保存失败",
);
if (!response.ok) throw new Error(result.error || "保存失败");
setImportOpen(false);
setImportMode("bulk");
setSingleForm({
profileUrl: "",
nickname: "",
publicAccountId: "",
ipLocation: "",
followers: "",
gender: "",
bio: "",
tags: "",
cooperationSource: "",
});
setSingleError("");
await onImported(result.message || "已保存账号");
} catch (reason) {
setSingleError(reason instanceof Error ? reason.message : "保存失败");
} finally {
setSingleWorking(false);
}
};
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);
}
};
const closeAccountEdit = () => {
if (editWorking) return;
setEditAccount(null);
setEditAccountId("");
setEditError("");
};
const submitAccountEdit = async () => {
if (!editAccount) return;
const value = editAccountId.trim();
if (!value) {
setEditError("请填写新的账号ID");
return;
}
if (!/^[\p{L}\p{N}._-]{2,80}$/u.test(value)) {
setEditError("账号ID需为 2-80 位字母、数字或 . _ - 组合");
return;
}
try {
setEditWorking(true);
setEditError("");
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "set_public_account_ids",
items: [{ accountId: editAccount.id, publicAccountId: value }],
}),
});
const result = await readApiResponse<{ error?: string }>(response, "保存失败");
if (!response.ok) throw new Error(result.error || "保存失败");
const label = editAccount.nickname || editAccount.public_account_id || "该账号";
setEditAccount(null);
setEditAccountId("");
await onImported(`已更新 ${label} 的账号ID`);
} catch (reason) {
setEditError(reason instanceof Error ? reason.message : "保存失败");
} finally {
setEditWorking(false);
}
};
const runningIdRefresh = idRefresh?.status === "running";
const fetchIdRefreshStatus = useCallback(async () => {
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "public_account_id_refresh_status" }),
});
const result = await readApiResponse<{ refresh: IdRefreshStatus | null }>(
response,
"查询失败",
);
if (!response.ok) throw new Error(result.error || "查询失败");
return result.refresh;
}, []);
const applyIdRefreshStatus = useCallback(
(status: IdRefreshStatus) => {
if (status.status === "done" && !idRefreshNotifiedRef.current) {
idRefreshNotifiedRef.current = true;
const summary = `账号ID更新完成:成功 ${status.updated} 个${
status.skipped ? `,未识别 ${status.skipped} 个` : ""
}${status.failed ? `,失败 ${status.failed} 个` : ""}`;
void onImported(summary);
}
setIdRefresh(status);
},
[onImported],
);
useEffect(() => {
void fetchIdRefreshStatus()
.then((status) => {
if (status?.status === "running") {
idRefreshNotifiedRef.current = false;
applyIdRefreshStatus(status);
}
})
.catch(() => undefined);
}, [applyIdRefreshStatus, fetchIdRefreshStatus]);
useEffect(() => {
if (!runningIdRefresh) return;
const timer = window.setInterval(() => {
void fetchIdRefreshStatus()
.then((status) => {
if (status) applyIdRefreshStatus(status);
})
.catch(() => undefined);
}, 3000);
return () => window.clearInterval(timer);
}, [applyIdRefreshStatus, fetchIdRefreshStatus, runningIdRefresh]);
const startIdRefresh = async () => {
if (runningIdRefresh || idRefreshStarting) return;
if (
!window.confirm(
"将逐个爬取全部账号主页以更新小红书号/抖音号(其他资料不变),账号较多时需要几分钟。确定开始?",
)
) {
return;
}
try {
setIdRefreshStarting(true);
setIdRefreshError("");
const response = await fetch("/api/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "refresh_public_account_ids" }),
});
const result = await readApiResponse<{ refresh: IdRefreshStatus }>(
response,
"启动失败",
);
if (!response.ok) throw new Error(result.error || "启动失败");
idRefreshNotifiedRef.current = false;
setIdRefresh(result.refresh);
} catch (reason) {
setIdRefreshError(reason instanceof Error ? reason.message : "启动失败");
} finally {
setIdRefreshStarting(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("全部平台");
setPage(1);
}}
>
全部平台
</button>
{platformOptions.map((platform) => (
<button
className={platformFilter === platform ? "active" : ""}
key={platform}
onClick={() => {
setPlatformFilter(platform);
setPage(1);
}}
>
<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);
setPage(1);
}}
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);
setPage(1);
}}
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);
setPage(1);
}}
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> 个结果{totalPages > 1 ? ` · 第 ${currentPage}/${totalPages} 页` : ""}</span>
<button
className="export-data-button"
disabled={runningIdRefresh || idRefreshStarting}
title="爬取全部账号主页,更新小红书号/抖音号,其他资料不变"
onClick={() => void startIdRefresh()}
>
{idRefreshStarting
? "正在启动…"
: runningIdRefresh
? `更新中 ${idRefresh?.processed ?? 0}/${idRefresh?.total ?? 0}`
: "更新全部账号ID"}
</button>
<button
className="export-data-button"
disabled={exporting || filteredAccounts.length === 0}
onClick={() =>
onExport(filteredAccounts.map((item) => item.account.id))
}
>
{exporting ? "正在导出…" : "导出筛选结果"}
</button>
</div>
{idRefreshError && <div className="form-error">{idRefreshError}</div>}
</div>
<div className="resource-grid">
{pagedAccounts.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>
<button
type="button"
className="account-id-edit-button"
title="手动修改账号ID"
onClick={() => {
setEditAccount(account);
setEditAccountId(account.public_account_id || "");
setEditError("");
}}
>
修改
</button>
</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>
)}
{totalPages > 1 && (
<div className="resource-pagination">
<button
disabled={currentPage === 1}
onClick={() => setPage(currentPage - 1)}
>
上一页
</button>
{pageList(currentPage, totalPages).map((item, index) =>
item === "gap" ? (
<span className="gap" key={`gap-${index}`}>…</span>
) : (
<button
className={item === currentPage ? "active" : ""}
key={item}
onClick={() => setPage(item)}
>
{item}
</button>
),
)}
<button
disabled={currentPage === totalPages}
onClick={() => setPage(currentPage + 1)}
>
下一页
</button>
</div>
)}
</section>
{editAccount && (
<div className="modal-backdrop" role="presentation" onMouseDown={closeAccountEdit}>
<div
className="modal-card compact"
role="dialog"
aria-modal="true"
aria-labelledby="account-id-edit-title"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="modal-heading">
<div>
<p className="eyebrow">{editAccount.nickname || "账号"}</p>
<h2 id="account-id-edit-title">修改账号ID</h2>
</div>
<button type="button" onClick={closeAccountEdit} aria-label="关闭">×</button>
</div>
<div className="account-id-edit-form">
<label>
<span>
{editAccount.platform === "小红书"
? "小红书号"
: editAccount.platform === "抖音"
? "抖音号"
: "账号ID"}
</span>
<input
autoFocus
type="text"
value={editAccountId}
placeholder="2-80 位字母、数字或 . _ -"
onChange={(event) => setEditAccountId(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void submitAccountEdit();
}}
/>
<small>只更新账号ID,昵称、粉丝数等其他资料保持不变</small>
</label>
{editError && <div className="form-error">{editError}</div>}
</div>
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={closeAccountEdit}>取消</button>
<button
type="button"
className="primary-button"
disabled={editWorking || !editAccountId.trim()}
onClick={() => void submitAccountEdit()}
>
{editWorking ? "保存中…" : "保存"}
</button>
</div>
</div>
</div>
)}
{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">{importMode === "single" ? "单条新增" : "批量导入"}</p>
<h2 id="resource-import-title">导入已有KOC资源</h2>
</div>
<button type="button" onClick={closeImport} aria-label="关闭">×</button>
</div>
<div className="import-mode-tabs" role="tablist">
<button
type="button"
role="tab"
aria-selected={importMode === "bulk"}
className={importMode === "bulk" ? "active" : ""}
onClick={() => switchImportMode("bulk")}
>
批量导入
</button>
<button
type="button"
role="tab"
aria-selected={importMode === "single"}
className={importMode === "single" ? "active" : ""}
onClick={() => switchImportMode("single")}
>
单条新增
</button>
</div>
{importMode === "bulk" && (
<>
<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>
</>
)}
{importMode === "single" && (
<>
<div className="import-guide-strip">
<span>1</span><p>账号主页链接必填,平台自动识别</p>
<i />
<span>2</span><p>其余资料选填,空缺字段系统会补全</p>
<i />
<span>3</span><p>已存在的账号会更新资料</p>
</div>
<div className="form-grid two">
<label>
<span>账号主页链接 *</span>
<input
type="url"
value={singleForm.profileUrl}
onChange={(event) =>
setSingleForm((form) => ({ ...form, profileUrl: event.target.value }))
}
placeholder="https://www.xiaohongshu.com/user/profile/..."
/>
<small>
{platformFromProfileUrl(singleForm.profileUrl)
? `识别平台:${platformFromProfileUrl(singleForm.profileUrl)}`
: "支持小红书 / 抖音账号主页"}
</small>
</label>
<label>
<span>账号昵称</span>
<input
type="text"
value={singleForm.nickname}
onChange={(event) =>
setSingleForm((form) => ({ ...form, nickname: event.target.value }))
}
placeholder="选填"
/>
</label>
<label>
<span>账号ID</span>
<input
type="text"
value={singleForm.publicAccountId}
onChange={(event) =>
setSingleForm((form) => ({ ...form, publicAccountId: event.target.value }))
}
placeholder="小红书号 / 抖音号"
/>
</label>
<label>
<span>IP属地</span>
<input
type="text"
value={singleForm.ipLocation}
onChange={(event) =>
setSingleForm((form) => ({ ...form, ipLocation: event.target.value }))
}
placeholder="省份 / 地区 / 国家"
/>
</label>
<label>
<span>粉丝数</span>
<input
type="text"
value={singleForm.followers}
onChange={(event) =>
setSingleForm((form) => ({ ...form, followers: event.target.value }))
}
placeholder="如 1.3万 / 8000"
/>
</label>
<label>
<span>性别</span>
<select
value={singleForm.gender}
onChange={(event) =>
setSingleForm((form) => ({ ...form, gender: event.target.value }))
}
>
<option value="">未填写</option>
<option value="男">男</option>
<option value="女">女</option>
</select>
</label>
<label>
<span>合作来源</span>
<input
type="text"
value={singleForm.cooperationSource}
onChange={(event) =>
setSingleForm((form) => ({ ...form, cooperationSource: event.target.value }))
}
placeholder="历史合作来源 / 资源来源"
/>
</label>
<label>
<span>标签</span>
<input
type="text"
value={singleForm.tags}
onChange={(event) =>
setSingleForm((form) => ({ ...form, tags: event.target.value }))
}
placeholder="逗号分隔,最多 5 个"
/>
</label>
</div>
<label>
<span>简介</span>
<textarea
value={singleForm.bio}
onChange={(event) =>
setSingleForm((form) => ({ ...form, bio: event.target.value }))
}
placeholder="选填"
/>
</label>
{singleError && <div className="form-error">{singleError}</div>}
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={closeImport}>取消</button>
<button
type="button"
className="primary-button"
disabled={!singleForm.profileUrl.trim() || singleWorking}
onClick={() => void submitSingle()}
>
{singleWorking ? "保存中…" : "保存账号"}
</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,
collectingDistributionIds,
canUpdatePublishUrl,
onCollect,
onUpdatePublishUrl,
onSaveSchedule,
onUpload,
onFallback,
onRetryFailed,
onExport,
onExportScreenshots,
exportingTaskId,
}: {
tasks: Task[];
distributions: Distribution[];
working: boolean;
collectingDistributionIds: ReadonlySet<string>;
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">
{item.ocr_status === "processing"
? "正在识别数据"
: "识别失败,请手动填写"}
</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={
!noteUrl ||
(working &&
(collectingDistributionIds.size === 0 ||
collectingDistributionIds.has(item.id)))
}
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>勾选第1天到第7天,所选日期均在北京时间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">左右滑动查看第1—7天</span>
</div>
{task.collection_schedule_updated_at && (
<p className="schedule-updated">
当前计划更新于 {formatDate(task.collection_schedule_updated_at, true)}
</p>
)}
</section>
);
}