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>}

View File

@@ -1,45 +0,0 @@
import { access, cp, mkdir, rm } from "node:fs/promises";
import { resolve } from "node:path";
import type { Plugin } from "vite";
async function exists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return false;
}
throw error;
}
}
// Packages Sites metadata and migrations after Vite finishes compiling.
export function sites(): Plugin {
let root = process.cwd();
return {
name: "sites",
apply: "build",
configResolved(config) {
root = config.root;
},
async closeBundle() {
const outputDirectory = resolve(root, "dist", ".openai");
const hostingConfig = resolve(root, ".openai", "hosting.json");
const drizzleSource = resolve(root, "drizzle");
await rm(outputDirectory, { recursive: true, force: true });
await mkdir(outputDirectory, { recursive: true });
if (await exists(hostingConfig)) {
await cp(hostingConfig, resolve(outputDirectory, "hosting.json"));
}
if (await exists(drizzleSource)) {
await cp(drizzleSource, resolve(outputDirectory, "drizzle"), {
recursive: true,
});
}
},
};
}

View File

@@ -1,13 +0,0 @@
import { env } from "cloudflare:workers";
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./schema";
export function getDb() {
if (!env.DB) {
throw new Error(
"Cloudflare D1 binding `DB` is unavailable. Set the `d1` field in .openai/hosting.json to `DB` or let your control plane inject the real binding values before using the database."
);
}
return drizzle(env.DB, { schema });
}

View File

@@ -1,4 +0,0 @@
// Intentionally empty by default.
// Add Drizzle tables here when the site actually needs a database.
// See examples/d1/db/schema.ts for an opt-in example.
export {};

View File

@@ -1,7 +0,0 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
out: "./drizzle",
schema: "./db/schema.ts",
dialect: "sqlite",
});

View File

@@ -1,5 +0,0 @@
{
"version": "7",
"dialect": "sqlite",
"entries": []
}

View File

@@ -1,7 +1,13 @@
import type { NextConfig } from "next";
import path from "node:path";
const nextConfig: NextConfig = {
/* config options here */
output: "export",
basePath: "/koc",
assetPrefix: "/koc",
trailingSlash: true,
images: { unoptimized: true },
turbopack: { root: path.resolve(process.cwd()) },
};
export default nextConfig;

File diff suppressed because it is too large Load Diff

View File

@@ -6,37 +6,27 @@
"node": ">=22.13.0"
},
"scripts": {
"dev": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext dev",
"build": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext build",
"start": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext start",
"dev": "next dev -p 3001",
"build": "next build",
"start": "npx serve out -l 3001",
"test": "npm run build && node --test tests/rendered-html.test.mjs",
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next",
"db:generate": "drizzle-kit generate"
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next --ignore-pattern out"
},
"dependencies": {
"drizzle-orm": "0.45.2",
"fflate": "0.7.4",
"next": "16.2.6",
"next": "^16.3.0",
"react": "19.2.6",
"react-dom": "19.2.6"
},
"devDependencies": {
"@cloudflare/vite-plugin": "1.37.1",
"@tailwindcss/postcss": "4.2.1",
"@types/node": "22.19.19",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.2",
"@vitejs/plugin-rsc": "0.5.26",
"drizzle-kit": "0.31.10",
"eslint": "9.39.4",
"eslint-config-next": "16.2.6",
"react-server-dom-webpack": "19.2.6",
"tailwindcss": "4.2.1",
"typescript": "5.9.3",
"vinext": "0.0.50",
"vite": "8.0.13",
"wrangler": "4.92.0"
"typescript": "5.9.3"
},
"type": "module"
}

View File

