2026-06-02 12:38:01 +08:00
|
|
|
import type {
|
|
|
|
|
WebCanvasWorkflow,
|
|
|
|
|
WebCanvasWorkflowNode,
|
|
|
|
|
WebEnterpriseUsageSummary,
|
|
|
|
|
WebProjectSummary,
|
|
|
|
|
WebUsageSummary,
|
|
|
|
|
WebUserSession,
|
|
|
|
|
} from "../types";
|
|
|
|
|
import {
|
|
|
|
|
getErrorMessage,
|
|
|
|
|
getServerBaseUrl,
|
|
|
|
|
isRecord,
|
|
|
|
|
isServerRequestError,
|
|
|
|
|
readStoredSession,
|
|
|
|
|
serverRequest,
|
|
|
|
|
unwrapApiPayload,
|
|
|
|
|
writeStoredSession,
|
|
|
|
|
} from "./serverConnection";
|
|
|
|
|
|
|
|
|
|
interface LoginInput {
|
|
|
|
|
username: string;
|
|
|
|
|
password: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface RegisterInput extends LoginInput {
|
|
|
|
|
betaCode: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface EmailAuthInput {
|
|
|
|
|
email: string;
|
|
|
|
|
password: string;
|
|
|
|
|
username?: string;
|
|
|
|
|
betaCode?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface PhoneAuthInput {
|
|
|
|
|
phone: string;
|
|
|
|
|
code: string;
|
|
|
|
|
password?: string;
|
|
|
|
|
betaCode?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface UpdateProfileInput {
|
|
|
|
|
avatarUrl?: string | null;
|
|
|
|
|
avatarOssKey?: string | null;
|
|
|
|
|
bio?: string | null;
|
|
|
|
|
backgroundUrl?: string | null;
|
|
|
|
|
profileBackgroundUrl?: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface DeleteProjectOptions {
|
|
|
|
|
cleanupUserData?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface WechatLoginTicket {
|
|
|
|
|
configured: boolean;
|
|
|
|
|
url?: string;
|
|
|
|
|
state?: string;
|
|
|
|
|
message?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface WechatLoginSessionStatus {
|
|
|
|
|
status: "pending" | "completed" | "failed" | "expired" | "missing" | "consumed" | string;
|
|
|
|
|
error?: string;
|
|
|
|
|
session?: WebUserSession;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const getBaseUrl = getServerBaseUrl;
|
|
|
|
|
const request = serverRequest;
|
|
|
|
|
const isHttpError = isServerRequestError;
|
|
|
|
|
const PROJECT_CONTENT_ENRICH_CONCURRENCY = 1;
|
|
|
|
|
let projectContentEnrichmentDisabled = false;
|
|
|
|
|
|
|
|
|
|
function toNumber(value: unknown, fallback = 0): number {
|
|
|
|
|
const numberValue = typeof value === "number" ? value : Number(value);
|
|
|
|
|
return Number.isFinite(numberValue) ? numberValue : fallback;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toStringValue(value: unknown, fallback = ""): string {
|
|
|
|
|
if (typeof value === "string") {
|
|
|
|
|
const trimmed = value.trim();
|
|
|
|
|
return trimmed || fallback;
|
|
|
|
|
}
|
|
|
|
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
|
|
|
return String(value);
|
|
|
|
|
}
|
|
|
|
|
return fallback;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toNullableString(value: unknown): string | null {
|
|
|
|
|
if (typeof value !== "string") return null;
|
|
|
|
|
const trimmed = value.trim();
|
|
|
|
|
return trimmed || null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toNullableId(value: unknown): number | string | null {
|
|
|
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
|
|
|
return toNullableString(value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toIdValue(value: unknown, fallback: string): number | string {
|
|
|
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
|
|
|
return toStringValue(value, fallback);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isPlaceholderProjectText(value: string | null | undefined): boolean {
|
|
|
|
|
if (!value) return true;
|
|
|
|
|
return /^(新建项目|新建创作|未命名项目|Untitled project)$/i.test(value.trim());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isPlaceholderProjectDescription(value: string | null | undefined): boolean {
|
|
|
|
|
if (!value) return true;
|
|
|
|
|
return /^(从空白画布开始|从空白画布开始,直接进入节点式创作。|最近更新的项目)$/i.test(value.trim());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function chooseProjectText(contentText: string | null, currentText: string | null, fallback: string): string {
|
|
|
|
|
if (contentText && (!isPlaceholderProjectText(contentText) || isPlaceholderProjectText(currentText))) {
|
|
|
|
|
return contentText;
|
|
|
|
|
}
|
|
|
|
|
return currentText || contentText || fallback;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function chooseProjectDescription(contentText: string | null, currentText: string | null): string | null {
|
|
|
|
|
if (contentText && (!isPlaceholderProjectDescription(contentText) || isPlaceholderProjectDescription(currentText))) {
|
|
|
|
|
return contentText;
|
|
|
|
|
}
|
|
|
|
|
return currentText || contentText || null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pickFirstString(...values: unknown[]): string | null {
|
|
|
|
|
for (const value of values) {
|
|
|
|
|
const next = toNullableString(value);
|
|
|
|
|
if (next) return next;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toProjectSummary(
|
|
|
|
|
raw: Record<string, unknown>,
|
|
|
|
|
source: WebProjectSummary["source"],
|
|
|
|
|
): WebProjectSummary {
|
|
|
|
|
return {
|
|
|
|
|
id: toStringValue(raw.id),
|
|
|
|
|
name: toStringValue(raw.name, "未命名项目"),
|
|
|
|
|
description: toNullableString(raw.description),
|
|
|
|
|
thumbnailUrl: toNullableString(raw.thumbnail_url ?? raw.thumbnailUrl),
|
|
|
|
|
updatedAt: toStringValue(raw.updated_at ?? raw.updatedAt, "刚刚"),
|
|
|
|
|
storyboardCount: toNumber(raw.storyboard_count ?? raw.storyboardCount),
|
|
|
|
|
imageCount: toNumber(raw.image_count ?? raw.imageCount),
|
|
|
|
|
videoCount: toNumber(raw.video_count ?? raw.videoCount),
|
|
|
|
|
source,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildProjectSummaryFromWorkflow(
|
|
|
|
|
workflow: WebCanvasWorkflow,
|
|
|
|
|
source: WebProjectSummary["source"],
|
|
|
|
|
errorMessage?: string,
|
|
|
|
|
): WebProjectSummary {
|
|
|
|
|
const previewNode = workflow.nodes.find((node) => node.previewUrl);
|
|
|
|
|
return {
|
|
|
|
|
id: workflow.id,
|
|
|
|
|
name: workflow.title,
|
|
|
|
|
description: workflow.description,
|
|
|
|
|
thumbnailUrl: previewNode?.previewUrl ?? null,
|
|
|
|
|
updatedAt: "刚刚",
|
|
|
|
|
storyboardCount: workflow.nodes.length,
|
|
|
|
|
imageCount: workflow.nodes.filter((node) => node.kind === "image").length,
|
|
|
|
|
videoCount: workflow.nodes.filter((node) => node.kind === "video").length,
|
|
|
|
|
source,
|
|
|
|
|
...(errorMessage ? { errorMessage } : {}),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function unwrapProjectContentPayload(payload: unknown): unknown {
|
|
|
|
|
const unwrapped = unwrapApiPayload(payload);
|
|
|
|
|
return isRecord(unwrapped) && "content" in unwrapped ? unwrapped.content : unwrapped;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getContentWorkflowRecord(content: unknown): Record<string, unknown> | null {
|
|
|
|
|
if (!isRecord(content)) return null;
|
|
|
|
|
const workflow = content.workflowData ?? content.workflow ?? content.workflow_data;
|
|
|
|
|
return isRecord(workflow) ? workflow : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getContentNodes(content: unknown): Record<string, unknown>[] {
|
|
|
|
|
const workflow = getContentWorkflowRecord(content);
|
|
|
|
|
const nodeSource =
|
|
|
|
|
(workflow && Array.isArray(workflow.nodes) ? workflow.nodes : null) ??
|
|
|
|
|
(isRecord(content) && Array.isArray(content.nodes) ? content.nodes : null);
|
|
|
|
|
return Array.isArray(nodeSource) ? nodeSource.filter(isRecord) : [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getContentArray(content: unknown, key: string): Record<string, unknown>[] {
|
|
|
|
|
if (!isRecord(content)) return [];
|
|
|
|
|
const value = content[key];
|
|
|
|
|
return Array.isArray(value) ? value.filter(isRecord) : [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function countNodesByKind(nodes: Record<string, unknown>[], kind: string): number {
|
|
|
|
|
return nodes.filter((node) => node.kind === kind || node.type === kind).length;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pickProjectPreviewUrl(content: unknown): string | null {
|
|
|
|
|
if (!isRecord(content)) return null;
|
|
|
|
|
const workflow = getContentWorkflowRecord(content);
|
|
|
|
|
const nodes = getContentNodes(content);
|
|
|
|
|
const storyboards = getContentArray(content, "storyboards");
|
|
|
|
|
const videos = getContentArray(content, "videos");
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
pickFirstString(
|
|
|
|
|
content.thumbnailUrl,
|
|
|
|
|
content.thumbnail_url,
|
|
|
|
|
content.coverUrl,
|
|
|
|
|
content.cover_url,
|
|
|
|
|
workflow?.thumbnailUrl,
|
|
|
|
|
workflow?.thumbnail_url,
|
|
|
|
|
nodes.find((node) => pickFirstString(node.previewUrl, node.preview_url, node.imageUrl, node.image_url))
|
|
|
|
|
?.previewUrl,
|
|
|
|
|
nodes.find((node) => pickFirstString(node.previewUrl, node.preview_url, node.imageUrl, node.image_url))
|
|
|
|
|
?.preview_url,
|
|
|
|
|
nodes.find((node) => pickFirstString(node.previewUrl, node.preview_url, node.imageUrl, node.image_url))
|
|
|
|
|
?.imageUrl,
|
|
|
|
|
nodes.find((node) => pickFirstString(node.previewUrl, node.preview_url, node.imageUrl, node.image_url))
|
|
|
|
|
?.image_url,
|
|
|
|
|
storyboards.find((item) => pickFirstString(item.imageUrl, item.image_url, item.coverUrl, item.cover_url))
|
|
|
|
|
?.imageUrl,
|
|
|
|
|
storyboards.find((item) => pickFirstString(item.imageUrl, item.image_url, item.coverUrl, item.cover_url))
|
|
|
|
|
?.image_url,
|
|
|
|
|
storyboards.find((item) => pickFirstString(item.imageUrl, item.image_url, item.coverUrl, item.cover_url))
|
|
|
|
|
?.coverUrl,
|
|
|
|
|
storyboards.find((item) => pickFirstString(item.imageUrl, item.image_url, item.coverUrl, item.cover_url))
|
|
|
|
|
?.cover_url,
|
|
|
|
|
videos.find((item) => pickFirstString(item.coverUrl, item.cover_url, item.thumbnailUrl, item.thumbnail_url))
|
|
|
|
|
?.coverUrl,
|
|
|
|
|
videos.find((item) => pickFirstString(item.coverUrl, item.cover_url, item.thumbnailUrl, item.thumbnail_url))
|
|
|
|
|
?.cover_url,
|
|
|
|
|
videos.find((item) => pickFirstString(item.coverUrl, item.cover_url, item.thumbnailUrl, item.thumbnail_url))
|
|
|
|
|
?.thumbnailUrl,
|
|
|
|
|
videos.find((item) => pickFirstString(item.coverUrl, item.cover_url, item.thumbnailUrl, item.thumbnail_url))
|
|
|
|
|
?.thumbnail_url,
|
|
|
|
|
) || null
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mergeProjectSummaryWithContent(project: WebProjectSummary, payload: unknown): WebProjectSummary {
|
|
|
|
|
const content = unwrapProjectContentPayload(payload);
|
|
|
|
|
if (!isRecord(content)) return project;
|
|
|
|
|
|
|
|
|
|
const workflow = getContentWorkflowRecord(content);
|
|
|
|
|
const nodes = getContentNodes(content);
|
|
|
|
|
const storyboards = getContentArray(content, "storyboards");
|
|
|
|
|
const videos = getContentArray(content, "videos");
|
|
|
|
|
const contentTitle = pickFirstString(content.name, content.projectName, content.title, workflow?.title);
|
|
|
|
|
const contentDescription = pickFirstString(
|
|
|
|
|
content.description,
|
|
|
|
|
content.projectDescription,
|
|
|
|
|
content.summary,
|
|
|
|
|
workflow?.description,
|
|
|
|
|
);
|
|
|
|
|
const imageCount =
|
|
|
|
|
countNodesByKind(nodes, "image") ||
|
|
|
|
|
storyboards.filter((item) => pickFirstString(item.imageUrl, item.image_url, item.coverUrl, item.cover_url)).length;
|
|
|
|
|
const videoCount =
|
|
|
|
|
countNodesByKind(nodes, "video") ||
|
|
|
|
|
videos.length ||
|
|
|
|
|
storyboards.filter((item) => pickFirstString(item.videoUrl, item.video_url)).length;
|
|
|
|
|
const storyboardCount = nodes.length || storyboards.length;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...project,
|
|
|
|
|
name: chooseProjectText(contentTitle, project.name, project.name || "未命名项目"),
|
|
|
|
|
description: chooseProjectDescription(contentDescription, project.description ?? null),
|
|
|
|
|
thumbnailUrl: pickProjectPreviewUrl(content) || project.thumbnailUrl || null,
|
|
|
|
|
storyboardCount: storyboardCount || project.storyboardCount,
|
|
|
|
|
imageCount: imageCount || project.imageCount,
|
|
|
|
|
videoCount: videoCount || project.videoCount,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function enrichProjectSummariesWithContent(projects: WebProjectSummary[]): Promise<WebProjectSummary[]> {
|
|
|
|
|
if (projectContentEnrichmentDisabled) return projects;
|
|
|
|
|
|
|
|
|
|
const enriched = [...projects];
|
|
|
|
|
|
|
|
|
|
for (let index = 0; index < projects.length; index += PROJECT_CONTENT_ENRICH_CONCURRENCY) {
|
|
|
|
|
if (projectContentEnrichmentDisabled) break;
|
|
|
|
|
const batch = projects.slice(index, index + PROJECT_CONTENT_ENRICH_CONCURRENCY);
|
|
|
|
|
const results = await Promise.allSettled(
|
|
|
|
|
batch.map(async (project) => {
|
|
|
|
|
const payload = await request<unknown>(`/projects/${encodeURIComponent(project.id)}/content?resolveMedia=1`);
|
|
|
|
|
return mergeProjectSummaryWithContent(project, payload);
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
results.forEach((result, offset) => {
|
|
|
|
|
const targetIndex = index + offset;
|
|
|
|
|
if (result.status === "fulfilled") {
|
|
|
|
|
enriched[targetIndex] = result.value;
|
|
|
|
|
} else {
|
|
|
|
|
const status = isHttpError(result.reason) ? result.reason.status : undefined;
|
|
|
|
|
if (status === 404 || (typeof status === "number" && status >= 500)) {
|
|
|
|
|
projectContentEnrichmentDisabled = true;
|
|
|
|
|
}
|
|
|
|
|
enriched[targetIndex] = {
|
|
|
|
|
...enriched[targetIndex],
|
|
|
|
|
errorMessage: getErrorMessage(result.reason),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return enriched;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createWorkflowFingerprint(workflow: WebCanvasWorkflow): string {
|
|
|
|
|
const payload = JSON.stringify({
|
|
|
|
|
title: workflow.title,
|
|
|
|
|
description: workflow.description,
|
|
|
|
|
settings: workflow.settings,
|
|
|
|
|
nodes: workflow.nodes.map((node) => ({
|
|
|
|
|
id: node.id,
|
|
|
|
|
kind: node.kind,
|
|
|
|
|
label: node.label,
|
|
|
|
|
detail: node.detail,
|
|
|
|
|
previewUrl: node.previewUrl || "",
|
|
|
|
|
})),
|
|
|
|
|
edges: workflow.edges.map((edge) => ({
|
|
|
|
|
id: edge.id,
|
|
|
|
|
source: edge.source,
|
|
|
|
|
target: edge.target,
|
|
|
|
|
label: edge.label || "",
|
|
|
|
|
})),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let hash = 0x811c9dc5;
|
|
|
|
|
for (let i = 0; i < payload.length; i += 1) {
|
|
|
|
|
hash ^= payload.charCodeAt(i);
|
|
|
|
|
hash = Math.imul(hash, 0x01000193);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return `wf-${(hash >>> 0).toString(16).padStart(8, "0")}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toActivePackages(value: unknown): WebUserSession["user"]["activePackages"] {
|
|
|
|
|
if (!Array.isArray(value)) return undefined;
|
|
|
|
|
|
|
|
|
|
return value.filter(isRecord).map((entry) => ({
|
|
|
|
|
name: toStringValue(entry.name, "Preview package"),
|
|
|
|
|
expiresAt: toStringValue(entry.expiresAt ?? entry.expires_at, ""),
|
|
|
|
|
remainingImage: toNumber(entry.remainingImage ?? entry.remaining_image),
|
|
|
|
|
remainingVideo: toNumber(entry.remainingVideo ?? entry.remaining_video),
|
|
|
|
|
remainingText: toNumber(entry.remainingText ?? entry.remaining_text),
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeUser(raw: unknown): WebUserSession["user"] | null {
|
|
|
|
|
const payload = unwrapApiPayload(raw);
|
|
|
|
|
const candidate = isRecord(payload) && isRecord(payload.user) ? payload.user : payload;
|
|
|
|
|
if (!isRecord(candidate)) return null;
|
|
|
|
|
|
|
|
|
|
const id = candidate.id ?? candidate.userId ?? candidate.user_id;
|
|
|
|
|
const username = toStringValue(candidate.username ?? candidate.name, "预览用户");
|
|
|
|
|
if (id === undefined || !username) return null;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: typeof id === "number" && Number.isFinite(id) ? id : toStringValue(id),
|
|
|
|
|
username,
|
|
|
|
|
displayName: toNullableString(candidate.displayName ?? candidate.display_name ?? candidate.nickname ?? candidate.name),
|
|
|
|
|
bio: toNullableString(candidate.bio ?? candidate.profileBio ?? candidate.profile_bio ?? candidate.description ?? candidate.intro),
|
|
|
|
|
avatarUrl: toNullableString(candidate.avatarUrl ?? candidate.avatar_url),
|
|
|
|
|
backgroundUrl: toNullableString(
|
|
|
|
|
candidate.profileBackgroundUrl ??
|
|
|
|
|
candidate.profile_background_url ??
|
|
|
|
|
candidate.backgroundUrl ??
|
|
|
|
|
candidate.background_url ??
|
|
|
|
|
candidate.coverUrl ??
|
|
|
|
|
candidate.cover_url,
|
|
|
|
|
),
|
|
|
|
|
email: toNullableString(candidate.email),
|
|
|
|
|
emailVerified: candidate.emailVerified === true || candidate.email_verified === true || candidate.email_verified === 1,
|
|
|
|
|
phone: toNullableString(candidate.phone),
|
|
|
|
|
authProvider: toNullableString(candidate.authProvider ?? candidate.auth_provider),
|
|
|
|
|
sessionId: toNullableString(candidate.sessionId ?? candidate.session_id),
|
|
|
|
|
sessionStartedAt: toNullableString(candidate.sessionStartedAt ?? candidate.session_started_at),
|
|
|
|
|
role: toNullableString(candidate.role) ?? undefined,
|
|
|
|
|
accountType: toNullableString(candidate.accountType ?? candidate.account_type) ?? undefined,
|
|
|
|
|
enterpriseId: toNullableString(candidate.enterpriseId ?? candidate.enterprise_id),
|
|
|
|
|
enterpriseName: toNullableString(candidate.enterpriseName ?? candidate.enterprise_name),
|
|
|
|
|
enterpriseRole: toNullableString(candidate.enterpriseRole ?? candidate.enterprise_role) ?? undefined,
|
|
|
|
|
enterpriseAdminUserId: toNullableId(candidate.enterpriseAdminUserId ?? candidate.enterprise_admin_user_id),
|
|
|
|
|
balanceCents: toNumber(
|
|
|
|
|
candidate.balanceCents ?? candidate.balance_cents ?? candidate.userBalanceCents ?? candidate.user_balance_cents,
|
|
|
|
|
),
|
|
|
|
|
enterpriseBalanceCents: toNumber(
|
|
|
|
|
candidate.enterpriseBalanceCents ??
|
|
|
|
|
candidate.enterprise_balance_cents ??
|
|
|
|
|
candidate.enterpriseBalance ??
|
|
|
|
|
candidate.enterprise_balance,
|
|
|
|
|
),
|
|
|
|
|
activePackages: toActivePackages(candidate.activePackages ?? candidate.active_packages),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function extractProjectRows(payload: unknown): Record<string, unknown>[] {
|
|
|
|
|
const unwrapped = unwrapApiPayload(payload);
|
|
|
|
|
if (Array.isArray(unwrapped)) {
|
|
|
|
|
return unwrapped.filter(isRecord);
|
|
|
|
|
}
|
|
|
|
|
if (!isRecord(unwrapped)) return [];
|
|
|
|
|
|
|
|
|
|
const rows = unwrapped.projects ?? unwrapped.items;
|
|
|
|
|
return Array.isArray(rows) ? rows.filter(isRecord) : [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isCanvasWorkflow(value: unknown): value is WebCanvasWorkflow {
|
|
|
|
|
if (!isRecord(value)) return false;
|
|
|
|
|
return (
|
|
|
|
|
typeof value.id === "string" &&
|
|
|
|
|
typeof value.title === "string" &&
|
|
|
|
|
typeof value.description === "string" &&
|
|
|
|
|
typeof value.version === "number" && value.version >= 1 &&
|
|
|
|
|
isRecord(value.settings) &&
|
|
|
|
|
Array.isArray(value.nodes) &&
|
|
|
|
|
Array.isArray(value.edges)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isLegacyWorkflowData(value: unknown): value is Record<string, unknown> {
|
|
|
|
|
if (!isRecord(value)) return false;
|
|
|
|
|
return (
|
|
|
|
|
typeof value.version === "number" &&
|
|
|
|
|
Array.isArray(value.nodes) &&
|
|
|
|
|
Array.isArray(value.edges)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function migrateLegacyWorkflowData(old: Record<string, unknown>, wrapper: Record<string, unknown>, projectId: string): WebCanvasWorkflow {
|
|
|
|
|
const viewport = isRecord(old.viewport) ? old.viewport : {};
|
|
|
|
|
return {
|
|
|
|
|
id: old.id as string || projectId,
|
|
|
|
|
version: 1,
|
|
|
|
|
title: String(wrapper.name || wrapper.title || "未命名项目"),
|
|
|
|
|
description: String(wrapper.description || ""),
|
|
|
|
|
source: (wrapper.source as WebCanvasWorkflow["source"]) || "blank",
|
|
|
|
|
settings: {
|
|
|
|
|
model: String(isRecord(old.settings) ? old.settings.model || "Nano Banana Pro" : "Nano Banana Pro"),
|
|
|
|
|
ratio: String(isRecord(old.settings) ? old.settings.ratio || "1:1" : "1:1"),
|
|
|
|
|
duration: String(isRecord(old.settings) ? old.settings.duration || "0s" : "0s"),
|
|
|
|
|
resolution: String(isRecord(old.settings) ? old.settings.resolution || "2K" : "2K"),
|
|
|
|
|
},
|
|
|
|
|
viewport: {
|
|
|
|
|
x: Number(viewport.x || 0),
|
|
|
|
|
y: Number(viewport.y || 0),
|
|
|
|
|
zoom: Number(viewport.zoom || viewport.scale || 1),
|
|
|
|
|
},
|
|
|
|
|
nodes: (Array.isArray(old.nodes) ? old.nodes : []).map((n: unknown) => isRecord(n) ? { ...n, position: isRecord(n.position) ? { ...n.position } : { x: 0, y: 0 } } : n) as WebCanvasWorkflowNode[],
|
|
|
|
|
edges: Array.isArray(old.edges) ? old.edges : [],
|
|
|
|
|
packages: Array.isArray(old.packages) ? old.packages : [],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cloneWorkflow(workflow: WebCanvasWorkflow): WebCanvasWorkflow {
|
|
|
|
|
return {
|
|
|
|
|
...workflow,
|
|
|
|
|
settings: { ...workflow.settings },
|
|
|
|
|
nodes: workflow.nodes.map((node) => ({ ...node, position: { ...node.position } })),
|
|
|
|
|
edges: workflow.edges.map((edge) => ({ ...edge })),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeProjectContent(payload: unknown, projectId: string): WebCanvasWorkflow {
|
|
|
|
|
const unwrapped = unwrapApiPayload(payload);
|
|
|
|
|
const content = isRecord(unwrapped) && "content" in unwrapped ? unwrapped.content : unwrapped;
|
|
|
|
|
|
|
|
|
|
// New format: content.workflowData is a full WebCanvasWorkflow
|
|
|
|
|
if (isRecord(content) && isCanvasWorkflow(content.workflowData)) {
|
|
|
|
|
return cloneWorkflow({ ...content.workflowData, id: content.workflowData.id || projectId });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// New format: content.workflow is a full WebCanvasWorkflow
|
|
|
|
|
if (isRecord(content) && isCanvasWorkflow(content.workflow)) {
|
|
|
|
|
return cloneWorkflow({ ...content.workflow, id: content.workflow.id || projectId });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Content itself is a WebCanvasWorkflow
|
|
|
|
|
if (isCanvasWorkflow(content)) {
|
|
|
|
|
return cloneWorkflow({ ...content, id: content.id || projectId });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Legacy format: wrapper has name/description, workflowData has nodes/edges/viewport
|
|
|
|
|
if (isRecord(content) && isLegacyWorkflowData(content.workflowData)) {
|
|
|
|
|
return migrateLegacyWorkflowData(content.workflowData, content, projectId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new Error("Project content did not include a canvas workflow");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeLoginResult(payload: unknown): WebUserSession | null {
|
|
|
|
|
const unwrapped = unwrapApiPayload(payload);
|
|
|
|
|
if (!isRecord(unwrapped) || typeof unwrapped.token !== "string") return null;
|
|
|
|
|
|
|
|
|
|
const user = normalizeUser(unwrapped.user ?? unwrapped);
|
|
|
|
|
if (!user) return null;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
token: unwrapped.token,
|
|
|
|
|
user,
|
|
|
|
|
source: "server",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateStoredSessionUser(user: WebUserSession["user"]): WebUserSession | null {
|
|
|
|
|
const stored = readStoredSession();
|
|
|
|
|
if (!stored) return null;
|
|
|
|
|
|
|
|
|
|
const session: WebUserSession = {
|
|
|
|
|
...stored,
|
|
|
|
|
user: {
|
|
|
|
|
...stored.user,
|
|
|
|
|
...user,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
writeStoredSession(session);
|
|
|
|
|
return session;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeUsageSummary(payload: unknown): WebUsageSummary {
|
|
|
|
|
const unwrapped = unwrapApiPayload(payload);
|
|
|
|
|
const raw = isRecord(unwrapped) ? unwrapped : {};
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
balanceCents: toNumber(
|
|
|
|
|
raw.balanceCents ?? raw.balance_cents ?? raw.currentBalanceCents ?? raw.current_balance_cents,
|
|
|
|
|
),
|
|
|
|
|
enterpriseBalanceCents:
|
|
|
|
|
toOptionalNumber(
|
|
|
|
|
raw.enterpriseBalanceCents ??
|
|
|
|
|
raw.enterprise_balance_cents ??
|
|
|
|
|
raw.enterpriseBalance ??
|
|
|
|
|
raw.enterprise_balance,
|
|
|
|
|
) ?? undefined,
|
|
|
|
|
imageUsed: toNumber(raw.imageUsed ?? raw.image_used ?? raw.imageCount ?? raw.image_count),
|
|
|
|
|
videoUsed: toNumber(raw.videoUsed ?? raw.video_used ?? raw.videoCount ?? raw.video_count),
|
|
|
|
|
textUsed: toNumber(
|
|
|
|
|
raw.textUsed ?? raw.text_used ?? raw.textTokens ?? raw.text_tokens ?? raw.total_prompt_tokens,
|
|
|
|
|
),
|
|
|
|
|
source: "server",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toOptionalNumber(value: unknown): number | null {
|
|
|
|
|
if (value === undefined || value === null || value === "") return null;
|
|
|
|
|
const numberValue = typeof value === "number" ? value : Number(value);
|
|
|
|
|
return Number.isFinite(numberValue) ? numberValue : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeEnterpriseUsageSummary(payload: unknown): WebEnterpriseUsageSummary {
|
|
|
|
|
const unwrapped = unwrapApiPayload(payload);
|
|
|
|
|
const raw = isRecord(unwrapped) ? unwrapped : {};
|
|
|
|
|
const memberRows = Array.isArray(raw.members) ? raw.members.filter(isRecord) : [];
|
|
|
|
|
const modelRows = Array.isArray(raw.modelBreakdown)
|
|
|
|
|
? raw.modelBreakdown.filter(isRecord)
|
|
|
|
|
: Array.isArray(raw.model_breakdown)
|
|
|
|
|
? raw.model_breakdown.filter(isRecord)
|
|
|
|
|
: [];
|
|
|
|
|
const recordRows = Array.isArray(raw.records)
|
|
|
|
|
? raw.records.filter(isRecord)
|
|
|
|
|
: Array.isArray(raw.items)
|
|
|
|
|
? raw.items.filter(isRecord)
|
|
|
|
|
: [];
|
|
|
|
|
const trendRows = Array.isArray(raw.dailyTrend)
|
|
|
|
|
? raw.dailyTrend.filter(isRecord)
|
|
|
|
|
: Array.isArray(raw.daily_trend)
|
|
|
|
|
? raw.daily_trend.filter(isRecord)
|
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
enterpriseId: toStringValue(raw.enterpriseId ?? raw.enterprise_id),
|
|
|
|
|
enterpriseName: toStringValue(raw.enterpriseName ?? raw.enterprise_name, "Enterprise"),
|
|
|
|
|
balanceCents: toNumber(raw.balanceCents ?? raw.balance_cents ?? raw.enterpriseBalanceCents ?? raw.enterprise_balance_cents),
|
|
|
|
|
totalUsedCents: toNumber(raw.totalUsedCents ?? raw.total_used_cents ?? raw.usedCents ?? raw.used_cents),
|
|
|
|
|
members: memberRows.map((member, index) => ({
|
|
|
|
|
userId: toIdValue(member.userId ?? member.user_id ?? member.id, `member-${index + 1}`),
|
|
|
|
|
username: toStringValue(member.username ?? member.userName ?? member.user_name ?? member.name, "employee"),
|
|
|
|
|
displayName: toNullableString(member.displayName ?? member.display_name ?? member.nickname ?? member.name),
|
|
|
|
|
role: toStringValue(member.role ?? member.enterpriseRole ?? member.enterprise_role, "employee"),
|
|
|
|
|
usedCents: toNumber(
|
|
|
|
|
member.usedCents ??
|
|
|
|
|
member.used_cents ??
|
|
|
|
|
member.amountCents ??
|
|
|
|
|
member.amount_cents ??
|
|
|
|
|
member.totalUsedCents ??
|
|
|
|
|
member.total_used_cents,
|
|
|
|
|
),
|
|
|
|
|
taskCount: toNumber(member.taskCount ?? member.task_count ?? member.calls ?? member.count),
|
|
|
|
|
lastUsedAt: toNullableString(member.lastUsedAt ?? member.last_used_at ?? member.updatedAt ?? member.updated_at),
|
|
|
|
|
})),
|
|
|
|
|
modelBreakdown: modelRows.map((model) => ({
|
|
|
|
|
model: toStringValue(model.model ?? model.modelId ?? model.model_id, "unknown"),
|
|
|
|
|
usedCents: toNumber(model.usedCents ?? model.used_cents ?? model.amountCents ?? model.amount_cents),
|
|
|
|
|
taskCount: toNumber(model.taskCount ?? model.task_count ?? model.calls ?? model.count),
|
|
|
|
|
})),
|
|
|
|
|
dailyTrend: trendRows.map((row) => ({
|
|
|
|
|
date: toStringValue(row.date ?? row.day),
|
|
|
|
|
usedCents: toNumber(row.usedCents ?? row.used_cents ?? row.amountCents ?? row.amount_cents),
|
|
|
|
|
taskCount: toNumber(row.taskCount ?? row.task_count ?? row.count),
|
|
|
|
|
})),
|
|
|
|
|
records: recordRows.map((record, index) => ({
|
|
|
|
|
id: toStringValue(record.id ?? record.ledgerId ?? record.ledger_id ?? record.taskId ?? record.task_id, `record-${index + 1}`),
|
|
|
|
|
userId: toIdValue(record.userId ?? record.user_id ?? record.memberId ?? record.member_id, "unknown"),
|
|
|
|
|
username: toStringValue(record.username ?? record.userName ?? record.user_name ?? record.name, "employee"),
|
|
|
|
|
model: toStringValue(record.model ?? record.modelId ?? record.model_id, "unknown"),
|
|
|
|
|
taskType: toStringValue(record.taskType ?? record.task_type ?? record.type, "image"),
|
|
|
|
|
resolution: toNullableString(record.resolution ?? record.quality),
|
|
|
|
|
durationSeconds: toOptionalNumber(record.durationSeconds ?? record.duration_seconds ?? record.duration),
|
|
|
|
|
amountCents: toNumber(record.amountCents ?? record.amount_cents ?? record.usedCents ?? record.used_cents),
|
|
|
|
|
prompt: toNullableString(record.prompt),
|
|
|
|
|
status: toStringValue(record.status, "completed"),
|
|
|
|
|
createdAt: toStringValue(record.createdAt ?? record.created_at ?? record.updatedAt ?? record.updated_at),
|
|
|
|
|
})),
|
|
|
|
|
source: "server",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildProjectUpsertPayload(workflow: WebCanvasWorkflow, session: WebUserSession): Record<string, unknown> {
|
|
|
|
|
const userId = String(session.user.id).replace(/[^a-zA-Z0-9_-]/g, "");
|
|
|
|
|
const projectId = workflow.id.trim();
|
|
|
|
|
const ossKey = `users/${userId}/projects/${projectId}/current/project.json`;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: projectId,
|
|
|
|
|
name: workflow.title.trim() || "未命名项目",
|
|
|
|
|
description: workflow.description.trim() || null,
|
|
|
|
|
ossKey,
|
|
|
|
|
thumbnailUrl: workflow.nodes.find((node) => node.previewUrl)?.previewUrl || null,
|
|
|
|
|
storyboardCount: workflow.nodes.length,
|
|
|
|
|
imageCount: workflow.nodes.filter((node) => node.kind === "image").length,
|
|
|
|
|
videoCount: workflow.nodes.filter((node) => node.kind === "video").length,
|
|
|
|
|
fileSize: JSON.stringify(workflow).length,
|
|
|
|
|
fingerprint: createWorkflowFingerprint(workflow),
|
|
|
|
|
deviceId: "web",
|
|
|
|
|
baseRevision: null,
|
|
|
|
|
forceOverwrite: true,
|
|
|
|
|
saveReason: "create",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const keyServerClient = {
|
|
|
|
|
getBaseUrl,
|
|
|
|
|
getStoredSession: readStoredSession,
|
|
|
|
|
updateStoredSessionUser,
|
|
|
|
|
clearSession() {
|
|
|
|
|
writeStoredSession(null);
|
|
|
|
|
},
|
|
|
|
|
async login(input: LoginInput): Promise<WebUserSession> {
|
|
|
|
|
const session = normalizeLoginResult(
|
|
|
|
|
await request<unknown>("/auth/login", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: {
|
|
|
|
|
username: input.username.trim(),
|
|
|
|
|
password: input.password,
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
if (!session) {
|
|
|
|
|
throw new Error("Login response did not include a token and user");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
writeStoredSession(session);
|
|
|
|
|
return session;
|
|
|
|
|
},
|
|
|
|
|
async register(input: RegisterInput): Promise<WebUserSession> {
|
|
|
|
|
const session = normalizeLoginResult(
|
|
|
|
|
await request<unknown>("/auth/register", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: {
|
|
|
|
|
username: input.username.trim(),
|
|
|
|
|
password: input.password,
|
|
|
|
|
betaCode: input.betaCode.trim(),
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
if (!session) {
|
|
|
|
|
throw new Error("Register response did not include a token and user");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
writeStoredSession(session);
|
|
|
|
|
return session;
|
|
|
|
|
},
|
|
|
|
|
async loginEmail(input: EmailAuthInput): Promise<WebUserSession> {
|
|
|
|
|
const session = normalizeLoginResult(
|
|
|
|
|
await request<unknown>("/auth/login-email", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: {
|
|
|
|
|
email: input.email.trim(),
|
|
|
|
|
password: input.password,
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
if (!session) {
|
|
|
|
|
throw new Error("Email login response did not include a token and user");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
writeStoredSession(session);
|
|
|
|
|
return session;
|
|
|
|
|
},
|
|
|
|
|
async registerEmail(input: EmailAuthInput): Promise<WebUserSession> {
|
|
|
|
|
const session = normalizeLoginResult(
|
|
|
|
|
await request<unknown>("/auth/register-email", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: {
|
|
|
|
|
email: input.email.trim(),
|
|
|
|
|
username: input.username?.trim() || undefined,
|
|
|
|
|
password: input.password,
|
|
|
|
|
betaCode: input.betaCode?.trim() || undefined,
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
if (!session) {
|
|
|
|
|
throw new Error("Email register response did not include a token and user");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
writeStoredSession(session);
|
|
|
|
|
return session;
|
|
|
|
|
},
|
|
|
|
|
async sendSmsCode(phone: string, purpose: "login" | "register", betaCode?: string): Promise<{ cooldownSeconds?: number; ttlSeconds?: number }> {
|
|
|
|
|
return request<{ cooldownSeconds?: number; ttlSeconds?: number }>("/auth/sms/send", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: { phone: phone.trim(), purpose, betaCode: betaCode?.trim() || undefined },
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
async loginPhone(input: PhoneAuthInput): Promise<WebUserSession> {
|
|
|
|
|
const session = normalizeLoginResult(
|
|
|
|
|
await request<unknown>("/auth/login-phone", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: {
|
|
|
|
|
phone: input.phone.trim(),
|
|
|
|
|
code: input.code.trim(),
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
if (!session) {
|
|
|
|
|
throw new Error("Phone login response did not include a token and user");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
writeStoredSession(session);
|
|
|
|
|
return session;
|
|
|
|
|
},
|
|
|
|
|
async registerPhone(input: PhoneAuthInput): Promise<WebUserSession> {
|
|
|
|
|
const session = normalizeLoginResult(
|
|
|
|
|
await request<unknown>("/auth/register-phone", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: {
|
|
|
|
|
phone: input.phone.trim(),
|
|
|
|
|
code: input.code.trim(),
|
|
|
|
|
password: input.password || "",
|
|
|
|
|
betaCode: input.betaCode?.trim() || undefined,
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
if (!session) {
|
|
|
|
|
throw new Error("Phone register response did not include a token and user");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
writeStoredSession(session);
|
|
|
|
|
return session;
|
|
|
|
|
},
|
|
|
|
|
async getWechatLoginTicket(): Promise<WechatLoginTicket> {
|
|
|
|
|
const browserCrypto = typeof globalThis !== "undefined" ? globalThis.crypto : undefined;
|
|
|
|
|
const state =
|
|
|
|
|
browserCrypto && "randomUUID" in browserCrypto
|
|
|
|
|
? browserCrypto.randomUUID().replace(/-/g, "")
|
|
|
|
|
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
|
|
|
|
|
return request<WechatLoginTicket>(`/auth/wechat/login-url?state=${encodeURIComponent(state)}`);
|
|
|
|
|
},
|
|
|
|
|
async getWechatLoginSession(state: string): Promise<WechatLoginSessionStatus> {
|
|
|
|
|
const response = await request<unknown>(`/auth/wechat/session?state=${encodeURIComponent(state)}`);
|
|
|
|
|
const raw = isRecord(response) ? response : {};
|
|
|
|
|
const session = normalizeLoginResult(raw);
|
|
|
|
|
if (session) {
|
|
|
|
|
writeStoredSession(session);
|
|
|
|
|
return { status: "completed", session };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const status = toStringValue(raw.status, "pending");
|
|
|
|
|
return {
|
|
|
|
|
status,
|
|
|
|
|
error: toNullableString(raw.error) ?? undefined,
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
async updateProfile(input: UpdateProfileInput): Promise<WebUserSession["user"]> {
|
|
|
|
|
const user = normalizeUser(
|
|
|
|
|
await request<unknown>("/auth/profile", {
|
|
|
|
|
method: "PUT",
|
|
|
|
|
body: {
|
|
|
|
|
...input,
|
|
|
|
|
profileBackgroundUrl: input.profileBackgroundUrl ?? input.backgroundUrl ?? undefined,
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
if (!user) {
|
|
|
|
|
throw new Error("Profile response did not include a user");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateStoredSessionUser(user);
|
|
|
|
|
return user;
|
|
|
|
|
},
|
|
|
|
|
async getCurrentSession(): Promise<WebUserSession | null> {
|
|
|
|
|
const stored = readStoredSession();
|
|
|
|
|
if (!stored) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const user = normalizeUser(await request<unknown>("/auth/me", { token: stored.token }));
|
|
|
|
|
if (!user) {
|
|
|
|
|
throw new Error("Current-user response did not include a user");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const session: WebUserSession = { ...stored, user, source: "server", errorMessage: undefined };
|
|
|
|
|
writeStoredSession(session);
|
|
|
|
|
return session;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (isHttpError(error) && (error.status === 401 || error.status === 403)) {
|
|
|
|
|
writeStoredSession(null);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
...stored,
|
|
|
|
|
source: "server",
|
|
|
|
|
errorMessage: getErrorMessage(error),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
async listProjects(): Promise<WebProjectSummary[]> {
|
|
|
|
|
const summaries = extractProjectRows(await request<unknown>("/projects")).map((project) =>
|
|
|
|
|
toProjectSummary(project, "server"),
|
|
|
|
|
);
|
|
|
|
|
return enrichProjectSummariesWithContent(summaries);
|
|
|
|
|
},
|
|
|
|
|
async getProjectContent(projectId: string): Promise<WebCanvasWorkflow> {
|
|
|
|
|
const stored = readStoredSession();
|
|
|
|
|
if (!stored) {
|
|
|
|
|
throw new Error("闇€瑕佸厛鐧诲綍");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const safeProjectId = encodeURIComponent(projectId.trim());
|
|
|
|
|
if (!safeProjectId) {
|
|
|
|
|
throw new Error("Project id is required");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const response = await request<unknown>(`/projects/${safeProjectId}/content?resolveMedia=1`);
|
|
|
|
|
return normalizeProjectContent(response, projectId);
|
|
|
|
|
},
|
|
|
|
|
async getUsageSummary(): Promise<WebUsageSummary> {
|
|
|
|
|
return normalizeUsageSummary(await request<unknown>("/user/usage/summary"));
|
|
|
|
|
},
|
|
|
|
|
async getEnterpriseUsageSummary(): Promise<WebEnterpriseUsageSummary> {
|
|
|
|
|
return normalizeEnterpriseUsageSummary(await request<unknown>("/enterprise/usage/summary"));
|
|
|
|
|
},
|
|
|
|
|
async getPersonalUsageSummary(): Promise<WebEnterpriseUsageSummary> {
|
|
|
|
|
return normalizeEnterpriseUsageSummary(await request<unknown>("/user/usage/credits"));
|
|
|
|
|
},
|
|
|
|
|
async createProjectSpace(workflow: WebCanvasWorkflow): Promise<WebProjectSummary> {
|
|
|
|
|
const stored = readStoredSession();
|
|
|
|
|
if (!stored) {
|
|
|
|
|
const error = new Error("需要先登录");
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const payload = buildProjectUpsertPayload(workflow, stored);
|
|
|
|
|
const response = await request<unknown>("/projects/upsert", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: payload,
|
|
|
|
|
});
|
|
|
|
|
const projectPayload = isRecord(response) ? response.project ?? response : response;
|
|
|
|
|
if (!isRecord(projectPayload)) {
|
|
|
|
|
throw new Error("Project response did not include a project");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return toProjectSummary(projectPayload, "server");
|
|
|
|
|
},
|
|
|
|
|
async saveProjectContent(projectId: string, workflow: WebCanvasWorkflow): Promise<WebProjectSummary> {
|
|
|
|
|
const stored = readStoredSession();
|
|
|
|
|
if (!stored) {
|
|
|
|
|
throw new Error("需要先登录");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const response = await request<unknown>(`projects/${encodeURIComponent(projectId)}/content`, {
|
|
|
|
|
method: "PUT",
|
|
|
|
|
body: {
|
|
|
|
|
content: {
|
|
|
|
|
name: workflow.title,
|
|
|
|
|
description: workflow.description,
|
|
|
|
|
workflowData: workflow,
|
|
|
|
|
nodes: workflow.nodes,
|
|
|
|
|
edges: workflow.edges,
|
|
|
|
|
},
|
|
|
|
|
meta: {
|
|
|
|
|
name: workflow.title,
|
|
|
|
|
description: workflow.description,
|
|
|
|
|
thumbnailUrl: workflow.nodes.find((node) => node.previewUrl)?.previewUrl || null,
|
|
|
|
|
storyboardCount: workflow.nodes.length,
|
|
|
|
|
imageCount: workflow.nodes.filter((node) => node.kind === "image").length,
|
|
|
|
|
videoCount: workflow.nodes.filter((node) => node.kind === "video").length,
|
|
|
|
|
},
|
|
|
|
|
saveReason: "web-create",
|
|
|
|
|
deviceId: "web",
|
|
|
|
|
forceOverwrite: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
const rawProject = isRecord(response) && isRecord(response.project) ? response.project : response;
|
|
|
|
|
if (!isRecord(rawProject)) {
|
|
|
|
|
return buildProjectSummaryFromWorkflow(workflow, "server");
|
|
|
|
|
}
|
|
|
|
|
return toProjectSummary(rawProject, "server");
|
|
|
|
|
},
|
|
|
|
|
async deleteProject(projectId: string, options?: DeleteProjectOptions): Promise<void> {
|
|
|
|
|
const stored = readStoredSession();
|
|
|
|
|
if (!stored) {
|
|
|
|
|
throw new Error("闇€瑕佸厛鐧诲綍");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const path = options?.cleanupUserData ? `projects/${encodeURIComponent(projectId)}?cleanupUserData=1` : `projects/${encodeURIComponent(projectId)}`;
|
|
|
|
|
await request(path, {
|
|
|
|
|
method: "DELETE",
|
|
|
|
|
});
|
|
|
|
|
},
|
2026-06-03 02:01:21 +08:00
|
|
|
|
|
|
|
|
async getClientErrors(page = 1): Promise<{ items: unknown[]; total: number }> {
|
|
|
|
|
const data = await request<{ items: unknown[]; total: number }>(`/client-errors?page=${page}`);
|
|
|
|
|
return data;
|
|
|
|
|
},
|
2026-06-02 12:38:01 +08:00
|
|
|
};
|