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