排队和token不足

This commit is contained in:
北枳 2025-08-29 01:07:22 +08:00
parent dd5772c14f
commit cf1c9823ea
21 changed files with 406 additions and 278 deletions

View File

@ -39,7 +39,7 @@ export const getUploadToken = async (timeoutMs: number = 10000): Promise<{ token
// 添加超时控制
const controller = new AbortController()
const timeoutId = setTimeout(() => {
console.log(`请求超时(${timeoutMs / 1000}秒),正在中断请求...`)
console.log(`Request timeout${timeoutMs / 1000}saborting...`)
controller.abort()
}, timeoutMs)
@ -59,15 +59,15 @@ export const getUploadToken = async (timeoutMs: number = 10000): Promise<{ token
if (!response.ok) {
const errorText = await response.text()
console.error("获取token响应错误:", response.status, errorText)
throw new Error(`获取token失败: ${response.status} ${response.statusText}`)
console.error("Get token response error:", response.status, errorText)
throw new Error(`Get token failed: ${response.status} ${response.statusText}`)
}
const result: TokenWithDomainResponse | TokenResponse = await response.json()
console.log("获取token响应:", result)
console.log("Get token response:", result)
if (result.code === 0 && result.successful && result.data.token) {
console.log("成功获取token")
console.log("Successfully get token")
// Support both old and new API response formats
const domain = 'domain' in result.data ? result.data.domain : 'cdn.qikongjian.com'
return {
@ -75,17 +75,17 @@ export const getUploadToken = async (timeoutMs: number = 10000): Promise<{ token
domain: domain
}
} else {
throw new Error(result.message || "获取token失败服务器未返回有效token")
throw new Error(result.message || "Get token failed, server did not return a valid token")
}
} catch (error) {
clearTimeout(timeoutId)
console.error("获取上传token失败:", error)
console.error("Get upload token failed:", error)
if (error instanceof Error) {
if (error.name === "AbortError") {
throw new Error("请求超时,请检查网络连接")
throw new Error("Request timeout, please check your network connection")
} else if (error.message.includes("Failed to fetch")) {
throw new Error("网络连接失败可能是CORS策略限制或服务器不可达")
throw new Error("Network connection failed, possibly due to CORS policy or server unreachable")
}
}
@ -129,25 +129,25 @@ export const uploadToQiniu = async (
try {
const response: QiniuUploadResponse = JSON.parse(xhr.responseText)
const qiniuUrl = `https://cdn.qikongjian.com/${response.key || uniqueFileName}`
console.log("七牛云上传成功:", response)
console.log("Qiniu cloud upload success:", response)
resolve(qiniuUrl)
} catch (error) {
console.error("解析响应失败:", error, "原始响应:", xhr.responseText)
reject(new Error(`解析上传响应失败: ${xhr.responseText}`))
console.error("Parse response failed:", error, "Original response:", xhr.responseText)
reject(new Error(`Parse upload response failed: ${xhr.responseText}`))
}
} else {
console.error("七牛云上传失败:", xhr.status, xhr.statusText, xhr.responseText)
reject(new Error(`上传失败: ${xhr.status} ${xhr.statusText}`))
console.error("Qiniu cloud upload failed:", xhr.status, xhr.statusText, xhr.responseText)
reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`))
}
})
xhr.addEventListener("error", (e) => {
console.error("上传网络错误:", e)
reject(new Error("网络错误,上传失败"))
console.error("Upload network error:", e)
reject(new Error("Network error, upload failed"))
})
xhr.addEventListener("abort", () => {
reject(new Error("上传被取消"))
reject(new Error("Upload aborted"))
})
xhr.open("POST", "https://up-z2.qiniup.com")

View File

@ -1,3 +1,3 @@
export const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL
// export const BASE_URL ='http://192.168.120.36:8000'
// export const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL
export const BASE_URL ='http://192.168.120.5:8000'
//

39
api/create_movie.ts Normal file
View File

@ -0,0 +1,39 @@
import {
CreateMovieProjectV2Request,
CreateMovieProjectV3Request,
} from "./DTO/movie_start_dto";
import { post } from "./request";
import { withQueuePolling, QueueResponse } from './movie_queue';
// 新-创建接口
export const createMovieProject = async (data: any): Promise<QueueResponse> => {
const apiCall = (params: any) => post<QueueResponse>('/movie/create_movie_project', params);
return withQueuePolling(apiCall, data);
};
/**
* V2
* @param request -
* @returns Promise<QueueResponse>
*/
export const createMovieProjectV2 = async (
request: CreateMovieProjectV2Request
): Promise<QueueResponse> => {
const apiCall = (params: CreateMovieProjectV2Request) =>
post<QueueResponse>("/movie/create_movie_project_v2", params);
return withQueuePolling(apiCall, request);
};
/**
* V3
* @param request -
* @returns Promise<QueueResponse>
*/
export const createMovieProjectV3 = async (
request: CreateMovieProjectV3Request
): Promise<QueueResponse> => {
const apiCall = (params: CreateMovieProjectV3Request) =>
post<QueueResponse>("/movie/create_movie_project_v3", params);
return withQueuePolling(apiCall, request);
};

View File

@ -15,6 +15,7 @@ const HTTP_ERROR_MESSAGES: Record<number, string> = {
502: "Gateway error, please try again later.",
503: "Service temporarily unavailable, please try again later.",
504: "Gateway timeout, please try again later.",
4001: "Insufficient points, please recharge.",
};
/**
@ -32,7 +33,7 @@ const ERROR_HANDLERS: Record<number, () => void> = {
// 跳转到登录页面
window.location.href = '/login';
},
403: () => {
4001: () => {
// 显示积分不足通知
import('../utils/notifications').then(({ showInsufficientPointsNotification }) => {
showInsufficientPointsNotification();

161
api/movie_queue.ts Normal file
View File

@ -0,0 +1,161 @@
import { ApiResponse } from './common';
import { showQueueNotification } from '../components/QueueBox/QueueNotification2';
import { notification } from 'antd';
/** 队列状态枚举 */
export enum QueueStatus {
WAIT = 'wait', // 排队等待中
READY = 'ready', // 可以开始处理
PROCESS = 'process' // 处理中
}
/** 队列响应数据接口 */
export interface QueueResponseData {
status: QueueStatus;
position?: number;
queue_length?: number;
waiting?: number;
message?: string;
}
/** 队列响应接口 */
export interface QueueResponse extends ApiResponse<QueueResponseData> {
code: number;
message: string;
data: QueueResponseData;
}
/**
* API轮询的配置选项
*/
interface PollConfig {
/** 轮询间隔(ms), 默认10000ms */
interval?: number;
/** 最大轮询次数, 默认120次(20分钟) */
maxAttempts?: number;
/** 轮询成功后的回调 */
onSuccess?: (data: any) => void;
/** 轮询失败后的回调 */
onError?: (error: Error) => void;
/** 每次轮询时的回调 */
onPolling?: (data: QueueResponseData) => void;
/** 取消轮询时的回调 */
onCancel?: () => void;
}
// 用于存储取消函数的映射
const cancelTokens = new Map<string, () => void>();
/**
* API调用封装
*/
export async function withQueuePolling<T>(
apiCall: (params: T) => Promise<QueueResponse>,
params: T,
config: PollConfig = {}
): Promise<QueueResponse> {
const {
interval = 10000,
maxAttempts = 120,
onSuccess,
onError,
onPolling,
onCancel
} = config;
let attempts = 0;
let lastNotificationPosition: number | undefined;
let isCancelled = false;
// 生成唯一的轮询ID
const pollId = Math.random().toString(36).substring(7);
// 创建取消函数
const cancel = () => {
isCancelled = true;
notification.destroy(); // 关闭通知
onCancel?.();
cancelTokens.delete(pollId);
};
// 存储取消函数
cancelTokens.set(pollId, cancel);
const poll = async (): Promise<QueueResponse> => {
try {
if (isCancelled) {
throw new Error('操作已取消');
}
const response = await apiCall(params);
// 处理队列状态
if (response.code === 202 &&
(response.data.status === QueueStatus.WAIT || response.data.status === QueueStatus.PROCESS)) {
attempts++;
// 获取队列位置和等待时间
const { position, waiting, status } = response.data;
// 如果位置发生变化,更新通知
if ((position !== lastNotificationPosition || status === QueueStatus.PROCESS) &&
position !== undefined && waiting !== undefined) {
showQueueNotification(position, waiting, status, cancel);
lastNotificationPosition = position;
}
// 调用轮询回调
onPolling?.(response.data);
// 检查是否达到最大尝试次数
if (attempts >= maxAttempts) {
notification.destroy(); // 关闭通知
throw new Error('超过最大轮询次数限制');
}
// 继续轮询
await new Promise(resolve => setTimeout(resolve, interval));
return poll();
}
// 如果状态为ready结束轮询
if (response.code !== 202 && response.data) {
notification.destroy(); // 关闭通知
onSuccess?.(response.data);
return response;
}
notification.destroy(); // 关闭通知
return response;
} catch (error) {
notification.destroy(); // 关闭通知
if (error instanceof Error) {
onError?.(error);
}
throw error;
} finally {
cancelTokens.delete(pollId);
}
};
return poll();
}
/**
*
*/
export function cancelPolling(pollId: string) {
const cancel = cancelTokens.get(pollId);
if (cancel) {
cancel();
}
}
/**
*
*/
export function cancelAllPolling() {
// 将 Map 转换为数组后再遍历
Array.from(cancelTokens.values()).forEach(cancel => cancel());
cancelTokens.clear();
}

View File

@ -1,11 +1,7 @@
import { ApiResponse } from "./common";
import {
CreateMovieProjectV2Request,
CreateMovieProjectResponse,
MovieStartDTO,
StoryAnalysisTask,
MovieStoryTaskDetail,
CreateMovieProjectV3Request,
GeminiTextToImageRequest,
GeminiTextToImageResponse,
TextToImageRequest,
@ -44,34 +40,6 @@ export const AIGenerateImageStory = async (request: {
);
};
/**
* V2
* @param request -
* @returns Promise<ApiResponse<CreateMovieProjectResponse>>
*/
export const createMovieProjectV2 = async (
request: CreateMovieProjectV2Request
) => {
return post<ApiResponse<CreateMovieProjectResponse>>(
"/movie/create_movie_project_v2",
request
);
};
/**
* V3
* @param request -
* @returns Promise<ApiResponse<CreateMovieProjectResponse>>
*/
export const createMovieProjectV3 = async (
request: CreateMovieProjectV3Request
) => {
return post<ApiResponse<CreateMovieProjectResponse>>(
"/movie/create_movie_project_v3",
request
);
};
/**
*
* @param taskId - ID

View File

@ -49,19 +49,19 @@ request.interceptors.request.use(
request.interceptors.response.use(
(response: AxiosResponse) => {
// 检查业务状态码
if (response.data?.code !== 0) {
if (response.data?.code !== 0 && response.data?.code !== 202) {
// 处理业务层面的错误
const businessCode = response.data?.code;
const errorMessage = response.data?.message;
// 特殊处理 401 和 403 业务状态码
// 特殊处理 401 和 4001 业务状态码
if (businessCode === 401) {
errorHandle(401, errorMessage);
return Promise.reject(new Error(errorMessage));
}
if (businessCode === 403) {
errorHandle(403, errorMessage);
if (businessCode === 4001) {
errorHandle(4001, errorMessage);
return Promise.reject(new Error(errorMessage));
}

View File

@ -66,59 +66,6 @@ export const getScriptEpisodeListNew = async (data: any): Promise<ApiResponse<an
return post<ApiResponse<any>>('/movie/list_movie_projects', data);
};
// 新-创建接口
export const createScriptEpisodeNew = async (data: any): Promise<ApiResponse<any>> => {
return post<ApiResponse<any>>('/movie/create_movie_project', data);
};
// 创建剧集
export const createScriptEpisode = async (data: CreateScriptEpisodeRequest): Promise<ApiResponse<ScriptEpisode>> => {
// return post<ApiResponse<ScriptEpisode>>('/script_episode/create', data);
return new Promise((resolve) => {
setTimeout(() => {
resolve({
code: 0,
message: 'success',
data: {
id: 1,
title: 'test',
script_id: 1,
status: 1,
episode_sort: 1,
creator_name: 'king',
created_at: '2025-07-03 10:00:00',
updated_at: '2025-07-03 10:00:00'
},
successful: true
});
}, 1000);
});
};
// 更新剧集
export const updateScriptEpisode = async (data: UpdateScriptEpisodeRequest): Promise<ApiResponse<ScriptEpisode>> => {
// return post<ApiResponse<ScriptEpisode>>('/script_episode/update', data);
return new Promise((resolve) => {
setTimeout(() => {
resolve({
code: 0,
message: 'success',
data: {
id: 1,
title: 'test',
script_id: 1,
status: 1,
episode_sort: 1,
creator_name: 'king',
created_at: '2025-07-03 10:00:00',
updated_at: '2025-07-03 10:00:00'
},
successful: true
});
}, 0);
});
};
// 获取剧集详情
export const detailScriptEpisode = async (data: detailScriptEpisodeRequest): Promise<ApiResponse<ScriptEpisode>> => {
return post<ApiResponse<ScriptEpisode>>('/script_episode/detail', data);

View File

@ -243,3 +243,11 @@ body {
.ant-notification-notice-wrapper {
background: transparent !important;
}
.ant-switch.ant-switch-checked {
background: rgb(146 78 173 / 0.8);
}
.ant-switch.ant-switch-checked:hover {
background: rgb(146 78 173) !important;
}

View File

@ -42,9 +42,14 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<head>
<title>AI Movie Flow - Create Amazing Videos with AI</title>
<title>Movie Flow - Create Amazing Movies with AI</title>
<meta name="description" content="Professional AI-powered video creation platform with advanced editing tools" />
<meta name="robots" content="noindex" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/x-icon" sizes="16x16" href="/favicon.ico?v=1" />
<link rel="icon" type="image/x-icon" sizes="32x32" href="/favicon.ico?v=1" />
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico?v=1" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon.ico?v=1" />
</head>
<body className="font-sans antialiased">
<ConfigProvider

View File

@ -13,7 +13,7 @@ import {
CreateMovieProjectV2Request,
CreateMovieProjectResponse,
} from "@/api/DTO/movie_start_dto";
import { createMovieProjectV2 } from "@/api/movie_start";
import { MovieProjectService, MovieProjectMode } from "./MovieProjectService";
interface UseImageStoryService {
/** 当前图片故事数据 */
@ -58,7 +58,7 @@ interface UseImageStoryService {
mode?: "auto" | "manual",
resolution?: "720p" | "1080p" | "4k",
language?: string
) => Promise<CreateMovieProjectResponse | undefined>;
) => Promise<{ project_id: string } | undefined>;
/** 设置角色分析 */
setCharactersAnalysis: Dispatch<SetStateAction<CharacterAnalysis[]>>;
/** 设置原始用户描述 */
@ -484,8 +484,11 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
};
// 调用create_movie_project_v2接口
const result = await createMovieProjectV2(params);
return result.data;
const result = await MovieProjectService.createProject(
MovieProjectMode.IMAGE,
params
);
return { project_id: result.project_id };
}
} catch (error) {
console.error("创建电影项目失败:", error);

View File

@ -0,0 +1,90 @@
import { CreateMovieProjectV2Request, CreateMovieProjectV3Request } from "@/api/DTO/movie_start_dto";
import { createMovieProject, createMovieProjectV2, createMovieProjectV3 } from "@/api/create_movie";
import { QueueResponse, QueueStatus, withQueuePolling, QueueResponseData } from "@/api/movie_queue";
import { message } from "antd";
/**
*
*/
export enum MovieProjectMode {
/** 普通模式 */
NORMAL = "normal",
/** 照片生成模式 */
IMAGE = "image",
/** 模板生成模式 */
TEMPLATE = "template"
}
/** 创建项目响应数据 */
export interface CreateProjectResponse {
project_id: string;
}
/**
*
*/
export class MovieProjectService {
/**
*
*/
static async createProject<T>(
mode: MovieProjectMode,
params: T
): Promise<CreateProjectResponse> {
try {
let apiCall: (p: T) => Promise<QueueResponse>;
switch (mode) {
case MovieProjectMode.NORMAL:
apiCall = createMovieProject as (p: T) => Promise<QueueResponse>;
break;
case MovieProjectMode.IMAGE:
apiCall = createMovieProjectV2 as (p: T) => Promise<QueueResponse>;
break;
case MovieProjectMode.TEMPLATE:
apiCall = createMovieProjectV3 as (p: T) => Promise<QueueResponse>;
break;
default:
throw new Error(`不支持的创建模式: ${mode}`);
}
// 使用 withQueuePolling 包装 API 调用
const result = await withQueuePolling(apiCall, params, {
interval: 5000, // 5秒轮询一次
maxAttempts: 120, // 最多轮询10分钟
onPolling: (data: QueueResponseData) => {
console.log("轮询状态:", data);
},
onError: (error: Error) => {
if (error.message === '操作已取消') {
message.info('已取消排队');
} else {
message.error(error instanceof Error ? error.message : "创建项目失败");
}
},
onCancel: () => {
message.info('已取消排队');
}
});
// 处理其他错误状态
if (result.code !== 0 && result.code !== 202) {
throw new Error(result.message || "创建项目失败");
}
// 从响应中提取 project_id
const projectId = (result.data as any).project_id;
if (!projectId) {
throw new Error("创建项目失败未返回项目ID");
}
return { project_id: projectId };
} catch (error) {
if (error instanceof Error && error.message === '操作已取消') {
throw error;
}
message.error(error instanceof Error ? error.message : "创建项目失败");
throw error;
}
}
}

View File

@ -4,7 +4,8 @@ import { useUploadFile } from "../domain/service";
import { debounce } from "lodash";
import { TemplateStoryUseCase } from "../usecase/templateStoryUseCase";
import { useState, useCallback, useMemo } from "react";
import { createMovieProjectV3, generateTextToImage } from "@/api/movie_start";
import { generateTextToImage } from "@/api/movie_start";
import { MovieProjectService, MovieProjectMode } from "./MovieProjectService";
import { CreateMovieProjectV3Request } from "@/api/DTO/movie_start_dto";
/** 模板角色接口 */
@ -312,12 +313,11 @@ export const useTemplateStoryServiceHook = (): UseTemplateStoryService => {
fillable_content: selectedTemplate?.fillable_content || [],
};
console.log("params", params);
const result = await createMovieProjectV3(params);
if((result.data as unknown) === 'message'){
message.error(result.message);
return;
}
return result.data.project_id as string;
const result = await MovieProjectService.createProject(
MovieProjectMode.TEMPLATE,
params
);
return result.project_id;
} catch (error) {
console.error("创建电影项目失败:", error);
} finally {

View File

@ -35,7 +35,7 @@ import { AudioRecorder } from "./AudioRecorder";
import { useTemplateStoryServiceHook } from "@/app/service/Interaction/templateStoryService";
import { useRouter } from "next/navigation";
import { createMovieProjectV1 } from "@/api/video_flow";
import { createScriptEpisodeNew } from "@/api/script_episode";
import { MovieProjectService, MovieProjectMode } from "@/app/service/Interaction/MovieProjectService";
import { useLoadScriptText, useUploadFile } from "@/app/service/domain/service";
import { ActionButton } from "../common/ActionButton";
import { HighlightEditor } from "../common/HighlightEditor";
@ -656,17 +656,19 @@ export function ChatInputBox({ noData }: { noData: boolean }) {
};
// 调用创建剧集API
const episodeResponse = await createScriptEpisodeNew(episodeData);
console.log("episodeResponse", episodeResponse);
if (episodeResponse.code !== 0) {
console.error(`创建剧集失败: ${episodeResponse.message}`);
alert(`创建剧集失败: ${episodeResponse.message}`);
return;
}
let episodeId = episodeResponse.data.project_id;
// let episodeId = '9c34fcc4-c8d8-44fc-879e-9bd56f608c76';
try {
const result = await MovieProjectService.createProject(
MovieProjectMode.NORMAL,
episodeData
);
const episodeId = result.project_id;
router.push(`/create/work-flow?episodeId=${episodeId}`);
} catch (error) {
console.error("创建剧集失败:", error);
} finally {
setIsCreating(false);
}
};
return (

View File

@ -94,7 +94,12 @@ const Workstation = () => (
* @param position -
* @param estimatedMinutes -
*/
export const showQueueNotification = (position: number, estimatedMinutes: number) => {
export const showQueueNotification = (
position: number,
estimatedMinutes: number,
status: string,
onCancel: () => void
) => {
notification.open({
message: null,
description: (
@ -118,7 +123,7 @@ export const showQueueNotification = (position: number, estimatedMinutes: number
borderRadius: '6px',
}}>
<span style={{ marginRight: '8px' }}>🎬</span>
{position}
{status === 'process' ? `您的作品正在制作,请等待完成后再创建新作品` : `您的作品正在第 ${position} 位等待制作`}
</div>
{/* 预计等待时间 */}
@ -127,12 +132,15 @@ export const showQueueNotification = (position: number, estimatedMinutes: number
color: 'rgba(255, 255, 255, 0.65)',
marginBottom: '12px',
}}>
{estimatedMinutes}
{status !== 'process' && `预计等待时间:约 ${estimatedMinutes} 分钟`}
</div>
{/* 取消按钮 */}
<button
onClick={() => notification.destroy()}
onClick={() => {
onCancel?.();
notification.destroy();
}}
style={{
color: 'rgb(250 173 20 / 90%)',
background: 'transparent',
@ -148,7 +156,7 @@ export const showQueueNotification = (position: number, estimatedMinutes: number
}}
data-alt="cancel-queue-button"
>
</button>
</div>
),

View File

@ -13,14 +13,14 @@ export function DateDivider({ timestamp }: DateDividerProps) {
// 判断是否是今天
if (date.toDateString() === today.toDateString()) {
return '今天';
return 'Today';
}
// 判断是否是昨天
if (date.toDateString() === yesterday.toDateString()) {
return '昨天';
return 'Yesterday';
}
// 其他日期显示完整日期
return date.toLocaleDateString('zh-CN', {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'

View File

@ -12,9 +12,9 @@ export function ProgressBar({ value, total = 100, label }: ProgressBarProps) {
return (
<div className="my-2" data-alt="progress-bar">
{label ? <div className="mb-1 text-xs opacity-80">{label}</div> : null}
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden">
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1 overflow-hidden">
<motion.div
className="h-2 bg-green-500 rounded-full"
className="h-1 bg-[#6fd0d3] rounded-full"
initial={{ width: 0 }}
animate={{ width: `${pct}%` }}
transition={{ duration: 0.5 }}

View File

@ -137,7 +137,7 @@ export default function SmartChatBox({
unCheckedChildren="System push: Off"
checked={systemPush}
onChange={toggleSystemPush}
className="ml-2"
className="ml-2 "
/>
</div>
<div className="text-xs opacity-70">

View File

@ -42,9 +42,9 @@ export function TopBar({
const currentUser: User = JSON.parse(
localStorage.getItem("currentUser") || "{}"
);
const pathname = usePathname()
const [mounted, setMounted] = React.useState(false);
const [isLogin, setIsLogin] = useState(false);
const pathname = usePathname();
const [isManagingSubscription, setIsManagingSubscription] = useState(false);
const [subscriptionStatus, setSubscriptionStatus] = useState<string>('');
const [credits, setCredits] = useState<number>(100);

View File

@ -5,7 +5,7 @@ import { ArrowLeft, ListOrdered, Play, Loader2, Pause, MoreHorizontal, Edit2, Ch
import { useRouter, useSearchParams } from 'next/navigation';
import './style/create-to-video2.css';
import { createScriptEpisodeNew, getScriptEpisodeListNew, CreateScriptEpisodeRequest } from "@/api/script_episode";
import { getScriptEpisodeListNew } from "@/api/script_episode";
import { EmptyStateAnimation } from '@/components/common/EmptyStateAnimation2';
import { ChatInputBox } from '@/components/ChatInputBox/ChatInputBox';
import cover_image1 from '@/public/assets/cover_image1.jpg';
@ -119,20 +119,7 @@ export default function CreateToVideo2() {
transition={{ repeat: Infinity, duration: 1.5 }}
/>
{/* 状态文字 */}
<span className="text-xs tracking-widest text-yellow-300 font-medium drop-shadow-[0_0_6px_rgba(255,220,100,0.6)]">
PROCESSING
</span>
</>
)}
{/* 已完成 */}
{status === 'completed' && (
<>
<motion.span
className="w-2 h-2 rounded-full bg-green-400 shadow-[0_0_8px_rgba(0,255,120,0.9)]"
/>
<span className="text-xs tracking-widest text-green-300 font-medium drop-shadow-[0_0_6px_rgba(0,255,120,0.6)]">
COMPLETED
</span>
<span className="text-xs tracking-widest text-yellow-300 font-medium drop-shadow-[0_0_6px_rgba(255,220,100,0.6)]"></span>
</>
)}
{/* 失败 */}
@ -183,13 +170,14 @@ export default function CreateToVideo2() {
return (
<div
key={project.project_id}
className="group relative aspect-video bg-black/20 rounded-lg overflow-hidden cursor-pointer"
className="group flex flex-col bg-black/20 rounded-lg overflow-hidden cursor-pointer"
onClick={() => router.push(`/create/work-flow?episodeId=${project.project_id}`)}
onMouseEnter={() => handleMouseEnter(project.project_id)}
onMouseLeave={() => handleMouseLeave(project.project_id)}
data-alt="project-card"
>
{/* 视频/图片区域 */}
<div className="relative aspect-video">
{project.final_video_url ? (
<video
ref={(el) => setVideoRef(project.project_id, el)}
@ -210,18 +198,16 @@ export default function CreateToVideo2() {
/>
)}
{/* 渐变遮罩 */}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent" />
{/* 状态标签 - 左上角 */}
<div className="absolute top-3 left-3">
{StatusBadge(project.status === 'COMPLETED' ? 'completed' : project.status === 'FAILED' ? 'failed' : 'pending')}
</div>
</div>
{/* 底部信息 */}
<div className="absolute bottom-0 left-0 right-0 p-4">
<div className="p-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium text-white line-clamp-1">
<h2 className="text-sm font-medium text-white line-clamp-1">
{project.name || "Unnamed"}
</h2>
</div>
@ -230,96 +216,6 @@ export default function CreateToVideo2() {
);
};
// 渲染剧集卡片
const renderEpisodeCard = (episode: any) => {
return (
<div
key={episode.project_id}
className="group relative bg-white/[0.08] backdrop-blur-[20px] border border-white/[0.12] rounded-[12px] overflow-hidden hover:bg-white/[0.12] transition-all duration-300 hover:shadow-[0_8px_32px_rgba(0,0,0,0.4)] cursor-pointer"
onClick={() => router.push(`/create/work-flow?episodeId=${episode.project_id}`)}
>
{/* 视频缩略图 */}
<div className="relative h-[180px] bg-gradient-to-br from-purple-500/20 to-blue-500/20 overflow-hidden">
{episode.final_video_url ? (
<video
src={episode.final_video_url}
className="w-full h-full object-cover"
muted
loop
preload="none"
poster={`${episode.final_video_url}?vframe/jpg/offset/1`}
onMouseEnter={(e) => (e.target as HTMLVideoElement).play()}
onMouseLeave={(e) => (e.target as HTMLVideoElement).pause()}
/>
) : (
<div className="flex items-center justify-center h-full">
<Video className="w-12 h-12 text-white/30" />
</div>
)}
{/* 播放按钮覆盖 */}
<div className="absolute inset-0 bg-black/20 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<div className="w-12 h-12 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center">
<Play className="w-6 h-6 text-white ml-1" />
</div>
</div>
{/* 状态标签 */}
<div className="absolute top-3 left-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
episode.status === 'COMPLETED' ? 'bg-green-500/80 text-white' :
episode.status !== 'COMPLETED' ? 'bg-yellow-500/80 text-white' :
'bg-gray-500/80 text-white'
}`}>
{episode.status === 'COMPLETED' ? 'finished' : 'processing'}
</span>
</div>
{/* 时长标签 */}
{episode.duration && (
<div className="absolute bottom-3 right-3">
<span className="px-2 py-1 bg-black/60 backdrop-blur-sm rounded text-xs text-white">
{episode.duration}
</span>
</div>
)}
</div>
{/* 内容区域 */}
<div className="p-4">
<h3 className="text-white font-medium text-sm mb-2 line-clamp-2 group-hover:text-blue-300 transition-colors">
{episode.name || episode.title || 'Unnamed episode'}
</h3>
{/* 元数据 */}
<div className="flex items-center justify-between text-xs text-white/40">
<div className="flex items-center gap-2">
<Calendar className="w-3 h-3" />
<span>{new Date(episode.created_at).toLocaleDateString()}</span>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>{new Date(episode.updated_at).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</span>
</div>
</div>
</div>
</div>
{/* 操作按钮 */}
<div className="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<div className="flex gap-2">
<button className="w-8 h-8 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center hover:bg-white/30 transition-colors">
<Share2 className="w-4 h-4 text-white" />
</button>
</div>
</div>
</div>
);
};
return (
<>
<div className="flex flex-col absolute top-[5rem] left-0 right-0 bottom-[1rem] px-6">

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 656 KiB