@@ -14,16 +14,16 @@ test("builds the branded external task shell", async () => {
assert.match(layout, /KOC LOOP外部任务领取/);
assert.match(layout, /og\.png/);
assert.match(page, /正在打开任务/);
await access(new URL("../dist/server/index.js", import.meta.url));
await access(new URL("../out/index.html", import.meta.url));
});
test("keeps claiming minimal and backfill one-to-one", async () => {
const [page, layout, packageJson, hosting] =
const [page, layout, packageJson, nextConfig] =
await Promise.all([
readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"),
readFile(new URL("../package.json", import.meta.url), "utf8"),
readFile(new URL("../.openai/hosting.json", import.meta.url), "utf8"),
readFile(new URL("../next.config.ts", import.meta.url), "utf8"),
]);
assert.match(page, /微信号\s*\/\s*手机号/);
@@ -55,7 +55,8 @@ test("keeps claiming minimal and backfill one-to-one", async () => {
assert.match(page, /这篇笔记已回填,不会与其他笔记错配/);
assert.doesNotMatch(page, /批量回填/);
assert.doesNotMatch(page, /复制标题和正文/);
assert.match(page, /PRODUCTION_ADMIN_ORIGIN/);
assert.match(page, /CONFIGURED_ADMIN_ORIGIN/);
assert.match(page, /window\.location\.origin/);
assert.match(page, /\/api\/partner-upload/);
assert.match(page, /"X-KOC-Distribution":\s*selectedItem\.id/);
assert.match(page, /"X-KOC-Upload-Kind":\s*kind/);
@@ -65,22 +66,50 @@ test("keeps claiming minimal and backfill one-to-one", async () => {
assert.match(page, /creatorExposure/);
assert.match(page, /creatorViews/);
assert.match(page, /截图仅用于运营核对不再自动OCR/);
assert.match(page, /evidenceImageUrl/);
assert.match(page, /evidenceImageUrl\(selected, "publish"\)/);
assert.match(page, /evidenceImageUrl\(selected, "creator"\)/);
assert.match(page, /ImageLightbox/);
assert.match(page, /点击查看大图/);
assert.match(page, /publishScreenshotPreview/);
assert.match(page, /creatorScreenshotPreview/);
assert.match(page, /曝光量/);
assert.match(page, /阅读量/);
assert.doesNotMatch(page, /recognizeCreatorMetrics/);
assert.match(page, /note-index \$\{item\.publish_url \? "done" : ""\}/);
assert.match(page, /note-index \$\{\(isScreenshotTask \? item\.result_submitted_at : item\.publish_url\) \? "done" : ""\}/);
assert.match(packageJson, /"fflate":\s*"0\.7\.4"/);
assert.doesNotMatch(packageJson, /tesseract\.js/);
assert.match(layout, /逐篇查看笔记详情并一一回填/);
assert.doesNotMatch(packageJson, /react-loading-skeleton/);
const hostingConfig = JSON.parse(hosting);
assert.equal(hostingConfig.d1, null);
assert.equal(hostingConfig.r2, null);
assert.match(nextConfig, /output: "export"/);
assert.match(nextConfig, /basePath: "\/koc"/);
await access(new URL("../public/og.png", import.meta.url));
await access(new URL("../public/favicon.svg", import.meta.url));
});
test("renders screenshot-only tasks with anonymous delegation and multi-image uploads", async () => {
const [page, styles] = await Promise.all([
readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/globals.css", import.meta.url), "utf8"),
]);
assert.match(page, /screenshot_collect/);
assert.match(page, /小红书搜索关键词/);
assert.match(page, /上传小红书 AI 总结截图/);
assert.match(page, /submit_screenshot_result/);
assert.match(page, /"task-result"/);
assert.match(page, /不需要填写发布链接或其他数据/);
assert.match(page, /multiple/);
assert.match(page, /taskResultScreenshots/);
assert.match(page, /task-result-thumb/);
assert.match(page, /最多9张/);
assert.doesNotMatch(page, /target="_blank"[\s\S]{0,200}task-result-thumb uploaded/);
assert.match(styles, /\.task-result-gallery/);
assert.match(styles, /\.image-lightbox/);
assert.match(styles, /\.evidence-preview-button/);
});
test("shows D1 timestamps in Beijing time", () => {
const stored = "2026-07-29 05:36:00";
assert.equal(parseStoredDate(stored).toISOString(), "2026-07-29T05:36:00.000Z");

View File

@@ -30,5 +30,5 @@
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
"exclude": ["node_modules", "build", "worker", "examples"]
}

View File

@@ -1,59 +0,0 @@
import vinext from "vinext";
import { defineConfig } from "vite";
import hostingConfig from "./.openai/hosting.json";
import { sites } from "./build/sites-vite-plugin";
const SITE_CREATOR_PLACEHOLDER_DATABASE_ID =
"00000000-0000-4000-8000-000000000000";
const { d1, r2 } = hostingConfig;
// macOS Seatbelt blocks FSEvents, so Codex previews need polling for HMR.
const isCodexSeatbeltSandbox = process.env.CODEX_SANDBOX === "seatbelt";
const localBindingConfig = {
main: "./worker/index.ts",
compatibility_flags: ["nodejs_compat"],
d1_databases: d1
? [
{
binding: d1,
database_name: "site-creator-d1",
database_id: SITE_CREATOR_PLACEHOLDER_DATABASE_ID,
},
]
: [],
r2_buckets: r2
? [
{
binding: r2,
bucket_name: "site-creator-r2",
},
]
: [],
};
export default defineConfig(async () => {
// Keep Wrangler and Miniflare state project-local. These are non-secret tool
// settings; application environment belongs in ignored `.env*` files.
process.env.WRANGLER_WRITE_LOGS ??= "false";
process.env.WRANGLER_LOG_PATH ??= ".wrangler/logs";
process.env.MINIFLARE_REGISTRY_PATH ??= ".wrangler/registry";
// Wrangler snapshots its log path while the Cloudflare plugin is imported.
const { cloudflare } = await import("@cloudflare/vite-plugin");
return {
server: isCodexSeatbeltSandbox
? { watch: { useFsEvents: false, usePolling: true } }
: undefined,
plugins: [
vinext(),
sites(),
cloudflare({
viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] },
config: localBindingConfig,
}),
],
};
});

View File

@@ -1,47 +0,0 @@
/** Cloudflare Worker entry point for the vinext-starter template. */
import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } from "vinext/server/image-optimization";
import handler from "vinext/server/app-router-entry";
interface Env {
ASSETS: Fetcher;
DB: D1Database;
IMAGES: {
input(stream: ReadableStream): {
transform(options: Record<string, unknown>): {
output(options: { format: string; quality: number }): Promise<{ response(): Response }>;
};
};
};
}
interface ExecutionContext {
waitUntil(promise: Promise<unknown>): void;
passThroughOnException(): void;
}
// Image security config. SVG sources with .svg extension auto-skip the
// optimization endpoint on the client side (served directly, no proxy).
// To route SVGs through the optimizer (with security headers), set
// dangerouslyAllowSVG: true in next.config.js and uncomment below:
// const imageConfig: ImageConfig = { dangerouslyAllowSVG: true };
const worker = {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/_vinext/image") {
const allowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES];
return handleImageOptimization(request, {
fetchAsset: (path) => env.ASSETS.fetch(new Request(new URL(path, request.url))),
transformImage: async (body, { width, format, quality }) => {
const result = await env.IMAGES.input(body).transform(width > 0 ? { width } : {}).output({ format, quality });
return result.response();
},
}, allowedWidths);
}
return handler.fetch(request, env, ctx);
},
};
export default worker;