All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m22s
密码管理:用户自助修改密码(个人中心弹窗)、管理员重置用户密码(审计日志记录) 错误提示:补全火山 ARK 错误码映射(+7 个)、修复创建失败时前端不显示真实错误、 轮询失败走 ERROR_MESSAGES 映射、前端 catch 统一取后端 message Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
496 lines
16 KiB
TypeScript
496 lines
16 KiB
TypeScript
import { create } from 'zustand';
|
|
import type { GenerationTask, ReferenceSnapshot, UploadedFile, BackendTask } from '../types';
|
|
import { useInputBarStore } from './inputBar';
|
|
import { videoApi, mediaApi } from '../lib/api';
|
|
import { useAuthStore } from './auth';
|
|
import { showToast } from '../components/Toast';
|
|
|
|
// Map raw API error messages to user-friendly Chinese
|
|
function mapErrorMessage(raw?: string): string | undefined {
|
|
if (!raw) return undefined;
|
|
const s = raw.toLowerCase();
|
|
|
|
// HTTP 4xx
|
|
if (s.includes('400') || s.includes('bad request') || s.includes('invalid'))
|
|
return '请求参数有误,请检查输入内容';
|
|
if (s.includes('401') || s.includes('403') || s.includes('unauthorized') || s.includes('forbidden'))
|
|
return '服务认证失败,请联系管理员';
|
|
if (s.includes('429') || s.includes('too many') || s.includes('rate limit'))
|
|
return '请求过于频繁,请稍后再试';
|
|
|
|
// HTTP 5xx / server errors
|
|
if (s.includes('500') || s.includes('502') || s.includes('503') || s.includes('internal server') || s.includes('bad gateway') || s.includes('service unavailable'))
|
|
return '服务器繁忙,请稍后重试';
|
|
|
|
// Timeout
|
|
if (s.includes('timeout') || s.includes('timed out'))
|
|
return '请求超时,请重试';
|
|
|
|
// Connection errors
|
|
if (s.includes('connection') || s.includes('network') || s.includes('econnrefused'))
|
|
return '网络连接失败,请检查网络后重试';
|
|
|
|
// Model / generation errors
|
|
if (s.includes('quota') || s.includes('insufficient'))
|
|
return '额度不足,请联系管理员';
|
|
|
|
// If already Chinese, return as-is
|
|
if (/[\u4e00-\u9fa5]/.test(raw)) return raw;
|
|
|
|
// Fallback
|
|
return '生成失败,请重试';
|
|
}
|
|
|
|
// Map backend status to frontend TaskStatus
|
|
function mapStatus(backendStatus: string): 'generating' | 'completed' | 'failed' {
|
|
if (backendStatus === 'completed' || backendStatus === 'succeeded') return 'completed';
|
|
if (backendStatus === 'failed') return 'failed';
|
|
return 'generating';
|
|
}
|
|
|
|
function mapProgress(backendStatus: string): number {
|
|
if (backendStatus === 'completed') return 100;
|
|
if (backendStatus === 'failed') return 0;
|
|
return 50;
|
|
}
|
|
|
|
// Convert a BackendTask to a frontend GenerationTask
|
|
function backendToFrontend(bt: BackendTask): GenerationTask {
|
|
const references: ReferenceSnapshot[] = (bt.reference_urls || []).map((ref, i) => ({
|
|
id: `ref_${bt.task_id}_${i}`,
|
|
type: (ref.type || 'image') as 'image' | 'video',
|
|
previewUrl: ref.url,
|
|
label: ref.label || `素材${i + 1}`,
|
|
role: ref.role,
|
|
}));
|
|
|
|
return {
|
|
id: `backend_${bt.task_id}`,
|
|
taskId: bt.task_id,
|
|
prompt: bt.prompt,
|
|
editorHtml: bt.prompt,
|
|
mode: bt.mode,
|
|
model: bt.model,
|
|
aspectRatio: bt.aspect_ratio as GenerationTask['aspectRatio'],
|
|
duration: bt.duration as GenerationTask['duration'],
|
|
references,
|
|
status: mapStatus(bt.status),
|
|
progress: mapProgress(bt.status),
|
|
resultUrl: bt.result_url || undefined,
|
|
errorMessage: mapErrorMessage(bt.error_message),
|
|
createdAt: new Date(bt.created_at).getTime(),
|
|
};
|
|
}
|
|
|
|
// Active polling timers
|
|
const pollTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
|
|
// Smooth progress animation — continuously ticks generating tasks forward
|
|
let smoothProgressTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
function ensureSmoothProgress() {
|
|
if (smoothProgressTimer) return;
|
|
smoothProgressTimer = setInterval(() => {
|
|
const state = useGenerationStore.getState();
|
|
const generating = state.tasks.filter((t) => t.status === 'generating');
|
|
if (generating.length === 0) {
|
|
clearInterval(smoothProgressTimer!);
|
|
smoothProgressTimer = null;
|
|
return;
|
|
}
|
|
useGenerationStore.setState((s) => ({
|
|
tasks: s.tasks.map((t) => {
|
|
if (t.status !== 'generating') return t;
|
|
// Decelerate: fast at start, slow near end
|
|
const increment = t.progress < 30 ? 2 : t.progress < 60 ? 1 : 0.5;
|
|
return { ...t, progress: Math.min(t.progress + increment, 95) };
|
|
}),
|
|
}));
|
|
}, 2000);
|
|
}
|
|
|
|
// Progressive polling: 10s for first 2min, 30s for 2-5min, 60s after 5min
|
|
function getPollingInterval(startTime: number): number {
|
|
const elapsed = Date.now() - startTime;
|
|
if (elapsed < 2 * 60 * 1000) return 10 * 1000; // first 2 min: every 10s
|
|
if (elapsed < 5 * 60 * 1000) return 30 * 1000; // 2-5 min: every 30s
|
|
return 60 * 1000; // 5+ min: every 60s
|
|
}
|
|
|
|
function startPolling(taskId: string, frontendId: string) {
|
|
if (pollTimers.has(frontendId)) return;
|
|
|
|
const startTime = Date.now();
|
|
|
|
function schedulePoll() {
|
|
const timer = setTimeout(async () => {
|
|
try {
|
|
const { data } = await videoApi.getTaskStatus(taskId);
|
|
const newStatus = mapStatus(data.status);
|
|
|
|
useGenerationStore.setState((s) => ({
|
|
tasks: s.tasks.map((t) =>
|
|
t.id === frontendId
|
|
? {
|
|
...t,
|
|
status: newStatus,
|
|
progress: newStatus === 'completed' ? 100 : newStatus === 'failed' ? 0 : t.progress,
|
|
resultUrl: data.result_url || t.resultUrl,
|
|
errorMessage: mapErrorMessage(data.error_message) || t.errorMessage,
|
|
}
|
|
: t
|
|
),
|
|
}));
|
|
|
|
if (newStatus === 'completed' || newStatus === 'failed') {
|
|
pollTimers.delete(frontendId);
|
|
if (newStatus === 'completed') {
|
|
useAuthStore.getState().fetchUserInfo();
|
|
}
|
|
} else {
|
|
schedulePoll(); // schedule next poll with progressive interval
|
|
}
|
|
} catch {
|
|
schedulePoll(); // retry on error
|
|
}
|
|
}, getPollingInterval(startTime));
|
|
|
|
pollTimers.set(frontendId, timer);
|
|
}
|
|
|
|
schedulePoll();
|
|
}
|
|
|
|
function stopPolling(frontendId: string) {
|
|
const timer = pollTimers.get(frontendId);
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
pollTimers.delete(frontendId);
|
|
}
|
|
}
|
|
|
|
const PAGE_SIZE = 20;
|
|
|
|
interface GenerationState {
|
|
tasks: GenerationTask[];
|
|
isLoading: boolean;
|
|
isLoadingMore: boolean;
|
|
hasMore: boolean;
|
|
savedScrollTop: number | null;
|
|
addTask: () => Promise<string | null>;
|
|
removeTask: (id: string) => void;
|
|
reEdit: (id: string) => void;
|
|
regenerate: (id: string) => void;
|
|
loadTasks: () => Promise<void>;
|
|
loadMore: () => Promise<void>;
|
|
saveScrollPosition: (top: number) => void;
|
|
}
|
|
|
|
export const useGenerationStore = create<GenerationState>((set, get) => ({
|
|
tasks: [],
|
|
isLoading: false,
|
|
isLoadingMore: false,
|
|
hasMore: false,
|
|
savedScrollTop: null,
|
|
|
|
loadTasks: async () => {
|
|
set({ isLoading: true });
|
|
|
|
let tasks: GenerationTask[] = [];
|
|
let hasMore = false;
|
|
|
|
try {
|
|
const { data } = await videoApi.getTasks({ page_size: PAGE_SIZE, offset: 0 });
|
|
tasks = data.results.map(backendToFrontend).reverse();
|
|
hasMore = data.has_more;
|
|
} catch {
|
|
// API unavailable — tasks stays empty
|
|
}
|
|
|
|
set({ tasks, hasMore, isLoading: false });
|
|
|
|
// Start polling and smooth progress for any active tasks
|
|
let hasGenerating = false;
|
|
for (const task of tasks) {
|
|
if (task.status === 'generating' && task.taskId) {
|
|
startPolling(task.taskId, task.id);
|
|
hasGenerating = true;
|
|
}
|
|
}
|
|
if (hasGenerating) ensureSmoothProgress();
|
|
},
|
|
|
|
loadMore: async () => {
|
|
const { tasks, isLoadingMore, hasMore } = get();
|
|
if (isLoadingMore || !hasMore) return;
|
|
|
|
set({ isLoadingMore: true });
|
|
|
|
try {
|
|
// Backend returns newest-first; we display oldest-first (reversed).
|
|
// tasks[0] is the oldest currently loaded. We need older ones = higher offset.
|
|
// offset = total loaded from backend perspective
|
|
const currentBackendCount = tasks.filter((t) => !t.id.startsWith('temp_')).length;
|
|
const { data } = await videoApi.getTasks({ page_size: PAGE_SIZE, offset: currentBackendCount });
|
|
const olderTasks = data.results.map(backendToFrontend).reverse();
|
|
set((s) => ({
|
|
tasks: [...olderTasks, ...s.tasks],
|
|
hasMore: data.has_more,
|
|
isLoadingMore: false,
|
|
}));
|
|
} catch {
|
|
set({ isLoadingMore: false });
|
|
}
|
|
},
|
|
|
|
addTask: async () => {
|
|
const input = useInputBarStore.getState();
|
|
if (!input.canSubmit()) return null;
|
|
|
|
// Collect files to upload (or existing TOS URLs for regeneration)
|
|
const filesToUpload: { file?: File; tosUrl?: string; type: 'image' | 'video' | 'audio'; role: string; label: string }[] = [];
|
|
|
|
const getRoleForType = (type: 'image' | 'video' | 'audio') =>
|
|
type === 'video' ? 'reference_video' : type === 'audio' ? 'reference_audio' : 'reference_image';
|
|
|
|
if (input.mode === 'universal') {
|
|
for (const ref of input.references) {
|
|
if (ref.tosUrl) {
|
|
filesToUpload.push({ tosUrl: ref.tosUrl, type: ref.type, role: getRoleForType(ref.type), label: ref.label });
|
|
} else if (ref.file) {
|
|
filesToUpload.push({ file: ref.file, type: ref.type, role: getRoleForType(ref.type), label: ref.label });
|
|
}
|
|
}
|
|
} else {
|
|
if (input.firstFrame?.tosUrl) {
|
|
filesToUpload.push({ tosUrl: input.firstFrame.tosUrl, type: 'image', role: 'first_frame', label: '首帧' });
|
|
} else if (input.firstFrame?.file) {
|
|
filesToUpload.push({ file: input.firstFrame.file, type: 'image', role: 'first_frame', label: '首帧' });
|
|
}
|
|
if (input.lastFrame?.tosUrl) {
|
|
filesToUpload.push({ tosUrl: input.lastFrame.tosUrl, type: 'image', role: 'last_frame', label: '尾帧' });
|
|
} else if (input.lastFrame?.file) {
|
|
filesToUpload.push({ file: input.lastFrame.file, type: 'image', role: 'last_frame', label: '尾帧' });
|
|
}
|
|
}
|
|
|
|
// Snapshot references for local display before uploading
|
|
const localRefs: ReferenceSnapshot[] =
|
|
input.mode === 'universal'
|
|
? input.references.map((r) => ({
|
|
id: r.id,
|
|
type: r.type,
|
|
previewUrl: r.previewUrl,
|
|
label: r.label,
|
|
}))
|
|
: [
|
|
input.firstFrame && {
|
|
id: input.firstFrame.id,
|
|
type: input.firstFrame.type,
|
|
previewUrl: input.firstFrame.previewUrl,
|
|
label: '首帧',
|
|
},
|
|
input.lastFrame && {
|
|
id: input.lastFrame.id,
|
|
type: input.lastFrame.type,
|
|
previewUrl: input.lastFrame.previewUrl,
|
|
label: '尾帧',
|
|
},
|
|
].filter(Boolean) as ReferenceSnapshot[];
|
|
|
|
// Create a placeholder task immediately for UI feedback
|
|
const tempId = `temp_${Date.now()}`;
|
|
const placeholderTask: GenerationTask = {
|
|
id: tempId,
|
|
taskId: '',
|
|
prompt: input.prompt,
|
|
editorHtml: input.editorHtml,
|
|
mode: input.mode,
|
|
model: input.model,
|
|
aspectRatio: input.aspectRatio,
|
|
duration: input.duration,
|
|
references: localRefs,
|
|
status: 'generating',
|
|
progress: 0,
|
|
createdAt: Date.now(),
|
|
};
|
|
|
|
set((s) => ({ tasks: [...s.tasks, placeholderTask] }));
|
|
|
|
// Start smooth progress animation
|
|
ensureSmoothProgress();
|
|
|
|
// Clear input
|
|
useInputBarStore.setState({
|
|
prompt: '',
|
|
editorHtml: '',
|
|
references: [],
|
|
firstFrame: null,
|
|
lastFrame: null,
|
|
});
|
|
|
|
try {
|
|
// Upload files to TOS (or reuse existing TOS URLs)
|
|
const uploadedRefs: { url: string; type: string; role: string; label: string }[] = [];
|
|
|
|
for (const item of filesToUpload) {
|
|
if (item.tosUrl) {
|
|
uploadedRefs.push({ url: item.tosUrl, type: item.type, role: item.role, label: item.label });
|
|
} else if (item.file) {
|
|
const { data: uploadResult } = await mediaApi.upload(item.file);
|
|
uploadedRefs.push({ url: uploadResult.url, type: item.type, role: item.role, label: item.label });
|
|
}
|
|
}
|
|
|
|
// Call generate API
|
|
const { data: genResult } = await videoApi.generate({
|
|
prompt: input.prompt,
|
|
mode: input.mode,
|
|
model: input.model,
|
|
aspect_ratio: input.aspectRatio,
|
|
duration: input.duration,
|
|
references: uploadedRefs,
|
|
});
|
|
|
|
// Update task with real backend IDs
|
|
const frontendId = `backend_${genResult.task_id}`;
|
|
const taskStatus = mapStatus(genResult.status);
|
|
set((s) => ({
|
|
tasks: s.tasks.map((t) =>
|
|
t.id === tempId
|
|
? {
|
|
...t,
|
|
id: frontendId,
|
|
taskId: genResult.task_id,
|
|
status: taskStatus as GenerationTask['status'],
|
|
progress: taskStatus === 'completed' ? 100 : taskStatus === 'failed' ? 0 : t.progress,
|
|
errorMessage: mapErrorMessage(genResult.error_message) || t.errorMessage,
|
|
}
|
|
: t
|
|
),
|
|
}));
|
|
|
|
// Update reference previews with TOS URLs (persist on refresh)
|
|
const updatedRefs = localRefs.map((ref, i) => ({
|
|
...ref,
|
|
previewUrl: uploadedRefs[i]?.url || ref.previewUrl,
|
|
}));
|
|
set((s) => ({
|
|
tasks: s.tasks.map((t) =>
|
|
t.id === frontendId ? { ...t, references: updatedRefs } : t
|
|
),
|
|
}));
|
|
|
|
// Start polling only if task is still generating
|
|
if (taskStatus === 'generating') {
|
|
startPolling(genResult.task_id, frontendId);
|
|
}
|
|
|
|
// Refresh quota
|
|
useAuthStore.getState().fetchUserInfo();
|
|
|
|
return frontendId;
|
|
} catch (err: unknown) {
|
|
const error = err as { response?: { status?: number; data?: { message?: string } } };
|
|
const msg = error.response?.data?.message;
|
|
showToast(msg || '生成失败,请重试');
|
|
|
|
// Mark task as failed
|
|
set((s) => ({
|
|
tasks: s.tasks.map((t) =>
|
|
t.id === tempId ? { ...t, status: 'failed' as const, progress: 0 } : t
|
|
),
|
|
}));
|
|
|
|
return null;
|
|
}
|
|
},
|
|
|
|
removeTask: (id) => {
|
|
stopPolling(id);
|
|
const task = get().tasks.find((t) => t.id === id);
|
|
set((s) => ({ tasks: s.tasks.filter((t) => t.id !== id) }));
|
|
if (task?.taskId) {
|
|
videoApi.deleteTask(task.taskId).catch(() => {});
|
|
}
|
|
},
|
|
|
|
reEdit: (id) => {
|
|
const task = get().tasks.find((t) => t.id === id);
|
|
if (!task) return;
|
|
|
|
const inputStore = useInputBarStore.getState();
|
|
|
|
if (inputStore.mode !== task.mode) {
|
|
inputStore.switchMode(task.mode);
|
|
}
|
|
|
|
if (task.mode === 'universal') {
|
|
const references: UploadedFile[] = task.references.map((r) => ({
|
|
id: r.id,
|
|
type: r.type,
|
|
previewUrl: r.previewUrl,
|
|
label: r.label,
|
|
tosUrl: r.previewUrl,
|
|
}));
|
|
useInputBarStore.setState({
|
|
prompt: task.prompt,
|
|
editorHtml: task.editorHtml || task.prompt,
|
|
aspectRatio: task.aspectRatio,
|
|
duration: task.duration,
|
|
references,
|
|
});
|
|
} else {
|
|
// Keyframe mode: restore firstFrame and lastFrame
|
|
const firstRef = task.references.find((r) => r.label === '首帧');
|
|
const lastRef = task.references.find((r) => r.label === '尾帧');
|
|
useInputBarStore.setState({
|
|
prompt: task.prompt,
|
|
editorHtml: task.editorHtml || task.prompt,
|
|
aspectRatio: task.aspectRatio,
|
|
duration: task.duration,
|
|
firstFrame: firstRef ? { id: firstRef.id, type: firstRef.type, previewUrl: firstRef.previewUrl, label: '首帧', tosUrl: firstRef.previewUrl } : null,
|
|
lastFrame: lastRef ? { id: lastRef.id, type: lastRef.type, previewUrl: lastRef.previewUrl, label: '尾帧', tosUrl: lastRef.previewUrl } : null,
|
|
});
|
|
}
|
|
},
|
|
|
|
regenerate: (id) => {
|
|
const task = get().tasks.find((t) => t.id === id);
|
|
if (!task) return;
|
|
|
|
// Restore task data into input bar and trigger addTask
|
|
const inputStore = useInputBarStore.getState();
|
|
|
|
if (inputStore.mode !== task.mode) {
|
|
inputStore.switchMode(task.mode);
|
|
}
|
|
|
|
// For regeneration, we need to re-submit with the same TOS URLs
|
|
// Set up the input bar state, then call addTask
|
|
const references: UploadedFile[] = task.references.map((r) => ({
|
|
id: r.id,
|
|
type: r.type,
|
|
previewUrl: r.previewUrl,
|
|
label: r.label,
|
|
tosUrl: r.previewUrl, // TOS URL from previous upload
|
|
}));
|
|
|
|
useInputBarStore.setState({
|
|
prompt: task.prompt,
|
|
editorHtml: task.editorHtml || task.prompt,
|
|
model: task.model,
|
|
aspectRatio: task.aspectRatio,
|
|
duration: task.duration,
|
|
references: task.mode === 'universal' ? references : [],
|
|
});
|
|
|
|
// Trigger generation
|
|
get().addTask();
|
|
},
|
|
|
|
saveScrollPosition: (top: number) => {
|
|
set({ savedScrollTop: top });
|
|
},
|
|
}));
|