diff --git a/apps/web/src/api/assets.ts b/apps/web/src/api/assets.ts index b533f244b..d88b03861 100644 --- a/apps/web/src/api/assets.ts +++ b/apps/web/src/api/assets.ts @@ -12,6 +12,10 @@ export interface AssetItem { storage_key: string; mime_type: string; metadata: Record; + file_size?: number; + status?: string; + classification_status?: string | null; + quality_score?: number | null; } export interface AssetLibraryItem { @@ -20,6 +24,8 @@ export interface AssetLibraryItem { project_id: string; name: string; kind: 'video' | 'voice' | 'image'; + asset_count?: number; + total_size?: number; } export interface IngestJob { @@ -55,7 +61,7 @@ export const createAssetLibrary = async (data: { workspace_id: string; project_id: string; name: string; - kind: 'video' | 'voice'; + kind: 'video' | 'voice' | 'image'; }): Promise => { const response = await apiClient.post('/asset-libraries', data); return response.data; diff --git a/apps/web/src/api/generation.ts b/apps/web/src/api/generation.ts index a884569d6..cf65e30db 100644 --- a/apps/web/src/api/generation.ts +++ b/apps/web/src/api/generation.ts @@ -5,12 +5,12 @@ export interface GenerationTaskItem { workspace_id: string; project_id: string; asset_library_id: string; - strategy_id: string; - voice_library_id: string; + strategy_id?: string | null; + voice_library_id?: string | null; status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'; progress: number; result_count: number; - error_message: string; + error_message?: string | null; } export interface GeneratedVideoItem { diff --git a/apps/web/src/api/projects.ts b/apps/web/src/api/projects.ts new file mode 100644 index 000000000..f9919cc9f --- /dev/null +++ b/apps/web/src/api/projects.ts @@ -0,0 +1,24 @@ +import apiClient from './client'; + +export interface ProjectItem { + id: string; + workspace_id: string; + name: string; + description: string; +} + +export interface CreateProjectRequest { + workspace_id: string; + name: string; + description?: string; +} + +export const getProjects = async (workspaceId: string): Promise => { + const response = await apiClient.get('/projects', { params: { workspace_id: workspaceId } }); + return response.data.items; +}; + +export const createProject = async (data: CreateProjectRequest): Promise => { + const response = await apiClient.post('/projects', data); + return response.data; +}; diff --git a/apps/web/src/api/workspace.ts b/apps/web/src/api/workspace.ts index 5cdc9cc4d..a2a306fce 100644 --- a/apps/web/src/api/workspace.ts +++ b/apps/web/src/api/workspace.ts @@ -39,6 +39,13 @@ export interface InviteMemberRequest { 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; @@ -60,10 +67,10 @@ const normalizeWorkspace = (workspace: WorkspaceApiResponse): Workspace => { }; const normalizeMember = (member: WorkspaceMemberApiResponse): WorkspaceMember => { - const memberId = member.id || member.member_id || member.user_id || ''; + const memberId = member.member_id || member.id || ''; return { ...member, - id: memberId, + id: member.user_id || memberId, member_id: memberId, }; }; @@ -97,7 +104,7 @@ export const getMembers = async (workspaceId: string): Promise => { +): Promise => { const response = await apiClient.post(`/workspaces/${workspaceId}/members/invite`, data); return response.data; }; @@ -105,23 +112,21 @@ export const inviteMember = async ( export const removeMember = async ( workspaceId: string, memberId: string -): Promise<{ message: string }> => { - const response = await apiClient.delete(`/workspaces/${workspaceId}/members/${memberId}`); - return response.data; +): Promise => { + await apiClient.delete(`/workspaces/${workspaceId}/members/${memberId}`); }; export const updateMemberRole = async ( workspaceId: string, memberId: string, role: 'admin' | 'member' | 'viewer' -): Promise<{ message: string }> => { - const response = await apiClient.patch(`/workspaces/${workspaceId}/members/${memberId}`, { +): 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 }> => { - const response = await apiClient.post(`/workspaces/${workspaceId}/leave`); - return response.data; +export const leaveWorkspace = async (_workspaceId: string): Promise<{ message: string }> => { + throw new Error('主动退出工作空间暂未开放'); }; diff --git a/apps/web/src/pages/workspace/ProjectAssets.tsx b/apps/web/src/pages/workspace/ProjectAssets.tsx index faaf0dae5..1f7f54a08 100644 --- a/apps/web/src/pages/workspace/ProjectAssets.tsx +++ b/apps/web/src/pages/workspace/ProjectAssets.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { useLocation, useParams } from 'react-router-dom'; import { Alert, Button, @@ -81,9 +81,12 @@ const defaultBatchProgress: BatchProgressState = { const ProjectAssets: React.FC = () => { const { id } = useParams<{ id: string }>(); + const location = useLocation(); const projectId = id || ''; + const routedWorkspaceId = (location.state as { workspaceId?: string } | null)?.workspaceId || ''; + const storedWorkspaceId = projectId ? sessionStorage.getItem(`project-workspace:${projectId}`) || '' : ''; + const workspaceId = routedWorkspaceId || storedWorkspaceId; const [libraryId, setLibraryId] = useState(''); - const [workspaceId, setWorkspaceId] = useState('demo-workspace'); const [createLibraryOpen, setCreateLibraryOpen] = useState(false); const [uploading, setUploading] = useState(false); const [ingestJobId, setIngestJobId] = useState(''); @@ -98,6 +101,12 @@ const ProjectAssets: React.FC = () => { const [batchFailedIds, setBatchFailedIds] = useState([]); const [form] = Form.useForm(); + useEffect(() => { + if (projectId && routedWorkspaceId) { + sessionStorage.setItem(`project-workspace:${projectId}`, routedWorkspaceId); + } + }, [projectId, routedWorkspaceId]); + const librariesQuery = useQuery({ queryKey: ['asset-libraries', projectId], queryFn: () => getAssetLibraries(projectId), @@ -191,7 +200,9 @@ const ProjectAssets: React.FC = () => { if (pendingCount === 0) { assetsQuery.refetch(); } - } catch { + } catch (error) { + message.error(error instanceof Error ? error.message : '批量分类轮询失败'); + setBatchProgress((current) => ({ ...current, running: false, tracking: false })); } }; @@ -213,7 +224,7 @@ const ProjectAssets: React.FC = () => { setLibraryId(library.id); form.resetFields(); }, - onError: () => message.error('素材库创建失败'), + onError: (error: any) => message.error(error.response?.data?.detail || '素材库创建失败'), }); const classificationMutation = useMutation({ @@ -222,9 +233,9 @@ const ProjectAssets: React.FC = () => { setClassificationJobId(job.id); message.info('已发起素材分类任务'); }, - onError: () => { + onError: (error) => { setClassifyingAssetId(''); - message.error('发起分类失败'); + message.error((error as any).response?.data?.detail || '发起分类失败'); }, }); @@ -397,7 +408,7 @@ const ProjectAssets: React.FC = () => { } catch (error) { setUploading(false); onError(error); - message.error('上传失败'); + message.error((error as any).response?.data?.detail || '上传失败'); } }; @@ -436,6 +447,32 @@ const ProjectAssets: React.FC = () => { } > + {!workspaceId && ( + + )} + + {librariesQuery.isError && ( + + )} + + {assetsQuery.isError && ( + + )} + + {ingestJobQuery.isError && ( + + )} + + {classificationJobQuery.isError && ( + + )} + {ingestJobQuery.data && ( { )} - +

点击或拖拽文件到这里上传素材

支持视频、音频、图片文件;上传后自动进入导入任务

@@ -488,7 +525,7 @@ const ProjectAssets: React.FC = () => { - setCreateLibraryOpen(false)} onOk={() => form.submit()} confirmLoading={createLibraryMutation.isPending}> + setCreateLibraryOpen(false)} onOk={() => form.submit()} confirmLoading={createLibraryMutation.isPending} okButtonProps={{ disabled: !workspaceId }}>
{ }); }} > - setWorkspaceId(e.target.value)} /> -
diff --git a/apps/web/src/pages/workspace/ProjectGeneration.tsx b/apps/web/src/pages/workspace/ProjectGeneration.tsx index c76770878..382a4650f 100644 --- a/apps/web/src/pages/workspace/ProjectGeneration.tsx +++ b/apps/web/src/pages/workspace/ProjectGeneration.tsx @@ -1,17 +1,28 @@ -import React, { useMemo, useState } from 'react'; -import { useParams } from 'react-router-dom'; +import React, { useEffect, useMemo, useState } from 'react'; +import { useLocation, useParams } from 'react-router-dom'; import { Alert, Button, Card, Form, Input, Select, Space, Typography, message } from 'antd'; import { useMutation, useQuery } from '@tanstack/react-query'; import { createGenerationTask, getGenerationResults, getGenerationTask } from '@/api/generation'; import { getAssetLibraries } from '@/api/assets'; +import { useAuthStore } from '@/store/authStore'; const ProjectGeneration: React.FC = () => { const { id } = useParams<{ id: string }>(); + const location = useLocation(); + const user = useAuthStore((state) => state.user); const projectId = id || ''; - const [workspaceId, setWorkspaceId] = useState('demo-workspace'); + const routedWorkspaceId = (location.state as { workspaceId?: string } | null)?.workspaceId || ''; + const storedWorkspaceId = projectId ? sessionStorage.getItem(`project-workspace:${projectId}`) || '' : ''; + const workspaceId = routedWorkspaceId || storedWorkspaceId; const [taskId, setTaskId] = useState(''); const [form] = Form.useForm(); + useEffect(() => { + if (projectId && routedWorkspaceId) { + sessionStorage.setItem(`project-workspace:${projectId}`, routedWorkspaceId); + } + }, [projectId, routedWorkspaceId]); + const librariesQuery = useQuery({ queryKey: ['generation-libraries', projectId], queryFn: () => getAssetLibraries(projectId), @@ -40,9 +51,15 @@ const ProjectGeneration: React.FC = () => { setTaskId(task.id); message.success('生成任务已创建'); }, - onError: () => message.error('创建生成任务失败'), + onError: (error: any) => message.error(error.response?.data?.detail || '创建生成任务失败'), }); + useEffect(() => { + if (taskQuery.data?.status === 'completed') { + resultsQuery.refetch(); + } + }, [taskQuery.data?.status, resultsQuery]); + const assetLibraryOptions = useMemo( () => (librariesQuery.data || []).filter((item) => item.kind === 'video' || item.kind === 'image').map((item) => ({ label: `${item.name} (${item.kind})`, value: item.id })), [librariesQuery.data] @@ -56,6 +73,28 @@ const ProjectGeneration: React.FC = () => { return (
+ {(!workspaceId || !user?.id) && ( + + )} + + {librariesQuery.isError && ( + + )} + + {taskQuery.isError && ( + + )} + + {resultsQuery.isError && ( + + )} +
{ asset_library_id: values.asset_library_id, voice_library_id: values.voice_library_id || '', strategy_id: values.strategy_id || '', - created_by_user_id: values.created_by_user_id || '', + created_by_user_id: user?.id || '', }) } > - - setWorkspaceId(e.target.value)} /> - - - - - +
@@ -103,7 +136,12 @@ const ProjectGeneration: React.FC = () => { {resultsQuery.data.map((item) => ( - {item.name} - {item.file_url} + + {item.name} + + ))} diff --git a/apps/web/src/pages/workspace/WorkspaceDetail.tsx b/apps/web/src/pages/workspace/WorkspaceDetail.tsx index 80bd06c5d..6266d9afd 100644 --- a/apps/web/src/pages/workspace/WorkspaceDetail.tsx +++ b/apps/web/src/pages/workspace/WorkspaceDetail.tsx @@ -3,11 +3,12 @@ */ import React, { useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { Tabs, Button, Descriptions, Progress, Card, Row, Col } from 'antd'; +import { Tabs, Button, Descriptions, Progress, Card, Row, Col, List, Modal, Form, Input, message, Alert } from 'antd'; import { PlusOutlined, TeamOutlined, ProjectOutlined } from '@ant-design/icons'; import { useWorkspace } from '@/hooks/useWorkspace'; -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { getQuotaStatus } from '@/api/subscription'; +import { createProject, getProjects } from '@/api/projects'; import MemberList from '@/components/business/MemberList'; import InviteMemberModal from '@/components/business/InviteMemberModal'; import PermissionMatrix from '@/components/business/PermissionMatrix'; @@ -21,7 +22,39 @@ const WorkspaceDetail: React.FC = () => { queryFn: () => getQuotaStatus(id!), enabled: !!id, }); + const queryClient = useQueryClient(); const [inviteModalOpen, setInviteModalOpen] = useState(false); + const [createProjectOpen, setCreateProjectOpen] = useState(false); + const [projectForm] = Form.useForm<{ name: string; description?: string }>(); + + const projectsQuery = useQuery({ + queryKey: ['projects', id], + queryFn: () => getProjects(id!), + enabled: !!id, + }); + + const createProjectMutation = useMutation({ + mutationFn: createProject, + onSuccess: (project) => { + message.success('项目创建成功'); + setCreateProjectOpen(false); + projectForm.resetFields(); + queryClient.invalidateQueries({ queryKey: ['projects', id] }); + navigate(`/projects/${project.id}/assets`, { state: { workspaceId: id } }); + }, + onError: (error: any) => { + message.error(error.response?.data?.detail || '项目创建失败,请稍后重试'); + }, + }); + + const handleCreateProject = async () => { + const values = await projectForm.validateFields(); + createProjectMutation.mutate({ + workspace_id: id!, + name: values.name, + description: values.description || '', + }); + }; if (isLoading) return
加载中...
; if (!workspace) return
工作空间不存在
; @@ -46,8 +79,8 @@ const WorkspaceDetail: React.FC = () => { title="工作空间信息" style={{ marginBottom: 16 }} extra={ - } > @@ -63,6 +96,34 @@ const WorkspaceDetail: React.FC = () => {
+ + {projectsQuery.isError && ( + + )} + ( + navigate(`/projects/${project.id}/assets`, { state: { workspaceId: id } })}> + 素材管理 + , + , + , + ]} + > + + + )} + /> + + {quota && ( @@ -130,6 +191,31 @@ const WorkspaceDetail: React.FC = () => {

{workspace.name}

+ setCreateProjectOpen(false)} + > +
+ + + + + + +
+