实现 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>
|
||||
);
|
||||
}
|
||||
255
src/lib/works-project-publish.ts
Normal file
255
src/lib/works-project-publish.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
export const WORKS_APP_ID_PATTERN = /^[a-z0-9][a-z0-9-]{2,79}$/;
|
||||
|
||||
export type WorksPublishFailure = {
|
||||
title: string;
|
||||
reason: string;
|
||||
nextStep: string;
|
||||
};
|
||||
|
||||
const PUBLISH_FAILURES: Record<string, WorksPublishFailure> = {
|
||||
AUTH_REQUIRED: {
|
||||
title: '需要重新登录',
|
||||
reason: '当前登录状态已失效,无法提交作品。',
|
||||
nextStep: '重新登录 Makelore 后再提交。',
|
||||
},
|
||||
PROJECT_NOT_FOUND: {
|
||||
title: '找不到项目文件夹',
|
||||
reason: 'Makelore 无法读取当前项目。',
|
||||
nextStep: '重新打开项目,确认文件夹仍然存在后再提交。',
|
||||
},
|
||||
PROJECT_FILE_MISSING: {
|
||||
title: '项目文件不完整',
|
||||
reason: '发布所需的基础文件缺失。',
|
||||
nextStep: '请让开发助手补齐 package.json、package-lock.json 和 index.html 后重试。',
|
||||
},
|
||||
PROJECT_FILE_INVALID: {
|
||||
title: '项目配置无法读取',
|
||||
reason: 'package.json 或 package-lock.json 格式不正确。',
|
||||
nextStep: '请让开发助手修复项目配置文件后重试。',
|
||||
},
|
||||
VITE_NOT_DECLARED: {
|
||||
title: '项目还不是可发布的 Vite 工程',
|
||||
reason: 'package.json 中没有找到 Vite 依赖。',
|
||||
nextStep: '请让开发助手补齐 Vite 配置并确认本地可以启动。',
|
||||
},
|
||||
PACKAGE_MANAGER_UNSUPPORTED: {
|
||||
title: '项目缺少 npm 锁文件',
|
||||
reason: '当前自动提交只支持带 package-lock.json 的 npm 项目。',
|
||||
nextStep: '请让开发助手使用 npm 生成并提交 package-lock.json。',
|
||||
},
|
||||
LOCKFILE_UNSUPPORTED: {
|
||||
title: 'npm 锁文件版本过旧',
|
||||
reason: '当前 package-lock.json 不能用于安全构建。',
|
||||
nextStep: '请让开发助手使用新版 npm 重新生成 package-lock.json。',
|
||||
},
|
||||
INDEX_HTML_EMPTY: {
|
||||
title: '项目入口是空的',
|
||||
reason: '根目录的 index.html 没有可用内容。',
|
||||
nextStep: '请让开发助手修复页面入口,并确认本地可以打开。',
|
||||
},
|
||||
PROJECT_TOO_LARGE: {
|
||||
title: '项目文件太大',
|
||||
reason: '源码总大小超过平台允许范围。',
|
||||
nextStep: '删除不需要的大文件和生成文件后重新提交。',
|
||||
},
|
||||
ARCHIVE_TOO_LARGE: {
|
||||
title: '项目压缩包太大',
|
||||
reason: '安全打包后的文件仍超过平台限制。',
|
||||
nextStep: '移除不需要的图片、音视频或其他大文件后重试。',
|
||||
},
|
||||
PROJECT_TOO_MANY_FILES: {
|
||||
title: '项目文件太多',
|
||||
reason: '源码文件数量超过平台允许范围。',
|
||||
nextStep: '清理缓存、生成文件和不再使用的素材后重试。',
|
||||
},
|
||||
PROJECT_SYMLINK_UNSUPPORTED: {
|
||||
title: '项目包含不安全的链接文件',
|
||||
reason: '自动提交不会跟随符号链接读取其他位置的文件。',
|
||||
nextStep: '将需要的文件复制到项目内,并移除符号链接后重试。',
|
||||
},
|
||||
PROJECT_FILE_UNSUPPORTED: {
|
||||
title: '项目包含不支持的文件',
|
||||
reason: '部分文件类型无法安全打包。',
|
||||
nextStep: '请让开发助手移除特殊文件后重试。',
|
||||
},
|
||||
PROJECT_PATH_UNSUPPORTED: {
|
||||
title: '项目包含不支持的文件名',
|
||||
reason: '部分路径无法在云端安全解压。',
|
||||
nextStep: '请让开发助手重命名异常文件或文件夹后重试。',
|
||||
},
|
||||
PROJECT_CHANGED_DURING_PACKAGING: {
|
||||
title: '项目正在发生变化',
|
||||
reason: '打包时有文件仍在写入,无法保证提交内容完整。',
|
||||
nextStep: '等待开发助手或其他程序保存完成后重新提交。',
|
||||
},
|
||||
PROJECT_SUBMISSION_CONFLICT: {
|
||||
title: '这个作品已有版本在处理中',
|
||||
reason: '平台正在构建或审核上一版,暂时不能重复提交。',
|
||||
nextStep: '等待当前版本完成审核或被驳回后再提交。',
|
||||
},
|
||||
PROJECT_CREATE_REJECTED: {
|
||||
title: '作品信息没有通过检查',
|
||||
reason: '平台没有接受自动生成的作品信息。',
|
||||
nextStep: '请联系运营人员检查作品信息。',
|
||||
},
|
||||
PROJECT_OWNERSHIP_UNCONFIRMED: {
|
||||
title: '作品标识已被占用',
|
||||
reason: '自动生成的作品标识不属于当前账号。',
|
||||
nextStep: '请联系运营人员处理作品归属。',
|
||||
},
|
||||
SOURCE_PACKAGE_REJECTED: {
|
||||
title: '项目没有通过平台检查',
|
||||
reason: '云端发现项目包不符合安全构建要求。',
|
||||
nextStep: '请让开发助手检查依赖、入口文件和敏感文件后重试。',
|
||||
},
|
||||
PUBLISH_FORBIDDEN: {
|
||||
title: '当前账号不能发布这个作品',
|
||||
reason: '平台没有确认当前账号的作品权限。',
|
||||
nextStep: '确认登录了正确账号;如果仍然失败,请联系运营人员。',
|
||||
},
|
||||
WORKS_SQUARE_UNAVAILABLE: {
|
||||
title: '发布服务暂时不可用',
|
||||
reason: '平台当前没有完成这次提交。',
|
||||
nextStep: '请稍后重新提交;自动重试不会创建重复版本。',
|
||||
},
|
||||
BUILD_STATUS_UNAVAILABLE: {
|
||||
title: '暂时无法读取构建进度',
|
||||
reason: '作品已经提交,但 Makelore 暂时没有拿到最新状态。',
|
||||
nextStep: '稍后重新打开项目查看,不需要重复提交。',
|
||||
},
|
||||
BUILD_STATUS_TIMEOUT: {
|
||||
title: '构建时间比平时更长',
|
||||
reason: '作品仍可能在云端处理中。',
|
||||
nextStep: '稍后重新打开项目查看,不需要重复提交。',
|
||||
},
|
||||
DEPENDENCY_PREFETCH_FAILED: {
|
||||
title: '暂时无法下载项目依赖',
|
||||
reason: '云端没有成功准备 package-lock.json 中的依赖。',
|
||||
nextStep: '请检查 package-lock.json 是否已提交,并确认依赖名称和版本有效后重试。',
|
||||
},
|
||||
DEPENDENCY_PREFETCH_TIMEOUT: {
|
||||
title: '下载项目依赖超时',
|
||||
reason: '云端准备依赖的时间超过限制。',
|
||||
nextStep: '稍后重试;如果持续失败,请让开发助手精简依赖。',
|
||||
},
|
||||
BUILD_COMMAND_FAILED: {
|
||||
title: '项目没有构建成功',
|
||||
reason: '云端执行项目构建时发现代码或配置错误。',
|
||||
nextStep: '请让开发助手运行 npm run build,修复错误后提交新版本。',
|
||||
},
|
||||
BUILD_TIMEOUT: {
|
||||
title: '项目构建超时',
|
||||
reason: '云端构建时间超过平台限制。',
|
||||
nextStep: '请让开发助手减少构建步骤或过大的依赖后重试。',
|
||||
},
|
||||
BUILD_SANDBOX_UNAVAILABLE: {
|
||||
title: '云端构建环境暂时不可用',
|
||||
reason: '平台暂时无法启动安全构建环境。',
|
||||
nextStep: '稍后重新提交,无需修改项目。',
|
||||
},
|
||||
BUILD_PIPELINE_MISMATCH: {
|
||||
title: '项目发布方式不匹配',
|
||||
reason: '当前提交不是平台支持的 Makelore 静态项目格式。',
|
||||
nextStep: '请从当前项目重新点击一键提交,不要手工修改发布包。',
|
||||
},
|
||||
BUILD_PIPELINE_UNSUPPORTED: {
|
||||
title: '这个项目暂不支持自动发布',
|
||||
reason: '平台没有找到适合当前项目类型的安全构建方式。',
|
||||
nextStep: '请联系运营人员确认项目类型。',
|
||||
},
|
||||
VERSION_NOT_FOUND: {
|
||||
title: '平台没有找到本次提交',
|
||||
reason: '构建服务无法读取刚刚上传的版本。',
|
||||
nextStep: '请重新提交;如果持续失败,请联系运营人员。',
|
||||
},
|
||||
SOURCE_DIGEST_MISMATCH: {
|
||||
title: '上传内容校验失败',
|
||||
reason: '平台收到的项目内容与提交时不一致。',
|
||||
nextStep: '重新提交一次;如果持续失败,请联系运营人员。',
|
||||
},
|
||||
OUTPUT_MISSING: {
|
||||
title: '没有生成可运行页面',
|
||||
reason: '构建完成后没有找到发布所需的页面文件。',
|
||||
nextStep: '请让开发助手检查 Vite 输出目录和 index.html。',
|
||||
},
|
||||
OUTPUT_INVALID: {
|
||||
title: '生成的页面无法发布',
|
||||
reason: '构建输出缺少必要文件或包含不安全内容。',
|
||||
nextStep: '请让开发助手检查 Vite 构建输出后重试。',
|
||||
},
|
||||
OUTPUT_LIMIT_EXCEEDED: {
|
||||
title: '生成的页面文件太大',
|
||||
reason: '构建结果超过平台允许范围。',
|
||||
nextStep: '请让开发助手压缩素材、拆分资源或删除无用输出。',
|
||||
},
|
||||
RELEASE_STORE_FAILED: {
|
||||
title: '平台暂时无法保存发布文件',
|
||||
reason: '作品已经构建,但发布存储当前不可用。',
|
||||
nextStep: '稍后重新提交,无需修改项目。',
|
||||
},
|
||||
BUILD_STALE: {
|
||||
title: '构建任务已中断',
|
||||
reason: '云端构建任务长时间没有继续运行。',
|
||||
nextStep: '重新提交一次;如果持续发生,请联系运营人员。',
|
||||
},
|
||||
BROWSER_SMOKE_FAILED: {
|
||||
title: '自动打开作品时发现问题',
|
||||
reason: '云端浏览器检测到了白屏、运行错误或资源加载失败。',
|
||||
nextStep: '请让开发助手运行 npm run build,并在浏览器中检查构建结果后重试。',
|
||||
},
|
||||
BROWSER_SMOKE_TIMEOUT: {
|
||||
title: '自动打开作品超时',
|
||||
reason: '作品在规定时间内没有完成加载。',
|
||||
nextStep: '请让开发助手检查首屏资源大小和启动逻辑,优化后重新提交。',
|
||||
},
|
||||
BROWSER_SMOKE_UNAVAILABLE: {
|
||||
title: '自动验收环境暂时不可用',
|
||||
reason: '平台暂时无法启动用于检查作品的安全浏览器。',
|
||||
nextStep: '稍后重新提交,无需修改项目。',
|
||||
},
|
||||
BUILD_CANCELLED: {
|
||||
title: '构建已取消',
|
||||
reason: '平台没有继续处理这个版本。',
|
||||
nextStep: '确认没有其他版本正在处理后重新提交。',
|
||||
},
|
||||
BUILD_FAILED: {
|
||||
title: '项目没有构建成功',
|
||||
reason: '云端未能生成可审核的作品。',
|
||||
nextStep: '请让开发助手先确认 npm run build 成功,再提交新版本。',
|
||||
},
|
||||
};
|
||||
|
||||
export function describeWorksPublishFailure(
|
||||
code: string | null,
|
||||
statusCode?: number,
|
||||
): WorksPublishFailure {
|
||||
const knownCode = code?.trim().toUpperCase();
|
||||
if (knownCode && PUBLISH_FAILURES[knownCode]) {
|
||||
return PUBLISH_FAILURES[knownCode];
|
||||
}
|
||||
if (statusCode === 401) return PUBLISH_FAILURES.AUTH_REQUIRED;
|
||||
if (statusCode === 403) return PUBLISH_FAILURES.PUBLISH_FORBIDDEN;
|
||||
if (statusCode === 409) return PUBLISH_FAILURES.PROJECT_SUBMISSION_CONFLICT;
|
||||
if (statusCode === 413) return PUBLISH_FAILURES.ARCHIVE_TOO_LARGE;
|
||||
if (statusCode !== undefined && statusCode >= 500) {
|
||||
return PUBLISH_FAILURES.WORKS_SQUARE_UNAVAILABLE;
|
||||
}
|
||||
if (statusCode === 400 || statusCode === 422) {
|
||||
return PUBLISH_FAILURES.SOURCE_PACKAGE_REJECTED;
|
||||
}
|
||||
return {
|
||||
title: '提交没有完成',
|
||||
reason: 'Makelore 没有收到可确认的提交结果。',
|
||||
nextStep: '请稍后重新提交;如果持续失败,请联系运营人员。',
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefaultWorksAppId(project: { id: string; name?: string }): string {
|
||||
const normalize = (value: string) => value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
const fromId = normalize(project.id);
|
||||
const fallback = `makelore-${fromId || 'project'}`.slice(0, 80).replace(/-+$/g, '');
|
||||
return WORKS_APP_ID_PATTERN.test(fallback) ? fallback : 'makelore-project';
|
||||
}
|
||||
@@ -82,6 +82,8 @@ export type WorksProjectMetadataInput = {
|
||||
export type WorksProjectVersionUpload = {
|
||||
version_id: string;
|
||||
review_status: string;
|
||||
build_job_id?: string | null;
|
||||
build_status?: string | null;
|
||||
};
|
||||
|
||||
export type WorksProjectVersion = {
|
||||
@@ -90,6 +92,10 @@ export type WorksProjectVersion = {
|
||||
review_status: string;
|
||||
change_log: string;
|
||||
build_job_id: string | null;
|
||||
build_status: string | null;
|
||||
build_error_code: string | null;
|
||||
release_id: string | null;
|
||||
rejection_reason?: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
@@ -112,6 +118,36 @@ export type WorksProjectUploadInput = {
|
||||
zipFilePath: string;
|
||||
};
|
||||
|
||||
export type WorksStaticPackageSummary = {
|
||||
archiveName: string;
|
||||
sha256: string;
|
||||
fileCount: number;
|
||||
sourceBytes: number;
|
||||
archiveBytes: number;
|
||||
excludedCount: number;
|
||||
excludedPaths: string[];
|
||||
manifest: {
|
||||
schema_version: 1;
|
||||
kind: 'web';
|
||||
runtime: 'static';
|
||||
build: {
|
||||
preset: 'vite';
|
||||
package_manager: 'npm';
|
||||
entry: 'index.html';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type WorksProjectSourcePublishInput = {
|
||||
projectId: string;
|
||||
project: WorksProjectMetadataInput;
|
||||
};
|
||||
|
||||
export type WorksProjectSourcePublishResult = {
|
||||
package: WorksStaticPackageSummary;
|
||||
upload: WorksProjectVersionUpload;
|
||||
};
|
||||
|
||||
export type WorksSpeechTranscription = {
|
||||
text: string;
|
||||
model: string;
|
||||
@@ -152,6 +188,7 @@ export type PlazaCard = {
|
||||
type WorksActionResponse<TField extends string, TValue> = {
|
||||
success: boolean;
|
||||
status?: number;
|
||||
code?: string;
|
||||
error?: string;
|
||||
} & {
|
||||
[key in TField]?: TValue;
|
||||
@@ -177,11 +214,13 @@ type FetchMyWorksProjectsInput = {
|
||||
|
||||
export class WorksSquareApiError extends Error {
|
||||
readonly statusCode?: number;
|
||||
readonly code?: string;
|
||||
|
||||
constructor(message: string, statusCode?: number) {
|
||||
constructor(message: string, statusCode?: number, code?: string) {
|
||||
super(message);
|
||||
this.name = 'WorksSquareApiError';
|
||||
this.statusCode = statusCode;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +230,7 @@ function assertSuccess<TField extends string, TValue>(
|
||||
fallback: string,
|
||||
): TValue {
|
||||
if (!response.success || response[field] === undefined) {
|
||||
throw new WorksSquareApiError(response.error || fallback, response.status);
|
||||
throw new WorksSquareApiError(response.error || fallback, response.status, response.code);
|
||||
}
|
||||
return response[field] as TValue;
|
||||
}
|
||||
@@ -366,6 +405,15 @@ export async function fetchMyWorksProjectStatus(
|
||||
return assertSuccess(response, 'status', 'Failed to load Works Square project status');
|
||||
}
|
||||
|
||||
export async function fetchCurrentWorksProjectStatus(
|
||||
appId: string,
|
||||
): Promise<WorksProjectStatusDetail> {
|
||||
const response = await hostApiFetch<WorksActionResponse<'status', WorksProjectStatusDetail>>(
|
||||
`/api/works/projects/mine/${encodeURIComponent(appId)}/status`,
|
||||
);
|
||||
return assertSuccess(response, 'status', 'Failed to load Works Square project status');
|
||||
}
|
||||
|
||||
export async function uploadWorksProjectZip(
|
||||
input: WorksProjectUploadInput,
|
||||
): Promise<WorksProjectVersionUpload> {
|
||||
@@ -385,6 +433,33 @@ export async function uploadWorksProjectZip(
|
||||
return assertSuccess(response, 'upload', 'Failed to upload Works Square project zip');
|
||||
}
|
||||
|
||||
export async function publishWorksProjectSource(
|
||||
input: WorksProjectSourcePublishInput,
|
||||
): Promise<WorksProjectSourcePublishResult> {
|
||||
const response = await hostApiFetch<{
|
||||
success: boolean;
|
||||
status?: number;
|
||||
code?: string;
|
||||
error?: string;
|
||||
package?: WorksStaticPackageSummary;
|
||||
upload?: WorksProjectVersionUpload;
|
||||
}>('/api/works/projects/publish-source', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
projectId: input.projectId,
|
||||
project: input.project,
|
||||
}),
|
||||
});
|
||||
if (!response.success || !response.package || !response.upload) {
|
||||
throw new WorksSquareApiError(
|
||||
response.error || 'Failed to package and upload Works Square project',
|
||||
response.status,
|
||||
response.code,
|
||||
);
|
||||
}
|
||||
return { package: response.package, upload: response.upload };
|
||||
}
|
||||
|
||||
export async function armWorksCloudDeployment(projectId: string): Promise<WorksCloudDeploymentRecord> {
|
||||
const response = await hostApiFetch<WorksActionResponse<'deployment', WorksCloudDeploymentRecord>>(
|
||||
`/api/opencode/projects/${encodeURIComponent(projectId)}/works-cloud-deploy`,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { AgentCreationDialog, type AgentCreationInput } from '@/components/opencode/AgentCreationDialog';
|
||||
import { ProjectPublishAction } from '@/components/works/ProjectPublishAction';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import { buildConfiguredModelOptions, type ConfiguredModelOption } from '@/lib/model-options';
|
||||
import { getSkillDisplayInfo } from '@/lib/skill-display';
|
||||
@@ -319,9 +320,12 @@ export function ProjectConfiguration() {
|
||||
{!draft.initialized ? <div className="rounded-lg border border-foreground/15 bg-surface-tertiary px-5 py-3 text-sm font-semibold shadow-soft">新项目还没有联系人。点击“新增伙伴”,为它设置名字、头像、基础模型和职责后即可开始对话。</div> : null}
|
||||
<section className="grid gap-3 md:grid-cols-3"><ResourceCard id="models" icon="🧠" title="大脑(大语言模型)" subtitle={draft.defaultModel ? `默认:${draft.defaultModel}` : '使用运行时默认模型'} onClick={() => setDrawerMode('models')} /><ResourceCard id="skills" icon="⚡" title="工具箱(技能)" subtitle={`已安装${skills.length}个`} onClick={() => setDrawerMode('skills')} /><ResourceCard id="knowledge" icon="📓" title="笔记本(知识库)" subtitle={`已上传${knowledge.length}个`} onClick={() => setDrawerMode('knowledge')} /></section>
|
||||
<section className="config-motion-partners surface-card rounded-2xl border border-border/70 bg-background p-5 shadow-soft"><div className="mb-4 flex items-center justify-between gap-3"><div><h2 className="text-xl font-semibold">我的伙伴</h2><p className="mt-1 text-sm font-medium text-muted-foreground">每个联系人只属于当前项目;一个联系人可以拥有多条独立会话。</p></div><Button className="border border-brand/20 bg-brand-soft text-brand" onClick={() => { if (modelOptions.length === 0) { toast.error('请先在模型管理中配置至少一个可用模型。'); setDrawerMode('models'); return; } setCreatePartnerOpen(true); }}><Plus className="mr-2 h-4 w-4" />新增伙伴</Button></div><div data-testid="agent-cards-scroll-region" className="-m-2 flex gap-4 overflow-auto p-2">{draft.agents.filter((agent) => !agent.archivedAt).map((agent) => <AgentCard key={agent.id} agent={agent} onOpen={() => { setActiveAgentId(agent.id); setDrawerMode('agent'); }} />)}{draft.agents.every((agent) => agent.archivedAt) ? <div className="w-full rounded-xl border border-dashed border-foreground/15 p-6 text-center text-sm font-medium text-muted-foreground">还没有联系人,点击右上角创建第一个。</div> : null}</div>{draft.agents.some((agent) => agent.archivedAt) ? <div className="mt-4 border-t border-border/70 pt-4"><p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">已归档</p><div className="mt-2 flex flex-wrap gap-2">{draft.agents.filter((agent) => agent.archivedAt).map((agent) => <div key={agent.id} className="flex items-center gap-1"><Button type="button" variant="outline" className="border-border/70 bg-surface-subtle" onClick={() => updateAgent({ ...agent, archivedAt: null })}>{agent.name || '未命名联系人'} · 恢复</Button>{!agent.builtIn ? <Button type="button" variant="outline" size="icon" aria-label={'永久删除联系人 ' + (agent.name || '未命名联系人')} title="永久删除联系人" className="h-9 w-9 border-border/70 text-muted-foreground hover:bg-accent-soft hover:text-destructive" onClick={() => setDeleteAgentId(agent.id)}><Trash2 className="h-3.5 w-3.5" /></Button> : null}</div>)}</div></div> : null}</section>
|
||||
<div className="glass-surface config-motion-actions sticky bottom-3 flex items-center justify-between gap-3 rounded-2xl border border-border/70 bg-background/85 p-2 shadow-float">
|
||||
<div className="glass-surface config-motion-actions sticky bottom-3 flex flex-wrap items-start justify-between gap-3 rounded-2xl border border-border/70 bg-background/85 p-2 shadow-float">
|
||||
<Button type="button" variant="outline" onClick={() => setDeleteDialogOpen(true)} className="h-12 border border-foreground/15 bg-accent-soft px-5 font-semibold text-foreground shadow-soft"><Trash2 className="mr-2 h-5 w-5" />删除项目</Button>
|
||||
<Button onClick={() => void submit()} disabled={saving} className="project-save-button h-12 border border-brand/20 bg-brand px-6 font-semibold text-primary-foreground"><Check className="mr-2 h-5 w-5" />{saving ? '保存中…' : draft.initialized ? '保存项目配置' : '确认并完成初始化'}</Button>
|
||||
<div className="ml-auto flex flex-wrap items-start justify-end gap-3">
|
||||
<Button onClick={() => void submit()} disabled={saving} className="project-save-button h-12 border border-brand/20 bg-brand px-6 font-semibold text-primary-foreground"><Check className="mr-2 h-5 w-5" />{saving ? '保存中…' : draft.initialized ? '保存项目配置' : '确认并完成初始化'}</Button>
|
||||
<ProjectPublishAction key={activeProject.id} project={activeProject} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AgentCreationDialog
|
||||
|
||||
Reference in New Issue
Block a user