实现 Makelore 一键提交审核
需求:让非专业用户在项目操作区一次提交,运营审核通过后直接发布。 实现:由 Electron Main 完成安全打包、自动版本、幂等重试和状态脱敏;补齐友好失败反馈、唯一提交入口及隔离 Electron E2E fixture。
This commit is contained in:
199
src/components/works/ProjectPublishAction.tsx
Normal file
199
src/components/works/ProjectPublishAction.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { CheckCircle2, Loader2, UploadCloud, XCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
createDefaultWorksAppId,
|
||||
describeWorksPublishFailure,
|
||||
type WorksPublishFailure,
|
||||
} from '@/lib/works-project-publish';
|
||||
import {
|
||||
fetchCurrentWorksProjectStatus,
|
||||
publishWorksProjectSource,
|
||||
type WorksProjectMetadataInput,
|
||||
} from '@/lib/works-square';
|
||||
import type { OpencodeProject } from '@/types/opencode';
|
||||
|
||||
type PublishPhase = 'idle' | 'submitting' | 'polling' | 'succeeded' | 'failed' | 'uncertain';
|
||||
|
||||
type BuildVersion = {
|
||||
id: string;
|
||||
build_status?: string | null;
|
||||
build_error_code?: string | null;
|
||||
};
|
||||
|
||||
type ProjectPublishActionProps = {
|
||||
project: OpencodeProject;
|
||||
};
|
||||
|
||||
const BUILD_POLL_INTERVAL_MS = 2_000;
|
||||
const BUILD_POLL_ATTEMPTS = 300;
|
||||
|
||||
function delay(milliseconds: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
window.setTimeout(resolve, milliseconds);
|
||||
});
|
||||
}
|
||||
|
||||
function readErrorCode(error: unknown): string | null {
|
||||
if (!error || typeof error !== 'object' || !('code' in error)) return null;
|
||||
const value = (error as { code?: unknown }).code;
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
function readErrorStatus(error: unknown): number | undefined {
|
||||
if (!error || typeof error !== 'object') return undefined;
|
||||
const value = 'statusCode' in error
|
||||
? (error as { statusCode?: unknown }).statusCode
|
||||
: ('status' in error ? (error as { status?: unknown }).status : undefined);
|
||||
return typeof value === 'number' ? value : undefined;
|
||||
}
|
||||
|
||||
function failureFromVersion(version: BuildVersion): WorksPublishFailure {
|
||||
const code = version.build_error_code
|
||||
?? (version.build_status === 'cancelled' ? 'BUILD_CANCELLED' : 'BUILD_FAILED');
|
||||
return describeWorksPublishFailure(code);
|
||||
}
|
||||
|
||||
function buttonLabel(phase: PublishPhase): string {
|
||||
if (phase === 'submitting') return '正在检查并提交…';
|
||||
if (phase === 'polling') return '正在等待云端检查…';
|
||||
if (phase === 'succeeded') return '已提交,等待运营审核';
|
||||
if (phase === 'uncertain') return '已提交,请稍后查看';
|
||||
if (phase === 'failed') return '重新提交审核';
|
||||
return '一键提交审核';
|
||||
}
|
||||
|
||||
export function ProjectPublishAction({ project }: ProjectPublishActionProps) {
|
||||
const [phase, setPhase] = useState<PublishPhase>('idle');
|
||||
const [failure, setFailure] = useState<WorksPublishFailure | null>(null);
|
||||
const pollGeneration = useRef(0);
|
||||
|
||||
useEffect(() => () => {
|
||||
pollGeneration.current += 1;
|
||||
}, []);
|
||||
|
||||
const appId = createDefaultWorksAppId(project);
|
||||
const title = project.name.trim().slice(0, 160) || 'Makelore 作品';
|
||||
const metadata: WorksProjectMetadataInput = {
|
||||
app_id: appId,
|
||||
title,
|
||||
summary: `${title},由 Makelore 创建的作品。`,
|
||||
cover_url: null,
|
||||
category: 'web',
|
||||
age_band: null,
|
||||
difficulty: null,
|
||||
};
|
||||
|
||||
async function pollBuildStatus(versionId: string, generation: number): Promise<void> {
|
||||
for (let attempt = 0; attempt < BUILD_POLL_ATTEMPTS; attempt += 1) {
|
||||
await delay(BUILD_POLL_INTERVAL_MS);
|
||||
if (pollGeneration.current !== generation) return;
|
||||
|
||||
let status;
|
||||
try {
|
||||
status = await fetchCurrentWorksProjectStatus(appId);
|
||||
} catch {
|
||||
if (pollGeneration.current !== generation) return;
|
||||
setPhase('uncertain');
|
||||
setFailure(describeWorksPublishFailure('BUILD_STATUS_UNAVAILABLE'));
|
||||
return;
|
||||
}
|
||||
if (pollGeneration.current !== generation) return;
|
||||
|
||||
const versions = status.versions as BuildVersion[];
|
||||
const latestVersion = status.latest_version as BuildVersion | null;
|
||||
const version = versions.find((item) => item.id === versionId)
|
||||
?? (latestVersion?.id === versionId ? latestVersion : null);
|
||||
if (!version) continue;
|
||||
|
||||
if (version.build_status === 'succeeded') {
|
||||
setPhase('succeeded');
|
||||
setFailure(null);
|
||||
return;
|
||||
}
|
||||
if (version.build_status === 'failed' || version.build_status === 'cancelled') {
|
||||
setPhase('failed');
|
||||
setFailure(failureFromVersion(version));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (pollGeneration.current === generation) {
|
||||
setPhase('uncertain');
|
||||
setFailure(describeWorksPublishFailure('BUILD_STATUS_TIMEOUT'));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
pollGeneration.current += 1;
|
||||
const generation = pollGeneration.current;
|
||||
setFailure(null);
|
||||
setPhase('submitting');
|
||||
|
||||
try {
|
||||
const result = await publishWorksProjectSource({
|
||||
projectId: project.id,
|
||||
project: metadata,
|
||||
});
|
||||
if (pollGeneration.current !== generation) return;
|
||||
setPhase('polling');
|
||||
void pollBuildStatus(result.upload.version_id, generation);
|
||||
} catch (error) {
|
||||
if (pollGeneration.current !== generation) return;
|
||||
setPhase('failed');
|
||||
setFailure(describeWorksPublishFailure(readErrorCode(error), readErrorStatus(error)));
|
||||
}
|
||||
}
|
||||
|
||||
const locked = phase === 'submitting'
|
||||
|| phase === 'polling'
|
||||
|| phase === 'succeeded'
|
||||
|| phase === 'uncertain';
|
||||
const busy = phase === 'submitting' || phase === 'polling';
|
||||
|
||||
return (
|
||||
<div className="flex min-w-[250px] flex-col items-stretch gap-2 sm:items-end">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={locked}
|
||||
onClick={() => void handleSubmit()}
|
||||
className="h-12 w-full border border-brand/20 bg-brand px-6 font-semibold text-primary-foreground sm:w-auto"
|
||||
>
|
||||
{busy
|
||||
? <Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
: phase === 'succeeded'
|
||||
? <CheckCircle2 className="mr-2 h-5 w-5" />
|
||||
: phase === 'failed' || phase === 'uncertain'
|
||||
? <XCircle className="mr-2 h-5 w-5" />
|
||||
: <UploadCloud className="mr-2 h-5 w-5" />}
|
||||
{buttonLabel(phase)}
|
||||
</Button>
|
||||
|
||||
{phase === 'submitting' || phase === 'polling' || phase === 'succeeded' ? (
|
||||
<p
|
||||
data-testid="project-publish-status"
|
||||
aria-live="polite"
|
||||
className="max-w-md text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{phase === 'submitting'
|
||||
? 'Makelore 正在自动检查、打包并提交项目,不需要准备 ZIP。'
|
||||
: phase === 'polling'
|
||||
? '项目已提交,正在等待云端构建与自动浏览器检查。'
|
||||
: '已提交,等待运营审核。审核通过后会直接发布。'}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{failure ? (
|
||||
<div
|
||||
data-testid="project-publish-failure"
|
||||
aria-live="polite"
|
||||
className="max-w-md rounded-xl border border-destructive/30 bg-destructive/5 px-3 py-2 text-left text-xs leading-5 text-foreground"
|
||||
>
|
||||
<p className="font-semibold">{failure.title}</p>
|
||||
<p className="text-muted-foreground">{failure.reason}</p>
|
||||
<p className="font-medium">下一步:{failure.nextStep}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user