532 lines
15 KiB
TypeScript
532 lines
15 KiB
TypeScript
import { hostApiFetch } from '@/lib/host-api';
|
|
|
|
export type ProjectPublic = {
|
|
app_id: string;
|
|
title: string;
|
|
summary: string;
|
|
description?: string | null;
|
|
cover_url?: string | null;
|
|
category?: string | null;
|
|
age_band?: string | null;
|
|
difficulty?: string | null;
|
|
status?: string | null;
|
|
updated_at?: string | null;
|
|
playable?: boolean;
|
|
play_url?: string | null;
|
|
/** @deprecated Use play_url. Kept for one client compatibility release. */
|
|
runtime_url?: string | null;
|
|
creator_name?: string | null;
|
|
creator_age?: number | null;
|
|
buddy_name?: string | null;
|
|
buddy_sprite_url?: string | null;
|
|
buddy_pose_url?: string | null;
|
|
version_name?: string | null;
|
|
testing_ask?: string | null;
|
|
update_note?: string | null;
|
|
remix_note?: string | null;
|
|
how_to?: string | null;
|
|
learning_note?: string | null;
|
|
visual_alt?: string | null;
|
|
};
|
|
|
|
export type ProjectPage = {
|
|
items: ProjectPublic[];
|
|
next_cursor: string | null;
|
|
limit: number;
|
|
};
|
|
|
|
export type WorksAssetPublic = {
|
|
slug: string;
|
|
title: string;
|
|
summary: string;
|
|
category: string;
|
|
source: string;
|
|
source_url?: string | null;
|
|
license?: string | null;
|
|
license_url?: string | null;
|
|
series?: string | null;
|
|
tags: string[];
|
|
preview_url?: string | null;
|
|
image_urls: string[];
|
|
archive_size_bytes?: number | null;
|
|
extracted_file_count?: number | null;
|
|
published_at?: string | null;
|
|
updated_at?: string | null;
|
|
};
|
|
|
|
export type WorksAssetPage = {
|
|
items: WorksAssetPublic[];
|
|
next_cursor: string | null;
|
|
limit: number;
|
|
};
|
|
|
|
export type WorksAssetDownloadInput = {
|
|
slug: string;
|
|
projectId: string;
|
|
};
|
|
|
|
export type WorksAssetDownloadResult = {
|
|
slug: string;
|
|
filePath: string;
|
|
relativePath: string;
|
|
bytesWritten: number;
|
|
};
|
|
|
|
export type WorksProjectMetadataInput = {
|
|
app_id: string;
|
|
title: string;
|
|
summary: string;
|
|
description?: string | null;
|
|
cover_url?: string | null;
|
|
creator_name?: string | null;
|
|
creator_age?: number | null;
|
|
category?: string | null;
|
|
age_band?: string | null;
|
|
difficulty?: string | null;
|
|
};
|
|
|
|
export type WorksProjectVersionUpload = {
|
|
version_id: string;
|
|
review_status: string;
|
|
build_job_id?: string | null;
|
|
build_status?: string | null;
|
|
};
|
|
|
|
export type WorksProjectVersion = {
|
|
id: string;
|
|
version_name: string;
|
|
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;
|
|
};
|
|
|
|
export type WorksProjectVersionsPage = {
|
|
items: WorksProjectVersion[];
|
|
};
|
|
|
|
export type WorksProjectStatusDetail = {
|
|
project: ProjectPublic;
|
|
latest_version: WorksProjectVersion | null;
|
|
versions: WorksProjectVersion[];
|
|
};
|
|
|
|
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;
|
|
cover?: WorksProjectCoverUpload;
|
|
};
|
|
|
|
export type WorksProjectCoverUpload = {
|
|
fileName: string;
|
|
mimeType: string;
|
|
dataBase64: string;
|
|
};
|
|
|
|
export type WorksSubmissionBindingWarning = {
|
|
code: 'LOCAL_PREVIEW_BINDING_SAVE_FAILED';
|
|
message: string;
|
|
};
|
|
|
|
export type WorksProjectSourcePublishResult = {
|
|
package: WorksStaticPackageSummary;
|
|
upload: WorksProjectVersionUpload;
|
|
bindingWarning?: WorksSubmissionBindingWarning;
|
|
};
|
|
|
|
export type ReleaseJobStatus = {
|
|
jobId: string;
|
|
projectId: string;
|
|
state: 'queued' | 'running' | 'complete' | 'failed' | 'cancelled';
|
|
phase: 'queued' | 'packaging' | 'installing' | 'building' | 'archiving' | 'complete';
|
|
percent: number;
|
|
errorCode?: string;
|
|
errorMessage?: string;
|
|
result?: {
|
|
package: WorksStaticPackageSummary;
|
|
contract: Record<string, unknown>;
|
|
};
|
|
};
|
|
|
|
export type WorksSpeechTranscription = {
|
|
text: string;
|
|
model: string;
|
|
};
|
|
|
|
export type WorksSpeechTranscriptionInput = {
|
|
accessToken: string;
|
|
audioBase64: string;
|
|
fileName: string;
|
|
mimeType: string;
|
|
language?: string;
|
|
prompt?: string;
|
|
model?: string;
|
|
};
|
|
|
|
export type PlazaCard = {
|
|
id: string;
|
|
title: string;
|
|
summary: string;
|
|
coverImage: string | null;
|
|
type: string | null;
|
|
ageBand: string | null;
|
|
difficulty: string | null;
|
|
updatedAt: string | null;
|
|
playable: boolean;
|
|
runtimeUrl: string | null;
|
|
};
|
|
|
|
type WorksActionResponse<TField extends string, TValue> = {
|
|
success: boolean;
|
|
status?: number;
|
|
code?: string;
|
|
error?: string;
|
|
} & {
|
|
[key in TField]?: TValue;
|
|
};
|
|
|
|
type FetchWorksProjectsInput = {
|
|
q?: string;
|
|
category?: string;
|
|
limit?: number;
|
|
};
|
|
|
|
type FetchWorksAssetsInput = {
|
|
q?: string;
|
|
category?: string;
|
|
tag?: string;
|
|
source?: string;
|
|
limit?: number;
|
|
};
|
|
|
|
type FetchMyWorksProjectsInput = {
|
|
limit?: number;
|
|
};
|
|
|
|
export class WorksSquareApiError extends Error {
|
|
readonly statusCode?: number;
|
|
readonly code?: string;
|
|
|
|
constructor(message: string, statusCode?: number, code?: string) {
|
|
super(message);
|
|
this.name = 'WorksSquareApiError';
|
|
this.statusCode = statusCode;
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
function assertSuccess<TField extends string, TValue>(
|
|
response: WorksActionResponse<TField, TValue>,
|
|
field: TField,
|
|
fallback: string,
|
|
): TValue {
|
|
if (!response.success || response[field] === undefined) {
|
|
throw new WorksSquareApiError(response.error || fallback, response.status, response.code);
|
|
}
|
|
return response[field] as TValue;
|
|
}
|
|
|
|
function appendTrimmedParam(params: URLSearchParams, name: string, value: string | undefined): void {
|
|
const trimmed = value?.trim();
|
|
if (trimmed) {
|
|
params.set(name, trimmed);
|
|
}
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null;
|
|
}
|
|
|
|
function readNonEmptyString(value: unknown): string | null {
|
|
if (typeof value !== 'string') {
|
|
return null;
|
|
}
|
|
const trimmed = value.trim();
|
|
return trimmed ? trimmed : null;
|
|
}
|
|
|
|
function normalizeSpeechTranscription(value: unknown): WorksSpeechTranscription | null {
|
|
const directText = readNonEmptyString(value);
|
|
if (directText) {
|
|
return { text: directText, model: '' };
|
|
}
|
|
if (!isRecord(value)) {
|
|
return null;
|
|
}
|
|
|
|
const text = readNonEmptyString(value.text);
|
|
const model = readNonEmptyString(value.model) ?? '';
|
|
if (text) {
|
|
return { text, model };
|
|
}
|
|
|
|
for (const key of ['transcription', 'data', 'result', 'payload']) {
|
|
const nested = normalizeSpeechTranscription(value[key]);
|
|
if (nested) {
|
|
return {
|
|
text: nested.text,
|
|
model: nested.model || model,
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function toPlazaCard(project: ProjectPublic): PlazaCard {
|
|
return {
|
|
id: project.app_id,
|
|
title: project.title,
|
|
summary: project.summary,
|
|
coverImage: project.cover_url ?? null,
|
|
type: project.category ?? null,
|
|
ageBand: project.age_band ?? null,
|
|
difficulty: project.difficulty ?? null,
|
|
updatedAt: project.updated_at ?? null,
|
|
playable: Boolean(project.playable),
|
|
runtimeUrl: project.play_url ?? project.runtime_url ?? null,
|
|
};
|
|
}
|
|
|
|
export async function fetchWorksProjects(input: FetchWorksProjectsInput = {}): Promise<ProjectPage> {
|
|
const params = new URLSearchParams();
|
|
appendTrimmedParam(params, 'q', input.q);
|
|
appendTrimmedParam(params, 'category', input.category);
|
|
params.set('limit', String(input.limit ?? 24));
|
|
const query = params.toString();
|
|
const response = await hostApiFetch<WorksActionResponse<'page', ProjectPage>>(
|
|
`/api/works/projects${query ? `?${query}` : ''}`,
|
|
);
|
|
return assertSuccess(response, 'page', 'Failed to load Works Square projects');
|
|
}
|
|
|
|
export async function fetchWorksProject(appId: string): Promise<ProjectPublic> {
|
|
const response = await hostApiFetch<WorksActionResponse<'project', ProjectPublic>>(
|
|
`/api/works/projects/${encodeURIComponent(appId)}`,
|
|
);
|
|
return assertSuccess(response, 'project', 'Failed to load Works Square project');
|
|
}
|
|
|
|
export async function fetchWorksAssets(input: FetchWorksAssetsInput = {}): Promise<WorksAssetPage> {
|
|
const params = new URLSearchParams();
|
|
appendTrimmedParam(params, 'q', input.q);
|
|
appendTrimmedParam(params, 'category', input.category);
|
|
appendTrimmedParam(params, 'tag', input.tag);
|
|
appendTrimmedParam(params, 'source', input.source);
|
|
params.set('limit', String(input.limit ?? 24));
|
|
const query = params.toString();
|
|
const response = await hostApiFetch<WorksActionResponse<'assets', WorksAssetPage>>(
|
|
`/api/works/assets${query ? `?${query}` : ''}`,
|
|
);
|
|
return assertSuccess(response, 'assets', 'Failed to load Works Square assets');
|
|
}
|
|
|
|
export async function fetchWorksAsset(slug: string): Promise<WorksAssetPublic> {
|
|
const response = await hostApiFetch<WorksActionResponse<'asset', WorksAssetPublic>>(
|
|
`/api/works/assets/${encodeURIComponent(slug)}`,
|
|
);
|
|
return assertSuccess(response, 'asset', 'Failed to load Works Square asset');
|
|
}
|
|
|
|
export async function downloadWorksAssetToProject(
|
|
input: WorksAssetDownloadInput,
|
|
): Promise<WorksAssetDownloadResult> {
|
|
const response = await hostApiFetch<WorksActionResponse<'download', WorksAssetDownloadResult>>(
|
|
`/api/works/assets/${encodeURIComponent(input.slug)}/download`,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({ projectId: input.projectId }),
|
|
},
|
|
);
|
|
return assertSuccess(response, 'download', 'Failed to download Works Square asset');
|
|
}
|
|
|
|
export async function createWorksProject(
|
|
accessToken: string,
|
|
project: WorksProjectMetadataInput,
|
|
): Promise<ProjectPublic> {
|
|
const response = await hostApiFetch<WorksActionResponse<'project', ProjectPublic>>('/api/works/projects', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ accessToken, project }),
|
|
});
|
|
return assertSuccess(response, 'project', 'Failed to create Works Square project');
|
|
}
|
|
|
|
export async function fetchMyWorksProjects(
|
|
accessToken: string,
|
|
input: FetchMyWorksProjectsInput = {},
|
|
): Promise<ProjectPage> {
|
|
const params = new URLSearchParams();
|
|
params.set('limit', String(input.limit ?? 50));
|
|
const response = await hostApiFetch<WorksActionResponse<'page', ProjectPage>>(
|
|
`/api/works/projects/mine?${params.toString()}`,
|
|
{
|
|
headers: {
|
|
'X-NianCode-Access-Token': accessToken,
|
|
},
|
|
},
|
|
);
|
|
return assertSuccess(response, 'page', 'Failed to load your Works Square projects');
|
|
}
|
|
|
|
export async function fetchMyWorksProjectStatus(
|
|
accessToken: string,
|
|
appId: string,
|
|
): Promise<WorksProjectStatusDetail> {
|
|
const response = await hostApiFetch<WorksActionResponse<'status', WorksProjectStatusDetail>>(
|
|
`/api/works/projects/mine/${encodeURIComponent(appId)}/status`,
|
|
{
|
|
headers: {
|
|
'X-NianCode-Access-Token': accessToken,
|
|
},
|
|
},
|
|
);
|
|
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 publishWorksProjectSource(
|
|
input: WorksProjectSourcePublishInput,
|
|
): Promise<WorksProjectSourcePublishResult> {
|
|
const response = await hostApiFetch<{
|
|
success: boolean;
|
|
status?: number;
|
|
code?: string;
|
|
error?: string;
|
|
package?: WorksStaticPackageSummary;
|
|
upload?: WorksProjectVersionUpload;
|
|
binding_warning?: unknown;
|
|
}>('/api/works/projects/publish-source', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
projectId: input.projectId,
|
|
project: input.project,
|
|
...(input.cover ? { cover: input.cover } : {}),
|
|
}),
|
|
});
|
|
if (!response.success || !response.package || !response.upload) {
|
|
throw new WorksSquareApiError(
|
|
response.error || 'Failed to package and upload Works Square project',
|
|
response.status,
|
|
response.code,
|
|
);
|
|
}
|
|
const bindingWarning = isRecord(response.binding_warning)
|
|
&& response.binding_warning.code === 'LOCAL_PREVIEW_BINDING_SAVE_FAILED'
|
|
? {
|
|
code: 'LOCAL_PREVIEW_BINDING_SAVE_FAILED' as const,
|
|
message: '已提交云端,但本机预览绑定保存失败;可重新打开项目/重新提交。',
|
|
}
|
|
: undefined;
|
|
return {
|
|
package: response.package,
|
|
upload: response.upload,
|
|
...(bindingWarning ? { bindingWarning } : {}),
|
|
};
|
|
}
|
|
|
|
export async function startWorksReleaseJob(projectId: string): Promise<ReleaseJobStatus> {
|
|
const response = await hostApiFetch<{ success: boolean; job?: ReleaseJobStatus; error?: string; code?: string }>(
|
|
'/api/works/projects/release-jobs',
|
|
{ method: 'POST', body: JSON.stringify({ projectId }) },
|
|
);
|
|
if (!response.success || !response.job) {
|
|
throw new WorksSquareApiError(response.error || 'Failed to start release build', undefined, response.code);
|
|
}
|
|
return response.job;
|
|
}
|
|
|
|
export async function getWorksReleaseJob(jobId: string): Promise<ReleaseJobStatus> {
|
|
const response = await hostApiFetch<{ success: boolean; job?: ReleaseJobStatus; error?: string; code?: string }>(
|
|
`/api/works/projects/release-jobs/${encodeURIComponent(jobId)}`,
|
|
);
|
|
if (!response.success || !response.job) {
|
|
throw new WorksSquareApiError(response.error || 'Failed to read release build status', undefined, response.code);
|
|
}
|
|
return response.job;
|
|
}
|
|
|
|
export async function cancelWorksReleaseJob(jobId: string): Promise<ReleaseJobStatus> {
|
|
const response = await hostApiFetch<{ success: boolean; job?: ReleaseJobStatus; error?: string; code?: string }>(
|
|
`/api/works/projects/release-jobs/${encodeURIComponent(jobId)}/cancel`,
|
|
{ method: 'POST', body: JSON.stringify({}) },
|
|
);
|
|
if (!response.success || !response.job) {
|
|
throw new WorksSquareApiError(response.error || 'Failed to cancel release build', undefined, response.code);
|
|
}
|
|
return response.job;
|
|
}
|
|
|
|
export async function fetchWorksProjectVersions(
|
|
accessToken: string,
|
|
appId: string,
|
|
): Promise<WorksProjectVersionsPage> {
|
|
const response = await hostApiFetch<WorksActionResponse<'versions', WorksProjectVersionsPage>>(
|
|
`/api/works/projects/${encodeURIComponent(appId)}/versions`,
|
|
{
|
|
headers: {
|
|
'X-NianCode-Access-Token': accessToken,
|
|
},
|
|
},
|
|
);
|
|
return assertSuccess(response, 'versions', 'Failed to load Works Square uploaded versions');
|
|
}
|
|
|
|
export async function transcribeWorksSpeech(
|
|
input: WorksSpeechTranscriptionInput,
|
|
): Promise<WorksSpeechTranscription> {
|
|
const response = await hostApiFetch<WorksActionResponse<'transcription', unknown>>(
|
|
'/api/works/speech/transcriptions',
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
accessToken: input.accessToken,
|
|
audioBase64: input.audioBase64,
|
|
fileName: input.fileName,
|
|
mimeType: input.mimeType,
|
|
language: input.language,
|
|
prompt: input.prompt,
|
|
model: input.model,
|
|
}),
|
|
},
|
|
);
|
|
const transcriptionPayload = assertSuccess(response, 'transcription', 'Failed to transcribe speech');
|
|
const transcription = normalizeSpeechTranscription(transcriptionPayload);
|
|
if (!transcription) {
|
|
throw new WorksSquareApiError(response.error || 'Speech transcription response did not contain text', response.status);
|
|
}
|
|
return transcription;
|
|
}
|