init
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
// 统一的卡片样式配置
|
||||
export const cardStyles = {
|
||||
// 基础卡片样式
|
||||
base: {
|
||||
borderRadius: 12,
|
||||
transition: 'all 0.3s ease',
|
||||
} as CSSProperties,
|
||||
|
||||
// 悬浮效果
|
||||
hoverable: {
|
||||
cursor: 'pointer',
|
||||
} as CSSProperties,
|
||||
|
||||
// 角色卡片样式
|
||||
character: {
|
||||
// height: 320,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderColor: '#1890ff',
|
||||
borderRadius: 12,
|
||||
} as CSSProperties,
|
||||
|
||||
// 组织卡片样式
|
||||
organization: {
|
||||
// height: 320,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderColor: '#52c41a',
|
||||
backgroundColor: '#f6ffed',
|
||||
borderRadius: 12,
|
||||
} as CSSProperties,
|
||||
|
||||
// 项目卡片样式
|
||||
project: {
|
||||
height: '100%',
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
background: '#fff',
|
||||
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.08)',
|
||||
transition: 'all 0.3s ease',
|
||||
} as CSSProperties,
|
||||
|
||||
// 卡片内容区域样式
|
||||
body: {
|
||||
padding: 20,
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
} as CSSProperties,
|
||||
|
||||
// 卡片描述区域样式(固定高度,内容截断)
|
||||
description: {
|
||||
marginTop: 12,
|
||||
maxHeight: 200,
|
||||
overflow: 'hidden' as const,
|
||||
} as CSSProperties,
|
||||
|
||||
// 文本截断样式
|
||||
ellipsis: {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap' as const,
|
||||
} as CSSProperties,
|
||||
|
||||
// 多行文本截断
|
||||
ellipsisMultiline: (lines: number = 2) => ({
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: lines,
|
||||
WebkitBoxOrient: 'vertical' as const,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
} as CSSProperties),
|
||||
};
|
||||
|
||||
// 卡片悬浮动画
|
||||
export const cardHoverHandlers = {
|
||||
onMouseEnter: (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.currentTarget;
|
||||
target.style.transform = 'translateY(-8px)';
|
||||
target.style.boxShadow = '0 12px 32px rgba(0, 0, 0, 0.15)';
|
||||
},
|
||||
onMouseLeave: (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.currentTarget;
|
||||
target.style.transform = 'translateY(0)';
|
||||
target.style.boxShadow = '0 4px 16px rgba(0, 0, 0, 0.08)';
|
||||
},
|
||||
};
|
||||
|
||||
// 响应式网格配置
|
||||
export const gridConfig = {
|
||||
gutter: [16, 16] as [number, number],
|
||||
xs: 24,
|
||||
sm: 12,
|
||||
lg: 8,
|
||||
xl: 6,
|
||||
};
|
||||
|
||||
// 角色卡片网格配置
|
||||
export const characterGridConfig = {
|
||||
gutter: 0, // 移除 gutter,避免负边距
|
||||
xs: 24, // 手机:1列
|
||||
sm: 12, // 平板:2列
|
||||
md: 12, // 中等屏幕:3列
|
||||
lg: 6, // 大屏:4列
|
||||
xl: 6, // 超大屏:4列
|
||||
xxl: 5, // 超超大屏:6列
|
||||
};
|
||||
|
||||
// 文本样式
|
||||
export const textStyles = {
|
||||
label: {
|
||||
fontSize: 12,
|
||||
color: 'rgba(0, 0, 0, 0.45)',
|
||||
} as CSSProperties,
|
||||
|
||||
value: {
|
||||
fontSize: 14,
|
||||
color: 'rgba(0, 0, 0, 0.85)',
|
||||
} as CSSProperties,
|
||||
|
||||
description: {
|
||||
fontSize: 12,
|
||||
color: 'rgba(0, 0, 0, 0.45)',
|
||||
lineHeight: 1.6,
|
||||
} as CSSProperties,
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Card, Space, Tag, Typography, Popconfirm } from 'antd';
|
||||
import { EditOutlined, DeleteOutlined, UserOutlined, BankOutlined } from '@ant-design/icons';
|
||||
import { cardStyles } from './CardStyles';
|
||||
import type { Character } from '../types';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
|
||||
interface CharacterCardProps {
|
||||
character: Character;
|
||||
onEdit?: (character: Character) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export const CharacterCard: React.FC<CharacterCardProps> = ({ character, onEdit, onDelete }) => {
|
||||
const getRoleTypeColor = (roleType?: string) => {
|
||||
const roleColors: Record<string, string> = {
|
||||
'protagonist': 'blue',
|
||||
'supporting': 'green',
|
||||
'antagonist': 'red',
|
||||
};
|
||||
return roleColors[roleType || ''] || 'default';
|
||||
};
|
||||
|
||||
const getRoleTypeLabel = (roleType?: string) => {
|
||||
const roleLabels: Record<string, string> = {
|
||||
'protagonist': '主角',
|
||||
'supporting': '配角',
|
||||
'antagonist': '反派',
|
||||
};
|
||||
return roleLabels[roleType || ''] || '其他';
|
||||
};
|
||||
|
||||
const isOrganization = character.is_organization;
|
||||
|
||||
return (
|
||||
<Card
|
||||
hoverable
|
||||
style={isOrganization ? cardStyles.organization : cardStyles.character}
|
||||
styles={{
|
||||
body: {
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
},
|
||||
actions: {
|
||||
borderRadius: '0 0 12px 12px'
|
||||
}
|
||||
}}
|
||||
actions={[
|
||||
...(onEdit ? [<EditOutlined key="edit" onClick={() => onEdit(character)} />] : []),
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title={`确定删除这个${isOrganization ? '组织' : '角色'}吗?`}
|
||||
onConfirm={() => onDelete(character.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</Popconfirm>,
|
||||
]}
|
||||
>
|
||||
<Card.Meta
|
||||
avatar={
|
||||
isOrganization ? (
|
||||
<BankOutlined style={{ fontSize: 32, color: '#52c41a' }} />
|
||||
) : (
|
||||
<UserOutlined style={{ fontSize: 32, color: '#1890ff' }} />
|
||||
)
|
||||
}
|
||||
title={
|
||||
<Space>
|
||||
<span style={cardStyles.ellipsis}>{character.name}</span>
|
||||
{isOrganization ? (
|
||||
<Tag color="green">组织</Tag>
|
||||
) : (
|
||||
character.role_type && (
|
||||
<Tag color={getRoleTypeColor(character.role_type)}>
|
||||
{getRoleTypeLabel(character.role_type)}
|
||||
</Tag>
|
||||
)
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<div style={cardStyles.description}>
|
||||
{/* 角色特有字段 */}
|
||||
{!isOrganization && (
|
||||
<>
|
||||
{character.age && (
|
||||
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'flex-start' }}>
|
||||
<Text type="secondary" style={{ flexShrink: 0 }}>年龄:</Text>
|
||||
<Text style={{ flex: 1 }}>{character.age}</Text>
|
||||
</div>
|
||||
)}
|
||||
{character.gender && (
|
||||
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'flex-start' }}>
|
||||
<Text type="secondary" style={{ flexShrink: 0 }}>性别:</Text>
|
||||
<Text style={{ flex: 1 }}>{character.gender}</Text>
|
||||
</div>
|
||||
)}
|
||||
{character.personality && (
|
||||
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'flex-start' }}>
|
||||
<Text type="secondary" style={{ flexShrink: 0 }}>性格:</Text>
|
||||
<Text
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
ellipsis={{ tooltip: character.personality }}
|
||||
>
|
||||
{character.personality}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 组织特有字段 */}
|
||||
{isOrganization && (
|
||||
<>
|
||||
{character.organization_type && (
|
||||
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'center' }}>
|
||||
<Text type="secondary" style={{ flexShrink: 0 }}>类型:</Text>
|
||||
<Tag color="cyan">{character.organization_type}</Tag>
|
||||
</div>
|
||||
)}
|
||||
{character.organization_purpose && (
|
||||
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'flex-start' }}>
|
||||
<Text type="secondary" style={{ flexShrink: 0 }}>目的:</Text>
|
||||
<Text
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
ellipsis={{ tooltip: character.organization_purpose }}
|
||||
>
|
||||
{character.organization_purpose}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{character.organization_members && (
|
||||
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'flex-start' }}>
|
||||
<Text type="secondary" style={{ flexShrink: 0 }}>成员:</Text>
|
||||
<Text
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
ellipsis={{
|
||||
tooltip: typeof character.organization_members === 'string'
|
||||
? character.organization_members
|
||||
: JSON.stringify(character.organization_members)
|
||||
}}
|
||||
>
|
||||
{typeof character.organization_members === 'string'
|
||||
? character.organization_members
|
||||
: JSON.stringify(character.organization_members)}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 通用字段 - 背景信息截断显示 */}
|
||||
{character.background && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Paragraph
|
||||
type="secondary"
|
||||
style={{ fontSize: 12, marginBottom: 0 }}
|
||||
ellipsis={{ tooltip: character.background, rows: 3 }}
|
||||
>
|
||||
{character.background}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { Spin } from 'antd';
|
||||
import { authApi } from '../services/api';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
await authApi.getCurrentUser();
|
||||
setIsAuthenticated(true);
|
||||
} catch {
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
};
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
if (isAuthenticated === null) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
}}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to={`/login?redirect=${encodeURIComponent(location.pathname)}`} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import React from 'react';
|
||||
import { Spin } from 'antd';
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
|
||||
interface SSELoadingOverlayProps {
|
||||
loading: boolean;
|
||||
progress: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const SSELoadingOverlay: React.FC<SSELoadingOverlayProps> = ({
|
||||
loading,
|
||||
progress,
|
||||
message
|
||||
}) => {
|
||||
if (!loading) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.45)',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 9999
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: '40px 60px',
|
||||
minWidth: 400,
|
||||
maxWidth: 600,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.2)'
|
||||
}}>
|
||||
{/* 标题和图标 */}
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
marginBottom: 24
|
||||
}}>
|
||||
<Spin
|
||||
indicator={<LoadingOutlined style={{ fontSize: 48, color: '#1890ff' }} spin />}
|
||||
/>
|
||||
<div style={{
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
marginTop: 16,
|
||||
color: '#262626'
|
||||
}}>
|
||||
AI生成中...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div style={{
|
||||
marginBottom: 16
|
||||
}}>
|
||||
<div style={{
|
||||
height: 12,
|
||||
background: '#f0f0f0',
|
||||
borderRadius: 6,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 12
|
||||
}}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
background: progress === 100
|
||||
? 'linear-gradient(90deg, #52c41a 0%, #73d13d 100%)'
|
||||
: 'linear-gradient(90deg, #1890ff 0%, #40a9ff 100%)',
|
||||
width: `${progress}%`,
|
||||
transition: 'all 0.3s ease',
|
||||
borderRadius: 6,
|
||||
boxShadow: progress > 0 ? '0 0 10px rgba(24, 144, 255, 0.3)' : 'none'
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* 进度百分比 */}
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
color: progress === 100 ? '#52c41a' : '#1890ff',
|
||||
marginBottom: 8
|
||||
}}>
|
||||
{progress}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 状态消息 */}
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
color: '#595959',
|
||||
minHeight: 24,
|
||||
padding: '0 20px'
|
||||
}}>
|
||||
{message || '准备生成...'}
|
||||
</div>
|
||||
|
||||
{/* 提示文字 */}
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
fontSize: 13,
|
||||
color: '#8c8c8c',
|
||||
marginTop: 16
|
||||
}}>
|
||||
请勿关闭页面,生成过程需要一定时间
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
|
||||
interface SSEProgressBarProps {
|
||||
loading: boolean;
|
||||
progress: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const SSEProgressBar: React.FC<SSEProgressBarProps> = ({
|
||||
loading,
|
||||
progress,
|
||||
message
|
||||
}) => {
|
||||
if (!loading) return null;
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
{/* 进度条 */}
|
||||
<div style={{
|
||||
height: 8,
|
||||
background: '#f0f0f0',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 8
|
||||
}}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
background: progress === 100 ? '#52c41a' : '#1890ff',
|
||||
width: `${progress}%`,
|
||||
transition: 'all 0.3s ease',
|
||||
borderRadius: 4
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* 进度信息 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
fontSize: 14
|
||||
}}>
|
||||
<span style={{ color: '#666' }}>
|
||||
{message || '准备生成...'}
|
||||
</span>
|
||||
<span style={{
|
||||
fontWeight: 'bold',
|
||||
color: progress === 100 ? '#52c41a' : '#1890ff'
|
||||
}}>
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,346 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Dropdown, Avatar, Space, Typography, message, Modal, Table, Button, Tag, Popconfirm, Pagination } from 'antd';
|
||||
import { UserOutlined, LogoutOutlined, TeamOutlined, CrownOutlined } from '@ant-design/icons';
|
||||
import { authApi, userApi } from '../services/api';
|
||||
import type { User } from '../types';
|
||||
import type { MenuProps } from 'antd';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function UserMenu() {
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [showUserManagement, setShowUserManagement] = useState(false);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
useEffect(() => {
|
||||
loadCurrentUser();
|
||||
}, []);
|
||||
|
||||
const loadCurrentUser = async () => {
|
||||
try {
|
||||
const user = await authApi.getCurrentUser();
|
||||
setCurrentUser(user);
|
||||
} catch (error) {
|
||||
console.error('获取用户信息失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await authApi.logout();
|
||||
message.success('已退出登录');
|
||||
window.location.href = '/login';
|
||||
} catch (error) {
|
||||
console.error('退出登录失败:', error);
|
||||
message.error('退出登录失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowUserManagement = async () => {
|
||||
if (!currentUser?.is_admin) {
|
||||
message.warning('只有管理员可以访问用户管理');
|
||||
return;
|
||||
}
|
||||
|
||||
setShowUserManagement(true);
|
||||
loadUsers();
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const userList = await userApi.listUsers();
|
||||
setUsers(userList);
|
||||
} catch (error) {
|
||||
console.error('获取用户列表失败:', error);
|
||||
message.error('获取用户列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetAdmin = async (userId: string, isAdmin: boolean) => {
|
||||
try {
|
||||
await userApi.setAdmin(userId, isAdmin);
|
||||
message.success(isAdmin ? '已设置为管理员' : '已取消管理员权限');
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
console.error('设置管理员失败:', error);
|
||||
message.error('设置管理员失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (userId: string) => {
|
||||
try {
|
||||
await userApi.deleteUser(userId);
|
||||
message.success('用户已删除');
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
console.error('删除用户失败:', error);
|
||||
message.error('删除用户失败');
|
||||
}
|
||||
};
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'user-info',
|
||||
label: (
|
||||
<div style={{ padding: '8px 0' }}>
|
||||
<Text strong>{currentUser?.display_name || currentUser?.username}</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Trust Level: {currentUser?.trust_level}
|
||||
{currentUser?.is_admin && ' · 管理员'}
|
||||
</Text>
|
||||
</div>
|
||||
),
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
...(currentUser?.is_admin ? [{
|
||||
key: 'user-management',
|
||||
icon: <TeamOutlined />,
|
||||
label: '用户管理',
|
||||
onClick: handleShowUserManagement,
|
||||
}, {
|
||||
type: 'divider' as const,
|
||||
}] : []),
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: '退出登录',
|
||||
onClick: handleLogout,
|
||||
},
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '用户名',
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
render: (text: string, record: User) => (
|
||||
<Space>
|
||||
<Avatar src={record.avatar_url} icon={<UserOutlined />} size="small" />
|
||||
<div>
|
||||
<div>{record.display_name || text}</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{text}</Text>
|
||||
</div>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Trust Level',
|
||||
dataIndex: 'trust_level',
|
||||
key: 'trust_level',
|
||||
width: 120,
|
||||
render: (level: number) => <Tag color="blue">{level}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'is_admin',
|
||||
key: 'is_admin',
|
||||
width: 100,
|
||||
render: (isAdmin: boolean) => (
|
||||
isAdmin ? <Tag color="gold" icon={<CrownOutlined />}>管理员</Tag> : <Tag>普通用户</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '最后登录',
|
||||
dataIndex: 'last_login',
|
||||
key: 'last_login',
|
||||
width: 180,
|
||||
render: (date: string) => new Date(date).toLocaleString('zh-CN'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 200,
|
||||
render: (_: unknown, record: User) => {
|
||||
const isSelf = record.user_id === currentUser?.user_id;
|
||||
return (
|
||||
<Space>
|
||||
{record.is_admin ? (
|
||||
<Popconfirm
|
||||
title="确定要取消管理员权限吗?"
|
||||
onConfirm={() => handleSetAdmin(record.user_id, false)}
|
||||
disabled={isSelf}
|
||||
>
|
||||
<Button size="small" disabled={isSelf}>
|
||||
取消管理员
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
onClick={() => handleSetAdmin(record.user_id, true)}
|
||||
>
|
||||
设为管理员
|
||||
</Button>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="确定要删除该用户吗?此操作不可恢复!"
|
||||
onConfirm={() => handleDeleteUser(record.user_id)}
|
||||
disabled={isSelf}
|
||||
>
|
||||
<Button size="small" danger disabled={isSelf}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (!currentUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown menu={{ items: menuItems }} placement="bottomRight">
|
||||
<div
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '8px 16px',
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
WebkitBackdropFilter: 'blur(10px)',
|
||||
borderRadius: 24,
|
||||
border: '1px solid rgba(102, 126, 234, 0.2)',
|
||||
transition: 'all 0.3s ease',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 1)';
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(102, 126, 234, 0.3)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.95)';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.1)';
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Avatar
|
||||
src={currentUser.avatar_url}
|
||||
icon={<UserOutlined />}
|
||||
size={40}
|
||||
style={{
|
||||
backgroundColor: '#1890ff',
|
||||
border: '3px solid #fff',
|
||||
boxShadow: '0 2px 8px rgba(102, 126, 234, 0.3)',
|
||||
}}
|
||||
/>
|
||||
{currentUser.is_admin && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: -2,
|
||||
right: -2,
|
||||
width: 18,
|
||||
height: 18,
|
||||
background: 'linear-gradient(135deg, #ffd700 0%, #ffaa00 100%)',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '2px solid white',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.2)',
|
||||
}}>
|
||||
<CrownOutlined style={{ fontSize: 9, color: '#fff' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Space direction="vertical" size={0} style={{ display: window.innerWidth <= 768 ? 'none' : 'flex' }}>
|
||||
<Text strong style={{
|
||||
color: '#262626',
|
||||
fontSize: 14,
|
||||
lineHeight: '20px',
|
||||
}}>
|
||||
{currentUser.display_name || currentUser.username}
|
||||
</Text>
|
||||
<Text style={{
|
||||
color: '#8c8c8c',
|
||||
fontSize: 12,
|
||||
lineHeight: '18px',
|
||||
}}>
|
||||
{currentUser.is_admin ? '👑 管理员' : `🎖️ Trust Level ${currentUser.trust_level}`}
|
||||
</Text>
|
||||
</Space>
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
||||
<Modal
|
||||
title="用户管理"
|
||||
open={showUserManagement}
|
||||
onCancel={() => setShowUserManagement(false)}
|
||||
footer={null}
|
||||
width={900}
|
||||
centered
|
||||
styles={{
|
||||
body: {
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: 'calc(100vh - 200px)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
}}>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
padding: '0 12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={users.slice((currentPage - 1) * pageSize, currentPage * pageSize)}
|
||||
rowKey="user_id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 800, y: 'calc(100vh - 340px)' }}
|
||||
sticky
|
||||
/>
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '16px 24px',
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
background: '#fff',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={users.length}
|
||||
showSizeChanger
|
||||
showTotal={(total) => `共 ${total} 个用户`}
|
||||
pageSizeOptions={['10', '20', '50', '100']}
|
||||
onChange={(page, newPageSize) => {
|
||||
setCurrentPage(page);
|
||||
setPageSize(newPageSize);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user