diff --git a/web/src/components/VideoGenerationPage.tsx b/web/src/components/VideoGenerationPage.tsx
index 1e1b977..a16b326 100644
--- a/web/src/components/VideoGenerationPage.tsx
+++ b/web/src/components/VideoGenerationPage.tsx
@@ -9,9 +9,15 @@ import styles from './VideoGenerationPage.module.css';
export function VideoGenerationPage() {
const tasks = useGenerationStore((s) => s.tasks);
+ const loadTasks = useGenerationStore((s) => s.loadTasks);
const scrollRef = useRef
(null);
const prevCountRef = useRef(tasks.length);
+ // Load tasks from backend on mount (persist across page refresh)
+ useEffect(() => {
+ loadTasks();
+ }, [loadTasks]);
+
// Auto-scroll to top when new task is added
useEffect(() => {
if (tasks.length > prevCountRef.current && scrollRef.current) {
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index 3347903..077d569 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -2,6 +2,7 @@ import axios, { AxiosError } from 'axios';
import type {
User, Quota, AuthTokens, AdminStats, AdminUser, AdminUserDetail,
AdminRecord, SystemSettings, ProfileOverview, PaginatedResponse,
+ BackendTask,
} from '../types';
import { reportError } from './logCenter';
@@ -75,18 +76,46 @@ export const authApi = {
api.get('/auth/me'),
};
+// Media upload API
+export const mediaApi = {
+ upload: (file: File) => {
+ const formData = new FormData();
+ formData.append('file', file);
+ return api.post<{
+ url: string;
+ type: 'image' | 'video';
+ filename: string;
+ size: number;
+ }>('/media/upload', formData, {
+ headers: { 'Content-Type': 'multipart/form-data' },
+ });
+ },
+};
+
// Video generation API
export const videoApi = {
- generate: (formData: FormData) =>
+ generate: (data: {
+ prompt: string;
+ mode: string;
+ model: string;
+ aspect_ratio: string;
+ duration: number;
+ references: { url: string; type: string; role: string; label: string }[];
+ }) =>
api.post<{
task_id: string;
+ ark_task_id: string;
status: string;
estimated_time: number;
seconds_consumed: number;
remaining_seconds_today: number;
- }>('/video/generate', formData, {
- headers: { 'Content-Type': 'multipart/form-data' },
- }),
+ }>('/video/generate', data),
+
+ getTasks: () =>
+ api.get<{ results: BackendTask[] }>('/video/tasks'),
+
+ getTaskStatus: (taskId: string) =>
+ api.get(`/video/tasks/${taskId}`),
};
// Admin APIs
diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx
index 3e7b8da..5e597ab 100644
--- a/web/src/pages/DashboardPage.tsx
+++ b/web/src/pages/DashboardPage.tsx
@@ -11,33 +11,6 @@ import styles from './DashboardPage.module.css';
echarts.use([LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, CanvasRenderer]);
-// Generate mock data for development when backend returns empty
-function generateMockTrend(): { date: string; seconds: number }[] {
- const data: { date: string; seconds: number }[] = [];
- const now = new Date();
- for (let i = 29; i >= 0; i--) {
- const d = new Date(now);
- d.setDate(d.getDate() - i);
- const isWeekend = d.getDay() === 0 || d.getDay() === 6;
- const base = isWeekend ? 1200 : 3000;
- const variation = Math.random() * 2000 - 800;
- data.push({
- date: d.toISOString().slice(0, 10),
- seconds: Math.max(0, Math.round(base + variation)),
- });
- }
- return data;
-}
-
-function generateMockTopUsers(): { user_id: number; username: string; seconds_consumed: number }[] {
- const names = ['alice', 'bob', 'charlie', 'diana', 'edward', 'fiona', 'george', 'helen', 'ivan', 'julia'];
- return names.map((name, i) => ({
- user_id: i + 1,
- username: name,
- seconds_consumed: Math.round(5000 - i * 400 + Math.random() * 200),
- }));
-}
-
export function DashboardPage() {
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
@@ -45,28 +18,9 @@ export function DashboardPage() {
const fetchData = useCallback(async () => {
try {
const { data } = await adminApi.getStats();
- // If daily_trend is all zeros (no real data), use mock
- const hasRealTrend = data.daily_trend.some((d) => d.seconds > 0);
- if (!hasRealTrend) {
- data.daily_trend = generateMockTrend();
- }
- if (data.top_users.length === 0) {
- data.top_users = generateMockTopUsers();
- }
setStats(data);
} catch {
showToast('加载数据失败');
- // Use complete mock data
- setStats({
- total_users: 1234,
- new_users_today: 23,
- seconds_consumed_today: 4560,
- seconds_consumed_this_month: 89010,
- today_change_percent: -5.0,
- month_change_percent: 8.0,
- daily_trend: generateMockTrend(),
- top_users: generateMockTopUsers(),
- });
} finally {
setLoading(false);
}
diff --git a/web/src/store/generation.ts b/web/src/store/generation.ts
index b9df121..8448938 100644
--- a/web/src/store/generation.ts
+++ b/web/src/store/generation.ts
@@ -1,60 +1,162 @@
import { create } from 'zustand';
-import type { GenerationTask, ReferenceSnapshot, UploadedFile } from '../types';
+import type { GenerationTask, ReferenceSnapshot, UploadedFile, BackendTask } from '../types';
import { useInputBarStore } from './inputBar';
-import { videoApi } from '../lib/api';
+import { videoApi, mediaApi } from '../lib/api';
import { useAuthStore } from './auth';
import { showToast } from '../components/Toast';
-let taskCounter = 0;
-
-interface GenerationState {
- tasks: GenerationTask[];
- addTask: () => string | null;
- removeTask: (id: string) => void;
- reEdit: (id: string) => void;
- regenerate: (id: string) => void;
+// 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 simulateProgress(taskId: string) {
- const store = useGenerationStore.getState;
- let progress = 0;
- const interval = setInterval(() => {
- progress += Math.random() * 15 + 5;
- if (progress >= 100) {
- progress = 100;
- clearInterval(interval);
- const task = store().tasks.find((t) => t.id === taskId);
- // Use the first reference image as mock result, or a placeholder
- const resultUrl = task?.references.find((r) => r.type === 'image')?.previewUrl;
+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: bt.error_message || undefined,
+ createdAt: new Date(bt.created_at).getTime(),
+ };
+}
+
+// Active polling timers
+const pollTimers = new Map>();
+
+function startPolling(taskId: string, frontendId: string) {
+ if (pollTimers.has(frontendId)) return;
+
+ const timer = setInterval(async () => {
+ try {
+ const { data } = await videoApi.getTaskStatus(taskId);
+ const newStatus = mapStatus(data.status);
+
useGenerationStore.setState((s) => ({
tasks: s.tasks.map((t) =>
- t.id === taskId
- ? { ...t, status: 'completed' as const, progress: 100, resultUrl }
+ t.id === frontendId
+ ? {
+ ...t,
+ status: newStatus,
+ progress: newStatus === 'completed' ? 100 : newStatus === 'failed' ? 0 : Math.min(t.progress + 5, 90),
+ resultUrl: data.result_url || t.resultUrl,
+ errorMessage: data.error_message || t.errorMessage,
+ }
: t
),
}));
- } else {
- useGenerationStore.setState((s) => ({
- tasks: s.tasks.map((t) =>
- t.id === taskId ? { ...t, progress: Math.round(progress) } : t
- ),
- }));
+
+ if (newStatus === 'completed' || newStatus === 'failed') {
+ clearInterval(timer);
+ pollTimers.delete(frontendId);
+ if (newStatus === 'completed') {
+ useAuthStore.getState().fetchUserInfo();
+ }
+ }
+ } catch {
+ // Silently continue polling on error
}
- }, 400);
+ }, 5000);
+
+ pollTimers.set(frontendId, timer);
+}
+
+function stopPolling(frontendId: string) {
+ const timer = pollTimers.get(frontendId);
+ if (timer) {
+ clearInterval(timer);
+ pollTimers.delete(frontendId);
+ }
+}
+
+interface GenerationState {
+ tasks: GenerationTask[];
+ isLoading: boolean;
+ addTask: () => Promise;
+ removeTask: (id: string) => void;
+ reEdit: (id: string) => void;
+ regenerate: (id: string) => void;
+ loadTasks: () => Promise;
}
export const useGenerationStore = create((set, get) => ({
tasks: [],
+ isLoading: false,
- addTask: () => {
+ loadTasks: async () => {
+ set({ isLoading: true });
+ try {
+ const { data } = await videoApi.getTasks();
+ const tasks = data.results.map(backendToFrontend);
+ set({ tasks, isLoading: false });
+
+ // Start polling for any active tasks
+ for (const task of tasks) {
+ if (task.status === 'generating' && task.taskId) {
+ startPolling(task.taskId, task.id);
+ }
+ }
+ } catch {
+ set({ isLoading: false });
+ }
+ },
+
+ addTask: async () => {
const input = useInputBarStore.getState();
if (!input.canSubmit()) return null;
- taskCounter++;
- const id = `task_${taskCounter}_${Date.now()}`;
+ // Collect files to upload (or existing TOS URLs for regeneration)
+ const filesToUpload: { file?: File; tosUrl?: string; type: 'image' | 'video'; role: string; label: string }[] = [];
- // Snapshot references
- const references: ReferenceSnapshot[] =
+ if (input.mode === 'universal') {
+ for (const ref of input.references) {
+ if (ref.tosUrl) {
+ // Already uploaded to TOS (regeneration)
+ filesToUpload.push({ tosUrl: ref.tosUrl, type: ref.type, role: ref.type === 'video' ? 'reference_video' : 'reference_image', label: ref.label });
+ } else if (ref.file) {
+ filesToUpload.push({ file: ref.file, type: ref.type, role: ref.type === 'video' ? 'reference_video' : 'reference_image', 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,
@@ -77,23 +179,26 @@ export const useGenerationStore = create((set, get) => ({
},
].filter(Boolean) as ReferenceSnapshot[];
- const task: GenerationTask = {
- id,
+ // 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,
+ references: localRefs,
status: 'generating',
- progress: 0,
+ progress: 5,
createdAt: Date.now(),
};
- set((s) => ({ tasks: [task, ...s.tasks] }));
+ set((s) => ({ tasks: [placeholderTask, ...s.tasks] }));
- // Clear input after submit (don't revoke URLs since task snapshots reference them)
+ // Clear input
useInputBarStore.setState({
prompt: '',
editorHtml: '',
@@ -102,30 +207,100 @@ export const useGenerationStore = create((set, get) => ({
lastFrame: null,
});
- // Start mock progress (frontend simulation)
- simulateProgress(id);
+ try {
+ // Upload files to TOS (or reuse existing TOS URLs)
+ const uploadedRefs: { url: string; type: string; role: string; label: string }[] = [];
- // Call backend API to record the generation (fire-and-forget)
- const formData = new FormData();
- formData.append('prompt', input.prompt);
- formData.append('mode', input.mode);
- formData.append('model', input.model);
- formData.append('aspect_ratio', input.aspectRatio);
- formData.append('duration', String(input.duration));
+ for (const item of filesToUpload) {
+ set((s) => ({
+ tasks: s.tasks.map((t) =>
+ t.id === tempId ? { ...t, progress: Math.min(t.progress + 10, 40) } : t
+ ),
+ }));
- videoApi.generate(formData).then(() => {
- // Refresh quota info after successful generation
- useAuthStore.getState().fetchUserInfo();
- }).catch((err) => {
- if (err.response?.status === 429) {
- showToast(err.response.data.message || '今日额度已用完');
+ 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 });
+ }
}
- });
- return id;
+ // Update progress: files uploaded
+ set((s) => ({
+ tasks: s.tasks.map((t) =>
+ t.id === tempId ? { ...t, progress: 50 } : t
+ ),
+ }));
+
+ // 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 : 60,
+ }
+ : 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 } } };
+ if (error.response?.status === 429) {
+ showToast(error.response.data?.message || '今日额度已用完');
+ } else {
+ showToast('生成失败,请重试');
+ }
+
+ // 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);
set((s) => ({ tasks: s.tasks.filter((t) => t.id !== id) }));
},
@@ -135,12 +310,10 @@ export const useGenerationStore = create((set, get) => ({
const inputStore = useInputBarStore.getState();
- // Switch mode first
if (inputStore.mode !== task.mode) {
inputStore.switchMode(task.mode);
}
- // Restore references from task snapshot (without original File)
const references: UploadedFile[] = task.references.map((r) => ({
id: r.id,
type: r.type,
@@ -148,7 +321,6 @@ export const useGenerationStore = create((set, get) => ({
label: r.label,
}));
- // Set prompt, editorHtml, settings, and references
useInputBarStore.setState({
prompt: task.prompt,
editorHtml: task.editorHtml || task.prompt,
@@ -162,18 +334,33 @@ export const useGenerationStore = create((set, get) => ({
const task = get().tasks.find((t) => t.id === id);
if (!task) return;
- taskCounter++;
- const newId = `task_${taskCounter}_${Date.now()}`;
- const newTask: GenerationTask = {
- ...task,
- id: newId,
- status: 'generating',
- progress: 0,
- resultUrl: undefined,
- createdAt: Date.now(),
- };
+ // Restore task data into input bar and trigger addTask
+ const inputStore = useInputBarStore.getState();
- set((s) => ({ tasks: [newTask, ...s.tasks] }));
- simulateProgress(newId);
+ 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();
},
}));
diff --git a/web/src/types/index.ts b/web/src/types/index.ts
index 578bab9..b3dc20a 100644
--- a/web/src/types/index.ts
+++ b/web/src/types/index.ts
@@ -10,6 +10,7 @@ export interface UploadedFile {
type: 'image' | 'video';
previewUrl: string;
label: string;
+ tosUrl?: string; // TOS URL after upload
}
export interface DropdownOption {
@@ -26,10 +27,12 @@ export interface ReferenceSnapshot {
type: 'image' | 'video';
previewUrl: string;
label: string;
+ role?: string;
}
export interface GenerationTask {
id: string;
+ taskId: string; // backend UUID task_id
prompt: string;
editorHtml: string;
mode: CreationMode;
@@ -40,9 +43,27 @@ export interface GenerationTask {
status: TaskStatus;
progress: number;
resultUrl?: string;
+ errorMessage?: string;
createdAt: number;
}
+export interface BackendTask {
+ id: number;
+ task_id: string;
+ ark_task_id: string;
+ prompt: string;
+ mode: CreationMode;
+ model: ModelOption;
+ aspect_ratio: string;
+ duration: number;
+ seconds_consumed: number;
+ status: 'queued' | 'processing' | 'completed' | 'failed';
+ result_url: string;
+ error_message: string;
+ reference_urls: { url: string; type: string; role: string; label: string }[];
+ created_at: string;
+}
+
// Auth types
export interface User {
id: number;
diff --git a/web/test/unit/generationStore.test.ts b/web/test/unit/generationStore.test.ts
index 076b600..20f6279 100644
--- a/web/test/unit/generationStore.test.ts
+++ b/web/test/unit/generationStore.test.ts
@@ -10,6 +10,26 @@ vi.stubGlobal('localStorage', {
key: vi.fn(() => null),
});
+const mockGenerate = vi.fn().mockResolvedValue({
+ data: {
+ task_id: 'test-uuid-001',
+ ark_task_id: '',
+ status: 'queued',
+ estimated_time: 120,
+ seconds_consumed: 5,
+ remaining_seconds_today: 595,
+ },
+});
+
+const mockUpload = vi.fn().mockResolvedValue({
+ data: { url: 'https://tos.example.com/image/test.jpg', type: 'image', filename: 'test.jpg', size: 1234 },
+});
+
+const mockGetTasks = vi.fn().mockResolvedValue({ data: { results: [] } });
+const mockGetTaskStatus = vi.fn().mockResolvedValue({
+ data: { task_id: 'test-uuid-001', status: 'queued', result_url: '', error_message: '', reference_urls: [] },
+});
+
// Mock auth API to prevent network calls from auth store
vi.mock('../../src/lib/api', () => ({
authApi: {
@@ -18,7 +38,14 @@ vi.mock('../../src/lib/api', () => ({
refreshToken: vi.fn(),
getMe: vi.fn(),
},
- videoApi: { generate: vi.fn().mockResolvedValue({ data: { remaining_quota: 45 } }) },
+ videoApi: {
+ generate: (...args: unknown[]) => mockGenerate(...args),
+ getTasks: (...args: unknown[]) => mockGetTasks(...args),
+ getTaskStatus: (...args: unknown[]) => mockGetTaskStatus(...args),
+ },
+ mediaApi: {
+ upload: (...args: unknown[]) => mockUpload(...args),
+ },
adminApi: { getStats: vi.fn(), getUserRankings: vi.fn(), updateUserQuota: vi.fn() },
default: { interceptors: { request: { use: vi.fn() }, response: { use: vi.fn() } } },
}));
@@ -27,6 +54,19 @@ vi.mock('../../src/components/Toast', () => ({
showToast: vi.fn(),
}));
+// Mock fetchUserInfo to prevent network calls
+vi.mock('../../src/store/auth', async (importOriginal) => {
+ const original = await importOriginal() as Record;
+ return {
+ ...original,
+ useAuthStore: Object.assign(
+ (selector: (s: Record) => unknown) =>
+ selector({ fetchUserInfo: vi.fn() }),
+ { getState: () => ({ fetchUserInfo: vi.fn() }) }
+ ),
+ };
+});
+
import { useGenerationStore } from '../../src/store/generation';
import { useInputBarStore } from '../../src/store/inputBar';
@@ -38,7 +78,26 @@ describe('Generation Store', () => {
beforeEach(() => {
vi.useFakeTimers();
useInputBarStore.getState().reset();
- useGenerationStore.setState({ tasks: [] });
+ useGenerationStore.setState({ tasks: [], isLoading: false });
+ mockGenerate.mockClear();
+ mockUpload.mockClear();
+ mockGetTasks.mockClear();
+ mockGetTaskStatus.mockClear();
+ // Reset generate mock to return unique IDs
+ let callCount = 0;
+ mockGenerate.mockImplementation(() => {
+ callCount++;
+ return Promise.resolve({
+ data: {
+ task_id: `test-uuid-${String(callCount).padStart(3, '0')}`,
+ ark_task_id: '',
+ status: 'queued',
+ estimated_time: 120,
+ seconds_consumed: 5,
+ remaining_seconds_today: 595,
+ },
+ });
+ });
});
afterEach(() => {
@@ -46,145 +105,192 @@ describe('Generation Store', () => {
});
describe('addTask', () => {
- it('should return null when canSubmit is false', () => {
- const result = useGenerationStore.getState().addTask();
+ it('should return null when canSubmit is false', async () => {
+ const result = await useGenerationStore.getState().addTask();
expect(result).toBeNull();
});
- it('should create a task when prompt has text', () => {
+ it('should create a task when prompt has text', async () => {
+ vi.useRealTimers();
useInputBarStore.getState().setPrompt('test prompt');
- const id = useGenerationStore.getState().addTask();
+ const id = await useGenerationStore.getState().addTask();
expect(id).not.toBeNull();
expect(useGenerationStore.getState().tasks).toHaveLength(1);
+ vi.useFakeTimers();
});
- it('should create task with correct properties', () => {
+ it('should create task with correct properties', async () => {
useInputBarStore.getState().setPrompt('a beautiful scene');
useInputBarStore.getState().setModel('seedance_2.0_fast');
useInputBarStore.getState().setAspectRatio('16:9');
useInputBarStore.getState().setDuration(10);
- useGenerationStore.getState().addTask();
+ // Placeholder task is created synchronously
+ const promise = useGenerationStore.getState().addTask();
const task = useGenerationStore.getState().tasks[0];
-
expect(task.prompt).toBe('a beautiful scene');
expect(task.model).toBe('seedance_2.0_fast');
expect(task.aspectRatio).toBe('16:9');
expect(task.duration).toBe(10);
expect(task.mode).toBe('universal');
expect(task.status).toBe('generating');
- expect(task.progress).toBe(0);
+
+ vi.useRealTimers();
+ await promise;
+ vi.useFakeTimers();
});
- it('should snapshot references in universal mode', () => {
+ it('should snapshot references in universal mode', async () => {
useInputBarStore.getState().addReferences([
createMockFile('img1.jpg', 'image/jpeg'),
createMockFile('img2.jpg', 'image/jpeg'),
]);
- useGenerationStore.getState().addTask();
+ const promise = useGenerationStore.getState().addTask();
const task = useGenerationStore.getState().tasks[0];
-
expect(task.references).toHaveLength(2);
expect(task.references[0].type).toBe('image');
+
+ vi.useRealTimers();
+ await promise;
+ vi.useFakeTimers();
});
- it('should snapshot frames in keyframe mode', () => {
+ it('should snapshot frames in keyframe mode', async () => {
useInputBarStore.getState().switchMode('keyframe');
useInputBarStore.getState().setFirstFrame(createMockFile('first.jpg', 'image/jpeg'));
useInputBarStore.getState().setLastFrame(createMockFile('last.jpg', 'image/jpeg'));
- useGenerationStore.getState().addTask();
+ const promise = useGenerationStore.getState().addTask();
const task = useGenerationStore.getState().tasks[0];
-
expect(task.references).toHaveLength(2);
expect(task.references[0].label).toBe('首帧');
expect(task.references[1].label).toBe('尾帧');
+
+ vi.useRealTimers();
+ await promise;
+ vi.useFakeTimers();
});
- it('should clear input after submit', () => {
+ it('should clear input after submit', async () => {
useInputBarStore.getState().setPrompt('test');
useInputBarStore.getState().addReferences([createMockFile('img.jpg', 'image/jpeg')]);
- useGenerationStore.getState().addTask();
-
+ const promise = useGenerationStore.getState().addTask();
+ // Input is cleared synchronously after placeholder creation
expect(useInputBarStore.getState().prompt).toBe('');
expect(useInputBarStore.getState().references).toHaveLength(0);
+
+ vi.useRealTimers();
+ await promise;
+ vi.useFakeTimers();
});
- it('should prepend new tasks (newest first)', () => {
+ it('should prepend new tasks (newest first)', async () => {
+ vi.useRealTimers();
useInputBarStore.getState().setPrompt('first');
- useGenerationStore.getState().addTask();
+ await useGenerationStore.getState().addTask();
useInputBarStore.getState().setPrompt('second');
- useGenerationStore.getState().addTask();
+ await useGenerationStore.getState().addTask();
const tasks = useGenerationStore.getState().tasks;
expect(tasks).toHaveLength(2);
expect(tasks[0].prompt).toBe('second');
expect(tasks[1].prompt).toBe('first');
+ vi.useFakeTimers();
});
- it('should simulate progress over time', () => {
- useInputBarStore.getState().setPrompt('test');
- useGenerationStore.getState().addTask();
+ it('should call videoApi.generate with correct data', async () => {
+ vi.useRealTimers();
+ useInputBarStore.getState().setPrompt('test prompt');
+ useInputBarStore.getState().setModel('seedance_2.0');
+ useInputBarStore.getState().setAspectRatio('16:9');
+ useInputBarStore.getState().setDuration(10);
- // Advance timers to trigger progress
- vi.advanceTimersByTime(2000);
+ await useGenerationStore.getState().addTask();
- const task = useGenerationStore.getState().tasks[0];
- expect(task.progress).toBeGreaterThan(0);
+ expect(mockGenerate).toHaveBeenCalledWith({
+ prompt: 'test prompt',
+ mode: 'universal',
+ model: 'seedance_2.0',
+ aspect_ratio: '16:9',
+ duration: 10,
+ references: [],
+ });
+ vi.useFakeTimers();
+ });
+
+ it('should upload files to TOS before generating', async () => {
+ vi.useRealTimers();
+ useInputBarStore.getState().addReferences([
+ createMockFile('img.jpg', 'image/jpeg'),
+ ]);
+
+ await useGenerationStore.getState().addTask();
+
+ expect(mockUpload).toHaveBeenCalledTimes(1);
+ expect(mockGenerate).toHaveBeenCalledTimes(1);
+ vi.useFakeTimers();
});
});
describe('removeTask', () => {
- it('should remove a task by id', () => {
+ it('should remove a task by id', async () => {
+ vi.useRealTimers();
useInputBarStore.getState().setPrompt('test');
- const id = useGenerationStore.getState().addTask()!;
- expect(useGenerationStore.getState().tasks).toHaveLength(1);
+ const id = await useGenerationStore.getState().addTask();
- useGenerationStore.getState().removeTask(id);
+ expect(useGenerationStore.getState().tasks).toHaveLength(1);
+ useGenerationStore.getState().removeTask(id!);
expect(useGenerationStore.getState().tasks).toHaveLength(0);
+ vi.useFakeTimers();
});
- it('should not affect other tasks', () => {
+ it('should not affect other tasks', async () => {
+ vi.useRealTimers();
useInputBarStore.getState().setPrompt('first');
- const id1 = useGenerationStore.getState().addTask()!;
+ const id1 = await useGenerationStore.getState().addTask();
useInputBarStore.getState().setPrompt('second');
- useGenerationStore.getState().addTask();
+ await useGenerationStore.getState().addTask();
- useGenerationStore.getState().removeTask(id1);
+ useGenerationStore.getState().removeTask(id1!);
expect(useGenerationStore.getState().tasks).toHaveLength(1);
expect(useGenerationStore.getState().tasks[0].prompt).toBe('second');
+ vi.useFakeTimers();
});
});
describe('reEdit', () => {
- it('should restore prompt from task', () => {
+ it('should restore prompt from task', async () => {
+ vi.useRealTimers();
useInputBarStore.getState().setPrompt('original prompt');
- const id = useGenerationStore.getState().addTask()!;
+ const id = await useGenerationStore.getState().addTask();
// Input is cleared after submit
expect(useInputBarStore.getState().prompt).toBe('');
- useGenerationStore.getState().reEdit(id);
+ useGenerationStore.getState().reEdit(id!);
expect(useInputBarStore.getState().prompt).toBe('original prompt');
+ vi.useFakeTimers();
});
- it('should restore settings from task', () => {
+ it('should restore settings from task', async () => {
+ vi.useRealTimers();
useInputBarStore.getState().setPrompt('test');
useInputBarStore.getState().setAspectRatio('16:9');
useInputBarStore.getState().setDuration(10);
- const id = useGenerationStore.getState().addTask()!;
+ const id = await useGenerationStore.getState().addTask();
// Reset to defaults
useInputBarStore.getState().setAspectRatio('21:9');
useInputBarStore.getState().setDuration(15);
- useGenerationStore.getState().reEdit(id);
+ useGenerationStore.getState().reEdit(id!);
expect(useInputBarStore.getState().aspectRatio).toBe('16:9');
expect(useInputBarStore.getState().duration).toBe(10);
+ vi.useFakeTimers();
});
it('should do nothing for non-existent task', () => {
@@ -195,19 +301,22 @@ describe('Generation Store', () => {
});
describe('regenerate', () => {
- it('should create a new task based on existing one', () => {
+ it('should create a new task based on existing one', async () => {
+ vi.useRealTimers();
useInputBarStore.getState().setPrompt('test');
- useGenerationStore.getState().addTask();
+ const originalId = await useGenerationStore.getState().addTask();
- const originalId = useGenerationStore.getState().tasks[0].id;
- useGenerationStore.getState().regenerate(originalId);
+ useGenerationStore.getState().regenerate(originalId!);
+ // Allow time for the async regenerate to complete
+ await new Promise(resolve => setTimeout(resolve, 100));
- expect(useGenerationStore.getState().tasks).toHaveLength(2);
- const newTask = useGenerationStore.getState().tasks[0]; // newest first
+ const tasks = useGenerationStore.getState().tasks;
+ expect(tasks.length).toBeGreaterThanOrEqual(2);
+ const newTask = tasks[0]; // newest first
expect(newTask.id).not.toBe(originalId);
expect(newTask.prompt).toBe('test');
expect(newTask.status).toBe('generating');
- expect(newTask.progress).toBe(0);
+ vi.useFakeTimers();
});
it('should do nothing for non-existent task', () => {
@@ -215,4 +324,48 @@ describe('Generation Store', () => {
expect(useGenerationStore.getState().tasks).toHaveLength(0);
});
});
+
+ describe('loadTasks', () => {
+ it('should fetch tasks from backend on load', async () => {
+ vi.useRealTimers();
+ mockGetTasks.mockResolvedValueOnce({
+ data: {
+ results: [
+ {
+ id: 1,
+ task_id: 'uuid-from-db',
+ ark_task_id: 'ark-123',
+ prompt: 'loaded prompt',
+ mode: 'universal',
+ model: 'seedance_2.0',
+ aspect_ratio: '16:9',
+ duration: 10,
+ seconds_consumed: 10,
+ status: 'completed',
+ result_url: 'https://example.com/video.mp4',
+ error_message: '',
+ reference_urls: [],
+ created_at: '2026-03-13T00:00:00Z',
+ },
+ ],
+ },
+ });
+
+ await useGenerationStore.getState().loadTasks();
+
+ const tasks = useGenerationStore.getState().tasks;
+ expect(tasks).toHaveLength(1);
+ expect(tasks[0].prompt).toBe('loaded prompt');
+ expect(tasks[0].status).toBe('completed');
+ expect(tasks[0].resultUrl).toBe('https://example.com/video.mp4');
+ vi.useFakeTimers();
+ });
+
+ it('should set isLoading state', async () => {
+ vi.useRealTimers();
+ await useGenerationStore.getState().loadTasks();
+ expect(useGenerationStore.getState().isLoading).toBe(false);
+ vi.useFakeTimers();
+ });
+ });
});