This commit is contained in:
xiamuceer
2025-10-30 11:14:43 +08:00
parent b97410d973
commit 0f6c2d344a
91 changed files with 22309 additions and 0 deletions
+127
View File
@@ -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,
};
+173
View File
@@ -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>
);
};
+346
View File
@@ -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>
</>
);
}