fix(web): connect project context in media flow
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled

This commit is contained in:
Xiaoxia AI
2026-06-21 21:48:21 +08:00
parent 1a9d862584
commit 59291b0e8f
7 changed files with 238 additions and 43 deletions
+7 -1
View File
@@ -12,6 +12,10 @@ export interface AssetItem {
storage_key: string;
mime_type: string;
metadata: Record<string, unknown>;
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<AssetLibraryItem> => {
const response = await apiClient.post('/asset-libraries', data);
return response.data;
+3 -3
View File
@@ -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 {
+24
View File
@@ -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<ProjectItem[]> => {
const response = await apiClient.get('/projects', { params: { workspace_id: workspaceId } });
return response.data.items;
};
export const createProject = async (data: CreateProjectRequest): Promise<ProjectItem> => {
const response = await apiClient.post('/projects', data);
return response.data;
};
+16 -11
View File
@@ -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<Workspace, 'id' | 'workspace_id'> & {
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<WorkspaceMember[]
export const inviteMember = async (
workspaceId: string,
data: InviteMemberRequest
): Promise<{ message: string }> => {
): Promise<InviteMemberResponse> => {
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<void> => {
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('主动退出工作空间暂未开放');
};
+47 -11
View File
@@ -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<string>('');
const [workspaceId, setWorkspaceId] = useState('demo-workspace');
const [createLibraryOpen, setCreateLibraryOpen] = useState(false);
const [uploading, setUploading] = useState(false);
const [ingestJobId, setIngestJobId] = useState<string>('');
@@ -98,6 +101,12 @@ const ProjectAssets: React.FC = () => {
const [batchFailedIds, setBatchFailedIds] = useState<string[]>([]);
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 = () => {
</Space>
}
>
{!workspaceId && (
<Alert
style={{ marginBottom: 16 }}
type="error"
showIcon
message="缺少工作空间上下文"
description="请从工作空间详情页进入项目素材管理,不要直接打开项目地址。"
/>
)}
{librariesQuery.isError && (
<Alert style={{ marginBottom: 16 }} type="error" showIcon message="素材库加载失败" />
)}
{assetsQuery.isError && (
<Alert style={{ marginBottom: 16 }} type="error" showIcon message="素材列表加载失败" />
)}
{ingestJobQuery.isError && (
<Alert style={{ marginBottom: 16 }} type="error" showIcon message="导入任务查询失败" />
)}
{classificationJobQuery.isError && (
<Alert style={{ marginBottom: 16 }} type="error" showIcon message="分类任务查询失败" />
)}
{ingestJobQuery.data && (
<Alert
style={{ marginBottom: 16 }}
@@ -467,7 +504,7 @@ const ProjectAssets: React.FC = () => {
</Card>
)}
<Dragger name="file" multiple={false} customRequest={customUpload} showUploadList={false} disabled={uploading} style={{ marginBottom: 24 }}>
<Dragger name="file" multiple={false} customRequest={customUpload} showUploadList={false} disabled={uploading || !workspaceId} style={{ marginBottom: 24 }}>
<p className="ant-upload-drag-icon"><InboxOutlined /></p>
<p className="ant-upload-text">点击或拖拽文件到这里上传素材</p>
<p className="ant-upload-hint">支持视频、音频、图片文件;上传后自动进入导入任务</p>
@@ -488,7 +525,7 @@ const ProjectAssets: React.FC = () => {
</Col>
</Row>
<Modal title="新建素材库" open={createLibraryOpen} onCancel={() => setCreateLibraryOpen(false)} onOk={() => form.submit()} confirmLoading={createLibraryMutation.isPending}>
<Modal title="新建素材库" open={createLibraryOpen} onCancel={() => setCreateLibraryOpen(false)} onOk={() => form.submit()} confirmLoading={createLibraryMutation.isPending} okButtonProps={{ disabled: !workspaceId }}>
<Form
form={form}
layout="vertical"
@@ -501,10 +538,9 @@ const ProjectAssets: React.FC = () => {
});
}}
>
<Form.Item label="Workspace ID"><Input value={workspaceId} onChange={(e) => setWorkspaceId(e.target.value)} /></Form.Item>
<Form.Item label="素材库名称" name="name" rules={[{ required: true, message: '请输入素材库名称' }]}><Input placeholder="例如:项目视频库" /></Form.Item>
<Form.Item label="素材库类型" name="kind" initialValue="video" rules={[{ required: true }]}>
<Select options={[{ label: '视频库', value: 'video' }, { label: '配音库', value: 'voice' }]} />
<Select options={[{ label: '视频库', value: 'video' }, { label: '配音库', value: 'voice' }, { label: '图片库', value: 'image' }]} />
</Form.Item>
</Form>
</Modal>
@@ -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 (
<div style={{ padding: 24 }}>
<Card title="项目生成任务">
{(!workspaceId || !user?.id) && (
<Alert
style={{ marginBottom: 16 }}
type="error"
showIcon
message="缺少生成上下文"
description="请从工作空间详情页进入项目生成,并确认登录态有效。"
/>
)}
{librariesQuery.isError && (
<Alert style={{ marginBottom: 16 }} type="error" showIcon message="素材库加载失败" />
)}
{taskQuery.isError && (
<Alert style={{ marginBottom: 16 }} type="error" showIcon message="生成任务查询失败" />
)}
{resultsQuery.isError && (
<Alert style={{ marginBottom: 16 }} type="error" showIcon message="生成结果查询失败" />
)}
<Form
form={form}
layout="vertical"
@@ -66,13 +105,10 @@ const ProjectGeneration: React.FC = () => {
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 || '',
})
}
>
<Form.Item label="Workspace ID">
<Input value={workspaceId} onChange={(e) => setWorkspaceId(e.target.value)} />
</Form.Item>
<Form.Item label="素材库" name="asset_library_id" rules={[{ required: true, message: '请选择素材库' }]}>
<Select options={assetLibraryOptions} placeholder="选择视频/图片素材库" loading={librariesQuery.isLoading} />
</Form.Item>
@@ -82,11 +118,8 @@ const ProjectGeneration: React.FC = () => {
<Form.Item label="策略 ID" name="strategy_id">
<Input placeholder="可选:例如 default-strategy" />
</Form.Item>
<Form.Item label="创建者 ID" name="created_by_user_id">
<Input placeholder="可选:例如 user-1" />
</Form.Item>
<Space>
<Button type="primary" htmlType="submit" loading={generationMutation.isPending}>发起生成</Button>
<Button type="primary" htmlType="submit" loading={generationMutation.isPending} disabled={!workspaceId || !user?.id}>发起生成</Button>
</Space>
</Form>
@@ -103,7 +136,12 @@ const ProjectGeneration: React.FC = () => {
<Card size="small" style={{ marginTop: 16 }} title="最新生成结果">
<Space direction="vertical">
{resultsQuery.data.map((item) => (
<Typography.Text key={item.id}>{item.name} - {item.file_url}</Typography.Text>
<Space key={item.id}>
<Typography.Text>{item.name}</Typography.Text>
<Button type="link" href={item.file_url} target="_blank" rel="noreferrer">
下载/查看
</Button>
</Space>
))}
</Space>
</Card>
@@ -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 <div>加载中...</div>;
if (!workspace) return <div>工作空间不存在</div>;
@@ -46,8 +79,8 @@ const WorkspaceDetail: React.FC = () => {
title="工作空间信息"
style={{ marginBottom: 16 }}
extra={
<Button type="primary" onClick={() => navigate(`/projects/${id}/assets`)}>
进入素材管理
<Button type="primary" onClick={() => setCreateProjectOpen(true)}>
创建项目
</Button>
}
>
@@ -63,6 +96,34 @@ const WorkspaceDetail: React.FC = () => {
</Descriptions>
</Card>
<Card title="项目" style={{ marginBottom: 16 }}>
{projectsQuery.isError && (
<Alert type="error" showIcon message="项目列表加载失败" style={{ marginBottom: 16 }} />
)}
<List
loading={projectsQuery.isLoading}
dataSource={projectsQuery.data || []}
locale={{ emptyText: '还没有项目,请先创建项目再上传素材' }}
renderItem={(project) => (
<List.Item
actions={[
<Button key="assets" type="link" onClick={() => navigate(`/projects/${project.id}/assets`, { state: { workspaceId: id } })}>
素材管理
</Button>,
<Button key="generation" type="link" onClick={() => navigate(`/projects/${project.id}/generation`, { state: { workspaceId: id } })}>
视频生成
</Button>,
<Button key="results" type="link" onClick={() => navigate(`/projects/${project.id}/results`, { state: { workspaceId: id } })}>
生成结果
</Button>,
]}
>
<List.Item.Meta title={project.name} description={project.description || '暂无描述'} />
</List.Item>
)}
/>
</Card>
{quota && (
<Card title="配额使用情况">
<Row gutter={16}>
@@ -130,6 +191,31 @@ const WorkspaceDetail: React.FC = () => {
<div>
<h1>{workspace.name}</h1>
<Tabs items={items} />
<Modal
title="创建项目"
open={createProjectOpen}
okText="创建"
cancelText="取消"
confirmLoading={createProjectMutation.isPending}
onOk={handleCreateProject}
onCancel={() => setCreateProjectOpen(false)}
>
<Form form={projectForm} layout="vertical" preserve={false}>
<Form.Item
name="name"
label="项目名称"
rules={[
{ required: true, message: '请输入项目名称' },
{ max: 100, message: '项目名称最多 100 个字符' },
]}
>
<Input placeholder="例如:618 宣传片" />
</Form.Item>
<Form.Item name="description" label="项目描述">
<Input.TextArea placeholder="可选" rows={3} maxLength={500} />
</Form.Item>
</Form>
</Modal>
<InviteMemberModal
workspaceId={id!}
open={inviteModalOpen}