From 1d581731785283c3bf430adc95b4258b1a3d9e7d Mon Sep 17 00:00:00 2001 From: Audit Bot Date: Sun, 28 Jun 2026 14:46:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=201=20-=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=88=E6=96=B0=E8=B7=AF=E7=94=B1/?= =?UTF-8?q?=E6=96=B0=E9=A1=B5=E9=9D=A2/=E6=89=8B=E6=9C=BA=E7=AB=AF?= =?UTF-8?q?=E9=80=82=E9=85=8D/=E6=B8=85=E7=90=86=E6=97=A7=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增扁平化路由:/dashboard, /assets, /titles, /voices, /templates, /generate, /history, /products, /subscription, /profile - 新建 8 个功能页面,全部使用新 API(无 project_id) - 新建 5 个 API 模块:assets, titles, voices, templates, products, dashboard - Header 导航改造,支持手机端响应式(底部导航 + 抽屉菜单) - 删除旧 workspace 页面和 API(ProjectAssets, ProjectGeneration 等) - 修复路由懒加载类型(.then(m => ({ Component: m.default }))) - 修复 retryTask API 签名和 batchImportTitles 类型 - 全部页面中文注释,TypeScript 零错误 --- apps/web/src/api/assets.ts | 90 ++-- apps/web/src/api/dashboard.ts | 43 ++ apps/web/src/api/editPlans.ts | 92 ++-- apps/web/src/api/products.ts | 50 +++ apps/web/src/api/tasks.ts | 20 +- apps/web/src/api/templates.ts | 44 ++ apps/web/src/api/titles.ts | 59 +++ apps/web/src/api/voices.ts | 65 +++ apps/web/src/api/workspace.ts | 132 ------ apps/web/src/components/layout/Header.css | 110 ++++- apps/web/src/components/layout/Header.tsx | 159 ++++--- apps/web/src/hooks/useWorkspace.ts | 66 --- apps/web/src/pages/assets/AssetLibrary.tsx | 347 ++++++++++++++ apps/web/src/pages/dashboard/Dashboard.tsx | 218 +++++++++ apps/web/src/pages/generate/GeneratePage.tsx | 320 +++++++++++++ apps/web/src/pages/history/TaskHistory.tsx | 189 ++++++++ .../web/src/pages/products/ProductLibrary.tsx | 275 ++++++++++++ .../src/pages/templates/TemplateLibrary.tsx | 234 ++++++++++ apps/web/src/pages/titles/TitleLibrary.tsx | 298 ++++++++++++ apps/web/src/pages/voices/VoiceLibrary.tsx | 362 +++++++++++++++ .../web/src/pages/workspace/ProjectAssets.css | 270 ----------- .../web/src/pages/workspace/ProjectAssets.tsx | 93 ---- .../src/pages/workspace/ProjectGeneration.css | 177 -------- .../src/pages/workspace/ProjectGeneration.tsx | 423 ------------------ .../src/pages/workspace/ProjectResults.css | 204 --------- .../src/pages/workspace/ProjectResults.tsx | 51 --- apps/web/src/pages/workspace/ProjectTasks.css | 53 --- apps/web/src/pages/workspace/ProjectTasks.tsx | 131 ------ .../web/src/pages/workspace/ProjectTitles.css | 178 -------- .../web/src/pages/workspace/ProjectTitles.tsx | 110 ----- .../web/src/pages/workspace/ProjectVoices.css | 193 -------- .../web/src/pages/workspace/ProjectVoices.tsx | 52 --- .../web/src/pages/workspace/WorkspaceList.css | 98 ---- .../web/src/pages/workspace/WorkspaceList.tsx | 99 ---- apps/web/src/router/index.tsx | 69 +-- 35 files changed, 2881 insertions(+), 2493 deletions(-) create mode 100644 apps/web/src/api/dashboard.ts create mode 100644 apps/web/src/api/products.ts create mode 100644 apps/web/src/api/templates.ts create mode 100644 apps/web/src/api/titles.ts create mode 100644 apps/web/src/api/voices.ts delete mode 100644 apps/web/src/api/workspace.ts delete mode 100644 apps/web/src/hooks/useWorkspace.ts create mode 100644 apps/web/src/pages/assets/AssetLibrary.tsx create mode 100644 apps/web/src/pages/dashboard/Dashboard.tsx create mode 100644 apps/web/src/pages/generate/GeneratePage.tsx create mode 100644 apps/web/src/pages/history/TaskHistory.tsx create mode 100644 apps/web/src/pages/products/ProductLibrary.tsx create mode 100644 apps/web/src/pages/templates/TemplateLibrary.tsx create mode 100644 apps/web/src/pages/titles/TitleLibrary.tsx create mode 100644 apps/web/src/pages/voices/VoiceLibrary.tsx delete mode 100644 apps/web/src/pages/workspace/ProjectAssets.css delete mode 100644 apps/web/src/pages/workspace/ProjectAssets.tsx delete mode 100644 apps/web/src/pages/workspace/ProjectGeneration.css delete mode 100644 apps/web/src/pages/workspace/ProjectGeneration.tsx delete mode 100644 apps/web/src/pages/workspace/ProjectResults.css delete mode 100644 apps/web/src/pages/workspace/ProjectResults.tsx delete mode 100644 apps/web/src/pages/workspace/ProjectTasks.css delete mode 100644 apps/web/src/pages/workspace/ProjectTasks.tsx delete mode 100644 apps/web/src/pages/workspace/ProjectTitles.css delete mode 100644 apps/web/src/pages/workspace/ProjectTitles.tsx delete mode 100644 apps/web/src/pages/workspace/ProjectVoices.css delete mode 100644 apps/web/src/pages/workspace/ProjectVoices.tsx delete mode 100644 apps/web/src/pages/workspace/WorkspaceList.css delete mode 100644 apps/web/src/pages/workspace/WorkspaceList.tsx diff --git a/apps/web/src/api/assets.ts b/apps/web/src/api/assets.ts index 3fef14bf4..56a20468d 100644 --- a/apps/web/src/api/assets.ts +++ b/apps/web/src/api/assets.ts @@ -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 => { - const response = await apiClient.get(`/projects/${projectId}/asset-diagnosis`); +// ─── 素材诊断 ────────────────────────────────────────────── + +/** 获取当前用户的素材诊断信息 */ +export const getAssetDiagnosis = async (): Promise => { + const response = await apiClient.get('/asset-diagnosis'); return response.data; }; -export const getAssetLibraries = async (projectId: string): Promise => { - const response = await apiClient.get('/asset-libraries', { - params: { project_id: projectId }, - }); - return response.data.items; +// ─── 素材库 ──────────────────────────────────────────────── + +/** 获取当前用户的所有素材库 */ +export const getAssetLibraries = async (): Promise => { + 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 => { @@ -85,13 +102,22 @@ export const createAssetLibrary = async (data: { return response.data; }; +/** 删除素材库 */ +export const deleteAssetLibrary = async (libraryId: string): Promise => { + await apiClient.delete(`/asset-libraries/${libraryId}`); +}; + +// ─── 素材 ────────────────────────────────────────────────── + +/** 获取素材库下的所有素材 */ export const getAssets = async (libraryId: string): Promise => { 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 => { + 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 => { const response = await apiClient.get(`/ingest-jobs/${jobId}`); return response.data; }; +/** 提交素材分类任务 */ export const submitClassificationJob = async (data: { - project_id: string; asset_id: string; }): Promise => { const response = await apiClient.post('/classification-jobs', data); return response.data; }; -export const getClassificationJob = async (jobId: string): Promise => { +/** 查询分类任务状态 */ +export const getClassificationJob = async ( + jobId: string +): Promise => { const response = await apiClient.get(`/classification-jobs/${jobId}`); return response.data; }; diff --git a/apps/web/src/api/dashboard.ts b/apps/web/src/api/dashboard.ts new file mode 100644 index 000000000..4fe3f6e6f --- /dev/null +++ b/apps/web/src/api/dashboard.ts @@ -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 => { + const response = await apiClient.get('/dashboard/overview'); + return response.data; + }; diff --git a/apps/web/src/api/editPlans.ts b/apps/web/src/api/editPlans.ts index c1ee65dda..50f98674d 100644 --- a/apps/web/src/api/editPlans.ts +++ b/apps/web/src/api/editPlans.ts @@ -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 => { - 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 => { - const response = await apiClient.post(`/projects/${projectId}/edit-plans`, data); - return response.data; -}; - -/** - * 获取编排计划列表 - */ -export const getEditPlans = async (projectId: string): Promise => { - const response = await apiClient.get(`/projects/${projectId}/edit-plans/`); +/** 获取当前用户的编辑计划列表 */ +export const getEditPlans = async (): Promise => { + const response = await apiClient.get('/edit-plans'); return response.data.items || []; }; -/** - * 获取单个编排计划 - */ -export const getEditPlan = async (projectId: string, planId: string): Promise => { - const response = await apiClient.get(`/projects/${projectId}/edit-plans/${planId}`); +/** 获取单个编辑计划 */ +export const getEditPlan = async (planId: string): Promise => { + 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 => { + 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 => { - 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 => { - await apiClient.delete(`/projects/${projectId}/edit-plans/${planId}`); -}; - -/** - * 更新编辑计划 - */ -export const updateEditPlan = async (projectId: string, planId: string, data: Partial): Promise => { - const response = await apiClient.patch(`/projects/${projectId}/edit-plans/${planId}`, data); +/** 更新编辑计划 */ +export const updateEditPlan = async ( + planId: string, + data: Partial +): Promise => { + const response = await apiClient.patch(`/edit-plans/${planId}`, data); return response.data; }; + +/** 删除编辑计划 */ +export const deleteEditPlan = async (planId: string): Promise => { + await apiClient.delete(`/edit-plans/${planId}`); +}; diff --git a/apps/web/src/api/products.ts b/apps/web/src/api/products.ts new file mode 100644 index 000000000..948b0a4c3 --- /dev/null +++ b/apps/web/src/api/products.ts @@ -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 => { + const response = await apiClient.get('/products'); + return response.data.items || response.data || []; +}; + +/** 获取单个成品详情 */ +export const getProduct = async ( + productId: string +): Promise => { + const response = await apiClient.get(`/products/${productId}`); + return response.data; +}; + +/** 删除成品 */ +export const deleteProduct = async (productId: string): Promise => { + 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; +}; diff --git a/apps/web/src/api/tasks.ts b/apps/web/src/api/tasks.ts index 6a4a19ec6..cb6519cde 100644 --- a/apps/web/src/api/tasks.ts +++ b/apps/web/src/api/tasks.ts @@ -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 => { - const response = await apiClient.get(`/projects/${projectId}/tasks`); - return response.data.items; +/** 获取当前用户的所有任务(生成记录) */ +export const getUserTasks = async (): Promise => { + const response = await apiClient.get('/tasks'); + return response.data.items || []; }; -export const retryProjectTask = async (taskType: string, sourceId: string): Promise => { - const response = await apiClient.post(`/tasks/${taskType}/${sourceId}/retry`); +/** 重试失败的任务 */ +export const retryTask = async (taskId: string): Promise => { + const response = await apiClient.post(`/tasks/${taskId}/retry`); return response.data; }; diff --git a/apps/web/src/api/templates.ts b/apps/web/src/api/templates.ts new file mode 100644 index 000000000..a39cd2684 --- /dev/null +++ b/apps/web/src/api/templates.ts @@ -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 => { + const response = await apiClient.get('/templates'); + return response.data.items || response.data || []; +}; + +/** 获取单个模板详情 */ +export const getTemplate = async ( + templateId: string +): Promise => { + 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; +}; diff --git a/apps/web/src/api/titles.ts b/apps/web/src/api/titles.ts new file mode 100644 index 000000000..cdd60d931 --- /dev/null +++ b/apps/web/src/api/titles.ts @@ -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 => { + const response = await apiClient.get('/titles'); + return response.data.items || response.data || []; +}; + +/** 创建标题 */ +export const createTitle = async ( + data: CreateTitleRequest +): Promise => { + const response = await apiClient.post('/titles', data); + return response.data; +}; + +/** 更新标题 */ +export const updateTitle = async ( + titleId: string, + data: Partial +): Promise => { + const response = await apiClient.patch(`/titles/${titleId}`, data); + return response.data; +}; + +/** 删除标题 */ +export const deleteTitle = async (titleId: string): Promise => { + 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; +}; diff --git a/apps/web/src/api/voices.ts b/apps/web/src/api/voices.ts new file mode 100644 index 000000000..84ea882ff --- /dev/null +++ b/apps/web/src/api/voices.ts @@ -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 => { + const response = await apiClient.get('/voices'); + return response.data.items || response.data || []; +}; + +/** 创建配音 */ +export const createVoice = async ( + data: CreateVoiceRequest +): Promise => { + const response = await apiClient.post('/voices', data); + return response.data; +}; + +/** 更新配音 */ +export const updateVoice = async ( + voiceId: string, + data: Partial +): Promise => { + const response = await apiClient.patch(`/voices/${voiceId}`, data); + return response.data; +}; + +/** 删除配音 */ +export const deleteVoice = async (voiceId: string): Promise => { + await apiClient.delete(`/voices/${voiceId}`); +}; + +/** AI 生成配音 */ +export const generateAIVoice = async (data: { + text: string; + voice_type?: string; + speed?: number; +}): Promise => { + const response = await apiClient.post('/voices/generate', data); + return response.data; +}; diff --git a/apps/web/src/api/workspace.ts b/apps/web/src/api/workspace.ts deleted file mode 100644 index a2a306fce..000000000 --- a/apps/web/src/api/workspace.ts +++ /dev/null @@ -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 & { - id?: string; - workspace_id?: string; -}; - -type WorkspaceMemberApiResponse = Omit & { - 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 => { - 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 => { - const response = await apiClient.get(`/workspaces/${id}`); - return normalizeWorkspace(response.data); -}; - -export const createWorkspace = async ( - data: CreateWorkspaceRequest -): Promise => { - const response = await apiClient.post('/workspaces', data); - return normalizeWorkspace(response.data); -}; - -export const getMembers = async (workspaceId: string): Promise => { - 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 => { - const response = await apiClient.post(`/workspaces/${workspaceId}/members/invite`, data); - return response.data; -}; - -export const removeMember = async ( - workspaceId: string, - memberId: string -): Promise => { - 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('主动退出工作空间暂未开放'); -}; diff --git a/apps/web/src/components/layout/Header.css b/apps/web/src/components/layout/Header.css index a161e5905..f6dbaed88 100644 --- a/apps/web/src/components/layout/Header.css +++ b/apps/web/src/components/layout/Header.css @@ -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; + } +} diff --git a/apps/web/src/components/layout/Header.tsx b/apps/web/src/components/layout/Header.tsx index 76ceb32d7..8a8f8b7a8 100644 --- a/apps/web/src/components/layout/Header.tsx +++ b/apps/web/src/components/layout/Header.tsx @@ -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: }, + { key: 'assets', label: '素材库', path: '/assets', icon: }, + { key: 'titles', label: '标题库', path: '/titles', icon: }, + { key: 'voices', label: '配音库', path: '/voices', icon: }, + { key: 'templates', label: '模板库', path: '/templates', icon: }, + { key: 'generate', label: '一键生成', path: '/generate', icon: }, + { key: 'history', label: '任务历史', path: '/history', icon: }, + { key: 'products', label: '成品库', path: '/products', icon: }, +]; 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: , - 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 (
- + {/* 桌面端导航 */} - - - } /> - {user?.display_name || user?.username || '小虾用户'} - - +
+ {/* 手机端汉堡菜单按钮 */} + + + + + } /> + {user?.display_name || user?.username || '小虾用户'} + + +
+ + {/* 手机端侧边抽屉导航 */} + setMobileMenuOpen(false)} + open={mobileMenuOpen} + width={260} + className="xx-mobile-drawer" + > +
+ {NAV_ITEMS.map((item) => ( + + ))} +
+
); }; diff --git a/apps/web/src/hooks/useWorkspace.ts b/apps/web/src/hooks/useWorkspace.ts deleted file mode 100644 index 83f708b1e..000000000 --- a/apps/web/src/hooks/useWorkspace.ts +++ /dev/null @@ -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[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 }; -}; diff --git a/apps/web/src/pages/assets/AssetLibrary.tsx b/apps/web/src/pages/assets/AssetLibrary.tsx new file mode 100644 index 000000000..16fee17f3 --- /dev/null +++ b/apps/web/src/pages/assets/AssetLibrary.tsx @@ -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 ; + case 'voice': + return ; + case 'image': + return ; + default: + return ; + } +}; + +/** 素材库类型标签 */ +const kindLabel: Record = { + video: '视频', + voice: '配音', + image: '图片', +}; + +const AssetLibrary: React.FC = () => { + const queryClient = useQueryClient(); + const [activeLibrary, setActiveLibrary] = useState(''); + 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 ( +
+
+ + 素材库 + + +
+ + {libsLoading ? ( +
+ +
+ ) : libraries.length === 0 ? ( + + + + ) : ( + <> + {/* 素材库标签页 */} + ({ + key: lib.id, + label: ( + + + {lib.name} + {lib.asset_count ?? 0} + + ), + }))} + style={{ marginBottom: 16 }} + /> + + {/* 上传区域 */} + {activeLibrary && ( + +

+ +

+

点击或拖拽文件到此区域上传

+

+ 支持 {kindLabel[currentLib?.kind || 'video']} 格式文件 +

+
+ )} + + {/* 素材列表 */} + {assetsLoading ? ( +
+ +
+ ) : assets.length === 0 ? ( + + ) : ( + + {assets.map((asset) => ( + + + ) : ( +
+ {currentLib?.kind === 'video' ? ( + + ) : ( + + )} +
+ ) + } + actions={[ + deleteMutation.mutate(asset.id)} + > + + , + ]} + > + + {asset.name} + + } + description={ + + + {asset.file_size + ? `${(asset.file_size / 1024 / 1024).toFixed(1)} MB` + : '-'} + + {asset.status && ( + + {asset.status} + + )} + + } + /> +
+ + ))} +
+ )} + + )} + + {/* 新建素材库弹窗 */} + setCreateModalOpen(false)} + onOk={() => + createLibMutation.mutate({ name: newLibName, kind: newLibKind }) + } + confirmLoading={createLibMutation.isPending} + > + + setNewLibName(e.target.value)} + /> + +
+ + {isLoading ? ( +
+ +
+ ) : filteredTasks.length === 0 ? ( + + ) : ( + + )} + + ); +}; + +export default TaskHistory; diff --git a/apps/web/src/pages/products/ProductLibrary.tsx b/apps/web/src/pages/products/ProductLibrary.tsx new file mode 100644 index 000000000..cc21f4075 --- /dev/null +++ b/apps/web/src/pages/products/ProductLibrary.tsx @@ -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 = { + completed: { color: 'success', text: '已完成' }, + processing: { color: 'processing', text: '处理中' }, + failed: { color: 'error', text: '失败' }, + }; + const c = config[status] || config.processing; + return {c.text}; +}; + + +const ProductLibrary: React.FC = () => { + const queryClient = useQueryClient(); + const [previewProduct, setPreviewProduct] = useState(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 ( +
+ + 成品库 + + + {isLoading ? ( +
+ +
+ ) : products.length === 0 ? ( + + + + ) : ( + + {products.map((product) => ( +
+ + ) : ( +
+ +
+ ) + } + actions={[ + + + + } + > + {previewProduct && ( + + {previewProduct.video_url ? ( +
+ 分辨率: + {previewProduct.resolution || '-'} + + + 时长: + + {previewProduct.duration_seconds + ? `${previewProduct.duration_seconds}s` + : '-'} + + + + 文件大小: + + {previewProduct.file_size + ? `${(previewProduct.file_size / 1024 / 1024).toFixed(1)} MB` + : '-'} + + + + 查重率: + + {previewProduct.duplicate_rate !== undefined + ? `${previewProduct.duplicate_rate.toFixed(1)}%` + : '-'} + + + + + )} + + + ); +}; + +export default ProductLibrary; diff --git a/apps/web/src/pages/templates/TemplateLibrary.tsx b/apps/web/src/pages/templates/TemplateLibrary.tsx new file mode 100644 index 000000000..cedae704b --- /dev/null +++ b/apps/web/src/pages/templates/TemplateLibrary.tsx @@ -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( + 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 ( +
+ + 模板库 + + + {/* 搜索和筛选 */} + +
+ } + placeholder="搜索模板..." + value={searchText} + onChange={(e) => setSearchText(e.target.value)} + allowClear + /> + + + } + placeholder="搜索标题..." + value={searchText} + onChange={(e) => setSearchText(e.target.value)} + allowClear + style={{ marginBottom: 16 }} + /> + + {isLoading ? ( +
+ +
+ ) : filteredTitles.length === 0 ? ( + + ) : ( +
+ )} + + {/* 新建/编辑弹窗 */} + { + setModalOpen(false); + resetForm(); + }} + onOk={handleSave} + confirmLoading={createMutation.isPending || updateMutation.isPending} + > + +