"use client"; import { zipSync } from "fflate"; import { ChangeEvent, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { formatShanghaiDate as formatDate, parseStoredDate, } from "./date-utils"; import { compactPartnerBatchWorkbookForUpload, PARTNER_BATCH_UPLOAD_MAX_BYTES, } from "./batch-workbook-upload"; type Assignment = { id: string; status: string; publish_url: string | null; publish_time: string | null; publish_screenshot_key: string | null; creator_screenshot_key: string | null; result_screenshot_key: string | null; result_submitted_at: string | null; ocr_status: string; exposure: number | null; views: number | null; title: string; body: string; source_row: number | null; account_nickname: string | null; delegation_bundle_id?: string | null; delegation_label?: string | null; images: Array<{ index: number; width: number | null; height: number | null; }>; videos: Array<{ index: number; width: number | null; height: number | null; }>; }; type DelegationSummary = { id: string; label: string; share_token: string; quantity: number; status: "active" | "revoked"; created_at: string; revoked_at: string | null; completed_count: number; creator_completed_count: number; result_completed_count?: number; }; type RecoveredClaim = { claimToken: string; quantity: number; completedCount: number; createdAt: string; firstTitle: string; }; type TaskPayload = { task: { name: string; brand: string; quantity?: number; claimedQuantity?: number; availableQuantity?: number; dueAt: string; status: string; type: "content_publish" | "screenshot_collect"; platform: "小红书" | "抖音"; contentFormat: "image_text" | "video"; }; claim: null | { id: string; claimantName: string; quantity: number; createdAt: string; assignments: Assignment[]; delegations: DelegationSummary[]; }; delegation: null | { id: string; label: string; quantity: number; createdAt: string; assignments: Assignment[]; }; }; const CONFIGURED_ADMIN_ORIGIN = process.env.NEXT_PUBLIC_ADMIN_ORIGIN?.replace(/\/$/, "") ?? ""; function resolveAdminOrigin() { if (CONFIGURED_ADMIN_ORIGIN) return CONFIGURED_ADMIN_ORIGIN; if (window.location.hostname === "localhost" && window.location.port === "3001") { return "http://localhost:3000"; } return window.location.origin; } function partnerApi( path: | "/api/partner" | "/api/partner-upload" | "/api/partner-batch-workbook", ) { return `${resolveAdminOrigin()}${path}`; } function isMobilePortalViewport() { return ( typeof window !== "undefined" && window.matchMedia("(max-width: 620px)").matches ); } function statusLabel(item: Assignment, taskType = "content_publish") { if (taskType === "screenshot_collect") { return item.result_submitted_at ? "已提交" : "待提交"; } return item.publish_url ? "已回填" : "待发布"; } const MAX_TASK_RESULT_SCREENSHOTS = 9; const PUBLISH_BACKFILL_SUCCESS = "这篇笔记的发布记录回填成功啦~"; function toastClassName(message: string) { return message === PUBLISH_BACKFILL_SUCCESS ? "toast success" : "toast"; } function resultScreenshotKeys(value: string | null) { const text = String(value ?? "").trim(); if (!text) return []; if (text.startsWith("task-results/")) return [text]; try { const parsed = JSON.parse(text) as unknown; return Array.isArray(parsed) ? parsed.filter( (item): item is string => typeof item === "string" && item.startsWith("task-results/"), ) : []; } catch { return []; } } function creatorScreenshotDueAt(publishTime?: string | null) { if (!publishTime) return null; const date = parseStoredDate(publishTime); if (Number.isNaN(date.getTime())) return null; date.setDate(date.getDate() + 7); return date.toISOString(); } function imageExtension(contentType: string) { const extensionByType: Record = { "image/avif": "avif", "image/gif": "gif", "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp", }; return extensionByType[contentType] ?? "jpg"; } function safeFileBase(item: Assignment) { return ( item.title.replace(/[\\/:*?"<>|]/g, "").trim().slice(0, 30) || `笔记-${item.source_row ?? "配图"}` ); } function PlatformBadge({ platform, compact = false, logoOnly = false, }: { platform: "小红书" | "抖音"; compact?: boolean; logoOnly?: boolean; }) { return ( {!logoOnly && {platform}} ); } function exactArrayBuffer(bytes: Uint8Array) { const copy = new Uint8Array(bytes.byteLength); copy.set(bytes); return copy.buffer; } function downloadBlob(blob: Blob, fileName: string) { const objectUrl = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = objectUrl; anchor.download = fileName; document.body.appendChild(anchor); anchor.click(); anchor.remove(); window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); } async function compressScreenshot(file: File) { if (file.size < 900_000 || !file.type.startsWith("image/")) return file; const bitmap = await createImageBitmap(file); const maxEdge = 1800; 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.84), ); if (!blob) return file; return new File([blob], `${file.name.replace(/\.[^.]+$/, "")}.jpg`, { type: "image/jpeg", }); } function useFilePreview(file: File | null) { const previewUrl = useMemo( () => (file ? URL.createObjectURL(file) : ""), [file], ); useEffect( () => () => { if (previewUrl) URL.revokeObjectURL(previewUrl); }, [previewUrl], ); return previewUrl; } function ImageLightbox({ image, onClose, }: { image: { src: string; alt: string } | null; onClose: () => void; }) { useEffect(() => { if (!image) return; const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") onClose(); }; document.addEventListener("keydown", onKeyDown); const previousOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.removeEventListener("keydown", onKeyDown); document.body.style.overflow = previousOverflow; }; }, [image, onClose]); if (!image) return null; return (
event.stopPropagation()} >
{image.alt}
{/* eslint-disable-next-line @next/next/no-img-element */} {image.alt}
); } export default function Home() { const [taskToken, setTaskToken] = useState(""); const [claimToken, setClaimToken] = useState(""); const [delegationToken, setDelegationToken] = useState(""); const [selectedId, setSelectedId] = useState(""); const [payload, setPayload] = useState(null); const [loading, setLoading] = useState(true); const [working, setWorking] = useState(false); const [error, setError] = useState(""); const [toast, setToast] = useState(""); const [claimantName, setClaimantName] = useState(""); const [quantity, setQuantity] = useState(1); const [publishUrl, setPublishUrl] = useState(""); const [screenshot, setScreenshot] = useState(null); const [taskResultScreenshots, setTaskResultScreenshots] = useState([]); const [creatorScreenshot, setCreatorScreenshot] = useState(null); const [creatorExposure, setCreatorExposure] = useState(""); const [creatorViews, setCreatorViews] = useState(""); const [entryMode, setEntryMode] = useState<"claim" | "recover">("claim"); const [recoveredClaims, setRecoveredClaims] = useState([]); const [adminOrigin, setAdminOrigin] = useState(""); const [downloadingImage, setDownloadingImage] = useState(null); const [batchDownloading, setBatchDownloading] = useState(false); const [batchWorkbookWorking, setBatchWorkbookWorking] = useState(false); const [creatorWorking, setCreatorWorking] = useState(false); const [creatorStage, setCreatorStage] = useState(""); const [selectedForShare, setSelectedForShare] = useState([]); const [shareLabel, setShareLabel] = useState(""); const [shareComposerOpen, setShareComposerOpen] = useState(false); const [shareWorking, setShareWorking] = useState(false); const [previewImage, setPreviewImage] = useState<{ src: string; alt: string; } | null>(null); const [noteContentCollapsed, setNoteContentCollapsed] = useState(false); const submitCardRef = useRef(null); const batchWorkbookInputRef = useRef(null); const publishScreenshotPreview = useFilePreview(screenshot); const creatorScreenshotPreview = useFilePreview(creatorScreenshot); const taskResultPreviews = useMemo( () => taskResultScreenshots.map((file) => ({ file, url: URL.createObjectURL(file), })), [taskResultScreenshots], ); useEffect( () => () => { taskResultPreviews.forEach((preview) => URL.revokeObjectURL(preview.url)); }, [taskResultPreviews], ); const updateUrl = useCallback( (nextClaim: string, nextNote = "", nextDelegation = "") => { const url = new URL(window.location.href); if (nextClaim) url.searchParams.set("claim", nextClaim); else url.searchParams.delete("claim"); if (nextDelegation) url.searchParams.set("share", nextDelegation); else url.searchParams.delete("share"); if (nextNote) url.searchParams.set("note", nextNote); else url.searchParams.delete("note"); if (nextDelegation) url.searchParams.delete("task"); window.history.replaceState({}, "", url); }, [], ); const loadTask = useCallback(async ( task: string, claim: string, delegation = "", ) => { try { setLoading(true); setError(""); const params = new URLSearchParams(); if (task) params.set("task", task); if (claim) params.set("claim", claim); if (delegation) params.set("share", delegation); const response = await fetch(`${partnerApi("/api/partner")}?${params}`, { cache: "no-store", }); const result = (await response.json()) as TaskPayload & { error?: string }; if (!response.ok) throw new Error(result.error || "暂时无法打开任务"); setPayload(result); if (result.claim) setClaimantName(result.claim.claimantName); } catch (reason) { setError(reason instanceof Error ? reason.message : "暂时无法打开任务"); } finally { setLoading(false); } }, []); useEffect(() => { const timer = window.setTimeout(() => { setAdminOrigin(resolveAdminOrigin()); const params = new URLSearchParams(window.location.search); const task = params.get("task") ?? ""; const claim = params.get("claim") ?? ""; const delegation = params.get("share") ?? ""; const note = params.get("note") ?? ""; setTaskToken(task); setClaimToken(claim); setDelegationToken(delegation); setSelectedId(note); if (!task && !delegation) { setLoading(false); setError("任务链接不完整,请向运营重新获取"); return; } void loadTask(task, claim, delegation); }, 0); return () => window.clearTimeout(timer); }, [loadTask]); useEffect(() => { if (!toast) return; const timer = window.setTimeout(() => setToast(""), 2600); return () => window.clearTimeout(timer); }, [toast]); const activeBatch = payload?.claim ?? payload?.delegation ?? null; const selected = useMemo( () => activeBatch?.assignments.find((item) => item.id === selectedId) ?? null, [activeBatch, selectedId], ); useEffect(() => { if (!selected) return; const timer = window.setTimeout(() => { setPublishUrl(selected.publish_url ?? ""); setScreenshot(null); setTaskResultScreenshots([]); setCreatorScreenshot(null); setCreatorExposure( selected.exposure === null ? "" : String(selected.exposure), ); setCreatorViews(selected.views === null ? "" : String(selected.views)); setNoteContentCollapsed( Boolean(selected.publish_url) && isMobilePortalViewport(), ); }, 0); return () => window.clearTimeout(timer); }, [selected]); const claimNotes = async (event: FormEvent) => { event.preventDefault(); try { setWorking(true); const response = await fetch(partnerApi("/api/partner"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "claim", taskToken, claimantIdentifier: claimantName, quantity, }), }); const result = (await response.json()) as { claimToken?: string; claimedCount?: number; error?: string; }; if (!response.ok || !result.claimToken) { throw new Error(result.error || "领取失败"); } setClaimToken(result.claimToken); setDelegationToken(""); updateUrl(result.claimToken); await loadTask(taskToken, result.claimToken); setToast( payload?.task.type === "screenshot_collect" ? `已领取 ${result.claimedCount} 份,完成后逐份上传截图` : `已领取 ${result.claimedCount} 篇,逐篇发布即可`, ); } catch (reason) { setToast(reason instanceof Error ? reason.message : "领取失败"); } finally { setWorking(false); } }; const openRecoveredClaim = async (nextClaimToken: string) => { setClaimToken(nextClaimToken); setDelegationToken(""); setSelectedId(""); setShareComposerOpen(false); setSelectedForShare([]); updateUrl(nextClaimToken); await loadTask(taskToken, nextClaimToken); }; const recoverClaims = async (event: FormEvent) => { event.preventDefault(); try { setWorking(true); const response = await fetch(partnerApi("/api/partner"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "recover", taskToken, claimantIdentifier: claimantName, }), }); const result = (await response.json()) as { claims?: RecoveredClaim[]; error?: string; }; if (!response.ok || !result.claims?.length) { throw new Error(result.error || "没有找到领取记录"); } setRecoveredClaims(result.claims); if (result.claims.length === 1) { await openRecoveredClaim(result.claims[0].claimToken); } } catch (reason) { setToast(reason instanceof Error ? reason.message : "领取记录查找失败"); } finally { setWorking(false); } }; const showRecovery = async () => { setEntryMode("recover"); setRecoveredClaims([]); setClaimToken(""); setDelegationToken(""); setSelectedId(""); setShareComposerOpen(false); setSelectedForShare([]); updateUrl(""); await loadTask(taskToken, ""); }; const noteImageUrl = (item: Assignment, imageIndex: number) => { const params = new URLSearchParams({ distribution: item.id, index: String(imageIndex), }); if (delegationToken) params.set("share", delegationToken); else { params.set("task", taskToken); params.set("claim", claimToken); } return `${adminOrigin}/api/partner-image?${params}`; }; const noteVideoUrl = ( item: Assignment, videoIndex: number, download = false, ) => { const params = new URLSearchParams({ distribution: item.id, index: String(videoIndex), kind: "video", }); if (download) params.set("download", "1"); if (delegationToken) params.set("share", delegationToken); else { params.set("task", taskToken); params.set("claim", claimToken); } return `${adminOrigin}/api/partner-image?${params}`; }; const taskResultImageUrl = (item: Assignment, imageIndex: number) => { const params = new URLSearchParams({ distribution: item.id, index: String(imageIndex), kind: "result", }); if (delegationToken) params.set("share", delegationToken); else { params.set("task", taskToken); params.set("claim", claimToken); } return `${adminOrigin}/api/partner-image?${params}`; }; const evidenceImageUrl = ( item: Assignment, kind: "publish" | "creator", ) => { const params = new URLSearchParams({ distribution: item.id, index: "1", kind, }); const evidenceKey = kind === "publish" ? item.publish_screenshot_key : item.creator_screenshot_key; if (evidenceKey) params.set("v", evidenceKey); if (delegationToken) params.set("share", delegationToken); else { params.set("task", taskToken); params.set("claim", claimToken); } return `${adminOrigin}/api/partner-image?${params}`; }; const openNote = (item: Assignment) => { setSelectedId(item.id); updateUrl(claimToken, item.id, delegationToken); }; const closeNote = () => { setSelectedId(""); updateUrl(claimToken, "", delegationToken); }; const copyText = async (value: string, successMessage: string) => { try { await navigator.clipboard.writeText(value); setToast(successMessage); } catch { setToast("复制失败,请长按对应文字复制"); } }; const delegationUrl = (shareToken: string) => { const url = new URL(window.location.href); url.search = ""; url.hash = ""; url.searchParams.set("share", shareToken); return url.toString(); }; const toggleShareSelection = (distributionId: string) => { setSelectedForShare((current) => current.includes(distributionId) ? current.filter((id) => id !== distributionId) : [...current, distributionId], ); }; const createDelegation = async (event: FormEvent) => { event.preventDefault(); if (!payload?.claim || selectedForShare.length === 0) return; try { setShareWorking(true); const response = await fetch(partnerApi("/api/partner"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "create_delegation", taskToken, claimToken, delegationLabel: shareLabel, distributionIds: selectedForShare, }), }); const result = (await response.json()) as { delegation?: { id: string; label: string; shareToken: string; quantity: number; }; error?: string; }; if (!response.ok || !result.delegation) { throw new Error(result.error || "分享链接生成失败"); } await copyText( delegationUrl(result.delegation.shareToken), `已生成给 ${result.delegation.label} 的分享链接并复制`, ); setSelectedForShare([]); setShareLabel(""); setShareComposerOpen(false); await loadTask(taskToken, claimToken); } catch (reason) { setToast(reason instanceof Error ? reason.message : "分享链接生成失败"); } finally { setShareWorking(false); } }; const revokeDelegation = async (bundle: DelegationSummary) => { if (!window.confirm(`确认撤销“${bundle.label}”的分享链接吗?`)) return; try { setShareWorking(true); const response = await fetch(partnerApi("/api/partner"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "revoke_delegation", taskToken, claimToken, delegationBundleId: bundle.id, }), }); const result = (await response.json()) as { revoked?: boolean; error?: string; }; if (!response.ok || !result.revoked) { throw new Error(result.error || "撤销失败"); } await loadTask(taskToken, claimToken); setSelectedForShare([]); setToast("分享链接已撤销,相关笔记可以重新转派"); } catch (reason) { setToast(reason instanceof Error ? reason.message : "撤销失败"); } finally { setShareWorking(false); } }; const batchWorkbookParams = () => { const params = new URLSearchParams(); if (delegationToken) params.set("share", delegationToken); else { params.set("task", taskToken); params.set("claim", claimToken); } return params; }; const exportBatchWorkbook = async () => { try { setBatchWorkbookWorking(true); const response = await fetch( `${partnerApi("/api/partner-batch-workbook")}?${batchWorkbookParams()}`, { cache: "no-store" }, ); if (!response.ok) { const result = (await response.json()) as { error?: string }; throw new Error(result.error || "Excel导出失败"); } const disposition = response.headers.get("Content-Disposition") || ""; const encodedName = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1]; const fileName = encodedName ? decodeURIComponent(encodedName) : `${payload?.task.name || "领取笔记"}-批量回填.xlsx`; downloadBlob(await response.blob(), fileName); setToast("Excel已导出,填写后从本页面上传即可批量回填"); } catch (reason) { setToast(reason instanceof Error ? reason.message : "Excel导出失败"); } finally { setBatchWorkbookWorking(false); } }; const importBatchWorkbook = async (event: ChangeEvent) => { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; try { setBatchWorkbookWorking(true); let uploadBody: Blob = file; let compacted = false; if (file.size > PARTNER_BATCH_UPLOAD_MAX_BYTES) { setToast("文件较大,正在保留回填截图并精简原图…"); await new Promise((resolve) => requestAnimationFrame(() => resolve())); const compactedWorkbook = compactPartnerBatchWorkbookForUpload( new Uint8Array(await file.arrayBuffer()), ); if (compactedWorkbook.bytes.byteLength > PARTNER_BATCH_UPLOAD_MAX_BYTES) { throw new Error("精简后的回填表仍超过80MB,请重新导出最新版回填表"); } uploadBody = new Blob([exactArrayBuffer(compactedWorkbook.bytes)], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", }); compacted = true; } const form = new FormData(); form.set("file", uploadBody, file.name); form.set("taskToken", taskToken); form.set("claimToken", claimToken); form.set("delegationToken", delegationToken); const response = await fetch(partnerApi("/api/partner-batch-workbook"), { method: "POST", body: form, }); const contentType = response.headers.get("Content-Type") || ""; const result = (contentType.includes("application/json") ? await response.json() : { error: response.status === 413 ? "回填表超过上传限制,请重新导出最新版回填表" : "批量回填服务暂时不可用,请稍后重试", }) as { error?: string; updatedRows?: number; publishedCount?: number; noteScreenshotCount?: number; analysisScreenshotCount?: number; }; if (!response.ok) throw new Error(result.error || "批量回填失败"); await loadTask(taskToken, claimToken, delegationToken); const details = [ result.publishedCount ? `${result.publishedCount}篇发布信息` : "", result.noteScreenshotCount ? `${result.noteScreenshotCount}张笔记截图` : "", result.analysisScreenshotCount ? `${result.analysisScreenshotCount}张数据分析截图` : "", ].filter(Boolean); setToast( details.length > 0 ? `${compacted ? "文件已自动精简," : ""}已更新${details.join("、")}` : `${compacted ? "文件已自动精简," : ""}表格已读取,没有需要更新的数据`, ); } catch (reason) { setToast(reason instanceof Error ? reason.message : "批量回填失败"); } finally { setBatchWorkbookWorking(false); } }; const prepareImage = async (item: Assignment, imageIndex: number) => { const response = await fetch(noteImageUrl(item, imageIndex)); if (!response.ok) throw new Error("图片读取失败"); const blob = await response.blob(); const extension = imageExtension(blob.type); const fileName = `${safeFileBase(item)}-图片${imageIndex}.${extension}`; const buffer = await blob.arrayBuffer(); return { bytes: new Uint8Array(buffer), file: new File([buffer], fileName, { type: blob.type || "image/jpeg" }), }; }; const shareImages = async (files: File[], title: string) => { if ( typeof navigator.share !== "function" || typeof navigator.canShare !== "function" || !navigator.canShare({ files }) ) { return false; } await navigator.share({ files, title }); return true; }; const downloadImage = async (item: Assignment, imageIndex: number) => { try { setDownloadingImage(imageIndex); const prepared = await prepareImage(item, imageIndex); try { if (await shareImages([prepared.file], item.title)) { setToast("请在系统菜单中选择“存储图像”"); return; } } catch (reason) { if (reason instanceof DOMException && reason.name === "AbortError") return; } downloadBlob( new Blob([exactArrayBuffer(prepared.bytes)], { type: prepared.file.type, }), prepared.file.name, ); setToast(`图片 ${imageIndex} 已下载到文件`); } catch { setToast("图片下载失败,请点击图片后保存"); } finally { setDownloadingImage(null); } }; const downloadAllImages = async (item: Assignment) => { try { setBatchDownloading(true); const prepared = await Promise.all( item.images.map((image) => prepareImage(item, image.index)), ); const files = prepared.map((image) => image.file); try { if (await shareImages(files, `${item.title}|全部配图`)) { setToast("请在系统菜单中选择“存储图像”批量保存"); return; } } catch (reason) { if (reason instanceof DOMException && reason.name === "AbortError") return; } const archive = zipSync( Object.fromEntries( prepared.map((image) => [image.file.name, image.bytes]), ), { level: 0 }, ); downloadBlob( new Blob([exactArrayBuffer(archive)], { type: "application/zip" }), `${safeFileBase(item)}-全部配图.zip`, ); setToast("当前浏览器不支持直接存相册,已下载全部配图 ZIP"); } catch { setToast("批量保存失败,请尝试用系统浏览器打开"); } finally { setBatchDownloading(false); } }; const chooseScreenshot = (event: ChangeEvent) => { setScreenshot(event.target.files?.[0] ?? null); }; const chooseTaskResultScreenshots = (event: ChangeEvent) => { const incoming = Array.from(event.target.files ?? []); if (incoming.length === 0) return; const existingCount = selected ? resultScreenshotKeys(selected.result_screenshot_key).length : 0; setTaskResultScreenshots((current) => { const merged = [...current, ...incoming].filter( (file, index, files) => files.findIndex( (candidate) => candidate.name === file.name && candidate.size === file.size && candidate.lastModified === file.lastModified, ) === index, ); const allowed = Math.max(0, MAX_TASK_RESULT_SCREENSHOTS - existingCount); if (merged.length > allowed) { setToast(`每份任务最多上传${MAX_TASK_RESULT_SCREENSHOTS}张截图`); } return merged.slice(0, allowed); }); event.target.value = ""; }; const removeTaskResultScreenshot = (target: File) => { setTaskResultScreenshots((current) => current.filter((file) => file !== target), ); }; const chooseCreatorScreenshot = (event: ChangeEvent) => { setCreatorScreenshot(event.target.files?.[0] ?? null); }; const uploadEvidence = async ( selectedItem: Assignment, file: File, kind: "publish" | "creator-center" | "task-result", ) => { const compressed = await compressScreenshot(file); const headers: Record = { "Content-Type": compressed.type || "image/jpeg", "X-KOC-Distribution": selectedItem.id, "X-KOC-File-Name": encodeURIComponent(compressed.name), "X-KOC-Upload-Kind": kind, }; if (delegationToken) { headers["X-KOC-Delegation"] = delegationToken; } else { headers["X-KOC-Task"] = taskToken; headers["X-KOC-Claim"] = claimToken; } const uploadResponse = await fetch(partnerApi("/api/partner-upload"), { method: "POST", headers, body: compressed, }); const uploadResult = (await uploadResponse.json()) as { error?: string }; if (!uploadResponse.ok) { throw new Error( uploadResult.error || (kind === "creator-center" ? "创作者中心截图上传失败" : kind === "task-result" ? "任务截图上传失败" : "发布截图上传失败"), ); } }; const submitNote = async (event: FormEvent) => { event.preventDefault(); if (!selected) return; const isFirstBackfill = !selected.publish_url; try { setWorking(true); if (screenshot) { await uploadEvidence(selected, screenshot, "publish"); } else if (!selected.publish_screenshot_key) { throw new Error("请上传这篇笔记的发布截图"); } const response = await fetch(partnerApi("/api/partner"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "submit", taskToken, claimToken, delegationToken, distributionId: selected.id, publishUrl, }), }); const result = (await response.json()) as { error?: string }; if (!response.ok) throw new Error(result.error || "回填失败"); await loadTask(taskToken, claimToken, delegationToken); setScreenshot(null); if (isFirstBackfill && isMobilePortalViewport()) { setNoteContentCollapsed(true); window.setTimeout(() => { submitCardRef.current?.scrollIntoView({ behavior: "smooth", block: "start", }); }, 120); } setToast(PUBLISH_BACKFILL_SUCCESS); } catch (reason) { setToast(reason instanceof Error ? reason.message : "回填失败"); } finally { setWorking(false); } }; const submitScreenshotTask = async (event: FormEvent) => { event.preventDefault(); if (!selected) return; try { setWorking(true); const existingScreenshots = resultScreenshotKeys( selected.result_screenshot_key, ); if (taskResultScreenshots.length > 0) { for (const file of taskResultScreenshots) { await uploadEvidence(selected, file, "task-result"); setTaskResultScreenshots((current) => current.filter((candidate) => candidate !== file), ); } } else if (existingScreenshots.length === 0) { throw new Error("请上传任务要求的截图"); } const response = await fetch(partnerApi("/api/partner"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "submit_screenshot_result", taskToken, claimToken, delegationToken, distributionId: selected.id, }), }); const result = (await response.json()) as { error?: string }; if (!response.ok) throw new Error(result.error || "截图提交失败"); await loadTask(taskToken, claimToken, delegationToken); setTaskResultScreenshots([]); setToast( taskResultScreenshots.length > 1 ? `${taskResultScreenshots.length}张任务截图已提交` : "任务截图已提交", ); } catch (reason) { setToast(reason instanceof Error ? reason.message : "截图提交失败"); } finally { setWorking(false); } }; const submitCreatorData = async () => { if (!selected) return; if (!creatorScreenshot && !selected.creator_screenshot_key) { setToast("请选择创作者中心截图"); return; } if ( !/^\d{1,12}$/.test(creatorExposure) || !/^\d{1,12}$/.test(creatorViews) ) { setToast("请填写正确的曝光量和阅读量"); return; } try { setCreatorWorking(true); if (creatorScreenshot) { setCreatorStage("正在上传截图…"); await uploadEvidence( selected, creatorScreenshot, "creator-center", ); } setCreatorStage("正在保存数据…"); const response = await fetch(partnerApi("/api/partner"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "submit_creator_metrics", taskToken, claimToken, delegationToken, distributionId: selected.id, exposure: creatorExposure, views: creatorViews, }), }); const result = (await response.json()) as { error?: string }; if (!response.ok) { throw new Error(result.error || "创作者数据提交失败"); } await loadTask(taskToken, claimToken, delegationToken); setCreatorScreenshot(null); setToast("创作者截图、曝光量和阅读量已提交"); } catch (reason) { setToast( reason instanceof Error ? reason.message : "创作者数据提交失败", ); } finally { setCreatorWorking(false); setCreatorStage(""); } }; if (loading) { return (
K

正在打开任务…

); } if (error || !payload) { return (
!

KOC LOOP

暂时无法打开任务

{error || "任务不存在"}

); } if (selected && activeBatch) { if (payload.task.type === "screenshot_collect") { return (
KKOC LOOP
{payload.claim ? payload.claim.claimantName : "合作社转派 · 无需登录"}
截图任务 {activeBatch.assignments.findIndex((item) => item.id === selected.id) + 1} 只需上传结果截图

小红书搜索关键词

{selected.title}

任务说明

{selected.body}
{selected.images.length > 0 && (

示例截图

请参考示例,确保小红书 AI 总结完整可见
{selected.images.map((image, index) => ( ))}
)}

截图回收

上传小红书 AI 总结截图

{statusLabel(selected, payload.task.type)}
1 : N

这些截图只会绑定到当前任务,不需要填写发布链接或其他数据。

{selected.result_submitted_at && (

上次提交:{formatDate(selected.result_submitted_at, true)}

)}
setPreviewImage(null)} /> {toast &&
{toast}
}
); } const creatorDueAt = creatorScreenshotDueAt(selected.publish_time); const creatorDataReady = Boolean(selected.creator_screenshot_key) && selected.exposure !== null && selected.views !== null; return (
KKOC LOOP
{payload.claim ? payload.claim.claimantName : "合作社转派 · 无需登录"}
笔记内容已收起 {selected.title} {selected.videos.length > 0 ? `${selected.videos.length} 个视频` : `${selected.images.length} 张配图`} · 已完成首次回填
笔记 {activeBatch.assignments.findIndex((item) => item.id === selected.id) + 1} 飞书源行 {selected.source_row ?? "—"}
{selected.publish_url && ( )}

{selected.title}

正文与话题

{selected.body || "暂无正文"}
{selected.images.length > 0 && (

发布配图

手机优先打开系统保存菜单,其他浏览器会下载文件
{selected.images.length} 张 {selected.images.length > 1 && ( )}
{selected.images.map((image, index) => (
{/* Private claim-bound images intentionally bypass the public image optimizer. */} {/* eslint-disable-next-line @next/next/no-img-element */} {`${selected.title}
图片 {index + 1}
))}
)} {selected.videos.length > 0 && (

发布视频

点击播放,可使用播放器菜单保存原视频
{selected.videos.length} 个
{selected.videos.map((video, index) => (
))}
)}

逐篇回填

填写这篇笔记的发布信息

{statusLabel(selected)}
1 : 1

以下信息只会绑定到当前笔记,不按顺序批量匹配。

发布截图
{screenshot || selected.publish_screenshot_key ? ( ) : (
点击上传这篇笔记的发布截图 支持 JPG、PNG,最大8MB
)}
{selected.publish_time && (

上次回填:{formatDate(selected.publish_time, true)}

)} {selected.publish_url && (

第7天数据回收

上传截图并填写数据

{creatorDataReady ? "已提交" : "待提交"}

请在发布满7天后,上传创作者中心数据页完整截图,并填写页面显示的数据。 {creatorDueAt && ` 建议上传日期:${formatDate(creatorDueAt)}。`}

创作者中心截图
{creatorScreenshot || selected.creator_screenshot_key ? ( ) : (
点击选择第7天创作者中心截图 截图仅用于运营核对,不再自动OCR
)}
)}
setPreviewImage(null)} /> {toast &&
{toast}
}
); } if (activeBatch) { const isScreenshotTask = payload.task.type === "screenshot_collect"; const completed = activeBatch.assignments.filter((item) => isScreenshotTask ? item.result_submitted_at : item.publish_url, ).length; const isClaimOwner = Boolean(payload.claim); const shareableCount = payload.claim?.assignments.filter( (item) => !(isScreenshotTask ? item.result_submitted_at : item.publish_url) && !item.delegation_bundle_id, ).length ?? 0; return (
KKOC LOOP
{payload.claim ? (
{payload.claim.claimantName}
) : (
合作社转派 · 无需登录
)}

{isClaimOwner ? "我的领取任务" : isScreenshotTask ? "合作社转派截图任务包" : "合作社转派发布包"}

{payload.task.name}

{payload.task.brand} {formatDate(payload.task.dueAt)}前{isScreenshotTask ? "提交" : "发布"} {payload.delegation ? ` · ${payload.delegation.label}` : ""}

{completed}/{activeBatch.assignments.length} 已回填

{isClaimOwner ? isScreenshotTask ? "我领取的截图任务" : "我领取的笔记" : isScreenshotTask ? "需要完成的截图任务" : "需要发布的笔记"}

{isClaimOwner ? isScreenshotTask ? "打开一份查看关键词和要求,完成后单独上传截图;也可以转派给底层KOC" : "可逐篇回填,也可导出Excel填写后批量上传;还可以选择笔记转派给底层KOC" : isScreenshotTask ? "打开任务查看搜索关键词和要求,完成后逐份上传截图" : "打开一篇查看完整内容,发布后逐篇回填"}

{activeBatch.assignments.length} {isScreenshotTask ? "份" : "篇"} {!isScreenshotTask && ( <> void importBatchWorkbook(event)} /> )} {isClaimOwner && ( )}
{payload.claim && shareComposerOpen && (

合作社转派

生成免登录分享链接

{isScreenshotTask ? "勾选待提交任务,底层KOC打开链接即可查看要求并上传截图。" : "勾选待发布笔记,底层KOC打开链接即可查看内容并逐篇回填。"}

已选 {selectedForShare.length} {isScreenshotTask ? "份" : "篇"}
对方无需登录,也不会填写手机号、微信或企微;分享链接可用于后续第7天数据回收。
)}
{activeBatch.assignments.map((item, index) => { const shareDisabled = Boolean( (isScreenshotTask ? item.result_submitted_at : item.publish_url) || item.delegation_bundle_id, ); return (
{shareComposerOpen && isClaimOwner && ( )} {String(index + 1).padStart(2, "0")} {item.images[0] ? ( <> {/* Private claim-bound images intentionally bypass the public image optimizer. */} {/* eslint-disable-next-line @next/next/no-img-element */} ) : item.videos.length > 0 ? ( ) : ( 无图 )}

{item.title}

{isScreenshotTask ? `搜索关键词 · ${statusLabel(item, payload.task.type)}` : <> {item.videos.length > 0 ? `${item.videos.length} 个视频` : `${item.images.length} 张配图`} 飞书源行 {item.source_row ?? "—"} · {statusLabel(item, payload.task.type)} } {!isScreenshotTask && item.creator_screenshot_key ? " · D7截图已交" : ""} {item.delegation_label ? ` · 已转派给 ${item.delegation_label}` : ""}

{(isScreenshotTask ? item.result_submitted_at : item.publish_url) ? isScreenshotTask ? "已提交" : "已回填" : item.delegation_bundle_id ? "已转派" : isScreenshotTask ? "待提交" : "待发布"}
); })}
{payload.claim && payload.claim.delegations.length > 0 && (

分享记录

{isScreenshotTask ? "转派截图任务包" : "转派发布包"}

{payload.claim.delegations.length} 个
{payload.claim.delegations.map((bundle) => (
{bundle.label} {bundle.quantity} {isScreenshotTask ? "份" : "篇"} · {isScreenshotTask ? "已提交" : "已发布"}{" "} {Number(isScreenshotTask ? bundle.result_completed_count ?? bundle.completed_count : bundle.completed_count ?? 0)}/{bundle.quantity} {!isScreenshotTask && ` · D7完成 ${Number(bundle.creator_completed_count ?? 0)}`}
{formatDate(bundle.created_at, true)} {bundle.status === "active" ? "分享中" : "已撤销"} {bundle.status === "active" && (
{Number(isScreenshotTask ? bundle.result_completed_count ?? bundle.completed_count : bundle.completed_count ?? 0) === 0 && ( )}
)}
))}
)}
{isClaimOwner ? "以后再次打开原任务链接,填写相同微信号或手机号即可找回全部领取批次和分享记录。" : isScreenshotTask ? "请保存当前分享链接;完成任务后通过此链接上传截图即可。" : "请保存当前分享链接;发布后仍需通过此链接回填第7天截图、曝光量和阅读量。"}
{toast &&
{toast}
}
); } return (
KKOC LOOP
KOC领取页

正在招募 · {payload.task.brand}

{payload.task.name}

{payload.task.type === "screenshot_collect" ? "领取后查看搜索关键词和任务要求,完成后逐份上传截图。" : "领取后可查看完整笔记正文,每篇笔记独立回填发布信息。"}

截止 {formatDate(payload.task.dueAt)} 剩余 {payload.task.availableQuantity} {payload.task.type === "screenshot_collect" ? "份" : "篇"}
1选择数量

一次可领多篇

2{payload.task.type === "screenshot_collect" ? "逐份查看" : "逐篇查看"}

{payload.task.type === "screenshot_collect" ? "关键词与要求完整展示" : "标题正文完整展示"}

3{payload.task.type === "screenshot_collect" ? "上传截图" : "单篇回填"}

{payload.task.type === "screenshot_collect" ? "每份任务与截图一一对应" : "昵称、链接、截图一一对应"}

由 KOC LOOP 提供任务与数据安全保障
{toast &&
{toast}
}
); }