feat: ship self-hosted KOC LOOP workflows
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--font-geist-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
--ink: #15221f;
|
||||
--ink-soft: #354640;
|
||||
--green: #1e8d68;
|
||||
@@ -173,6 +174,140 @@ button:disabled {
|
||||
box-shadow: 0 1px 2px rgb(15 31 26 / 0.03);
|
||||
}
|
||||
|
||||
.screenshot-task-detail .document-label:first-of-type {
|
||||
margin-top: 26px;
|
||||
}
|
||||
|
||||
.screenshot-task-detail .note-title-row h1 {
|
||||
color: var(--green-deep);
|
||||
}
|
||||
|
||||
.task-result-picker {
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
.task-result-label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.task-result-label-row small {
|
||||
color: #7f8b87;
|
||||
font-size: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.task-result-gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 9px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.task-result-thumb,
|
||||
.task-result-add {
|
||||
position: relative;
|
||||
min-height: 112px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #d8e2dd;
|
||||
border-radius: 11px;
|
||||
background: #f6f9f7;
|
||||
}
|
||||
|
||||
.task-result-thumb {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 1px solid #d8e2dd;
|
||||
color: inherit;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.task-result-thumb img {
|
||||
width: 100%;
|
||||
height: 112px;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.task-result-thumb > span {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 7px;
|
||||
color: #267257;
|
||||
background: rgb(238 249 244 / 0.94);
|
||||
font-size: 7px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.task-result-thumb.pending-upload > span {
|
||||
color: #8a643f;
|
||||
background: rgb(255 245 233 / 0.94);
|
||||
}
|
||||
|
||||
.task-result-thumb button {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
display: grid;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
background: rgb(17 48 39 / 0.78);
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-result-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
border-style: dashed;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-result-add input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-result-add b {
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
color: var(--green);
|
||||
background: var(--green-soft);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.task-result-add strong {
|
||||
margin-top: 8px;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.task-result-hint {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: #8c9793;
|
||||
font-size: 7px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.claim-card {
|
||||
min-height: 430px;
|
||||
padding: 28px;
|
||||
@@ -1171,6 +1306,19 @@ footer {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.evidence-field {
|
||||
display: block;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.evidence-field > span {
|
||||
display: block;
|
||||
margin-bottom: 7px;
|
||||
color: #52605b;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
color: #98a29e;
|
||||
font-size: 8px;
|
||||
@@ -1286,6 +1434,142 @@ footer {
|
||||
min-height: 96px;
|
||||
}
|
||||
|
||||
.screenshot-picker.preview-mode {
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
padding: 8px;
|
||||
background: #f7faf8;
|
||||
}
|
||||
|
||||
.evidence-empty-state {
|
||||
display: flex;
|
||||
min-height: 82px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.evidence-preview-button {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: #eaf1ed;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.evidence-preview-button img {
|
||||
width: 100%;
|
||||
max-height: 260px;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.evidence-preview-button.compact-preview img {
|
||||
max-height: 210px;
|
||||
}
|
||||
|
||||
.evidence-preview-button > span {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
background: rgb(17 48 39 / 0.78);
|
||||
font-size: 7px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.evidence-upload-action {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
margin-top: 8px !important;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid #c5dcd2;
|
||||
border-radius: 8px;
|
||||
color: var(--green-deep);
|
||||
background: #eef7f3;
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.evidence-upload-action input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.image-lightbox {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 18px;
|
||||
background: rgb(9 25 20 / 0.78);
|
||||
}
|
||||
|
||||
.image-lightbox-card {
|
||||
width: min(920px, 100%);
|
||||
max-height: calc(100vh - 36px);
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(255 255 255 / 0.2);
|
||||
border-radius: 16px;
|
||||
background: #f7faf8;
|
||||
box-shadow: 0 24px 80px rgb(0 0 0 / 0.32);
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.image-lightbox-heading {
|
||||
display: flex;
|
||||
min-height: 48px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #dce5e0;
|
||||
}
|
||||
|
||||
.image-lightbox-heading strong {
|
||||
overflow: hidden;
|
||||
color: var(--green-deep);
|
||||
font-size: 10px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.image-lightbox-heading button {
|
||||
flex: 0 0 auto;
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #c7d8d0;
|
||||
border-radius: 8px;
|
||||
color: var(--green-deep);
|
||||
background: white;
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.image-lightbox-card > img {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
max-height: calc(100vh - 102px);
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
background: #14231e;
|
||||
}
|
||||
|
||||
.creator-metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -1318,6 +1602,10 @@ footer {
|
||||
}
|
||||
|
||||
@media (width <= 520px) {
|
||||
.task-result-gallery {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.creator-metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
|
||||
@@ -1,66 +1,23 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { headers } from "next/headers";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
const description =
|
||||
"领取KOC内容任务,逐篇查看笔记详情并一一回填发布账号、链接与截图。";
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const requestHeaders = await headers();
|
||||
const host = requestHeaders.get("x-forwarded-host") || requestHeaders.get("host");
|
||||
const protocol =
|
||||
requestHeaders.get("x-forwarded-proto") ||
|
||||
(host?.startsWith("localhost") ? "http" : "https");
|
||||
const metadataBase = host
|
||||
? new URL(`${protocol}://${host}`)
|
||||
: new URL("https://koc-task.example.com");
|
||||
const description =
|
||||
"领取KOC内容任务,逐篇查看笔记详情并一一回填发布账号、链接与截图。";
|
||||
|
||||
return {
|
||||
metadataBase,
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://koc-loop.example.com"),
|
||||
title: "KOC LOOP|外部任务领取",
|
||||
description,
|
||||
robots: { index: false, follow: false, noarchive: true, nosnippet: true },
|
||||
referrer: "no-referrer",
|
||||
icons: { icon: "/koc/favicon.svg", shortcut: "/koc/favicon.svg" },
|
||||
openGraph: {
|
||||
title: "KOC LOOP|外部任务领取",
|
||||
description,
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
noarchive: true,
|
||||
nosnippet: true,
|
||||
},
|
||||
referrer: "no-referrer",
|
||||
icons: {
|
||||
icon: "/favicon.svg",
|
||||
shortcut: "/favicon.svg",
|
||||
},
|
||||
openGraph: {
|
||||
title: "KOC LOOP|外部任务领取",
|
||||
description,
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: new URL("/og.png", metadataBase).toString(),
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "KOC LOOP 外部任务领取与逐篇回填",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "KOC LOOP|外部任务领取",
|
||||
description,
|
||||
images: [new URL("/og.png", metadataBase).toString()],
|
||||
},
|
||||
};
|
||||
}
|
||||
type: "website",
|
||||
images: [{ url: "/koc/og.png", width: 1200, height: 630 }],
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
@@ -69,9 +26,7 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||
{children}
|
||||
</body>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ type Assignment = {
|
||||
publish_time: string | null;
|
||||
publish_screenshot_key: string | null;
|
||||
creator_screenshot_key: string | null;
|
||||
result_screenshot_key: string | null;
|
||||
result_submitted_at: string | null;
|
||||
ocr_status: string;
|
||||
exposure: number | null;
|
||||
views: number | null;
|
||||
@@ -40,6 +42,7 @@ type DelegationSummary = {
|
||||
revoked_at: string | null;
|
||||
completed_count: number;
|
||||
creator_completed_count: number;
|
||||
result_completed_count?: number;
|
||||
};
|
||||
|
||||
type RecoveredClaim = {
|
||||
@@ -59,6 +62,7 @@ type TaskPayload = {
|
||||
availableQuantity?: number;
|
||||
dueAt: string;
|
||||
status: string;
|
||||
type: "content_publish" | "screenshot_collect";
|
||||
};
|
||||
claim: null | {
|
||||
id: string;
|
||||
@@ -77,21 +81,47 @@ type TaskPayload = {
|
||||
};
|
||||
};
|
||||
|
||||
const PRODUCTION_ADMIN_ORIGIN =
|
||||
"https://koc-loop-mvp-wufp.pyeongwu.chatgpt.site";
|
||||
const CONFIGURED_ADMIN_ORIGIN =
|
||||
process.env.NEXT_PUBLIC_ADMIN_ORIGIN?.replace(/\/$/, "") ?? "";
|
||||
|
||||
function partnerApi(path: "/api/partner" | "/api/partner-upload") {
|
||||
const origin =
|
||||
window.location.hostname === "localhost"
|
||||
? "http://localhost:3001"
|
||||
: PRODUCTION_ADMIN_ORIGIN;
|
||||
return `${origin}${path}`;
|
||||
function resolveAdminOrigin() {
|
||||
if (CONFIGURED_ADMIN_ORIGIN) return CONFIGURED_ADMIN_ORIGIN;
|
||||
if (window.location.hostname === "localhost" && window.location.port === "3001") {
|
||||
return "http://localhost:3000";
|
||||
}
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
function statusLabel(item: Assignment) {
|
||||
function partnerApi(path: "/api/partner" | "/api/partner-upload") {
|
||||
return `${resolveAdminOrigin()}${path}`;
|
||||
}
|
||||
|
||||
function statusLabel(item: Assignment, taskType = "content_publish") {
|
||||
if (taskType === "screenshot_collect") {
|
||||
return item.result_submitted_at ? "已提交" : "待提交";
|
||||
}
|
||||
return item.publish_url ? "已回填" : "待发布";
|
||||
}
|
||||
|
||||
const MAX_TASK_RESULT_SCREENSHOTS = 9;
|
||||
|
||||
function resultScreenshotKeys(value: string | null) {
|
||||
const text = String(value ?? "").trim();
|
||||
if (!text) return [];
|
||||
if (text.startsWith("task-results/")) return [text];
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter(
|
||||
(item): item is string =>
|
||||
typeof item === "string" && item.startsWith("task-results/"),
|
||||
)
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function creatorScreenshotDueAt(publishTime?: string | null) {
|
||||
if (!publishTime) return null;
|
||||
const date = parseStoredDate(publishTime);
|
||||
@@ -159,6 +189,64 @@ async function compressScreenshot(file: File) {
|
||||
});
|
||||
}
|
||||
|
||||
function useFilePreview(file: File | null) {
|
||||
const previewUrl = useMemo(
|
||||
() => (file ? URL.createObjectURL(file) : ""),
|
||||
[file],
|
||||
);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
},
|
||||
[previewUrl],
|
||||
);
|
||||
return previewUrl;
|
||||
}
|
||||
|
||||
function ImageLightbox({
|
||||
image,
|
||||
onClose,
|
||||
}: {
|
||||
image: { src: string; alt: string } | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!image) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, [image, onClose]);
|
||||
if (!image) return null;
|
||||
return (
|
||||
<div
|
||||
className="image-lightbox"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={image.alt}
|
||||
onMouseDown={onClose}
|
||||
>
|
||||
<div
|
||||
className="image-lightbox-card"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="image-lightbox-heading">
|
||||
<strong>{image.alt}</strong>
|
||||
<button type="button" onClick={onClose}>关闭</button>
|
||||
</div>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={image.src} alt={image.alt} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [taskToken, setTaskToken] = useState("");
|
||||
const [claimToken, setClaimToken] = useState("");
|
||||
@@ -173,12 +261,13 @@ export default function Home() {
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [publishUrl, setPublishUrl] = useState("");
|
||||
const [screenshot, setScreenshot] = useState<File | null>(null);
|
||||
const [taskResultScreenshots, setTaskResultScreenshots] = useState<File[]>([]);
|
||||
const [creatorScreenshot, setCreatorScreenshot] = useState<File | null>(null);
|
||||
const [creatorExposure, setCreatorExposure] = useState("");
|
||||
const [creatorViews, setCreatorViews] = useState("");
|
||||
const [entryMode, setEntryMode] = useState<"claim" | "recover">("claim");
|
||||
const [recoveredClaims, setRecoveredClaims] = useState<RecoveredClaim[]>([]);
|
||||
const [adminOrigin, setAdminOrigin] = useState(PRODUCTION_ADMIN_ORIGIN);
|
||||
const [adminOrigin, setAdminOrigin] = useState("");
|
||||
const [downloadingImage, setDownloadingImage] = useState<number | null>(null);
|
||||
const [batchDownloading, setBatchDownloading] = useState(false);
|
||||
const [creatorWorking, setCreatorWorking] = useState(false);
|
||||
@@ -187,6 +276,27 @@ export default function Home() {
|
||||
const [shareLabel, setShareLabel] = useState("");
|
||||
const [shareComposerOpen, setShareComposerOpen] = useState(false);
|
||||
const [shareWorking, setShareWorking] = useState(false);
|
||||
const [previewImage, setPreviewImage] = useState<{
|
||||
src: string;
|
||||
alt: string;
|
||||
} | null>(null);
|
||||
const publishScreenshotPreview = useFilePreview(screenshot);
|
||||
const creatorScreenshotPreview = useFilePreview(creatorScreenshot);
|
||||
const taskResultPreviews = useMemo(
|
||||
() =>
|
||||
taskResultScreenshots.map((file) => ({
|
||||
file,
|
||||
url: URL.createObjectURL(file),
|
||||
})),
|
||||
[taskResultScreenshots],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
taskResultPreviews.forEach((preview) => URL.revokeObjectURL(preview.url));
|
||||
},
|
||||
[taskResultPreviews],
|
||||
);
|
||||
|
||||
const updateUrl = useCallback(
|
||||
(nextClaim: string, nextNote = "", nextDelegation = "") => {
|
||||
@@ -231,9 +341,7 @@ export default function Home() {
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
if (window.location.hostname === "localhost") {
|
||||
setAdminOrigin("http://localhost:3001");
|
||||
}
|
||||
setAdminOrigin(resolveAdminOrigin());
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const task = params.get("task") ?? "";
|
||||
const claim = params.get("claim") ?? "";
|
||||
@@ -270,6 +378,7 @@ export default function Home() {
|
||||
const timer = window.setTimeout(() => {
|
||||
setPublishUrl(selected.publish_url ?? "");
|
||||
setScreenshot(null);
|
||||
setTaskResultScreenshots([]);
|
||||
setCreatorScreenshot(null);
|
||||
setCreatorExposure(
|
||||
selected.exposure === null ? "" : String(selected.exposure),
|
||||
@@ -305,7 +414,11 @@ export default function Home() {
|
||||
setDelegationToken("");
|
||||
updateUrl(result.claimToken);
|
||||
await loadTask(taskToken, result.claimToken);
|
||||
setToast(`已领取 ${result.claimedCount} 篇,逐篇发布即可`);
|
||||
setToast(
|
||||
payload?.task.type === "screenshot_collect"
|
||||
? `已领取 ${result.claimedCount} 份,完成后逐份上传截图`
|
||||
: `已领取 ${result.claimedCount} 篇,逐篇发布即可`,
|
||||
);
|
||||
} catch (reason) {
|
||||
setToast(reason instanceof Error ? reason.message : "领取失败");
|
||||
} finally {
|
||||
@@ -379,6 +492,37 @@ export default function Home() {
|
||||
return `${adminOrigin}/api/partner-image?${params}`;
|
||||
};
|
||||
|
||||
const taskResultImageUrl = (item: Assignment, imageIndex: number) => {
|
||||
const params = new URLSearchParams({
|
||||
distribution: item.id,
|
||||
index: String(imageIndex),
|
||||
kind: "result",
|
||||
});
|
||||
if (delegationToken) params.set("share", delegationToken);
|
||||
else {
|
||||
params.set("task", taskToken);
|
||||
params.set("claim", claimToken);
|
||||
}
|
||||
return `${adminOrigin}/api/partner-image?${params}`;
|
||||
};
|
||||
|
||||
const evidenceImageUrl = (
|
||||
item: Assignment,
|
||||
kind: "publish" | "creator",
|
||||
) => {
|
||||
const params = new URLSearchParams({
|
||||
distribution: item.id,
|
||||
index: "1",
|
||||
kind,
|
||||
});
|
||||
if (delegationToken) params.set("share", delegationToken);
|
||||
else {
|
||||
params.set("task", taskToken);
|
||||
params.set("claim", claimToken);
|
||||
}
|
||||
return `${adminOrigin}/api/partner-image?${params}`;
|
||||
};
|
||||
|
||||
const openNote = (item: Assignment) => {
|
||||
setSelectedId(item.id);
|
||||
updateUrl(claimToken, item.id, delegationToken);
|
||||
@@ -574,6 +718,37 @@ export default function Home() {
|
||||
setScreenshot(event.target.files?.[0] ?? null);
|
||||
};
|
||||
|
||||
const chooseTaskResultScreenshots = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const incoming = Array.from(event.target.files ?? []);
|
||||
if (incoming.length === 0) return;
|
||||
const existingCount = selected
|
||||
? resultScreenshotKeys(selected.result_screenshot_key).length
|
||||
: 0;
|
||||
setTaskResultScreenshots((current) => {
|
||||
const merged = [...current, ...incoming].filter(
|
||||
(file, index, files) =>
|
||||
files.findIndex(
|
||||
(candidate) =>
|
||||
candidate.name === file.name &&
|
||||
candidate.size === file.size &&
|
||||
candidate.lastModified === file.lastModified,
|
||||
) === index,
|
||||
);
|
||||
const allowed = Math.max(0, MAX_TASK_RESULT_SCREENSHOTS - existingCount);
|
||||
if (merged.length > allowed) {
|
||||
setToast(`每份任务最多上传${MAX_TASK_RESULT_SCREENSHOTS}张截图`);
|
||||
}
|
||||
return merged.slice(0, allowed);
|
||||
});
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
const removeTaskResultScreenshot = (target: File) => {
|
||||
setTaskResultScreenshots((current) =>
|
||||
current.filter((file) => file !== target),
|
||||
);
|
||||
};
|
||||
|
||||
const chooseCreatorScreenshot = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setCreatorScreenshot(event.target.files?.[0] ?? null);
|
||||
};
|
||||
@@ -581,7 +756,7 @@ export default function Home() {
|
||||
const uploadEvidence = async (
|
||||
selectedItem: Assignment,
|
||||
file: File,
|
||||
kind: "publish" | "creator-center",
|
||||
kind: "publish" | "creator-center" | "task-result",
|
||||
) => {
|
||||
const compressed = await compressScreenshot(file);
|
||||
const headers: Record<string, string> = {
|
||||
@@ -607,6 +782,8 @@ export default function Home() {
|
||||
uploadResult.error ||
|
||||
(kind === "creator-center"
|
||||
? "创作者中心截图上传失败"
|
||||
: kind === "task-result"
|
||||
? "任务截图上传失败"
|
||||
: "发布截图上传失败"),
|
||||
);
|
||||
}
|
||||
@@ -647,6 +824,51 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
|
||||
const submitScreenshotTask = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!selected) return;
|
||||
try {
|
||||
setWorking(true);
|
||||
const existingScreenshots = resultScreenshotKeys(
|
||||
selected.result_screenshot_key,
|
||||
);
|
||||
if (taskResultScreenshots.length > 0) {
|
||||
for (const file of taskResultScreenshots) {
|
||||
await uploadEvidence(selected, file, "task-result");
|
||||
setTaskResultScreenshots((current) =>
|
||||
current.filter((candidate) => candidate !== file),
|
||||
);
|
||||
}
|
||||
} else if (existingScreenshots.length === 0) {
|
||||
throw new Error("请上传任务要求的截图");
|
||||
}
|
||||
const response = await fetch(partnerApi("/api/partner"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "submit_screenshot_result",
|
||||
taskToken,
|
||||
claimToken,
|
||||
delegationToken,
|
||||
distributionId: selected.id,
|
||||
}),
|
||||
});
|
||||
const result = (await response.json()) as { error?: string };
|
||||
if (!response.ok) throw new Error(result.error || "截图提交失败");
|
||||
await loadTask(taskToken, claimToken, delegationToken);
|
||||
setTaskResultScreenshots([]);
|
||||
setToast(
|
||||
taskResultScreenshots.length > 1
|
||||
? `${taskResultScreenshots.length}张任务截图已提交`
|
||||
: "任务截图已提交",
|
||||
);
|
||||
} catch (reason) {
|
||||
setToast(reason instanceof Error ? reason.message : "截图提交失败");
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitCreatorData = async () => {
|
||||
if (!selected) return;
|
||||
if (!creatorScreenshot && !selected.creator_screenshot_key) {
|
||||
@@ -725,6 +947,146 @@ export default function Home() {
|
||||
}
|
||||
|
||||
if (selected && activeBatch) {
|
||||
if (payload.task.type === "screenshot_collect") {
|
||||
return (
|
||||
<main className="portal-shell detail-shell">
|
||||
<header className="portal-topbar">
|
||||
<div className="portal-brand"><span>K</span><strong>KOC LOOP</strong></div>
|
||||
<div className="claimant-chip">
|
||||
{payload.claim ? payload.claim.claimantName : "合作社转派 · 无需登录"}
|
||||
</div>
|
||||
</header>
|
||||
<button className="back-link" onClick={closeNote}>← 返回我的任务</button>
|
||||
<div className="detail-grid screenshot-task-detail">
|
||||
<article className="note-document">
|
||||
<div className="note-document-meta">
|
||||
<span>截图任务 {activeBatch.assignments.findIndex((item) => item.id === selected.id) + 1}</span>
|
||||
<span>只需上传结果截图</span>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
const creatorDueAt = creatorScreenshotDueAt(selected.publish_time);
|
||||
const creatorDataReady =
|
||||
Boolean(selected.creator_screenshot_key) &&
|
||||
@@ -851,21 +1213,53 @@ export default function Home() {
|
||||
系统会自动提取链接,并识别发布账号
|
||||
</small>
|
||||
</label>
|
||||
<label>
|
||||
<div className="evidence-field">
|
||||
<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>支持 JPG、PNG,最大8MB</small>
|
||||
<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>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<button className="submit-primary" disabled={working}>
|
||||
{working ? "正在提交…" : selected.publish_url ? "更新这篇笔记" : "确认回填这篇笔记"}
|
||||
</button>
|
||||
@@ -887,35 +1281,57 @@ export default function Home() {
|
||||
请在发布满7天后,上传创作者中心数据页完整截图,并填写页面显示的数据。
|
||||
{creatorDueAt && ` 建议上传日期:${formatDate(creatorDueAt)}。`}
|
||||
</p>
|
||||
<label>
|
||||
<div className="evidence-field compact-field">
|
||||
<span>创作者中心截图</span>
|
||||
<div
|
||||
className={`screenshot-picker compact ${
|
||||
creatorScreenshot || selected.creator_screenshot_key
|
||||
? "has-file"
|
||||
? "has-file preview-mode"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={chooseCreatorScreenshot}
|
||||
/>
|
||||
<b>
|
||||
{creatorScreenshot || selected.creator_screenshot_key ? (
|
||||
<button
|
||||
type="button"
|
||||
className="evidence-preview-button compact-preview"
|
||||
onClick={() =>
|
||||
setPreviewImage({
|
||||
src:
|
||||
creatorScreenshotPreview ||
|
||||
evidenceImageUrl(selected, "creator"),
|
||||
alt: "创作者中心截图",
|
||||
})
|
||||
}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={
|
||||
creatorScreenshotPreview ||
|
||||
evidenceImageUrl(selected, "creator")
|
||||
}
|
||||
alt="创作者中心截图"
|
||||
/>
|
||||
<span>点击查看大图</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="evidence-empty-state">
|
||||
<b>+</b>
|
||||
<strong>点击选择第7天创作者中心截图</strong>
|
||||
<small>截图仅用于运营核对,不再自动OCR</small>
|
||||
</div>
|
||||
)}
|
||||
<label className="evidence-upload-action">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={chooseCreatorScreenshot}
|
||||
/>
|
||||
{creatorScreenshot || selected.creator_screenshot_key
|
||||
? "✓"
|
||||
: "+"}
|
||||
</b>
|
||||
<strong>
|
||||
{creatorScreenshot
|
||||
? creatorScreenshot.name
|
||||
: selected.creator_screenshot_key
|
||||
? "创作者中心截图已提交"
|
||||
: "点击选择第7天创作者中心截图"}
|
||||
</strong>
|
||||
<small>截图仅用于运营核对,不再自动OCR</small>
|
||||
? "重新选择"
|
||||
: "选择图片"}
|
||||
</label>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="creator-metric-grid">
|
||||
<label>
|
||||
<span>曝光量</span>
|
||||
@@ -967,16 +1383,22 @@ export default function Home() {
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
<ImageLightbox image={previewImage} onClose={() => setPreviewImage(null)} />
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeBatch) {
|
||||
const completed = activeBatch.assignments.filter((item) => item.publish_url).length;
|
||||
const isScreenshotTask = payload.task.type === "screenshot_collect";
|
||||
const completed = activeBatch.assignments.filter((item) =>
|
||||
isScreenshotTask ? item.result_submitted_at : item.publish_url,
|
||||
).length;
|
||||
const isClaimOwner = Boolean(payload.claim);
|
||||
const shareableCount = payload.claim?.assignments.filter(
|
||||
(item) => !item.publish_url && !item.delegation_bundle_id,
|
||||
(item) =>
|
||||
!(isScreenshotTask ? item.result_submitted_at : item.publish_url) &&
|
||||
!item.delegation_bundle_id,
|
||||
).length ?? 0;
|
||||
return (
|
||||
<main className="portal-shell">
|
||||
@@ -994,11 +1416,15 @@ export default function Home() {
|
||||
<section className="claimed-hero">
|
||||
<div>
|
||||
<p className="micro">
|
||||
{isClaimOwner ? "我的领取任务" : "合作社转派发布包"}
|
||||
{isClaimOwner
|
||||
? "我的领取任务"
|
||||
: isScreenshotTask
|
||||
? "合作社转派截图任务包"
|
||||
: "合作社转派发布包"}
|
||||
</p>
|
||||
<h1>{payload.task.name}</h1>
|
||||
<p>
|
||||
{payload.task.brand} · {formatDate(payload.task.dueAt)}前发布
|
||||
{payload.task.brand} · {formatDate(payload.task.dueAt)}前{isScreenshotTask ? "提交" : "发布"}
|
||||
{payload.delegation ? ` · ${payload.delegation.label}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1010,15 +1436,23 @@ export default function Home() {
|
||||
<section className="notes-section">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>{isClaimOwner ? "我领取的笔记" : "需要发布的笔记"}</h2>
|
||||
<h2>
|
||||
{isClaimOwner
|
||||
? isScreenshotTask ? "我领取的截图任务" : "我领取的笔记"
|
||||
: isScreenshotTask ? "需要完成的截图任务" : "需要发布的笔记"}
|
||||
</h2>
|
||||
<p>
|
||||
{isClaimOwner
|
||||
? "打开一篇,查看内容并单独回填;也可以选择笔记转派给底层KOC"
|
||||
: "打开一篇查看完整内容,发布后逐篇回填"}
|
||||
? isScreenshotTask
|
||||
? "打开一份查看关键词和要求,完成后单独上传截图;也可以转派给底层KOC"
|
||||
: "打开一篇,查看内容并单独回填;也可以选择笔记转派给底层KOC"
|
||||
: isScreenshotTask
|
||||
? "打开任务查看搜索关键词和要求,完成后逐份上传截图"
|
||||
: "打开一篇查看完整内容,发布后逐篇回填"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="section-actions">
|
||||
<span>{activeBatch.assignments.length} 篇</span>
|
||||
<span>{activeBatch.assignments.length} {isScreenshotTask ? "份" : "篇"}</span>
|
||||
{isClaimOwner && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1030,7 +1464,7 @@ export default function Home() {
|
||||
setShareLabel("");
|
||||
}}
|
||||
>
|
||||
{shareComposerOpen ? "取消选择" : "选择笔记并分享"}
|
||||
{shareComposerOpen ? "取消选择" : isScreenshotTask ? "选择任务并分享" : "选择笔记并分享"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1041,7 +1475,9 @@ export default function Home() {
|
||||
<p className="micro">合作社转派</p>
|
||||
<h3>生成免登录分享链接</h3>
|
||||
<p>
|
||||
勾选待发布笔记,底层KOC打开链接即可查看内容并逐篇回填。
|
||||
{isScreenshotTask
|
||||
? "勾选待提交任务,底层KOC打开链接即可查看要求并上传截图。"
|
||||
: "勾选待发布笔记,底层KOC打开链接即可查看内容并逐篇回填。"}
|
||||
</p>
|
||||
</div>
|
||||
<label>
|
||||
@@ -1055,7 +1491,7 @@ export default function Home() {
|
||||
/>
|
||||
</label>
|
||||
<div className="share-composer-action">
|
||||
<strong>已选 {selectedForShare.length} 篇</strong>
|
||||
<strong>已选 {selectedForShare.length} {isScreenshotTask ? "份" : "篇"}</strong>
|
||||
<button
|
||||
className="share-primary"
|
||||
disabled={
|
||||
@@ -1075,7 +1511,8 @@ export default function Home() {
|
||||
<div className="note-list">
|
||||
{activeBatch.assignments.map((item, index) => {
|
||||
const shareDisabled = Boolean(
|
||||
item.publish_url || item.delegation_bundle_id,
|
||||
(isScreenshotTask ? item.result_submitted_at : item.publish_url) ||
|
||||
item.delegation_bundle_id,
|
||||
);
|
||||
return (
|
||||
<article
|
||||
@@ -1086,8 +1523,8 @@ export default function Home() {
|
||||
<label
|
||||
className={`share-checkbox ${shareDisabled ? "disabled" : ""}`}
|
||||
title={
|
||||
item.publish_url
|
||||
? "已发布笔记不能转派"
|
||||
(isScreenshotTask ? item.result_submitted_at : item.publish_url)
|
||||
? isScreenshotTask ? "已提交任务不能转派" : "已发布笔记不能转派"
|
||||
: item.delegation_bundle_id
|
||||
? `已转派给 ${item.delegation_label}`
|
||||
: "选择这篇笔记"
|
||||
@@ -1103,7 +1540,7 @@ export default function Home() {
|
||||
<span>✓</span>
|
||||
</label>
|
||||
)}
|
||||
<span className={`note-index ${item.publish_url ? "done" : ""}`}>
|
||||
<span className={`note-index ${(isScreenshotTask ? item.result_submitted_at : item.publish_url) ? "done" : ""}`}>
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
{item.images[0] ? (
|
||||
@@ -1123,28 +1560,32 @@ export default function Home() {
|
||||
<div>
|
||||
<h3>{item.title}</h3>
|
||||
<p>
|
||||
{item.images.length} 张配图 · 飞书源行 {item.source_row ?? "—"} · {statusLabel(item)}
|
||||
{item.creator_screenshot_key ? " · D7截图已交" : ""}
|
||||
{isScreenshotTask
|
||||
? `搜索关键词 · ${statusLabel(item, payload.task.type)}`
|
||||
: `${item.images.length} 张配图 · 飞书源行 ${item.source_row ?? "—"} · ${statusLabel(item, payload.task.type)}`}
|
||||
{!isScreenshotTask && item.creator_screenshot_key ? " · D7截图已交" : ""}
|
||||
{item.delegation_label ? ` · 已转派给 ${item.delegation_label}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`note-status ${
|
||||
item.publish_url
|
||||
(isScreenshotTask ? item.result_submitted_at : item.publish_url)
|
||||
? "done"
|
||||
: item.delegation_bundle_id
|
||||
? "delegated"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{item.publish_url
|
||||
? "已回填"
|
||||
{(isScreenshotTask ? item.result_submitted_at : item.publish_url)
|
||||
? isScreenshotTask ? "已提交" : "已回填"
|
||||
: item.delegation_bundle_id
|
||||
? "已转派"
|
||||
: "待发布"}
|
||||
: isScreenshotTask ? "待提交" : "待发布"}
|
||||
</span>
|
||||
<button onClick={() => openNote(item)}>
|
||||
{item.publish_url ? "查看 / 修改" : "查看并回填"} →
|
||||
{(isScreenshotTask ? item.result_submitted_at : item.publish_url)
|
||||
? "查看 / 修改"
|
||||
: isScreenshotTask ? "查看并上传" : "查看并回填"} →
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
@@ -1155,7 +1596,7 @@ export default function Home() {
|
||||
<div className="delegation-heading">
|
||||
<div>
|
||||
<p className="micro">分享记录</p>
|
||||
<h3>转派发布包</h3>
|
||||
<h3>{isScreenshotTask ? "转派截图任务包" : "转派发布包"}</h3>
|
||||
</div>
|
||||
<span>{payload.claim.delegations.length} 个</span>
|
||||
</div>
|
||||
@@ -1168,9 +1609,9 @@ export default function Home() {
|
||||
<div>
|
||||
<strong>{bundle.label}</strong>
|
||||
<span>
|
||||
{bundle.quantity} 篇 · 已发布{" "}
|
||||
{Number(bundle.completed_count ?? 0)}/{bundle.quantity} ·
|
||||
D7完成 {Number(bundle.creator_completed_count ?? 0)}
|
||||
{bundle.quantity} {isScreenshotTask ? "份" : "篇"} · {isScreenshotTask ? "已提交" : "已发布"}{" "}
|
||||
{Number(isScreenshotTask ? bundle.result_completed_count ?? bundle.completed_count : bundle.completed_count ?? 0)}/{bundle.quantity}
|
||||
{!isScreenshotTask && ` · D7完成 ${Number(bundle.creator_completed_count ?? 0)}`}
|
||||
</span>
|
||||
</div>
|
||||
<small>{formatDate(bundle.created_at, true)}</small>
|
||||
@@ -1190,7 +1631,7 @@ export default function Home() {
|
||||
>
|
||||
复制链接
|
||||
</button>
|
||||
{Number(bundle.completed_count ?? 0) === 0 && (
|
||||
{Number(isScreenshotTask ? bundle.result_completed_count ?? bundle.completed_count : bundle.completed_count ?? 0) === 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="danger"
|
||||
@@ -1211,7 +1652,9 @@ export default function Home() {
|
||||
<div className="safe-tip">
|
||||
{isClaimOwner
|
||||
? "以后再次打开原任务链接,填写相同微信号或手机号即可找回全部领取批次和分享记录。"
|
||||
: "请保存当前分享链接;发布后仍需通过此链接回填第7天截图、曝光量和阅读量。"}
|
||||
: isScreenshotTask
|
||||
? "请保存当前分享链接;完成任务后通过此链接上传截图即可。"
|
||||
: "请保存当前分享链接;发布后仍需通过此链接回填第7天截图、曝光量和阅读量。"}
|
||||
</div>
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
</main>
|
||||
@@ -1228,10 +1671,14 @@ export default function Home() {
|
||||
<div className="hero-copy">
|
||||
<p className="micro">正在招募 · {payload.task.brand}</p>
|
||||
<h1>{payload.task.name}</h1>
|
||||
<p>领取后可查看完整笔记正文,每篇笔记独立回填发布信息。</p>
|
||||
<p>
|
||||
{payload.task.type === "screenshot_collect"
|
||||
? "领取后查看搜索关键词和任务要求,完成后逐份上传截图。"
|
||||
: "领取后可查看完整笔记正文,每篇笔记独立回填发布信息。"}
|
||||
</p>
|
||||
<div className="hero-meta">
|
||||
<span>截止 {formatDate(payload.task.dueAt)}</span>
|
||||
<span>剩余 {payload.task.availableQuantity} 篇</span>
|
||||
<span>剩余 {payload.task.availableQuantity} {payload.task.type === "screenshot_collect" ? "份" : "篇"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<aside className="claim-card">
|
||||
@@ -1244,7 +1691,7 @@ export default function Home() {
|
||||
setRecoveredClaims([]);
|
||||
}}
|
||||
>
|
||||
领取新笔记
|
||||
{payload.task.type === "screenshot_collect" ? "领取新任务" : "领取新笔记"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1261,7 +1708,7 @@ export default function Home() {
|
||||
<form onSubmit={claimNotes}>
|
||||
<div>
|
||||
<p className="micro">01 · 确认领取</p>
|
||||
<h2>你要领取多少篇?</h2>
|
||||
<h2>你要领取多少{payload.task.type === "screenshot_collect" ? "份" : "篇"}?</h2>
|
||||
</div>
|
||||
<label>
|
||||
<span>微信号 / 手机号</span>
|
||||
@@ -1278,7 +1725,7 @@ export default function Home() {
|
||||
</label>
|
||||
<div className="quantity-control">
|
||||
<button type="button" onClick={() => setQuantity(Math.max(1, quantity - 1))}>−</button>
|
||||
<div><strong>{quantity}</strong><span>篇</span></div>
|
||||
<div><strong>{quantity}</strong><span>{payload.task.type === "screenshot_collect" ? "份" : "篇"}</span></div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -1296,15 +1743,15 @@ export default function Home() {
|
||||
? "当前任务已领完"
|
||||
: working
|
||||
? "领取中…"
|
||||
: `确认领取 ${quantity} 篇`}
|
||||
: `确认领取 ${quantity} ${payload.task.type === "screenshot_collect" ? "份" : "篇"}`}
|
||||
</button>
|
||||
<small>领取后系统会生成你的专属笔记清单</small>
|
||||
<small>领取后系统会生成你的专属{payload.task.type === "screenshot_collect" ? "任务清单" : "笔记清单"}</small>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={recoverClaims}>
|
||||
<div>
|
||||
<p className="micro">找回历史记录</p>
|
||||
<h2>继续之前领取的笔记</h2>
|
||||
<h2>继续之前领取的{payload.task.type === "screenshot_collect" ? "任务" : "笔记"}</h2>
|
||||
<p className="recover-intro">
|
||||
输入领取时使用的微信号或手机号;同一任务多次领取会分批展示。
|
||||
</p>
|
||||
@@ -1348,9 +1795,9 @@ export default function Home() {
|
||||
<section className="steps">
|
||||
<div><span>1</span><strong>选择数量</strong><p>一次可领多篇</p></div>
|
||||
<i />
|
||||
<div><span>2</span><strong>逐篇查看</strong><p>标题正文完整展示</p></div>
|
||||
<div><span>2</span><strong>{payload.task.type === "screenshot_collect" ? "逐份查看" : "逐篇查看"}</strong><p>{payload.task.type === "screenshot_collect" ? "关键词与要求完整展示" : "标题正文完整展示"}</p></div>
|
||||
<i />
|
||||
<div><span>3</span><strong>单篇回填</strong><p>昵称、链接、截图一一对应</p></div>
|
||||
<div><span>3</span><strong>{payload.task.type === "screenshot_collect" ? "上传截图" : "单篇回填"}</strong><p>{payload.task.type === "screenshot_collect" ? "每份任务与截图一一对应" : "昵称、链接、截图一一对应"}</p></div>
|
||||
</section>
|
||||
<footer>由 KOC LOOP 提供任务与数据安全保障</footer>
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
|
||||
Reference in New Issue
Block a user