fix: 后端代码健康修复 (P1-1/P1-2/P2-1/P2-2/P2-3) #68
@@ -206,7 +206,7 @@ async def forgot_password(
|
||||
):
|
||||
success, error = RequestPasswordResetUseCase(
|
||||
user_repository=user_repository,
|
||||
base_url="http://localhost:3000",
|
||||
base_url=settings.APP_BASE_URL,
|
||||
email_service=email_service,
|
||||
).execute(PasswordResetUseCaseRequest(email=request.email))
|
||||
if not success:
|
||||
|
||||
@@ -72,7 +72,7 @@ class Settings(BaseSettings):
|
||||
SMTP_USER: str = ""
|
||||
SMTP_PASSWORD: str = ""
|
||||
SMTP_FROM_EMAIL: str = ""
|
||||
SMTP_FRON_NAME: str = "小虾 SaaS"
|
||||
SMTP_FROM_NAME: str = "小虾 SaaS"
|
||||
SMTP_USE_TLS: bool = True
|
||||
|
||||
CELERY_BROKER_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* 视频生成 API
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import type { EditingMode } from "./editPlans";
|
||||
|
||||
export interface GenerationTaskItem {
|
||||
id: string;
|
||||
project_id: string;
|
||||
asset_library_id: string;
|
||||
strategy_id?: string | null;
|
||||
voice_library_id?: string | null;
|
||||
edit_plan_id?: string | null;
|
||||
editing_mode?: EditingMode;
|
||||
status: "pending" | "running" | "completed" | "failed" | "cancelled";
|
||||
progress: number;
|
||||
result_count: number;
|
||||
error_message?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface GeneratedVideoItem {
|
||||
id: string;
|
||||
project_id: string;
|
||||
generation_task_id: string;
|
||||
name: string;
|
||||
file_url: string;
|
||||
file_size: number;
|
||||
duration: number;
|
||||
thumbnail_url?: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
status?: string;
|
||||
review_status?: "pending_review" | "approved" | "rejected";
|
||||
generation_params?: Record<string, unknown>;
|
||||
editing_mode?: EditingMode;
|
||||
}
|
||||
|
||||
export const createGenerationTask = async (data: {
|
||||
project_id: string;
|
||||
asset_library_id: string;
|
||||
strategy_id?: string;
|
||||
voice_library_id?: string;
|
||||
edit_plan_id?: string;
|
||||
editing_mode?: EditingMode;
|
||||
created_by_user_id?: string;
|
||||
}): Promise<GenerationTaskItem> => {
|
||||
const response = await apiClient.post("/generation/tasks", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getGenerationTask = async (taskId: string): Promise<GenerationTaskItem> => {
|
||||
const response = await apiClient.get(`/generation/tasks/${taskId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getGenerationResults = async (taskId: string): Promise<GeneratedVideoItem[]> => {
|
||||
const response = await apiClient.get(`/generation/tasks/${taskId}/results`);
|
||||
return response.data.items;
|
||||
};
|
||||
|
||||
export const getGeneratedVideos = async (projectId: string): Promise<GeneratedVideoItem[]> => {
|
||||
const response = await apiClient.get("/generated-videos", { params: { project_id: projectId } });
|
||||
return response.data.items;
|
||||
};
|
||||
|
||||
export const updateGeneratedVideoReviewStatus = async (videoId: string, reviewStatus: "pending_review" | "approved" | "rejected"): Promise<GeneratedVideoItem> => {
|
||||
const response = await apiClient.patch(`/generated-videos/${videoId}/review`, { review_status: reviewStatus });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getGeneratedVideoDownloadUrl = async (videoId: string): Promise<string> => {
|
||||
const response = await apiClient.get(`/generated-videos/${videoId}/download-url`);
|
||||
return response.data.download_url;
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface ProjectTitleItem {
|
||||
id: string;
|
||||
project_id: string;
|
||||
text: string;
|
||||
category: 'default' | 'marketing' | 'tutorial' | 'story' | 'promo';
|
||||
favorite: boolean;
|
||||
usage_count: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const getProjectTitles = async (projectId: string, activeOnly = false): Promise<ProjectTitleItem[]> => {
|
||||
const response = await apiClient.get(`/projects/${projectId}/titles`, { params: { active_only: activeOnly } });
|
||||
return response.data.items;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export const updateProjectTitle = async (titleId: string, data: { text?: string; category?: ProjectTitleItem['category']; favorite?: boolean; is_active?: boolean }): Promise<ProjectTitleItem> => {
|
||||
const response = await apiClient.patch(`/project-titles/${titleId}`, data);
|
||||
return response.data;
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface ProjectItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface CreateProjectRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const getProject = async (projectId: string): Promise<ProjectItem> => {
|
||||
const response = await apiClient.get(`/projects/${projectId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getProjects = async (): Promise<ProjectItem[]> => {
|
||||
const response = await apiClient.get('/projects');
|
||||
return response.data.items;
|
||||
};
|
||||
|
||||
export const createProject = async (data: CreateProjectRequest): Promise<ProjectItem> => {
|
||||
const response = await apiClient.post('/projects', data);
|
||||
return response.data;
|
||||
};
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* 订阅相关 API
|
||||
*/
|
||||
import apiClient from './client';
|
||||
|
||||
// 类型定义
|
||||
export interface SubscriptionPlan {
|
||||
name: 'free' | 'pro' | 'enterprise';
|
||||
display_name: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
max_projects: number;
|
||||
max_storage_gb: number;
|
||||
features: string[];
|
||||
}
|
||||
|
||||
export interface QuotaStatus {
|
||||
projects: {
|
||||
used: number;
|
||||
limit: number;
|
||||
status: 'normal' | 'warning' | 'critical' | 'exceeded';
|
||||
};
|
||||
storage: {
|
||||
used_gb: number;
|
||||
limit_gb: number;
|
||||
status: 'normal' | 'warning' | 'critical' | 'exceeded';
|
||||
};
|
||||
}
|
||||
|
||||
// 获取所有订阅计划
|
||||
export const getPlans = async (): Promise<SubscriptionPlan[]> => {
|
||||
const response = await apiClient.get('/subscriptions/plans');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 获取当前订阅
|
||||
export const getCurrentSubscription = async (
|
||||
): Promise<SubscriptionPlan> => {
|
||||
const response = await apiClient.get('/subscription');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 升级订阅
|
||||
export const upgradeSubscription = async (
|
||||
plan: 'pro' | 'enterprise'
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post('/subscription/upgrade', {
|
||||
plan,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 取消订阅
|
||||
export const cancelSubscription = async (
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post('/subscription/cancel');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 获取配额状态
|
||||
export const getQuotaStatus = async (): Promise<QuotaStatus> => {
|
||||
const response = await apiClient.get('/quota');
|
||||
return response.data;
|
||||
};
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* 邀请成员弹窗
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Modal, Form, Input, Select, message } from 'antd';
|
||||
import { useInviteMember } from '@/hooks/useWorkspace';
|
||||
|
||||
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="取消"
|
||||
>
|
||||
<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,129 +0,0 @@
|
||||
/**
|
||||
* 成员列表组件
|
||||
*/
|
||||
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';
|
||||
|
||||
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 }> = {
|
||||
owner: { color: 'gold', text: '拥有者' },
|
||||
admin: { color: 'blue', text: '管理员' },
|
||||
member: { color: 'green', text: '成员' },
|
||||
viewer: { color: 'default', text: '访客' },
|
||||
};
|
||||
const config = roleConfig[role] || roleConfig.member;
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '用户名',
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
},
|
||||
{
|
||||
title: '邮箱',
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
key: 'role',
|
||||
render: (role: string, record: any) => {
|
||||
if (role === 'owner') {
|
||||
return getRoleTag(role);
|
||||
}
|
||||
return (
|
||||
<Select
|
||||
value={role}
|
||||
style={{ width: 120 }}
|
||||
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) => new Date(date).toLocaleDateString(),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
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 (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={members}
|
||||
loading={isLoading}
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 10 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemberList;
|
||||
@@ -1,100 +0,0 @@
|
||||
/**
|
||||
* 权限矩阵展示组件 - V21 UI
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Card, Table } from 'antd';
|
||||
import { CheckOutlined, CloseOutlined } from '@ant-design/icons';
|
||||
import './business.css';
|
||||
|
||||
const PermissionMatrix: React.FC = () => {
|
||||
const permissions = [
|
||||
{ action: '查看工作空间', owner: true, admin: true, member: true, viewer: true },
|
||||
{ action: '创建项目', owner: true, admin: true, member: true, viewer: false },
|
||||
{ action: '编辑项目', owner: true, admin: true, member: true, viewer: false },
|
||||
{ action: '删除项目', owner: true, admin: true, member: false, viewer: false },
|
||||
{ action: '邀请成员', owner: true, admin: true, member: false, viewer: false },
|
||||
{ action: '移除成员', owner: true, admin: true, member: false, viewer: false },
|
||||
{ action: '修改成员角色', owner: true, admin: true, member: false, viewer: false },
|
||||
{ action: '升级订阅', owner: true, admin: false, member: false, viewer: false },
|
||||
{ action: '删除工作空间', owner: true, admin: false, member: false, viewer: false },
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
fixed: 'left' as const,
|
||||
width: 160,
|
||||
render: (text: string) => <span style={{ fontWeight: 500 }}>{text}</span>,
|
||||
},
|
||||
{
|
||||
title: '拥有者',
|
||||
dataIndex: 'owner',
|
||||
key: 'owner',
|
||||
align: 'center' as const,
|
||||
width: 100,
|
||||
render: (value: boolean) =>
|
||||
value ? (
|
||||
<CheckOutlined style={{ color: '#16a34a', fontSize: '18px' }} />
|
||||
) : (
|
||||
<CloseOutlined style={{ color: '#dc2626', fontSize: '16px' }} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '管理员',
|
||||
dataIndex: 'admin',
|
||||
key: 'admin',
|
||||
align: 'center' as const,
|
||||
width: 100,
|
||||
render: (value: boolean) =>
|
||||
value ? (
|
||||
<CheckOutlined style={{ color: '#16a34a', fontSize: '18px' }} />
|
||||
) : (
|
||||
<CloseOutlined style={{ color: '#dc2626', fontSize: '16px' }} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '成员',
|
||||
dataIndex: 'member',
|
||||
key: 'member',
|
||||
align: 'center' as const,
|
||||
width: 100,
|
||||
render: (value: boolean) =>
|
||||
value ? (
|
||||
<CheckOutlined style={{ color: '#16a34a', fontSize: '18px' }} />
|
||||
) : (
|
||||
<CloseOutlined style={{ color: '#dc2626', fontSize: '16px' }} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '访客',
|
||||
dataIndex: 'viewer',
|
||||
key: 'viewer',
|
||||
align: 'center' as const,
|
||||
width: 100,
|
||||
render: (value: boolean) =>
|
||||
value ? (
|
||||
<CheckOutlined style={{ color: '#16a34a', fontSize: '18px' }} />
|
||||
) : (
|
||||
<CloseOutlined style={{ color: '#dc2626', fontSize: '16px' }} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card title="权限说明" className="xx-card">
|
||||
<div className="xx-table-wrapper">
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={permissions}
|
||||
pagination={false}
|
||||
rowKey="action"
|
||||
size="middle"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PermissionMatrix;
|
||||
@@ -89,7 +89,6 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
/* 表格包装器 */
|
||||
.xx-table-wrapper {
|
||||
border-radius: 16px;
|
||||
@@ -223,7 +222,6 @@
|
||||
box-shadow: 0 8px 24px rgba(79,70,229,0.1);
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
/* ==================== 进度条 ==================== */
|
||||
.xx-progress {
|
||||
margin-top: 12px;
|
||||
@@ -231,7 +229,6 @@
|
||||
|
||||
/* ==================== Ant Design 覆盖样式 ==================== */
|
||||
/* Table overrides */
|
||||
/* ==================== Ant Design 覆盖样式 ==================== */
|
||||
.ant-table-wrapper .ant-table-thead > tr > th {
|
||||
background: #f8fafc !important;
|
||||
font-weight: 700 !important;
|
||||
@@ -249,7 +246,6 @@
|
||||
background: #fafbfc !important;
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
/* Card overrides */
|
||||
.ant-card {
|
||||
border-radius: 22px !important;
|
||||
@@ -272,7 +268,6 @@
|
||||
padding: 20px 24px !important;
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
/* Modal overrides */
|
||||
.ant-modal-content {
|
||||
border-radius: 22px !important;
|
||||
@@ -298,7 +293,6 @@
|
||||
padding: 16px 24px !important;
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
/* Button overrides */
|
||||
.ant-btn-primary {
|
||||
background: linear-gradient(135deg, #6366f1, #4f46e5) !important;
|
||||
@@ -316,7 +310,6 @@
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
/* Tag overrides */
|
||||
.ant-tag {
|
||||
border-radius: 10px !important;
|
||||
@@ -324,7 +317,6 @@
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
/* Select overrides */
|
||||
.ant-select-selector {
|
||||
border-radius: 14px !important;
|
||||
@@ -340,7 +332,6 @@
|
||||
box-shadow: 0 0 0 3px rgba(79,70,229,0.1) !important;
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
/* Input overrides */
|
||||
.ant-input {
|
||||
border-radius: 14px !important;
|
||||
@@ -357,7 +348,6 @@
|
||||
box-shadow: 0 0 0 3px rgba(79,70,229,0.1) !important;
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
/* Progress overrides */
|
||||
.ant-progress-inner {
|
||||
background: #f1f5f9 !important;
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
/**
|
||||
* 侧边栏导航
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Layout, Menu } from 'antd';
|
||||
import {
|
||||
HomeOutlined,
|
||||
AppstoreOutlined,
|
||||
TeamOutlined,
|
||||
CrownOutlined,
|
||||
UserOutlined,
|
||||
DashboardOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import type { MenuProps } from 'antd';
|
||||
|
||||
const { Sider } = Layout;
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: '/',
|
||||
icon: <HomeOutlined />,
|
||||
label: '首页',
|
||||
onClick: () => navigate('/'),
|
||||
},
|
||||
{
|
||||
key: '/projects',
|
||||
icon: <AppstoreOutlined />,
|
||||
label: '项目',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
key: '/members',
|
||||
icon: <TeamOutlined />,
|
||||
label: '成员管理',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
key: '/subscription',
|
||||
icon: <CrownOutlined />,
|
||||
label: '订阅管理',
|
||||
onClick: () => navigate('/subscription'),
|
||||
},
|
||||
{
|
||||
key: '/admin',
|
||||
icon: <DashboardOutlined />,
|
||||
label: 'Admin(暂未开放)',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
key: '/profile',
|
||||
icon: <UserOutlined />,
|
||||
label: '个人中心',
|
||||
onClick: () => navigate('/profile'),
|
||||
},
|
||||
];
|
||||
|
||||
// 根据当前路径选中菜单项
|
||||
const selectedKey = '/' + location.pathname.split('/')[1];
|
||||
|
||||
return (
|
||||
<Sider
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
trigger={null}
|
||||
width={200}
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
height: '100vh',
|
||||
position: 'sticky',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: 64,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{collapsed ? '🦐' : '小虾 SaaS'}
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey]}
|
||||
items={menuItems}
|
||||
/>
|
||||
</Sider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
@@ -1,245 +0,0 @@
|
||||
/**
|
||||
* Admin Analytics 数据分析页面
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Card, Row, Col, Statistic, Table, DatePicker, Space } from 'antd';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
BarChart,
|
||||
Bar,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import {
|
||||
ArrowUpOutlined,
|
||||
UserAddOutlined,
|
||||
DollarOutlined,
|
||||
ProjectOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import './Admin.css';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
// V21 配色
|
||||
const V21_COLORS = {
|
||||
primary: '#4f46e5',
|
||||
primaryLight: '#6366f1',
|
||||
success: '#10b981',
|
||||
successLight: '#34d399',
|
||||
warning: '#f59e0b',
|
||||
warningLight: '#fbbf24',
|
||||
purple: '#8b5cf6',
|
||||
purpleLight: '#a78bfa',
|
||||
};
|
||||
|
||||
const Analytics: React.FC = () => {
|
||||
// 模拟用户增长数据
|
||||
const userGrowthData = [
|
||||
{ month: '1月', users: 120, paid: 15 },
|
||||
{ month: '2月', users: 180, paid: 28 },
|
||||
{ month: '3月', users: 240, paid: 42 },
|
||||
{ month: '4月', users: 320, paid: 58 },
|
||||
{ month: '5月', users: 450, paid: 89 },
|
||||
{ month: '6月', users: 620, paid: 135 },
|
||||
];
|
||||
|
||||
// 模拟营收数据
|
||||
const revenueData = [
|
||||
{ month: '1月', revenue: 1480 },
|
||||
{ month: '2月', revenue: 2770 },
|
||||
{ month: '3月', revenue: 4160 },
|
||||
{ month: '4月', revenue: 5740 },
|
||||
{ month: '5月', revenue: 8810 },
|
||||
{ month: '6月', revenue: 13365 },
|
||||
];
|
||||
|
||||
// 订阅计划分布
|
||||
const planDistribution = [
|
||||
{ name: 'Free', value: 485, color: '#a78bfa' },
|
||||
{ name: 'Pro', value: 120, color: '#34d399' },
|
||||
{ name: 'Enterprise', value: 15, color: '#fbbf24' },
|
||||
];
|
||||
|
||||
// 活跃度统计
|
||||
const activityStats = [
|
||||
{ metric: '日活跃用户 (DAU)', value: 892, growth: '+12.5%', icon: <UserAddOutlined />, colorClass: 'primary' },
|
||||
{ metric: '月活跃用户 (MAU)', value: 3456, growth: '+8.3%', icon: <ArrowUpOutlined />, colorClass: 'success' },
|
||||
{ metric: '本月收入', value: 13365, prefix: '¥', growth: '+51.6%', icon: <DollarOutlined />, colorClass: 'warning' },
|
||||
{ metric: '活跃项目数', value: 2341, growth: '+18.2%', icon: <ProjectOutlined />, colorClass: 'purple' },
|
||||
];
|
||||
|
||||
// 用户留存表格
|
||||
const retentionData = [
|
||||
{ cohort: '2024-01', day1: '100%', day7: '68%', day30: '45%', day90: '28%' },
|
||||
{ cohort: '2024-02', day1: '100%', day7: '72%', day30: '48%', day90: '31%' },
|
||||
{ cohort: '2024-03', day1: '100%', day7: '75%', day30: '52%', day90: '-' },
|
||||
{ cohort: '2024-04', day1: '100%', day7: '78%', day30: '55%', day90: '-' },
|
||||
{ cohort: '2024-05', day1: '100%', day7: '80%', day30: '-', day90: '-' },
|
||||
];
|
||||
|
||||
const retentionColumns = [
|
||||
{ title: 'Cohort', dataIndex: 'cohort', key: 'cohort' },
|
||||
{ title: 'Day 1', dataIndex: 'day1', key: 'day1' },
|
||||
{ title: 'Day 7', dataIndex: 'day7', key: 'day7' },
|
||||
{ title: 'Day 30', dataIndex: 'day30', key: 'day30' },
|
||||
{ title: 'Day 90', dataIndex: 'day90', key: 'day90' },
|
||||
];
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
primary: '#4f46e5',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
purple: '#8b5cf6',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="analytics-page">
|
||||
<div className="xx-page-head">
|
||||
<div className="xx-page-head-content">
|
||||
<h2>数据分析</h2>
|
||||
<p>用户增长、营收趋势与留存分析</p>
|
||||
</div>
|
||||
<div className="xx-page-head-actions">
|
||||
<Space>
|
||||
<RangePicker className="xx-date-picker" />
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 关键指标 */}
|
||||
<Row gutter={24} className="xx-grid-4">
|
||||
{activityStats.map((stat, index) => (
|
||||
<Col span={6} key={index}>
|
||||
<div className="xx-stat-card">
|
||||
<div className="xx-stat-card-header">
|
||||
<div className={`xx-stat-card-icon ${stat.colorClass}`}>
|
||||
{stat.icon}
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-stat-card-label">{stat.metric}</div>
|
||||
<div
|
||||
className="xx-stat-card-value"
|
||||
style={{ color: colorMap[stat.colorClass] }}
|
||||
>
|
||||
{stat.prefix}{stat.value.toLocaleString()}
|
||||
</div>
|
||||
<div className="xx-stat-card-growth">
|
||||
{stat.growth} vs 上月
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{/* 图表区域 */}
|
||||
<Row gutter={24} style={{ marginBottom: 24 }}>
|
||||
{/* 用户增长趋势 */}
|
||||
<Col span={16}>
|
||||
<div className="xx-chart-container">
|
||||
<Card title="用户增长趋势">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={userGrowthData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="users"
|
||||
stroke={V21_COLORS.primary}
|
||||
name="总用户"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="paid"
|
||||
stroke={V21_COLORS.success}
|
||||
name="付费用户"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
{/* 订阅计划分布 */}
|
||||
<Col span={8}>
|
||||
<div className="xx-chart-container">
|
||||
<Card title="订阅计划分布">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={planDistribution}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={(entry) => `${entry.name}: ${entry.value}`}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{planDistribution.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 营收趋势 */}
|
||||
<Row gutter={24} style={{ marginBottom: 24 }}>
|
||||
<Col span={24}>
|
||||
<div className="xx-chart-container">
|
||||
<Card title="营收趋势">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={revenueData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="revenue"
|
||||
fill={V21_COLORS.warning}
|
||||
name="收入 (¥)"
|
||||
radius={[8, 8, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 用户留存表 */}
|
||||
<div className="xx-table-wrapper">
|
||||
<Card title="用户留存率 (Cohort Analysis)">
|
||||
<Table
|
||||
columns={retentionColumns}
|
||||
dataSource={retentionData}
|
||||
rowKey="cohort"
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Analytics;
|
||||
|
||||
export const Component = Analytics;
|
||||
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* Admin Dashboard 仪表盘
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Card, Row, Col, Statistic, Table } from 'antd';
|
||||
import {
|
||||
UserOutlined,
|
||||
CrownOutlined,
|
||||
DollarOutlined,
|
||||
RiseOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import './Admin.css';
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
// 模拟数据
|
||||
const stats = [
|
||||
{ title: '总用户数', value: 1234, icon: <UserOutlined />, colorClass: 'primary' },
|
||||
{ title: '付费用户', value: 156, icon: <CrownOutlined />, colorClass: 'success' },
|
||||
{ title: '月收入', value: 15444, prefix: '¥', icon: <DollarOutlined />, colorClass: 'warning' },
|
||||
{ title: '活跃用户', value: 892, icon: <RiseOutlined />, colorClass: 'purple' },
|
||||
];
|
||||
|
||||
const recentUsers = [
|
||||
{ id: '1', username: 'user1', email: 'user1@example.com', created_at: '2024-06-17' },
|
||||
{ id: '2', username: 'user2', email: 'user2@example.com', created_at: '2024-06-16' },
|
||||
{ id: '3', username: 'user3', email: 'user3@example.com', created_at: '2024-06-15' },
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{ title: '用户名', dataIndex: 'username', key: 'username' },
|
||||
{ title: '邮箱', dataIndex: 'email', key: 'email' },
|
||||
{ title: '注册时间', dataIndex: 'created_at', key: 'created_at' },
|
||||
];
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
primary: '#4f46e5',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
purple: '#8b5cf6',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dashboard-page">
|
||||
<div className="xx-page-head-simple">
|
||||
<h2>Dashboard</h2>
|
||||
<p>系统数据总览与关键指标</p>
|
||||
</div>
|
||||
|
||||
<Row gutter={24} className="xx-grid-4">
|
||||
{stats.map((stat, index) => (
|
||||
<Col span={6} key={index}>
|
||||
<div className="xx-stat-card">
|
||||
<div className="xx-stat-card-header">
|
||||
<div className={`xx-stat-card-icon ${stat.colorClass}`}>
|
||||
{stat.icon}
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-stat-card-label">{stat.title}</div>
|
||||
<div
|
||||
className="xx-stat-card-value"
|
||||
style={{ color: colorMap[stat.colorClass] }}
|
||||
>
|
||||
{stat.prefix}{stat.value.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<div className="xx-table-wrapper">
|
||||
<Card title="最近注册用户" className="xx-card">
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={recentUsers}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
|
||||
export const Component = Dashboard;
|
||||
@@ -1,344 +0,0 @@
|
||||
/**
|
||||
* 日志查看器
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { Card, Table, Tag, Input, Select, DatePicker, Space, Button, Drawer } from 'antd';
|
||||
import {
|
||||
SearchOutlined,
|
||||
FilterOutlined,
|
||||
DownloadOutlined,
|
||||
EyeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import './Admin.css';
|
||||
|
||||
const { Search } = Input;
|
||||
const { RangePicker } = DatePicker;
|
||||
const { Option } = Select;
|
||||
|
||||
interface LogEntry {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
level: 'INFO' | 'WARN' | 'ERROR' | 'DEBUG';
|
||||
service: string;
|
||||
user?: string;
|
||||
action: string;
|
||||
details: string;
|
||||
ip?: string;
|
||||
request_id?: string;
|
||||
}
|
||||
|
||||
const LogViewer: React.FC = () => {
|
||||
const [selectedLog, setSelectedLog] = useState<LogEntry | null>(null);
|
||||
const [drawerVisible, setDrawerVisible] = useState(false);
|
||||
const [filterLevel, setFilterLevel] = useState<string>('all');
|
||||
const [filterService, setFilterService] = useState<string>('all');
|
||||
|
||||
// 模拟日志数据
|
||||
const logs: LogEntry[] = [
|
||||
{
|
||||
id: '1',
|
||||
timestamp: '2024-06-17 17:15:32',
|
||||
level: 'INFO',
|
||||
service: 'API',
|
||||
user: 'user@example.com',
|
||||
action: 'User Login',
|
||||
details: 'User successfully logged in',
|
||||
ip: '192.168.1.100',
|
||||
request_id: 'req_abc123',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
timestamp: '2024-06-17 17:14:28',
|
||||
level: 'WARN',
|
||||
service: 'API',
|
||||
user: 'admin@example.com',
|
||||
action: 'Failed Login Attempt',
|
||||
details: 'Invalid password provided (attempt 2/5)',
|
||||
ip: '192.168.1.101',
|
||||
request_id: 'req_def456',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
timestamp: '2024-06-17 17:12:45',
|
||||
level: 'ERROR',
|
||||
service: 'Database',
|
||||
action: 'Connection Timeout',
|
||||
details: 'PostgreSQL connection pool exhausted, timeout after 5s',
|
||||
request_id: 'req_ghi789',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
timestamp: '2024-06-17 17:10:15',
|
||||
level: 'INFO',
|
||||
service: 'Celery',
|
||||
action: 'Task Completed',
|
||||
details: 'Video processing task completed successfully',
|
||||
request_id: 'task_jkl012',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
timestamp: '2024-06-17 17:08:52',
|
||||
level: 'WARN',
|
||||
service: 'ObjectStorage',
|
||||
action: 'Slow Upload',
|
||||
details: 'File upload took 3.2s (>1s threshold)',
|
||||
ip: '192.168.1.102',
|
||||
request_id: 'req_mno345',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
timestamp: '2024-06-17 17:05:33',
|
||||
level: 'ERROR',
|
||||
service: 'API',
|
||||
user: 'user2@example.com',
|
||||
action: 'Permission Denied',
|
||||
details: 'User attempted to access admin endpoint without permission',
|
||||
ip: '192.168.1.103',
|
||||
request_id: 'req_pqr678',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
timestamp: '2024-06-17 17:03:21',
|
||||
level: 'INFO',
|
||||
service: 'API',
|
||||
user: 'user3@example.com',
|
||||
action: 'Project Created', details: 'New project "My Project" created',
|
||||
ip: '192.168.1.104',
|
||||
request_id: 'req_stu901',
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
timestamp: '2024-06-17 17:01:08',
|
||||
level: 'DEBUG',
|
||||
service: 'Redis',
|
||||
action: 'Cache Miss',
|
||||
details: 'Cache key "project:123" not found, fetching from DB',
|
||||
request_id: 'req_vwx234',
|
||||
},
|
||||
];
|
||||
|
||||
const getLevelTagClass = (level: string) => {
|
||||
const classes: Record<string, string> = {
|
||||
INFO: 'xx-tag info',
|
||||
WARN: 'xx-tag warning',
|
||||
ERROR: 'xx-tag error',
|
||||
DEBUG: 'xx-tag debug',
|
||||
};
|
||||
return classes[level] || 'xx-tag debug';
|
||||
};
|
||||
|
||||
const handleViewDetails = (log: LogEntry) => {
|
||||
setSelectedLog(log);
|
||||
setDrawerVisible(true);
|
||||
};
|
||||
|
||||
const columns: ColumnsType<LogEntry> = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'timestamp',
|
||||
key: 'timestamp',
|
||||
width: 180,
|
||||
sorter: (a, b) => a.timestamp.localeCompare(b.timestamp),
|
||||
},
|
||||
{
|
||||
title: '级别',
|
||||
dataIndex: 'level',
|
||||
key: 'level',
|
||||
width: 100,
|
||||
render: (level: string) => <Tag className={getLevelTagClass(level)}>{level}</Tag>,
|
||||
filters: [
|
||||
{ text: 'INFO', value: 'INFO' },
|
||||
{ text: 'WARN', value: 'WARN' },
|
||||
{ text: 'ERROR', value: 'ERROR' },
|
||||
{ text: 'DEBUG', value: 'DEBUG' },
|
||||
],
|
||||
onFilter: (value, record) => record.level === value,
|
||||
},
|
||||
{
|
||||
title: '服务',
|
||||
dataIndex: 'service',
|
||||
key: 'service',
|
||||
width: 120,
|
||||
filters: [
|
||||
{ text: 'API', value: 'API' },
|
||||
{ text: 'Database', value: 'Database' },
|
||||
{ text: 'Celery', value: 'Celery' },
|
||||
{ text: 'ObjectStorage', value: 'ObjectStorage' },
|
||||
{ text: 'Redis', value: 'Redis' },
|
||||
],
|
||||
onFilter: (value, record) => record.service === value,
|
||||
},
|
||||
{
|
||||
title: '用户',
|
||||
dataIndex: 'user',
|
||||
key: 'user',
|
||||
width: 180,
|
||||
render: (user?: string) => user || '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: '详情',
|
||||
dataIndex: 'details',
|
||||
key: 'details',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 100,
|
||||
render: (_, record) => (
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => handleViewDetails(record)}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="log-viewer-page">
|
||||
<div className="xx-page-head-simple">
|
||||
<h2>日志查看器</h2>
|
||||
<p>系统日志查询与监控</p>
|
||||
</div>
|
||||
|
||||
<div className="xx-filter-bar">
|
||||
<Search
|
||||
placeholder="搜索日志内容..."
|
||||
allowClear
|
||||
enterButton={<SearchOutlined />}
|
||||
className="xx-search-input"
|
||||
style={{ width: 280 }}
|
||||
/>
|
||||
|
||||
<Select
|
||||
placeholder="日志级别"
|
||||
className="xx-select"
|
||||
style={{ width: 130 }}
|
||||
value={filterLevel}
|
||||
onChange={setFilterLevel}
|
||||
>
|
||||
<Option value="all">全部级别</Option>
|
||||
<Option value="INFO">INFO</Option>
|
||||
<Option value="WARN">WARN</Option>
|
||||
<Option value="ERROR">ERROR</Option>
|
||||
<Option value="DEBUG">DEBUG</Option>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
placeholder="服务"
|
||||
className="xx-select"
|
||||
style={{ width: 140 }}
|
||||
value={filterService}
|
||||
onChange={setFilterService}
|
||||
>
|
||||
<Option value="all">全部服务</Option>
|
||||
<Option value="API">API</Option>
|
||||
<Option value="Database">Database</Option>
|
||||
<Option value="Celery">Celery</Option>
|
||||
<Option value="ObjectStorage">Object Storage</Option>
|
||||
<Option value="Redis">Redis</Option>
|
||||
</Select>
|
||||
|
||||
<RangePicker showTime className="xx-date-picker" />
|
||||
|
||||
<Button icon={<FilterOutlined />} disabled className="xx-ghost-btn">
|
||||
应用过滤暂未开放
|
||||
</Button>
|
||||
<Button icon={<DownloadOutlined />} disabled className="xx-ghost-btn">
|
||||
导出日志暂未开放
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="xx-table-wrapper">
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={logs}
|
||||
rowKey="id"
|
||||
pagination={{
|
||||
total: logs.length,
|
||||
pageSize: 10,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条日志`,
|
||||
}}
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 日志详情抽屉 */}
|
||||
<Drawer
|
||||
title="日志详情"
|
||||
placement="right"
|
||||
width={600}
|
||||
open={drawerVisible}
|
||||
onClose={() => setDrawerVisible(false)}
|
||||
className="xx-drawer"
|
||||
>
|
||||
{selectedLog && (
|
||||
<div>
|
||||
<div className="xx-log-detail">
|
||||
<div className="xx-log-detail-label">时间</div>
|
||||
<div className="xx-log-detail-value">{selectedLog.timestamp}</div>
|
||||
</div>
|
||||
|
||||
<div className="xx-log-detail">
|
||||
<div className="xx-log-detail-label">级别</div>
|
||||
<div className="xx-log-detail-value">
|
||||
<Tag className={getLevelTagClass(selectedLog.level)}>{selectedLog.level}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xx-log-detail">
|
||||
<div className="xx-log-detail-label">服务</div>
|
||||
<div className="xx-log-detail-value">{selectedLog.service}</div>
|
||||
</div>
|
||||
|
||||
{selectedLog.user && (
|
||||
<div className="xx-log-detail">
|
||||
<div className="xx-log-detail-label">用户</div>
|
||||
<div className="xx-log-detail-value">{selectedLog.user}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="xx-log-detail">
|
||||
<div className="xx-log-detail-label">操作</div>
|
||||
<div className="xx-log-detail-value">{selectedLog.action}</div>
|
||||
</div>
|
||||
|
||||
<div className="xx-log-detail">
|
||||
<div className="xx-log-detail-label">详细信息</div>
|
||||
<div className="xx-log-detail-code">{selectedLog.details}</div>
|
||||
</div>
|
||||
|
||||
{selectedLog.ip && (
|
||||
<div className="xx-log-detail">
|
||||
<div className="xx-log-detail-label">IP 地址</div>
|
||||
<div className="xx-log-detail-value">{selectedLog.ip}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedLog.request_id && (
|
||||
<div className="xx-log-detail">
|
||||
<div className="xx-log-detail-label">Request ID</div>
|
||||
<div className="xx-log-detail-code">{selectedLog.request_id}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogViewer;
|
||||
|
||||
export const Component = LogViewer;
|
||||
@@ -1,224 +0,0 @@
|
||||
/**
|
||||
* 系统监控页面
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Row, Col, Progress, Badge, Table, Tag, Button, Space } from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
SyncOutlined,
|
||||
ReloadOutlined,
|
||||
DatabaseOutlined,
|
||||
ApiOutlined,
|
||||
CloudServerOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import './Admin.css';
|
||||
|
||||
const SystemMonitor: React.FC = () => {
|
||||
const [refreshTime, setRefreshTime] = useState(new Date());
|
||||
|
||||
// 模拟实时数据
|
||||
const [systemMetrics, setSystemMetrics] = useState({
|
||||
cpu: 35,
|
||||
memory: 62,
|
||||
disk: 48,
|
||||
network: 28,
|
||||
});
|
||||
|
||||
// 服务健康状态
|
||||
const services = [
|
||||
{ name: 'API 服务', status: 'healthy', uptime: '99.98%', responseTime: '45ms' },
|
||||
{ name: 'PostgreSQL', status: 'healthy', uptime: '99.99%', responseTime: '8ms' },
|
||||
{ name: 'Redis', status: 'healthy', uptime: '99.97%', responseTime: '2ms' },
|
||||
{ name: 'Celery Worker', status: 'healthy', uptime: '99.95%', responseTime: '-' },
|
||||
{ name: 'Object Storage', status: 'warning', uptime: '99.85%', responseTime: '120ms' },
|
||||
];
|
||||
|
||||
// API 请求统计
|
||||
const apiStats = [
|
||||
{ endpoint: 'POST /api/v1/auth/login', requests: 15420, avgTime: '48ms', errors: 12 },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
// 错误日志
|
||||
const recentErrors = [
|
||||
{ time: '2024-06-17 16:45:32', level: 'ERROR', service: 'API', message: 'Database connection timeout' },
|
||||
{ time: '2024-06-17 16:42:15', level: 'WARN', service: 'Object Storage', message: 'Slow response detected (>1s)' },
|
||||
{ time: '2024-06-17 16:38:41', level: 'ERROR', service: 'Celery', message: 'Task retry limit exceeded' },
|
||||
];
|
||||
|
||||
const serviceColumns = [
|
||||
{
|
||||
title: '服务名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text: string) => <><CloudServerOutlined /> {text}</>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => {
|
||||
const config: Record<string, { color: string; icon: React.ReactNode; text: string }> = {
|
||||
healthy: { color: 'success', icon: <CheckCircleOutlined />, text: '正常' },
|
||||
warning: { color: 'warning', icon: <SyncOutlined spin />, text: '警告' },
|
||||
error: { color: 'error', icon: <CloseCircleOutlined />, text: '故障' },
|
||||
};
|
||||
const { color, text } = config[status] || config.healthy;
|
||||
return <Badge status={color as any} text={text} />;
|
||||
},
|
||||
},
|
||||
{ title: '可用性', dataIndex: 'uptime', key: 'uptime' },
|
||||
{ title: '响应时间', dataIndex: 'responseTime', key: 'responseTime' },
|
||||
];
|
||||
|
||||
const apiColumns = [
|
||||
{ title: 'API 端点', dataIndex: 'endpoint', key: 'endpoint' },
|
||||
{ title: '请求数', dataIndex: 'requests', key: 'requests', sorter: (a: any, b: any) => a.requests - b.requests },
|
||||
{ title: '平均响应时间', dataIndex: 'avgTime', key: 'avgTime' },
|
||||
{
|
||||
title: '错误数',
|
||||
dataIndex: 'errors',
|
||||
key: 'errors',
|
||||
render: (errors: number) => (
|
||||
<Tag className={errors > 10 ? 'xx-tag error' : errors > 5 ? 'xx-tag warning' : 'xx-tag success'}>{errors}</Tag>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const errorColumns = [
|
||||
{ title: '时间', dataIndex: 'time', key: 'time', width: 180 },
|
||||
{
|
||||
title: '级别',
|
||||
dataIndex: 'level',
|
||||
key: 'level',
|
||||
width: 100,
|
||||
render: (level: string) => (
|
||||
<Tag className={level === 'ERROR' ? 'xx-tag error' : 'xx-tag warning'}>{level}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '服务', dataIndex: 'service', key: 'service', width: 120 },
|
||||
{ title: '错误信息', dataIndex: 'message', key: 'message' },
|
||||
];
|
||||
|
||||
const refreshMetrics = () => {
|
||||
setSystemMetrics({
|
||||
cpu: Math.floor(Math.random() * 30) + 30,
|
||||
memory: Math.floor(Math.random() * 20) + 55,
|
||||
disk: Math.floor(Math.random() * 10) + 45,
|
||||
network: Math.floor(Math.random() * 40) + 20,
|
||||
});
|
||||
setRefreshTime(new Date());
|
||||
};
|
||||
|
||||
// 模拟实时更新
|
||||
useEffect(() => {
|
||||
const interval = setInterval(refreshMetrics, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const getProgressColor = (value: number) => {
|
||||
if (value > 80) return '#ef4444';
|
||||
if (value > 60) return '#f59e0b';
|
||||
return '#10b981';
|
||||
};
|
||||
|
||||
const getProgressClass = (value: number) => {
|
||||
if (value > 80) return 'xx-progress-warning';
|
||||
if (value > 60) return 'xx-progress-warning';
|
||||
return 'xx-progress-success';
|
||||
};
|
||||
|
||||
const resourceMetrics = [
|
||||
{ key: 'cpu', label: 'CPU 使用率', value: systemMetrics.cpu, icon: <ThunderboltOutlined />, bg: 'linear-gradient(135deg, #6366f1, #4f46e5)' },
|
||||
{ key: 'memory', label: '内存使用率', value: systemMetrics.memory, icon: <DatabaseOutlined />, bg: 'linear-gradient(135deg, #34d399, #10b981)' },
|
||||
{ key: 'disk', label: '磁盘使用率', value: systemMetrics.disk, icon: <CloudServerOutlined />, bg: 'linear-gradient(135deg, #fbbf24, #f59e0b)' },
|
||||
{ key: 'network', label: '网络使用率', value: systemMetrics.network, icon: <ApiOutlined />, bg: 'linear-gradient(135deg, #a78bfa, #8b5cf6)' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="system-monitor-page">
|
||||
<div className="xx-page-head">
|
||||
<div className="xx-page-head-content">
|
||||
<h2>系统监控</h2>
|
||||
<p>服务器资源、服务健康与 API 统计</p>
|
||||
</div>
|
||||
<div className="xx-page-head-actions">
|
||||
<Space>
|
||||
<span className="xx-refresh-time">上次更新: {refreshTime.toLocaleTimeString()}</span>
|
||||
<Button icon={<ReloadOutlined />} onClick={refreshMetrics} className="xx-primary-btn">
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 系统资源监控 */}
|
||||
<Row gutter={24} className="xx-grid-4">
|
||||
{resourceMetrics.map((metric) => (
|
||||
<Col span={6} key={metric.key}>
|
||||
<div className="xx-resource-card">
|
||||
<div
|
||||
className="xx-resource-card-icon"
|
||||
style={{ background: metric.bg, color: 'white' }}
|
||||
>
|
||||
{metric.icon}
|
||||
</div>
|
||||
<div className="xx-resource-card-label">{metric.label}</div>
|
||||
<Progress
|
||||
type="circle"
|
||||
percent={metric.value}
|
||||
strokeColor={getProgressColor(metric.value)}
|
||||
className={getProgressClass(metric.value)}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{/* 服务健康状态 */}
|
||||
<div className="xx-table-wrapper" style={{ marginBottom: 24 }}>
|
||||
<Card title="服务健康状态" className="xx-card">
|
||||
<Table
|
||||
columns={serviceColumns}
|
||||
dataSource={services}
|
||||
rowKey="name"
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* API 请求统计 */}
|
||||
<div className="xx-table-wrapper" style={{ marginBottom: 24 }}>
|
||||
<Card title="API 请求统计 (最近 1 小时)" className="xx-card">
|
||||
<Table
|
||||
columns={apiColumns}
|
||||
dataSource={apiStats}
|
||||
rowKey="endpoint"
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 最近错误 */}
|
||||
<div className="xx-table-wrapper">
|
||||
<Card title="最近错误日志" className="xx-card">
|
||||
<Table
|
||||
columns={errorColumns}
|
||||
dataSource={recentErrors}
|
||||
rowKey="time"
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemMonitor;
|
||||
|
||||
export const Component = SystemMonitor;
|
||||
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* Admin 用户管理页面
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Table, Button, Space, Tag, Input, Card } from 'antd';
|
||||
import { SearchOutlined, LockOutlined, UnlockOutlined } from '@ant-design/icons';
|
||||
import './Admin.css';
|
||||
|
||||
const { Search } = Input;
|
||||
|
||||
const UserManagement: React.FC = () => {
|
||||
// 模拟数据
|
||||
const users = [
|
||||
{
|
||||
id: '1',
|
||||
username: 'user1',
|
||||
email: 'user1@example.com',
|
||||
is_email_verified: true,
|
||||
status: 'active',
|
||||
created_at: '2024-01-15',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
username: 'user2',
|
||||
email: 'user2@example.com',
|
||||
is_email_verified: false,
|
||||
status: 'active',
|
||||
created_at: '2024-02-20',
|
||||
},
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '用户名',
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
},
|
||||
{
|
||||
title: '邮箱',
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
},
|
||||
{
|
||||
title: '邮箱验证',
|
||||
dataIndex: 'is_email_verified',
|
||||
key: 'is_email_verified',
|
||||
render: (verified: boolean) => (
|
||||
<Tag className={verified ? 'xx-tag success' : 'xx-tag debug'}>
|
||||
{verified ? '已验证' : '未验证'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => (
|
||||
<Tag className={status === 'active' ? 'xx-tag success' : 'xx-tag error'}>
|
||||
{status === 'active' ? '正常' : '已封禁'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '注册时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: any, record: any) => (
|
||||
<Space>
|
||||
<Button type="link" size="small" disabled>
|
||||
详情暂未开放
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger={record.status === 'active'}
|
||||
icon={record.status === 'active' ? <LockOutlined /> : <UnlockOutlined />}
|
||||
disabled
|
||||
>
|
||||
{record.status === 'active' ? '封禁暂未开放' : '解封暂未开放'}
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="user-management-page">
|
||||
<div className="xx-page-head-simple">
|
||||
<h2>用户管理</h2>
|
||||
<p>管理系统用户与账户状态</p>
|
||||
</div>
|
||||
|
||||
<div className="xx-table-wrapper">
|
||||
<Card title="用户列表" className="xx-card">
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Search
|
||||
placeholder="搜索用户名或邮箱"
|
||||
allowClear
|
||||
enterButton={<SearchOutlined />}
|
||||
className="xx-search-input"
|
||||
style={{ width: 320 }}
|
||||
/>
|
||||
</div>
|
||||
<Table columns={columns} dataSource={users} rowKey="id" />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserManagement;
|
||||
|
||||
export const Component = UserManagement;
|
||||
@@ -1,111 +0,0 @@
|
||||
/**
|
||||
* 账号安全设置页面
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Form, Input, Button, Alert } from 'antd';
|
||||
import { LockOutlined, MailOutlined } from '@ant-design/icons';
|
||||
import './ProfileSettings.css';
|
||||
|
||||
const AccountSecurity: React.FC = () => {
|
||||
const [passwordForm] = Form.useForm();
|
||||
const [emailForm] = Form.useForm();
|
||||
|
||||
const onPasswordSubmit = () => undefined;
|
||||
|
||||
const onEmailSubmit = () => undefined;
|
||||
|
||||
|
||||
return (
|
||||
<div className="xx-settings-page">
|
||||
<div className="xx-settings-card">
|
||||
<h3>修改密码</h3>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 20 }}
|
||||
message="账号安全设置暂未开放"
|
||||
description="当前后端尚未提供登录态修改密码/邮箱接口,避免假成功,入口暂时禁用。"
|
||||
/>
|
||||
<Form form={passwordForm} layout="vertical" onFinish={onPasswordSubmit}>
|
||||
<Form.Item
|
||||
name="currentPassword"
|
||||
label="当前密码"
|
||||
rules={[{ required: true, message: '请输入当前密码' }]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="newPassword"
|
||||
label="新密码"
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{ min: 8, message: '密码至少 8 个字符' },
|
||||
]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="confirmPassword"
|
||||
label="确认新密码"
|
||||
dependencies={['newPassword']}
|
||||
rules={[
|
||||
{ required: true, message: '请确认新密码' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('newPassword') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('两次输入的密码不一致'));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" disabled>
|
||||
修改密码暂未开放
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<div className="xx-settings-card">
|
||||
<h3>修改邮箱</h3>
|
||||
<Form form={emailForm} layout="vertical" onFinish={onEmailSubmit}>
|
||||
<Form.Item
|
||||
name="newEmail"
|
||||
label="新邮箱地址"
|
||||
rules={[
|
||||
{ required: true, message: '请输入新邮箱' },
|
||||
{ type: 'email', message: '请输入有效的邮箱地址' },
|
||||
]}
|
||||
>
|
||||
<Input prefix={<MailOutlined />} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="确认密码"
|
||||
rules={[{ required: true, message: '请输入密码以确认修改' }]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" disabled>
|
||||
修改邮箱暂未开放
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountSecurity;
|
||||
|
||||
export const Component = AccountSecurity;
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* 通知设置页面
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Form, Switch, Button, Divider, Alert } from 'antd';
|
||||
import './ProfileSettings.css';
|
||||
|
||||
|
||||
const NotificationSettings: React.FC = () => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const onFinish = () => undefined;
|
||||
|
||||
|
||||
return (
|
||||
<div className="xx-settings-page">
|
||||
<div className="xx-settings-card">
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 20 }}
|
||||
message="通知偏好暂未开放"
|
||||
description="当前后端尚未提供通知偏好保存接口,避免假成功,保存入口暂时禁用。"
|
||||
/>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={onFinish}
|
||||
initialValues={{
|
||||
emailNotifications: true,
|
||||
inviteNotifications: true,
|
||||
projectNotifications: false,
|
||||
marketingEmails: false,
|
||||
}}
|
||||
>
|
||||
<h3 className="xx-section-title">邮件通知</h3>
|
||||
<Form.Item
|
||||
name="emailNotifications"
|
||||
label="启用邮件通知"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Divider />
|
||||
|
||||
<h3 className="xx-section-title">通知类型</h3>
|
||||
<Form.Item
|
||||
name="inviteNotifications"
|
||||
label="工作空间邀请通知"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="projectNotifications"
|
||||
label="项目更新通知"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="subscriptionNotifications"
|
||||
label="订阅和账单通知"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Divider />
|
||||
|
||||
<h3 className="xx-section-title">营销</h3>
|
||||
<Form.Item
|
||||
name="marketingEmails"
|
||||
label="接收产品更新和优惠信息"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" disabled>
|
||||
保存设置暂未开放
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationSettings;
|
||||
|
||||
export const Component = NotificationSettings;
|
||||
@@ -1,21 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Result } from 'antd';
|
||||
import './ProfileSettings.css';
|
||||
|
||||
const SessionManagement: React.FC = () => {
|
||||
return (
|
||||
<div className="xx-settings-page">
|
||||
<div className="xx-result-page">
|
||||
<Result
|
||||
status="info"
|
||||
title="会话管理暂未开放"
|
||||
subTitle="当前版本未接入会话列表、设备管理和远程登出后端服务,因此不展示模拟设备,也不会伪造登出操作。"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SessionManagement;
|
||||
|
||||
export const Component = SessionManagement;
|
||||
@@ -1,196 +0,0 @@
|
||||
/**
|
||||
* 工作空间详情页面
|
||||
*/
|
||||
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';
|
||||
|
||||
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(),
|
||||
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({
|
||||
name: values.name,
|
||||
description: values.description || '',
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) return <div>加载中...</div>;
|
||||
if (!workspace) return <div>工作空间不存在</div>;
|
||||
|
||||
const items = [
|
||||
{
|
||||
key: 'overview',
|
||||
label: '概览',
|
||||
children: (
|
||||
<div>
|
||||
<Card
|
||||
title="工作空间信息"
|
||||
style={{ marginBottom: 16 }}
|
||||
extra={
|
||||
<Button type="primary" 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="项目" 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="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>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setInviteModalOpen(true)}
|
||||
>
|
||||
邀请成员
|
||||
</Button>
|
||||
</div>
|
||||
<MemberList workspaceId={id!} />
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<PermissionMatrix />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
label: '设置',
|
||||
children: <div>工作空间设置(待开发)</div>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<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}
|
||||
onClose={() => setInviteModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceDetail;
|
||||
export const Component = WorkspaceDetail;
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* UI 状态管理
|
||||
* 管理全局 UI 状态(侧边栏、加载状态等)
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface UIState {
|
||||
sidebarCollapsed: boolean;
|
||||
loading: boolean;
|
||||
|
||||
// Actions
|
||||
toggleSidebar: () => void;
|
||||
setSidebarCollapsed: (collapsed: boolean) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>((set) => ({
|
||||
sidebarCollapsed: false,
|
||||
loading: false,
|
||||
|
||||
toggleSidebar: () => {
|
||||
set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed }));
|
||||
},
|
||||
|
||||
setSidebarCollapsed: (collapsed) => {
|
||||
set({ sidebarCollapsed: collapsed });
|
||||
},
|
||||
|
||||
setLoading: (loading) => {
|
||||
set({ loading });
|
||||
},
|
||||
}));
|
||||
@@ -17,8 +17,8 @@
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path alias */
|
||||
|
||||
@@ -15,13 +15,8 @@ from typing import Optional
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EditingMode(StrEnum):
|
||||
"""剪辑模式枚举"""
|
||||
|
||||
ONE_TAKE = "one_take" # 一镜到底:顺序拼接+转场
|
||||
PIP = "pip" # 画中画:主视频+叠加
|
||||
VOICE_OVER = "voice_over" # 口播:背景画面+配音
|
||||
VOICE_PIP = "voice_pip" # 口播+画中画
|
||||
# 从 domain 层导入 EditingMode,避免重复定义
|
||||
from packages.domain.edit_plan import EditingMode
|
||||
|
||||
|
||||
class PIPPosition(StrEnum):
|
||||
|
||||
@@ -138,17 +138,9 @@ def _download_library_assets(
|
||||
# 导入模型和会话
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from worker_app.db import SessionLocal
|
||||
|
||||
db_url = os.getenv("DATABASE_URL")
|
||||
if not db_url:
|
||||
logger.warning("DATABASE_URL not set, cannot fetch assets")
|
||||
return []
|
||||
|
||||
engine = create_engine(db_url)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
session = SessionLocal()
|
||||
|
||||
try:
|
||||
# 查询素材库中的视频素材
|
||||
|
||||
@@ -116,7 +116,7 @@ services:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2.0'
|
||||
memory: 4G
|
||||
memory: 2G
|
||||
reservations:
|
||||
cpus: '0.5'
|
||||
memory: 1G
|
||||
|
||||
@@ -25,9 +25,9 @@ class UserModel(Base):
|
||||
subscription_status = Column(String(20), nullable=False, default="active")
|
||||
subscription_expires_at = Column(DateTime, nullable=True)
|
||||
# 配额限制 (移到 User 级别)
|
||||
max_projects = Column(Float, nullable=False, default=3)
|
||||
max_storage_gb = Column(Float, nullable=False, default=10)
|
||||
used_storage_gb = Column(Float, nullable=False, default=0.0)
|
||||
max_projects = Column(Integer, nullable=False, default=3)
|
||||
max_storage_gb = Column(Integer, nullable=False, default=10)
|
||||
used_storage_gb = Column(Integer, nullable=False, default=0)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user