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

1360 lines
48 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, useState } from "react";
import {
formatShanghaiDate as formatDate,
parseStoredDate,
} from "./date-utils";
type Assignment = {
id: string;
status: string;
publish_url: string | null;
publish_time: string | null;
publish_screenshot_key: string | null;
creator_screenshot_key: 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;
}>;
};
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;
};
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;
};
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 PRODUCTION_ADMIN_ORIGIN =
"https://koc-loop-mvp-wufp.pyeongwu.chatgpt.site";
function partnerApi(path: "/api/partner" | "/api/partner-upload") {
const origin =
window.location.hostname === "localhost"
? "http://localhost:3001"
: PRODUCTION_ADMIN_ORIGIN;
return `${origin}${path}`;
}
function statusLabel(item: Assignment) {
return item.publish_url ? "已回填" : "待发布";
}
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 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",
});
}
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 [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(PRODUCTION_ADMIN_ORIGIN);
const [downloadingImage, setDownloadingImage] = useState<number | null>(null);
const [batchDownloading, setBatchDownloading] = 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 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(() => {
if (window.location.hostname === "localhost") {
setAdminOrigin("http://localhost:3001");
}
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);
setCreatorScreenshot(null);
setCreatorExposure(
selected.exposure === null ? "" : String(selected.exposure),
);
setCreatorViews(selected.views === null ? "" : String(selected.views));
}, 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(`已领取 ${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,
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 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.origin);
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 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 chooseCreatorScreenshot = (event: ChangeEvent<HTMLInputElement>) => {
setCreatorScreenshot(event.target.files?.[0] ?? null);
};
const uploadEvidence = async (
selectedItem: Assignment,
file: File,
kind: "publish" | "creator-center",
) => {
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"
? "创作者中心截图上传失败"
: "发布截图上传失败"),
);
}
};
const submitNote = async (event: FormEvent) => {
event.preventDefault();
if (!selected) return;
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);
setToast("这篇笔记已回填,不会与其他笔记错配");
} 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) {
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">
<div className="note-document-meta">
<span> {activeBatch.assignments.findIndex((item) => item.id === selected.id) + 1}</span>
<span> {selected.source_row ?? "—"}</span>
</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" />
<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>
)}
</article>
<form 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="可粘贴小红书长链、短链或整段分享文案"
required
/>
<small className="field-hint">
2026-07-30 12:06:41 +08:00
</small>
</label>
<label>
<span></span>
<div className={`screenshot-picker ${screenshot || selected.publish_screenshot_key ? "has-file" : ""}`}>
<input type="file" accept="image/*" onChange={chooseScreenshot} />
<b>{screenshot ? "✓" : selected.publish_screenshot_key ? "✓" : ""}</b>
<strong>
{screenshot
? screenshot.name
: selected.publish_screenshot_key
? "发布截图已上传"
: "点击上传这篇笔记的发布截图"}
</strong>
<small> JPGPNG8MB</small>
</div>
</label>
<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>
<label>
<span></span>
<div
className={`screenshot-picker compact ${
creatorScreenshot || selected.creator_screenshot_key
? "has-file"
: ""
}`}
>
<input
type="file"
accept="image/*"
onChange={chooseCreatorScreenshot}
/>
<b>
{creatorScreenshot || selected.creator_screenshot_key
? "✓"
: ""}
</b>
<strong>
{creatorScreenshot
? creatorScreenshot.name
: selected.creator_screenshot_key
? "创作者中心截图已提交"
: "点击选择第7天创作者中心截图"}
</strong>
<small>OCR</small>
</div>
</label>
<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>
{toast && <div className="toast">{toast}</div>}
</main>
);
}
if (activeBatch) {
const completed = activeBatch.assignments.filter((item) => item.publish_url).length;
const isClaimOwner = Boolean(payload.claim);
const shareableCount = payload.claim?.assignments.filter(
(item) => !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 ? "我的领取任务" : "合作社转派发布包"}
</p>
<h1>{payload.task.name}</h1>
<p>
{payload.task.brand} · {formatDate(payload.task.dueAt)}
{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 ? "我领取的笔记" : "需要发布的笔记"}</h2>
<p>
{isClaimOwner
? "打开一篇查看内容并单独回填也可以选择笔记转派给底层KOC"
: "打开一篇查看完整内容,发布后逐篇回填"}
</p>
</div>
<div className="section-actions">
<span>{activeBatch.assignments.length} </span>
{isClaimOwner && (
<button
type="button"
className={shareComposerOpen ? "active" : ""}
disabled={shareableCount === 0 && !shareComposerOpen}
onClick={() => {
setShareComposerOpen((current) => !current);
setSelectedForShare([]);
setShareLabel("");
}}
>
{shareComposerOpen ? "取消选择" : "选择笔记并分享"}
</button>
)}
</div>
</div>
{payload.claim && shareComposerOpen && (
<form className="share-composer" onSubmit={createDelegation}>
<div>
<p className="micro"></p>
<h3></h3>
<p>
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} </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(
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={
item.publish_url
? "已发布笔记不能转派"
: 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 ${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"
/>
</>
) : (
<span className="note-thumb empty"></span>
)}
<div>
<h3>{item.title}</h3>
<p>
{item.images.length} · {item.source_row ?? "—"} · {statusLabel(item)}
{item.creator_screenshot_key ? " · D7截图已交" : ""}
{item.delegation_label ? ` · 已转派给 ${item.delegation_label}` : ""}
</p>
</div>
<span
className={`note-status ${
item.publish_url
? "done"
: item.delegation_bundle_id
? "delegated"
: ""
}`}
>
{item.publish_url
? "已回填"
: item.delegation_bundle_id
? "已转派"
: "待发布"}
</span>
<button onClick={() => openNote(item)}>
{item.publish_url ? "查看 / 修改" : "查看并回填"}
</button>
</article>
);
})}
</div>
{payload.claim && payload.claim.delegations.length > 0 && (
<section className="delegation-history">
<div className="delegation-heading">
<div>
<p className="micro"></p>
<h3></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} · {" "}
{Number(bundle.completed_count ?? 0)}/{bundle.quantity} ·
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(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
? "以后再次打开原任务链接,填写相同微信号或手机号即可找回全部领取批次和分享记录。"
2026-07-30 12:06:41 +08:00
: "请保存当前分享链接发布后仍需通过此链接回填第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">
<p className="micro"> · {payload.task.brand}</p>
<h1>{payload.task.name}</h1>
<p></p>
<div className="hero-meta">
<span> {formatDate(payload.task.dueAt)}</span>
<span> {payload.task.availableQuantity} </span>
</div>
</div>
<aside className="claim-card">
<div className="entry-switch" aria-label="领取入口">
<button
type="button"
className={entryMode === "claim" ? "active" : ""}
onClick={() => {
setEntryMode("claim");
setRecoveredClaims([]);
}}
>
</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></h2>
</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></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}`}
</button>
<small></small>
</form>
) : (
<form onSubmit={recoverClaims}>
<div>
<p className="micro"></p>
<h2></h2>
<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></strong><p></p></div>
<i />
<div><span>3</span><strong></strong><p></p></div>
</section>
<footer> KOC LOOP </footer>
{toast && <div className="toast">{toast}</div>}
</main>
);
}