refactor(web): remove workspace-related frontend code #41
@@ -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>
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -31,7 +31,7 @@ export const useLogin = () => {
|
||||
// 获取用户信息
|
||||
const user = await authApi.getCurrentUser();
|
||||
setAuth(user, data.access_token, refreshToken);
|
||||
navigate('/workspaces');
|
||||
navigate('/');
|
||||
return data;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -16,9 +16,8 @@ const AdminComingSoon: React.FC = () => {
|
||||
subTitle="当前版本未接入后台用户、监控、日志、分析等后端服务,因此不展示模拟运营数据,也不会提供假操作入口。"
|
||||
extra={[
|
||||
<Button
|
||||
key="workspaces"
|
||||
type="primary"
|
||||
onClick={() => navigate('/workspaces')}
|
||||
onClick={() => navigate('/')}
|
||||
className="xx-primary-btn"
|
||||
>
|
||||
返回工作空间
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
@@ -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'),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user