"use client"; import { ChangeEvent, FormEvent, useCallback, useEffect, useMemo, useState, } from "react"; import { formatShanghaiDate as formatDate, formatShanghaiToday, } from "../lib/date-utils"; import type { AuthUser } from "../lib/user-auth"; import { parseResultScreenshotKeys } from "../lib/result-screenshots"; import { sortWithNullsLast, type SortDirection, } from "../lib/sort-utils"; import UsersPage from "./users-page"; type Partner = { id: string; name: string; wecom_name: string; owner: string; claimed_total: number; completed_total: number; }; type Task = { id: string; name: string; brand: string; quantity: number; claimed_quantity: number; due_at: string; status: string; task_type: "content_publish" | "screenshot_collect"; source_url: string; source_sheet_id: string; source_sheet_name: string; source_synced_at: string | null; share_token: string; collection_start_date: string | null; collection_days: string; collection_schedule_updated_at: string | null; }; type Account = { id: string; platform: string; platform_uid: string; public_account_id: string; nickname: string; profile_url: string; ip_location: string; followers: number; post_count: number; avg_views: number; cooperation_source: string; tags: string; first_seen_at: string; last_seen_at: string; }; type ResourceImportPreview = { summary: { total: number; create: number; update: number; error: number }; rows: Array<{ rowNumber: number; platform: string; nickname: string; publicAccountId: string; ipLocation: string; followers: number; cooperationSource: string; tags: string; action: "create" | "update" | "error"; errors: string[]; }>; truncated: boolean; }; 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; collection_status: string; collection_status_description: string | null; collection_updated_at: string | null; last_collection_day: number | null; updated_at: string; content_title: string; partner_name: string; account_nickname: string | null; account_platform: string | null; task_name: string; task_brand: string; task_type: "content_publish" | "screenshot_collect"; due_at: string; }; type RecoverySortKey = | "publish_time" | "likes" | "collects" | "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; columns: string[]; preview: Array<{ sourceRow: number; title: string; body: string; }>; }; type NavKey = | "overview" | "tasks" | "distributions" | "resources" | "recovery" | "users"; const NAV_ITEMS: Array<{ key: NavKey; label: string; mark: string }> = [ { key: "overview", label: "工作台", mark: "⌂" }, { key: "tasks", label: "任务中心", mark: "▤" }, { key: "distributions", label: "任务分发", mark: "↗" }, { key: "resources", label: "KOC资源", mark: "◎" }, { key: "recovery", label: "数据回收", mark: "◫" }, { key: "users", label: "用户管理", mark: "♙" }, ]; const EMPTY_DATA: DashboardData = { partners: [], tasks: [], accounts: [], distributions: [], portal_url: "", }; async function readApiResponse(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 = { 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 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 === "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 ( ); } function hasCreatorMetrics(distribution: Distribution) { return distribution.exposure !== null && distribution.views !== null; } function parseCollectionDays(value: string | null | undefined) { try { const parsed = JSON.parse(value || "[]") as unknown; if (!Array.isArray(parsed)) return []; return [...new Set(parsed.map(Number))] .filter((day) => Number.isInteger(day) && day >= 1 && day <= 7) .sort((a, b) => a - b); } catch { return []; } } function todayInputValue() { const parts = new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit", }).formatToParts(new Date()); const value = Object.fromEntries( parts.map((part) => [part.type, part.value]), ); return `${value.year}-${value.month}-${value.day}`; } function addCalendarDays(value: string, days: number) { const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/); if (!match) return ""; const date = new Date( Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]) + days), ); return date.toISOString().slice(0, 10); } function xhsPublishUrl(value: string | null) { if (!value) return null; try { const url = new URL(value); const hostname = url.hostname.toLowerCase(); const isXhsHost = hostname === "xiaohongshu.com" || hostname.endsWith(".xiaohongshu.com") || hostname === "xhslink.cn" || hostname.endsWith(".xhslink.cn") || 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 = { 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((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(EMPTY_DATA); const [activeNav, setActiveNav] = useState("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(null); const [taskForm, setTaskForm] = useState({ taskType: "content_publish" as "content_publish" | "screenshot_collect", name: "", brand: "", dueAt: "2026-08-12", feishuUrl: "", keyword: "", instructions: "在小红书搜索指定关键词,打开 AI 总结并上传完整截图。", quantity: 1, }); const [taskExampleImage, setTaskExampleImage] = useState(null); const [sourcePreview, setSourcePreview] = useState(null); const [sourceWorking, setSourceWorking] = useState(false); const [metricForm, setMetricForm] = useState({ exposure: "", views: "" }); const [exportingTaskId, setExportingTaskId] = useState(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(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, 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(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; } 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", 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( response, "读取飞书表格失败", ); if (!response.ok) throw new Error(result.error || "读取飞书表格失败"); setSourcePreview(result); setTaskForm((current) => ({ ...current, name: current.name || result.sheetName.replace(/500篇$/, "分发任务"), })); setToast(`已读取 ${result.rowCount} 篇有效内容`); } catch (reason) { setSourcePreview(null); setToast(reason instanceof Error ? reason.message : "读取飞书表格失败"); } finally { setSourceWorking(false); } }; const copyTaskShareLink = async (task: Task) => { if (!data.portal_url || !task.share_token) { setToast("KOC外部站点尚未发布"); return; } const url = `${data.portal_url.replace(/\/$/, "")}/?task=${task.share_token}`; try { await navigator.clipboard.writeText(url); setToast("KOC领取链接已复制"); } catch { window.prompt("复制KOC领取链接", url); } }; const exportScreenshotResults = async (task: Task) => { try { setExportingTaskId(task.id); const response = await fetch( `/api/screenshot-task-export?task=${encodeURIComponent(task.id)}`, ); if (!response.ok) { const result = await readApiResponse>( response, "截图打包失败", ); throw new Error(result.error || "截图打包失败"); } const blob = await response.blob(); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = `${task.name}-截图回收.zip`; anchor.click(); URL.revokeObjectURL(url); setToast("任务截图和回收清单已打包下载"); } catch (reason) { setToast(reason instanceof Error ? reason.message : "截图打包失败"); } finally { setExportingTaskId(null); } }; const collectMetrics = async (distribution: Distribution) => { await runAction( { action: "collect_now", distributionId: distribution.id }, "公开数据已更新", ); }; const 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, ) => { 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(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 = { overview: { title: "分发工作台", subtitle: "用最少动作,跑完内容领取、发布与数据回收。", }, tasks: { title: "内容任务", subtitle: "从飞书同步内容,按数量开放给合作方领取。", }, distributions: { title: "内容分发", subtitle: "先按任务查看进度,再进入单个任务处理每篇笔记。", }, resources: { title: "KOC资源", subtitle: "只沉淀真实发布过的账号,不要求提前登记。", }, recovery: { title: "数据回收", subtitle: "按任务设置自动采集日,集中查看最新公开数据与创作者截图。", }, users: { title: "用户管理", subtitle: "由后台统一创建账号和重置密码,不开放自助注册与个人改密。", }, }; const logout = async () => { await fetch("/api/auth/logout", { method: "POST" }).catch(() => null); window.location.assign("/login"); }; return (
{menuOpen &&
KOC LOOP / {pageTitle[activeNav].title}
飞书内容表已接入

MVP · 核心闭环

{pageTitle[activeNav].title}

{pageTitle[activeNav].subtitle}

{activeNav === "overview" && (
今天 {formatShanghaiToday()}
)}
{loading ? ( ) : error ? (
!

暂时无法打开工作台

{error}

) : ( <> {activeNav === "overview" && ( )} {activeNav === "tasks" && ( setTaskModalOpen(true)} onCopyShare={copyTaskShareLink} /> )} {activeNav === "distributions" && ( runAction( { action: "release_distribution", distributionId: distribution.id, }, `“${distribution.content_title}”已释放,可重新领取`, ) } /> )} {activeNav === "resources" && ( )} {activeNav === "users" && isManager && ( )} {activeNav === "recovery" && ( )} )}
{taskModalOpen && (
setTaskModalOpen(false)}>
event.stopPropagation()}>

任务类型明确,回收链路独立

新建任务

{taskForm.taskType === "content_publish" ? ( <> {sourcePreview && (
{sourcePreview.sheetName} 工作表 {sourcePreview.sheetId} · 已匹配 {sourcePreview.columns.length} 个字段
{sourcePreview.rowCount} 篇
{sourcePreview.columns.map((column) => {column})}
{sourcePreview.preview.map((row) => (
{row.sourceRow} {row.title}
))}
)} ) : (