video-shuoshan/web/src/store/generation.ts
seaislee1209 b520b429c5
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m22s
feat: 密码管理 + 错误提示体系统一 (v0.9.2 & v0.9.3)
密码管理:用户自助修改密码(个人中心弹窗)、管理员重置用户密码(审计日志记录)
错误提示:补全火山 ARK 错误码映射(+7 个)、修复创建失败时前端不显示真实错误、
轮询失败走 ERROR_MESSAGES 映射、前端 catch 统一取后端 message

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 17:12:40 +08:00

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 });
},
}));