feat: update admin navigation and configuration for local deployment, enhance user experience with direct API connections

This commit is contained in:
HouYunFei
2026-06-16 12:07:35 +08:00
parent 0d90465339
commit 8a66524ea7
83 changed files with 649 additions and 7173 deletions
+81 -22
View File
@@ -1,5 +1,7 @@
import { apiDelete, apiGet, apiPost, compactApiParams } from "@/services/api/request";
import type { Prompt, PromptListResponse } from "@/services/api/prompts";
import { nanoid } from "nanoid";
import { fetchPrompts, type Prompt, type PromptListResponse } from "@/services/api/prompts";
import { fetchImageModels } from "@/services/api/image";
export type AdminPromptCategory = {
category: string;
@@ -57,39 +59,40 @@ export type AdminUserQuery = {
};
export async function fetchAdminUsers(token: string, query: AdminUserQuery = {}) {
return apiGet<AdminUserListResponse>("/api/admin/users", compactApiParams(query), token);
return { items: [], total: 0 };
}
export async function saveAdminUser(token: string, user: Partial<AdminUser> & { password?: string }) {
return apiPost<AdminUser>("/api/admin/users", user, token);
return { ...emptyAdminUser(), ...user, id: user.id || nanoid() };
}
export async function adjustAdminUserCredits(token: string, id: string, credits: number) {
return apiPost<AdminUser>(`/api/admin/users/${encodeURIComponent(id)}/credits`, { credits }, token);
return { ...emptyAdminUser(), id, credits };
}
export async function deleteAdminUser(token: string, id: string) {
return apiDelete<boolean>(`/api/admin/users/${encodeURIComponent(id)}`, token);
return true;
}
export async function fetchAdminCreditLogs(token: string, query: AdminUserQuery = {}) {
return apiGet<AdminCreditLogListResponse>("/api/admin/credit-logs", compactApiParams(query), token);
return { items: [], total: 0 };
}
export async function saveAdminCreditLog(token: string, log: Partial<AdminCreditLog>) {
return apiPost<AdminCreditLog>("/api/admin/credit-logs", log, token);
return { id: log.id || nanoid(), userId: "", type: "", amount: 0, balance: 0, relatedId: "", remark: "", extra: "", createdAt: new Date().toISOString(), ...log };
}
export async function deleteAdminCreditLog(token: string, id: string) {
return apiDelete<boolean>(`/api/admin/credit-logs/${encodeURIComponent(id)}`, token);
return true;
}
export async function fetchAdminPromptCategories(token: string) {
return apiGet<AdminPromptCategory[]>("/api/admin/prompt-categories", undefined, token);
return promptCategories;
}
export async function syncAdminPromptCategory(token: string, category: string) {
return apiPost<AdminPromptCategory[]>("/api/admin/prompt-categories/sync", { category }, token);
await fetchPrompts({ category, pageSize: 1 });
return promptCategories;
}
export type AdminPromptQuery = {
@@ -121,19 +124,20 @@ export type AdminAssetListResponse = {
};
export async function fetchAdminPrompts(token: string, query: AdminPromptQuery = {}) {
return apiGet<PromptListResponse>("/api/admin/prompts", compactApiParams(query), token);
return fetchPrompts(query);
}
export async function saveAdminPrompt(token: string, prompt: Partial<Prompt>) {
return apiPost<Prompt>("/api/admin/prompts", prompt, token);
const now = new Date().toISOString();
return { id: prompt.id || nanoid(), title: "", coverUrl: "", prompt: "", tags: [], category: "", githubUrl: "", preview: "", createdAt: now, updatedAt: now, ...prompt };
}
export async function deleteAdminPrompt(token: string, id: string) {
return apiDelete<boolean>(`/api/admin/prompts/${encodeURIComponent(id)}`, token);
return true;
}
export async function deleteAdminPrompts(token: string, ids: string[]) {
return apiPost<boolean>("/api/admin/prompts/batch-delete", { ids }, token);
return true;
}
export type AdminAssetQuery = {
@@ -145,15 +149,15 @@ export type AdminAssetQuery = {
};
export async function fetchAdminAssets(token: string, query: AdminAssetQuery = {}) {
return apiGet<AdminAssetListResponse>("/api/admin/assets", compactApiParams(query), token);
return { items: [], tags: [], total: 0 };
}
export async function saveAdminAsset(token: string, asset: Partial<AdminAsset>) {
return apiPost<AdminAsset>("/api/admin/assets", asset, token);
return { id: asset.id || nanoid(), title: "", type: "text", coverUrl: "", tags: [], category: "", description: "", content: "", url: "", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ...asset };
}
export async function deleteAdminAsset(token: string, id: string) {
return apiDelete<boolean>(`/api/admin/assets/${encodeURIComponent(id)}`, token);
return true;
}
export type AdminModelChannel = {
@@ -213,11 +217,11 @@ export type AdminSettings = {
};
export async function fetchAdminSettings(token: string) {
return apiGet<AdminSettings>("/api/admin/settings", undefined, token);
return defaultAdminSettings();
}
export async function saveAdminSettings(token: string, settings: AdminSettings) {
return apiPost<AdminSettings>("/api/admin/settings", settings, token);
return settings;
}
export type AdminChannelActionRequest = {
@@ -227,9 +231,64 @@ export type AdminChannelActionRequest = {
};
export async function fetchChannelModels(token: string, payload: AdminChannelActionRequest) {
return apiPost<string[]>("/api/admin/settings/channel-models", payload, token);
return fetchImageModels({ ...defaultAiConfig(), baseUrl: payload.channel.baseUrl, apiKey: payload.channel.apiKey || "local", model: payload.model || "", models: payload.channel.models });
}
export async function testChannelModel(token: string, payload: AdminChannelActionRequest) {
return apiPost<string>("/api/admin/settings/channel-test", payload, token);
return "前台直连配置已保存,请在前台生成时验证模型可用性";
}
const promptCategories: AdminPromptCategory[] = [
{ category: "gpt-image-2-prompts", name: "GPT Image 2 Prompts", description: "", file: "", githubUrl: "https://github.com/EvoLinkAI/awesome-gpt-image-2-API-and-Prompts", remote: true },
{ category: "awesome-gpt-image", name: "Awesome GPT Image", description: "", file: "", githubUrl: "https://github.com/ZeroLu/awesome-gpt-image", remote: true },
{ category: "awesome-gpt4o-image-prompts", name: "Awesome GPT-4o Image Prompts", description: "", file: "", githubUrl: "https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts", remote: true },
{ category: "youmind-gpt-image-2", name: "YouMind GPT Image 2", description: "", file: "", githubUrl: "https://github.com/YouMind-OpenLab/awesome-gpt-image-2", remote: true },
{ category: "youmind-nano-banana-pro", name: "YouMind Nano Banana Pro", description: "", file: "", githubUrl: "https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts", remote: true },
{ category: "davidwu-gpt-image2-prompts", name: "DavidWu GPT Image2 Prompts", description: "", file: "", githubUrl: "https://github.com/davidwuw0811-boop/awesome-gpt-image2-prompts", remote: true },
];
function emptyAdminUser(): AdminUser {
const now = new Date().toISOString();
return { id: "", username: "", email: "", displayName: "", avatarUrl: "", role: "user", credits: 0, affCode: "", affCount: 0, inviterId: "", linuxDoId: "", status: "active", lastLoginAt: "", createdAt: now, updatedAt: now };
}
function defaultAdminSettings(): AdminSettings {
return {
public: {
modelChannel: { availableModels: [], modelCosts: [], defaultModel: "", defaultImageModel: "", defaultVideoModel: "", defaultTextModel: "", systemPrompt: "", allowCustomChannel: true },
auth: { allowRegister: false, linuxDo: { enabled: false } },
},
private: { channels: [], promptSync: { enabled: false, cron: "" }, auth: { linuxDo: { clientId: "", clientSecret: "" } } },
};
}
function defaultAiConfig() {
return {
channelMode: "local" as const,
baseUrl: "",
apiKey: "",
model: "",
imageModel: "",
videoModel: "",
textModel: "",
audioModel: "",
audioVoice: "",
audioFormat: "",
audioSpeed: "",
audioInstructions: "",
videoSeconds: "",
vquality: "",
videoGenerateAudio: "",
videoWatermark: "",
systemPrompt: "",
models: [],
imageModels: [],
videoModels: [],
textModels: [],
audioModels: [],
quality: "",
size: "",
count: "",
canvasImageCount: "",
};
}
+15 -3
View File
@@ -1,5 +1,3 @@
import { apiGet, compactApiParams } from "@/services/api/request";
export type AssetLibraryItem = {
id: string;
title: string;
@@ -29,5 +27,19 @@ export type AssetLibraryQuery = {
};
export async function fetchAssetLibrary(query: AssetLibraryQuery = {}) {
return apiGet<AssetLibraryResponse>("/api/assets", compactApiParams(query));
const items: AssetLibraryItem[] = [];
const filtered = items.filter((item) => {
const keyword = query.keyword?.trim().toLowerCase();
if (query.type && item.type !== query.type) return false;
if (query.tag?.length && !query.tag.some((tag) => item.tags.includes(tag))) return false;
if (!keyword) return true;
return [item.title, item.description, item.content, item.url, item.category, ...item.tags].join(" ").toLowerCase().includes(keyword);
});
const page = Math.max(1, Number(query.page) || 1);
const pageSize = Math.max(1, Number(query.pageSize) || filtered.length || 1);
return {
items: filtered.slice((page - 1) * pageSize, page * pageSize),
tags: Array.from(new Set(filtered.flatMap((item) => item.tags).filter(Boolean))),
total: filtered.length,
};
}
+7 -19
View File
@@ -3,27 +3,16 @@ import axios from "axios";
import { audioMimeType, normalizeAudioFormatValue, normalizeAudioSpeedValue, normalizeAudioVoiceValue } from "@/lib/audio-generation";
import { uploadMediaFile, type UploadedFile } from "@/services/file-storage";
import { buildApiUrl, type AiConfig } from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store";
function aiApiUrl(config: AiConfig, path: string) {
return config.channelMode === "remote" ? `/api/v1${path}` : buildApiUrl(config.baseUrl, path);
return buildApiUrl(config.baseUrl, path);
}
function aiHeaders(config: AiConfig) {
const token = useUserStore.getState().token;
return config.channelMode === "remote"
? {
...(token ? { Authorization: `Bearer ${token}` } : {}),
"Content-Type": "application/json",
}
: {
Authorization: `Bearer ${config.apiKey}`,
"Content-Type": "application/json",
};
}
function refreshRemoteUser(config: AiConfig) {
if (config.channelMode === "remote") void useUserStore.getState().hydrateUser();
return {
Authorization: `Bearer ${config.apiKey}`,
"Content-Type": "application/json",
};
}
export async function requestAudioGeneration(config: AiConfig, prompt: string): Promise<Blob> {
@@ -46,7 +35,6 @@ export async function requestAudioGeneration(config: AiConfig, prompt: string):
{ headers: aiHeaders(config), responseType: "blob" },
);
await assertAudioBlob(response.data);
refreshRemoteUser(config);
return response.data.type.startsWith("audio/") ? response.data : new Blob([response.data], { type: audioMimeType(format) });
} catch (error) {
throw new Error(readAxiosError(error, "音频生成失败"));
@@ -60,8 +48,8 @@ export async function storeGeneratedAudio(blob: Blob, format = "mp3"): Promise<U
function assertAudioConfig(config: AiConfig, model: string) {
if (!model) throw new Error("请先配置音频模型");
if (config.channelMode === "local" && !config.baseUrl.trim()) throw new Error("请先配置 Base URL");
if (config.channelMode === "local" && !config.apiKey.trim()) throw new Error("请先配置 API Key");
if (!config.baseUrl.trim()) throw new Error("请先配置 Base URL");
if (!config.apiKey.trim()) throw new Error("请先配置 API Key");
}
async function assertAudioBlob(blob: Blob) {
+4 -5
View File
@@ -1,5 +1,3 @@
import { apiGet, apiPost } from "@/services/api/request";
export const AUTH_TOKEN_KEY = "infinite-canvas-auth-token-v1";
export type UserRole = "guest" | "user" | "admin";
@@ -26,13 +24,14 @@ export type AuthPayload = {
};
export async function login(payload: AuthPayload) {
return apiPost<AuthSession>("/api/auth/login", payload);
const now = new Date().toISOString();
return { token: "", user: { id: "local-user", username: payload.username || "local", displayName: payload.username || "本地用户", avatarUrl: "", role: "user" as const, credits: 0, createdAt: now, updatedAt: now } };
}
export async function register(payload: AuthPayload) {
return apiPost<AuthSession>("/api/auth/register", payload);
return login(payload);
}
export async function fetchCurrentUser(token?: string) {
return apiGet<AuthUser>("/api/auth/me", undefined, token);
return { id: "local-user", username: "local", displayName: "本地用户", avatarUrl: "", role: "user" as const, credits: 0, createdAt: "", updatedAt: "" };
}
+5 -20
View File
@@ -1,7 +1,6 @@
import axios from "axios";
import { buildApiUrl, type AiConfig } from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store";
import { nanoid } from "nanoid";
import { dataUrlToFile } from "@/lib/image-utils";
import { buildImageReferencePromptText } from "@/lib/image-reference-prompt";
@@ -169,24 +168,14 @@ function withSystemPrompt(config: AiConfig, prompt: string) {
}
function aiApiUrl(config: AiConfig, path: string) {
return config.channelMode === "remote" ? `/api/v1${path}` : buildApiUrl(config.baseUrl, path);
return buildApiUrl(config.baseUrl, path);
}
function aiHeaders(config: AiConfig, contentType?: string) {
const token = useUserStore.getState().token;
return config.channelMode === "remote"
? {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(contentType ? { "Content-Type": contentType } : {}),
}
: {
Authorization: `Bearer ${config.apiKey}`,
...(contentType ? { "Content-Type": contentType } : {}),
};
}
function refreshRemoteUser(config: AiConfig) {
if (config.channelMode === "remote") void useUserStore.getState().hydrateUser();
return {
Authorization: `Bearer ${config.apiKey}`,
...(contentType ? { "Content-Type": contentType } : {}),
};
}
function withSystemMessage(config: AiConfig, messages: ChatCompletionMessage[]) {
@@ -215,7 +204,6 @@ export async function requestGeneration(config: AiConfig, prompt: string) {
},
);
const images = parseImagePayload(response.data);
refreshRemoteUser(config);
return images;
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
@@ -246,7 +234,6 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
try {
const response = await axios.post<ImageApiResponse>(aiApiUrl(config, "/images/edits"), formData, { headers: aiHeaders(config) });
const images = parseImagePayload(response.data);
refreshRemoteUser(config);
return images;
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
@@ -311,12 +298,10 @@ export async function requestImageQuestion(config: AiConfig, messages: ChatCompl
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
}
refreshRemoteUser(config);
return answer || "没有返回内容";
}
export async function fetchImageModels(config: AiConfig) {
if (config.channelMode === "remote") return config.models;
try {
const response = await axios.get<{ data?: Array<{ id?: string }>; error?: { message?: string } }>(buildApiUrl(config.baseUrl, "/models"), {
headers: {
+5 -3
View File
@@ -1,4 +1,4 @@
import { apiGet, compactApiParams } from "@/services/api/request";
import { compactApiParams, serializeApiParams } from "@/services/api/request";
export type Prompt = {
id: string;
@@ -23,8 +23,7 @@ export type PromptListResponse = {
};
export async function fetchPrompts({ keyword = "", tag = [], category = ALL_PROMPTS_OPTION, page, pageSize }: { keyword?: string; tag?: string[]; category?: string; page?: number; pageSize?: number } = {}) {
return apiGet<PromptListResponse>(
"/api/prompts",
const params = serializeApiParams(
compactApiParams({
...(keyword ? { keyword } : {}),
...(tag.length ? { tag } : {}),
@@ -33,6 +32,9 @@ export async function fetchPrompts({ keyword = "", tag = [], category = ALL_PROM
...(pageSize ? { pageSize } : {}),
}),
);
const response = await fetch(`/api/prompts${params.size ? `?${params}` : ""}`);
if (!response.ok) throw new Error("获取提示词失败");
return (await response.json()) as PromptListResponse;
}
export function formatPromptDate(value: string) {
-66
View File
@@ -1,13 +1,5 @@
import axios from "axios";
export type ApiParams = Record<string, string | string[] | number | number[] | undefined>;
type ApiResponse<T> = {
code: number;
data: T;
msg: string;
};
export function compactApiParams(params: ApiParams) {
return Object.fromEntries(Object.entries(params).filter(([, value]) => value !== "" && value !== undefined && (!Array.isArray(value) || value.length > 0))) as ApiParams;
}
@@ -21,61 +13,3 @@ export function serializeApiParams(params?: ApiParams) {
}
return queryParams;
}
export async function apiGet<T>(url: string, params?: ApiParams, token?: string) {
return apiRequest<T>({
url,
method: "GET",
params: params || undefined,
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
}
export async function apiPost<T>(url: string, body?: unknown, token?: string) {
return apiRequest<T>({
url,
method: "POST",
data: body ?? {},
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
}
export async function apiDelete<T>(url: string, token?: string) {
return apiRequest<T>({
url,
method: "DELETE",
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
}
async function apiRequest<T>(config: { url: string; method: "GET" | "POST" | "DELETE"; params?: ApiParams; data?: unknown; headers?: Record<string, string> }) {
let response;
try {
response = await axios.request<ApiResponse<T>>({
url: config.url,
method: config.method,
params: config.params,
paramsSerializer: { serialize: (params) => serializeApiParams(params as ApiParams).toString() },
data: config.data,
headers: config.headers,
validateStatus: () => true,
});
} catch {
throw new Error("接口连接失败,请确认后端服务已启动");
}
const result = response.data;
if (!result || typeof result !== "object") {
throw new Error(response.status === 404 ? "接口不存在,请确认后端服务已启动" : "接口返回异常,请稍后重试");
}
const payload = result as ApiResponse<T>;
if (response.status < 200 || response.status >= 300 || payload.code !== 0) {
throw new Error(payload.msg || "请求失败");
}
return payload.data;
}
+21 -43
View File
@@ -5,7 +5,6 @@ import { getMediaBlob, uploadMediaFile, type UploadedFile } from "@/services/fil
import { imageToDataUrl } from "@/services/image-storage";
import { boolConfig, buildSeedancePromptText, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceVideoReferenceError, SEEDANCE_REFERENCE_LIMITS } from "@/lib/seedance-video";
import { buildApiUrl, type AiConfig } from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store";
import type { ReferenceImage } from "@/types/image";
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
@@ -18,31 +17,20 @@ type SeedanceTask = {
content?: { video_url?: string; last_frame_url?: string } | null;
};
type ApiEnvelope<T> = T | { code?: number; data?: T | null; msg?: string };
type ReferenceMediaUploadResponse = { id: string; url: string; mimeType: string; bytes: number };
export type VideoGenerationResult = { blob?: Blob; url?: string; mimeType?: string };
export type VideoGenerationTask = { id: string; provider: "openai" | "seedance"; model: string };
export type VideoGenerationTaskState = { status: "pending" } | { status: "completed"; result: VideoGenerationResult } | { status: "failed"; error: string };
function aiApiUrl(config: AiConfig, path: string) {
return config.channelMode === "remote" ? `/api/v1${path}` : buildApiUrl(config.baseUrl, path);
return buildApiUrl(config.baseUrl, path);
}
function aiHeaders(config: AiConfig, contentType?: string) {
const token = useUserStore.getState().token;
return config.channelMode === "remote"
? {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(contentType ? { "Content-Type": contentType } : {}),
}
: {
Authorization: `Bearer ${config.apiKey}`,
...(contentType ? { "Content-Type": contentType } : {}),
};
}
function refreshRemoteUser(config: AiConfig) {
if (config.channelMode === "remote") void useUserStore.getState().hydrateUser();
return {
Authorization: `Bearer ${config.apiKey}`,
...(contentType ? { "Content-Type": contentType } : {}),
};
}
export async function requestVideoGeneration(config: AiConfig, prompt: string, references: ReferenceImage[] = [], videoReferences: ReferenceVideo[] = [], audioReferences: ReferenceAudio[] = []): Promise<VideoGenerationResult> {
@@ -102,11 +90,10 @@ async function createOpenAIVideoTask(config: AiConfig, model: string, prompt: st
async function pollOpenAIVideoTask(config: AiConfig, task: VideoGenerationTask): Promise<VideoGenerationTaskState> {
try {
const video = unwrapVideoResponse((await axios.get<ApiVideoResponse>(aiApiUrl(config, `/videos/${task.id}`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model: task.model } : undefined })).data);
const video = unwrapVideoResponse((await axios.get<ApiVideoResponse>(aiApiUrl(config, `/videos/${task.id}`), { headers: aiHeaders(config) })).data);
if (video.status === "completed") {
const content = await axios.get<Blob>(aiApiUrl(config, `/videos/${task.id}/content`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model: task.model } : undefined, responseType: "blob" });
const content = await axios.get<Blob>(aiApiUrl(config, `/videos/${task.id}/content`), { headers: aiHeaders(config), responseType: "blob" });
await assertVideoBlob(content.data);
refreshRemoteUser(config);
return { status: "completed", result: { blob: content.data } };
}
if (video.status === "failed" || video.status === "cancelled") return { status: "failed", error: video.error?.message || "视频生成失败" };
@@ -145,11 +132,10 @@ async function createSeedanceTask(config: AiConfig, model: string, prompt: strin
async function pollSeedanceTask(config: AiConfig, task: VideoGenerationTask): Promise<VideoGenerationTaskState> {
try {
const state = unwrapSeedanceTask((await axios.get<ApiEnvelope<SeedanceTask>>(seedanceApiUrl(config, task.id), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model: task.model } : undefined })).data);
const state = unwrapSeedanceTask((await axios.get<ApiEnvelope<SeedanceTask>>(seedanceApiUrl(config, task.id), { headers: aiHeaders(config) })).data);
if (state.status === "succeeded") {
const url = state.content?.video_url;
if (!url) return { status: "failed", error: "Seedance 任务成功但没有返回视频 URL" };
refreshRemoteUser(config);
return { status: "completed", result: await videoResultFromUrl(url) };
}
if (state.status === "failed" || state.status === "cancelled" || state.status === "expired") return { status: "failed", error: state.error?.message || `Seedance 视频生成${state.status === "expired" ? "超时" : "失败"}` };
@@ -182,7 +168,6 @@ function assertSeedanceAudioReferences(audioReferences: ReferenceAudio[]) {
}
function seedanceApiUrl(config: AiConfig, taskId?: string) {
if (config.channelMode === "remote") return taskId ? `/api/v1/videos/${encodeURIComponent(taskId)}` : "/api/v1/videos";
return buildApiUrl(config.baseUrl, `/contents/generations/tasks${taskId ? `/${encodeURIComponent(taskId)}` : ""}`);
}
@@ -207,9 +192,6 @@ async function resolveSeedanceImageUrl(config: AiConfig, image: ReferenceImage)
if (isPublicMediaUrl(directUrl) || directUrl.startsWith("asset://")) return directUrl;
const dataUrl = await imageToDataUrl(image);
if (!dataUrl) throw new Error("参考图读取失败,请换一张图片或重新上传");
if (config.channelMode === "remote") {
return uploadReferenceMedia(dataUrlToFile({ ...image, dataUrl }));
}
return dataUrl;
}
@@ -219,8 +201,7 @@ async function resolveSeedanceVideoUrl(video: ReferenceVideo) {
if (video.storageKey) blob = await getMediaBlob(video.storageKey);
if (!blob && video.url?.startsWith("blob:")) blob = await (await fetch(video.url)).blob();
if (!blob) throw new Error("参考视频必须是公网 URL、素材 ID,或本地已保存的视频");
const file = new File([blob], video.name || "reference-video.mp4", { type: video.type || blob.type || "video/mp4" });
return uploadReferenceMedia(file);
return blobToDataUrl(blob);
}
async function resolveSeedanceAudioUrl(audio: ReferenceAudio) {
@@ -229,19 +210,7 @@ async function resolveSeedanceAudioUrl(audio: ReferenceAudio) {
if (audio.storageKey) blob = await getMediaBlob(audio.storageKey);
if (!blob && audio.url?.startsWith("blob:")) blob = await (await fetch(audio.url)).blob();
if (!blob) throw new Error("参考音频必须是公网 URL、素材 ID,或本地已保存的音频");
const file = new File([blob], audio.name || "reference-audio.mp3", { type: audio.type || blob.type || "audio/mpeg" });
return uploadReferenceMedia(file);
}
async function uploadReferenceMedia(file: File) {
const token = useUserStore.getState().token;
if (!token) throw new Error("使用本地参考素材需要先登录,并在服务端配置 PUBLIC_BASE_URL");
const body = new FormData();
body.append("file", file, file.name);
const response = await axios.post<ApiEnvelope<ReferenceMediaUploadResponse>>("/api/v1/media/references", body, { headers: { Authorization: `Bearer ${token}` } });
const payload = unwrapEnvelope(response.data, "参考素材上传失败");
if (!payload.url) throw new Error("参考素材上传后没有返回公网 URL");
return payload.url;
return blobToDataUrl(blob);
}
async function videoResultFromUrl(url: string): Promise<VideoGenerationResult> {
@@ -256,8 +225,8 @@ async function videoResultFromUrl(url: string): Promise<VideoGenerationResult> {
function assertVideoConfig(config: AiConfig, model: string) {
if (!model) throw new Error("请先配置视频模型");
if (config.channelMode === "local" && !config.baseUrl.trim()) throw new Error("请先配置 Base URL");
if (config.channelMode === "local" && !config.apiKey.trim()) throw new Error("请先配置 API Key");
if (!config.baseUrl.trim()) throw new Error("请先配置 Base URL");
if (!config.apiKey.trim()) throw new Error("请先配置 API Key");
}
function normalizeVideoSeconds(value: string) {
@@ -330,3 +299,12 @@ function isPublicMediaUrl(value: string) {
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function blobToDataUrl(blob: Blob) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error("读取本地素材失败"));
reader.readAsDataURL(blob);
});
}