feat: Phase 1 - 前端重构(新路由/新页面/手机端适配/清理旧页面) #73
+65
-25
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* 素材相关 API
|
||||
* Phase 1 重构:去掉 project_id,素材直接归属用户
|
||||
*/
|
||||
import apiClient from './client';
|
||||
|
||||
/** 素材条目 */
|
||||
export interface AssetItem {
|
||||
id: string;
|
||||
project_id: string;
|
||||
library_id: string;
|
||||
name: string;
|
||||
storage_key: string;
|
||||
@@ -15,20 +16,22 @@ export interface AssetItem {
|
||||
status?: string;
|
||||
classification_status?: string | null;
|
||||
quality_score?: number | null;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
/** 素材库 */
|
||||
export interface AssetLibraryItem {
|
||||
id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
kind: 'video' | 'voice' | 'image';
|
||||
asset_count?: number;
|
||||
total_size?: number;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
/** 入库任务 */
|
||||
export interface IngestJob {
|
||||
id: string;
|
||||
project_id: string;
|
||||
library_id: string;
|
||||
storage_key: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
@@ -36,9 +39,9 @@ export interface IngestJob {
|
||||
result_asset_id: string;
|
||||
}
|
||||
|
||||
/** 分类任务 */
|
||||
export interface ClassificationJob {
|
||||
id: string;
|
||||
project_id: string;
|
||||
asset_id: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
classification: string;
|
||||
@@ -46,8 +49,8 @@ export interface ClassificationJob {
|
||||
error_message: string;
|
||||
}
|
||||
|
||||
export interface ProjectAssetDiagnosis {
|
||||
project_id: string;
|
||||
/** 素材诊断信息 */
|
||||
export interface AssetDiagnosis {
|
||||
readiness_score: number;
|
||||
readiness_label: string;
|
||||
total_assets: number;
|
||||
@@ -60,24 +63,38 @@ export interface ProjectAssetDiagnosis {
|
||||
used_assets: number;
|
||||
unused_assets: number;
|
||||
pending_review_assets: number;
|
||||
smart_views: Array<{ key: string; label: string; count: number; description: string }>;
|
||||
gaps: Array<{ key: string; severity: 'critical' | 'warning' | 'info'; message: string; recommendation: string }>;
|
||||
smart_views: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
count: number;
|
||||
description: string;
|
||||
}>;
|
||||
gaps: Array<{
|
||||
key: string;
|
||||
severity: 'critical' | 'warning' | 'info';
|
||||
message: string;
|
||||
recommendation: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const getProjectAssetDiagnosis = async (projectId: string): Promise<ProjectAssetDiagnosis> => {
|
||||
const response = await apiClient.get(`/projects/${projectId}/asset-diagnosis`);
|
||||
// ─── 素材诊断 ──────────────────────────────────────────────
|
||||
|
||||
/** 获取当前用户的素材诊断信息 */
|
||||
export const getAssetDiagnosis = async (): Promise<AssetDiagnosis> => {
|
||||
const response = await apiClient.get('/asset-diagnosis');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getAssetLibraries = async (projectId: string): Promise<AssetLibraryItem[]> => {
|
||||
const response = await apiClient.get('/asset-libraries', {
|
||||
params: { project_id: projectId },
|
||||
});
|
||||
return response.data.items;
|
||||
// ─── 素材库 ────────────────────────────────────────────────
|
||||
|
||||
/** 获取当前用户的所有素材库 */
|
||||
export const getAssetLibraries = async (): Promise<AssetLibraryItem[]> => {
|
||||
const response = await apiClient.get('/asset-libraries');
|
||||
return response.data.items || [];
|
||||
};
|
||||
|
||||
/** 创建素材库 */
|
||||
export const createAssetLibrary = async (data: {
|
||||
project_id: string;
|
||||
name: string;
|
||||
kind: 'video' | 'voice' | 'image';
|
||||
}): Promise<AssetLibraryItem> => {
|
||||
@@ -85,13 +102,22 @@ export const createAssetLibrary = async (data: {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 删除素材库 */
|
||||
export const deleteAssetLibrary = async (libraryId: string): Promise<void> => {
|
||||
await apiClient.delete(`/asset-libraries/${libraryId}`);
|
||||
};
|
||||
|
||||
// ─── 素材 ──────────────────────────────────────────────────
|
||||
|
||||
/** 获取素材库下的所有素材 */
|
||||
export const getAssets = async (libraryId: string): Promise<AssetItem[]> => {
|
||||
const response = await apiClient.get('/assets', {
|
||||
params: { library_id: libraryId },
|
||||
});
|
||||
return response.data.items;
|
||||
return response.data.items || [];
|
||||
};
|
||||
|
||||
/** 更新素材审核状态 */
|
||||
export const updateAssetReviewStatus = async (
|
||||
assetId: string,
|
||||
reviewStatus: 'pending_review' | 'approved' | 'rejected'
|
||||
@@ -102,6 +128,14 @@ export const updateAssetReviewStatus = async (
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 删除素材 */
|
||||
export const deleteAsset = async (assetId: string): Promise<void> => {
|
||||
await apiClient.delete(`/assets/${assetId}`);
|
||||
};
|
||||
|
||||
// ─── 上传 ──────────────────────────────────────────────────
|
||||
|
||||
/** 表单上传素材(小文件) */
|
||||
export const uploadAsset = async (
|
||||
formData: FormData
|
||||
): Promise<{ storage_key: string; ingest_job_id: string; url: string }> => {
|
||||
@@ -112,8 +146,8 @@ export const uploadAsset = async (
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 预签名直传准备 */
|
||||
export const prepareDirectUpload = async (data: {
|
||||
project_id: string;
|
||||
library_id: string;
|
||||
filename: string;
|
||||
content_type: string;
|
||||
@@ -130,8 +164,8 @@ export const prepareDirectUpload = async (data: {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 直传完成确认 */
|
||||
export const completeDirectUpload = async (data: {
|
||||
project_id: string;
|
||||
library_id: string;
|
||||
storage_key: string;
|
||||
}): Promise<{ storage_key: string; ingest_job_id: string }> => {
|
||||
@@ -139,13 +173,12 @@ export const completeDirectUpload = async (data: {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 直传上传(大文件推荐) */
|
||||
export const uploadAssetDirect = async (data: {
|
||||
file: File;
|
||||
project_id: string;
|
||||
library_id: string;
|
||||
}): Promise<{ storage_key: string; ingest_job_id: string }> => {
|
||||
const prepared = await prepareDirectUpload({
|
||||
project_id: data.project_id,
|
||||
library_id: data.library_id,
|
||||
filename: data.file.name,
|
||||
content_type: data.file.type || 'application/octet-stream',
|
||||
@@ -153,7 +186,9 @@ export const uploadAssetDirect = async (data: {
|
||||
});
|
||||
|
||||
const directForm = new FormData();
|
||||
Object.entries(prepared.fields).forEach(([key, value]) => directForm.append(key, value));
|
||||
Object.entries(prepared.fields).forEach(([key, value]) =>
|
||||
directForm.append(key, value)
|
||||
);
|
||||
directForm.append('file', data.file);
|
||||
|
||||
const uploadResponse = await fetch(prepared.upload_url, {
|
||||
@@ -165,26 +200,31 @@ export const uploadAssetDirect = async (data: {
|
||||
}
|
||||
|
||||
return completeDirectUpload({
|
||||
project_id: data.project_id,
|
||||
library_id: data.library_id,
|
||||
storage_key: prepared.storage_key,
|
||||
});
|
||||
};
|
||||
|
||||
// ─── 入库 / 分类任务 ───────────────────────────────────────
|
||||
|
||||
/** 查询入库任务状态 */
|
||||
export const getIngestJob = async (jobId: string): Promise<IngestJob> => {
|
||||
const response = await apiClient.get(`/ingest-jobs/${jobId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 提交素材分类任务 */
|
||||
export const submitClassificationJob = async (data: {
|
||||
project_id: string;
|
||||
asset_id: string;
|
||||
}): Promise<ClassificationJob> => {
|
||||
const response = await apiClient.post('/classification-jobs', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getClassificationJob = async (jobId: string): Promise<ClassificationJob> => {
|
||||
/** 查询分类任务状态 */
|
||||
export const getClassificationJob = async (
|
||||
jobId: string
|
||||
): Promise<ClassificationJob> => {
|
||||
const response = await apiClient.get(`/classification-jobs/${jobId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 仪表盘 API
|
||||
* Phase 1 新增:用户仪表盘概览
|
||||
*/
|
||||
import apiClient from './client';
|
||||
|
||||
/** 仪表盘概览数据 */
|
||||
export interface DashboardOverview {
|
||||
/** 素材总数 */
|
||||
total_assets: number;
|
||||
/** 已用存储(字节) */
|
||||
used_storage_bytes: number;
|
||||
/** 总标题数 */
|
||||
total_titles: number;
|
||||
/** 总配音数 */
|
||||
total_voices: number;
|
||||
/** 生成任务总数 */
|
||||
total_tasks: number;
|
||||
/** 成品总数 */
|
||||
total_products: number;
|
||||
/** 最近生成任务 */
|
||||
recent_tasks: Array<{
|
||||
id: string;
|
||||
task_type: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
user_message: string;
|
||||
created_at: string;
|
||||
}>;
|
||||
/** 订阅信息 */
|
||||
subscription: {
|
||||
plan: 'free' | 'pro' | 'enterprise';
|
||||
status: 'active' | 'inactive' | 'expired';
|
||||
expires_at?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取仪表盘概览数据 */
|
||||
export const getDashboardOverview =
|
||||
async (): Promise<DashboardOverview> => {
|
||||
const response = await apiClient.get('/dashboard/overview');
|
||||
return response.data;
|
||||
};
|
||||
@@ -1,20 +1,25 @@
|
||||
/**
|
||||
* 编辑计划 API
|
||||
* Phase 1 重构:去掉 projectId,编辑计划直接归属用户
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import apiClient from './client';
|
||||
|
||||
export type EditingMode = "one-take" | "pip" | "voiceover" | "voice_pip";
|
||||
/** 编辑模式 */
|
||||
export type EditingMode = 'one-take' | 'pip' | 'voiceover' | 'voice_pip';
|
||||
|
||||
/** 编辑模板 */
|
||||
export interface EditTemplateItem {
|
||||
id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
target_duration: number;
|
||||
clip_count: number;
|
||||
is_active: boolean;
|
||||
category?: string;
|
||||
thumbnail_url?: string;
|
||||
}
|
||||
|
||||
/** 编辑计划片段 */
|
||||
export interface EditPlanClipItem {
|
||||
id: string;
|
||||
asset_id: string;
|
||||
@@ -23,13 +28,13 @@ export interface EditPlanClipItem {
|
||||
start_time: number;
|
||||
duration: number;
|
||||
reason: string;
|
||||
layer?: "main" | "pip" | "broll";
|
||||
layer?: 'main' | 'pip' | 'broll';
|
||||
thumbnail_url?: string;
|
||||
}
|
||||
|
||||
/** 编辑计划 */
|
||||
export interface EditPlanItem {
|
||||
id: string;
|
||||
project_id: string;
|
||||
template_id: string;
|
||||
asset_library_id: string;
|
||||
title_id: string;
|
||||
@@ -41,58 +46,53 @@ export interface EditPlanItem {
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export const getEditTemplates = async (projectId: string): Promise<EditTemplateItem[]> => {
|
||||
const response = await apiClient.get(`/projects/${projectId}/edit-plans/templates`, );
|
||||
return response.data;
|
||||
};
|
||||
// ─── 编辑计划 ──────────────────────────────────────────────
|
||||
|
||||
export const createEditPlan = async ({ projectId, data }: { projectId: string; data: {
|
||||
asset_library_id: string;
|
||||
template_id?: string;
|
||||
title_id?: string;
|
||||
} }): Promise<EditPlanItem> => {
|
||||
const response = await apiClient.post(`/projects/${projectId}/edit-plans`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取编排计划列表
|
||||
*/
|
||||
export const getEditPlans = async (projectId: string): Promise<EditPlanItem[]> => {
|
||||
const response = await apiClient.get(`/projects/${projectId}/edit-plans/`);
|
||||
/** 获取当前用户的编辑计划列表 */
|
||||
export const getEditPlans = async (): Promise<EditPlanItem[]> => {
|
||||
const response = await apiClient.get('/edit-plans');
|
||||
return response.data.items || [];
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取单个编排计划
|
||||
*/
|
||||
export const getEditPlan = async (projectId: string, planId: string): Promise<EditPlanItem> => {
|
||||
const response = await apiClient.get(`/projects/${projectId}/edit-plans/${planId}`);
|
||||
/** 获取单个编辑计划 */
|
||||
export const getEditPlan = async (planId: string): Promise<EditPlanItem> => {
|
||||
const response = await apiClient.get(`/edit-plans/${planId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 智能编排 - 自动生成编辑计划
|
||||
*/
|
||||
export const autoGenerateEditPlan = async (projectId: string, params: {
|
||||
editing_mode: EditingMode;
|
||||
/** 创建编辑计划 */
|
||||
export const createEditPlan = async (data: {
|
||||
asset_library_id: string;
|
||||
template_id?: string;
|
||||
title_id?: string;
|
||||
}): Promise<EditPlanItem> => {
|
||||
const response = await apiClient.post('/edit-plans', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 智能编排 - 自动生成编辑计划 */
|
||||
export const autoGenerateEditPlan = async (params: {
|
||||
template_id: string;
|
||||
asset_ids?: string[];
|
||||
title_ids?: string[];
|
||||
voice_ids?: string[];
|
||||
editing_mode?: EditingMode;
|
||||
target_duration?: number;
|
||||
}): Promise<EditPlanItem> => {
|
||||
const response = await apiClient.post(`/projects/${projectId}/edit-plans/auto-generate/`, params);
|
||||
const response = await apiClient.post('/edit-plans/auto-generate', params);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除编辑计划
|
||||
*/
|
||||
export const deleteEditPlan = async (projectId: string, planId: string): Promise<void> => {
|
||||
await apiClient.delete(`/projects/${projectId}/edit-plans/${planId}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新编辑计划
|
||||
*/
|
||||
export const updateEditPlan = async (projectId: string, planId: string, data: Partial<EditPlanItem>): Promise<EditPlanItem> => {
|
||||
const response = await apiClient.patch(`/projects/${projectId}/edit-plans/${planId}`, data);
|
||||
/** 更新编辑计划 */
|
||||
export const updateEditPlan = async (
|
||||
planId: string,
|
||||
data: Partial<EditPlanItem>
|
||||
): Promise<EditPlanItem> => {
|
||||
const response = await apiClient.patch(`/edit-plans/${planId}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 删除编辑计划 */
|
||||
export const deleteEditPlan = async (planId: string): Promise<void> => {
|
||||
await apiClient.delete(`/edit-plans/${planId}`);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 成品相关 API
|
||||
* Phase 1 重构:去掉 projectId,成品直接归属用户
|
||||
*/
|
||||
import apiClient from './client';
|
||||
|
||||
/** 成品条目 */
|
||||
export interface ProductItem {
|
||||
id: string;
|
||||
title: string;
|
||||
video_url?: string;
|
||||
thumbnail_url?: string;
|
||||
duration_seconds?: number;
|
||||
file_size?: number;
|
||||
resolution?: string;
|
||||
status: 'processing' | 'completed' | 'failed';
|
||||
/** 查重率(百分比) */
|
||||
duplicate_rate?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/** 获取当前用户的所有成品 */
|
||||
export const getProducts = async (): Promise<ProductItem[]> => {
|
||||
const response = await apiClient.get('/products');
|
||||
return response.data.items || response.data || [];
|
||||
};
|
||||
|
||||
/** 获取单个成品详情 */
|
||||
export const getProduct = async (
|
||||
productId: string
|
||||
): Promise<ProductItem> => {
|
||||
const response = await apiClient.get(`/products/${productId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 删除成品 */
|
||||
export const deleteProduct = async (productId: string): Promise<void> => {
|
||||
await apiClient.delete(`/products/${productId}`);
|
||||
};
|
||||
|
||||
/** 获取成品下载链接 */
|
||||
export const getProductDownloadUrl = async (
|
||||
productId: string
|
||||
): Promise<{ url: string; expires_at: string }> => {
|
||||
const response = await apiClient.get(
|
||||
`/products/${productId}/download-url`
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
@@ -1,9 +1,13 @@
|
||||
/**
|
||||
* 任务相关 API
|
||||
* Phase 1 重构:去掉 projectId,任务直接归属用户
|
||||
*/
|
||||
import apiClient from './client';
|
||||
|
||||
export interface ProjectTaskItem {
|
||||
/** 任务条目 */
|
||||
export interface TaskItem {
|
||||
id: string;
|
||||
task_type: 'ingest' | 'generation' | string;
|
||||
project_id: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
current_step: string;
|
||||
@@ -15,12 +19,14 @@ export interface ProjectTaskItem {
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export const getProjectTasks = async (projectId: string): Promise<ProjectTaskItem[]> => {
|
||||
const response = await apiClient.get(`/projects/${projectId}/tasks`);
|
||||
return response.data.items;
|
||||
/** 获取当前用户的所有任务(生成记录) */
|
||||
export const getUserTasks = async (): Promise<TaskItem[]> => {
|
||||
const response = await apiClient.get('/tasks');
|
||||
return response.data.items || [];
|
||||
};
|
||||
|
||||
export const retryProjectTask = async (taskType: string, sourceId: string): Promise<ProjectTaskItem> => {
|
||||
const response = await apiClient.post(`/tasks/${taskType}/${sourceId}/retry`);
|
||||
/** 重试失败的任务 */
|
||||
export const retryTask = async (taskId: string): Promise<TaskItem> => {
|
||||
const response = await apiClient.post(`/tasks/${taskId}/retry`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 模板相关 API
|
||||
* Phase 1 新增:全局模板库
|
||||
*/
|
||||
import apiClient from './client';
|
||||
|
||||
/** 模板条目 */
|
||||
export interface TemplateItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
target_duration: number;
|
||||
clip_count: number;
|
||||
thumbnail_url?: string;
|
||||
preview_url?: string;
|
||||
is_active: boolean;
|
||||
is_favorite?: boolean;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
/** 获取全局模板列表 */
|
||||
export const getTemplates = async (): Promise<TemplateItem[]> => {
|
||||
const response = await apiClient.get('/templates');
|
||||
return response.data.items || response.data || [];
|
||||
};
|
||||
|
||||
/** 获取单个模板详情 */
|
||||
export const getTemplate = async (
|
||||
templateId: string
|
||||
): Promise<TemplateItem> => {
|
||||
const response = await apiClient.get(`/templates/${templateId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 收藏 / 取消收藏模板 */
|
||||
export const toggleFavoriteTemplate = async (
|
||||
templateId: string
|
||||
): Promise<{ is_favorite: boolean }> => {
|
||||
const response = await apiClient.post(
|
||||
`/templates/${templateId}/toggle-favorite`
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 标题相关 API
|
||||
* Phase 1 新增:全局标题库
|
||||
*/
|
||||
import apiClient from './client';
|
||||
|
||||
/** 标题条目 */
|
||||
export interface TitleItem {
|
||||
id: string;
|
||||
content: string;
|
||||
category?: string;
|
||||
source?: string;
|
||||
word_count?: number;
|
||||
is_favorite?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/** 创建标题请求 */
|
||||
export interface CreateTitleRequest {
|
||||
content: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
/** 获取当前用户的所有标题 */
|
||||
export const getTitles = async (): Promise<TitleItem[]> => {
|
||||
const response = await apiClient.get('/titles');
|
||||
return response.data.items || response.data || [];
|
||||
};
|
||||
|
||||
/** 创建标题 */
|
||||
export const createTitle = async (
|
||||
data: CreateTitleRequest
|
||||
): Promise<TitleItem> => {
|
||||
const response = await apiClient.post('/titles', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 更新标题 */
|
||||
export const updateTitle = async (
|
||||
titleId: string,
|
||||
data: Partial<CreateTitleRequest>
|
||||
): Promise<TitleItem> => {
|
||||
const response = await apiClient.patch(`/titles/${titleId}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 删除标题 */
|
||||
export const deleteTitle = async (titleId: string): Promise<void> => {
|
||||
await apiClient.delete(`/titles/${titleId}`);
|
||||
};
|
||||
|
||||
/** 批量导入标题 */
|
||||
export const batchImportTitles = async (
|
||||
titles: string[]
|
||||
): Promise<{ imported_count: number }> => {
|
||||
const response = await apiClient.post('/titles/batch-import', { titles });
|
||||
return response.data;
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 配音相关 API
|
||||
* Phase 1 新增:全局配音库
|
||||
*/
|
||||
import apiClient from './client';
|
||||
|
||||
/** 配音条目 */
|
||||
export interface VoiceItem {
|
||||
id: string;
|
||||
name: string;
|
||||
text: string;
|
||||
voice_type?: string;
|
||||
duration_seconds?: number;
|
||||
storage_key?: string;
|
||||
audio_url?: string;
|
||||
status?: string;
|
||||
is_favorite?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/** 创建配音请求 */
|
||||
export interface CreateVoiceRequest {
|
||||
name: string;
|
||||
text: string;
|
||||
voice_type?: string;
|
||||
}
|
||||
|
||||
/** 获取当前用户的所有配音 */
|
||||
export const getVoices = async (): Promise<VoiceItem[]> => {
|
||||
const response = await apiClient.get('/voices');
|
||||
return response.data.items || response.data || [];
|
||||
};
|
||||
|
||||
/** 创建配音 */
|
||||
export const createVoice = async (
|
||||
data: CreateVoiceRequest
|
||||
): Promise<VoiceItem> => {
|
||||
const response = await apiClient.post('/voices', data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 更新配音 */
|
||||
export const updateVoice = async (
|
||||
voiceId: string,
|
||||
data: Partial<CreateVoiceRequest>
|
||||
): Promise<VoiceItem> => {
|
||||
const response = await apiClient.patch(`/voices/${voiceId}`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** 删除配音 */
|
||||
export const deleteVoice = async (voiceId: string): Promise<void> => {
|
||||
await apiClient.delete(`/voices/${voiceId}`);
|
||||
};
|
||||
|
||||
/** AI 生成配音 */
|
||||
export const generateAIVoice = async (data: {
|
||||
text: string;
|
||||
voice_type?: string;
|
||||
speed?: number;
|
||||
}): Promise<VoiceItem> => {
|
||||
const response = await apiClient.post('/voices/generate', data);
|
||||
return response.data;
|
||||
};
|
||||
@@ -1,132 +0,0 @@
|
||||
/**
|
||||
* 工作空间相关 API
|
||||
*/
|
||||
import apiClient from './client';
|
||||
|
||||
export interface Workspace {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
name: string;
|
||||
owner_user_id?: string;
|
||||
subscription_plan: 'free' | 'pro' | 'enterprise';
|
||||
subscription_status: 'active' | 'inactive' | 'expired';
|
||||
created_at?: string;
|
||||
max_projects?: number;
|
||||
max_storage_gb?: number;
|
||||
used_storage_gb?: number;
|
||||
member_count?: number;
|
||||
user_role?: string;
|
||||
}
|
||||
|
||||
export interface CreateWorkspaceRequest {
|
||||
name: string;
|
||||
subscription_plan?: 'free' | 'pro' | 'enterprise';
|
||||
}
|
||||
|
||||
export interface WorkspaceMember {
|
||||
id: string;
|
||||
member_id: string;
|
||||
user_id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
display_name?: string;
|
||||
role: 'owner' | 'admin' | 'member' | 'viewer';
|
||||
joined_at: string;
|
||||
}
|
||||
|
||||
export interface InviteMemberRequest {
|
||||
email: string;
|
||||
role: 'admin' | 'member' | 'viewer';
|
||||
}
|
||||
|
||||
export interface InviteMemberResponse {
|
||||
invitation_id: string;
|
||||
invitee_email: string;
|
||||
role: 'admin' | 'member' | 'viewer';
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
type WorkspaceApiResponse = Omit<Workspace, 'id' | 'workspace_id'> & {
|
||||
id?: string;
|
||||
workspace_id?: string;
|
||||
};
|
||||
|
||||
type WorkspaceMemberApiResponse = Omit<WorkspaceMember, 'id' | 'member_id'> & {
|
||||
id?: string;
|
||||
member_id?: string;
|
||||
};
|
||||
|
||||
const normalizeWorkspace = (workspace: WorkspaceApiResponse): Workspace => {
|
||||
const workspaceId = workspace.id || workspace.workspace_id || '';
|
||||
return {
|
||||
...workspace,
|
||||
id: workspaceId,
|
||||
workspace_id: workspaceId,
|
||||
subscription_status: workspace.subscription_status || 'active',
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeMember = (member: WorkspaceMemberApiResponse): WorkspaceMember => {
|
||||
const memberId = member.member_id || member.id || '';
|
||||
return {
|
||||
...member,
|
||||
id: member.user_id || memberId,
|
||||
member_id: memberId,
|
||||
};
|
||||
};
|
||||
|
||||
export const getWorkspaces = async (): Promise<Workspace[]> => {
|
||||
const response = await apiClient.get('/workspaces');
|
||||
const payload = response.data;
|
||||
const workspaces = Array.isArray(payload) ? payload : payload.workspaces || [];
|
||||
return workspaces.map(normalizeWorkspace);
|
||||
};
|
||||
|
||||
export const getWorkspace = async (id: string): Promise<Workspace> => {
|
||||
const response = await apiClient.get(`/workspaces/${id}`);
|
||||
return normalizeWorkspace(response.data);
|
||||
};
|
||||
|
||||
export const createWorkspace = async (
|
||||
data: CreateWorkspaceRequest
|
||||
): Promise<Workspace> => {
|
||||
const response = await apiClient.post('/workspaces', data);
|
||||
return normalizeWorkspace(response.data);
|
||||
};
|
||||
|
||||
export const getMembers = async (workspaceId: string): Promise<WorkspaceMember[]> => {
|
||||
const response = await apiClient.get(`/workspaces/${workspaceId}/members`);
|
||||
const payload = response.data;
|
||||
const members = Array.isArray(payload) ? payload : payload.members || [];
|
||||
return members.map(normalizeMember);
|
||||
};
|
||||
|
||||
export const inviteMember = async (
|
||||
workspaceId: string,
|
||||
data: InviteMemberRequest
|
||||
): Promise<InviteMemberResponse> => {
|
||||
const response = await apiClient.post(`/workspaces/${workspaceId}/members/invite`, data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const removeMember = async (
|
||||
workspaceId: string,
|
||||
memberId: string
|
||||
): Promise<void> => {
|
||||
await apiClient.delete(`/workspaces/${workspaceId}/members/${memberId}`);
|
||||
};
|
||||
|
||||
export const updateMemberRole = async (
|
||||
workspaceId: string,
|
||||
memberId: string,
|
||||
role: 'admin' | 'member' | 'viewer'
|
||||
): Promise<{ user_id: string; old_role: string; new_role: string }> => {
|
||||
const response = await apiClient.patch(`/workspaces/${workspaceId}/members/${memberId}/role`, {
|
||||
role,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const leaveWorkspace = async (_workspaceId: string): Promise<{ message: string }> => {
|
||||
throw new Error('主动退出工作空间暂未开放');
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
/* V21 顶导 */
|
||||
/* Phase 1 Header 样式 + 手机端适配 */
|
||||
.xx-top-nav {
|
||||
height: 68px;
|
||||
position: sticky;
|
||||
@@ -30,6 +30,7 @@
|
||||
cursor: pointer;
|
||||
color: #0f172a;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.xx-logo {
|
||||
@@ -44,9 +45,10 @@
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
/* 桌面端导航链接 */
|
||||
.xx-nav-links {
|
||||
display: flex;
|
||||
gap: 23px;
|
||||
gap: 16px;
|
||||
color: #64748b;
|
||||
font-weight: 750;
|
||||
}
|
||||
@@ -56,10 +58,11 @@
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: #64748b;
|
||||
padding: 0;
|
||||
padding: 6px 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 750;
|
||||
transition: color 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xx-nav-links button:hover {
|
||||
@@ -70,6 +73,14 @@
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
/* 右侧区域 */
|
||||
.xx-right-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.xx-user-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -85,3 +96,96 @@
|
||||
background: #eef2ff;
|
||||
color: #4338ca;
|
||||
}
|
||||
|
||||
/* 汉堡菜单按钮(默认隐藏) */
|
||||
.xx-hamburger {
|
||||
display: none;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
color: #64748b;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* 手机端导航抽屉内容 */
|
||||
.xx-mobile-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.xx-mobile-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: #334155;
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.xx-mobile-nav-item:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.xx-mobile-nav-item.active {
|
||||
background: #eef2ff;
|
||||
color: #4f46e5;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.xx-mobile-nav-icon {
|
||||
font-size: 18px;
|
||||
width: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ===== 手机端响应式 ===== */
|
||||
@media (max-width: 768px) {
|
||||
.xx-top-nav {
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.xx-top-nav-inner {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.xx-brand-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 隐藏桌面端导航 */
|
||||
.xx-nav-links {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 显示汉堡菜单 */
|
||||
.xx-hamburger {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 隐藏用户名 */
|
||||
.xx-username {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 小屏幕平板:导航文字缩小 */
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
.xx-nav-links {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.xx-nav-links button {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,57 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Avatar, Dropdown, Space } from 'antd';
|
||||
import { LogoutOutlined, SettingOutlined, UserOutlined } from '@ant-design/icons';
|
||||
/**
|
||||
* Phase 1 Header 重构
|
||||
* 扁平化导航菜单 + 手机端汉堡菜单
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { Avatar, Dropdown, Drawer, Space } from 'antd';
|
||||
import {
|
||||
LogoutOutlined,
|
||||
SettingOutlined,
|
||||
UserOutlined,
|
||||
MenuOutlined,
|
||||
DashboardOutlined,
|
||||
FileOutlined,
|
||||
FileTextOutlined,
|
||||
AudioOutlined,
|
||||
AppstoreOutlined,
|
||||
VideoCameraOutlined,
|
||||
HistoryOutlined,
|
||||
TrophyOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useLogout } from '@/hooks/useAuth';
|
||||
import type { MenuProps } from 'antd';
|
||||
import './Header.css';
|
||||
|
||||
const extractProjectId = (pathname: string) => {
|
||||
const match = pathname.match(/^\/projects\/([^/]+)/);
|
||||
return match?.[1] || '';
|
||||
};
|
||||
/** 导航项定义 */
|
||||
interface NavItem {
|
||||
key: string;
|
||||
label: string;
|
||||
path: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
/** 固定导航菜单 */
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ key: 'dashboard', label: '概览', path: '/dashboard', icon: <DashboardOutlined /> },
|
||||
{ key: 'assets', label: '素材库', path: '/assets', icon: <FileOutlined /> },
|
||||
{ key: 'titles', label: '标题库', path: '/titles', icon: <FileTextOutlined /> },
|
||||
{ key: 'voices', label: '配音库', path: '/voices', icon: <AudioOutlined /> },
|
||||
{ key: 'templates', label: '模板库', path: '/templates', icon: <AppstoreOutlined /> },
|
||||
{ key: 'generate', label: '一键生成', path: '/generate', icon: <VideoCameraOutlined /> },
|
||||
{ key: 'history', label: '任务历史', path: '/history', icon: <HistoryOutlined /> },
|
||||
{ key: 'products', label: '成品库', path: '/products', icon: <TrophyOutlined /> },
|
||||
];
|
||||
|
||||
const Header: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const logoutMutation = useLogout();
|
||||
const projectId = extractProjectId(location.pathname);
|
||||
|
||||
const navItems = useMemo(() => {
|
||||
const projectNav = projectId
|
||||
? [
|
||||
{ key: 'assets', label: '素材库', path: `/projects/${projectId}/assets` },
|
||||
{ key: 'titles', label: '标题库', path: `/projects/${projectId}/titles` },
|
||||
{ key: 'voices', label: '配音库', path: `/projects/${projectId}/voices` },
|
||||
{ key: 'generation', label: '视频剪辑', path: `/projects/${projectId}/generation` },
|
||||
{ key: 'results', label: '成片库', path: `/projects/${projectId}/results` },
|
||||
]
|
||||
: [];
|
||||
|
||||
return [
|
||||
{ key: 'home', label: '首页', path: '/' },
|
||||
{ key: 'subscription', label: '订阅', path: '/subscription' },
|
||||
...projectNav,
|
||||
];
|
||||
}, [projectId]);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
/** 用户下拉菜单 */
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'profile',
|
||||
@@ -45,10 +60,10 @@ const Header: React.FC = () => {
|
||||
onClick: () => navigate('/profile'),
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
key: 'subscription',
|
||||
icon: <SettingOutlined />,
|
||||
label: '账号设置',
|
||||
onClick: () => navigate('/profile/settings'),
|
||||
label: '订阅管理',
|
||||
onClick: () => navigate('/subscription'),
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
@@ -59,37 +74,81 @@ const Header: React.FC = () => {
|
||||
},
|
||||
];
|
||||
|
||||
/** 判断导航项是否激活 */
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/dashboard') {
|
||||
return location.pathname === '/' || location.pathname === '/dashboard';
|
||||
}
|
||||
return location.pathname.startsWith(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="xx-top-nav">
|
||||
<div className="xx-top-nav-inner">
|
||||
<button className="xx-brand" type="button" onClick={() => navigate('/')}>
|
||||
<button className="xx-brand" type="button" onClick={() => navigate('/dashboard')}>
|
||||
<span className="xx-logo">🦐</span>
|
||||
<span>小虾自动剪辑</span>
|
||||
<span className="xx-brand-text">小虾自动剪辑</span>
|
||||
</button>
|
||||
|
||||
{/* 桌面端导航 */}
|
||||
<nav className="xx-nav-links">
|
||||
{navItems.map((item) => {
|
||||
const active = (item.path === '/' && location.pathname === '/') || location.pathname.startsWith(item.path);
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
className={active ? 'active' : ''}
|
||||
type="button"
|
||||
onClick={() => navigate(item.path)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
className={isActive(item.path) ? 'active' : ''}
|
||||
type="button"
|
||||
onClick={() => navigate(item.path)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<Dropdown menu={{ items: menuItems }} placement="bottomRight">
|
||||
<Space className="xx-user-menu">
|
||||
<Avatar className="xx-avatar" icon={<UserOutlined />} />
|
||||
<span>{user?.display_name || user?.username || '小虾用户'}</span>
|
||||
</Space>
|
||||
</Dropdown>
|
||||
<div className="xx-right-section">
|
||||
{/* 手机端汉堡菜单按钮 */}
|
||||
<button
|
||||
className="xx-hamburger"
|
||||
type="button"
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
>
|
||||
<MenuOutlined />
|
||||
</button>
|
||||
|
||||
<Dropdown menu={{ items: menuItems }} placement="bottomRight">
|
||||
<Space className="xx-user-menu">
|
||||
<Avatar className="xx-avatar" icon={<UserOutlined />} />
|
||||
<span className="xx-username">{user?.display_name || user?.username || '小虾用户'}</span>
|
||||
</Space>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 手机端侧边抽屉导航 */}
|
||||
<Drawer
|
||||
title="导航菜单"
|
||||
placement="left"
|
||||
onClose={() => setMobileMenuOpen(false)}
|
||||
open={mobileMenuOpen}
|
||||
width={260}
|
||||
className="xx-mobile-drawer"
|
||||
>
|
||||
<div className="xx-mobile-nav">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
className={`xx-mobile-nav-item ${isActive(item.path) ? 'active' : ''}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigate(item.path);
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="xx-mobile-nav-icon">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Drawer>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* 工作空间相关 Hooks
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import * as workspaceApi from '@/api/workspace';
|
||||
|
||||
// 获取工作空间列表
|
||||
export const useWorkspaces = () => {
|
||||
return useQuery({
|
||||
queryKey: ['workspaces'],
|
||||
queryFn: workspaceApi.getWorkspaces,
|
||||
});
|
||||
};
|
||||
|
||||
// 获取工作空间详情
|
||||
export const useWorkspace = (id: string) => {
|
||||
return useQuery({
|
||||
queryKey: ['workspace', id],
|
||||
queryFn: () => workspaceApi.getWorkspace(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
// 创建工作空间
|
||||
export const useCreateWorkspace = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: workspaceApi.createWorkspace,
|
||||
});
|
||||
|
||||
const createWorkspace = async (data: Parameters<typeof workspaceApi.createWorkspace>[0]) => {
|
||||
const result = await mutation.mutateAsync(data);
|
||||
queryClient.invalidateQueries({ queryKey: ['workspaces'] });
|
||||
return result;
|
||||
};
|
||||
|
||||
return { ...mutation, mutateAsync: createWorkspace };
|
||||
};
|
||||
|
||||
// 获取成员列表
|
||||
export const useWorkspaceMembers = (workspaceId: string) => {
|
||||
return useQuery({
|
||||
queryKey: ['workspaceMembers', workspaceId],
|
||||
queryFn: () => workspaceApi.getMembers(workspaceId),
|
||||
enabled: !!workspaceId,
|
||||
});
|
||||
};
|
||||
|
||||
// 邀请成员
|
||||
export const useInviteMember = (workspaceId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: workspaceApi.InviteMemberRequest) =>
|
||||
workspaceApi.inviteMember(workspaceId, data),
|
||||
});
|
||||
|
||||
const inviteMember = async (data: workspaceApi.InviteMemberRequest) => {
|
||||
const result = await mutation.mutateAsync(data);
|
||||
queryClient.invalidateQueries({ queryKey: ['workspaceMembers', workspaceId] });
|
||||
return result;
|
||||
};
|
||||
|
||||
return { ...mutation, mutateAsync: inviteMember };
|
||||
};
|
||||
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* 素材库页面
|
||||
* 展示用户所有素材,支持响应式上传
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Upload,
|
||||
message,
|
||||
Typography,
|
||||
Space,
|
||||
Tabs,
|
||||
Empty,
|
||||
Spin,
|
||||
Modal,
|
||||
Select,
|
||||
Input,
|
||||
Row,
|
||||
Col,
|
||||
Tag,
|
||||
Image,
|
||||
Popconfirm,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
VideoCameraOutlined,
|
||||
PictureOutlined,
|
||||
AudioOutlined,
|
||||
InboxOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
getAssetLibraries,
|
||||
createAssetLibrary,
|
||||
getAssets,
|
||||
deleteAsset,
|
||||
uploadAsset,
|
||||
} from '@/api/assets';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Dragger } = Upload;
|
||||
|
||||
/** 素材库类型图标 */
|
||||
const KindIcon: React.FC<{ kind: string }> = ({ kind }) => {
|
||||
switch (kind) {
|
||||
case 'video':
|
||||
return <VideoCameraOutlined style={{ color: '#1890ff' }} />;
|
||||
case 'voice':
|
||||
return <AudioOutlined style={{ color: '#52c41a' }} />;
|
||||
case 'image':
|
||||
return <PictureOutlined style={{ color: '#faad14' }} />;
|
||||
default:
|
||||
return <VideoCameraOutlined />;
|
||||
}
|
||||
};
|
||||
|
||||
/** 素材库类型标签 */
|
||||
const kindLabel: Record<string, string> = {
|
||||
video: '视频',
|
||||
voice: '配音',
|
||||
image: '图片',
|
||||
};
|
||||
|
||||
const AssetLibrary: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [activeLibrary, setActiveLibrary] = useState<string>('');
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [newLibName, setNewLibName] = useState('');
|
||||
const [newLibKind, setNewLibKind] = useState<'video' | 'voice' | 'image'>(
|
||||
'video'
|
||||
);
|
||||
|
||||
// 获取素材库列表
|
||||
const { data: libraries = [], isLoading: libsLoading } = useQuery({
|
||||
queryKey: ['asset-libraries'],
|
||||
queryFn: getAssetLibraries,
|
||||
});
|
||||
|
||||
// 获取当前素材库的素材
|
||||
const { data: assets = [], isLoading: assetsLoading } = useQuery({
|
||||
queryKey: ['assets', activeLibrary],
|
||||
queryFn: () => getAssets(activeLibrary),
|
||||
enabled: !!activeLibrary,
|
||||
});
|
||||
|
||||
// 创建素材库
|
||||
const createLibMutation = useMutation({
|
||||
mutationFn: createAssetLibrary,
|
||||
onSuccess: () => {
|
||||
message.success('素材库创建成功');
|
||||
setCreateModalOpen(false);
|
||||
setNewLibName('');
|
||||
queryClient.invalidateQueries({ queryKey: ['asset-libraries'] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error('创建失败');
|
||||
},
|
||||
});
|
||||
|
||||
// 上传素材
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: uploadAsset,
|
||||
onSuccess: () => {
|
||||
message.success('上传成功');
|
||||
queryClient.invalidateQueries({ queryKey: ['assets', activeLibrary] });
|
||||
queryClient.invalidateQueries({ queryKey: ['asset-libraries'] });
|
||||
},
|
||||
onError: () => {
|
||||
message.error('上传失败');
|
||||
},
|
||||
});
|
||||
|
||||
// 删除素材
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteAsset,
|
||||
onSuccess: () => {
|
||||
message.success('已删除');
|
||||
queryClient.invalidateQueries({ queryKey: ['assets', activeLibrary] });
|
||||
queryClient.invalidateQueries({ queryKey: ['asset-libraries'] });
|
||||
},
|
||||
});
|
||||
|
||||
/** 处理上传 */
|
||||
const handleUpload = async (file: File) => {
|
||||
if (!activeLibrary) {
|
||||
message.warning('请先选择素材库');
|
||||
return false;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('library_id', activeLibrary);
|
||||
await uploadMutation.mutateAsync(formData);
|
||||
return false;
|
||||
};
|
||||
|
||||
/** 当前选中的素材库 */
|
||||
const currentLib = libraries.find((l) => l.id === activeLibrary);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: 1200, margin: '0 auto' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<Title level={3} style={{ margin: 0 }}>
|
||||
素材库
|
||||
</Title>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
新建素材库
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{libsLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: 60 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : libraries.length === 0 ? (
|
||||
<Empty description="暂无素材库,点击上方按钮创建">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
创建第一个素材库
|
||||
</Button>
|
||||
</Empty>
|
||||
) : (
|
||||
<>
|
||||
{/* 素材库标签页 */}
|
||||
<Tabs
|
||||
activeKey={activeLibrary}
|
||||
onChange={setActiveLibrary}
|
||||
items={libraries.map((lib) => ({
|
||||
key: lib.id,
|
||||
label: (
|
||||
<Space>
|
||||
<KindIcon kind={lib.kind} />
|
||||
<span>{lib.name}</span>
|
||||
<Tag>{lib.asset_count ?? 0}</Tag>
|
||||
</Space>
|
||||
),
|
||||
}))}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
{/* 上传区域 */}
|
||||
{activeLibrary && (
|
||||
<Dragger
|
||||
accept={
|
||||
currentLib?.kind === 'video'
|
||||
? 'video/*'
|
||||
: currentLib?.kind === 'voice'
|
||||
? 'audio/*'
|
||||
: 'image/*'
|
||||
}
|
||||
beforeUpload={handleUpload}
|
||||
showUploadList={false}
|
||||
multiple
|
||||
style={{ marginBottom: 24 }}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">点击或拖拽文件到此区域上传</p>
|
||||
<p className="ant-upload-hint">
|
||||
支持 {kindLabel[currentLib?.kind || 'video']} 格式文件
|
||||
</p>
|
||||
</Dragger>
|
||||
)}
|
||||
|
||||
{/* 素材列表 */}
|
||||
{assetsLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : assets.length === 0 ? (
|
||||
<Empty description="该素材库暂无素材" />
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
{assets.map((asset) => (
|
||||
<Col xs={24} sm={12} md={8} lg={6} key={asset.id}>
|
||||
<Card
|
||||
hoverable
|
||||
size="small"
|
||||
cover={
|
||||
currentLib?.kind === 'image' ? (
|
||||
<Image
|
||||
src={`/api/v1/assets/${asset.id}/thumbnail`}
|
||||
alt={asset.name}
|
||||
style={{ height: 150, objectFit: 'cover' }}
|
||||
fallback="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE1MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE1MCIgZmlsbD0iI2YwZjBmMCIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjOTk5IiBmb250LXNpemU9IjE0Ij7lm77niYfliqDovb3lpLHotKU8L3RleHQ+PC9zdmc+"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
height: 150,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#f5f5f5',
|
||||
}}
|
||||
>
|
||||
{currentLib?.kind === 'video' ? (
|
||||
<VideoCameraOutlined
|
||||
style={{ fontSize: 40, color: '#bbb' }}
|
||||
/>
|
||||
) : (
|
||||
<AudioOutlined
|
||||
style={{ fontSize: 40, color: '#bbb' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
actions={[
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title="确定删除此素材?"
|
||||
onConfirm={() => deleteMutation.mutate(asset.id)}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</Popconfirm>,
|
||||
]}
|
||||
>
|
||||
<Card.Meta
|
||||
title={
|
||||
<Text ellipsis style={{ maxWidth: '100%' }}>
|
||||
{asset.name}
|
||||
</Text>
|
||||
}
|
||||
description={
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{asset.file_size
|
||||
? `${(asset.file_size / 1024 / 1024).toFixed(1)} MB`
|
||||
: '-'}
|
||||
</Text>
|
||||
{asset.status && (
|
||||
<Tag
|
||||
color={
|
||||
asset.status === 'completed'
|
||||
? 'success'
|
||||
: asset.status === 'failed'
|
||||
? 'error'
|
||||
: 'processing'
|
||||
}
|
||||
style={{ marginTop: 4 }}
|
||||
>
|
||||
{asset.status}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 新建素材库弹窗 */}
|
||||
<Modal
|
||||
title="新建素材库"
|
||||
open={createModalOpen}
|
||||
onCancel={() => setCreateModalOpen(false)}
|
||||
onOk={() =>
|
||||
createLibMutation.mutate({ name: newLibName, kind: newLibKind })
|
||||
}
|
||||
confirmLoading={createLibMutation.isPending}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Input
|
||||
placeholder="素材库名称"
|
||||
value={newLibName}
|
||||
onChange={(e) => setNewLibName(e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
value={newLibKind}
|
||||
onChange={setNewLibKind}
|
||||
style={{ width: '100%' }}
|
||||
options={[
|
||||
{ value: 'video', label: '视频素材' },
|
||||
{ value: 'voice', label: '配音素材' },
|
||||
{ value: 'image', label: '图片素材' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssetLibrary;
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* 仪表盘页面
|
||||
* 展示用户用量总览和最近生成记录
|
||||
*/
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Card,
|
||||
Col,
|
||||
Row,
|
||||
Statistic,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Spin,
|
||||
Button,
|
||||
Progress,
|
||||
Space,
|
||||
} from 'antd';
|
||||
import {
|
||||
FileOutlined,
|
||||
VideoCameraOutlined,
|
||||
AudioOutlined,
|
||||
FileTextOutlined,
|
||||
CloudServerOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getDashboardOverview } from '@/api/dashboard';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
/** 格式化文件大小 */
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
|
||||
};
|
||||
|
||||
/** 任务状态标签 */
|
||||
const StatusTag: React.FC<{ status: string }> = ({ status }) => {
|
||||
const config: Record<string, { color: string; icon: React.ReactNode }> = {
|
||||
completed: { color: 'success', icon: <CheckCircleOutlined /> },
|
||||
processing: { color: 'processing', icon: <ClockCircleOutlined /> },
|
||||
pending: { color: 'default', icon: <ClockCircleOutlined /> },
|
||||
failed: { color: 'error', icon: <CloseCircleOutlined /> },
|
||||
};
|
||||
const c = config[status] || config.pending;
|
||||
return (
|
||||
<Tag color={c.color} icon={c.icon}>
|
||||
{status}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['dashboard-overview'],
|
||||
queryFn: getDashboardOverview,
|
||||
});
|
||||
|
||||
const taskColumns: ColumnsType<NonNullable<typeof data>['recent_tasks'][number]> =
|
||||
[
|
||||
{
|
||||
title: '任务类型',
|
||||
dataIndex: 'task_type',
|
||||
key: 'task_type',
|
||||
render: (type: string) =>
|
||||
type === 'generation' ? '视频生成' : type,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => <StatusTag status={status} />,
|
||||
},
|
||||
{
|
||||
title: '进度',
|
||||
dataIndex: 'progress',
|
||||
key: 'progress',
|
||||
render: (progress: number) => (
|
||||
<Progress percent={Math.round(progress * 100)} size="small" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '信息',
|
||||
dataIndex: 'user_message',
|
||||
key: 'user_message',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
render: (t: string) =>
|
||||
t ? new Date(t).toLocaleString('zh-CN') : '-',
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: 1200, margin: '0 auto' }}>
|
||||
<Title level={3} style={{ marginBottom: 24 }}>
|
||||
概览
|
||||
</Title>
|
||||
|
||||
{/* 用量统计卡片 */}
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<Card hoverable onClick={() => navigate('/assets')}>
|
||||
<Statistic
|
||||
title="素材"
|
||||
value={data?.total_assets ?? 0}
|
||||
prefix={<FileOutlined />}
|
||||
suffix="个"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<Card hoverable onClick={() => navigate('/assets')}>
|
||||
<Statistic
|
||||
title="存储空间"
|
||||
value={formatFileSize(data?.used_storage_bytes ?? 0)}
|
||||
prefix={<CloudServerOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<Card hoverable onClick={() => navigate('/titles')}>
|
||||
<Statistic
|
||||
title="标题"
|
||||
value={data?.total_titles ?? 0}
|
||||
prefix={<FileTextOutlined />}
|
||||
suffix="条"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<Card hoverable onClick={() => navigate('/voices')}>
|
||||
<Statistic
|
||||
title="配音"
|
||||
value={data?.total_voices ?? 0}
|
||||
prefix={<AudioOutlined />}
|
||||
suffix="条"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<Card hoverable onClick={() => navigate('/history')}>
|
||||
<Statistic
|
||||
title="生成任务"
|
||||
value={data?.total_tasks ?? 0}
|
||||
prefix={<VideoCameraOutlined />}
|
||||
suffix="次"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<Card hoverable onClick={() => navigate('/products')}>
|
||||
<Statistic
|
||||
title="成品"
|
||||
value={data?.total_products ?? 0}
|
||||
prefix={<VideoCameraOutlined />}
|
||||
suffix="个"
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 快捷操作 */}
|
||||
<Card style={{ marginTop: 24 }}>
|
||||
<Space wrap>
|
||||
<Button type="primary" onClick={() => navigate('/generate')}>
|
||||
一键生成视频
|
||||
</Button>
|
||||
<Button onClick={() => navigate('/assets')}>上传素材</Button>
|
||||
<Button onClick={() => navigate('/templates')}>浏览模板</Button>
|
||||
<Button onClick={() => navigate('/subscription')}>订阅管理</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* 最近生成任务 */}
|
||||
<Card title="最近生成" style={{ marginTop: 24 }}>
|
||||
<Table
|
||||
columns={taskColumns}
|
||||
dataSource={data?.recent_tasks || []}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
locale={{ emptyText: '暂无生成记录' }}
|
||||
scroll={{ x: 600 }}
|
||||
/>
|
||||
{(data?.recent_tasks?.length ?? 0) > 0 && (
|
||||
<div style={{ textAlign: 'center', marginTop: 12 }}>
|
||||
<Button type="link" onClick={() => navigate('/history')}>
|
||||
查看全部
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* 一键生成页面
|
||||
* 流程:选择模板 → 选择素材 → 选择标题 → 选择配音 → 批量生成
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Typography,
|
||||
Space,
|
||||
Steps,
|
||||
Checkbox,
|
||||
message,
|
||||
Result,
|
||||
Spin,
|
||||
Row,
|
||||
Col,
|
||||
Tag,
|
||||
Alert,
|
||||
} from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
VideoCameraOutlined,
|
||||
FileTextOutlined,
|
||||
AudioOutlined,
|
||||
PictureOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getTemplates } from '@/api/templates';
|
||||
import { getAssetLibraries, getAssets, type AssetItem } from '@/api/assets';
|
||||
import { getTitles } from '@/api/titles';
|
||||
import { getVoices } from '@/api/voices';
|
||||
import { autoGenerateEditPlan } from '@/api/editPlans';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const GeneratePage: React.FC = () => {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string>('');
|
||||
const [selectedAssets, setSelectedAssets] = useState<string[]>([]);
|
||||
const [selectedTitles, setSelectedTitles] = useState<string[]>([]);
|
||||
const [selectedVoices, setSelectedVoices] = useState<string[]>([]);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [generated, setGenerated] = useState(false);
|
||||
|
||||
// 获取模板列表
|
||||
const { data: templates = [] } = useQuery({
|
||||
queryKey: ['templates'],
|
||||
queryFn: getTemplates,
|
||||
});
|
||||
|
||||
// 获取素材库和素材
|
||||
const { data: libraries = [] } = useQuery({
|
||||
queryKey: ['asset-libraries'],
|
||||
queryFn: getAssetLibraries,
|
||||
});
|
||||
|
||||
// 获取标题
|
||||
const { data: titles = [] } = useQuery({
|
||||
queryKey: ['titles'],
|
||||
queryFn: getTitles,
|
||||
});
|
||||
|
||||
// 获取配音
|
||||
const { data: voices = [] } = useQuery({
|
||||
queryKey: ['voices'],
|
||||
queryFn: getVoices,
|
||||
});
|
||||
|
||||
// 获取所有素材(跨库)
|
||||
const { data: allAssets = [] } = useQuery({
|
||||
queryKey: ['all-assets'],
|
||||
queryFn: async () => {
|
||||
const all: AssetItem[] = [];
|
||||
for (const lib of libraries) {
|
||||
const items = await getAssets(lib.id);
|
||||
all.push(...items);
|
||||
}
|
||||
return all;
|
||||
},
|
||||
enabled: libraries.length > 0,
|
||||
});
|
||||
|
||||
// 创建生成计划
|
||||
const generateMutation = useMutation({
|
||||
mutationFn: autoGenerateEditPlan,
|
||||
onSuccess: () => {
|
||||
message.success('生成任务已提交');
|
||||
setGenerated(true);
|
||||
setGenerating(false);
|
||||
},
|
||||
onError: () => {
|
||||
message.error('生成失败');
|
||||
setGenerating(false);
|
||||
},
|
||||
});
|
||||
|
||||
/** 开始生成 */
|
||||
const handleGenerate = async () => {
|
||||
if (!selectedTemplate) {
|
||||
message.warning('请选择模板');
|
||||
return;
|
||||
}
|
||||
setGenerating(true);
|
||||
generateMutation.mutate({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: selectedAssets,
|
||||
title_ids: selectedTitles,
|
||||
voice_ids: selectedVoices,
|
||||
});
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: '选择模板',
|
||||
icon: <VideoCameraOutlined />,
|
||||
content: (
|
||||
<Row gutter={[16, 16]}>
|
||||
{templates.length === 0 ? (
|
||||
<Col span={24}>
|
||||
<Alert message="暂无可用模板,请先在模板库中添加" type="info" />
|
||||
</Col>
|
||||
) : (
|
||||
templates.map((t) => (
|
||||
<Col xs={24} sm={12} md={8} key={t.id}>
|
||||
<Card
|
||||
hoverable
|
||||
size="small"
|
||||
onClick={() => setSelectedTemplate(t.id)}
|
||||
style={{
|
||||
border:
|
||||
selectedTemplate === t.id
|
||||
? '2px solid #1890ff'
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Card.Meta
|
||||
title={t.name}
|
||||
description={
|
||||
<Space>
|
||||
{t.category && <Tag>{t.category}</Tag>}
|
||||
{t.target_duration && (
|
||||
<Tag>{t.target_duration}s</Tag>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
))
|
||||
)}
|
||||
</Row>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '选择素材',
|
||||
icon: <PictureOutlined />,
|
||||
content: (
|
||||
<div>
|
||||
{allAssets.length === 0 ? (
|
||||
<Alert message="暂无素材,请先上传" type="info" />
|
||||
) : (
|
||||
<Checkbox.Group
|
||||
value={selectedAssets}
|
||||
onChange={(vals) => setSelectedAssets(vals as string[])}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Row gutter={[12, 12]}>
|
||||
{allAssets.map((a) => (
|
||||
<Col xs={24} sm={12} md={8} key={a.id}>
|
||||
<Checkbox value={a.id} style={{ width: '100%' }}>
|
||||
{a.name}
|
||||
</Checkbox>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Checkbox.Group>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '选择标题',
|
||||
icon: <FileTextOutlined />,
|
||||
content: (
|
||||
<div>
|
||||
{titles.length === 0 ? (
|
||||
<Alert message="暂无标题,请先在标题库中添加" type="info" />
|
||||
) : (
|
||||
<Checkbox.Group
|
||||
value={selectedTitles}
|
||||
onChange={(vals) => setSelectedTitles(vals as string[])}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Row gutter={[12, 12]}>
|
||||
{titles.map((t) => (
|
||||
<Col xs={24} sm={12} md={8} key={t.id}>
|
||||
<Checkbox value={t.id} style={{ width: '100%' }}>
|
||||
<Text ellipsis>{t.content}</Text>
|
||||
</Checkbox>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Checkbox.Group>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '选择配音',
|
||||
icon: <AudioOutlined />,
|
||||
content: (
|
||||
<div>
|
||||
{voices.length === 0 ? (
|
||||
<Alert message="暂无配音,请先在配音库中添加" type="info" />
|
||||
) : (
|
||||
<Checkbox.Group
|
||||
value={selectedVoices}
|
||||
onChange={(vals) => setSelectedVoices(vals as string[])}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Row gutter={[12, 12]}>
|
||||
{voices.map((v) => (
|
||||
<Col xs={24} sm={12} md={8} key={v.id}>
|
||||
<Checkbox value={v.id} style={{ width: '100%' }}>
|
||||
{v.name}
|
||||
</Checkbox>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Checkbox.Group>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '生成',
|
||||
icon: <CheckCircleOutlined />,
|
||||
content: generated ? (
|
||||
<Result
|
||||
status="success"
|
||||
title="生成任务已提交"
|
||||
subTitle="您可以在任务历史中查看生成进度"
|
||||
extra={
|
||||
<Button type="primary" onClick={() => window.location.href = '/history'}>
|
||||
查看任务
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||
{generating ? (
|
||||
<Spin size="large" tip="正在生成..." />
|
||||
) : (
|
||||
<Space direction="vertical" size={16}>
|
||||
<Text>
|
||||
已选择:{selectedAssets.length} 个素材,{selectedTitles.length}{' '}
|
||||
个标题,{selectedVoices.length} 个配音
|
||||
</Text>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<VideoCameraOutlined />}
|
||||
onClick={handleGenerate}
|
||||
disabled={!selectedTemplate}
|
||||
>
|
||||
开始生成
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: 1200, margin: '0 auto' }}>
|
||||
<Title level={3} style={{ marginBottom: 24 }}>
|
||||
一键生成
|
||||
</Title>
|
||||
|
||||
<Steps
|
||||
current={currentStep}
|
||||
onChange={setCurrentStep}
|
||||
items={steps.map((s) => ({ title: s.title, icon: s.icon }))}
|
||||
style={{ marginBottom: 32 }}
|
||||
responsive
|
||||
/>
|
||||
|
||||
<Card>{steps[currentStep].content}</Card>
|
||||
|
||||
{/* 步骤导航 */}
|
||||
{!generated && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 24,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
disabled={currentStep === 0}
|
||||
onClick={() => setCurrentStep((s) => s - 1)}
|
||||
>
|
||||
上一步
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={currentStep === steps.length - 1}
|
||||
onClick={() => setCurrentStep((s) => s + 1)}
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GeneratePage;
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* 任务历史页面
|
||||
* 展示用户所有生成任务,支持筛选和重试
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Button,
|
||||
Typography,
|
||||
Table,
|
||||
Tag,
|
||||
Empty,
|
||||
Spin,
|
||||
Select,
|
||||
Progress,
|
||||
Popconfirm,
|
||||
message,
|
||||
} from 'antd';
|
||||
import {
|
||||
ReloadOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
SyncOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getUserTasks, retryTask, type TaskItem } from '@/api/tasks';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
/** 任务状态标签 */
|
||||
const StatusTag: React.FC<{ status: string }> = ({ status }) => {
|
||||
const config: Record<string, { color: string; icon: React.ReactNode; text: string }> = {
|
||||
completed: { color: 'success', icon: <CheckCircleOutlined />, text: '已完成' },
|
||||
processing: { color: 'processing', icon: <SyncOutlined spin />, text: '处理中' },
|
||||
pending: { color: 'default', icon: <ClockCircleOutlined />, text: '等待中' },
|
||||
failed: { color: 'error', icon: <CloseCircleOutlined />, text: '失败' },
|
||||
};
|
||||
const c = config[status] || config.pending;
|
||||
return (
|
||||
<Tag color={c.color} icon={c.icon}>
|
||||
{c.text}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
const TaskHistory: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
|
||||
// 获取任务列表
|
||||
const { data: tasks = [], isLoading } = useQuery({
|
||||
queryKey: ['user-tasks'],
|
||||
queryFn: getUserTasks,
|
||||
});
|
||||
|
||||
// 重试任务
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: retryTask,
|
||||
onSuccess: () => {
|
||||
message.success('任务已重新提交');
|
||||
queryClient.invalidateQueries({ queryKey: ['user-tasks'] });
|
||||
},
|
||||
onError: () => message.error('重试失败'),
|
||||
});
|
||||
|
||||
/** 过滤后的任务 */
|
||||
const filteredTasks = statusFilter
|
||||
? tasks.filter((t) => t.status === statusFilter)
|
||||
: tasks;
|
||||
|
||||
const columns: ColumnsType<TaskItem> = [
|
||||
{
|
||||
title: '任务类型',
|
||||
dataIndex: 'task_type',
|
||||
key: 'task_type',
|
||||
width: 120,
|
||||
render: (type: string) => {
|
||||
const map: Record<string, string> = {
|
||||
generation: '视频生成',
|
||||
ingest: '素材入库',
|
||||
classification: '素材分类',
|
||||
voice_generate: '配音生成',
|
||||
};
|
||||
return map[type] || type;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (status: string) => <StatusTag status={status} />,
|
||||
},
|
||||
{
|
||||
title: '进度',
|
||||
dataIndex: 'progress',
|
||||
key: 'progress',
|
||||
width: 120,
|
||||
render: (progress: number) => (
|
||||
<Progress percent={Math.round((progress || 0) * 100)} size="small" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '信息',
|
||||
dataIndex: 'user_message',
|
||||
key: 'user_message',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 180,
|
||||
render: (t: string) => (t ? new Date(t).toLocaleString('zh-CN') : '-'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 80,
|
||||
render: (_, record) =>
|
||||
record.status === 'failed' ? (
|
||||
<Popconfirm
|
||||
title="确定重试此任务?"
|
||||
onConfirm={() => retryMutation.mutate(record.id)}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: 1200, margin: '0 auto' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<Title level={3} style={{ margin: 0 }}>
|
||||
任务历史
|
||||
</Title>
|
||||
<Select
|
||||
placeholder="按状态筛选"
|
||||
value={statusFilter || undefined}
|
||||
onChange={setStatusFilter}
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
options={[
|
||||
{ value: 'pending', label: '等待中' },
|
||||
{ value: 'processing', label: '处理中' },
|
||||
{ value: 'completed', label: '已完成' },
|
||||
{ value: 'failed', label: '失败' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: 60 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : filteredTasks.length === 0 ? (
|
||||
<Empty description={statusFilter ? '没有匹配的任务' : '暂无任务记录'} />
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={filteredTasks}
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 20, showSizeChanger: true }}
|
||||
size="small"
|
||||
scroll={{ x: 700 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskHistory;
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* 成品库页面
|
||||
* 展示用户生成的成品视频,支持查重率显示
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Typography,
|
||||
Space,
|
||||
Row,
|
||||
Col,
|
||||
Tag,
|
||||
Empty,
|
||||
Spin,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
message,
|
||||
Progress,
|
||||
Image,
|
||||
} from 'antd';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
PlayCircleOutlined,
|
||||
EyeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
getProducts,
|
||||
deleteProduct,
|
||||
getProductDownloadUrl,
|
||||
type ProductItem,
|
||||
} from '@/api/products';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/** 状态标签 */
|
||||
const StatusTag: React.FC<{ status: string }> = ({ status }) => {
|
||||
const config: Record<string, { color: string; text: string }> = {
|
||||
completed: { color: 'success', text: '已完成' },
|
||||
processing: { color: 'processing', text: '处理中' },
|
||||
failed: { color: 'error', text: '失败' },
|
||||
};
|
||||
const c = config[status] || config.processing;
|
||||
return <Tag color={c.color}>{c.text}</Tag>;
|
||||
};
|
||||
|
||||
|
||||
const ProductLibrary: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [previewProduct, setPreviewProduct] = useState<ProductItem | null>(null);
|
||||
|
||||
// 获取成品列表
|
||||
const { data: products = [], isLoading } = useQuery({
|
||||
queryKey: ['products'],
|
||||
queryFn: getProducts,
|
||||
});
|
||||
|
||||
// 删除成品
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteProduct,
|
||||
onSuccess: () => {
|
||||
message.success('已删除');
|
||||
queryClient.invalidateQueries({ queryKey: ['products'] });
|
||||
},
|
||||
});
|
||||
|
||||
// 下载
|
||||
const handleDownload = async (productId: string) => {
|
||||
try {
|
||||
const { url } = await getProductDownloadUrl(productId);
|
||||
window.open(url, '_blank');
|
||||
} catch {
|
||||
message.error('获取下载链接失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: 1200, margin: '0 auto' }}>
|
||||
<Title level={3} style={{ marginBottom: 24 }}>
|
||||
成品库
|
||||
</Title>
|
||||
|
||||
{isLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: 60 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : products.length === 0 ? (
|
||||
<Empty description="暂无成品,去一键生成吧">
|
||||
<Button type="primary" onClick={() => (window.location.href = '/generate')}>
|
||||
去生成
|
||||
</Button>
|
||||
</Empty>
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
{products.map((product) => (
|
||||
<Col xs={24} sm={12} md={8} lg={6} key={product.id}>
|
||||
<Card
|
||||
hoverable
|
||||
size="small"
|
||||
cover={
|
||||
product.thumbnail_url ? (
|
||||
<Image
|
||||
src={product.thumbnail_url}
|
||||
alt={product.title}
|
||||
style={{ height: 180, objectFit: 'cover' }}
|
||||
fallback="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE4MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE4MCIgZmlsbD0iI2YwZjBmMCIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjOTk5IiBmb250LXNpemU9IjE0Ij7lt6XlooPlvIDova7kuK3lm77niYw8L3RleHQ+PC9zdmc+"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
height: 180,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#f5f5f5',
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined style={{ fontSize: 48, color: '#bbb' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
actions={[
|
||||
<Button
|
||||
key="preview"
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => setPreviewProduct(product)}
|
||||
/>,
|
||||
<Button
|
||||
key="download"
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => handleDownload(product.id)}
|
||||
disabled={product.status !== 'completed'}
|
||||
/>,
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title="确定删除此成品?"
|
||||
onConfirm={() => deleteMutation.mutate(product.id)}
|
||||
>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>,
|
||||
]}
|
||||
>
|
||||
<Card.Meta
|
||||
title={
|
||||
<Text ellipsis style={{ maxWidth: '100%' }}>
|
||||
{product.title}
|
||||
</Text>
|
||||
}
|
||||
description={
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Space>
|
||||
<StatusTag status={product.status} />
|
||||
{product.duration_seconds && (
|
||||
<Tag>{product.duration_seconds}s</Tag>
|
||||
)}
|
||||
</Space>
|
||||
{/* 查重率 */}
|
||||
{product.duplicate_rate !== undefined &&
|
||||
product.duplicate_rate !== null && (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Text style={{ fontSize: 12 }}>
|
||||
查重率:{product.duplicate_rate.toFixed(1)}%
|
||||
</Text>
|
||||
<Progress
|
||||
percent={Math.round(product.duplicate_rate)}
|
||||
size="small"
|
||||
status={
|
||||
product.duplicate_rate <= 10
|
||||
? 'success'
|
||||
: product.duplicate_rate <= 30
|
||||
? 'normal'
|
||||
: 'exception'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* 预览弹窗 */}
|
||||
<Modal
|
||||
title={previewProduct?.title}
|
||||
open={!!previewProduct}
|
||||
onCancel={() => setPreviewProduct(null)}
|
||||
footer={
|
||||
<Space>
|
||||
<Button
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() =>
|
||||
previewProduct && handleDownload(previewProduct.id)
|
||||
}
|
||||
disabled={previewProduct?.status !== 'completed'}
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => setPreviewProduct(null)}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{previewProduct && (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{previewProduct.video_url ? (
|
||||
<video
|
||||
src={previewProduct.video_url}
|
||||
controls
|
||||
style={{ width: '100%', maxHeight: 400 }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
height: 200,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#f5f5f5',
|
||||
}}
|
||||
>
|
||||
<Text type="secondary">视频预览不可用</Text>
|
||||
</div>
|
||||
)}
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Text type="secondary">分辨率:</Text>
|
||||
<Text>{previewProduct.resolution || '-'}</Text>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Text type="secondary">时长:</Text>
|
||||
<Text>
|
||||
{previewProduct.duration_seconds
|
||||
? `${previewProduct.duration_seconds}s`
|
||||
: '-'}
|
||||
</Text>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Text type="secondary">文件大小:</Text>
|
||||
<Text>
|
||||
{previewProduct.file_size
|
||||
? `${(previewProduct.file_size / 1024 / 1024).toFixed(1)} MB`
|
||||
: '-'}
|
||||
</Text>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Text type="secondary">查重率:</Text>
|
||||
<Text>
|
||||
{previewProduct.duplicate_rate !== undefined
|
||||
? `${previewProduct.duplicate_rate.toFixed(1)}%`
|
||||
: '-'}
|
||||
</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductLibrary;
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* 模板库页面
|
||||
* 展示系统模板,支持预览和筛选
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Typography,
|
||||
Space,
|
||||
Row,
|
||||
Col,
|
||||
Tag,
|
||||
Empty,
|
||||
Spin,
|
||||
Input,
|
||||
Select,
|
||||
Modal,
|
||||
Image,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
StarOutlined,
|
||||
StarFilled,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
getTemplates,
|
||||
toggleFavoriteTemplate,
|
||||
type TemplateItem,
|
||||
} from '@/api/templates';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
const TemplateLibrary: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState('');
|
||||
const [previewTemplate, setPreviewTemplate] = useState<TemplateItem | null>(
|
||||
null
|
||||
);
|
||||
|
||||
// 获取模板列表
|
||||
const { data: templates = [], isLoading } = useQuery({
|
||||
queryKey: ['templates'],
|
||||
queryFn: getTemplates,
|
||||
});
|
||||
|
||||
// 收藏/取消收藏
|
||||
const favMutation = useMutation({
|
||||
mutationFn: toggleFavoriteTemplate,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['templates'] });
|
||||
},
|
||||
});
|
||||
|
||||
/** 提取所有分类 */
|
||||
const categories = Array.from(
|
||||
new Set(templates.map((t) => t.category).filter(Boolean))
|
||||
);
|
||||
|
||||
/** 过滤后的模板 */
|
||||
const filteredTemplates = templates.filter((t) => {
|
||||
const matchSearch =
|
||||
!searchText ||
|
||||
t.name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
(t.description || '').toLowerCase().includes(searchText.toLowerCase());
|
||||
const matchCategory = !categoryFilter || t.category === categoryFilter;
|
||||
return matchSearch && matchCategory;
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: 1200, margin: '0 auto' }}>
|
||||
<Title level={3} style={{ marginBottom: 24 }}>
|
||||
模板库
|
||||
</Title>
|
||||
|
||||
{/* 搜索和筛选 */}
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={24} sm={16} md={18}>
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索模板..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={8} md={6}>
|
||||
<Select
|
||||
placeholder="按分类筛选"
|
||||
value={categoryFilter || undefined}
|
||||
onChange={setCategoryFilter}
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
options={categories.map((c) => ({ value: c, label: c }))}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{isLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: 60 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : filteredTemplates.length === 0 ? (
|
||||
<Empty description={searchText || categoryFilter ? '未找到匹配的模板' : '暂无模板'} />
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
{filteredTemplates.map((template) => (
|
||||
<Col xs={24} sm={12} md={8} lg={6} key={template.id}>
|
||||
<Card
|
||||
hoverable
|
||||
size="small"
|
||||
cover={
|
||||
template.thumbnail_url ? (
|
||||
<Image
|
||||
src={template.thumbnail_url}
|
||||
alt={template.name}
|
||||
style={{ height: 180, objectFit: 'cover' }}
|
||||
fallback="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE4MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE4MCIgZmlsbD0iI2YwZjBmMCIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmaWxsPSIjOTk5IiBmb250LXNpemU9IjE0Ij7lt6XlooPlvIDova7kuK3lm77niYw8L3RleHQ+PC9zdmc+"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
height: 180,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#f5f5f5',
|
||||
}}
|
||||
>
|
||||
<PlayCircleOutlined style={{ fontSize: 48, color: '#bbb' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
actions={[
|
||||
<Button
|
||||
key="preview"
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={() => setPreviewTemplate(template)}
|
||||
>
|
||||
预览
|
||||
</Button>,
|
||||
<Button
|
||||
key="fav"
|
||||
type="text"
|
||||
size="small"
|
||||
icon={
|
||||
template.is_favorite ? (
|
||||
<StarFilled style={{ color: '#faad14' }} />
|
||||
) : (
|
||||
<StarOutlined />
|
||||
)
|
||||
}
|
||||
onClick={() => favMutation.mutate(template.id)}
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<Card.Meta
|
||||
title={template.name}
|
||||
description={
|
||||
<Space direction="vertical" size={4}>
|
||||
<Paragraph
|
||||
ellipsis={{ rows: 2 }}
|
||||
style={{ marginBottom: 0, fontSize: 12 }}
|
||||
>
|
||||
{template.description || '暂无描述'}
|
||||
</Paragraph>
|
||||
<Space>
|
||||
{template.category && (
|
||||
<Tag color="blue">{template.category}</Tag>
|
||||
)}
|
||||
{template.target_duration && (
|
||||
<Tag>目标时长 {template.target_duration}s</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* 预览弹窗 */}
|
||||
<Modal
|
||||
title={previewTemplate?.name}
|
||||
open={!!previewTemplate}
|
||||
onCancel={() => setPreviewTemplate(null)}
|
||||
footer={
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
// TODO: 跳转到生成页面,携带模板参数
|
||||
setPreviewTemplate(null);
|
||||
}}
|
||||
>
|
||||
使用此模板
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{previewTemplate && (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{previewTemplate.preview_url && (
|
||||
<video
|
||||
src={previewTemplate.preview_url}
|
||||
controls
|
||||
style={{ width: '100%', maxHeight: 400 }}
|
||||
/>
|
||||
)}
|
||||
<Paragraph>{previewTemplate.description}</Paragraph>
|
||||
<Space wrap>
|
||||
{previewTemplate.category && (
|
||||
<Tag color="blue">{previewTemplate.category}</Tag>
|
||||
)}
|
||||
{previewTemplate.target_duration && (
|
||||
<Tag>目标时长 {previewTemplate.target_duration}s</Tag>
|
||||
)}
|
||||
{previewTemplate.clip_count && (
|
||||
<Tag>片段数 {previewTemplate.clip_count}</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateLibrary;
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* 标题库页面
|
||||
* 管理用户标题,支持 CRUD 和批量导入
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Button,
|
||||
Typography,
|
||||
Space,
|
||||
Table,
|
||||
Empty,
|
||||
Spin,
|
||||
Modal,
|
||||
Input,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
message,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
ImportOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
getTitles,
|
||||
createTitle,
|
||||
updateTitle,
|
||||
deleteTitle,
|
||||
batchImportTitles,
|
||||
type TitleItem,
|
||||
} from '@/api/titles';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
const { Title } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const TitleLibrary: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [importModalOpen, setImportModalOpen] = useState(false);
|
||||
const [editingTitle, setEditingTitle] = useState<TitleItem | null>(null);
|
||||
const [formContent, setFormContent] = useState('');
|
||||
const [formCategory, setFormCategory] = useState('');
|
||||
const [importText, setImportText] = useState('');
|
||||
const [searchText, setSearchText] = useState('');
|
||||
|
||||
// 获取标题列表
|
||||
const { data: titles = [], isLoading } = useQuery({
|
||||
queryKey: ['titles'],
|
||||
queryFn: getTitles,
|
||||
});
|
||||
|
||||
// 创建标题
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createTitle,
|
||||
onSuccess: () => {
|
||||
message.success('标题创建成功');
|
||||
setModalOpen(false);
|
||||
resetForm();
|
||||
queryClient.invalidateQueries({ queryKey: ['titles'] });
|
||||
},
|
||||
onError: () => message.error('创建失败'),
|
||||
});
|
||||
|
||||
// 更新标题
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<{ content: string; category: string }> }) =>
|
||||
updateTitle(id, data),
|
||||
onSuccess: () => {
|
||||
message.success('标题更新成功');
|
||||
setModalOpen(false);
|
||||
resetForm();
|
||||
queryClient.invalidateQueries({ queryKey: ['titles'] });
|
||||
},
|
||||
onError: () => message.error('更新失败'),
|
||||
});
|
||||
|
||||
// 删除标题
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteTitle,
|
||||
onSuccess: () => {
|
||||
message.success('已删除');
|
||||
queryClient.invalidateQueries({ queryKey: ['titles'] });
|
||||
},
|
||||
});
|
||||
|
||||
// 批量导入
|
||||
const importMutation = useMutation({
|
||||
mutationFn: (titles: string[]) =>
|
||||
batchImportTitles(titles),
|
||||
onSuccess: () => {
|
||||
message.success('批量导入成功');
|
||||
setImportModalOpen(false);
|
||||
setImportText('');
|
||||
queryClient.invalidateQueries({ queryKey: ['titles'] });
|
||||
},
|
||||
onError: () => message.error('导入失败'),
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingTitle(null);
|
||||
setFormContent('');
|
||||
setFormCategory('');
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
resetForm();
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (title: TitleItem) => {
|
||||
setEditingTitle(title);
|
||||
setFormContent(title.content);
|
||||
setFormCategory(title.category || '');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!formContent.trim()) {
|
||||
message.warning('请输入标题内容');
|
||||
return;
|
||||
}
|
||||
if (editingTitle) {
|
||||
updateMutation.mutate({
|
||||
id: editingTitle.id,
|
||||
data: { content: formContent, category: formCategory },
|
||||
});
|
||||
} else {
|
||||
createMutation.mutate({ content: formContent, category: formCategory });
|
||||
}
|
||||
};
|
||||
|
||||
/** 过滤后的标题 */
|
||||
const filteredTitles = titles.filter(
|
||||
(t) =>
|
||||
!searchText ||
|
||||
t.content.toLowerCase().includes(searchText.toLowerCase())
|
||||
);
|
||||
|
||||
const columns: ColumnsType<TitleItem> = [
|
||||
{
|
||||
title: '标题内容',
|
||||
dataIndex: 'content',
|
||||
key: 'content',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
width: 120,
|
||||
render: (cat: string) =>
|
||||
cat ? <Tag>{cat}</Tag> : <Tag color="default">未分类</Tag>,
|
||||
},
|
||||
{
|
||||
title: '字数',
|
||||
dataIndex: 'word_count',
|
||||
key: 'word_count',
|
||||
width: 80,
|
||||
render: (count: number) => count ?? '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEdit(record)}
|
||||
/>
|
||||
<Popconfirm
|
||||
title="确定删除此标题?"
|
||||
onConfirm={() => deleteMutation.mutate(record.id)}
|
||||
>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: 1200, margin: '0 auto' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<Title level={3} style={{ margin: 0 }}>
|
||||
标题库
|
||||
</Title>
|
||||
<Space wrap>
|
||||
<Button
|
||||
icon={<ImportOutlined />}
|
||||
onClick={() => setImportModalOpen(true)}
|
||||
>
|
||||
批量导入
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新建标题
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 搜索 */}
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索标题..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
allowClear
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: 60 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : filteredTitles.length === 0 ? (
|
||||
<Empty description={searchText ? '未找到匹配的标题' : '暂无标题'} />
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={filteredTitles}
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 20, showSizeChanger: true }}
|
||||
size="small"
|
||||
scroll={{ x: 600 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 新建/编辑弹窗 */}
|
||||
<Modal
|
||||
title={editingTitle ? '编辑标题' : '新建标题'}
|
||||
open={modalOpen}
|
||||
onCancel={() => {
|
||||
setModalOpen(false);
|
||||
resetForm();
|
||||
}}
|
||||
onOk={handleSave}
|
||||
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<TextArea
|
||||
placeholder="输入标题内容"
|
||||
value={formContent}
|
||||
onChange={(e) => setFormContent(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
<Input
|
||||
placeholder="分类(可选)"
|
||||
value={formCategory}
|
||||
onChange={(e) => setFormCategory(e.target.value)}
|
||||
/>
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
{/* 批量导入弹窗 */}
|
||||
<Modal
|
||||
title="批量导入标题"
|
||||
open={importModalOpen}
|
||||
onCancel={() => setImportModalOpen(false)}
|
||||
onOk={() => {
|
||||
const lines = importText
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
if (lines.length === 0) {
|
||||
message.warning('请输入至少一条标题');
|
||||
return;
|
||||
}
|
||||
importMutation.mutate(lines);
|
||||
}}
|
||||
confirmLoading={importMutation.isPending}
|
||||
>
|
||||
<TextArea
|
||||
placeholder="每行一个标题,例如: 今天天气真好 分享我的日常 美食推荐"
|
||||
value={importText}
|
||||
onChange={(e) => setImportText(e.target.value)}
|
||||
rows={10}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TitleLibrary;
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* 配音库页面
|
||||
* 管理用户配音,支持手动创建和 AI 生成
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Button,
|
||||
Typography,
|
||||
Space,
|
||||
Table,
|
||||
Empty,
|
||||
Spin,
|
||||
Modal,
|
||||
Input,
|
||||
Select,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
message,
|
||||
Slider,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
RobotOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
getVoices,
|
||||
createVoice,
|
||||
updateVoice,
|
||||
deleteVoice,
|
||||
generateAIVoice,
|
||||
type VoiceItem,
|
||||
} from '@/api/voices';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/** 配音状态标签 */
|
||||
const StatusTag: React.FC<{ status?: string }> = ({ status }) => {
|
||||
if (!status) return null;
|
||||
const colorMap: Record<string, string> = {
|
||||
completed: 'success',
|
||||
processing: 'processing',
|
||||
failed: 'error',
|
||||
pending: 'default',
|
||||
};
|
||||
return (
|
||||
<Tag color={colorMap[status] || 'default'}>
|
||||
{status}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
const VoiceLibrary: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [aiModalOpen, setAiModalOpen] = useState(false);
|
||||
const [editingVoice, setEditingVoice] = useState<VoiceItem | null>(null);
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formText, setFormText] = useState('');
|
||||
const [formVoiceType, setFormVoiceType] = useState('');
|
||||
const [aiText, setAiText] = useState('');
|
||||
const [aiVoiceType, setAiVoiceType] = useState('');
|
||||
const [aiSpeed, setAiSpeed] = useState(1.0);
|
||||
|
||||
// 获取配音列表
|
||||
const { data: voices = [], isLoading } = useQuery({
|
||||
queryKey: ['voices'],
|
||||
queryFn: getVoices,
|
||||
});
|
||||
|
||||
// 创建配音
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createVoice,
|
||||
onSuccess: () => {
|
||||
message.success('配音创建成功');
|
||||
setModalOpen(false);
|
||||
resetForm();
|
||||
queryClient.invalidateQueries({ queryKey: ['voices'] });
|
||||
},
|
||||
onError: () => message.error('创建失败'),
|
||||
});
|
||||
|
||||
// 更新配音
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<{ name: string; text: string; voice_type: string }> }) =>
|
||||
updateVoice(id, data),
|
||||
onSuccess: () => {
|
||||
message.success('配音更新成功');
|
||||
setModalOpen(false);
|
||||
resetForm();
|
||||
queryClient.invalidateQueries({ queryKey: ['voices'] });
|
||||
},
|
||||
onError: () => message.error('更新失败'),
|
||||
});
|
||||
|
||||
// 删除配音
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteVoice,
|
||||
onSuccess: () => {
|
||||
message.success('已删除');
|
||||
queryClient.invalidateQueries({ queryKey: ['voices'] });
|
||||
},
|
||||
});
|
||||
|
||||
// AI 生成配音
|
||||
const aiMutation = useMutation({
|
||||
mutationFn: generateAIVoice,
|
||||
onSuccess: () => {
|
||||
message.success('AI 配音生成成功');
|
||||
setAiModalOpen(false);
|
||||
setAiText('');
|
||||
queryClient.invalidateQueries({ queryKey: ['voices'] });
|
||||
},
|
||||
onError: () => message.error('AI 生成失败'),
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingVoice(null);
|
||||
setFormName('');
|
||||
setFormText('');
|
||||
setFormVoiceType('');
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
resetForm();
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (voice: VoiceItem) => {
|
||||
setEditingVoice(voice);
|
||||
setFormName(voice.name);
|
||||
setFormText(voice.text);
|
||||
setFormVoiceType(voice.voice_type || '');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!formName.trim() || !formText.trim()) {
|
||||
message.warning('请填写名称和文本');
|
||||
return;
|
||||
}
|
||||
if (editingVoice) {
|
||||
updateMutation.mutate({
|
||||
id: editingVoice.id,
|
||||
data: { name: formName, text: formText, voice_type: formVoiceType },
|
||||
});
|
||||
} else {
|
||||
createMutation.mutate({
|
||||
name: formName,
|
||||
text: formText,
|
||||
voice_type: formVoiceType,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<VoiceItem> = [
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '文本',
|
||||
dataIndex: 'text',
|
||||
key: 'text',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'voice_type',
|
||||
key: 'voice_type',
|
||||
width: 100,
|
||||
render: (type: string) => type || '-',
|
||||
},
|
||||
{
|
||||
title: '时长',
|
||||
dataIndex: 'duration_seconds',
|
||||
key: 'duration_seconds',
|
||||
width: 80,
|
||||
render: (d: number) => (d ? `${d.toFixed(1)}s` : '-'),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 80,
|
||||
render: (status: string) => <StatusTag status={status} />,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEdit(record)}
|
||||
/>
|
||||
<Popconfirm
|
||||
title="确定删除此配音?"
|
||||
onConfirm={() => deleteMutation.mutate(record.id)}
|
||||
>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: 1200, margin: '0 auto' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<Title level={3} style={{ margin: 0 }}>
|
||||
配音库
|
||||
</Title>
|
||||
<Space wrap>
|
||||
<Button
|
||||
icon={<RobotOutlined />}
|
||||
onClick={() => setAiModalOpen(true)}
|
||||
>
|
||||
AI 生成
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新建配音
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: 60 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : voices.length === 0 ? (
|
||||
<Empty description="暂无配音">
|
||||
<Space>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
手动创建
|
||||
</Button>
|
||||
<Button icon={<RobotOutlined />} onClick={() => setAiModalOpen(true)}>
|
||||
AI 生成
|
||||
</Button>
|
||||
</Space>
|
||||
</Empty>
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={voices}
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 20, showSizeChanger: true }}
|
||||
size="small"
|
||||
scroll={{ x: 700 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 新建/编辑弹窗 */}
|
||||
<Modal
|
||||
title={editingVoice ? '编辑配音' : '新建配音'}
|
||||
open={modalOpen}
|
||||
onCancel={() => {
|
||||
setModalOpen(false);
|
||||
resetForm();
|
||||
}}
|
||||
onOk={handleSave}
|
||||
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Input
|
||||
placeholder="配音名称"
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
/>
|
||||
<Input.TextArea
|
||||
placeholder="配音文本内容"
|
||||
value={formText}
|
||||
onChange={(e) => setFormText(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
<Select
|
||||
placeholder="配音类型(可选)"
|
||||
value={formVoiceType || undefined}
|
||||
onChange={setFormVoiceType}
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
options={[
|
||||
{ value: 'male', label: '男声' },
|
||||
{ value: 'female', label: '女声' },
|
||||
{ value: 'child', label: '童声' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
{/* AI 生成弹窗 */}
|
||||
<Modal
|
||||
title="AI 生成配音"
|
||||
open={aiModalOpen}
|
||||
onCancel={() => setAiModalOpen(false)}
|
||||
onOk={() => {
|
||||
if (!aiText.trim()) {
|
||||
message.warning('请输入配音文本');
|
||||
return;
|
||||
}
|
||||
aiMutation.mutate({
|
||||
text: aiText,
|
||||
voice_type: aiVoiceType || undefined,
|
||||
speed: aiSpeed,
|
||||
});
|
||||
}}
|
||||
confirmLoading={aiMutation.isPending}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Input.TextArea
|
||||
placeholder="输入需要配音的文本"
|
||||
value={aiText}
|
||||
onChange={(e) => setAiText(e.target.value)}
|
||||
rows={5}
|
||||
/>
|
||||
<Select
|
||||
placeholder="选择声音类型"
|
||||
value={aiVoiceType || undefined}
|
||||
onChange={setAiVoiceType}
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
options={[
|
||||
{ value: 'male', label: '男声' },
|
||||
{ value: 'female', label: '女声' },
|
||||
{ value: 'child', label: '童声' },
|
||||
]}
|
||||
/>
|
||||
<div>
|
||||
<Text>语速:{aiSpeed.toFixed(1)}x</Text>
|
||||
<Slider
|
||||
min={0.5}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={aiSpeed}
|
||||
onChange={setAiSpeed}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VoiceLibrary;
|
||||
@@ -1,270 +0,0 @@
|
||||
/* V21 素材库页面 - 9:16 竖屏紧凑卡片 */
|
||||
|
||||
/* ========== 布局 ========== */
|
||||
.xx-project-assets {
|
||||
padding: var(--space-xl);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ========== 素材网格 - 竖屏卡片 ========== */
|
||||
.xx-assets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.xx-assets-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.xx-assets-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.xx-assets-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 素材卡片 - V21 大圆角 ========== */
|
||||
.xx-asset-card {
|
||||
border: 1px solid var(--border-color);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 14px;
|
||||
transition: all 0.25s ease;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-asset-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
/* ========== 缩略图 - 9:16 竖屏 ========== */
|
||||
.xx-asset-thumb {
|
||||
aspect-ratio: 9 / 16;
|
||||
border-radius: var(--radius-lg);
|
||||
background: linear-gradient(135deg, #0ea5e9, #312e81);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: white;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.xx-asset-thumb video,
|
||||
.xx-asset-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* 播放按钮 - 毛玻璃效果 */
|
||||
.xx-asset-thumb span {
|
||||
position: absolute;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
border: 2px solid rgba(255, 255, 255, 0.4);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
font-size: 18px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.xx-asset-card:hover .xx-asset-thumb span {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* ========== 素材状态 ========== */
|
||||
.xx-asset-status {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
font-weight: 850;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.xx-asset-status.ready {
|
||||
background: var(--success-soft);
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.xx-asset-status.processing {
|
||||
background: var(--warning-soft);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.xx-asset-status.failed {
|
||||
background: var(--error-soft);
|
||||
color: #be123c;
|
||||
}
|
||||
|
||||
/* 卡片文字 */
|
||||
.xx-asset-card b {
|
||||
display: block;
|
||||
color: var(--text-primary);
|
||||
font-weight: 850;
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xx-asset-card p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ========== 视频卡片(横向) ========== */
|
||||
.xx-video-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.xx-video-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.xx-video-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.xx-video-card {
|
||||
border: 1px solid var(--border-color);
|
||||
background: white;
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 14px;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.xx-video-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 视频封面 */
|
||||
.xx-video-thumb {
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: var(--radius-lg);
|
||||
background: linear-gradient(135deg, #0ea5e9, #312e81);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: white;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.xx-loading {
|
||||
padding: 60px 24px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ========== 状态标签 ========== */
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
font-weight: 850;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill.ok {
|
||||
background: var(--success-soft);
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.pill.warn {
|
||||
background: var(--warning-soft);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.pill.bad {
|
||||
background: var(--error-soft);
|
||||
color: #be123c;
|
||||
}
|
||||
|
||||
/* ========== 操作按钮 ========== */
|
||||
.assist-actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.assist-actions .btn {
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 14px;
|
||||
font-weight: 850;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.assist-actions .btn.primary {
|
||||
background: var(--gradient-primary);
|
||||
color: white;
|
||||
box-shadow: var(--shadow-primary);
|
||||
}
|
||||
|
||||
.assist-actions .btn.primary:hover {
|
||||
box-shadow: var(--shadow-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.assist-actions .btn.ghost {
|
||||
background: white;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.assist-actions .btn.ghost:hover {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* ========== 空状态 ========== */
|
||||
.xx-empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.xx-empty-state p {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* 素材库页面 - V21 UI
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { message } from 'antd';
|
||||
import type { AssetItem } from '@/api/assets';
|
||||
import './ProjectAssets.css';
|
||||
|
||||
const ProjectAssets: React.FC = () => {
|
||||
const [assets] = useState<AssetItem[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const handleFileUpload = async (files: FileList | null) => {
|
||||
if (!files) return;
|
||||
try {
|
||||
setUploading(true);
|
||||
message.success('上传成功');
|
||||
} catch (error: any) {
|
||||
message.error('上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="xx-project-assets">
|
||||
<div className="xx-page-head">
|
||||
<h2>素材管理</h2>
|
||||
<p>上传和管理您的视频素材,支持多种格式</p>
|
||||
</div>
|
||||
|
||||
{/* 上传区域 - V21 设计 */}
|
||||
<div className="xx-card">
|
||||
<h3>📤 上传素材</h3>
|
||||
<div
|
||||
className="xx-upload-zone"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer.files) handleFileUpload(e.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
<div className="icon">🎬</div>
|
||||
<p>拖拽视频文件到此处,或点击下方按钮上传</p>
|
||||
<p>支持 MP4、MOV、AVI 格式,单个文件最大 500MB</p>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept="video/*"
|
||||
id="file-input"
|
||||
onChange={(e) => e.target.files && handleFileUpload(e.target.files)}
|
||||
/>
|
||||
<button
|
||||
className="xx-upload-btn"
|
||||
onClick={() => document.getElementById('file-input')?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? '上传中...' : '选择文件上传'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 素材库 - V21 竖屏卡片网格 */}
|
||||
<div className="xx-card">
|
||||
<h3>📁 我的素材库</h3>
|
||||
{assets.length === 0 ? (
|
||||
<div className="xx-empty-state">
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📂</div>
|
||||
<p>还没有上传素材</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="xx-assets-grid">
|
||||
{assets.map((asset) => (
|
||||
<div key={asset.id} className="xx-asset-card">
|
||||
<div className="xx-asset-thumb">
|
||||
<span>▶</span>
|
||||
</div>
|
||||
<span className={`xx-asset-status ${asset.status || 'ready'}`}>
|
||||
{asset.status === 'processing' ? '⏳ 处理中' : '✓ 就绪'}
|
||||
</span>
|
||||
<b>{asset.name}</b>
|
||||
<p>{((asset.file_size || 0) / 1024 / 1024).toFixed(2)} MB</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectAssets;
|
||||
export const Component = ProjectAssets;
|
||||
@@ -1,177 +0,0 @@
|
||||
/* V21 视频剪辑页面 */
|
||||
|
||||
/* ========== 布局 ========== */
|
||||
.xx-project-generation {
|
||||
padding: var(--space-xl);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.xx-generation-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 360px;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.xx-generation-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 模板网格 - V21 卡片 ========== */
|
||||
.xx-template-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.xx-template-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.xx-template-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 模板选择卡片 ========== */
|
||||
.xx-choice-card {
|
||||
position: relative;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
padding: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-choice-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.xx-choice-card.selected {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.16);
|
||||
}
|
||||
|
||||
/* 选中标记 */
|
||||
.xx-choice-card .check {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 10px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--gradient-primary);
|
||||
color: white;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 900;
|
||||
font-size: 14px;
|
||||
box-shadow: var(--shadow-primary);
|
||||
}
|
||||
|
||||
/* 模板封面 - 渐变背景 */
|
||||
.xx-template-cover {
|
||||
height: 90px;
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: white;
|
||||
font-size: 32px;
|
||||
font-weight: 850;
|
||||
margin-bottom: 12px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-template-cover::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.1) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
/* 渐变预设 */
|
||||
.gradient-1 {
|
||||
background: linear-gradient(135deg, #4f46e5, #6366f1);
|
||||
}
|
||||
|
||||
.gradient-2 {
|
||||
background: linear-gradient(135deg, #10b981, #14b8a6);
|
||||
}
|
||||
|
||||
.gradient-3 {
|
||||
background: linear-gradient(135deg, #f59e0b, #f97316);
|
||||
}
|
||||
|
||||
.gradient-4 {
|
||||
background: linear-gradient(135deg, #ec4899, #8b5cf6);
|
||||
}
|
||||
|
||||
/* 模板信息 */
|
||||
.xx-template-info b {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 850;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.xx-template-info p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ========== 预览区域 ========== */
|
||||
.xx-preview-section {
|
||||
position: sticky;
|
||||
top: var(--space-xl);
|
||||
}
|
||||
|
||||
.xx-preview-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-sm);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.xx-preview-item:hover {
|
||||
background: var(--primary-soft);
|
||||
}
|
||||
|
||||
.xx-preview-item .icon {
|
||||
font-size: 28px;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.xx-preview-item h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 850;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.xx-preview-item p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@@ -1,423 +0,0 @@
|
||||
/**
|
||||
* 视频剪辑/生成页面 - V21 完全对标
|
||||
* 功能:剪辑模式选择器 + 智能编排时间轴
|
||||
*/
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Select, Modal, Form, message, Radio, Spin, Tooltip, Button, Empty, Tag } from "antd";
|
||||
import { ThunderboltOutlined, PlayCircleOutlined, DeleteOutlined, CheckCircleOutlined } from "@ant-design/icons";
|
||||
import "./ProjectGeneration.css";
|
||||
import { autoGenerateEditPlan, getEditPlans, deleteEditPlan, EditingMode, EditPlanItem, EditPlanClipItem } from "@/api/editPlans";
|
||||
|
||||
/**
|
||||
* 剪辑模式配置
|
||||
*/
|
||||
const EDITING_MODE_OPTIONS: {
|
||||
value: EditingMode;
|
||||
label: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
color: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "one-take",
|
||||
label: "一镜到底",
|
||||
icon: "🎬",
|
||||
description: "所有素材顺序拼接,适合连续场景",
|
||||
color: "#4f46e5",
|
||||
},
|
||||
{
|
||||
value: "pip",
|
||||
label: "画中画",
|
||||
icon: "🖼️",
|
||||
description: "主画面 + 角落小窗口,适合对比展示",
|
||||
color: "#f97316",
|
||||
},
|
||||
{
|
||||
value: "voiceover",
|
||||
label: "口播",
|
||||
icon: "🎙️",
|
||||
description: "人物口播为主,穿插 B-roll",
|
||||
color: "#10b981",
|
||||
},
|
||||
{
|
||||
value: "voice_pip",
|
||||
label: "口播+画中画",
|
||||
icon: "🎙️🖼️",
|
||||
description: "口播主体 + 画中画叠加",
|
||||
color: "#8b5cf6",
|
||||
},
|
||||
];
|
||||
|
||||
const TITLE_OPTIONS = [
|
||||
{ id: "1", name: "产品介绍", content: "完整介绍产品功能和优势" },
|
||||
{ id: "2", name: "用户评价", content: "展示用户真实反馈和评分" },
|
||||
{ id: "3", name: "功能演示", content: "详细演示产品各项功能" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 时间轴片段组件
|
||||
*/
|
||||
const TimelineClip: React.FC<{
|
||||
clip: EditPlanClipItem;
|
||||
scale: number;
|
||||
totalDuration: number;
|
||||
}> = ({ clip, scale, totalDuration }) => {
|
||||
const left = (clip.start_time / totalDuration) * 100;
|
||||
const width = (clip.duration / totalDuration) * 100;
|
||||
const layerIndex = clip.layer === "pip" ? 1 : clip.layer === "broll" ? 2 : 0;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={
|
||||
<div>
|
||||
<div><strong>{clip.asset_name}</strong></div>
|
||||
<div>时长: {clip.duration.toFixed(1)}s</div>
|
||||
<div>理由: {clip.reason}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`timeline-clip timeline-clip-${clip.layer || "main"}`}
|
||||
style={{
|
||||
left: `${left}%`,
|
||||
width: `${Math.max(width, 2)}%`,
|
||||
top: `${layerIndex * 28}px`,
|
||||
}}
|
||||
>
|
||||
<span className="clip-label">{clip.asset_name.substring(0, 8)}</span>
|
||||
<span className="clip-duration">{clip.duration.toFixed(0)}s</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 时间轴组件
|
||||
*/
|
||||
const Timeline: React.FC<{
|
||||
clips: EditPlanClipItem[];
|
||||
totalDuration: number;
|
||||
}> = ({ clips, totalDuration }) => {
|
||||
|
||||
|
||||
const tickCount = 10;
|
||||
const ticks = Array.from({ length: tickCount + 1 }, (_, i) => {
|
||||
const time = (totalDuration / tickCount) * i;
|
||||
return { time, position: (time / totalDuration) * 100 };
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="timeline-container">
|
||||
<div className="timeline-controls">
|
||||
<span className="timeline-duration">总时长: {totalDuration.toFixed(1)}s</span>
|
||||
<span className="timeline-clips-count">{clips.length} 个片段</span>
|
||||
</div>
|
||||
<div className="timeline-wrapper">
|
||||
<div className="timeline-layers">
|
||||
<div className="layer-label main">主画面</div>
|
||||
<div className="layer-label pip">画中画</div>
|
||||
<div className="layer-label broll">B-roll</div>
|
||||
</div>
|
||||
<div className="timeline-body">
|
||||
<div className="timeline-ruler">
|
||||
{ticks.map((tick, i) => (
|
||||
<div key={i} className="timeline-tick" style={{ left: `${tick.position}%` }}>
|
||||
<span>{tick.time.toFixed(0)}s</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="timeline-tracks">
|
||||
<div className="timeline-track main-track" />
|
||||
<div className="timeline-track pip-track" />
|
||||
<div className="timeline-track broll-track" />
|
||||
{clips.map((clip) => (
|
||||
<TimelineClip key={clip.id} clip={clip} totalDuration={totalDuration} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 智能编排面板
|
||||
*/
|
||||
const AutoArrangePanel: React.FC<{
|
||||
projectId: string;
|
||||
onSelectPlan: (plan: EditPlanItem) => void;
|
||||
selectedPlanId?: string;
|
||||
}> = ({ projectId, onSelectPlan, selectedPlanId }) => {
|
||||
const [plans, setPlans] = useState<EditPlanItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [selectedMode, setSelectedMode] = useState<EditingMode>("one-take");
|
||||
const [targetDuration, setTargetDuration] = useState<number | undefined>(60);
|
||||
|
||||
useEffect(() => { fetchPlans(); }, [projectId]);
|
||||
|
||||
const fetchPlans = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getEditPlans(projectId);
|
||||
setPlans(data);
|
||||
} catch {
|
||||
// 编排方案获取失败,静默处理
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoGenerate = async () => {
|
||||
setGenerating(true);
|
||||
try {
|
||||
const plan = await autoGenerateEditPlan(projectId, { editing_mode: selectedMode, target_duration: targetDuration });
|
||||
message.success("智能编排完成");
|
||||
setPlans((prev) => [plan, ...prev]);
|
||||
onSelectPlan(plan);
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || "智能编排失败");
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePlan = async (planId: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await deleteEditPlan(projectId, planId);
|
||||
setPlans((prev) => prev.filter((p) => p.id !== planId));
|
||||
message.success("删除成功");
|
||||
} catch (error) {
|
||||
message.error("删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
const getModeLabel = (mode?: EditingMode) => {
|
||||
if (!mode) return null;
|
||||
const option = EDITING_MODE_OPTIONS.find((o) => o.value === mode);
|
||||
return option ? `${option.icon} ${option.label}` : mode;
|
||||
};
|
||||
|
||||
const getModeColor = (mode?: EditingMode) => {
|
||||
if (!mode) return "#64748b";
|
||||
const option = EDITING_MODE_OPTIONS.find((o) => o.value === mode);
|
||||
return option?.color || "#64748b";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auto-arrange-panel">
|
||||
<div className="panel-header">
|
||||
<h4>🪄 智能编排</h4>
|
||||
<p>AI 自动分析素材并生成剪辑方案</p>
|
||||
</div>
|
||||
<div className="mode-selector-section">
|
||||
<label>选择剪辑模式:</label>
|
||||
<div className="mode-cards">
|
||||
{EDITING_MODE_OPTIONS.map((mode) => (
|
||||
<div
|
||||
key={mode.value}
|
||||
className={`mode-card ${selectedMode === mode.value ? "selected" : ""}`}
|
||||
onClick={() => setSelectedMode(mode.value)}
|
||||
style={{
|
||||
borderColor: selectedMode === mode.value ? mode.color : undefined,
|
||||
background: selectedMode === mode.value ? `${mode.color}10` : undefined,
|
||||
}}
|
||||
>
|
||||
<span className="mode-icon">{mode.icon}</span>
|
||||
<span className="mode-label">{mode.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="duration-selector">
|
||||
<label>目标时长:</label>
|
||||
<Select value={targetDuration} onChange={setTargetDuration} style={{ width: 120 }}>
|
||||
<Select.Option value={15}>15秒</Select.Option>
|
||||
<Select.Option value={30}>30秒</Select.Option>
|
||||
<Select.Option value={60}>60秒</Select.Option>
|
||||
<Select.Option value={90}>90秒</Select.Option>
|
||||
<Select.Option value={120}>120秒</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ThunderboltOutlined />}
|
||||
loading={generating}
|
||||
onClick={handleAutoGenerate}
|
||||
className="generate-btn"
|
||||
style={{ background: EDITING_MODE_OPTIONS.find((m) => m.value === selectedMode)?.color }}
|
||||
>
|
||||
智能编排生成
|
||||
</Button>
|
||||
<div className="plans-list">
|
||||
<h5>编排方案列表</h5>
|
||||
{loading ? (
|
||||
<Spin size="small" />
|
||||
) : plans.length === 0 ? (
|
||||
<Empty description="暂无编排方案" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
) : (
|
||||
plans.map((plan) => (
|
||||
<div key={plan.id} className={`plan-item ${selectedPlanId === plan.id ? "selected" : ""}`} onClick={() => onSelectPlan(plan)}>
|
||||
<div className="plan-info">
|
||||
<div className="plan-header">
|
||||
<span className="plan-summary">{plan.summary || "未命名方案"}</span>
|
||||
<Tag color={getModeColor(plan.editing_mode)} style={{ marginLeft: 8 }}>{getModeLabel(plan.editing_mode)}</Tag>
|
||||
</div>
|
||||
<div className="plan-meta">
|
||||
<span>📎 {plan.clips.length} 个片段</span>
|
||||
<span>⏱️ {plan.clips.reduce((sum, c) => sum + c.duration, 0).toFixed(1)}s</span>
|
||||
{plan.created_at && <span>{new Date(plan.created_at).toLocaleDateString()}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="plan-actions">
|
||||
<Tooltip title="使用此方案生成">
|
||||
<Button type="text" icon={<PlayCircleOutlined />} onClick={(e) => { e.stopPropagation(); onSelectPlan(plan); }} />
|
||||
</Tooltip>
|
||||
<Tooltip title="删除方案">
|
||||
<Button type="text" danger icon={<DeleteOutlined />} onClick={(e) => handleDeletePlan(plan.id, e)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ProjectGeneration: React.FC = () => {
|
||||
const [selectedTitle, setSelectedTitle] = useState<string>("");
|
||||
const [generateOpen, setGenerateOpen] = useState(false);
|
||||
const [editingMode, setEditingMode] = useState<EditingMode>("one-take");
|
||||
const [selectedPlan, setSelectedPlan] = useState<EditPlanItem | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const projectId = "demo-project-id";
|
||||
|
||||
const handleGenerate = async () => {
|
||||
await form.validateFields();
|
||||
try {
|
||||
form.getFieldsValue();
|
||||
message.success("成片生成任务已创建");
|
||||
setGenerateOpen(false);
|
||||
form.resetFields();
|
||||
setSelectedTitle("");
|
||||
setSelectedPlan(null);
|
||||
} catch (error: any) {
|
||||
message.error("创建生成任务失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlanSelect = (plan: EditPlanItem) => {
|
||||
setSelectedPlan(plan);
|
||||
setEditingMode(plan.editing_mode || "one-take");
|
||||
};
|
||||
|
||||
const getModeOption = (mode: EditingMode) => EDITING_MODE_OPTIONS.find((o) => o.value === mode);
|
||||
|
||||
return (
|
||||
<div className="xx-project-generation">
|
||||
<div className="xx-page-head">
|
||||
<div>
|
||||
<h2>视频剪辑</h2>
|
||||
<p>生成您的成片</p>
|
||||
</div>
|
||||
<button className="btn primary" onClick={() => setGenerateOpen(true)}>+ 开始生成</button>
|
||||
</div>
|
||||
|
||||
<div className="xx-card">
|
||||
<h3>生成计划</h3>
|
||||
<div className="xx-template-grid">
|
||||
{TITLE_OPTIONS.map((title, idx) => {
|
||||
const colors = [
|
||||
"linear-gradient(135deg,#4f46e5,#818cf8)",
|
||||
"linear-gradient(135deg,#f97316,#ef4444)",
|
||||
"linear-gradient(135deg,#10b981,#0284c7)",
|
||||
"linear-gradient(135deg,#64748b,#334155)",
|
||||
];
|
||||
return (
|
||||
<div key={title.id} className={`xx-choice ${selectedTitle === title.id ? "selected" : ""}`} onClick={() => setSelectedTitle(title.id)}>
|
||||
<div className="cover" style={{ background: colors[idx % 4] }}>📹</div>
|
||||
{selectedTitle === title.id && <div className="check">✓</div>}
|
||||
<b>{title.name}</b>
|
||||
<p>{title.content.substring(0, 50)}...</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xx-card">
|
||||
<AutoArrangePanel projectId={projectId} onSelectPlan={handlePlanSelect} selectedPlanId={selectedPlan?.id} />
|
||||
{selectedPlan && selectedPlan.clips.length > 0 && (
|
||||
<div className="timeline-section">
|
||||
<div className="section-header">
|
||||
<h4>📺 编排预览</h4>
|
||||
<Button type="primary" icon={<PlayCircleOutlined />} onClick={() => setGenerateOpen(true)}>使用此方案生成</Button>
|
||||
</div>
|
||||
<Timeline clips={selectedPlan.clips} totalDuration={selectedPlan.clips.reduce((sum, c) => sum + c.duration, 0)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={
|
||||
<div className="modal-title">
|
||||
<span>生成新成片</span>
|
||||
{editingMode && <Tag color={getModeOption(editingMode)?.color}>{getModeOption(editingMode)?.icon} {getModeOption(editingMode)?.label}</Tag>}
|
||||
</div>
|
||||
}
|
||||
open={generateOpen}
|
||||
okText="开始生成"
|
||||
cancelText="取消"
|
||||
onOk={handleGenerate}
|
||||
onCancel={() => setGenerateOpen(false)}
|
||||
width={600}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label="剪辑模式" required>
|
||||
<Radio.Group value={editingMode} onChange={(e) => setEditingMode(e.target.value)} className="editing-mode-group">
|
||||
{EDITING_MODE_OPTIONS.map((mode) => (
|
||||
<Radio.Button key={mode.value} value={mode.value} className="editing-mode-btn">
|
||||
<div className="mode-option">
|
||||
<span className="mode-emoji">{mode.icon}</span>
|
||||
<div className="mode-text">
|
||||
<span className="mode-name">{mode.label}</span>
|
||||
<span className="mode-desc">{mode.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Radio.Button>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item name="template_id" label="选择模板" rules={[{ required: true, message: "请选择模板" }]}>
|
||||
<Select placeholder="选择剪辑模板">
|
||||
<Select.Option value="basic">基础模板</Select.Option>
|
||||
<Select.Option value="product">产品介绍</Select.Option>
|
||||
<Select.Option value="lifestyle">生活方式</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="duration" label="视频时长" rules={[{ required: true, message: "请选择时长" }]}>
|
||||
<Select placeholder="选择时长">
|
||||
<Select.Option value="15">15秒</Select.Option>
|
||||
<Select.Option value="30">30秒</Select.Option>
|
||||
<Select.Option value="60">60秒</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
{selectedPlan && (
|
||||
<Form.Item>
|
||||
<div className="selected-plan-hint">
|
||||
<CheckCircleOutlined style={{ color: "#52c41a" }} />
|
||||
<span>已选择编排方案: <strong>{selectedPlan.summary || "未命名方案"}</strong> ({selectedPlan.clips.length} 个片段)</span>
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectGeneration;
|
||||
export const Component = ProjectGeneration;
|
||||
@@ -1,204 +0,0 @@
|
||||
/* V21 成片库页面 - 9:16 竖屏紧凑卡片 */
|
||||
|
||||
/* ========== 布局 ========== */
|
||||
.xx-project-results {
|
||||
padding: var(--space-xl);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ========== 竖屏卡片网格 ========== */
|
||||
.xx-vertical-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.xx-vertical-grid.compact {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.xx-vertical-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.xx-vertical-grid.compact {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.xx-vertical-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.xx-vertical-grid.compact {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 竖屏卡片 - V21 大圆角 ========== */
|
||||
.xx-vertical-card {
|
||||
border: 1px solid var(--border-color);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 14px;
|
||||
transition: all 0.25s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-vertical-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
/* ========== 竖屏缩略图 - 9:16 ========== */
|
||||
.xx-vertical-thumb {
|
||||
aspect-ratio: 9 / 16;
|
||||
border-radius: var(--radius-lg);
|
||||
background: linear-gradient(135deg, #4f46e5, #6366f1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: white;
|
||||
margin-bottom: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 缩略图渐变变体 */
|
||||
.xx-vertical-thumb.orange {
|
||||
background: linear-gradient(135deg, #f97316, #ef4444);
|
||||
}
|
||||
|
||||
.xx-vertical-thumb.gray {
|
||||
background: linear-gradient(135deg, #64748b, #334155);
|
||||
}
|
||||
|
||||
.xx-vertical-thumb.green {
|
||||
background: linear-gradient(135deg, #10b981, #0284c7);
|
||||
}
|
||||
|
||||
.xx-vertical-thumb.purple {
|
||||
background: linear-gradient(135deg, #8b5cf6, #a855f7);
|
||||
}
|
||||
|
||||
/* 视频/图片填充 */
|
||||
.xx-vertical-thumb video,
|
||||
.xx-vertical-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* 播放按钮 - 毛玻璃效果 */
|
||||
.mini-play {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
border: 2px solid rgba(255, 255, 255, 0.4);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
font-size: 20px;
|
||||
color: white;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mini-play:hover {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
/* 卡片文字 */
|
||||
.xx-vertical-card .row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.xx-vertical-card b {
|
||||
display: block;
|
||||
color: var(--text-primary);
|
||||
font-weight: 850;
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xx-vertical-card p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ========== 状态标签 ========== */
|
||||
.pill {
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
font-weight: 850;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill.ok {
|
||||
background: var(--success-soft);
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.pill.warn {
|
||||
background: var(--warning-soft);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.pill.bad {
|
||||
background: var(--error-soft);
|
||||
color: #be123c;
|
||||
}
|
||||
|
||||
/* ========== 操作按钮组 ========== */
|
||||
.assist-actions {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.assist-actions .btn {
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 14px;
|
||||
font-weight: 850;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.assist-actions .btn.primary {
|
||||
background: var(--gradient-primary);
|
||||
color: white;
|
||||
box-shadow: var(--shadow-primary);
|
||||
}
|
||||
|
||||
.assist-actions .btn.primary:hover {
|
||||
box-shadow: var(--shadow-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.assist-actions .btn.ghost {
|
||||
background: white;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.assist-actions .btn.ghost:hover {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* 成片库页面 - V21 UI
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import './ProjectResults.css';
|
||||
|
||||
const ProjectResults: React.FC = () => {
|
||||
const [videos] = useState<any[]>([]);
|
||||
|
||||
const colors = ['orange', 'indigo', 'green', 'purple'];
|
||||
|
||||
return (
|
||||
<div className="xx-project-results">
|
||||
<div className="xx-page-head">
|
||||
<h2>成片库</h2>
|
||||
<p>查看和管理已生成的视频成片</p>
|
||||
</div>
|
||||
|
||||
{videos.length === 0 ? (
|
||||
<div className="xx-empty-state">
|
||||
<div style={{ fontSize: '64px', marginBottom: '16px' }}>🎬</div>
|
||||
<p>还没有生成的成片</p>
|
||||
<p style={{ fontSize: '14px', color: '#94a3b8' }}>
|
||||
前往剪辑计划开始生成您的第一个视频
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="xx-vertical-grid">
|
||||
{videos.map((video, idx) => (
|
||||
<div key={video.id} className="xx-vertical-card">
|
||||
<div className={`xx-vertical-thumb ${colors[idx % 4]}`}>
|
||||
<div className="xx-play-btn">▶</div>
|
||||
</div>
|
||||
<span className={`xx-status-pill ${video.status || 'ready'}`}>
|
||||
{video.status === 'processing' ? '⏳ 生成中' : '✓ 就绪'}
|
||||
</span>
|
||||
<b>{video.name}</b>
|
||||
<div className="meta">
|
||||
<span className="duration">{video.duration || 0}s</span>
|
||||
<span className="size">{(video.file_size / 1024 / 1024).toFixed(1)} MB</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectResults;
|
||||
export const Component = ProjectResults;
|
||||
@@ -1,53 +0,0 @@
|
||||
/* V21 项目任务中心页面 */
|
||||
.xx-project-tasks {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* 任务列表项 */
|
||||
.xx-task-item {
|
||||
padding: 20px 0 !important;
|
||||
border-bottom: 1px solid #f1f5f9 !important;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.xx-task-item:last-child {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.xx-task-item:hover {
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
/* 任务标题行 */
|
||||
.xx-task-title {
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.xx-task-type {
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* 任务描述 */
|
||||
.xx-task-desc {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* V21 标签 */
|
||||
.xx-tag {
|
||||
padding: 4px 12px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.xx-tag-indigo {
|
||||
background: #eef2ff;
|
||||
color: #4f46e5;
|
||||
border: 1px solid #c7d2fe;
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
/**
|
||||
* 项目任务中心 - V21 UI
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Alert, Button, List, Progress, Space, Tag, Typography } from 'antd';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { getProjectTasks, retryProjectTask } from '@/api/tasks';
|
||||
import '@/components/business/business.css';
|
||||
import './ProjectTasks.css';
|
||||
|
||||
const statusMap: Record<string, { label: string; color: string }> = {
|
||||
pending: { label: '排队中', color: 'default' },
|
||||
processing: { label: '处理中', color: 'processing' },
|
||||
running: { label: '运行中', color: 'processing' },
|
||||
completed: { label: '已完成', color: 'success' },
|
||||
failed: { label: '失败', color: 'error' },
|
||||
cancelled: { label: '已取消', color: 'default' },
|
||||
};
|
||||
|
||||
const taskTypeLabels: Record<string, string> = {
|
||||
ingest: '素材导入',
|
||||
generation: '视频生成',
|
||||
};
|
||||
|
||||
const ProjectTasks: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const projectId = id || '';
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const tasksQuery = useQuery({
|
||||
queryKey: ['project-tasks', projectId],
|
||||
queryFn: () => getProjectTasks(projectId),
|
||||
enabled: !!projectId,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: ({ taskType, sourceId }: { taskType: string; sourceId: string }) =>
|
||||
retryProjectTask(taskType, sourceId),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['project-tasks', projectId] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="xx-project-tasks">
|
||||
<div className="xx-page-head">
|
||||
<div>
|
||||
<h2>任务中心</h2>
|
||||
<p>查看和管理项目中的所有任务</p>
|
||||
</div>
|
||||
<Button className="xx-ghost-btn" onClick={() => tasksQuery.refetch()}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="xx-card">
|
||||
{tasksQuery.isError && (
|
||||
<Alert style={{ marginBottom: 16 }} type="error" showIcon message="任务列表加载失败" />
|
||||
)}
|
||||
{tasksQuery.data?.some((task) => task.retryable) && (
|
||||
<Alert
|
||||
style={{ marginBottom: 16 }}
|
||||
type="warning"
|
||||
showIcon
|
||||
message="部分任务可重试"
|
||||
description={"失败任务可通过「重试」重新排队;系统会保留原始失败原因方便排查。"}
|
||||
/>
|
||||
)}
|
||||
<List
|
||||
loading={tasksQuery.isLoading}
|
||||
dataSource={tasksQuery.data || []}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<div className="xx-empty-state">
|
||||
<div className="xx-empty-state-icon">📋</div>
|
||||
<p>暂无任务,上传素材或发起生成后会出现在这里</p>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
renderItem={(task) => {
|
||||
const status = statusMap[task.status] || { label: task.status, color: 'default' };
|
||||
return (
|
||||
<List.Item className="xx-task-item">
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<Space wrap className="xx-task-title">
|
||||
<span className="xx-task-type">{taskTypeLabels[task.task_type] || task.task_type}</span>
|
||||
<Tag color={status.color} className="xx-tag">{status.label}</Tag>
|
||||
<Tag className="xx-tag xx-tag-indigo">{task.current_step}</Tag>
|
||||
{task.retryable && (
|
||||
<Button
|
||||
size="small"
|
||||
className="xx-ghost-btn"
|
||||
loading={retryMutation.isPending}
|
||||
onClick={() =>
|
||||
retryMutation.mutate({ taskType: task.task_type, sourceId: task.source_id })
|
||||
}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<Space direction="vertical" style={{ width: '100%' }} className="xx-task-desc">
|
||||
<Progress
|
||||
percent={Math.round(task.progress || 0)}
|
||||
size="small"
|
||||
status={task.status === 'failed' ? 'exception' : undefined}
|
||||
strokeColor={task.status === 'failed' ? '#ef4444' : '#4f46e5'}
|
||||
/>
|
||||
{task.user_message ? (
|
||||
<Typography.Text type="danger">{task.user_message}</Typography.Text>
|
||||
) : null}
|
||||
<Typography.Text type="secondary" copyable style={{ fontSize: 12 }}>
|
||||
任务 ID:{task.source_id}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Component = ProjectTasks;
|
||||
export default ProjectTasks;
|
||||
@@ -1,178 +0,0 @@
|
||||
/* V21 标题库页面 */
|
||||
.xx-project-titles {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 页面头部 */
|
||||
.xx-page-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 18px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.xx-page-head h2 {
|
||||
font-size: 28px;
|
||||
font-weight: 850;
|
||||
color: var(--slate, #0f172a);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.xx-page-head p {
|
||||
font-size: 14px;
|
||||
color: var(--muted, #64748b);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* V21 按钮 */
|
||||
.xx-primary-btn {
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5) !important;
|
||||
color: white !important;
|
||||
border: none !important;
|
||||
border-radius: 14px !important;
|
||||
padding: 12px 24px !important;
|
||||
font-weight: 850 !important;
|
||||
box-shadow: 0 14px 26px rgba(79,70,229,0.22) !important;
|
||||
transition: all 0.2s !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xx-primary-btn:hover {
|
||||
box-shadow: 0 18px 34px rgba(79,70,229,0.28) !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* V21 卡片 */
|
||||
.xx-card {
|
||||
background: rgba(255,255,255,0.94);
|
||||
border: 1px solid rgba(226,232,240,0.95);
|
||||
border-radius: 28px;
|
||||
box-shadow: 0 24px 70px rgba(15,23,42,0.09);
|
||||
padding: 28px;
|
||||
margin-bottom: 24px;
|
||||
transition: all 0.25s;
|
||||
}
|
||||
|
||||
.xx-card:hover {
|
||||
box-shadow: 0 26px 64px rgba(15,23,42,0.14);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 搜索栏 - V21 风格 */
|
||||
.xx-search-bar {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.xx-search-input {
|
||||
width: 100%;
|
||||
padding: 14px 20px;
|
||||
border: 2px solid var(--line, #e2e8f0);
|
||||
border-radius: 16px;
|
||||
font-size: 15px;
|
||||
background: white;
|
||||
transition: all 0.2s;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xx-search-input:focus {
|
||||
border-color: var(--indigo, #4f46e5);
|
||||
box-shadow: 0 0 0 4px rgba(79,70,229,0.1);
|
||||
}
|
||||
|
||||
.xx-search-input::placeholder {
|
||||
color: var(--muted, #64748b);
|
||||
}
|
||||
|
||||
/* 标题列表网格 */
|
||||
.xx-title-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* V21 标题卡片 */
|
||||
.xx-title-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--line, #e2e8f0);
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
transition: all 0.25s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xx-title-row:hover {
|
||||
border-color: var(--indigo, #4f46e5);
|
||||
box-shadow: 0 12px 36px rgba(79,70,229,0.1);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.xx-title-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.xx-title-row b {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
font-weight: 850;
|
||||
color: var(--slate, #0f172a);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.xx-title-row p {
|
||||
font-size: 14px;
|
||||
color: var(--muted, #64748b);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.xx-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.xx-action-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg, #f8fafc);
|
||||
border: 1px solid var(--line, #e2e8f0);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 16px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.xx-action-btn:hover {
|
||||
background: var(--indigo-soft, #eef2ff);
|
||||
border-color: var(--indigo, #4f46e5);
|
||||
}
|
||||
|
||||
.xx-action-btn.copy:hover {
|
||||
color: var(--indigo, #4f46e5);
|
||||
}
|
||||
|
||||
.xx-action-btn.delete:hover {
|
||||
color: var(--red, #ef4444);
|
||||
background: #fee2e2;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.xx-empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 24px;
|
||||
color: var(--muted, #64748b);
|
||||
}
|
||||
|
||||
.xx-empty-state p {
|
||||
font-size: 16px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* 标题库页面 - V21 UI
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { Input, Modal, Form, message } from 'antd';
|
||||
import './ProjectTitles.css';
|
||||
|
||||
const ProjectTitles: React.FC = () => {
|
||||
const [titles] = useState<any[]>([]);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const handleCreate = async () => {
|
||||
await form.validateFields();
|
||||
try {
|
||||
message.success('创建标题成功');
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
} catch (error: any) {
|
||||
message.error('创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = titles.filter((t: any) =>
|
||||
t.name.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
t.content.toLowerCase().includes(searchText.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="xx-project-titles">
|
||||
<div className="xx-page-head">
|
||||
<div>
|
||||
<h2>标题库</h2>
|
||||
<p>管理和创作吸引眼球的视频标题</p>
|
||||
</div>
|
||||
<button className="xx-primary-btn" onClick={() => setCreateOpen(true)}>
|
||||
+ 创建标题
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="xx-card">
|
||||
{/* 搜索栏 */}
|
||||
<div className="xx-search-bar">
|
||||
<input
|
||||
type="text"
|
||||
className="xx-search-input"
|
||||
placeholder="搜索标题..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="xx-empty-state">
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>✍️</div>
|
||||
<p>{searchText ? '没有找到匹配的标题' : '还没有创建标题'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="xx-title-grid">
|
||||
{filtered.map((title: any) => (
|
||||
<div key={title.id} className="xx-title-row">
|
||||
<div className="xx-title-content">
|
||||
<b>{title.name}</b>
|
||||
<p>{title.content}</p>
|
||||
</div>
|
||||
<div className="xx-actions">
|
||||
<button className="xx-action-btn copy" title="复制">📋</button>
|
||||
<button className="xx-action-btn delete" title="删除">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title="创建标题"
|
||||
open={createOpen}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
onOk={handleCreate}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="标题名称"
|
||||
rules={[{ required: true, message: '请输入标题名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入标题名称" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="content"
|
||||
label="标题内容"
|
||||
rules={[{ required: true, message: '请输入标题内容' }]}
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder="请输入标题内容"
|
||||
rows={4}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectTitles;
|
||||
export const Component = ProjectTitles;
|
||||
@@ -1,193 +0,0 @@
|
||||
/* V21 配音库页面 - 波形图样式 */
|
||||
|
||||
/* ========== 布局 ========== */
|
||||
.xx-project-voices {
|
||||
padding: var(--space-xl);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ========== 配音网格 ========== */
|
||||
.xx-voice-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.xx-voice-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.xx-voice-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.xx-voice-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 配音卡片 - V21 大圆角 ========== */
|
||||
.xx-voice {
|
||||
text-align: center;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
padding: 18px 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xx-voice:hover {
|
||||
border-color: var(--primary-color);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.xx-voice.selected {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.14);
|
||||
}
|
||||
|
||||
/* 选中标记 */
|
||||
.xx-voice.selected::after {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
right: -8px;
|
||||
top: -8px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--gradient-primary);
|
||||
color: white;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 900;
|
||||
font-size: 14px;
|
||||
box-shadow: var(--shadow-primary);
|
||||
}
|
||||
|
||||
/* 音色图标 - 渐变圆形 */
|
||||
.voice-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
margin: 0 auto 12px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: white;
|
||||
font-size: 26px;
|
||||
background: var(--gradient-primary);
|
||||
box-shadow: var(--shadow-primary);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.xx-voice:hover .voice-icon {
|
||||
transform: scale(1.08);
|
||||
box-shadow: var(--shadow-hover);
|
||||
}
|
||||
|
||||
/* ========== 波形图样式 ========== */
|
||||
.waveform {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
height: 32px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.waveform-bar {
|
||||
width: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--gradient-primary);
|
||||
opacity: 0.7;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.xx-voice:hover .waveform-bar {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.waveform-bar:nth-child(1) { height: 40%; }
|
||||
.waveform-bar:nth-child(2) { height: 70%; }
|
||||
.waveform-bar:nth-child(3) { height: 100%; }
|
||||
.waveform-bar:nth-child(4) { height: 60%; }
|
||||
.waveform-bar:nth-child(5) { height: 80%; }
|
||||
.waveform-bar:nth-child(6) { height: 50%; }
|
||||
.waveform-bar:nth-child(7) { height: 90%; }
|
||||
.waveform-bar:nth-child(8) { height: 65%; }
|
||||
.waveform-bar:nth-child(9) { height: 45%; }
|
||||
.waveform-bar:nth-child(10) { height: 75%; }
|
||||
|
||||
/* 播放中波形动画 */
|
||||
.waveform.playing .waveform-bar {
|
||||
animation: wave 0.5s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.waveform-bar:nth-child(1) { animation-delay: 0s; }
|
||||
.waveform-bar:nth-child(2) { animation-delay: 0.05s; }
|
||||
.waveform-bar:nth-child(3) { animation-delay: 0.1s; }
|
||||
.waveform-bar:nth-child(4) { animation-delay: 0.15s; }
|
||||
.waveform-bar:nth-child(5) { animation-delay: 0.2s; }
|
||||
.waveform-bar:nth-child(6) { animation-delay: 0.25s; }
|
||||
.waveform-bar:nth-child(7) { animation-delay: 0.3s; }
|
||||
.waveform-bar:nth-child(8) { animation-delay: 0.35s; }
|
||||
.waveform-bar:nth-child(9) { animation-delay: 0.4s; }
|
||||
.waveform-bar:nth-child(10) { animation-delay: 0.45s; }
|
||||
|
||||
@keyframes wave {
|
||||
from { transform: scaleY(0.6); }
|
||||
to { transform: scaleY(1.2); }
|
||||
}
|
||||
|
||||
/* 文字样式 */
|
||||
.xx-voice div:not(.voice-icon):not(.waveform) {
|
||||
font-weight: 850;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.xx-voice small {
|
||||
display: block;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ========== 使用记录列表 ========== */
|
||||
.xx-voice-usage-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.xx-voice-usage-row {
|
||||
border: 1px solid var(--border-color);
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.xx-voice-usage-row:hover {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.xx-voice-usage-row b {
|
||||
display: block;
|
||||
color: var(--text-primary);
|
||||
font-weight: 850;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.xx-voice-usage-row p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
/**
|
||||
* 配音库页面 - V21 UI
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import './ProjectVoices.css';
|
||||
|
||||
const VOICE_OPTIONS = [
|
||||
{ id: 'female-youth', name: '元气少女', desc: '年轻女性,活泼开朗', tag: '热门' },
|
||||
{ id: 'female-mature', name: '知性姐姐', desc: '成熟女性,温柔亲切', tag: '推荐' },
|
||||
{ id: 'male-young', name: '阳光男孩', desc: '年轻男性,清新自然', tag: '热门' },
|
||||
{ id: 'male-mature', name: '磁性大叔', desc: '成熟男性,深沉有力', tag: '' },
|
||||
{ id: 'neutral-youth', name: '活力童声', desc: '童声配音,活泼可爱', tag: '新品' },
|
||||
{ id: 'neutral-mature', name: '专业播音', desc: '标准播音腔,端庄大气', tag: '' },
|
||||
];
|
||||
|
||||
const ProjectVoices: React.FC = () => {
|
||||
const [selectedVoice, setSelectedVoice] = useState<string>('');
|
||||
return (
|
||||
<div className="xx-project-voices">
|
||||
<div className="xx-page-head">
|
||||
<h2>配音库</h2>
|
||||
<p>选择适合您内容的配音音色</p>
|
||||
</div>
|
||||
|
||||
<div className="xx-card">
|
||||
<h3>🎙️ 选择音色</h3>
|
||||
<div className="xx-voice-grid">
|
||||
{VOICE_OPTIONS.map((voice) => (
|
||||
<div
|
||||
key={voice.id}
|
||||
className={`xx-voice-card ${selectedVoice === voice.id ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedVoice(voice.id)}
|
||||
>
|
||||
<div className="xx-voice-waveform">
|
||||
<span>🔊</span>
|
||||
</div>
|
||||
<div className="xx-voice-info">
|
||||
<h4>{voice.name}</h4>
|
||||
<p>{voice.desc}</p>
|
||||
{voice.tag && <span className="xx-voice-tag">{voice.tag}</span>}
|
||||
</div>
|
||||
<button className="xx-voice-play">▶</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectVoices;
|
||||
export const Component = ProjectVoices;
|
||||
@@ -1,98 +0,0 @@
|
||||
/* V21 工作空间列表 */
|
||||
.xx-workspace-list {
|
||||
}
|
||||
|
||||
.xx-page-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 18px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.xx-page-head h2 {
|
||||
font-size: 28px;
|
||||
margin: 0 0 6px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.xx-page-head p {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.xx-grid4 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.xx-grid4 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.xx-grid4 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.xx-grid4 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.xx-workspace-card {
|
||||
border: 1px solid #e2e8f0;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border-radius: 24px;
|
||||
padding: 18px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.xx-workspace-card:hover {
|
||||
border-color: #4f46e5;
|
||||
box-shadow: 0 12px 36px rgba(79, 70, 229, 0.12);
|
||||
}
|
||||
|
||||
.xx-workspace-name {
|
||||
font-size: 16px;
|
||||
font-weight: 850;
|
||||
color: #0f172a;
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xx-workspace-plan {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
background: #eef2ff;
|
||||
color: #4338ca;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.xx-workspace-date {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.xx-empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 24px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.xx-empty-state p {
|
||||
font-size: 16px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* 工作空间列表页面 - V21 严格对标
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Button, Modal, Form, Input, message } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useCreateWorkspace, useWorkspaces } from '@/hooks/useWorkspace';
|
||||
import './WorkspaceList.css';
|
||||
|
||||
const WorkspaceList: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { data: workspaces, isLoading } = useWorkspaces();
|
||||
const createWorkspaceMutation = useCreateWorkspace();
|
||||
const [form] = Form.useForm<{ name: string }>();
|
||||
const [createOpen, setCreateOpen] = React.useState(false);
|
||||
|
||||
const openCreateModal = () => setCreateOpen(true);
|
||||
|
||||
const handleCreateWorkspace = async () => {
|
||||
const values = await form.validateFields();
|
||||
try {
|
||||
const workspace = await createWorkspaceMutation.mutateAsync({ name: values.name });
|
||||
message.success('工作空间创建成功');
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
navigate(`/workspaces/${workspace.id}`);
|
||||
} catch (createError: any) {
|
||||
message.error(createError.response?.data?.detail || '工作空间创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="xx-workspace-list">
|
||||
<div className="xx-page-head">
|
||||
<div>
|
||||
<h2>我的工作空间</h2>
|
||||
<p>创建和管理您的视频剪辑项目</p>
|
||||
</div>
|
||||
<Button type="primary" size="large" onClick={openCreateModal}>
|
||||
+ 创建工作空间
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="xx-grid4">
|
||||
{workspaces?.map((workspace) => (
|
||||
<div
|
||||
key={workspace.id}
|
||||
className="xx-workspace-card"
|
||||
onClick={() => navigate(`/workspaces/${workspace.id}`)}
|
||||
>
|
||||
<div className="xx-workspace-name">{workspace.name}</div>
|
||||
<div className="xx-workspace-plan">
|
||||
{workspace.subscription_plan === 'enterprise' ? '企业版' : workspace.subscription_plan === 'pro' ? 'Pro' : 'Free'}
|
||||
</div>
|
||||
<div className="xx-workspace-date">
|
||||
{workspace.created_at ? new Date(workspace.created_at).toLocaleDateString() : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!isLoading && workspaces?.length === 0 && (
|
||||
<div className="xx-empty-state">
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📦</div>
|
||||
<p>还没有工作空间</p>
|
||||
<Button type="primary" onClick={openCreateModal}>
|
||||
创建第一个工作空间
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title="创建工作空间"
|
||||
open={createOpen}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
confirmLoading={createWorkspaceMutation.isPending}
|
||||
onOk={handleCreateWorkspace}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="工作空间名称"
|
||||
rules={[
|
||||
{ required: true, message: '请输入工作空间名称' },
|
||||
{ max: 50, message: '最多 50 个字符' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="例如:我的剪辑工作室" autoFocus />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceList;
|
||||
export const Component = WorkspaceList;
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* 更新路由,添加 Admin 和 Billing 页面
|
||||
* Phase 1 路由重构
|
||||
* 扁平化路由:去掉 Project 层级,所有资源直接归属用户
|
||||
*/
|
||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
||||
import React from 'react';
|
||||
@@ -10,19 +11,19 @@ import ForgotPassword from '@/pages/auth/ForgotPassword';
|
||||
import ResetPassword from '@/pages/auth/ResetPassword';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
// 受保护的路由组件
|
||||
/** 受保护的路由组件 */
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
const hasAccessToken = Boolean(localStorage.getItem('access_token'));
|
||||
|
||||
|
||||
if (!isAuthenticated || !hasAccessToken) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
// 路由配置
|
||||
/** 路由配置 */
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/login',
|
||||
@@ -50,77 +51,81 @@ export const router = createBrowserRouter([
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <Navigate to="/projects" replace />,
|
||||
element: <Navigate to="/dashboard" replace />,
|
||||
},
|
||||
{
|
||||
path: 'projects',
|
||||
lazy: () => import('@/pages/workspace/WorkspaceList'),
|
||||
path: 'dashboard',
|
||||
lazy: () => import('@/pages/dashboard/Dashboard').then(m => ({ Component: m.default })),
|
||||
},
|
||||
{
|
||||
path: 'projects/:id/assets',
|
||||
lazy: () => import('@/pages/workspace/ProjectAssets'),
|
||||
path: 'assets',
|
||||
lazy: () => import('@/pages/assets/AssetLibrary').then(m => ({ Component: m.default })),
|
||||
},
|
||||
{
|
||||
path: 'projects/:id/titles',
|
||||
lazy: () => import('@/pages/workspace/ProjectTitles'),
|
||||
path: 'titles',
|
||||
lazy: () => import('@/pages/titles/TitleLibrary').then(m => ({ Component: m.default })),
|
||||
},
|
||||
{
|
||||
path: 'projects/:id/voices',
|
||||
lazy: () => import('@/pages/workspace/ProjectVoices'),
|
||||
path: 'voices',
|
||||
lazy: () => import('@/pages/voices/VoiceLibrary').then(m => ({ Component: m.default })),
|
||||
},
|
||||
{
|
||||
path: 'projects/:id/generation',
|
||||
lazy: () => import('@/pages/workspace/ProjectGeneration'),
|
||||
path: 'templates',
|
||||
lazy: () => import('@/pages/templates/TemplateLibrary').then(m => ({ Component: m.default })),
|
||||
},
|
||||
{
|
||||
path: 'projects/:id/tasks',
|
||||
lazy: () => import('@/pages/workspace/ProjectTasks'),
|
||||
path: 'generate',
|
||||
lazy: () => import('@/pages/generate/GeneratePage').then(m => ({ Component: m.default })),
|
||||
},
|
||||
{
|
||||
path: 'projects/:id/results',
|
||||
lazy: () => import('@/pages/workspace/ProjectResults'),
|
||||
path: 'history',
|
||||
lazy: () => import('@/pages/history/TaskHistory').then(m => ({ Component: m.default })),
|
||||
},
|
||||
{
|
||||
path: 'products',
|
||||
lazy: () => import('@/pages/products/ProductLibrary').then(m => ({ Component: m.default })),
|
||||
},
|
||||
{
|
||||
path: 'subscription',
|
||||
lazy: () => import('@/pages/subscription/Plans'),
|
||||
lazy: () => import('@/pages/subscription/Plans').then(m => ({ Component: m.default })),
|
||||
},
|
||||
{
|
||||
path: 'subscription/upgrade',
|
||||
lazy: () => import('@/pages/subscription/UpgradeSubscription'),
|
||||
lazy: () => import('@/pages/subscription/UpgradeSubscription').then(m => ({ Component: m.default })),
|
||||
},
|
||||
{
|
||||
path: 'subscription/billing',
|
||||
lazy: () => import('@/pages/subscription/Billing'),
|
||||
lazy: () => import('@/pages/subscription/Billing').then(m => ({ Component: m.default })),
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
lazy: () => import('@/pages/profile/Settings').then(m => ({ Component: m.default })),
|
||||
},
|
||||
{
|
||||
path: 'admin',
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
lazy: () => import('@/pages/admin/AdminComingSoon'),
|
||||
lazy: () => import('@/pages/admin/AdminComingSoon').then(m => ({ Component: m.default })),
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
lazy: () => import('@/pages/admin/AdminComingSoon'),
|
||||
lazy: () => import('@/pages/admin/AdminComingSoon').then(m => ({ Component: m.default })),
|
||||
},
|
||||
{
|
||||
path: 'analytics',
|
||||
lazy: () => import('@/pages/admin/AdminComingSoon'),
|
||||
lazy: () => import('@/pages/admin/AdminComingSoon').then(m => ({ Component: m.default })),
|
||||
},
|
||||
{
|
||||
path: 'monitor',
|
||||
lazy: () => import('@/pages/admin/AdminComingSoon'),
|
||||
lazy: () => import('@/pages/admin/AdminComingSoon').then(m => ({ Component: m.default })),
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
lazy: () => import('@/pages/admin/AdminComingSoon'),
|
||||
lazy: () => import('@/pages/admin/AdminComingSoon').then(m => ({ Component: m.default })),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
lazy: () => import('@/pages/profile/Settings'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user