feat: ship self-hosted KOC LOOP workflows

This commit is contained in:
巫凤萍
2026-08-11 23:07:26 +08:00
parent 1f1887c860
commit ddad4b7659
88 changed files with 6056 additions and 8938 deletions

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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">98MB</small>
</label>
<button className="submit-primary" disabled={working}>
{working
? "正在提交…"
: selected.result_submitted_at
? "更新任务截图"
: "确认提交任务截图"}
</button>
{selected.result_submitted_at && (
<p className="submitted-at">{formatDate(selected.result_submitted_at, true)}</p>
)}
</form>
</div>
<ImageLightbox image={previewImage} onClose={() => setPreviewImage(null)} />
{toast && <div className="toast">{toast}</div>}
</main>
);
}
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> JPGPNG8MB</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> JPGPNG8MB</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>}