Files
koc-loop/koc-portal/app/page.tsx
2026-08-15 03:53:09 +08:00

2111 lines
79 KiB
TypeScript
Raw Permalink 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;
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,
});
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("这篇笔记已回填,不会与其他笔记错配");
} 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">98MB</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="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> JPGPNG8MB</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="填写截图中的曝光量"
required
/>
</label>
<label>
<span></span>
<input
type="number"
inputMode="numeric"
min="0"
step="1"
max="999999999999"
value={creatorViews}
onChange={(event) => setCreatorViews(event.target.value)}
placeholder="填写截图中的阅读量"
required
/>
</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="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="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="toast">{toast}</div>}
</main>
);
}