"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"; platform: "小红书" | "抖音"; contentFormat: "image_text" | "video"; recoveryEnabled?: boolean; recoveryDueAt?: string; recoveryInstructions?: string; recoveryExampleImages?: Array<{ index: number; width: number | null; height: number | null; }>; }; claim: null | { id: string; claimantName: string; quantity: number; createdAt: string; assignments: Assignment[]; delegations: DelegationSummary[]; joinQrCode?: string; }; 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" | "/api/wecom-oauth", ) { return `${resolveAdminOrigin()}${path}`; } const WECOM_CORP_ID = process.env.NEXT_PUBLIC_WECOM_CORP_ID ?? ""; const WECOM_AGENT_ID = process.env.NEXT_PUBLIC_WECOM_AGENT_ID ?? ""; const WECOM_OAUTH_ENABLED = Boolean(WECOM_CORP_ID && WECOM_AGENT_ID); const WECOM_PORTAL_BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? "/koc"; const WECOM_IDENTITY_SESSION_KEY = "koc-wecom-identity"; const WECOM_AUTH_FAILED_KEY = "koc-wecom-auth-failed"; const WECOM_OAUTH_SID_PREFIX = "koc-wecom-oauth-sid"; const WECOM_OAUTH_STATE_LIMIT = 120; type WecomPortalIdentity = { ticket: string; name: string; expiresAt: number; }; type PortalParams = { task: string; claim: string; share: string; note: string; owner: string; }; function readSessionValue(key: string): T | null { try { const raw = window.sessionStorage.getItem(key); return raw ? (JSON.parse(raw) as T) : null; } catch { return null; } } function writeSessionValue(key: string, value: unknown): boolean { try { window.sessionStorage.setItem(key, JSON.stringify(value)); return true; } catch { return false; } } function markSessionFlag(key: string) { try { window.sessionStorage.setItem(key, "1"); } catch { // 隐私模式等场景下忽略,最多少一次自动授权 } } function hasSessionFlag(key: string) { try { return window.sessionStorage.getItem(key) === "1"; } catch { return false; } } function loadWecomIdentity(): WecomPortalIdentity | null { const identity = readSessionValue( WECOM_IDENTITY_SESSION_KEY, ); if (!identity?.ticket || typeof identity.expiresAt !== "number") return null; if (Date.now() > identity.expiresAt) return null; return identity; } function isWecomEmbeddedBrowser() { const userAgent = window.navigator.userAgent; return /wxwork/i.test(userAgent) || /MicroMessenger/i.test(userAgent); } function buildWecomAuthorizeUrl(state: string) { const query = new URLSearchParams({ appid: WECOM_CORP_ID, redirect_uri: `${window.location.origin}${WECOM_PORTAL_BASE_PATH}/`, response_type: "code", scope: "snsapi_base", state, agentid: WECOM_AGENT_ID, }); return `https://open.weixin.qq.com/connect/oauth2/authorize?${query}#wechat_redirect`; } function buildWecomOAuthState(params: PortalParams): string { const compact = new URLSearchParams(); if (params.task) compact.set("t", params.task); if (params.claim) compact.set("c", params.claim); if (params.share) compact.set("s", params.share); if (params.note) compact.set("n", params.note); if (params.owner) compact.set("o", params.owner); if (compact.toString().length <= WECOM_OAUTH_STATE_LIMIT) { return compact.toString(); } const sid = crypto.randomUUID().replaceAll("-", "").slice(0, 12); if (writeSessionValue(`${WECOM_OAUTH_SID_PREFIX}:${sid}`, params)) { return `sid=${sid}`; } const fallback = new URLSearchParams(); if (params.task) fallback.set("t", params.task); if (params.share) fallback.set("s", params.share); if (params.owner) fallback.set("o", params.owner); return fallback.toString(); } function restoreParamsFromWecomState(state: string): PortalParams { const parsed = new URLSearchParams(state); const sid = parsed.get("sid"); if (sid) { const key = `${WECOM_OAUTH_SID_PREFIX}:${sid}`; const stored = readSessionValue(key); try { window.sessionStorage.removeItem(key); } catch { // 忽略清理失败 } if (stored?.task || stored?.share) { return { ...stored, owner: stored.owner ?? "" }; } return { task: "", claim: "", share: "", note: "", owner: "" }; } return { task: parsed.get("t") ?? "", claim: parsed.get("c") ?? "", share: parsed.get("s") ?? "", note: parsed.get("n") ?? "", owner: parsed.get("o") ?? "", }; } function isMobilePortalViewport() { return ( typeof window !== "undefined" && window.matchMedia("(max-width: 620px)").matches ); } function statusLabel(item: Assignment) { 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 [ownerPartnerId, setOwnerPartnerId] = 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 [wecomIdentity, setWecomIdentity] = useState( null, ); const [manualEntry, setManualEntry] = useState(false); 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); } }, []); const exchangeWecomIdentity = useCallback( async (code: string, restored: PortalParams) => { try { const response = await fetch(partnerApi("/api/wecom-oauth"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code }), }); const result = (await response.json()) as { userType?: string; identityTicket?: string; displayName?: string; expiresIn?: number; error?: string; }; if ( response.ok && (result.userType === "external" || result.userType === "internal") && result.identityTicket ) { const identity: WecomPortalIdentity = { ticket: result.identityTicket, name: result.displayName || "企微客户", expiresAt: Date.now() + Math.max(60, (result.expiresIn ?? 1800) - 60) * 1_000, }; writeSessionValue(WECOM_IDENTITY_SESSION_KEY, identity); setWecomIdentity(identity); setManualEntry(false); setToast( result.userType === "internal" ? `企业内部身份已确认:${identity.name}` : `企微身份已确认:${identity.name}`, ); } else { markSessionFlag(WECOM_AUTH_FAILED_KEY); setToast( result.userType === "internal" ? "当前为企业内部成员身份,请手动输入微信号领取" : result.error || "企微授权失败,请手动输入微信号", ); } } catch { markSessionFlag(WECOM_AUTH_FAILED_KEY); setToast("企微授权失败,请手动输入微信号"); } setTaskToken(restored.task); setClaimToken(restored.claim); setDelegationToken(restored.share); setSelectedId(restored.note); setOwnerPartnerId(restored.owner); if (!restored.task && !restored.share) { setLoading(false); setError("任务链接不完整,请向运营重新获取"); return; } await loadTask(restored.task, restored.claim, restored.share); }, [loadTask], ); useEffect(() => { const timer = window.setTimeout(() => { setAdminOrigin(resolveAdminOrigin()); const params = new URLSearchParams(window.location.search); const oauthCode = params.get("code") ?? ""; if (oauthCode) { const restored = restoreParamsFromWecomState(params.get("state") ?? ""); const cleaned = new URL(window.location.href); cleaned.searchParams.delete("code"); cleaned.searchParams.delete("state"); window.history.replaceState({}, "", cleaned); void exchangeWecomIdentity(oauthCode, restored); return; } const cachedIdentity = loadWecomIdentity(); if (cachedIdentity) setWecomIdentity(cachedIdentity); const task = params.get("task") ?? ""; const claim = params.get("claim") ?? ""; const delegation = params.get("share") ?? ""; const note = params.get("note") ?? ""; const owner = params.get("owner") ?? ""; setTaskToken(task); setClaimToken(claim); setDelegationToken(delegation); setSelectedId(note); setOwnerPartnerId(owner); if (!task && !delegation) { setLoading(false); setError("任务链接不完整,请向运营重新获取"); return; } if ( WECOM_OAUTH_ENABLED && !cachedIdentity && !hasSessionFlag(WECOM_AUTH_FAILED_KEY) && isWecomEmbeddedBrowser() ) { window.location.replace( buildWecomAuthorizeUrl( buildWecomOAuthState({ task, claim, share: delegation, note, owner, }), ), ); return; } void loadTask(task, claim, delegation); }, 0); return () => window.clearTimeout(timer); }, [loadTask, exchangeWecomIdentity]); 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, ...(wecomIdentity && !manualEntry ? { identityTicket: wecomIdentity.ticket } : {}), ...(ownerPartnerId ? { owner: ownerPartnerId } : {}), 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(`已领取 ${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, ...(wecomIdentity && !manualEntry ? { identityTicket: wecomIdentity.ticket } : {}), }), }); 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 recoveryExampleImageUrl = (imageIndex: number) => { if (!selected) return ""; const params = new URLSearchParams({ distribution: selected.id, index: String(imageIndex), kind: "task-example", }); 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", ): Promise<{ exposure?: number | null; views?: number | null; ocrStatus?: string }> => { 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; exposure?: number | null; views?: number | null; ocrStatus?: string; }; if (!uploadResponse.ok) { throw new Error( uploadResult.error || (kind === "creator-center" ? "创作者中心截图上传失败" : kind === "task-result" ? "任务截图上传失败" : "发布截图上传失败"), ); } return uploadResult; }; 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 submitRecoveryScreenshots = async (event?: FormEvent) => { event?.preventDefault(); if (!selected) return; if (!selected.publish_url) { setToast("请先回填这篇笔记的发布链接,再提交回收截图"); 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; } try { setCreatorWorking(true); let exposureValue = creatorExposure; let viewsValue = creatorViews; if (creatorScreenshot) { setCreatorStage("正在上传截图…"); const ocrResult = await uploadEvidence( selected, creatorScreenshot, "creator-center", ); if (typeof ocrResult.exposure === "number") { exposureValue = String(ocrResult.exposure); setCreatorExposure(exposureValue); } if (typeof ocrResult.views === "number") { viewsValue = String(ocrResult.views); setCreatorViews(viewsValue); } if (ocrResult.ocrStatus === "success") { setCreatorStage("已识别截图数据,正在保存…"); } } if (!/^\d{1,12}$/.test(exposureValue) || !/^\d{1,12}$/.test(viewsValue)) { throw new Error("未识别到完整数据,请填写曝光量和阅读量"); } 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: exposureValue, views: viewsValue, }), }); 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) { 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
)}
{!(screenshot || selected.publish_screenshot_key) && (

先上传发布截图,链接回填需以截图为凭证

)} {selected.publish_time && (

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

)} {selected.publish_url && (

第7天数据回收

上传截图并填写数据

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

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

创作者中心截图
{creatorScreenshot || selected.creator_screenshot_key ? ( ) : (
+ 点击选择第7天创作者中心截图 上传后自动识别曝光量和阅读量,识别失败可手动填写
)}
)} {payload.task.recoveryEnabled && (

截图回收

上传发布后的笔记截图

{selected.result_submitted_at ? "已提交" : "待提交"}
{selected.publish_url && (

{payload.task.recoveryInstructions || "请提交发布后的笔记截图"} {payload.task.recoveryDueAt ? ` 截止:${formatDate(payload.task.recoveryDueAt)}。` : ""}

)} {selected.publish_url && (payload.task.recoveryExampleImages?.length ?? 0) > 0 && (
{payload.task.recoveryExampleImages?.map((image) => ( ))}
)} {selected.publish_url ? ( <>
回收截图 已选择{" "} {resultScreenshotKeys(selected.result_screenshot_key) .length + taskResultScreenshots.length} /{MAX_TASK_RESULT_SCREENSHOTS} 张
{resultScreenshotKeys(selected.result_screenshot_key).map( (_, index) => { const imageUrl = taskResultImageUrl(selected, index + 1); return ( ); }, )} {taskResultPreviews.map(({ file, url }, index) => (
{/* eslint-disable-next-line @next/next/no-img-element */} {`待上传回收截图 待提交
))} {resultScreenshotKeys(selected.result_screenshot_key) .length + taskResultScreenshots.length < MAX_TASK_RESULT_SCREENSHOTS && (
+ {taskResultScreenshots.length || selected.result_screenshot_key ? "继续添加" : "选择截图"}
)}
支持一次选择或继续追加,最多9张,每张不超过8MB;点击图片可在当前页面查看大图。
{selected.result_submitted_at && (

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

)} ) : (

回填发布链接后,这里会开放回收截图上传。

)}
)}
setPreviewImage(null)} /> {toast &&
{toast}
}
); } if (activeBatch) { const recoveryEnabled = payload.task.recoveryEnabled === true; const completed = activeBatch.assignments.filter( (item) => item.publish_url, ).length; const recoverySubmitted = recoveryEnabled ? activeBatch.assignments.filter((item) => item.result_submitted_at).length : 0; const isClaimOwner = Boolean(payload.claim); const shareableCount = payload.claim?.assignments.filter( (item) => !item.publish_url && !item.delegation_bundle_id, ).length ?? 0; return (
KKOC LOOP
{payload.claim ? (
{payload.claim.claimantName}
) : (
合作社转派 · 无需登录
)}

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

{payload.task.name}

{payload.task.brand} {formatDate(payload.task.dueAt)}前发布 {recoveryEnabled && payload.task.recoveryDueAt ? ` · 截图回收 ${formatDate(payload.task.recoveryDueAt)}截止` : ""} {payload.delegation ? ` · ${payload.delegation.label}` : ""}

{completed}/{activeBatch.assignments.length} 已回填
{recoveryEnabled && ( 截图已收 {recoverySubmitted}/{activeBatch.assignments.length} )}
{isClaimOwner && payload.claim?.joinQrCode ? (
扫码进任务群二维码

扫码加入任务群

用微信扫描二维码进入本任务沟通群,进群后按群内指引发布内容。

) : null}

{isClaimOwner ? "我领取的笔记" : "需要发布的笔记"}

{isClaimOwner ? "可逐篇回填,也可导出Excel填写后批量上传;还可以选择笔记转派给底层KOC" : "打开一篇查看完整内容,发布后逐篇回填"}

{activeBatch.assignments.length} 篇 <> void importBatchWorkbook(event)} /> {isClaimOwner && ( )}
{payload.claim && shareComposerOpen && (

合作社转派

生成免登录分享链接

勾选待发布笔记,底层KOC打开链接即可查看内容并逐篇回填。

已选 {selectedForShare.length} 篇
对方无需登录,也不会填写手机号、微信或企微;分享链接可用于后续第7天数据回收。
)}
{activeBatch.assignments.map((item, index) => { const shareDisabled = Boolean( 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}

{item.videos.length > 0 ? `${item.videos.length} 个视频` : `${item.images.length} 张配图`} 飞书源行 {item.source_row ?? "—"} · {statusLabel(item)} {recoveryEnabled && item.publish_url ? item.result_submitted_at ? " · 截图已交" : " · 截图待交" : ""} {item.creator_screenshot_key ? " · D7截图已交" : ""} {item.delegation_label ? ` · 已转派给 ${item.delegation_label}` : ""}

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

分享记录

转派发布包

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

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

{payload.task.name}

领取后可查看完整笔记正文,每篇笔记独立回填发布信息。 {payload.task.recoveryEnabled ? "发布后还需提交回收截图。" : ""}

截止 {formatDate(payload.task.dueAt)} {payload.task.recoveryEnabled && payload.task.recoveryDueAt ? ( 截图回收截止 {formatDate(payload.task.recoveryDueAt)} ) : null} 剩余 {payload.task.availableQuantity} 篇
1选择数量

一次可领多篇

2逐篇查看

标题正文完整展示

3单篇回填

昵称、链接、截图一一对应

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