Files
koc-loop/koc-portal/app/page.tsx
2026-08-16 22:22:50 +08:00

2119 lines
79 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<string, string> = {
"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 (
<span
className={`platform-badge ${compact ? "compact" : ""} ${logoOnly ? "logo-only" : ""}`}
aria-label={logoOnly ? platform : undefined}
>
<span className={`platform-logo ${platform === "抖音" ? "douyin" : "xiaohongshu"}`} aria-hidden="true">
{platform === "抖音" ? (
<svg viewBox="0 0 24 24" focusable="false">
<path className="douyin-cyan" d="M14.2 3.2v9.5a4.7 4.7 0 1 1-3.4-4.5v2.7a2.1 2.1 0 1 0 .8 1.7V3.2h2.6Zm0 0c.4 2.4 2 3.8 4.3 4.1V10c-1.7-.1-3.2-.7-4.3-1.7V3.2Z" />
<path className="douyin-red" d="M15.2 2.5V12a4.7 4.7 0 1 1-3.4-4.5v2.7a2.1 2.1 0 1 0 .8 1.7V2.5h2.6Zm0 0c.4 2.4 2 3.8 4.3 4.1v2.7c-1.7-.1-3.2-.7-4.3-1.7V2.5Z" />
<path className="douyin-white" d="M14.7 2.9v9.5a4.7 4.7 0 1 1-3.4-4.5v2.7a2.1 2.1 0 1 0 .8 1.7V2.9h2.6Zm0 0c.4 2.4 2 3.8 4.3 4.1v2.7c-1.7-.1-3.2-.7-4.3-1.7V2.9Z" />
</svg>
) : (
<b>小红书</b>
)}
</span>
{!logoOnly && <span>{platform}</span>}
</span>
);
}
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<Blob | null>((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 (
<div
className="image-lightbox"
role="dialog"
aria-modal="true"
aria-label={image.alt}
onMouseDown={onClose}
>
<div
className="image-lightbox-card"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="image-lightbox-heading">
<strong>{image.alt}</strong>
<button type="button" onClick={onClose}>关闭</button>
</div>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={image.src} alt={image.alt} />
</div>
</div>
);
}
export default function Home() {
const [taskToken, setTaskToken] = useState("");
const [claimToken, setClaimToken] = useState("");
const [delegationToken, setDelegationToken] = useState("");
const [selectedId, setSelectedId] = useState("");
const [payload, setPayload] = useState<TaskPayload | null>(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<File | null>(null);
const [taskResultScreenshots, setTaskResultScreenshots] = useState<File[]>([]);
const [creatorScreenshot, setCreatorScreenshot] = useState<File | null>(null);
const [creatorExposure, setCreatorExposure] = useState("");
const [creatorViews, setCreatorViews] = useState("");
const [entryMode, setEntryMode] = useState<"claim" | "recover">("claim");
const [recoveredClaims, setRecoveredClaims] = useState<RecoveredClaim[]>([]);
const [adminOrigin, setAdminOrigin] = useState("");
const [downloadingImage, setDownloadingImage] = useState<number | null>(null);
const [batchDownloading, setBatchDownloading] = useState(false);
const [batchWorkbookWorking, setBatchWorkbookWorking] = useState(false);
const [creatorWorking, setCreatorWorking] = useState(false);
const [creatorStage, setCreatorStage] = useState("");
const [selectedForShare, setSelectedForShare] = useState<string[]>([]);
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<HTMLFormElement | null>(null);
const batchWorkbookInputRef = useRef<HTMLInputElement | null>(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<HTMLInputElement>) => {
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<void>((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<HTMLInputElement>) => {
setScreenshot(event.target.files?.[0] ?? null);
};
const chooseTaskResultScreenshots = (event: ChangeEvent<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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<string, string> = {
"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 (
<main className="portal-shell loading-shell">
<div className="loading-mark">K</div>
<p>正在打开任务…</p>
</main>
);
}
if (error || !payload) {
return (
<main className="portal-shell">
<section className="message-card">
<div className="message-icon">!</div>
<p className="micro">KOC LOOP</p>
<h1>暂时无法打开任务</h1>
<p>{error || "任务不存在"}</p>
<button onClick={() => window.location.reload()}>重新加载</button>
</section>
</main>
);
}
if (selected && activeBatch) {
if (payload.task.type === "screenshot_collect") {
return (
<main className="portal-shell detail-shell">
<header className="portal-topbar">
<div className="portal-brand"><span>K</span><strong>KOC LOOP</strong></div>
<div className="claimant-chip">
{payload.claim ? payload.claim.claimantName : "合作社转派 · 无需登录"}
</div>
</header>
<button className="back-link" onClick={closeNote}>← 返回我的任务</button>
<div className="detail-grid screenshot-task-detail">
<article className="note-document">
<div className="note-document-meta">
<span>截图任务 {activeBatch.assignments.findIndex((item) => item.id === selected.id) + 1}</span>
<span>只需上传结果截图</span>
</div>
<div className="document-label-row">
<p className="document-label">小红书搜索关键词</p>
<PlatformBadge platform={payload.task.platform} compact />
</div>
<div className="note-title-row">
<h1>{selected.title}</h1>
<button
type="button"
className="inline-copy"
onClick={() => void copyText(selected.title, "搜索关键词已复制")}
>
复制关键词
</button>
</div>
<div className="document-rule" />
<p className="document-label">任务说明</p>
<div className="note-body">{selected.body}</div>
{selected.images.length > 0 && (
<section className="note-images">
<div className="note-images-heading">
<div>
<p className="document-label">示例截图</p>
<span>请参考示例,确保小红书 AI 总结完整可见</span>
</div>
</div>
<div className="note-image-grid">
{selected.images.map((image, index) => (
<div className="note-image-card" key={image.index}>
<a className="note-image-preview" href={noteImageUrl(selected, image.index)} target="_blank" rel="noreferrer">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={noteImageUrl(selected, image.index)} alt={`示例截图 ${index + 1}`} />
</a>
</div>
))}
</div>
</section>
)}
</article>
<form className="submit-card" onSubmit={submitScreenshotTask}>
<div className="submit-heading">
<div>
<p className="micro">截图回收</p>
<h2>上传小红书 AI 总结截图</h2>
</div>
<span className={selected.result_submitted_at ? "done" : "pending"}>
{statusLabel(selected, payload.task.type)}
</span>
</div>
<div className="mapping-note">
<span>1 : N</span>
<p>这些截图只会绑定到当前任务,不需要填写发布链接或其他数据。</p>
</div>
<label>
<span className="task-result-label-row">
<span>任务结果截图</span>
<small>
已选择 {resultScreenshotKeys(selected.result_screenshot_key).length + taskResultScreenshots.length}/{MAX_TASK_RESULT_SCREENSHOTS} 张
</small>
</span>
<div className="task-result-gallery">
{resultScreenshotKeys(selected.result_screenshot_key).map((_, index) => {
const imageUrl = taskResultImageUrl(selected, index + 1);
return (
<button
type="button"
className="task-result-thumb uploaded"
onClick={() =>
setPreviewImage({
src: imageUrl,
alt: `已上传任务截图 ${index + 1}`,
})
}
key={`uploaded-${index}`}
>
<img src={imageUrl} alt={`已上传任务截图 ${index + 1}`} />
<span>已上传</span>
</button>
)})}
{taskResultPreviews.map(({ file, url }, index) => (
<div className="task-result-thumb pending-upload" key={`${file.name}-${file.lastModified}`}>
<img src={url} alt={`待上传任务截图 ${index + 1}`} />
<button
type="button"
aria-label={`移除 ${file.name}`}
onClick={() => removeTaskResultScreenshot(file)}
>
×
</button>
<span>待提交</span>
</div>
))}
{resultScreenshotKeys(selected.result_screenshot_key).length + taskResultScreenshots.length < MAX_TASK_RESULT_SCREENSHOTS && (
<div className="task-result-add">
<input
type="file"
accept="image/*"
multiple
onChange={chooseTaskResultScreenshots}
/>
<b>+</b>
<strong>
{taskResultScreenshots.length || selected.result_screenshot_key
? "继续添加"
: "选择截图"}
</strong>
</div>
)}
</div>
<small className="task-result-hint">支持一次选择或继续追加,最多9张,每张不超过8MB;点击图片可在当前页面查看大图。</small>
</label>
<button className="submit-primary" disabled={working}>
{working
? "正在提交…"
: selected.result_submitted_at
? "更新任务截图"
: "确认提交任务截图"}
</button>
{selected.result_submitted_at && (
<p className="submitted-at">上次提交:{formatDate(selected.result_submitted_at, true)}</p>
)}
</form>
</div>
<ImageLightbox image={previewImage} onClose={() => setPreviewImage(null)} />
{toast && <div className={toastClassName(toast)}>{toast}</div>}
</main>
);
}
const creatorDueAt = creatorScreenshotDueAt(selected.publish_time);
const creatorDataReady =
Boolean(selected.creator_screenshot_key) &&
selected.exposure !== null &&
selected.views !== null;
return (
<main className="portal-shell detail-shell">
<header className="portal-topbar">
<div className="portal-brand"><span>K</span><strong>KOC LOOP</strong></div>
<div className="claimant-chip">
{payload.claim ? payload.claim.claimantName : "合作社转派 · 无需登录"}
</div>
</header>
<button className="back-link" onClick={closeNote}>← 返回我的笔记</button>
<div className="detail-grid">
<article
className={`note-document ${
noteContentCollapsed ? "mobile-collapsed" : ""
}`}
>
<div className="mobile-note-summary">
<div>
<span>笔记内容已收起</span>
<strong>{selected.title}</strong>
<small>
{selected.videos.length > 0
? `${selected.videos.length} 个视频`
: `${selected.images.length} 张配图`} · 已完成首次回填
</small>
</div>
<button
type="button"
aria-expanded={!noteContentCollapsed}
onClick={() => setNoteContentCollapsed(false)}
>
展开笔记内容
</button>
</div>
<div className="note-document-content">
<div className="note-document-meta">
<span>笔记 {activeBatch.assignments.findIndex((item) => item.id === selected.id) + 1}</span>
<PlatformBadge platform={payload.task.platform} compact />
<span>飞书源行 {selected.source_row ?? "—"}</span>
</div>
{selected.publish_url && (
<button
type="button"
className="mobile-note-collapse-trigger"
onClick={() => setNoteContentCollapsed(true)}
>
收起笔记内容
</button>
)}
<div className="note-title-row">
<h1>{selected.title}</h1>
<button
type="button"
className="inline-copy"
onClick={() => void copyText(selected.title, "标题已复制")}
>
复制标题
</button>
</div>
<div className="document-rule" />
<div className="document-label-row">
<p className="document-label">正文与话题</p>
<button
type="button"
className="inline-copy"
onClick={() => void copyText(selected.body, "文案已复制")}
>
复制文案
</button>
</div>
<div className="note-body">{selected.body || "暂无正文"}</div>
{selected.images.length > 0 && (
<section className="note-images">
<div className="note-images-heading">
<div>
<p className="document-label">发布配图</p>
<span>手机优先打开系统保存菜单,其他浏览器会下载文件</span>
</div>
<div className="note-images-tools">
<b>{selected.images.length} 张</b>
{selected.images.length > 1 && (
<button
type="button"
disabled={batchDownloading}
onClick={() => void downloadAllImages(selected)}
>
{batchDownloading ? "正在准备…" : "批量保存图片"}
</button>
)}
</div>
</div>
<div className="note-image-grid">
{selected.images.map((image, index) => (
<div className="note-image-card" key={image.index}>
<a
className="note-image-preview"
href={noteImageUrl(selected, image.index)}
target="_blank"
rel="noreferrer"
>
{/* Private claim-bound images intentionally bypass the public image optimizer. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={noteImageUrl(selected, image.index)}
alt={`${selected.title} 配图 ${index + 1}`}
width={image.width ?? undefined}
height={image.height ?? undefined}
loading={index === 0 ? "eager" : "lazy"}
/>
</a>
<div className="note-image-actions">
<span>图片 {index + 1}</span>
<button
type="button"
disabled={downloadingImage === image.index}
onClick={() => void downloadImage(selected, image.index)}
>
{downloadingImage === image.index ? "下载中…" : "下载原图"}
</button>
</div>
</div>
))}
</div>
</section>
)}
{selected.videos.length > 0 && (
<section className="note-videos">
<div className="note-images-heading">
<div>
<p className="document-label">发布视频</p>
<span>点击播放,可使用播放器菜单保存原视频</span>
</div>
<b>{selected.videos.length} 个</b>
</div>
<div className="note-video-grid">
{selected.videos.map((video, index) => (
<div className="note-video-card" key={video.index}>
<video
controls
preload="metadata"
playsInline
src={noteVideoUrl(selected, video.index)}
/>
<div className="note-image-actions">
<span>视频 {index + 1}</span>
<a
href={noteVideoUrl(selected, video.index, true)}
download={`${safeFileBase(selected)}-视频-${index + 1}.mp4`}
>
下载视频
</a>
</div>
</div>
))}
</div>
</section>
)}
</div>
</article>
<form ref={submitCardRef} className="submit-card" onSubmit={submitNote}>
<div className="submit-heading">
<div>
<p className="micro">逐篇回填</p>
<h2>填写这篇笔记的发布信息</h2>
</div>
<span className={selected.publish_url ? "done" : "pending"}>
{statusLabel(selected)}
</span>
</div>
<div className="mapping-note">
<span>1 : 1</span>
<p>以下信息只会绑定到当前笔记,不按顺序批量匹配。</p>
</div>
<label>
<span>发布链接</span>
<input
type="text"
inputMode="url"
value={publishUrl}
onChange={(event) => setPublishUrl(event.target.value)}
placeholder={`可粘贴${payload.task.platform}作品链接或整段分享文案`}
required
/>
<small className="field-hint">
系统会自动提取链接,并识别发布账号
</small>
</label>
<div className="evidence-field">
<span>发布截图</span>
<div
className={`screenshot-picker ${
screenshot || selected.publish_screenshot_key
? "has-file preview-mode"
: ""
}`}
>
{screenshot || selected.publish_screenshot_key ? (
<button
type="button"
className="evidence-preview-button"
onClick={() =>
setPreviewImage({
src:
publishScreenshotPreview ||
evidenceImageUrl(selected, "publish"),
alt: "发布截图",
})
}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={
publishScreenshotPreview ||
evidenceImageUrl(selected, "publish")
}
alt="发布截图"
/>
<span>点击查看大图</span>
</button>
) : (
<div className="evidence-empty-state">
<b>+</b>
<strong>点击上传这篇笔记的发布截图</strong>
<small>支持 JPG、PNG,最大8MB</small>
</div>
)}
<label className="evidence-upload-action">
<input type="file" accept="image/*" onChange={chooseScreenshot} />
{screenshot || selected.publish_screenshot_key
? "重新选择"
: "选择图片"}
</label>
</div>
</div>
<button className="submit-primary" disabled={working}>
{working ? "正在提交…" : selected.publish_url ? "更新这篇笔记" : "确认回填这篇笔记"}
</button>
{selected.publish_time && (
<p className="submitted-at">上次回填:{formatDate(selected.publish_time, true)}</p>
)}
{selected.publish_url && (
<section className="creator-recovery">
<div className="creator-recovery-heading">
<div>
<p className="micro">第7天数据回收</p>
<h3>上传截图并填写数据</h3>
</div>
<span className={creatorDataReady ? "done" : ""}>
{creatorDataReady ? "已提交" : "待提交"}
</span>
</div>
<p className="creator-recovery-tip">
请在发布满7天后,上传创作者中心数据页完整截图,并填写页面显示的数据。
{creatorDueAt && ` 建议上传日期:${formatDate(creatorDueAt)}。`}
</p>
<div className="evidence-field compact-field">
<span>创作者中心截图</span>
<div
className={`screenshot-picker compact ${
creatorScreenshot || selected.creator_screenshot_key
? "has-file preview-mode"
: ""
}`}
>
{creatorScreenshot || selected.creator_screenshot_key ? (
<button
type="button"
className="evidence-preview-button compact-preview"
onClick={() =>
setPreviewImage({
src:
creatorScreenshotPreview ||
evidenceImageUrl(selected, "creator"),
alt: "创作者中心截图",
})
}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={
creatorScreenshotPreview ||
evidenceImageUrl(selected, "creator")
}
alt="创作者中心截图"
/>
<span>点击查看大图</span>
</button>
) : (
<div className="evidence-empty-state">
<b>+</b>
<strong>点击选择第7天创作者中心截图</strong>
<small>截图仅用于运营核对,不再自动OCR</small>
</div>
)}
<label className="evidence-upload-action">
<input
type="file"
accept="image/*"
onChange={chooseCreatorScreenshot}
/>
{creatorScreenshot || selected.creator_screenshot_key
? "重新选择"
: "选择图片"}
</label>
</div>
</div>
<div className="creator-metric-grid">
<label>
<span>曝光量</span>
<input
type="number"
inputMode="numeric"
min="0"
step="1"
max="999999999999"
value={creatorExposure}
onChange={(event) => setCreatorExposure(event.target.value)}
placeholder="填写截图中的曝光量"
/>
</label>
<label>
<span>阅读量</span>
<input
type="number"
inputMode="numeric"
min="0"
step="1"
max="999999999999"
value={creatorViews}
onChange={(event) => setCreatorViews(event.target.value)}
placeholder="填写截图中的阅读量"
/>
</label>
</div>
<button
type="button"
className="creator-submit"
disabled={
creatorWorking ||
(!creatorScreenshot && !selected.creator_screenshot_key) ||
!creatorExposure ||
!creatorViews
}
onClick={() => void submitCreatorData()}
>
{creatorWorking
? creatorStage || "正在处理…"
: creatorDataReady
? "更新创作者数据"
: "提交创作者数据"}
</button>
</section>
)}
</form>
</div>
<ImageLightbox image={previewImage} onClose={() => setPreviewImage(null)} />
{toast && <div className={toastClassName(toast)}>{toast}</div>}
</main>
);
}
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 (
<main className="portal-shell">
<header className="portal-topbar">
<div className="portal-brand"><span>K</span><strong>KOC LOOP</strong></div>
{payload.claim ? (
<div className="claim-tools">
<button onClick={() => void showRecovery()}>找回其他批次</button>
<div className="claimant-chip">{payload.claim.claimantName}</div>
</div>
) : (
<div className="claimant-chip">合作社转派 · 无需登录</div>
)}
</header>
<section className="claimed-hero">
<div>
<p className="micro">
{isClaimOwner
? "我的领取任务"
: isScreenshotTask
? "合作社转派截图任务包"
: "合作社转派发布包"}
</p>
<h1>{payload.task.name}</h1>
<p className="platform-meta-line">
<span>{payload.task.brand}</span>
<PlatformBadge platform={payload.task.platform} compact />
<span>{formatDate(payload.task.dueAt)}前{isScreenshotTask ? "提交" : "发布"}</span>
{payload.delegation ? ` · ${payload.delegation.label}` : ""}
</p>
</div>
<div className="completion-ring">
<strong>{completed}/{activeBatch.assignments.length}</strong>
<span>已回填</span>
</div>
</section>
<section className="notes-section">
<div className="section-heading">
<div>
<h2>
{isClaimOwner
? isScreenshotTask ? "我领取的截图任务" : "我领取的笔记"
: isScreenshotTask ? "需要完成的截图任务" : "需要发布的笔记"}
</h2>
<p>
{isClaimOwner
? isScreenshotTask
? "打开一份查看关键词和要求,完成后单独上传截图;也可以转派给底层KOC"
: "可逐篇回填,也可导出Excel填写后批量上传;还可以选择笔记转派给底层KOC"
: isScreenshotTask
? "打开任务查看搜索关键词和要求,完成后逐份上传截图"
: "打开一篇查看完整内容,发布后逐篇回填"}
</p>
</div>
<div className="section-actions">
<span>{activeBatch.assignments.length} {isScreenshotTask ? "份" : "篇"}</span>
{!isScreenshotTask && (
<>
<button
type="button"
disabled={batchWorkbookWorking}
onClick={() => void exportBatchWorkbook()}
>
{batchWorkbookWorking ? "处理中…" : "导出Excel"}
</button>
<button
type="button"
disabled={batchWorkbookWorking}
onClick={() => batchWorkbookInputRef.current?.click()}
>
上传回填表
</button>
<input
ref={batchWorkbookInputRef}
className="batch-workbook-input"
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
onChange={(event) => void importBatchWorkbook(event)}
/>
</>
)}
{isClaimOwner && (
<button
type="button"
className={shareComposerOpen ? "active" : ""}
disabled={shareableCount === 0 && !shareComposerOpen}
onClick={() => {
setShareComposerOpen((current) => !current);
setSelectedForShare([]);
setShareLabel("");
}}
>
{shareComposerOpen ? "取消选择" : isScreenshotTask ? "选择任务并分享" : "选择笔记并分享"}
</button>
)}
</div>
</div>
{payload.claim && shareComposerOpen && (
<form className="share-composer" onSubmit={createDelegation}>
<div>
<p className="micro">合作社转派</p>
<h3>生成免登录分享链接</h3>
<p>
{isScreenshotTask
? "勾选待提交任务,底层KOC打开链接即可查看要求并上传截图。"
: "勾选待发布笔记,底层KOC打开链接即可查看内容并逐篇回填。"}
</p>
</div>
<label>
<span>接收人备注</span>
<input
value={shareLabel}
maxLength={24}
onChange={(event) => setShareLabel(event.target.value)}
placeholder="例如:A、小王"
required
/>
</label>
<div className="share-composer-action">
<strong>已选 {selectedForShare.length} {isScreenshotTask ? "份" : "篇"}</strong>
<button
className="share-primary"
disabled={
shareWorking ||
selectedForShare.length === 0 ||
!shareLabel.trim()
}
>
{shareWorking ? "生成中…" : "生成并复制分享链接"}
</button>
</div>
<small>
对方无需登录,也不会填写手机号、微信或企微;分享链接可用于后续第7天数据回收。
</small>
</form>
)}
<div className="note-list">
{activeBatch.assignments.map((item, index) => {
const shareDisabled = Boolean(
(isScreenshotTask ? item.result_submitted_at : item.publish_url) ||
item.delegation_bundle_id,
);
return (
<article
key={item.id}
className={`note-row ${shareComposerOpen && isClaimOwner ? "share-selecting" : ""}`}
>
{shareComposerOpen && isClaimOwner && (
<label
className={`share-checkbox ${shareDisabled ? "disabled" : ""}`}
title={
(isScreenshotTask ? item.result_submitted_at : item.publish_url)
? isScreenshotTask ? "已提交任务不能转派" : "已发布笔记不能转派"
: item.delegation_bundle_id
? `已转派给 ${item.delegation_label}`
: "选择这篇笔记"
}
>
<input
type="checkbox"
checked={selectedForShare.includes(item.id)}
disabled={shareDisabled}
onChange={() => toggleShareSelection(item.id)}
aria-label={`选择笔记:${item.title}`}
/>
<span>✓</span>
</label>
)}
<span className={`note-index ${(isScreenshotTask ? item.result_submitted_at : item.publish_url) ? "done" : ""}`}>
{String(index + 1).padStart(2, "0")}
</span>
{item.images[0] ? (
<>
{/* Private claim-bound images intentionally bypass the public image optimizer. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
className="note-thumb"
src={noteImageUrl(item, item.images[0].index)}
alt=""
loading="lazy"
/>
</>
) : item.videos.length > 0 ? (
<span className="note-thumb platform-video-thumb">
<PlatformBadge platform={payload.task.platform} logoOnly />
</span>
) : (
<span className="note-thumb empty">无图</span>
)}
<div>
<h3>{item.title}</h3>
<p>
{isScreenshotTask
? `搜索关键词 · ${statusLabel(item, payload.task.type)}`
: <>
<span>{item.videos.length > 0 ? `${item.videos.length} 个视频` : `${item.images.length} 张配图`}</span>
<PlatformBadge platform={payload.task.platform} compact />
<span>飞书源行 {item.source_row ?? "—"} · {statusLabel(item, payload.task.type)}</span>
</>}
{!isScreenshotTask && item.creator_screenshot_key ? " · D7截图已交" : ""}
{item.delegation_label ? ` · 已转派给 ${item.delegation_label}` : ""}
</p>
</div>
<span
className={`note-status ${
(isScreenshotTask ? item.result_submitted_at : item.publish_url)
? "done"
: item.delegation_bundle_id
? "delegated"
: ""
}`}
>
{(isScreenshotTask ? item.result_submitted_at : item.publish_url)
? isScreenshotTask ? "已提交" : "已回填"
: item.delegation_bundle_id
? "已转派"
: isScreenshotTask ? "待提交" : "待发布"}
</span>
<button onClick={() => openNote(item)}>
{(isScreenshotTask ? item.result_submitted_at : item.publish_url)
? "查看 / 修改"
: isScreenshotTask ? "查看并上传" : "查看并回填"} →
</button>
</article>
);
})}
</div>
{payload.claim && payload.claim.delegations.length > 0 && (
<section className="delegation-history">
<div className="delegation-heading">
<div>
<p className="micro">分享记录</p>
<h3>{isScreenshotTask ? "转派截图任务包" : "转派发布包"}</h3>
</div>
<span>{payload.claim.delegations.length} 个</span>
</div>
<div className="delegation-list">
{payload.claim.delegations.map((bundle) => (
<article
key={bundle.id}
className={bundle.status === "revoked" ? "revoked" : ""}
>
<div>
<strong>{bundle.label}</strong>
<span>
{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)}`}
</span>
</div>
<small>{formatDate(bundle.created_at, true)}</small>
<span className={`delegation-status ${bundle.status}`}>
{bundle.status === "active" ? "分享中" : "已撤销"}
</span>
{bundle.status === "active" && (
<div className="delegation-actions">
<button
type="button"
onClick={() =>
void copyText(
delegationUrl(bundle.share_token),
`已复制给 ${bundle.label} 的分享链接`,
)
}
>
复制链接
</button>
{Number(isScreenshotTask ? bundle.result_completed_count ?? bundle.completed_count : bundle.completed_count ?? 0) === 0 && (
<button
type="button"
className="danger"
disabled={shareWorking}
onClick={() => void revokeDelegation(bundle)}
>
撤销
</button>
)}
</div>
)}
</article>
))}
</div>
</section>
)}
</section>
<div className="safe-tip">
{isClaimOwner
? "以后再次打开原任务链接,填写相同微信号或手机号即可找回全部领取批次和分享记录。"
: isScreenshotTask
? "请保存当前分享链接;完成任务后通过此链接上传截图即可。"
: "请保存当前分享链接;发布后仍需通过此链接回填第7天截图、曝光量和阅读量。"}
</div>
{toast && <div className={toastClassName(toast)}>{toast}</div>}
</main>
);
}
return (
<main className="portal-shell">
<header className="portal-topbar">
<div className="portal-brand"><span>K</span><strong>KOC LOOP</strong></div>
<span className="external-label">KOC领取页</span>
</header>
<section className="task-hero">
<div className="hero-copy">
<div className="hero-platform-line">
<p className="micro">正在招募 · {payload.task.brand}</p>
<PlatformBadge platform={payload.task.platform} />
</div>
<h1>{payload.task.name}</h1>
<p>
{payload.task.type === "screenshot_collect"
? "领取后查看搜索关键词和任务要求,完成后逐份上传截图。"
: "领取后可查看完整笔记正文,每篇笔记独立回填发布信息。"}
</p>
<div className="hero-meta">
<span>截止 {formatDate(payload.task.dueAt)}</span>
<span>剩余 {payload.task.availableQuantity} {payload.task.type === "screenshot_collect" ? "份" : "篇"}</span>
</div>
</div>
<aside className="claim-card">
<div className="entry-switch" aria-label="领取入口">
<button
type="button"
className={entryMode === "claim" ? "active" : ""}
onClick={() => {
setEntryMode("claim");
setRecoveredClaims([]);
}}
>
{payload.task.type === "screenshot_collect" ? "领取新任务" : "领取新笔记"}
</button>
<button
type="button"
className={entryMode === "recover" ? "active" : ""}
onClick={() => {
setEntryMode("recover");
setRecoveredClaims([]);
}}
>
找回领取记录
</button>
</div>
{entryMode === "claim" ? (
<form onSubmit={claimNotes}>
<div>
<p className="micro">01 · 确认领取</p>
<h2>你要领取多少{payload.task.type === "screenshot_collect" ? "份" : "篇"}?</h2>
</div>
<label>
<span>微信号 / 手机号</span>
<input
value={claimantName}
onChange={(event) => setClaimantName(event.target.value)}
placeholder="请输入微信号或手机号"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
maxLength={64}
required
/>
</label>
<div className="quantity-control">
<button type="button" onClick={() => setQuantity(Math.max(1, quantity - 1))}>−</button>
<div><strong>{quantity}</strong><span>{payload.task.type === "screenshot_collect" ? "份" : "篇"}</span></div>
<button
type="button"
onClick={() =>
setQuantity(Math.min(payload.task.availableQuantity || 1, quantity + 1))
}
>
+
</button>
</div>
<button
className="claim-primary"
disabled={working || payload.task.availableQuantity === 0}
>
{payload.task.availableQuantity === 0
? "当前任务已领完"
: working
? "领取中…"
: `确认领取 ${quantity} ${payload.task.type === "screenshot_collect" ? "份" : "篇"}`}
</button>
<small>领取后系统会生成你的专属{payload.task.type === "screenshot_collect" ? "任务清单" : "笔记清单"}</small>
</form>
) : (
<form onSubmit={recoverClaims}>
<div>
<p className="micro">找回历史记录</p>
<h2>继续之前领取的{payload.task.type === "screenshot_collect" ? "任务" : "笔记"}</h2>
<p className="recover-intro">
输入领取时使用的微信号或手机号;同一任务多次领取会分批展示。
</p>
</div>
<label>
<span>领取时的微信号 / 手机号</span>
<input
value={claimantName}
onChange={(event) => setClaimantName(event.target.value)}
placeholder="需与领取时完全一致"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
maxLength={64}
required
/>
</label>
<button className="claim-primary" disabled={working}>
{working ? "正在查找…" : "查找我的领取记录"}
</button>
{recoveredClaims.length > 0 && (
<div className="recovery-list">
{recoveredClaims.map((claim, index) => (
<button
type="button"
key={claim.claimToken}
onClick={() => void openRecoveredClaim(claim.claimToken)}
>
<span>第 {recoveredClaims.length - index} 批 · {formatDate(claim.createdAt, true)}</span>
<strong>{claim.firstTitle}</strong>
<small>{claim.completedCount}/{claim.quantity} 已回填</small>
<i>继续填写 →</i>
</button>
))}
</div>
)}
</form>
)}
</aside>
</section>
<section className="steps">
<div><span>1</span><strong>选择数量</strong><p>一次可领多篇</p></div>
<i />
<div><span>2</span><strong>{payload.task.type === "screenshot_collect" ? "逐份查看" : "逐篇查看"}</strong><p>{payload.task.type === "screenshot_collect" ? "关键词与要求完整展示" : "标题正文完整展示"}</p></div>
<i />
<div><span>3</span><strong>{payload.task.type === "screenshot_collect" ? "上传截图" : "单篇回填"}</strong><p>{payload.task.type === "screenshot_collect" ? "每份任务与截图一一对应" : "昵称、链接、截图一一对应"}</p></div>
</section>
<footer>由 KOC LOOP 提供任务与数据安全保障</footer>
{toast && <div className={toastClassName(toast)}>{toast}</div>}
</main>
);
}