Files
koc-loop/koc-portal/app/page.tsx

2111 lines
79 KiB
TypeScript
Raw Permalink Normal View History

2026-07-30 12:06:41 +08:00
"use client";
import { zipSync } from "fflate";
import { ChangeEvent, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
2026-07-30 12:06:41 +08:00
import {
formatShanghaiDate as formatDate,
parseStoredDate,
} from "./date-utils";
import {
compactPartnerBatchWorkbookForUpload,
PARTNER_BATCH_UPLOAD_MAX_BYTES,
} from "./batch-workbook-upload";
2026-07-30 12:06:41 +08:00
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;
2026-07-30 12:06:41 +08:00
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;
}>;
2026-07-30 12:06:41 +08:00
};
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;
2026-07-30 12:06:41 +08:00
};
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";
2026-07-30 12:06:41 +08:00
};
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;
}
2026-07-30 12:06:41 +08:00
function partnerApi(
path:
| "/api/partner"
| "/api/partner-upload"
| "/api/partner-batch-workbook",
) {
return `${resolveAdminOrigin()}${path}`;
2026-07-30 12:06:41 +08:00
}
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 ? "已提交" : "待提交";
}
2026-07-30 12:06:41 +08:00
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 [];
}
}
2026-07-30 12:06:41 +08:00
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>
);
}
2026-07-30 12:06:41 +08:00
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>
);
}
2026-07-30 12:06:41 +08:00
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[]>([]);
2026-07-30 12:06:41 +08:00
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("");
2026-07-30 12:06:41 +08:00
const [downloadingImage, setDownloadingImage] = useState<number | null>(null);
const [batchDownloading, setBatchDownloading] = useState(false);
const [batchWorkbookWorking, setBatchWorkbookWorking] = useState(false);
2026-07-30 12:06:41 +08:00
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],
);
2026-07-30 12:06:41 +08:00
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());
2026-07-30 12:06:41 +08:00
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([]);
2026-07-30 12:06:41 +08:00
setCreatorScreenshot(null);
setCreatorExposure(
selected.exposure === null ? "" : String(selected.exposure),
);
setCreatorViews(selected.views === null ? "" : String(selected.views));
setNoteContentCollapsed(
Boolean(selected.publish_url) && isMobilePortalViewport(),
);
2026-07-30 12:06:41 +08:00
}, 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,
2026-07-30 12:06:41 +08:00
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} 篇,逐篇发布即可`,
);
2026-07-30 12:06:41 +08:00
} 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,
2026-07-30 12:06:41 +08:00
}),
});
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}`;
};
2026-07-30 12:06:41 +08:00
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 = "";
2026-07-30 12:06:41 +08:00
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);
}
};
2026-07-30 12:06:41 +08:00
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),
);
};
2026-07-30 12:06:41 +08:00
const chooseCreatorScreenshot = (event: ChangeEvent<HTMLInputElement>) => {
setCreatorScreenshot(event.target.files?.[0] ?? null);
};
const uploadEvidence = async (
selectedItem: Assignment,
file: File,
kind: "publish" | "creator-center" | "task-result",
2026-07-30 12:06:41 +08:00
) => {
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"
? "任务截图上传失败"
2026-07-30 12:06:41 +08:00
: "发布截图上传失败"),
);
}
};
const submitNote = async (event: FormEvent) => {
event.preventDefault();
if (!selected) return;
const isFirstBackfill = !selected.publish_url;
2026-07-30 12:06:41 +08:00
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);
}
2026-07-30 12:06:41 +08:00
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);
}
};
2026-07-30 12:06:41 +08:00
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>
);
}
2026-07-30 12:06:41 +08:00
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">
2026-07-30 12:06:41 +08:00
<div className="note-document-meta">
<span> {activeBatch.assignments.findIndex((item) => item.id === selected.id) + 1}</span>
<PlatformBadge platform={payload.task.platform} compact />
2026-07-30 12:06:41 +08:00
<span> {selected.source_row ?? "—"}</span>
</div>
{selected.publish_url && (
<button
type="button"
className="mobile-note-collapse-trigger"
onClick={() => setNoteContentCollapsed(true)}
>
</button>
)}
2026-07-30 12:06:41 +08:00
<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>
2026-07-30 12:06:41 +08:00
</article>
<form ref={submitCardRef} className="submit-card" onSubmit={submitNote}>
2026-07-30 12:06:41 +08:00
<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}作品链接或整段分享文案`}
2026-07-30 12:06:41 +08:00
required
/>
<small className="field-hint">
2026-07-30 12:06:41 +08:00
</small>
</label>
<div className="evidence-field">
2026-07-30 12:06:41 +08:00
<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>
2026-07-30 12:06:41 +08:00
</div>
</div>
2026-07-30 12:06:41 +08:00
<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">
2026-07-30 12:06:41 +08:00
<span></span>
<div
className={`screenshot-picker compact ${
creatorScreenshot || selected.creator_screenshot_key
? "has-file preview-mode"
2026-07-30 12:06:41 +08:00
: ""
}`}
>
{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}
/>
2026-07-30 12:06:41 +08:00
{creatorScreenshot || selected.creator_screenshot_key
? "重新选择"
: "选择图片"}
</label>
2026-07-30 12:06:41 +08:00
</div>
</div>
2026-07-30 12:06:41 +08:00
<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)} />
2026-07-30 12:06:41 +08:00
{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;
2026-07-30 12:06:41 +08:00
const isClaimOwner = Boolean(payload.claim);
const shareableCount = payload.claim?.assignments.filter(
(item) =>
!(isScreenshotTask ? item.result_submitted_at : item.publish_url) &&
!item.delegation_bundle_id,
2026-07-30 12:06:41 +08:00
).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
? "合作社转派截图任务包"
: "合作社转派发布包"}
2026-07-30 12:06:41 +08:00
</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>
2026-07-30 12:06:41 +08:00
{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>
2026-07-30 12:06:41 +08:00
<p>
{isClaimOwner
? isScreenshotTask
? "打开一份查看关键词和要求完成后单独上传截图也可以转派给底层KOC"
: "可逐篇回填也可导出Excel填写后批量上传还可以选择笔记转派给底层KOC"
: isScreenshotTask
? "打开任务查看搜索关键词和要求,完成后逐份上传截图"
: "打开一篇查看完整内容,发布后逐篇回填"}
2026-07-30 12:06:41 +08:00
</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)}
/>
</>
)}
2026-07-30 12:06:41 +08:00
{isClaimOwner && (
<button
type="button"
className={shareComposerOpen ? "active" : ""}
disabled={shareableCount === 0 && !shareComposerOpen}
onClick={() => {
setShareComposerOpen((current) => !current);
setSelectedForShare([]);
setShareLabel("");
}}
>
{shareComposerOpen ? "取消选择" : isScreenshotTask ? "选择任务并分享" : "选择笔记并分享"}
2026-07-30 12:06:41 +08:00
</button>
)}
</div>
</div>
{payload.claim && shareComposerOpen && (
<form className="share-composer" onSubmit={createDelegation}>
<div>
<p className="micro"></p>
<h3></h3>
<p>
{isScreenshotTask
? "勾选待提交任务底层KOC打开链接即可查看要求并上传截图。"
: "勾选待发布笔记底层KOC打开链接即可查看内容并逐篇回填。"}
2026-07-30 12:06:41 +08:00
</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>
2026-07-30 12:06:41 +08:00
<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,
2026-07-30 12:06:41 +08:00
);
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 ? "已提交任务不能转派" : "已发布笔记不能转派"
2026-07-30 12:06:41 +08:00
: 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" : ""}`}>
2026-07-30 12:06:41 +08:00
{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>
2026-07-30 12:06:41 +08:00
) : (
<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截图已交" : ""}
2026-07-30 12:06:41 +08:00
{item.delegation_label ? ` · 已转派给 ${item.delegation_label}` : ""}
</p>
</div>
<span
className={`note-status ${
(isScreenshotTask ? item.result_submitted_at : item.publish_url)
2026-07-30 12:06:41 +08:00
? "done"
: item.delegation_bundle_id
? "delegated"
: ""
}`}
>
{(isScreenshotTask ? item.result_submitted_at : item.publish_url)
? isScreenshotTask ? "已提交" : "已回填"
2026-07-30 12:06:41 +08:00
: item.delegation_bundle_id
? "已转派"
: isScreenshotTask ? "待提交" : "待发布"}
2026-07-30 12:06:41 +08:00
</span>
<button onClick={() => openNote(item)}>
{(isScreenshotTask ? item.result_submitted_at : item.publish_url)
? "查看 / 修改"
: isScreenshotTask ? "查看并上传" : "查看并回填"}
2026-07-30 12:06:41 +08:00
</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>
2026-07-30 12:06:41 +08:00
</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)}`}
2026-07-30 12:06:41 +08:00
</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 && (
2026-07-30 12:06:41 +08:00
<button
type="button"
className="danger"
disabled={shareWorking}
onClick={() => void revokeDelegation(bundle)}
>
</button>
)}
</div>
)}
</article>
))}
</div>
</section>
)}
</section>
<div className="safe-tip">
{isClaimOwner
? "以后再次打开原任务链接,填写相同微信号或手机号即可找回全部领取批次和分享记录。"
: isScreenshotTask
? "请保存当前分享链接;完成任务后通过此链接上传截图即可。"
: "请保存当前分享链接发布后仍需通过此链接回填第7天截图、曝光量和阅读量。"}
2026-07-30 12:06:41 +08:00
</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>
2026-07-30 12:06:41 +08:00
<h1>{payload.task.name}</h1>
<p>
{payload.task.type === "screenshot_collect"
? "领取后查看搜索关键词和任务要求,完成后逐份上传截图。"
: "领取后可查看完整笔记正文,每篇笔记独立回填发布信息。"}
</p>
2026-07-30 12:06:41 +08:00
<div className="hero-meta">
<span> {formatDate(payload.task.dueAt)}</span>
<span> {payload.task.availableQuantity} {payload.task.type === "screenshot_collect" ? "份" : "篇"}</span>
2026-07-30 12:06:41 +08:00
</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" ? "领取新任务" : "领取新笔记"}
2026-07-30 12:06:41 +08:00
</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>
2026-07-30 12:06:41 +08:00
</div>
<label>
<span> / </span>
2026-07-30 12:06:41 +08:00
<input
value={claimantName}
onChange={(event) => setClaimantName(event.target.value)}
placeholder="请输入微信号或手机号"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
maxLength={64}
2026-07-30 12:06:41 +08:00
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>
2026-07-30 12:06:41 +08:00
<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" ? "份" : "篇"}`}
2026-07-30 12:06:41 +08:00
</button>
<small>{payload.task.type === "screenshot_collect" ? "任务清单" : "笔记清单"}</small>
2026-07-30 12:06:41 +08:00
</form>
) : (
<form onSubmit={recoverClaims}>
<div>
<p className="micro"></p>
<h2>{payload.task.type === "screenshot_collect" ? "任务" : "笔记"}</h2>
2026-07-30 12:06:41 +08:00
<p className="recover-intro">
使
2026-07-30 12:06:41 +08:00
</p>
</div>
<label>
<span> / </span>
2026-07-30 12:06:41 +08:00
<input
value={claimantName}
onChange={(event) => setClaimantName(event.target.value)}
placeholder="需与领取时完全一致"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
maxLength={64}
2026-07-30 12:06:41 +08:00
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>
2026-07-30 12:06:41 +08:00
<i />
<div><span>3</span><strong>{payload.task.type === "screenshot_collect" ? "上传截图" : "单篇回填"}</strong><p>{payload.task.type === "screenshot_collect" ? "每份任务与截图一一对应" : "昵称、链接、截图一一对应"}</p></div>
2026-07-30 12:06:41 +08:00
</section>
<footer> KOC LOOP </footer>
{toast && <div className="toast">{toast}</div>}
</main>
);
}