feat: require cover for first project submission

This commit is contained in:
2026-08-17 21:27:34 +08:00
parent 7e8d9e3811
commit 145a6ce571
7 changed files with 318 additions and 121 deletions

View File

@@ -23,6 +23,7 @@ import {
import {
fetchCurrentWorksProjectStatus,
publishWorksProjectSource,
type WorksProjectCoverUpload,
type WorksProjectMetadataInput,
} from '@/lib/works-square';
import type { OpencodeProject } from '@/types/opencode';
@@ -55,6 +56,30 @@ type ProjectPublishActionProps = {
const BUILD_POLL_INTERVAL_MS = 2_000;
const BUILD_POLL_ATTEMPTS = 300;
const MAX_PROJECT_COVER_BYTES = 10 * 1024 * 1024;
const PROJECT_COVER_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
function matchesCoverSignature(bytes: Uint8Array, mimeType: string): boolean {
if (mimeType === 'image/png') {
return bytes.length >= 8
&& [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every((value, index) => bytes[index] === value);
}
if (mimeType === 'image/jpeg') {
return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
}
return bytes.length >= 12
&& String.fromCharCode(...bytes.slice(0, 4)) === 'RIFF'
&& String.fromCharCode(...bytes.slice(8, 12)) === 'WEBP';
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = '';
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
}
return window.btoa(binary);
}
function delay(milliseconds: number): Promise<void> {
return new Promise((resolve) => {
@@ -106,6 +131,8 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
const [difficulty, setDifficulty] = useState('');
const [checkingProjectStatus, setCheckingProjectStatus] = useState(false);
const [existingProject, setExistingProject] = useState<ExistingProjectMetadata | null>(null);
const [cover, setCover] = useState<WorksProjectCoverUpload | null>(null);
const [coverPreview, setCoverPreview] = useState<string | null>(null);
const pollGeneration = useRef(0);
const authUser = useAuthStore((state) => state.user);
@@ -164,7 +191,10 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
}
}
async function submitProject(metadata: WorksProjectMetadataInput): Promise<void> {
async function submitProject(
metadata: WorksProjectMetadataInput,
projectCover?: WorksProjectCoverUpload,
): Promise<void> {
pollGeneration.current += 1;
const generation = pollGeneration.current;
setFailure(null);
@@ -175,6 +205,7 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
const result = await publishWorksProjectSource({
projectId: project.id,
project: metadata,
...(projectCover ? { cover: projectCover } : {}),
});
if (pollGeneration.current !== generation) return;
setBindingWarning(result.bindingWarning?.message ?? null);
@@ -191,6 +222,34 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
}
}
async function handleCoverChange(file: File | undefined): Promise<void> {
setCover(null);
setCoverPreview(null);
if (!file) return;
const mimeType = file.type.toLowerCase();
if (!PROJECT_COVER_MIME_TYPES.has(mimeType)) {
setFormError('项目封面仅支持 PNG、JPEG 或 WebP 图片。');
return;
}
if (file.size === 0) {
setFormError('项目封面不能为空,请重新选择图片。');
return;
}
if (file.size > MAX_PROJECT_COVER_BYTES) {
setFormError('项目封面不能超过 10 MiB请选择更小的图片。');
return;
}
const bytes = new Uint8Array(await file.arrayBuffer());
if (!matchesCoverSignature(bytes, mimeType)) {
setFormError('项目封面内容与图片格式不符,请重新选择 PNG、JPEG 或 WebP 图片。');
return;
}
const dataBase64 = bytesToBase64(bytes);
setCover({ fileName: file.name, mimeType, dataBase64 });
setCoverPreview(`data:${mimeType};base64,${dataBase64}`);
setFormError(null);
}
async function openPublishDialog(): Promise<void> {
setFailure(null);
setCheckingProjectStatus(true);
@@ -249,6 +308,10 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
setFormError('请填写项目简介。');
return;
}
if (!cover) {
setFormError('请选择项目封面。首次提交必须上传 PNG、JPEG 或 WebP 图片。');
return;
}
if (!trimmedCreatorName) {
setFormError('请填写发布者姓名。');
return;
@@ -271,7 +334,7 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
difficulty: difficulty.trim() || null,
};
setDialogOpen(false);
await submitProject(metadata);
await submitProject(metadata, cover);
}
const locked = checkingProjectStatus
@@ -326,7 +389,7 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
<DialogDescription>
{existingProject
? '当前发布契约不支持安全修改已有作品资料。本次只会提交新的构建版本,现有名称、简介、作者信息和封面都会保持不变。'
: '首次创建会保存这些作品信息并提交构建结果。当前发布契约暂不支持上传封面,因此本次作品将不设置封面。发布者年龄指作者本人年龄。'}
: '首次创建会原子保存作品信息与项目封面,再提交构建结果。发布者年龄指作者本人年龄。'}
</DialogDescription>
</DialogHeader>
@@ -400,6 +463,40 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
/>
</div>
<div className="grid gap-2">
<Label htmlFor="project-publish-cover"> <span className="text-destructive">*</span></Label>
<Input
id="project-publish-cover"
type="file"
accept="image/png,image/jpeg,image/webp"
className="sr-only"
onChange={(event) => void handleCoverChange(event.target.files?.[0])}
/>
<div className="flex min-w-0 items-center gap-3 rounded-xl border border-border/80 bg-muted/30 p-3">
{coverPreview ? (
<img
src={coverPreview}
alt="项目封面预览"
className="h-20 w-20 shrink-0 rounded-xl object-cover outline outline-1 -outline-offset-1 outline-black/10"
/>
) : (
<div className="flex h-20 w-20 shrink-0 items-center justify-center rounded-xl bg-muted text-xs text-muted-foreground [box-shadow:inset_0_0_0_1px_rgba(0,0,0,0.1)]">
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{cover?.fileName ?? '请选择一张图片'}</p>
<p className="mt-1 text-xs text-muted-foreground">PNGJPEG WebP 10 MiB</p>
<label
htmlFor="project-publish-cover"
className="mt-2 inline-flex min-h-10 cursor-pointer items-center rounded-md border border-input bg-background px-4 text-sm font-medium hover:bg-accent hover:text-accent-foreground"
>
{cover ? '重新选择' : '选择封面'}
</label>
</div>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="project-publish-age-band"></Label>
@@ -424,7 +521,7 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
</div>
<p className="rounded-xl bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
{projectType}
{projectType}
</p>
</>}
@@ -451,14 +548,14 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
{phase === 'submitting'
? existingProject
? '作品资料与封面保持不变Makelore 正在生成并提交新版本。'
: 'Makelore 正在创建无封面作品、生成本次构建结果并提交,不需要准备 ZIP。'
: 'Makelore 正在创建作品并上传封面、生成本次构建结果,不需要准备 ZIP。'
: phase === 'polling'
? existingProject
? '新版本已上传,现有作品资料与封面保持不变,正在等待平台校验。'
: '无封面作品与本次构建结果已提交,正在等待平台校验。'
: '作品封面与本次构建结果已提交,正在等待平台校验。'
: existingProject
? '新版本已提交,现有作品资料与封面保持不变。'
: '无封面作品已提交,等待运营审核。审核通过后会直接发布。'}
: '作品与封面已提交,等待运营审核。审核通过后会直接发布。'}
</p>
) : null}