2026-07-30 12:06:41 +08:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
import { zipSync } from "fflate";
|
2026-08-12 20:44:57 +08:00
|
|
|
|
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";
|
|
|
|
|
|
|
|
|
|
|
|
type Assignment = {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
status: string;
|
|
|
|
|
|
publish_url: string | null;
|
|
|
|
|
|
publish_time: string | null;
|
|
|
|
|
|
publish_screenshot_key: string | null;
|
|
|
|
|
|
creator_screenshot_key: string | null;
|
2026-08-11 23:07:26 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}>;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
2026-08-11 23:07:26 +08:00
|
|
|
|
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;
|
2026-08-11 23:07:26 +08:00
|
|
|
|
type: "content_publish" | "screenshot_collect";
|
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[];
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-11 23:07:26 +08:00
|
|
|
|
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") {
|
2026-08-11 23:07:26 +08:00
|
|
|
|
return `${resolveAdminOrigin()}${path}`;
|
2026-07-30 12:06:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 20:44:57 +08:00
|
|
|
|
function isMobilePortalViewport() {
|
|
|
|
|
|
return (
|
|
|
|
|
|
typeof window !== "undefined" &&
|
|
|
|
|
|
window.matchMedia("(max-width: 620px)").matches
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 23:07:26 +08:00
|
|
|
|
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 ? "已回填" : "待发布";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 23:07:26 +08:00
|
|
|
|
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 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",
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-11 23:07:26 +08:00
|
|
|
|
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);
|
2026-08-11 23:07:26 +08:00
|
|
|
|
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[]>([]);
|
2026-08-11 23:07:26 +08:00
|
|
|
|
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 [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);
|
2026-08-11 23:07:26 +08:00
|
|
|
|
const [previewImage, setPreviewImage] = useState<{
|
|
|
|
|
|
src: string;
|
|
|
|
|
|
alt: string;
|
|
|
|
|
|
} | null>(null);
|
2026-08-12 20:44:57 +08:00
|
|
|
|
const [noteContentCollapsed, setNoteContentCollapsed] = useState(false);
|
|
|
|
|
|
const submitCardRef = useRef<HTMLFormElement | null>(null);
|
2026-08-11 23:07:26 +08:00
|
|
|
|
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(() => {
|
2026-08-11 23:07:26 +08:00
|
|
|
|
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);
|
2026-08-11 23:07:26 +08:00
|
|
|
|
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));
|
2026-08-12 20:44:57 +08:00
|
|
|
|
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,
|
2026-08-03 13:48:01 +08:00
|
|
|
|
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);
|
2026-08-11 23:07:26 +08:00
|
|
|
|
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,
|
2026-08-03 13:48:01 +08:00
|
|
|
|
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}`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-11 23:07:26 +08:00
|
|
|
|
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) => {
|
2026-08-12 19:53:19 +08:00
|
|
|
|
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 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);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-11 23:07:26 +08:00
|
|
|
|
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,
|
2026-08-11 23:07:26 +08:00
|
|
|
|
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"
|
|
|
|
|
|
? "创作者中心截图上传失败"
|
2026-08-11 23:07:26 +08:00
|
|
|
|
: kind === "task-result"
|
|
|
|
|
|
? "任务截图上传失败"
|
2026-07-30 12:06:41 +08:00
|
|
|
|
: "发布截图上传失败"),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const submitNote = async (event: FormEvent) => {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
if (!selected) return;
|
2026-08-12 20:44:57 +08:00
|
|
|
|
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);
|
2026-08-12 20:44:57 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-11 23:07:26 +08:00
|
|
|
|
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) {
|
2026-08-11 23:07:26 +08:00
|
|
|
|
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>
|
|
|
|
|
|
<p className="document-label">小红书搜索关键词</p>
|
|
|
|
|
|
<div className="note-title-row">
|
|
|
|
|
|
<h1>{selected.title}</h1>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="inline-copy"
|
|
|
|
|
|
onClick={() => void copyText(selected.title, "搜索关键词已复制")}
|
|
|
|
|
|
>
|
|
|
|
|
|
复制关键词
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="document-rule" />
|
|
|
|
|
|
<p className="document-label">任务说明</p>
|
|
|
|
|
|
<div className="note-body">{selected.body}</div>
|
|
|
|
|
|
{selected.images.length > 0 && (
|
|
|
|
|
|
<section className="note-images">
|
|
|
|
|
|
<div className="note-images-heading">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="document-label">示例截图</p>
|
|
|
|
|
|
<span>请参考示例,确保小红书 AI 总结完整可见</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="note-image-grid">
|
|
|
|
|
|
{selected.images.map((image, index) => (
|
|
|
|
|
|
<div className="note-image-card" key={image.index}>
|
|
|
|
|
|
<a className="note-image-preview" href={noteImageUrl(selected, image.index)} target="_blank" rel="noreferrer">
|
|
|
|
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
|
|
|
|
<img src={noteImageUrl(selected, image.index)} alt={`示例截图 ${index + 1}`} />
|
|
|
|
|
|
</a>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</article>
|
|
|
|
|
|
<form className="submit-card" onSubmit={submitScreenshotTask}>
|
|
|
|
|
|
<div className="submit-heading">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="micro">截图回收</p>
|
|
|
|
|
|
<h2>上传小红书 AI 总结截图</h2>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span className={selected.result_submitted_at ? "done" : "pending"}>
|
|
|
|
|
|
{statusLabel(selected, payload.task.type)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="mapping-note">
|
|
|
|
|
|
<span>1 : N</span>
|
|
|
|
|
|
<p>这些截图只会绑定到当前任务,不需要填写发布链接或其他数据。</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<label>
|
|
|
|
|
|
<span className="task-result-label-row">
|
|
|
|
|
|
<span>任务结果截图</span>
|
|
|
|
|
|
<small>
|
|
|
|
|
|
已选择 {resultScreenshotKeys(selected.result_screenshot_key).length + taskResultScreenshots.length}/{MAX_TASK_RESULT_SCREENSHOTS} 张
|
|
|
|
|
|
</small>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<div className="task-result-gallery">
|
|
|
|
|
|
{resultScreenshotKeys(selected.result_screenshot_key).map((_, index) => {
|
|
|
|
|
|
const imageUrl = taskResultImageUrl(selected, index + 1);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="task-result-thumb uploaded"
|
|
|
|
|
|
onClick={() =>
|
|
|
|
|
|
setPreviewImage({
|
|
|
|
|
|
src: imageUrl,
|
|
|
|
|
|
alt: `已上传任务截图 ${index + 1}`,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
key={`uploaded-${index}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<img src={imageUrl} alt={`已上传任务截图 ${index + 1}`} />
|
|
|
|
|
|
<span>已上传</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)})}
|
|
|
|
|
|
{taskResultPreviews.map(({ file, url }, index) => (
|
|
|
|
|
|
<div className="task-result-thumb pending-upload" key={`${file.name}-${file.lastModified}`}>
|
|
|
|
|
|
<img src={url} alt={`待上传任务截图 ${index + 1}`} />
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
aria-label={`移除 ${file.name}`}
|
|
|
|
|
|
onClick={() => removeTaskResultScreenshot(file)}
|
|
|
|
|
|
>
|
|
|
|
|
|
×
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<span>待提交</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{resultScreenshotKeys(selected.result_screenshot_key).length + taskResultScreenshots.length < MAX_TASK_RESULT_SCREENSHOTS && (
|
|
|
|
|
|
<div className="task-result-add">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="file"
|
|
|
|
|
|
accept="image/*"
|
|
|
|
|
|
multiple
|
|
|
|
|
|
onChange={chooseTaskResultScreenshots}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<b>+</b>
|
|
|
|
|
|
<strong>
|
|
|
|
|
|
{taskResultScreenshots.length || selected.result_screenshot_key
|
|
|
|
|
|
? "继续添加"
|
|
|
|
|
|
: "选择截图"}
|
|
|
|
|
|
</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<small className="task-result-hint">支持一次选择或继续追加,最多9张,每张不超过8MB;点击图片可在当前页面查看大图。</small>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<button className="submit-primary" disabled={working}>
|
|
|
|
|
|
{working
|
|
|
|
|
|
? "正在提交…"
|
|
|
|
|
|
: selected.result_submitted_at
|
|
|
|
|
|
? "更新任务截图"
|
|
|
|
|
|
: "确认提交任务截图"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{selected.result_submitted_at && (
|
|
|
|
|
|
<p className="submitted-at">上次提交:{formatDate(selected.result_submitted_at, true)}</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<ImageLightbox image={previewImage} onClose={() => setPreviewImage(null)} />
|
|
|
|
|
|
{toast && <div className="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">
|
2026-08-12 20:44:57 +08:00
|
|
|
|
<article
|
|
|
|
|
|
className={`note-document ${
|
|
|
|
|
|
noteContentCollapsed ? "mobile-collapsed" : ""
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="mobile-note-summary">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<span>笔记内容已收起</span>
|
|
|
|
|
|
<strong>{selected.title}</strong>
|
|
|
|
|
|
<small>
|
|
|
|
|
|
{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>
|
|
|
|
|
|
<span>飞书源行 {selected.source_row ?? "—"}</span>
|
|
|
|
|
|
</div>
|
2026-08-12 20:44:57 +08:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-08-12 20:44:57 +08:00
|
|
|
|
</div>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</article>
|
|
|
|
|
|
|
2026-08-12 20:44:57 +08:00
|
|
|
|
<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="可粘贴小红书长链、短链或整段分享文案"
|
|
|
|
|
|
required
|
|
|
|
|
|
/>
|
|
|
|
|
|
<small className="field-hint">
|
2026-08-05 11:40:29 +08:00
|
|
|
|
系统会自动提取链接,并识别发布账号
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</small>
|
|
|
|
|
|
</label>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<div className="evidence-field">
|
2026-07-30 12:06:41 +08:00
|
|
|
|
<span>发布截图</span>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<div
|
|
|
|
|
|
className={`screenshot-picker ${
|
|
|
|
|
|
screenshot || selected.publish_screenshot_key
|
|
|
|
|
|
? "has-file preview-mode"
|
|
|
|
|
|
: ""
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{screenshot || selected.publish_screenshot_key ? (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="evidence-preview-button"
|
|
|
|
|
|
onClick={() =>
|
|
|
|
|
|
setPreviewImage({
|
|
|
|
|
|
src:
|
|
|
|
|
|
publishScreenshotPreview ||
|
|
|
|
|
|
evidenceImageUrl(selected, "publish"),
|
|
|
|
|
|
alt: "发布截图",
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
|
|
|
|
<img
|
|
|
|
|
|
src={
|
|
|
|
|
|
publishScreenshotPreview ||
|
|
|
|
|
|
evidenceImageUrl(selected, "publish")
|
|
|
|
|
|
}
|
|
|
|
|
|
alt="发布截图"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span>点击查看大图</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="evidence-empty-state">
|
|
|
|
|
|
<b>+</b>
|
|
|
|
|
|
<strong>点击上传这篇笔记的发布截图</strong>
|
|
|
|
|
|
<small>支持 JPG、PNG,最大8MB</small>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<label className="evidence-upload-action">
|
|
|
|
|
|
<input type="file" accept="image/*" onChange={chooseScreenshot} />
|
|
|
|
|
|
{screenshot || selected.publish_screenshot_key
|
|
|
|
|
|
? "重新选择"
|
|
|
|
|
|
: "选择图片"}
|
|
|
|
|
|
</label>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</div>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
</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>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<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
|
2026-08-11 23:07:26 +08:00
|
|
|
|
? "has-file preview-mode"
|
2026-07-30 12:06:41 +08:00
|
|
|
|
: ""
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
2026-08-11 23:07:26 +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
|
2026-08-11 23:07:26 +08:00
|
|
|
|
? "重新选择"
|
|
|
|
|
|
: "选择图片"}
|
|
|
|
|
|
</label>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</div>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
</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>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<ImageLightbox image={previewImage} onClose={() => setPreviewImage(null)} />
|
2026-07-30 12:06:41 +08:00
|
|
|
|
{toast && <div className="toast">{toast}</div>}
|
|
|
|
|
|
</main>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (activeBatch) {
|
2026-08-11 23:07:26 +08:00
|
|
|
|
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(
|
2026-08-11 23:07:26 +08:00
|
|
|
|
(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">
|
2026-08-11 23:07:26 +08:00
|
|
|
|
{isClaimOwner
|
|
|
|
|
|
? "我的领取任务"
|
|
|
|
|
|
: isScreenshotTask
|
|
|
|
|
|
? "合作社转派截图任务包"
|
|
|
|
|
|
: "合作社转派发布包"}
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
<h1>{payload.task.name}</h1>
|
|
|
|
|
|
<p>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
{payload.task.brand} · {formatDate(payload.task.dueAt)}前{isScreenshotTask ? "提交" : "发布"}
|
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>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<h2>
|
|
|
|
|
|
{isClaimOwner
|
|
|
|
|
|
? isScreenshotTask ? "我领取的截图任务" : "我领取的笔记"
|
|
|
|
|
|
: isScreenshotTask ? "需要完成的截图任务" : "需要发布的笔记"}
|
|
|
|
|
|
</h2>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
<p>
|
|
|
|
|
|
{isClaimOwner
|
2026-08-11 23:07:26 +08:00
|
|
|
|
? isScreenshotTask
|
|
|
|
|
|
? "打开一份查看关键词和要求,完成后单独上传截图;也可以转派给底层KOC"
|
|
|
|
|
|
: "打开一篇,查看内容并单独回填;也可以选择笔记转派给底层KOC"
|
|
|
|
|
|
: isScreenshotTask
|
|
|
|
|
|
? "打开任务查看搜索关键词和要求,完成后逐份上传截图"
|
|
|
|
|
|
: "打开一篇查看完整内容,发布后逐篇回填"}
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="section-actions">
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<span>{activeBatch.assignments.length} {isScreenshotTask ? "份" : "篇"}</span>
|
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("");
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
{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>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
{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">
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<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(
|
2026-08-11 23:07:26 +08:00
|
|
|
|
(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={
|
2026-08-11 23:07:26 +08:00
|
|
|
|
(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>
|
|
|
|
|
|
)}
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<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"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className="note-thumb empty">无图</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h3>{item.title}</h3>
|
|
|
|
|
|
<p>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
{isScreenshotTask
|
|
|
|
|
|
? `搜索关键词 · ${statusLabel(item, payload.task.type)}`
|
|
|
|
|
|
: `${item.images.length} 张配图 · 飞书源行 ${item.source_row ?? "—"} · ${statusLabel(item, payload.task.type)}`}
|
|
|
|
|
|
{!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 ${
|
2026-08-11 23:07:26 +08:00
|
|
|
|
(isScreenshotTask ? item.result_submitted_at : item.publish_url)
|
2026-07-30 12:06:41 +08:00
|
|
|
|
? "done"
|
|
|
|
|
|
: item.delegation_bundle_id
|
|
|
|
|
|
? "delegated"
|
|
|
|
|
|
: ""
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
{(isScreenshotTask ? item.result_submitted_at : item.publish_url)
|
|
|
|
|
|
? isScreenshotTask ? "已提交" : "已回填"
|
2026-07-30 12:06:41 +08:00
|
|
|
|
: item.delegation_bundle_id
|
|
|
|
|
|
? "已转派"
|
2026-08-11 23:07:26 +08:00
|
|
|
|
: isScreenshotTask ? "待提交" : "待发布"}
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
<button onClick={() => openNote(item)}>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
{(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>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<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>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
{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>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
{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
|
2026-08-03 13:48:01 +08:00
|
|
|
|
? "以后再次打开原任务链接,填写相同微信号或手机号即可找回全部领取批次和分享记录。"
|
2026-08-11 23:07:26 +08:00
|
|
|
|
: 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">
|
|
|
|
|
|
<p className="micro">正在招募 · {payload.task.brand}</p>
|
|
|
|
|
|
<h1>{payload.task.name}</h1>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<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>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<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([]);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
{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>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<h2>你要领取多少{payload.task.type === "screenshot_collect" ? "份" : "篇"}?</h2>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<label>
|
2026-08-03 13:48:01 +08:00
|
|
|
|
<span>微信号 / 手机号</span>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
<input
|
|
|
|
|
|
value={claimantName}
|
|
|
|
|
|
onChange={(event) => setClaimantName(event.target.value)}
|
2026-08-03 13:48:01 +08:00
|
|
|
|
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>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<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
|
|
|
|
|
|
? "领取中…"
|
2026-08-11 23:07:26 +08:00
|
|
|
|
: `确认领取 ${quantity} ${payload.task.type === "screenshot_collect" ? "份" : "篇"}`}
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</button>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<small>领取后系统会生成你的专属{payload.task.type === "screenshot_collect" ? "任务清单" : "笔记清单"}</small>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</form>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<form onSubmit={recoverClaims}>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="micro">找回历史记录</p>
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<h2>继续之前领取的{payload.task.type === "screenshot_collect" ? "任务" : "笔记"}</h2>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
<p className="recover-intro">
|
2026-08-03 13:48:01 +08:00
|
|
|
|
输入领取时使用的微信号或手机号;同一任务多次领取会分批展示。
|
2026-07-30 12:06:41 +08:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<label>
|
2026-08-03 13:48:01 +08:00
|
|
|
|
<span>领取时的微信号 / 手机号</span>
|
2026-07-30 12:06:41 +08:00
|
|
|
|
<input
|
|
|
|
|
|
value={claimantName}
|
|
|
|
|
|
onChange={(event) => setClaimantName(event.target.value)}
|
|
|
|
|
|
placeholder="需与领取时完全一致"
|
2026-08-03 13:48:01 +08:00
|
|
|
|
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 />
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<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 />
|
2026-08-11 23:07:26 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|