feat: Phase 1 - 前端重构(新路由/新页面/手机端适配/清理旧页面) #73

Merged
xiaoxia merged 1 commits from feat/phase1-frontend-restructure into develop 2026-06-28 14:50:17 +08:00
35 changed files with 2881 additions and 2493 deletions
+65 -25
View File
@@ -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;
};
+43
View File
@@ -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;
};
+46 -46
View File
@@ -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}`);
};
+50
View File
@@ -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;
};
+13 -7
View File
@@ -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;
};
+44
View File
@@ -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;
};
+59
View File
@@ -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;
};
+65
View File
@@ -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;
};
-132
View File
@@ -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('主动退出工作空间暂未开放');
};
+107 -3
View File
@@ -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;
}
}
+109 -50
View File
@@ -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>
);
};
-66
View File
@@ -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 };
};
+347
View File
@@ -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;
+218
View File
@@ -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;
+189
View File
@@ -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;
+298
View File
@@ -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="每行一个标题,例如:&#10;今天天气真好&#10;分享我的日常&#10;美食推荐"
value={importText}
onChange={(e) => setImportText(e.target.value)}
rows={10}
/>
</Modal>
</div>
);
};
export default TitleLibrary;
+362
View File
@@ -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> MP4MOVAVI 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;
+37 -32
View File
@@ -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'),
},
],
},
{