"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"; import { readApiResponse, WecomUserPickerModal, } from "./wecom-user-picker"; import { TaskCustomerChatModal } from "./task-customer-chat-modal"; type Partner = { id: string; name: string; wecom_name: string; owner: string; claimed_total: number; completed_total: number; wecom_external_user_id?: string | null; partner_type?: string; wecom_userid?: string; account_id?: string | null; account_nickname?: string | null; account_public_id?: string | null; account_contact?: string | null; account_profile_url?: string | null; }; type Task = { id: string; name: string; brand: string; quantity: number; claimed_quantity: number; due_at: string; status: string; platform: "小红书" | "抖音"; content_format: "image_text" | "video"; project_id?: string; owner_userid?: string; task_chat_id?: string; customer_chat_id?: string; customer_chat_name?: string; join_config_id?: string; join_qr_code?: string; keyword?: string; instructions?: string; recovery_enabled?: number; recovery_due_at?: string; recovery_instructions?: string | null; dist_total?: number; dist_complete?: number; dist_submitted?: number; 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 FieldRefreshStatus = { status: "running" | "done"; fields: string[]; total: number; processed: number; updated: number; failed: number; skipped: number; startedAt: string; finishedAt: string | null; }; const REFRESH_FIELD_OPTIONS = [ { key: "publicAccountId", label: "账号ID(小红书号/抖音号)" }, { key: "nickname", label: "昵称" }, { key: "followers", label: "粉丝数" }, { key: "ipLocation", label: "IP属地" }, { key: "gender", label: "性别" }, { key: "bio", label: "简介" }, { key: "tags", label: "标签(AI 重新打标,覆盖已有标签)" }, ] as const; 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_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" | "resources" | "partners" | "recovery" | "users"; const NAV_ITEMS: Array<{ key: NavKey; label: string; mark: string }> = [ { key: "overview", label: "工作台", mark: "⌂" }, { key: "tasks", 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: "", }; 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 ( {platform || "未知平台"} ); } function statusLabel(status: string) { const labels: Record = { claimed: "待发布", published: "已发布", collecting: "采集中", complete: "已完成", active: "分发中", completed: "已完结", closed: "已结束", }; return labels[status] || status; } function dueBadgeState(dueAt: string) { const time = new Date(dueAt).getTime(); if (Number.isNaN(time)) return ""; if (time < Date.now()) return " overdue"; return time - Date.now() <= 3 * 24 * 60 * 60 * 1000 ? " soon" : ""; } 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 ( ); } 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 = { 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 [collectingDistributionIds, setCollectingDistributionIds] = useState>( new Set(), ); const [error, setError] = useState(""); const [toast, setToast] = useState(""); const [menuOpen, setMenuOpen] = useState(false); const [taskModalOpen, setTaskModalOpen] = useState(false); const [groupPushTask, setGroupPushTask] = useState(null); const [ownerLinksTask, setOwnerLinksTask] = useState(null); const [customerChatTask, setCustomerChatTask] = useState(null); const [metricModal, setMetricModal] = useState(null); const [taskForm, setTaskForm] = useState({ id: "", platform: "小红书" as "小红书" | "抖音", contentFormat: "image_text" as "image_text" | "video", name: "", brand: "", dueAt: "2026-08-12", feishuUrl: "", projectId: "", ownerUserid: "", recoveryEnabled: true, recoveryDueAt: "", recoveryInstructions: "", }); const [taskOwnerPickerOpen, setTaskOwnerPickerOpen] = useState(false); const [taskExampleImage, setTaskExampleImage] = useState(null); const [taskFormProjects, setTaskFormProjects] = useState>([]); 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 [creatingPartnerId, setCreatingPartnerId] = useState(null); const isManager = currentUser.role === "super_admin" || currentUser.role === "admin"; const isTaskOwnerView = currentUser.role === "user"; const visibleNavItems = NAV_ITEMS.filter((item) => { if (isTaskOwnerView && ["resources", "partners"].includes(item.key)) { 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]); useEffect(() => { if (!taskModalOpen) return; const timer = window.setTimeout(() => { void (async () => { try { const response = await fetch("/api/action", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "project_list" }), }); const result = await readApiResponse< { projects?: Array<{ id: string; name: string }> } & { error?: string } >(response, "读取项目列表失败"); if (!response.ok) throw new Error(result.error || "读取项目列表失败"); setTaskFormProjects(result.projects ?? []); } catch { setTaskFormProjects([]); } })(); }, 0); return () => window.clearTimeout(timer); }, [taskModalOpen]); 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< DashboardData & { ownerWarning?: string } >(response, "操作失败"); if (!response.ok) throw new Error(result.error || "操作失败"); setData(result); setToast( result.ownerWarning ? `${successMessage};${result.ownerWarning}` : successMessage, ); return result; } 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; const recoveryTotals = data.tasks.reduce( (acc, task) => { if (Number(task.recovery_enabled ?? 1) !== 1) return acc; return { submitted: acc.submitted + (task.dist_submitted ?? 0), total: acc.total + (task.dist_total ?? 0), }; }, { submitted: 0, total: 0 }, ); const screenshotPending = data.distributions.filter( (item) => item.publish_url && !item.result_submitted_at, ).length; return { resources: data.accounts.length, activeTasks: data.tasks.filter((task) => task.status === "active").length, published, completed, pendingRecovery, completedTasks: data.tasks.filter((task) => task.status === "completed") .length, screenshotPending, pairedSubmitted: recoveryTotals.submitted, pairedTotal: recoveryTotals.total, }; }, [data]); const navigate = (key: NavKey) => { if ( currentUser.role === "user" && (key === "resources" || key === "partners") ) { return; } if (key === "users" && !isManager) return; setActiveNav(key); setMenuOpen(false); }; const submitTask = async (event: FormEvent) => { event.preventDefault(); const editing = Boolean(taskForm.id); if (!editing && !isManager && !taskForm.projectId.trim()) { setToast("请选择所属项目"); return; } if (!editing && !sourcePreview) { setToast("请先读取飞书表格"); return; } if ( !editing && taskForm.contentFormat === "video" && (sourcePreview?.videoCount ?? 0) === 0 ) { setToast("当前飞书表格没有识别到视频附件"); return; } let recoveryExampleImageKey = ""; if (taskForm.recoveryEnabled && 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 || "示例截图上传失败"); } recoveryExampleImageKey = uploadResult.key; } catch (reason) { setToast(reason instanceof Error ? reason.message : "示例截图上传失败"); setWorking(false); return; } } const fields = { name: taskForm.name, brand: taskForm.brand, dueAt: taskForm.dueAt, projectId: taskForm.projectId, ownerUserid: taskForm.ownerUserid, platform: taskForm.platform, contentFormat: taskForm.contentFormat, feishuUrl: taskForm.feishuUrl, recoveryEnabled: taskForm.recoveryEnabled, recoveryDueAt: taskForm.recoveryEnabled ? taskForm.recoveryDueAt || taskForm.dueAt : "", recoveryInstructions: taskForm.recoveryInstructions, ...(recoveryExampleImageKey ? { recoveryExampleImageKey } : {}), }; const success = editing ? await runAction( { action: "task_update", taskId: taskForm.id, ...fields, }, "任务已更新", ) : await runAction( { action: "create_task", ...fields }, `任务已创建,${sourcePreview?.rowCount ?? 0} 篇内容进入可领取池`, ); if (success) { setTaskModalOpen(false); setSourcePreview(null); setTaskExampleImage(null); setTaskForm({ id: "", platform: "小红书", contentFormat: "image_text", name: "", brand: "", dueAt: "2026-08-12", feishuUrl: "", projectId: "", ownerUserid: "", recoveryEnabled: true, recoveryDueAt: "", recoveryInstructions: "", }); } }; 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} 条有效内容,识别到 ${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>( 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 remindDueSoon = async (taskId: string, pendingCount: number) => { if (pendingCount === 0) { setToast("当前没有待发布的笔记"); return; } setWorking(true); try { const response = await fetch("/api/action", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "trigger_wecom_due_soon", taskId }), }); const result = await readApiResponse<{ dueSoonSent?: number; dueSoonSkipped?: number; dueSoonFailed?: number; error?: string; }>(response, "催办发送失败"); if (!response.ok) throw new Error(result.error || "催办发送失败"); const parts = [`送达 ${result.dueSoonSent ?? 0} 位`]; if (result.dueSoonSkipped) parts.push(`未绑定企微 ${result.dueSoonSkipped} 位`); if (result.dueSoonFailed) parts.push(`失败 ${result.dueSoonFailed} 位`); setToast(`催办已发出:${parts.join(",")}`); } catch (reason) { setToast(reason instanceof Error ? reason.message : "催办发送失败"); } finally { setWorking(false); } }; const saveCollectionSchedule = async ( taskId: string, startDate: string, days: number[], ): Promise => { const result = await runAction( { action: "save_collection_schedule", taskId, startDate, days, }, `采集任务已创建,将在所选日期09:00自动执行`, ); return result !== false; }; 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 partnerNameByAccountId = useMemo(() => { const map = new Map(); for (const partner of data.partners) { if ((partner.partner_type || "external_koc") !== "group_owner" && partner.account_id) { map.set(partner.account_id, partner.name); } } return map; }, [data.partners]); const createPartnerFromAccount = async (account: Account) => { if (creatingPartnerId) return; setCreatingPartnerId(account.id); try { const response = await fetch("/api/action", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "partner_create_from_account", accountId: account.id, }), }); const result = await readApiResponse<{ error?: string }>(response, "创建合作方失败"); if (!response.ok) throw new Error(result.error || "创建合作方失败"); setToast(`已创建合作方「${account.nickname || account.public_account_id || account.id}」`); await loadData(); } catch (reason) { setToast(reason instanceof Error ? reason.message : "创建合作方失败"); } finally { setCreatingPartnerId(null); } }; 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: "按项目组织任务群与总群成员,从飞书同步内容、开放领取并跟进分发进度。", }, 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 (
{menuOpen &&
KOC LOOP / {pageTitle[activeNav].title}
飞书内容表已接入 {isManager && ( )}

MVP · 核心闭环

{pageTitle[activeNav].title}

{pageTitle[activeNav].subtitle}

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

暂时无法打开工作台

{error}

) : ( <> {isTaskOwnerView && !currentUser.wecom_userid && (

账号尚未绑定企业微信

绑定任务负责人的企微后,这里会展示您负责的任务进度、领取明细与数据回收情况。点击右侧按钮用企业微信授权绑定(需在应用可见范围内),或联系管理员在「用户管理」中绑定。

企业微信授权绑定
)} {activeNav === "overview" && ( )} {activeNav === "tasks" && ( { if (projectId) { setTaskForm((form) => ({ ...form, projectId })); } setTaskModalOpen(true); }} onEditTask={(task) => { setSourcePreview(null); setTaskExampleImage(null); setTaskForm({ id: task.id, platform: task.platform, contentFormat: task.content_format, name: task.name, brand: task.brand, dueAt: task.due_at, feishuUrl: "", projectId: task.project_id ?? "", ownerUserid: task.owner_userid ?? "", recoveryEnabled: Number(task.recovery_enabled ?? 1) === 1, recoveryDueAt: task.recovery_due_at || task.due_at, recoveryInstructions: task.recovery_instructions ?? "", }); setTaskModalOpen(true); }} onCopyShare={copyTaskShareLink} onPushToGroups={(task) => setGroupPushTask(task)} onOwnerLinks={(task) => setOwnerLinksTask(task)} onBindCustomerChat={(task) => setCustomerChatTask(task)} onCreateRecovery={async (task) => { await runAction( { action: "task_enable_recovery", taskId: task.id, recoveryDueAt: task.recovery_due_at || task.due_at, recoveryInstructions: task.recovery_instructions || "请提交发布后的笔记截图", recoveryExampleImageKey: "", }, "回收已开通", ); }} onRelease={async (distribution) => { const result = await runAction( { action: "release_distribution", distributionId: distribution.id, }, `“${distribution.content_title}”已释放,可重新领取`, ); return result !== false; }} /> )} {activeNav === "resources" && ( )} {activeNav === "partners" && isManager && ( )} {activeNav === "users" && isManager && ( )} {activeNav === "recovery" && ( )} )}
{taskModalOpen && (
setTaskModalOpen(false)}>
event.stopPropagation()}>

{taskForm.id ? "任务创建后仍可调整基础信息" : "分发与回收一一对应,状态自动闭环"}

{taskForm.id ? "编辑任务" : "新建任务"}

{!taskForm.id && (
)} {!taskForm.id && ( )} {!taskForm.id && sourcePreview && (
✓
{sourcePreview.sheetName} 工作表 {sourcePreview.sheetId} · 已匹配 {sourcePreview.columns.length} 个字段
{sourcePreview.rowCount} 条 · {taskForm.contentFormat === "video" ? `${sourcePreview.videoCount} 个视频` : `${sourcePreview.imageCount} 张图片`}
{sourcePreview.columns.map((column) => {column})}
{sourcePreview.preview.map((row) => (
{row.sourceRow} {row.title}
))}
)}
{taskForm.recoveryEnabled && ( <>