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

1360 lines
48 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, 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,
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,
}),
});
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">
</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
? "以后再次打开原任务链接,填写相同微信号或手机号即可找回全部领取批次和分享记录。"
: "请保存当前分享链接发布后仍需通过此链接回填第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>
<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></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">
使
</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></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>
);
}