refactor(web): remove workspace-related frontend code #41

Merged
xiaoxia merged 1 commits from refactor/remove-workspace-code into develop 2026-06-27 07:35:57 +08:00
26 changed files with 29 additions and 1134 deletions
+1 -4
View File
@@ -22,10 +22,7 @@ function App() {
<div className="xx-card xx-quick-links">
<h3></h3>
<div className="xx-link-grid">
<Link to="/workspaces" className="xx-link-item">
<span className="xx-link-icon">📁</span>
<span></span>
</Link>
<span>Projects (Coming Soon)</span>
<Link to="/projects" className="xx-link-item">
<span className="xx-link-icon">📋</span>
<span></span>
-12
View File
@@ -5,7 +5,6 @@ import apiClient from './client';
export interface AssetItem {
id: string;
workspace_id: string;
project_id: string;
library_id: string;
name: string;
@@ -20,7 +19,6 @@ export interface AssetItem {
export interface AssetLibraryItem {
id: string;
workspace_id: string;
project_id: string;
name: string;
kind: 'video' | 'voice' | 'image';
@@ -30,7 +28,6 @@ export interface AssetLibraryItem {
export interface IngestJob {
id: string;
workspace_id: string;
project_id: string;
library_id: string;
storage_key: string;
@@ -41,7 +38,6 @@ export interface IngestJob {
export interface ClassificationJob {
id: string;
workspace_id: string;
project_id: string;
asset_id: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
@@ -51,7 +47,6 @@ export interface ClassificationJob {
}
export interface ProjectAssetDiagnosis {
workspace_id: string;
project_id: string;
readiness_score: number;
readiness_label: string;
@@ -82,7 +77,6 @@ export const getAssetLibraries = async (projectId: string): Promise<AssetLibrary
};
export const createAssetLibrary = async (data: {
workspace_id: string;
project_id: string;
name: string;
kind: 'video' | 'voice' | 'image';
@@ -119,7 +113,6 @@ export const uploadAsset = async (
};
export const prepareDirectUpload = async (data: {
workspace_id: string;
project_id: string;
library_id: string;
filename: string;
@@ -138,7 +131,6 @@ export const prepareDirectUpload = async (data: {
};
export const completeDirectUpload = async (data: {
workspace_id: string;
project_id: string;
library_id: string;
storage_key: string;
@@ -149,12 +141,10 @@ export const completeDirectUpload = async (data: {
export const uploadAssetDirect = async (data: {
file: File;
workspace_id: string;
project_id: string;
library_id: string;
}): Promise<{ storage_key: string; ingest_job_id: string }> => {
const prepared = await prepareDirectUpload({
workspace_id: data.workspace_id,
project_id: data.project_id,
library_id: data.library_id,
filename: data.file.name,
@@ -175,7 +165,6 @@ export const uploadAssetDirect = async (data: {
}
return completeDirectUpload({
workspace_id: data.workspace_id,
project_id: data.project_id,
library_id: data.library_id,
storage_key: prepared.storage_key,
@@ -188,7 +177,6 @@ export const getIngestJob = async (jobId: string): Promise<IngestJob> => {
};
export const submitClassificationJob = async (data: {
workspace_id: string;
project_id: string;
asset_id: string;
}): Promise<ClassificationJob> => {
+2 -5
View File
@@ -7,7 +7,6 @@ export type EditingMode = "one-take" | "pip" | "voiceover" | "voice_pip";
export interface EditTemplateItem {
id: string;
workspace_id: string;
project_id: string;
name: string;
description: string;
@@ -30,7 +29,6 @@ export interface EditPlanClipItem {
export interface EditPlanItem {
id: string;
workspace_id: string;
project_id: string;
template_id: string;
asset_library_id: string;
@@ -43,13 +41,12 @@ export interface EditPlanItem {
updated_at?: string;
}
export const getEditTemplates = async (projectId: string, workspaceId: string): Promise<EditTemplateItem[]> => {
const response = await apiClient.get(`/projects/${projectId}/edit-plans/templates`, { params: { workspace_id: workspaceId } });
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: {
workspace_id: string;
asset_library_id: string;
template_id?: string;
title_id?: string;
-3
View File
@@ -6,7 +6,6 @@ import type { EditingMode } from "./editPlans";
export interface GenerationTaskItem {
id: string;
workspace_id: string;
project_id: string;
asset_library_id: string;
strategy_id?: string | null;
@@ -23,7 +22,6 @@ export interface GenerationTaskItem {
export interface GeneratedVideoItem {
id: string;
workspace_id: string;
project_id: string;
generation_task_id: string;
name: string;
@@ -41,7 +39,6 @@ export interface GeneratedVideoItem {
}
export const createGenerationTask = async (data: {
workspace_id: string;
project_id: string;
asset_library_id: string;
strategy_id?: string;
+1 -2
View File
@@ -2,7 +2,6 @@ import apiClient from './client';
export interface ProjectTitleItem {
id: string;
workspace_id: string;
project_id: string;
text: string;
category: 'default' | 'marketing' | 'tutorial' | 'story' | 'promo';
@@ -18,7 +17,7 @@ export const getProjectTitles = async (projectId: string, activeOnly = false): P
return response.data.items;
};
export const createProjectTitle = async (projectId: string, data: { workspace_id: string; text: string; category?: ProjectTitleItem['category']; favorite?: boolean }): Promise<ProjectTitleItem> => {
export const createProjectTitle = async (projectId: string, data: { text: string; category?: ProjectTitleItem['category']; favorite?: boolean }): Promise<ProjectTitleItem> => {
const response = await apiClient.post(`/projects/${projectId}/titles`, data);
return response.data;
};
+2 -4
View File
@@ -2,13 +2,11 @@ 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;
}
@@ -18,8 +16,8 @@ export const getProject = async (projectId: string): Promise<ProjectItem> => {
return response.data;
};
export const getProjects = async (workspaceId: string): Promise<ProjectItem[]> => {
const response = await apiClient.get('/projects', { params: { workspace_id: workspaceId } });
export const getProjects = async (): Promise<ProjectItem[]> => {
const response = await apiClient.get('/projects');
return response.data.items;
};
+7 -10
View File
@@ -35,18 +35,16 @@ export const getPlans = async (): Promise<SubscriptionPlan[]> => {
// 获取当前订阅
export const getCurrentSubscription = async (
workspaceId: string
): Promise<SubscriptionPlan> => {
const response = await apiClient.get(`/workspaces/${workspaceId}/subscription`);
): Promise<SubscriptionPlan> => {
const response = await apiClient.get('/subscription');
return response.data;
};
// 升级订阅
export const upgradeSubscription = async (
workspaceId: string,
plan: 'pro' | 'enterprise'
): Promise<{ message: string }> => {
const response = await apiClient.post(`/workspaces/${workspaceId}/subscription/upgrade`, {
const response = await apiClient.post('/subscription/upgrade', {
plan,
});
return response.data;
@@ -54,14 +52,13 @@ export const upgradeSubscription = async (
// 取消订阅
export const cancelSubscription = async (
workspaceId: string
): Promise<{ message: string }> => {
const response = await apiClient.post(`/workspaces/${workspaceId}/subscription/cancel`);
): Promise<{ message: string }> => {
const response = await apiClient.post('/subscription/cancel');
return response.data;
};
// 获取配额状态
export const getQuotaStatus = async (workspaceId: string): Promise<QuotaStatus> => {
const response = await apiClient.get(`/workspaces/${workspaceId}/quota`);
export const getQuotaStatus = async (): Promise<QuotaStatus> => {
const response = await apiClient.get('/quota');
return response.data;
};
-1
View File
@@ -3,7 +3,6 @@ import apiClient from './client';
export interface ProjectTaskItem {
id: string;
task_type: 'ingest' | 'generation' | string;
workspace_id: string;
project_id: string;
status: string;
progress: number;
-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('主动退出工作空间暂未开放');
};
@@ -1,83 +0,0 @@
/**
* 邀请成员弹窗 - V21 UI
*/
import React from 'react';
import { Modal, Form, Input, Select, message } from 'antd';
import { useInviteMember } from '@/hooks/useWorkspace';
import './business.css';
interface InviteMemberModalProps {
workspaceId: string;
open: boolean;
onClose: () => void;
}
const InviteMemberModal: React.FC<InviteMemberModalProps> = ({
workspaceId,
open,
onClose,
}) => {
const [form] = Form.useForm();
const inviteMutation = useInviteMember(workspaceId);
const handleOk = async () => {
try {
const values = await form.validateFields();
await inviteMutation.mutateAsync(values);
message.success('邀请已发送!');
form.resetFields();
onClose();
} catch (error) {
// 验证失败或请求失败
}
};
const handleCancel = () => {
form.resetFields();
onClose();
};
return (
<Modal
title="邀请成员"
open={open}
onOk={handleOk}
onCancel={handleCancel}
confirmLoading={inviteMutation.isPending}
okText="发送邀请"
cancelText="取消"
className="xx-modal"
width={480}
>
<Form form={form} layout="vertical">
<Form.Item
name="email"
label="邮箱地址"
rules={[
{ required: true, message: '请输入邮箱地址' },
{ type: 'email', message: '请输入有效的邮箱地址' },
]}
>
<Input placeholder="member@example.com" />
</Form.Item>
<Form.Item
name="role"
label="角色权限"
initialValue="member"
rules={[{ required: true, message: '请选择角色' }]}
>
<Select
options={[
{ value: 'admin', label: '管理员 - 可以管理成员和项目' },
{ value: 'member', label: '成员 - 可以创建和管理项目' },
{ value: 'viewer', label: '访客 - 只读权限' },
]}
/>
</Form.Item>
</Form>
</Modal>
);
};
export default InviteMemberModal;
@@ -1,135 +0,0 @@
/**
* 成员列表组件 - V21 UI
*/
import React from 'react';
import { Table, Button, Tag, Space, Popconfirm, message, Select } from 'antd';
import { DeleteOutlined } from '@ant-design/icons';
import { useWorkspaceMembers } from '@/hooks/useWorkspace';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { removeMember, updateMemberRole } from '@/api/workspace';
import './business.css';
interface MemberListProps {
workspaceId: string;
}
const MemberList: React.FC<MemberListProps> = ({ workspaceId }) => {
const { data: members, isLoading } = useWorkspaceMembers(workspaceId);
const queryClient = useQueryClient();
const removeMutation = useMutation({
mutationFn: (memberId: string) => removeMember(workspaceId, memberId),
onSuccess: () => {
message.success('成员已移除');
queryClient.invalidateQueries({ queryKey: ['workspaceMembers', workspaceId] });
},
});
const updateRoleMutation = useMutation({
mutationFn: ({ memberId, role }: { memberId: string; role: string }) =>
updateMemberRole(workspaceId, memberId, role as any),
onSuccess: () => {
message.success('角色已更新');
queryClient.invalidateQueries({ queryKey: ['workspaceMembers', workspaceId] });
},
});
const getRoleTag = (role: string) => {
const roleConfig: Record<string, { color: string; text: string; className: string }> = {
owner: { color: 'gold', text: '拥有者', className: 'xx-tag-warning' },
admin: { color: 'blue', text: '管理员', className: 'xx-tag-indigo' },
member: { color: 'green', text: '成员', className: 'xx-tag-success' },
viewer: { color: 'default', text: '访客', className: '' },
};
const config = roleConfig[role] || roleConfig.member;
return <Tag color={config.color} className={`xx-tag ${config.className}`}>{config.text}</Tag>;
};
const columns = [
{
title: '用户名',
dataIndex: 'username',
key: 'username',
render: (text: string) => <span style={{ fontWeight: 600, color: '#0f172a' }}>{text}</span>,
},
{
title: '邮箱',
dataIndex: 'email',
key: 'email',
render: (text: string) => <span style={{ color: '#64748b' }}>{text}</span>,
},
{
title: '角色',
dataIndex: 'role',
key: 'role',
render: (role: string, record: any) => {
if (role === 'owner') {
return getRoleTag(role);
}
return (
<Select
value={role}
style={{ width: 130 }}
onChange={(newRole) =>
updateRoleMutation.mutate({ memberId: record.id, role: newRole })
}
options={[
{ value: 'admin', label: '管理员' },
{ value: 'member', label: '成员' },
{ value: 'viewer', label: '访客' },
]}
/>
);
},
},
{
title: '加入时间',
dataIndex: 'joined_at',
key: 'joined_at',
render: (date: string) => <span style={{ color: '#64748b' }}>{new Date(date).toLocaleDateString()}</span>,
},
{
title: '操作',
key: 'action',
width: 100,
render: (_: any, record: any) => {
if (record.role === 'owner') {
return <span style={{ color: '#999' }}>-</span>;
}
return (
<Space>
<Popconfirm
title="确定移除该成员?"
onConfirm={() => removeMutation.mutate(record.id)}
okText="确定"
cancelText="取消"
>
<Button
type="text"
danger
icon={<DeleteOutlined />}
loading={removeMutation.isPending}
>
</Button>
</Popconfirm>
</Space>
);
},
},
];
return (
<div className="xx-table-wrapper">
<Table
columns={columns}
dataSource={members}
loading={isLoading}
rowKey="id"
pagination={{ pageSize: 10, showSizeChanger: false }}
/>
</div>
);
};
export default MemberList;
+3 -4
View File
@@ -30,7 +30,7 @@ const Header: React.FC = () => {
: [];
return [
{ key: 'home', label: '首页', path: '/workspaces' },
{ key: 'home', label: '首页', path: '/' },
{ key: 'subscription', label: '订阅', path: '/subscription' },
...projectNav,
];
@@ -61,15 +61,14 @@ const Header: React.FC = () => {
return (
<header className="xx-top-nav">
<div className="xx-top-nav-inner">
<button className="xx-brand" type="button" onClick={() => navigate('/workspaces')}>
<button className="xx-brand" type="button" onClick={() => navigate('/')}>
<span className="xx-logo">🦐</span>
<span></span>
</button>
<nav className="xx-nav-links">
{navItems.map((item) => {
const active = item.path === '/workspaces'
? location.pathname === '/' || location.pathname.startsWith('/workspaces')
const active = item.path === '/' ? location.pathname === '/' : false
: location.pathname.startsWith(item.path);
return (
<button
+3 -3
View File
@@ -26,10 +26,10 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
const menuItems: MenuProps['items'] = [
{
key: '/workspaces',
key: '/',
icon: <HomeOutlined />,
label: '工作空间',
onClick: () => navigate('/workspaces'),
label: '首页',
onClick: () => navigate('/'),
},
{
key: '/projects',
+1 -1
View File
@@ -31,7 +31,7 @@ export const useLogin = () => {
// 获取用户信息
const user = await authApi.getCurrentUser();
setAuth(user, data.access_token, refreshToken);
navigate('/workspaces');
navigate('/');
return data;
};
-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 };
};
+1 -2
View File
@@ -16,9 +16,8 @@ const AdminComingSoon: React.FC = () => {
subTitle="当前版本未接入后台用户、监控、日志、分析等后端服务,因此不展示模拟运营数据,也不会提供假操作入口。"
extra={[
<Button
key="workspaces"
type="primary"
onClick={() => navigate('/workspaces')}
onClick={() => navigate('/')}
className="xx-primary-btn"
>
+2 -3
View File
@@ -103,8 +103,7 @@ const LogViewer: React.FC = () => {
level: 'INFO',
service: 'API',
user: 'user3@example.com',
action: 'Workspace Created',
details: 'New workspace "My Project" created',
action: 'Project Created', details: 'New project "My Project" created',
ip: '192.168.1.104',
request_id: 'req_stu901',
},
@@ -114,7 +113,7 @@ const LogViewer: React.FC = () => {
level: 'DEBUG',
service: 'Redis',
action: 'Cache Miss',
details: 'Cache key "workspace:123" not found, fetching from DB',
details: 'Cache key "project:123" not found, fetching from DB',
request_id: 'req_vwx234',
},
];
+1 -1
View File
@@ -38,7 +38,7 @@ const SystemMonitor: React.FC = () => {
// API 请求统计
const apiStats = [
{ endpoint: 'POST /api/v1/auth/login', requests: 15420, avgTime: '48ms', errors: 12 },
{ endpoint: 'GET /api/v1/workspaces', requests: 89340, avgTime: '32ms', errors: 5 },
{ endpoint: 'GET /api/v1/projects', requests: 89340, avgTime: '32ms', errors: 5 },
{ endpoint: 'POST /api/v1/projects', requests: 6780, avgTime: '156ms', errors: 8 },
{ endpoint: 'GET /api/v1/assets', requests: 124560, avgTime: '68ms', errors: 23 },
{ endpoint: 'POST /api/v1/subscriptions/subscribe', requests: 234, avgTime: '890ms', errors: 2 },
+1 -1
View File
@@ -25,7 +25,7 @@ const Login: React.FC = () => {
password: values.password,
});
message.success('登录成功!');
navigate('/workspaces');
navigate('/');
} catch (error: any) {
message.error(error.response?.data?.message || '登录失败,请检查邮箱和密码');
}
@@ -1,10 +1,10 @@
import React from 'react';
import { Button, Result } from 'antd';
import { useNavigate, useParams } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import '../profile/ProfileSettings.css';
const UpgradeSubscription: React.FC = () => {
const { workspaceId } = useParams<{ workspaceId: string }>();
// workspaceId removed - projects now belong directly to user
const navigate = useNavigate();
return (
@@ -18,11 +18,6 @@ const UpgradeSubscription: React.FC = () => {
<Button key="plans" onClick={() => navigate('/subscription')}>
</Button>,
workspaceId ? (
<Button key="workspace" type="primary" onClick={() => navigate(`/workspaces/${workspaceId}`)}>
</Button>
) : null,
]}
/>
</div>
@@ -1,211 +0,0 @@
/**
* 工作空间详情页面 - V21 UI
*/
import React, { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Tabs, Button, Descriptions, Card, List, Modal, Form, Input, message, Alert } from 'antd';
import { PlusOutlined, TeamOutlined } from '@ant-design/icons';
import { useWorkspace } from '@/hooks/useWorkspace';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { createProject, getProjects } from '@/api/projects';
import MemberList from '@/components/business/MemberList';
import InviteMemberModal from '@/components/business/InviteMemberModal';
import PermissionMatrix from '@/components/business/PermissionMatrix';
import '@/components/business/business.css';
const WorkspaceDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: workspace, isLoading } = useWorkspace(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 className="xx-page"><div className="xx-card" style={{textAlign: 'center', padding: '40px'}}>...</div></div>;
if (!workspace) return <div className="xx-page"><div className="xx-card" style={{textAlign: 'center', padding: '40px'}}></div></div>;
const items = [
{
key: 'overview',
label: '概览',
children: (
<div className="xx-workspace-overview">
<Card
title="工作空间信息"
className="xx-card"
extra={
<Button type="primary" className="xx-primary-btn" onClick={() => setCreateProjectOpen(true)}>
</Button>
}
>
<Descriptions column={2}>
<Descriptions.Item label="名称">{workspace.name}</Descriptions.Item>
<Descriptions.Item label="订阅计划">{workspace.subscription_plan}</Descriptions.Item>
<Descriptions.Item label="状态">{workspace.subscription_status}</Descriptions.Item>
<Descriptions.Item label="创建时间">
{workspace.created_at
? new Date(workspace.created_at).toLocaleDateString()
: '暂未返回'}
</Descriptions.Item>
</Descriptions>
</Card>
<Card title="项目列表" className="xx-card">
{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="titles" type="link" onClick={() => navigate(`/projects/${project.id}/titles`, { state: { workspaceId: id } })}>
</Button>,
<Button key="generation" type="link" onClick={() => navigate(`/projects/${project.id}/generation`, { state: { workspaceId: id } })}>
</Button>,
<Button key="tasks" type="link" onClick={() => navigate(`/projects/${project.id}/tasks`, { 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>
<Alert
type="info"
showIcon
message="配额与订阅功能暂未开放"
description="当前版本未接入订阅和配额后端服务,因此不展示模拟配额数据,也不会调用不存在的配额接口。"
/>
</div>
),
},
{
key: 'members',
label: (
<>
<TeamOutlined />
</>
),
children: (
<div className="xx-members-section">
<div className="xx-page-head" style={{marginBottom: 20}}>
<div>
<h3 style={{margin: 0, fontSize: 18, fontWeight: 700, color: '#0f172a'}}></h3>
<p style={{margin: '4px 0 0', fontSize: 14, color: '#64748b'}}>访</p>
</div>
<Button
type="primary"
className="xx-primary-btn"
icon={<PlusOutlined />}
onClick={() => setInviteModalOpen(true)}
>
</Button>
</div>
<div className="xx-card">
<MemberList workspaceId={id!} />
</div>
<div style={{marginTop: 24}}>
<PermissionMatrix />
</div>
</div>
),
},
{
key: 'settings',
label: '设置',
children: <div className="xx-card"><p style={{color: '#64748b'}}></p></div>,
},
];
return (
<div className="xx-page">
<div className="xx-page-head">
<div>
<h1 style={{margin: 0, fontSize: 28, fontWeight: 850, color: '#0f172a'}}>{workspace.name}</h1>
<p style={{margin: '8px 0 0', fontSize: 14, color: '#64748b'}}></p>
</div>
</div>
<Tabs items={items} />
<Modal
title="创建项目"
open={createProjectOpen}
okText="创建"
cancelText="取消"
confirmLoading={createProjectMutation.isPending}
onOk={handleCreateProject}
onCancel={() => setCreateProjectOpen(false)}
className="xx-modal"
>
<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}
onClose={() => setInviteModalOpen(false)}
/>
</div>
);
};
export default WorkspaceDetail;
export const Component = WorkspaceDetail;
@@ -1,202 +0,0 @@
/* V21 工作空间列表页面 */
.xx-workspace-list {
max-width: 1200px;
margin: 0 auto;
}
/* Hero 区域 - V21 设计 */
.xx-hero {
text-align: center;
padding: 60px 24px 80px;
position: relative;
}
.xx-hero::before {
content: '';
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 300px;
height: 300px;
background: radial-gradient(circle, rgba(79,70,229,0.15), transparent 70%);
border-radius: 50%;
filter: blur(40px);
}
.xx-hero h1 {
font-size: 42px;
font-weight: 900;
color: var(--slate, #0f172a);
margin: 0 0 16px;
letter-spacing: -0.02em;
position: relative;
}
.xx-hero p {
font-size: 18px;
color: var(--muted, #64748b);
margin: 0 0 32px;
max-width: 560px;
margin-left: auto;
margin-right: auto;
line-height: 1.6;
}
/* 功能卡片网格 - V21 */
.xx-features-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 60px;
}
@media (max-width: 1200px) {
.xx-features-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.xx-features-grid {
grid-template-columns: 1fr;
}
}
.xx-feature-card {
background: rgba(255,255,255,0.94);
border: 1px solid rgba(226,232,240,0.95);
border-radius: 24px;
padding: 28px;
text-align: center;
transition: all 0.25s;
cursor: pointer;
}
.xx-feature-card:hover {
transform: translateY(-4px);
box-shadow: 0 26px 64px rgba(15,23,42,0.14);
border-color: var(--indigo, #4f46e5);
}
.xx-feature-icon {
width: 56px;
height: 56px;
margin: 0 auto 16px;
background: linear-gradient(135deg, #6366f1, #4f46e5);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.xx-feature-card h3 {
font-size: 16px;
font-weight: 850;
color: var(--slate, #0f172a);
margin: 0 0 8px;
}
.xx-feature-card p {
font-size: 13px;
color: var(--muted, #64748b);
margin: 0;
}
/* 页面头部 */
.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: var(--slate, #0f172a);
}
.xx-page-head p {
margin: 0;
color: var(--muted, #64748b);
font-size: 14px;
}
.xx-grid4 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
@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 var(--line, #e2e8f0);
background: rgba(255, 255, 255, 0.94);
border-radius: 24px;
padding: 18px;
cursor: pointer;
transition: all 0.25s;
}
.xx-workspace-card:hover {
border-color: var(--indigo, #4f46e5);
box-shadow: 0 26px 64px rgba(15,23,42,0.14);
transform: translateY(-2px);
}
.xx-workspace-name {
font-size: 16px;
font-weight: 850;
color: var(--slate, #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 10px;
border-radius: 10px;
background: var(--indigo-soft, #eef2ff);
color: var(--indigo-dark, #4338ca);
margin-bottom: 12px;
}
.xx-workspace-date {
font-size: 12px;
color: #94a3b8;
}
.xx-empty-state {
text-align: center;
padding: 60px 24px;
color: var(--muted, #64748b);
}
.xx-empty-state p {
font-size: 16px;
margin: 12px 0;
}
@@ -1,125 +0,0 @@
/**
* 工作空间列表页面 - V21 UI
*/
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 || '创建失败');
}
};
// 功能卡片数据
const features = [
{ id: 'assets', icon: '🎬', title: '素材管理', desc: '上传管理视频素材' },
{ id: 'titles', icon: '✍️', title: '标题库', desc: '创作吸引眼球的标题' },
{ id: 'voices', icon: '🎙️', title: '配音库', desc: '选择优质配音音色' },
{ id: 'generation', icon: '✨', title: '剪辑计划', desc: 'AI智能生成成片' },
];
return (
<div className="xx-workspace-list">
{/* Hero 区域 - V21 设计 */}
<div className="xx-hero">
<h1> SaaS</h1>
<p>AI视频剪辑平台</p>
<Button type="primary" size="large" onClick={openCreateModal}>
+
</Button>
</div>
{/* 功能卡片 - V21 设计 */}
<div className="xx-features-grid">
{features.map((feature) => (
<div key={feature.id} className="xx-feature-card">
<div className="xx-feature-icon">{feature.icon}</div>
<h3>{feature.title}</h3>
<p>{feature.desc}</p>
</div>
))}
</div>
{/* 工作空间列表 */}
<div className="xx-page-head">
<div>
<h2></h2>
<p></p>
</div>
</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;
+2 -10
View File
@@ -50,15 +50,7 @@ export const router = createBrowserRouter([
children: [
{
index: true,
element: <Navigate to="/workspaces" replace />,
},
{
path: 'workspaces',
lazy: () => import('@/pages/workspace/WorkspaceList'),
},
{
path: 'workspaces/:id',
lazy: () => import('@/pages/workspace/WorkspaceDetail'),
element: <Navigate to="/projects" replace />,
},
{
path: 'projects/:id/assets',
@@ -89,7 +81,7 @@ export const router = createBrowserRouter([
lazy: () => import('@/pages/subscription/Plans'),
},
{
path: 'subscription/upgrade/:workspaceId',
path: 'subscription/upgrade',
lazy: () => import('@/pages/subscription/UpgradeSubscription'),
},
{
-43
View File
@@ -1,43 +0,0 @@
/**
* 工作空间状态管理
*/
import { create } from 'zustand';
import { Workspace } from '@/api/workspace';
interface WorkspaceState {
currentWorkspace: Workspace | null;
workspaces: Workspace[];
// Actions
setCurrentWorkspace: (workspace: Workspace | null) => void;
setWorkspaces: (workspaces: Workspace[]) => void;
addWorkspace: (workspace: Workspace) => void;
removeWorkspace: (workspaceId: string) => void;
}
export const useWorkspaceStore = create<WorkspaceState>((set) => ({
currentWorkspace: null,
workspaces: [],
setCurrentWorkspace: (workspace) => {
set({ currentWorkspace: workspace });
},
setWorkspaces: (workspaces) => {
set({ workspaces });
},
addWorkspace: (workspace) => {
set((state) => ({
workspaces: [...state.workspaces, workspace],
}));
},
removeWorkspace: (workspaceId) => {
set((state) => ({
workspaces: state.workspaces.filter((w) => w.id !== workspaceId),
currentWorkspace:
state.currentWorkspace?.id === workspaceId ? null : state.currentWorkspace,
}));
},
}));
@@ -1,64 +0,0 @@
/**
* WorkspaceList 组件单元测试
*/
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import WorkspaceList from '@/pages/workspace/WorkspaceList';
import * as workspaceApi from '@/api/workspace';
vi.mock('@/api/workspace');
const renderWorkspaceList = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<WorkspaceList />
</BrowserRouter>
</QueryClientProvider>
);
};
describe('WorkspaceList', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should render workspace list', async () => {
const mockWorkspaces: workspaceApi.Workspace[] = [
{
id: '1',
name: 'Test Workspace',
owner_user_id: 'user-1',
subscription_plan: 'free',
subscription_status: 'active',
created_at: '2024-01-01',
},
];
vi.mocked(workspaceApi.getWorkspaces).mockResolvedValue(mockWorkspaces);
renderWorkspaceList();
await waitFor(() => {
expect(screen.getByText('Test Workspace')).toBeInTheDocument();
});
});
it('should show create workspace button', async () => {
vi.mocked(workspaceApi.getWorkspaces).mockResolvedValue([]);
renderWorkspaceList();
await waitFor(() => {
expect(screen.getByText('创建工作空间')).toBeInTheDocument();
});
});
});