init
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
#root {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import ProjectList from './pages/ProjectList';
|
||||
import ProjectWizardNew from './pages/ProjectWizardNew';
|
||||
import ProjectDetail from './pages/ProjectDetail';
|
||||
import WorldSetting from './pages/WorldSetting';
|
||||
import Outline from './pages/Outline';
|
||||
import Characters from './pages/Characters';
|
||||
import Relationships from './pages/Relationships';
|
||||
import Organizations from './pages/Organizations';
|
||||
import Chapters from './pages/Chapters';
|
||||
// import Polish from './pages/Polish';
|
||||
import Login from './pages/Login';
|
||||
import AuthCallback from './pages/AuthCallback';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<BrowserRouter
|
||||
future={{
|
||||
v7_startTransition: true,
|
||||
v7_relativeSplatPath: true,
|
||||
}}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
|
||||
<Route path="/" element={<ProtectedRoute><ProjectList /></ProtectedRoute>} />
|
||||
<Route path="/wizard" element={<ProtectedRoute><ProjectWizardNew /></ProtectedRoute>} />
|
||||
<Route path="/project/:projectId" element={<ProtectedRoute><ProjectDetail /></ProtectedRoute>}>
|
||||
<Route index element={<Navigate to="world-setting" replace />} />
|
||||
<Route path="world-setting" element={<WorldSetting />} />
|
||||
<Route path="outline" element={<Outline />} />
|
||||
<Route path="characters" element={<Characters />} />
|
||||
<Route path="relationships" element={<Relationships />} />
|
||||
<Route path="organizations" element={<Organizations />} />
|
||||
<Route path="chapters" element={<Chapters />} />
|
||||
{/* <Route path="polish" element={<Polish />} /> */}
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
background-color: #f0f2f5;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
/* 移动端视口适配 */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
/* 禁止移动端双击缩放 */
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
/* Firefox 滚动条样式 */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #c1c1c1 #f1f1f1;
|
||||
}
|
||||
|
||||
/* 移动端响应式样式 */
|
||||
@media (max-width: 768px) {
|
||||
:root {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 移动端隐藏滚动条 */
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
/* 移动端优化触摸区域 */
|
||||
button, a, [role="button"] {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
:root {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 移动端安全区域适配 (iPhone X+) */
|
||||
@supports (padding: max(0px)) {
|
||||
body {
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
}
|
||||
|
||||
/* 移动端禁止长按选择 */
|
||||
@media (max-width: 768px) {
|
||||
img, button {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
/* 修复移动端表格分页器对齐问题 */
|
||||
.ant-pagination-simple {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ant-pagination-simple .ant-pagination-simple-pager {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.ant-pagination-simple .ant-pagination-simple-pager input {
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.ant-pagination-simple .ant-pagination-prev,
|
||||
.ant-pagination-simple .ant-pagination-next {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ConfigProvider } from 'antd'
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
import 'antd/dist/reset.css'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<App />
|
||||
</ConfigProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Spin, Result, Button } from 'antd';
|
||||
import { authApi } from '../services/api';
|
||||
|
||||
export default function AuthCallback() {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
try {
|
||||
// 后端会通过 Cookie 自动设置认证信息
|
||||
// 这里只需要验证登录状态
|
||||
await authApi.getCurrentUser();
|
||||
|
||||
setStatus('success');
|
||||
|
||||
// 从 sessionStorage 获取重定向地址
|
||||
const redirect = sessionStorage.getItem('login_redirect') || '/';
|
||||
sessionStorage.removeItem('login_redirect');
|
||||
|
||||
// 延迟一下再跳转,让用户看到成功提示
|
||||
setTimeout(() => {
|
||||
navigate(redirect);
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error);
|
||||
setStatus('error');
|
||||
setErrorMessage('登录失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
handleCallback();
|
||||
}, [navigate]);
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
}}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Spin size="large" />
|
||||
<div style={{ marginTop: 20, color: 'white', fontSize: 16 }}>
|
||||
正在处理登录...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
}}>
|
||||
<Result
|
||||
status="error"
|
||||
title="登录失败"
|
||||
subTitle={errorMessage}
|
||||
extra={
|
||||
<Button type="primary" onClick={() => navigate('/login')}>
|
||||
返回登录
|
||||
</Button>
|
||||
}
|
||||
style={{ background: 'white', padding: 40, borderRadius: 8 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
}}>
|
||||
<Result
|
||||
status="success"
|
||||
title="登录成功"
|
||||
subTitle="正在跳转..."
|
||||
style={{ background: 'white', padding: 40, borderRadius: 8 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, Tooltip } from 'antd';
|
||||
import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { useChapterSync } from '../store/hooks';
|
||||
import { projectApi } from '../services/api';
|
||||
import type { Chapter, ChapterUpdate, ApiError } from '../types';
|
||||
import { cardStyles } from '../components/CardStyles';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
export default function Chapters() {
|
||||
const { currentProject, chapters, setCurrentChapter, setCurrentProject } = useStore();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
const [isContinuing, setIsContinuing] = useState(false);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [editorForm] = Form.useForm();
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
|
||||
const contentTextAreaRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(window.innerWidth <= 768);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
refreshChapters,
|
||||
updateChapter,
|
||||
generateChapterContentStream
|
||||
} = useChapterSync();
|
||||
|
||||
useEffect(() => {
|
||||
if (currentProject?.id) {
|
||||
refreshChapters();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentProject?.id]);
|
||||
|
||||
if (!currentProject) return null;
|
||||
|
||||
const canGenerateChapter = (chapter: Chapter): boolean => {
|
||||
if (chapter.chapter_number === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const previousChapters = chapters.filter(
|
||||
c => c.chapter_number < chapter.chapter_number
|
||||
);
|
||||
|
||||
return previousChapters.every(c => c.content && c.content.trim() !== '');
|
||||
};
|
||||
|
||||
const getGenerateDisabledReason = (chapter: Chapter): string => {
|
||||
if (chapter.chapter_number === 1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const previousChapters = chapters.filter(
|
||||
c => c.chapter_number < chapter.chapter_number
|
||||
);
|
||||
|
||||
const incompleteChapters = previousChapters.filter(
|
||||
c => !c.content || c.content.trim() === ''
|
||||
);
|
||||
|
||||
if (incompleteChapters.length > 0) {
|
||||
const numbers = incompleteChapters.map(c => c.chapter_number).join('、');
|
||||
return `需要先完成前置章节:第 ${numbers} 章`;
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const handleOpenModal = (id: string) => {
|
||||
const chapter = chapters.find(c => c.id === id);
|
||||
if (chapter) {
|
||||
form.setFieldsValue(chapter);
|
||||
setEditingId(id);
|
||||
setIsModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: ChapterUpdate) => {
|
||||
if (!editingId) return;
|
||||
|
||||
try {
|
||||
await updateChapter(editingId, values);
|
||||
message.success('章节更新成功');
|
||||
setIsModalOpen(false);
|
||||
form.resetFields();
|
||||
} catch {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenEditor = (id: string) => {
|
||||
const chapter = chapters.find(c => c.id === id);
|
||||
if (chapter) {
|
||||
setCurrentChapter(chapter);
|
||||
editorForm.setFieldsValue({
|
||||
title: chapter.title,
|
||||
content: chapter.content,
|
||||
});
|
||||
setEditingId(id);
|
||||
setIsEditorOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditorSubmit = async (values: ChapterUpdate) => {
|
||||
if (!editingId || !currentProject) return;
|
||||
|
||||
try {
|
||||
await updateChapter(editingId, values);
|
||||
|
||||
// 刷新项目信息以更新总字数统计
|
||||
const updatedProject = await projectApi.getProject(currentProject.id);
|
||||
setCurrentProject(updatedProject);
|
||||
|
||||
message.success('章节保存成功');
|
||||
setIsEditorOpen(false);
|
||||
} catch {
|
||||
message.error('保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!editingId) return;
|
||||
|
||||
try {
|
||||
setIsContinuing(true);
|
||||
setIsGenerating(true);
|
||||
|
||||
await generateChapterContentStream(editingId, (content) => {
|
||||
editorForm.setFieldsValue({ content });
|
||||
|
||||
if (contentTextAreaRef.current) {
|
||||
const textArea = contentTextAreaRef.current.resizableTextArea?.textArea;
|
||||
if (textArea) {
|
||||
textArea.scrollTop = textArea.scrollHeight;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
message.success('AI创作成功');
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError;
|
||||
message.error('AI创作失败:' + (apiError.response?.data?.detail || apiError.message || '未知错误'));
|
||||
} finally {
|
||||
setIsContinuing(false);
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showGenerateModal = (chapter: Chapter) => {
|
||||
const previousChapters = chapters.filter(
|
||||
c => c.chapter_number < chapter.chapter_number
|
||||
).sort((a, b) => a.chapter_number - b.chapter_number);
|
||||
|
||||
const modal = Modal.confirm({
|
||||
title: 'AI创作章节内容',
|
||||
width: 700,
|
||||
centered: true,
|
||||
content: (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<p>AI将根据以下信息创作本章内容:</p>
|
||||
<ul>
|
||||
<li>章节大纲和要求</li>
|
||||
<li>项目的世界观设定</li>
|
||||
<li>相关角色信息</li>
|
||||
<li><strong>前面已完成章节的内容(确保剧情连贯)</strong></li>
|
||||
</ul>
|
||||
|
||||
{previousChapters.length > 0 && (
|
||||
<div style={{
|
||||
marginTop: 16,
|
||||
padding: 12,
|
||||
background: '#f0f5ff',
|
||||
borderRadius: 4,
|
||||
border: '1px solid #adc6ff'
|
||||
}}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500, color: '#1890ff' }}>
|
||||
📚 将引用的前置章节(共{previousChapters.length}章):
|
||||
</div>
|
||||
<div style={{ maxHeight: 150, overflowY: 'auto' }}>
|
||||
{previousChapters.map(ch => (
|
||||
<div key={ch.id} style={{ padding: '4px 0', fontSize: 13 }}>
|
||||
✓ 第{ch.chapter_number}章:{ch.title} ({ch.word_count || 0}字)
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: '#666' }}>
|
||||
💡 AI会参考这些章节内容,确保情节连贯、角色状态一致
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p style={{ color: '#ff4d4f', marginTop: 16, marginBottom: 0 }}>
|
||||
⚠️ 注意:此操作将覆盖当前章节内容
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
okText: '开始创作',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
modal.update({
|
||||
okButtonProps: { danger: true, loading: true },
|
||||
cancelButtonProps: { disabled: true },
|
||||
closable: false,
|
||||
maskClosable: false,
|
||||
keyboard: false,
|
||||
});
|
||||
|
||||
try {
|
||||
await handleGenerate();
|
||||
modal.destroy();
|
||||
} catch (error) {
|
||||
modal.update({
|
||||
okButtonProps: { danger: true, loading: false },
|
||||
cancelButtonProps: { disabled: false },
|
||||
closable: true,
|
||||
maskClosable: true,
|
||||
keyboard: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
onCancel: () => {
|
||||
if (isGenerating) {
|
||||
message.warning('AI正在创作中,请等待完成');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
'draft': 'default',
|
||||
'writing': 'processing',
|
||||
'completed': 'success',
|
||||
};
|
||||
return colors[status] || 'default';
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const texts: Record<string, string> = {
|
||||
'draft': '草稿',
|
||||
'writing': '创作中',
|
||||
'completed': '已完成',
|
||||
};
|
||||
return texts[status] || status;
|
||||
};
|
||||
|
||||
const sortedChapters = [...chapters].sort((a, b) => a.chapter_number - b.chapter_number);
|
||||
|
||||
const handleExport = () => {
|
||||
if (chapters.length === 0) {
|
||||
message.warning('当前项目没有章节,无法导出');
|
||||
return;
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: '导出项目章节',
|
||||
content: `确定要将《${currentProject.title}》的所有章节导出为TXT文件吗?`,
|
||||
centered: true,
|
||||
okText: '确定导出',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
try {
|
||||
projectApi.exportProject(currentProject.id);
|
||||
message.success('开始下载导出文件');
|
||||
} catch {
|
||||
message.error('导出失败,请重试');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
backgroundColor: '#fff',
|
||||
padding: isMobile ? '12px 0' : '16px 0',
|
||||
marginBottom: isMobile ? 12 : 16,
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
gap: isMobile ? 12 : 0,
|
||||
justifyContent: 'space-between',
|
||||
alignItems: isMobile ? 'stretch' : 'center'
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}>章节管理</h2>
|
||||
<Space direction={isMobile ? 'vertical' : 'horizontal'} style={{ width: isMobile ? '100%' : 'auto' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleExport}
|
||||
disabled={chapters.length === 0}
|
||||
block={isMobile}
|
||||
size={isMobile ? 'middle' : 'middle'}
|
||||
>
|
||||
导出为TXT
|
||||
</Button>
|
||||
{!isMobile && <Tag color="blue">章节由大纲管理,请在大纲页面添加/删除</Tag>}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{chapters.length === 0 ? (
|
||||
<Empty description="还没有章节,开始创作吧!" />
|
||||
) : (
|
||||
<Card style={cardStyles.base}>
|
||||
<List
|
||||
dataSource={sortedChapters}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
style={{
|
||||
padding: '16px 0',
|
||||
borderRadius: 8,
|
||||
transition: 'background 0.3s ease',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
alignItems: isMobile ? 'flex-start' : 'center'
|
||||
}}
|
||||
actions={isMobile ? undefined : [
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleOpenEditor(item.id)}
|
||||
>
|
||||
编辑内容
|
||||
</Button>,
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleOpenModal(item.id)}
|
||||
>
|
||||
修改信息
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div style={{ width: '100%' }}>
|
||||
<List.Item.Meta
|
||||
avatar={!isMobile && <FileTextOutlined style={{ fontSize: 32, color: '#1890ff' }} />}
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: isMobile ? 4 : 8, flexWrap: 'wrap', fontSize: isMobile ? 14 : 16 }}>
|
||||
<span>第{item.chapter_number}章:{item.title}</span>
|
||||
<Tag color={getStatusColor(item.status)}>{getStatusText(item.status)}</Tag>
|
||||
<Badge count={`${item.word_count || 0}字`} style={{ backgroundColor: '#52c41a' }} />
|
||||
{!canGenerateChapter(item) && (
|
||||
<Tooltip title={getGenerateDisabledReason(item)}>
|
||||
<Tag icon={<LockOutlined />} color="warning">
|
||||
需前置章节
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
item.content ? (
|
||||
<div style={{ marginTop: 8, color: 'rgba(0,0,0,0.65)', lineHeight: 1.6, fontSize: isMobile ? 12 : 14 }}>
|
||||
{item.content.substring(0, isMobile ? 80 : 150)}
|
||||
{item.content.length > (isMobile ? 80 : 150) && '...'}
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ color: 'rgba(0,0,0,0.45)', fontSize: isMobile ? 12 : 14 }}>暂无内容</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{isMobile && (
|
||||
<Space style={{ marginTop: 12, width: '100%', justifyContent: 'flex-end' }} wrap>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleOpenEditor(item.id)}
|
||||
size="small"
|
||||
title="编辑内容"
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<SettingOutlined />}
|
||||
onClick={() => handleOpenModal(item.id)}
|
||||
size="small"
|
||||
title="修改信息"
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={editingId ? '编辑章节信息' : '添加章节'}
|
||||
open={isModalOpen}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
footer={null}
|
||||
centered={!isMobile}
|
||||
width={isMobile ? 'calc(100% - 32px)' : 520}
|
||||
style={isMobile ? {
|
||||
top: 20,
|
||||
paddingBottom: 0,
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
margin: '0 16px'
|
||||
} : undefined}
|
||||
styles={{
|
||||
body: {
|
||||
maxHeight: isMobile ? 'calc(100vh - 150px)' : 'calc(80vh - 110px)',
|
||||
overflowY: 'auto'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item
|
||||
label="章节标题"
|
||||
name="title"
|
||||
tooltip="章节标题由大纲管理,建议在大纲页面统一修改"
|
||||
>
|
||||
<Input placeholder="输入章节标题" disabled />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="章节序号"
|
||||
name="chapter_number"
|
||||
tooltip="章节序号由大纲的顺序决定,无法修改。请在大纲页面使用上移/下移功能调整顺序"
|
||||
>
|
||||
<Input type="number" placeholder="章节排序序号" disabled />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="状态" name="status">
|
||||
<Select placeholder="选择状态">
|
||||
<Select.Option value="draft">草稿</Select.Option>
|
||||
<Select.Option value="writing">创作中</Select.Option>
|
||||
<Select.Option value="completed">已完成</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space style={{ float: 'right' }}>
|
||||
<Button onClick={() => setIsModalOpen(false)}>取消</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
更新
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="编辑章节内容"
|
||||
open={isEditorOpen}
|
||||
onCancel={() => {
|
||||
if (isGenerating) {
|
||||
message.warning('AI正在创作中,请等待完成后再关闭');
|
||||
return;
|
||||
}
|
||||
setIsEditorOpen(false);
|
||||
}}
|
||||
closable={!isGenerating}
|
||||
maskClosable={!isGenerating}
|
||||
keyboard={!isGenerating}
|
||||
width={isMobile ? 'calc(100% - 32px)' : '85%'}
|
||||
centered={!isMobile}
|
||||
style={isMobile ? {
|
||||
top: 20,
|
||||
paddingBottom: 0,
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
margin: '0 16px'
|
||||
} : undefined}
|
||||
styles={{
|
||||
body: {
|
||||
maxHeight: isMobile ? 'calc(100vh - 150px)' : 'calc(85vh - 110px)',
|
||||
overflowY: 'auto',
|
||||
padding: isMobile ? '16px 12px' : '8px'
|
||||
}
|
||||
}}
|
||||
footer={null}
|
||||
>
|
||||
<Form form={editorForm} layout="vertical" onFinish={handleEditorSubmit}>
|
||||
<Form.Item
|
||||
label="章节标题"
|
||||
tooltip="章节标题由大纲统一管理,建议在大纲页面修改以保持一致性"
|
||||
>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Form.Item
|
||||
name="title"
|
||||
noStyle
|
||||
>
|
||||
<Input size="large" disabled style={{ flex: 1 }} />
|
||||
</Form.Item>
|
||||
{editingId && (() => {
|
||||
const currentChapter = chapters.find(c => c.id === editingId);
|
||||
const canGenerate = currentChapter ? canGenerateChapter(currentChapter) : false;
|
||||
const disabledReason = currentChapter ? getGenerateDisabledReason(currentChapter) : '';
|
||||
|
||||
return (
|
||||
<Tooltip title={!canGenerate ? disabledReason : '根据大纲和前置章节内容创作'}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={canGenerate ? <ThunderboltOutlined /> : <LockOutlined />}
|
||||
onClick={() => currentChapter && showGenerateModal(currentChapter)}
|
||||
loading={isContinuing}
|
||||
disabled={!canGenerate}
|
||||
danger={!canGenerate}
|
||||
size="large"
|
||||
style={{ fontWeight: 'bold' }}
|
||||
>
|
||||
{isMobile ? 'AI创作' : 'AI创作章节内容'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
})()}
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="章节内容" name="content">
|
||||
<TextArea
|
||||
ref={contentTextAreaRef}
|
||||
rows={isMobile ? 12 : 20}
|
||||
placeholder="开始写作..."
|
||||
style={{ fontFamily: 'monospace', fontSize: isMobile ? 12 : 14 }}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space style={{ width: '100%', justifyContent: 'flex-end', flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'stretch' : 'center' }}>
|
||||
<Space style={{ width: isMobile ? '100%' : 'auto' }}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (isGenerating) {
|
||||
message.warning('AI正在创作中,请等待完成后再关闭');
|
||||
return;
|
||||
}
|
||||
setIsEditorOpen(false);
|
||||
}}
|
||||
block={isMobile}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
block={isMobile}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
保存章节
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button, Modal, Form, Input, Select, message, Row, Col, Empty, Tabs, Divider, Typography, Space } from 'antd';
|
||||
import { ThunderboltOutlined, UserOutlined, TeamOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { useCharacterSync } from '../store/hooks';
|
||||
import { characterGridConfig } from '../components/CardStyles';
|
||||
import { CharacterCard } from '../components/CharacterCard';
|
||||
import type { Character, CharacterUpdate } from '../types';
|
||||
import { characterApi } from '../services/api';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
export default function Characters() {
|
||||
const { currentProject, characters } = useStore();
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'all' | 'character' | 'organization'>('all');
|
||||
const [generateForm] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [editingCharacter, setEditingCharacter] = useState<Character | null>(null);
|
||||
|
||||
const {
|
||||
refreshCharacters,
|
||||
deleteCharacter,
|
||||
generateCharacter
|
||||
} = useCharacterSync();
|
||||
|
||||
useEffect(() => {
|
||||
if (currentProject?.id) {
|
||||
refreshCharacters();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentProject?.id]);
|
||||
|
||||
if (!currentProject) return null;
|
||||
|
||||
const handleDeleteCharacter = async (id: string) => {
|
||||
try {
|
||||
await deleteCharacter(id);
|
||||
message.success('删除成功');
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = async (values: { name?: string; role_type: string; background?: string }) => {
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
await generateCharacter({
|
||||
project_id: currentProject.id,
|
||||
name: values.name,
|
||||
role_type: values.role_type,
|
||||
background: values.background,
|
||||
});
|
||||
message.success('AI生成角色成功');
|
||||
Modal.destroyAll();
|
||||
} catch {
|
||||
message.error('AI生成失败');
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditCharacter = (character: Character) => {
|
||||
setEditingCharacter(character);
|
||||
editForm.setFieldsValue(character);
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleUpdateCharacter = async (values: CharacterUpdate) => {
|
||||
if (!editingCharacter) return;
|
||||
|
||||
try {
|
||||
await characterApi.updateCharacter(editingCharacter.id, values);
|
||||
message.success('更新成功');
|
||||
setIsEditModalOpen(false);
|
||||
editForm.resetFields();
|
||||
setEditingCharacter(null);
|
||||
await refreshCharacters();
|
||||
} catch {
|
||||
message.error('更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCharacterWrapper = (id: string) => {
|
||||
handleDeleteCharacter(id);
|
||||
};
|
||||
|
||||
const showGenerateModal = () => {
|
||||
Modal.confirm({
|
||||
title: 'AI生成角色',
|
||||
width: 600,
|
||||
centered: true,
|
||||
content: (
|
||||
<Form form={generateForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
label="角色名称"
|
||||
name="name"
|
||||
>
|
||||
<Input placeholder="如:张三、李四(可选,AI会自动生成)" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="角色定位"
|
||||
name="role_type"
|
||||
rules={[{ required: true, message: '请选择角色定位' }]}
|
||||
>
|
||||
<Select placeholder="选择角色定位">
|
||||
<Select.Option value="protagonist">主角</Select.Option>
|
||||
<Select.Option value="supporting">配角</Select.Option>
|
||||
<Select.Option value="antagonist">反派</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="背景设定" name="background">
|
||||
<TextArea rows={3} placeholder="简要描述角色背景和故事环境..." />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
okText: '生成',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
const values = await generateForm.validateFields();
|
||||
await handleGenerate(values);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const characterList = characters.filter(c => !c.is_organization);
|
||||
const organizationList = characters.filter(c => c.is_organization);
|
||||
|
||||
const getDisplayList = () => {
|
||||
if (activeTab === 'character') return characterList;
|
||||
if (activeTab === 'organization') return organizationList;
|
||||
return characters;
|
||||
};
|
||||
|
||||
const displayList = getDisplayList();
|
||||
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
backgroundColor: '#fff',
|
||||
padding: isMobile ? '12px 0' : '16px 0',
|
||||
marginBottom: isMobile ? 12 : 16,
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
gap: isMobile ? 12 : 0,
|
||||
justifyContent: 'space-between',
|
||||
alignItems: isMobile ? 'stretch' : 'center'
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}>角色与组织管理</h2>
|
||||
<Button
|
||||
type="dashed"
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={showGenerateModal}
|
||||
loading={isGenerating}
|
||||
block={isMobile}
|
||||
>
|
||||
AI生成角色
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{characters.length > 0 && (
|
||||
<div style={{
|
||||
position: 'sticky',
|
||||
top: isMobile ? 60 : 72,
|
||||
zIndex: 9,
|
||||
backgroundColor: '#fff',
|
||||
paddingBottom: 8,
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
}}>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => setActiveTab(key as 'all' | 'character' | 'organization')}
|
||||
items={[
|
||||
{
|
||||
key: 'all',
|
||||
label: `全部 (${characters.length})`,
|
||||
},
|
||||
{
|
||||
key: 'character',
|
||||
label: (
|
||||
<span>
|
||||
<UserOutlined /> 角色 ({characterList.length})
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'organization',
|
||||
label: (
|
||||
<span>
|
||||
<TeamOutlined /> 组织 ({organizationList.length})
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{characters.length === 0 ? (
|
||||
<Empty description="还没有角色或组织,开始创建吧!" />
|
||||
) : (
|
||||
<>
|
||||
<Row gutter={isMobile ? [8, 8] : characterGridConfig.gutter}>
|
||||
{activeTab === 'all' && (
|
||||
<>
|
||||
{characterList.length > 0 && (
|
||||
<>
|
||||
<Col span={24}>
|
||||
<Divider orientation="left">
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
<UserOutlined style={{ marginRight: 8 }} />
|
||||
角色 ({characterList.length})
|
||||
</Title>
|
||||
</Divider>
|
||||
</Col>
|
||||
{characterList.map((character) => (
|
||||
<Col
|
||||
xs={24}
|
||||
sm={characterGridConfig.sm}
|
||||
md={characterGridConfig.md}
|
||||
lg={characterGridConfig.lg}
|
||||
xl={characterGridConfig.xl}
|
||||
key={character.id}
|
||||
style={{ padding: isMobile ? '4px' : '8px' }}
|
||||
>
|
||||
<CharacterCard
|
||||
character={character}
|
||||
onEdit={handleEditCharacter}
|
||||
onDelete={handleDeleteCharacterWrapper}
|
||||
/>
|
||||
</Col>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{organizationList.length > 0 && (
|
||||
<>
|
||||
<Col span={24}>
|
||||
<Divider orientation="left">
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
<TeamOutlined style={{ marginRight: 8 }} />
|
||||
组织 ({organizationList.length})
|
||||
</Title>
|
||||
</Divider>
|
||||
</Col>
|
||||
{organizationList.map((org) => (
|
||||
<Col
|
||||
xs={24}
|
||||
sm={characterGridConfig.sm}
|
||||
md={characterGridConfig.md}
|
||||
lg={characterGridConfig.lg}
|
||||
xl={characterGridConfig.xl}
|
||||
key={org.id}
|
||||
style={{ padding: isMobile ? '4px' : '8px' }}
|
||||
>
|
||||
<CharacterCard
|
||||
character={org}
|
||||
onEdit={handleEditCharacter}
|
||||
onDelete={handleDeleteCharacterWrapper}
|
||||
/>
|
||||
</Col>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'character' && characterList.map((character) => (
|
||||
<Col
|
||||
xs={24}
|
||||
sm={characterGridConfig.sm}
|
||||
md={characterGridConfig.md}
|
||||
lg={characterGridConfig.lg}
|
||||
xl={characterGridConfig.xl}
|
||||
key={character.id}
|
||||
style={{ padding: isMobile ? '4px' : '8px' }}
|
||||
>
|
||||
<CharacterCard
|
||||
character={character}
|
||||
onEdit={handleEditCharacter}
|
||||
onDelete={handleDeleteCharacterWrapper}
|
||||
/>
|
||||
</Col>
|
||||
))}
|
||||
|
||||
{activeTab === 'organization' && organizationList.map((org) => (
|
||||
<Col
|
||||
xs={24}
|
||||
sm={characterGridConfig.sm}
|
||||
md={characterGridConfig.md}
|
||||
lg={characterGridConfig.lg}
|
||||
xl={characterGridConfig.xl}
|
||||
key={org.id}
|
||||
style={{ padding: isMobile ? '4px' : '8px' }}
|
||||
>
|
||||
<CharacterCard
|
||||
character={org}
|
||||
onEdit={handleEditCharacter}
|
||||
onDelete={handleDeleteCharacterWrapper}
|
||||
/>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{displayList.length === 0 && (
|
||||
<Empty
|
||||
description={
|
||||
activeTab === 'character'
|
||||
? '暂无角色'
|
||||
: activeTab === 'organization'
|
||||
? '暂无组织'
|
||||
: '暂无数据'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={editingCharacter?.is_organization ? '编辑组织' : '编辑角色'}
|
||||
open={isEditModalOpen}
|
||||
onCancel={() => {
|
||||
setIsEditModalOpen(false);
|
||||
editForm.resetFields();
|
||||
setEditingCharacter(null);
|
||||
}}
|
||||
footer={null}
|
||||
centered={!isMobile}
|
||||
width={isMobile ? '100%' : 600}
|
||||
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
|
||||
styles={isMobile ? { body: { maxHeight: 'calc(100vh - 110px)', overflowY: 'auto' } } : undefined}
|
||||
>
|
||||
<Form form={editForm} layout="vertical" onFinish={handleUpdateCharacter}>
|
||||
<Row gutter={16}>
|
||||
<Col span={editingCharacter?.is_organization ? 24 : 12}>
|
||||
<Form.Item
|
||||
label={editingCharacter?.is_organization ? '组织名称' : '角色名称'}
|
||||
name="name"
|
||||
rules={[{ required: true, message: `请输入${editingCharacter?.is_organization ? '组织' : '角色'}名称` }]}
|
||||
>
|
||||
<Input placeholder={`输入${editingCharacter?.is_organization ? '组织' : '角色'}名称`} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
{!editingCharacter?.is_organization && (
|
||||
<Col span={12}>
|
||||
<Form.Item label="角色定位" name="role_type">
|
||||
<Select>
|
||||
<Select.Option value="protagonist">主角</Select.Option>
|
||||
<Select.Option value="supporting">配角</Select.Option>
|
||||
<Select.Option value="antagonist">反派</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
{!editingCharacter?.is_organization && (
|
||||
<>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="年龄" name="age">
|
||||
<Input placeholder="如:25、30岁" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="性别" name="gender">
|
||||
<Select placeholder="选择性别">
|
||||
<Select.Option value="男">男</Select.Option>
|
||||
<Select.Option value="女">女</Select.Option>
|
||||
<Select.Option value="其他">其他</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item label="性格特点" name="personality">
|
||||
<TextArea rows={2} placeholder="描述角色的性格特点..." />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="外貌描写" name="appearance">
|
||||
<TextArea rows={2} placeholder="描述角色的外貌特征..." />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="人际关系" name="relationships">
|
||||
<TextArea rows={2} placeholder="描述角色与其他角色的关系..." />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{editingCharacter?.is_organization && (
|
||||
<>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="组织类型"
|
||||
name="organization_type"
|
||||
rules={[{ required: true, message: '请输入组织类型' }]}
|
||||
>
|
||||
<Input placeholder="如:帮派、公司、门派、学院" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="主要成员" name="organization_members">
|
||||
<Input placeholder="如:张三、李四、王五" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item
|
||||
label="组织目的"
|
||||
name="organization_purpose"
|
||||
rules={[{ required: true, message: '请输入组织目的' }]}
|
||||
>
|
||||
<TextArea rows={2} placeholder="描述组织的宗旨和目标..." />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Form.Item label={editingCharacter?.is_organization ? '组织背景' : '角色背景'} name="background">
|
||||
<TextArea rows={3} placeholder={`描述${editingCharacter?.is_organization ? '组织' : '角色'}的背景故事...`} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||
<Button onClick={() => {
|
||||
setIsEditModalOpen(false);
|
||||
editForm.resetFields();
|
||||
setEditingCharacter(null);
|
||||
}}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Card, Space, Typography, message, Spin, Form, Input, Tabs } from 'antd';
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||
import { authApi } from '../services/api';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [checking, setChecking] = useState(true);
|
||||
const [localAuthEnabled, setLocalAuthEnabled] = useState(false);
|
||||
const [linuxdoEnabled, setLinuxdoEnabled] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// 检查是否已登录和获取认证配置
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
await authApi.getCurrentUser();
|
||||
// 已登录,重定向到首页
|
||||
const redirect = searchParams.get('redirect') || '/';
|
||||
navigate(redirect);
|
||||
} catch {
|
||||
// 未登录,获取认证配置
|
||||
try {
|
||||
const config = await authApi.getAuthConfig();
|
||||
setLocalAuthEnabled(config.local_auth_enabled);
|
||||
setLinuxdoEnabled(config.linuxdo_enabled);
|
||||
} catch (error) {
|
||||
console.error('获取认证配置失败:', error);
|
||||
// 默认显示LinuxDO登录
|
||||
setLinuxdoEnabled(true);
|
||||
}
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
checkAuth();
|
||||
}, [navigate, searchParams]);
|
||||
|
||||
const handleLocalLogin = async (values: { username: string; password: string }) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await authApi.localLogin(values.username, values.password);
|
||||
|
||||
if (response.success) {
|
||||
message.success('登录成功!');
|
||||
const redirect = searchParams.get('redirect') || '/';
|
||||
navigate(redirect);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('本地登录失败:', error);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLinuxDOLogin = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await authApi.getLinuxDOAuthUrl();
|
||||
|
||||
// 保存重定向地址到 sessionStorage
|
||||
const redirect = searchParams.get('redirect');
|
||||
if (redirect) {
|
||||
sessionStorage.setItem('login_redirect', redirect);
|
||||
}
|
||||
|
||||
// 跳转到 LinuxDO 授权页面
|
||||
window.location.href = response.auth_url;
|
||||
} catch (error) {
|
||||
console.error('获取授权地址失败:', error);
|
||||
message.error('获取授权地址失败,请稍后重试');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
}}>
|
||||
<Spin size="large" style={{ color: '#fff' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 渲染本地登录表单
|
||||
const renderLocalLogin = () => (
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleLocalLogin}
|
||||
size="large"
|
||||
style={{ marginTop: '24px' }}
|
||||
>
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined style={{ color: '#999' }} />}
|
||||
placeholder="用户名"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined style={{ color: '#999' }} />}
|
||||
placeholder="密码"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
block
|
||||
style={{
|
||||
height: 48,
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 16px rgba(102, 126, 234, 0.4)',
|
||||
}}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
|
||||
// 渲染LinuxDO登录
|
||||
const renderLinuxDOLogin = () => (
|
||||
<div style={{ padding: '24px 0 8px' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={
|
||||
<img
|
||||
src="/favicon.ico"
|
||||
alt="LinuxDO"
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
marginRight: 8,
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
loading={loading}
|
||||
onClick={handleLinuxDOLogin}
|
||||
block
|
||||
style={{
|
||||
height: 52,
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 16px rgba(102, 126, 234, 0.4)',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 6px 24px rgba(102, 126, 234, 0.5)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 16px rgba(102, 126, 234, 0.4)';
|
||||
}}
|
||||
>
|
||||
使用 LinuxDO 登录
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
padding: '20px',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* 装饰性背景元素 */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '-10%',
|
||||
right: '-5%',
|
||||
width: '400px',
|
||||
height: '400px',
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(60px)',
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '-10%',
|
||||
left: '-5%',
|
||||
width: '350px',
|
||||
height: '350px',
|
||||
background: 'rgba(255, 255, 255, 0.08)',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(60px)',
|
||||
}} />
|
||||
|
||||
<Card
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 420,
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.2)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
borderRadius: '16px',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
bodyStyle={{
|
||||
padding: '40px 32px',
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%', textAlign: 'center' }}>
|
||||
{/* Logo区域 */}
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<div style={{
|
||||
width: '72px',
|
||||
height: '72px',
|
||||
margin: '0 auto 20px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
borderRadius: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 8px 24px rgba(102, 126, 234, 0.4)',
|
||||
}}>
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt="Logo"
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
filter: 'brightness(0) invert(1)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Title level={2} style={{
|
||||
marginBottom: 8,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
fontWeight: 700,
|
||||
}}>
|
||||
AI小说创作助手
|
||||
</Title>
|
||||
<Paragraph style={{
|
||||
color: '#666',
|
||||
fontSize: '14px',
|
||||
marginBottom: 0,
|
||||
}}>
|
||||
{localAuthEnabled && linuxdoEnabled ? '选择登录方式' :
|
||||
localAuthEnabled ? '使用账户密码登录' :
|
||||
'使用 LinuxDO 账号登录'}
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{/* 登录方式 */}
|
||||
{localAuthEnabled && linuxdoEnabled ? (
|
||||
<Tabs
|
||||
defaultActiveKey="local"
|
||||
centered
|
||||
items={[
|
||||
{
|
||||
key: 'local',
|
||||
label: '账户密码',
|
||||
children: renderLocalLogin(),
|
||||
},
|
||||
{
|
||||
key: 'linuxdo',
|
||||
label: 'LinuxDO',
|
||||
children: renderLinuxDOLogin(),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : localAuthEnabled ? (
|
||||
renderLocalLogin()
|
||||
) : (
|
||||
renderLinuxDOLogin()
|
||||
)}
|
||||
|
||||
{/* 提示信息 */}
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
background: 'rgba(102, 126, 234, 0.08)',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid rgba(102, 126, 234, 0.1)',
|
||||
}}>
|
||||
<Paragraph style={{
|
||||
fontSize: 13,
|
||||
color: '#666',
|
||||
marginBottom: 0,
|
||||
lineHeight: 1.6,
|
||||
}}>
|
||||
🎉 首次登录将自动创建账号
|
||||
<br />
|
||||
🔒 每个用户拥有独立的数据空间
|
||||
</Paragraph>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Card, Table, Tag, Button, Space, message, Modal, Form, Select, InputNumber, Input, Descriptions } from 'antd';
|
||||
import { PlusOutlined, TeamOutlined, UserOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import axios from 'axios';
|
||||
|
||||
interface Organization {
|
||||
id: string;
|
||||
character_id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
purpose: string;
|
||||
member_count: number;
|
||||
power_level: number;
|
||||
location?: string;
|
||||
motto?: string;
|
||||
}
|
||||
|
||||
interface OrganizationMember {
|
||||
id: string;
|
||||
character_id: string;
|
||||
character_name: string;
|
||||
position: string;
|
||||
rank: number;
|
||||
loyalty: number;
|
||||
contribution: number;
|
||||
status: string;
|
||||
joined_at?: string;
|
||||
}
|
||||
|
||||
interface Character {
|
||||
id: string;
|
||||
name: string;
|
||||
is_organization: boolean;
|
||||
}
|
||||
|
||||
export default function Organizations() {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const { currentProject } = useStore();
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [selectedOrg, setSelectedOrg] = useState<Organization | null>(null);
|
||||
const [members, setMembers] = useState<OrganizationMember[]>([]);
|
||||
const [characters, setCharacters] = useState<Character[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isAddMemberModalOpen, setIsAddMemberModalOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(window.innerWidth <= 768);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
const loadOrganizations = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.get(`/api/organizations/project/${projectId}`);
|
||||
setOrganizations(res.data);
|
||||
if (res.data.length > 0 && !selectedOrg) {
|
||||
setSelectedOrg(res.data[0]);
|
||||
loadMembers(res.data[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('加载组织列表失败');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, selectedOrg]);
|
||||
|
||||
const loadCharacters = useCallback(async () => {
|
||||
try {
|
||||
const res = await axios.get(`/api/characters?project_id=${projectId}`);
|
||||
setCharacters(res.data.items || []);
|
||||
} catch (error) {
|
||||
console.error('加载角色列表失败', error);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
loadOrganizations();
|
||||
loadCharacters();
|
||||
}
|
||||
}, [projectId, loadOrganizations, loadCharacters]);
|
||||
|
||||
const loadMembers = async (orgId: string) => {
|
||||
try {
|
||||
const res = await axios.get(`/api/organizations/${orgId}/members`);
|
||||
setMembers(res.data);
|
||||
} catch (error) {
|
||||
message.error('加载成员列表失败');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectOrganization = (org: Organization) => {
|
||||
setSelectedOrg(org);
|
||||
loadMembers(org.id);
|
||||
};
|
||||
|
||||
const handleAddMember = async (values: Record<string, unknown>) => {
|
||||
if (!selectedOrg) return;
|
||||
|
||||
try {
|
||||
await axios.post(`/api/organizations/${selectedOrg.id}/members`, values);
|
||||
message.success('成员添加成功');
|
||||
setIsAddMemberModalOpen(false);
|
||||
form.resetFields();
|
||||
loadMembers(selectedOrg.id);
|
||||
loadOrganizations(); // 刷新成员计数
|
||||
} catch (error) {
|
||||
message.error('添加成员失败');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (memberId: string) => {
|
||||
Modal.confirm({
|
||||
title: '确认移除',
|
||||
content: '确定要移除该成员吗?',
|
||||
centered: true,
|
||||
okText: '移除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await axios.delete(`/api/organizations/members/${memberId}`);
|
||||
message.success('成员移除成功');
|
||||
if (selectedOrg) {
|
||||
loadMembers(selectedOrg.id);
|
||||
loadOrganizations(); // 刷新成员计数
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('移除失败');
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
active: 'green',
|
||||
retired: 'default',
|
||||
expelled: 'red',
|
||||
deceased: 'black'
|
||||
};
|
||||
return colors[status] || 'default';
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const texts: Record<string, string> = {
|
||||
active: '在职',
|
||||
retired: '退休',
|
||||
expelled: '除名',
|
||||
deceased: '已故'
|
||||
};
|
||||
return texts[status] || status;
|
||||
};
|
||||
|
||||
const memberColumns = [
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'character_name',
|
||||
key: 'name',
|
||||
render: (name: string) => (
|
||||
<Space>
|
||||
<UserOutlined />
|
||||
<span>{name}</span>
|
||||
</Space>
|
||||
),
|
||||
width: isMobile ? 80 : undefined,
|
||||
},
|
||||
{
|
||||
title: '职位',
|
||||
dataIndex: 'position',
|
||||
key: 'position',
|
||||
render: (position: string, record: OrganizationMember) => (
|
||||
<Tag color="blue">{position} {!isMobile && `(级别 ${record.rank})`}</Tag>
|
||||
),
|
||||
width: isMobile ? 80 : undefined,
|
||||
},
|
||||
...(!isMobile ? [
|
||||
{
|
||||
title: '忠诚度',
|
||||
dataIndex: 'loyalty',
|
||||
key: 'loyalty',
|
||||
render: (loyalty: number) => (
|
||||
<span style={{ color: loyalty >= 70 ? 'green' : loyalty >= 40 ? 'orange' : 'red' }}>
|
||||
{loyalty}%
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '贡献度',
|
||||
dataIndex: 'contribution',
|
||||
key: 'contribution',
|
||||
render: (contribution: number) => `${contribution}%`,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => (
|
||||
<Tag color={getStatusColor(status)}>{getStatusText(status)}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '加入时间',
|
||||
dataIndex: 'joined_at',
|
||||
key: 'joined_at',
|
||||
render: (time: string) => time || '-',
|
||||
}
|
||||
] : []),
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: unknown, record: OrganizationMember) => (
|
||||
<Space>
|
||||
{!isMobile && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleRemoveMember(record.id)}
|
||||
>
|
||||
{isMobile ? '删除' : '移除'}
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
width: isMobile ? 60 : undefined,
|
||||
fixed: isMobile ? 'right' as const : undefined,
|
||||
},
|
||||
];
|
||||
|
||||
// 过滤掉已是成员的角色
|
||||
const availableCharacters = characters.filter(
|
||||
c => !c.is_organization && !members.some(m => m.character_id === c.id)
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card
|
||||
title={
|
||||
<Space wrap>
|
||||
<TeamOutlined />
|
||||
<span style={{ fontSize: isMobile ? 14 : 16 }}>组织管理</span>
|
||||
{!isMobile && <Tag color="blue">{currentProject?.title}</Tag>}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<div style={{
|
||||
display: isMobile ? 'flex' : 'grid',
|
||||
flexDirection: isMobile ? 'column' : undefined,
|
||||
gridTemplateColumns: isMobile ? undefined : '300px 1fr',
|
||||
gap: isMobile ? '16px' : '24px',
|
||||
maxHeight: isMobile ? 'calc(100vh - 200px)' : undefined,
|
||||
overflowY: isMobile ? 'auto' : undefined
|
||||
}}>
|
||||
{/* 左侧:组织列表 */}
|
||||
<div>
|
||||
<Card
|
||||
size="small"
|
||||
title={`组织列表 (${organizations.length})`}
|
||||
loading={loading}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{organizations.map(org => (
|
||||
<Card
|
||||
key={org.id}
|
||||
size="small"
|
||||
hoverable
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
border: selectedOrg?.id === org.id ? '2px solid #1890ff' : '1px solid #d9d9d9'
|
||||
}}
|
||||
onClick={() => handleSelectOrganization(org)}
|
||||
>
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<strong>{org.name}</strong>
|
||||
<Tag>{org.type}</Tag>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>
|
||||
成员: {org.member_count} | 势力: {org.power_level}
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 右侧:组织详情和成员 */}
|
||||
<div style={{ minHeight: isMobile ? 'auto' : undefined }}>
|
||||
{selectedOrg ? (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<Card title="组织详情" size="small">
|
||||
<Descriptions column={isMobile ? 1 : 2} size="small">
|
||||
<Descriptions.Item label="组织名称">{selectedOrg.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">{selectedOrg.type}</Descriptions.Item>
|
||||
<Descriptions.Item label="成员数量">{selectedOrg.member_count}</Descriptions.Item>
|
||||
<Descriptions.Item label="势力等级">{selectedOrg.power_level}</Descriptions.Item>
|
||||
{selectedOrg.location && (
|
||||
<Descriptions.Item label="所在地">{selectedOrg.location}</Descriptions.Item>
|
||||
)}
|
||||
{selectedOrg.motto && (
|
||||
<Descriptions.Item label="宗旨" span={2}>{selectedOrg.motto}</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="目标/宗旨" span={2}>
|
||||
{selectedOrg.purpose}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={`组织成员 (${members.length})`}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setIsAddMemberModalOpen(true)}
|
||||
disabled={availableCharacters.length === 0}
|
||||
>
|
||||
添加成员
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
columns={memberColumns}
|
||||
dataSource={members}
|
||||
rowKey="id"
|
||||
pagination={isMobile ? { simple: true, pageSize: 10 } : false}
|
||||
size="small"
|
||||
scroll={isMobile ? { x: 'max-content', y: 400 } : undefined}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
) : (
|
||||
<Card>
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
|
||||
请从左侧选择一个组织查看详情
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 添加成员模态框 */}
|
||||
<Modal
|
||||
title="添加组织成员"
|
||||
open={isAddMemberModalOpen}
|
||||
onCancel={() => {
|
||||
setIsAddMemberModalOpen(false);
|
||||
form.resetFields();
|
||||
}}
|
||||
footer={null}
|
||||
centered={!isMobile}
|
||||
width={isMobile ? '100%' : 500}
|
||||
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
|
||||
styles={isMobile ? { body: { maxHeight: 'calc(100vh - 110px)', overflowY: 'auto' } } : undefined}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleAddMember}
|
||||
>
|
||||
<Form.Item
|
||||
name="character_id"
|
||||
label="选择角色"
|
||||
rules={[{ required: true, message: '请选择角色' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择要加入的角色"
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
options={availableCharacters.map(c => ({
|
||||
label: c.name,
|
||||
value: c.id
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="position"
|
||||
label="职位"
|
||||
rules={[{ required: true, message: '请输入职位' }]}
|
||||
>
|
||||
<Input placeholder="如:掌门、长老、弟子" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="rank"
|
||||
label="职位等级"
|
||||
initialValue={5}
|
||||
tooltip="数字越大等级越高"
|
||||
>
|
||||
<InputNumber min={0} max={10} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="loyalty"
|
||||
label="初始忠诚度"
|
||||
initialValue={50}
|
||||
>
|
||||
<InputNumber min={0} max={100} style={{ width: '100%' }} addonAfter="%" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="status"
|
||||
label="状态"
|
||||
initialValue="active"
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="active">在职</Select.Option>
|
||||
<Select.Option value="retired">退休</Select.Option>
|
||||
<Select.Option value="expelled">除名</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||
<Button onClick={() => setIsAddMemberModalOpen(false)}>取消</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
添加
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button, List, Modal, Form, Input, message, Empty, Space, Popconfirm, Card, Select, Radio, Tag } from 'antd';
|
||||
import { EditOutlined, DeleteOutlined, ThunderboltOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { useOutlineSync } from '../store/hooks';
|
||||
import { cardStyles } from '../components/CardStyles';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
export default function Outline() {
|
||||
const { currentProject, outlines } = useStore();
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [editForm] = Form.useForm();
|
||||
const [generateForm] = Form.useForm();
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(window.innerWidth <= 768);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// 使用同步 hooks(移除createOutline)
|
||||
const {
|
||||
refreshOutlines,
|
||||
updateOutline,
|
||||
deleteOutline,
|
||||
reorderOutlines,
|
||||
generateOutlines
|
||||
} = useOutlineSync();
|
||||
|
||||
// 初始加载大纲列表
|
||||
useEffect(() => {
|
||||
if (currentProject?.id) {
|
||||
refreshOutlines();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentProject?.id]); // 只依赖 ID,不依赖函数
|
||||
|
||||
// 移除事件监听,避免无限循环
|
||||
// Hook 内部已经更新了 store,不需要再次刷新
|
||||
|
||||
if (!currentProject) return null;
|
||||
|
||||
// 确保大纲按 order_index 排序
|
||||
const sortedOutlines = [...outlines].sort((a, b) => a.order_index - b.order_index);
|
||||
|
||||
const handleOpenEditModal = (id: string) => {
|
||||
const outline = outlines.find(o => o.id === id);
|
||||
if (outline) {
|
||||
editForm.setFieldsValue(outline);
|
||||
Modal.confirm({
|
||||
title: '编辑大纲',
|
||||
width: 600,
|
||||
centered: true,
|
||||
content: (
|
||||
<Form
|
||||
form={editForm}
|
||||
layout="vertical"
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Form.Item
|
||||
label="标题"
|
||||
name="title"
|
||||
rules={[{ required: true, message: '请输入标题' }]}
|
||||
>
|
||||
<Input placeholder="输入大纲标题" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="内容"
|
||||
name="content"
|
||||
rules={[{ required: true, message: '请输入内容' }]}
|
||||
>
|
||||
<TextArea rows={6} placeholder="输入大纲内容..." />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
okText: '更新',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
const values = await editForm.validateFields();
|
||||
try {
|
||||
await updateOutline(id, values);
|
||||
message.success('大纲更新成功');
|
||||
} catch {
|
||||
message.error('更新失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteOutline = async (id: string) => {
|
||||
try {
|
||||
await deleteOutline(id);
|
||||
message.success('删除成功');
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveUp = async (index: number) => {
|
||||
if (index === 0) return;
|
||||
|
||||
const items = Array.from(sortedOutlines);
|
||||
[items[index - 1], items[index]] = [items[index], items[index - 1]];
|
||||
|
||||
const newOrders = items.map((item, idx) => ({
|
||||
id: item.id,
|
||||
order_index: idx + 1
|
||||
}));
|
||||
|
||||
try {
|
||||
await reorderOutlines(newOrders);
|
||||
message.success('上移成功');
|
||||
} catch (error) {
|
||||
message.error('调整失败');
|
||||
console.error('重排序失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveDown = async (index: number) => {
|
||||
if (index === sortedOutlines.length - 1) return;
|
||||
|
||||
const items = Array.from(sortedOutlines);
|
||||
[items[index], items[index + 1]] = [items[index + 1], items[index]];
|
||||
|
||||
const newOrders = items.map((item, idx) => ({
|
||||
id: item.id,
|
||||
order_index: idx + 1
|
||||
}));
|
||||
|
||||
try {
|
||||
await reorderOutlines(newOrders);
|
||||
message.success('下移成功');
|
||||
} catch (error) {
|
||||
message.error('调整失败');
|
||||
console.error('重排序失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
interface GenerateFormValues {
|
||||
theme?: string;
|
||||
chapter_count?: number;
|
||||
narrative_perspective?: string;
|
||||
requirements?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
mode?: 'auto' | 'new' | 'continue';
|
||||
story_direction?: string;
|
||||
plot_stage?: 'development' | 'climax' | 'ending';
|
||||
keep_existing?: boolean;
|
||||
}
|
||||
|
||||
const handleGenerate = async (values: GenerateFormValues) => {
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
// 如果是全新生成模式,keep_existing应该为false
|
||||
const isNewMode = values.mode === 'new';
|
||||
const result = await generateOutlines({
|
||||
project_id: currentProject.id,
|
||||
genre: currentProject.genre || '通用',
|
||||
theme: values.theme || currentProject.theme || '',
|
||||
chapter_count: values.chapter_count || 5,
|
||||
narrative_perspective: values.narrative_perspective || currentProject.narrative_perspective || '第三人称',
|
||||
target_words: currentProject.target_words || 100000,
|
||||
requirements: values.requirements,
|
||||
// 续写参数
|
||||
mode: values.mode || 'auto',
|
||||
story_direction: values.story_direction,
|
||||
plot_stage: values.plot_stage || 'development',
|
||||
keep_existing: !isNewMode, // 全新生成模式下不保留旧大纲
|
||||
});
|
||||
message.success(`成功生成 ${result.length} 条大纲`);
|
||||
Modal.destroyAll();
|
||||
// 刷新大纲列表,确保显示最新数据
|
||||
await refreshOutlines();
|
||||
} catch (error) {
|
||||
console.error('AI生成失败:', error);
|
||||
message.error('AI生成失败');
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showGenerateModal = () => {
|
||||
const hasOutlines = outlines.length > 0;
|
||||
const initialMode = hasOutlines ? 'continue' : 'new';
|
||||
|
||||
Modal.confirm({
|
||||
title: hasOutlines ? (
|
||||
<Space>
|
||||
<span>AI生成/续写大纲</span>
|
||||
<Tag color="blue">当前已有 {outlines.length} 章</Tag>
|
||||
</Space>
|
||||
) : 'AI生成大纲',
|
||||
width: 700,
|
||||
centered: true,
|
||||
content: (
|
||||
<Form
|
||||
form={generateForm}
|
||||
layout="vertical"
|
||||
style={{ marginTop: 16 }}
|
||||
initialValues={{
|
||||
mode: initialMode,
|
||||
chapter_count: 5,
|
||||
narrative_perspective: currentProject.narrative_perspective || '第三人称',
|
||||
plot_stage: 'development',
|
||||
keep_existing: true,
|
||||
theme: currentProject.theme || '',
|
||||
}}
|
||||
>
|
||||
{hasOutlines && (
|
||||
<Form.Item
|
||||
label="生成模式"
|
||||
name="mode"
|
||||
tooltip="自动判断:根据是否有大纲自动选择;全新生成:删除旧大纲重新生成;续写模式:基于已有大纲继续创作"
|
||||
>
|
||||
<Radio.Group buttonStyle="solid">
|
||||
<Radio.Button value="auto">自动判断</Radio.Button>
|
||||
<Radio.Button value="new">全新生成</Radio.Button>
|
||||
<Radio.Button value="continue">续写模式</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) => prevValues.mode !== currentValues.mode}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const mode = getFieldValue('mode');
|
||||
const isContinue = mode === 'continue' || (mode === 'auto' && hasOutlines);
|
||||
|
||||
// 续写模式不显示主题输入,使用项目原有主题
|
||||
if (isContinue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 全新生成模式需要输入主题
|
||||
return (
|
||||
<Form.Item
|
||||
label="故事主题"
|
||||
name="theme"
|
||||
rules={[{ required: true, message: '请输入故事主题' }]}
|
||||
>
|
||||
<TextArea rows={3} placeholder="描述你的故事主题、核心设定和主要情节..." />
|
||||
</Form.Item>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) => prevValues.mode !== currentValues.mode}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const mode = getFieldValue('mode');
|
||||
const isContinue = mode === 'continue' || (mode === 'auto' && hasOutlines);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isContinue && (
|
||||
<>
|
||||
<Form.Item
|
||||
label="故事发展方向"
|
||||
name="story_direction"
|
||||
tooltip="告诉AI你希望故事接下来如何发展"
|
||||
>
|
||||
<TextArea
|
||||
rows={3}
|
||||
placeholder="例如:主角遇到新的挑战、引入新角色、揭示关键秘密等..."
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="情节阶段"
|
||||
name="plot_stage"
|
||||
tooltip="帮助AI理解当前故事所处的阶段"
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="development">发展阶段 - 继续展开情节</Select.Option>
|
||||
<Select.Option value="climax">高潮阶段 - 矛盾激化</Select.Option>
|
||||
<Select.Option value="ending">结局阶段 - 收束伏笔</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label={isContinue ? "续写章节数" : "章节数量"}
|
||||
name="chapter_count"
|
||||
rules={[{ required: true, message: '请输入章节数量' }]}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
placeholder={isContinue ? "建议5-10章" : "如:30"}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="叙事视角"
|
||||
name="narrative_perspective"
|
||||
rules={[{ required: true, message: '请选择叙事视角' }]}
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="第一人称">第一人称</Select.Option>
|
||||
<Select.Option value="第三人称">第三人称</Select.Option>
|
||||
<Select.Option value="全知视角">全知视角</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="其他要求" name="requirements">
|
||||
<TextArea rows={2} placeholder="其他特殊要求(可选)" />
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
okText: hasOutlines ? '开始续写' : '开始生成',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
const values = await generateForm.validateFields();
|
||||
await handleGenerate(values);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{/* 固定头部 */}
|
||||
<div style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
backgroundColor: '#fff',
|
||||
padding: isMobile ? '12px 0' : '16px 0',
|
||||
marginBottom: isMobile ? 12 : 16,
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
gap: isMobile ? 12 : 0,
|
||||
justifyContent: 'space-between',
|
||||
alignItems: isMobile ? 'stretch' : 'center'
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}>故事大纲</h2>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={showGenerateModal}
|
||||
loading={isGenerating}
|
||||
block={isMobile}
|
||||
>
|
||||
{isMobile ? 'AI生成/续写' : 'AI生成/续写大纲'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 可滚动内容区域 */}
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{outlines.length === 0 ? (
|
||||
<Empty description="还没有大纲,开始创建吧!" />
|
||||
) : (
|
||||
<Card style={cardStyles.base}>
|
||||
<List
|
||||
dataSource={sortedOutlines}
|
||||
renderItem={(item, index) => (
|
||||
<List.Item
|
||||
style={{
|
||||
padding: '16px 0',
|
||||
borderRadius: 8,
|
||||
transition: 'background 0.3s ease',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
alignItems: isMobile ? 'flex-start' : 'center'
|
||||
}}
|
||||
actions={isMobile ? undefined : [
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowUpOutlined />}
|
||||
onClick={() => handleMoveUp(index)}
|
||||
disabled={index === 0}
|
||||
title="上移"
|
||||
>
|
||||
上移
|
||||
</Button>,
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowDownOutlined />}
|
||||
onClick={() => handleMoveDown(index)}
|
||||
disabled={index === sortedOutlines.length - 1}
|
||||
title="下移"
|
||||
>
|
||||
下移
|
||||
</Button>,
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleOpenEditModal(item.id)}
|
||||
>
|
||||
编辑
|
||||
</Button>,
|
||||
<Popconfirm
|
||||
title="确定删除这条大纲吗?"
|
||||
onConfirm={() => handleDeleteOutline(item.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="text" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>,
|
||||
]}
|
||||
>
|
||||
<div style={{ width: '100%' }}>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<span style={{ fontSize: isMobile ? 14 : 16 }}>
|
||||
<span style={{ color: '#1890ff', marginRight: 8, fontWeight: 'bold' }}>
|
||||
第{item.order_index || '?'}章
|
||||
</span>
|
||||
{item.title}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
<div style={{ fontSize: isMobile ? 12 : 14 }}>
|
||||
{item.content}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 移动端:按钮显示在内容下方 */}
|
||||
{isMobile && (
|
||||
<Space style={{ marginTop: 12, width: '100%', justifyContent: 'flex-end' }} wrap>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowUpOutlined />}
|
||||
onClick={() => handleMoveUp(index)}
|
||||
disabled={index === 0}
|
||||
size="small"
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowDownOutlined />}
|
||||
onClick={() => handleMoveDown(index)}
|
||||
disabled={index === sortedOutlines.length - 1}
|
||||
size="small"
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleOpenEditModal(item.id)}
|
||||
size="small"
|
||||
/>
|
||||
<Popconfirm
|
||||
title="确定删除这条大纲吗?"
|
||||
onConfirm={() => handleDeleteOutline(item.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="text" danger icon={<DeleteOutlined />} size="small" />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, Input, Button, message, Space } from 'antd';
|
||||
import { ThunderboltOutlined } from '@ant-design/icons';
|
||||
import { polishApi } from '../services/api';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
export default function Polish() {
|
||||
const [originalText, setOriginalText] = useState('');
|
||||
const [polishedText, setPolishedText] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handlePolish = async () => {
|
||||
if (!originalText.trim()) {
|
||||
message.warning('请输入要去味的文本');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await polishApi.polishText({ text: originalText });
|
||||
setPolishedText(result.polished_text);
|
||||
message.success('AI去味完成');
|
||||
} catch {
|
||||
message.error('AI去味失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(polishedText);
|
||||
message.success('已复制到剪贴板');
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ marginBottom: 16 }}>AI去味工具</h2>
|
||||
<p style={{ color: 'rgba(0,0,0,0.45)', marginBottom: 24 }}>
|
||||
将AI生成的文本变得更自然、更像人类作家的手笔
|
||||
</p>
|
||||
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<Card title="原始文本" extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={handlePolish}
|
||||
loading={loading}
|
||||
>
|
||||
开始去味
|
||||
</Button>
|
||||
}>
|
||||
<TextArea
|
||||
rows={10}
|
||||
placeholder="粘贴或输入需要去味的文本..."
|
||||
value={originalText}
|
||||
onChange={(e) => setOriginalText(e.target.value)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{polishedText && (
|
||||
<Card title="去味后文本" extra={
|
||||
<Button onClick={handleCopy}>复制文本</Button>
|
||||
}>
|
||||
<TextArea
|
||||
rows={10}
|
||||
value={polishedText}
|
||||
readOnly
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useParams, useNavigate, Outlet, Link, useLocation } from 'react-router-dom';
|
||||
import { Layout, Menu, Spin, Button, Statistic, Row, Col, Card, Drawer } from 'antd';
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
FileTextOutlined,
|
||||
TeamOutlined,
|
||||
BookOutlined,
|
||||
// ToolOutlined,
|
||||
GlobalOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
ApartmentOutlined,
|
||||
BankOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { useCharacterSync, useOutlineSync, useChapterSync } from '../store/hooks';
|
||||
import { projectApi } from '../services/api';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
|
||||
// 判断是否为移动端
|
||||
const isMobile = () => window.innerWidth <= 768;
|
||||
|
||||
export default function ProjectDetail() {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [drawerVisible, setDrawerVisible] = useState(false);
|
||||
const [mobile, setMobile] = useState(isMobile());
|
||||
|
||||
// 监听窗口大小变化
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setMobile(isMobile());
|
||||
if (!isMobile()) {
|
||||
setDrawerVisible(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
const {
|
||||
currentProject,
|
||||
setCurrentProject,
|
||||
clearProjectData,
|
||||
loading,
|
||||
setLoading,
|
||||
outlines,
|
||||
characters,
|
||||
chapters,
|
||||
} = useStore();
|
||||
|
||||
// 使用同步 hooks
|
||||
const { refreshCharacters } = useCharacterSync();
|
||||
const { refreshOutlines } = useOutlineSync();
|
||||
const { refreshChapters } = useChapterSync();
|
||||
|
||||
useEffect(() => {
|
||||
const loadProjectData = async (id: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// 加载项目基本信息
|
||||
const project = await projectApi.getProject(id);
|
||||
setCurrentProject(project);
|
||||
|
||||
// 并行加载其他数据
|
||||
await Promise.all([
|
||||
refreshOutlines(id),
|
||||
refreshCharacters(id),
|
||||
refreshChapters(id),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('加载项目数据失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (projectId) {
|
||||
loadProjectData(projectId);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearProjectData();
|
||||
};
|
||||
}, [projectId, clearProjectData, setLoading, setCurrentProject, refreshOutlines, refreshCharacters, refreshChapters]);
|
||||
|
||||
// 移除事件监听,避免无限循环
|
||||
// Hook 内部已经更新了 store,不需要再次刷新
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
key: 'world-setting',
|
||||
icon: <GlobalOutlined />,
|
||||
label: <Link to={`/project/${projectId}/world-setting`}>世界设定</Link>,
|
||||
},
|
||||
{
|
||||
key: 'characters',
|
||||
icon: <TeamOutlined />,
|
||||
label: <Link to={`/project/${projectId}/characters`}>角色管理</Link>,
|
||||
},
|
||||
{
|
||||
key: 'relationships',
|
||||
icon: <ApartmentOutlined />,
|
||||
label: <Link to={`/project/${projectId}/relationships`}>关系管理</Link>,
|
||||
},
|
||||
{
|
||||
key: 'organizations',
|
||||
icon: <BankOutlined />,
|
||||
label: <Link to={`/project/${projectId}/organizations`}>组织管理</Link>,
|
||||
},
|
||||
{
|
||||
key: 'outline',
|
||||
icon: <FileTextOutlined />,
|
||||
label: <Link to={`/project/${projectId}/outline`}>大纲管理</Link>,
|
||||
},
|
||||
{
|
||||
key: 'chapters',
|
||||
icon: <BookOutlined />,
|
||||
label: <Link to={`/project/${projectId}/chapters`}>章节管理</Link>,
|
||||
},
|
||||
// {
|
||||
// key: 'polish',
|
||||
// icon: <ToolOutlined />,
|
||||
// label: <Link to={`/project/${projectId}/polish`}>AI去味</Link>,
|
||||
// },
|
||||
];
|
||||
|
||||
// 根据当前路径动态确定选中的菜单项
|
||||
const selectedKey = useMemo(() => {
|
||||
const path = location.pathname;
|
||||
if (path.includes('/world-setting')) return 'world-setting';
|
||||
if (path.includes('/relationships')) return 'relationships';
|
||||
if (path.includes('/organizations')) return 'organizations';
|
||||
if (path.includes('/outline')) return 'outline';
|
||||
if (path.includes('/characters')) return 'characters';
|
||||
if (path.includes('/chapters')) return 'chapters';
|
||||
// if (path.includes('/polish')) return 'polish';
|
||||
return 'world-setting'; // 默认选中世界设定
|
||||
}, [location.pathname]);
|
||||
|
||||
if (loading || !currentProject) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 渲染菜单内容
|
||||
const renderMenu = () => (
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden'
|
||||
}}>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey]}
|
||||
style={{
|
||||
borderRight: 0,
|
||||
paddingTop: '16px'
|
||||
}}
|
||||
items={menuItems}
|
||||
onClick={() => mobile && setDrawerVisible(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh', height: '100vh', overflow: 'hidden' }}>
|
||||
<Header style={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
padding: mobile ? '0 12px' : '0 24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
height: mobile ? 56 : 70
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', zIndex: 1 }}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={mobile ? <MenuUnfoldOutlined /> : (collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />)}
|
||||
onClick={() => mobile ? setDrawerVisible(true) : setCollapsed(!collapsed)}
|
||||
style={{
|
||||
fontSize: mobile ? '18px' : '20px',
|
||||
color: '#fff',
|
||||
width: mobile ? '36px' : '40px',
|
||||
height: mobile ? '36px' : '40px'
|
||||
}}
|
||||
/>
|
||||
{!mobile && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/')}
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
color: '#fff',
|
||||
height: '40px',
|
||||
padding: '0 16px'
|
||||
}}
|
||||
>
|
||||
返回主页
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h2 style={{
|
||||
margin: 0,
|
||||
color: '#fff',
|
||||
fontSize: mobile ? '16px' : '24px',
|
||||
fontWeight: 600,
|
||||
textShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
position: mobile ? 'static' : 'absolute',
|
||||
left: mobile ? 'auto' : '50%',
|
||||
transform: mobile ? 'none' : 'translateX(-50%)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
flex: mobile ? 1 : 'none',
|
||||
textAlign: mobile ? 'center' : 'left',
|
||||
paddingLeft: mobile ? '8px' : '0',
|
||||
paddingRight: mobile ? '8px' : '0'
|
||||
}}>
|
||||
{currentProject.title}
|
||||
</h2>
|
||||
|
||||
{mobile && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/')}
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: '#fff',
|
||||
height: '36px',
|
||||
padding: '0 8px',
|
||||
zIndex: 1
|
||||
}}
|
||||
>
|
||||
主页
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!mobile && (
|
||||
<Row gutter={12} style={{ width: '450px', justifyContent: 'flex-end', zIndex: 1 }}>
|
||||
<Col>
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.95)',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
minWidth: '80px',
|
||||
textAlign: 'center',
|
||||
padding: '4px 8px'
|
||||
}}
|
||||
styles={{ body: { padding: '8px' } }}
|
||||
>
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: '11px', color: '#666' }}>大纲</span>}
|
||||
value={outlines.length}
|
||||
suffix="条"
|
||||
valueStyle={{ fontSize: '16px', fontWeight: 600, color: '#667eea' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col>
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.95)',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
minWidth: '80px',
|
||||
textAlign: 'center',
|
||||
padding: '4px 8px'
|
||||
}}
|
||||
styles={{ body: { padding: '8px' } }}
|
||||
>
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: '11px', color: '#666' }}>角色</span>}
|
||||
value={characters.length}
|
||||
suffix="个"
|
||||
valueStyle={{ fontSize: '16px', fontWeight: 600, color: '#52c41a' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col>
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.95)',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
minWidth: '80px',
|
||||
textAlign: 'center',
|
||||
padding: '4px 8px'
|
||||
}}
|
||||
styles={{ body: { padding: '8px' } }}
|
||||
>
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: '11px', color: '#666' }}>章节</span>}
|
||||
value={chapters.length}
|
||||
suffix="章"
|
||||
valueStyle={{ fontSize: '16px', fontWeight: 600, color: '#1890ff' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col>
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.95)',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
minWidth: '80px',
|
||||
textAlign: 'center',
|
||||
padding: '4px 8px'
|
||||
}}
|
||||
styles={{ body: { padding: '8px' } }}
|
||||
>
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: '11px', color: '#666' }}>已写</span>}
|
||||
value={currentProject.current_words}
|
||||
suffix="字"
|
||||
valueStyle={{ fontSize: '16px', fontWeight: 600, color: '#fa8c16' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</Header>
|
||||
|
||||
<Layout style={{ marginTop: mobile ? 56 : 70 }}>
|
||||
{mobile ? (
|
||||
<Drawer
|
||||
title="导航菜单"
|
||||
placement="left"
|
||||
onClose={() => setDrawerVisible(false)}
|
||||
open={drawerVisible}
|
||||
width={280}
|
||||
styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column' } }}
|
||||
>
|
||||
{renderMenu()}
|
||||
</Drawer>
|
||||
) : (
|
||||
<Sider
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
onCollapse={setCollapsed}
|
||||
trigger={null}
|
||||
width={220}
|
||||
collapsedWidth={60}
|
||||
style={{
|
||||
background: '#fff',
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: 70,
|
||||
bottom: 0,
|
||||
overflow: 'hidden',
|
||||
boxShadow: '2px 0 12px rgba(0,0,0,0.08)',
|
||||
transition: 'all 0.2s',
|
||||
height: 'calc(100vh - 70px)'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
{renderMenu()}
|
||||
</div>
|
||||
</Sider>
|
||||
)}
|
||||
|
||||
<Layout style={{
|
||||
marginLeft: mobile ? 0 : (collapsed ? 60 : 220),
|
||||
transition: 'all 0.2s'
|
||||
}}>
|
||||
<Content
|
||||
style={{
|
||||
background: '#f5f7fa',
|
||||
padding: mobile ? 12 : 24,
|
||||
height: mobile ? 'calc(100vh - 56px)' : 'calc(100vh - 70px)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
background: '#fff',
|
||||
padding: mobile ? 12 : 24,
|
||||
borderRadius: mobile ? '8px' : '12px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, Button, Empty, Modal, message, Spin, Row, Col, Statistic, Space, Tag, Progress, Typography, Tooltip, Badge } from 'antd';
|
||||
import { EditOutlined, DeleteOutlined, BookOutlined, RocketOutlined, CalendarOutlined, FileTextOutlined, TrophyOutlined, FireOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { useProjectSync } from '../store/hooks';
|
||||
import type { ReactNode } from 'react';
|
||||
import { cardStyles, cardHoverHandlers, gridConfig } from '../components/CardStyles';
|
||||
import UserMenu from '../components/UserMenu';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
export default function ProjectList() {
|
||||
const navigate = useNavigate();
|
||||
const { projects, loading } = useStore();
|
||||
|
||||
const { refreshProjects, deleteProject } = useProjectSync();
|
||||
|
||||
useEffect(() => {
|
||||
refreshProjects();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (!document.hidden) {
|
||||
refreshProjects();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '删除项目将同时删除所有相关数据,此操作不可恢复。确定要删除吗?',
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
okType: 'danger',
|
||||
centered: true,
|
||||
...(isMobile && {
|
||||
style: { top: 'auto' }
|
||||
}),
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteProject(id);
|
||||
message.success('项目删除成功');
|
||||
} catch {
|
||||
message.error('删除项目失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnterProject = (id: string) => {
|
||||
const project = projects.find(p => p.id === id);
|
||||
if (project) {
|
||||
console.log('项目信息:', {
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
wizard_status: project.wizard_status,
|
||||
wizard_step: project.wizard_step
|
||||
});
|
||||
|
||||
if (project.wizard_status === 'incomplete' || !project.wizard_status) {
|
||||
console.log('向导未完成,跳转到向导页面');
|
||||
navigate(`/wizard?projectId=${id}&step=${project.wizard_step || 0}`);
|
||||
} else {
|
||||
console.log('向导已完成,进入项目管理界面');
|
||||
navigate(`/project/${id}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const statusConfig: Record<string, { color: string; text: string; icon: ReactNode }> = {
|
||||
planning: { color: 'blue', text: '规划中', icon: <CalendarOutlined /> },
|
||||
writing: { color: 'green', text: '创作中', icon: <EditOutlined /> },
|
||||
revising: { color: 'orange', text: '修改中', icon: <FileTextOutlined /> },
|
||||
completed: { color: 'purple', text: '已完成', icon: <TrophyOutlined /> },
|
||||
};
|
||||
const config = statusConfig[status] || statusConfig.planning;
|
||||
return (
|
||||
<Tag color={config.color} icon={config.icon}>
|
||||
{config.text}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
const getProgress = (current: number, target: number) => {
|
||||
if (!target) return 0;
|
||||
return Math.min(Math.round((current / target) * 100), 100);
|
||||
};
|
||||
|
||||
const getProgressColor = (progress: number) => {
|
||||
if (progress >= 80) return '#52c41a';
|
||||
if (progress >= 50) return '#1890ff';
|
||||
if (progress >= 20) return '#faad14';
|
||||
return '#ff4d4f';
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) return '今天';
|
||||
if (days === 1) return '昨天';
|
||||
if (days < 7) return `${days}天前`;
|
||||
if (days < 30) return `${Math.floor(days / 7)}周前`;
|
||||
return date.toLocaleDateString('zh-CN');
|
||||
};
|
||||
|
||||
const totalWords = projects.reduce((sum, p) => sum + (p.current_words || 0), 0);
|
||||
const activeProjects = projects.filter(p => p.status === 'writing').length;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
padding: window.innerWidth <= 768 ? '20px 16px' : '40px 24px'
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: 1400,
|
||||
margin: '0 auto',
|
||||
marginBottom: window.innerWidth <= 768 ? 20 : 40
|
||||
}}>
|
||||
<Card
|
||||
variant="borderless"
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
borderRadius: window.innerWidth <= 768 ? 12 : 16,
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
<Row align="middle" justify="space-between" gutter={[16, 16]}>
|
||||
<Col xs={24} sm={12} md={10}>
|
||||
<Space direction="vertical" size={4}>
|
||||
<Title level={window.innerWidth <= 768 ? 3 : 2} style={{ margin: 0 }}>
|
||||
<FireOutlined style={{ color: '#ff4d4f', marginRight: 8 }} />
|
||||
我的创作空间
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: window.innerWidth <= 768 ? 12 : 14 }}>
|
||||
开启你的小说创作之旅
|
||||
</Text>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={14} style={{ display: 'flex', justifyContent: window.innerWidth <= 768 ? 'space-between' : 'flex-end', alignItems: 'center', gap: 16 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size={window.innerWidth <= 768 ? 'middle' : 'large'}
|
||||
icon={<RocketOutlined />}
|
||||
onClick={() => navigate('/wizard')}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
border: 'none'
|
||||
}}
|
||||
>
|
||||
向导创建
|
||||
</Button>
|
||||
<UserMenu />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{projects.length > 0 && (
|
||||
<Row gutter={[16, 16]} style={{ marginTop: window.innerWidth <= 768 ? 16 : 24 }}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card variant="borderless" style={{ background: '#f0f5ff', borderRadius: 12 }}>
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: window.innerWidth <= 768 ? 12 : 14, color: '#595959' }}>总项目数</span>}
|
||||
value={projects.length}
|
||||
prefix={<BookOutlined style={{ color: '#1890ff' }} />}
|
||||
suffix="个"
|
||||
valueStyle={{ color: '#1890ff', fontSize: window.innerWidth <= 768 ? 20 : 28, fontWeight: 'bold' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card variant="borderless" style={{ background: '#f6ffed', borderRadius: 12 }}>
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: window.innerWidth <= 768 ? 12 : 14, color: '#595959' }}>创作中</span>}
|
||||
value={activeProjects}
|
||||
prefix={<EditOutlined style={{ color: '#52c41a' }} />}
|
||||
suffix="个"
|
||||
valueStyle={{ color: '#52c41a', fontSize: window.innerWidth <= 768 ? 20 : 28, fontWeight: 'bold' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card variant="borderless" style={{ background: '#fff7e6', borderRadius: 12 }}>
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: window.innerWidth <= 768 ? 12 : 14, color: '#595959' }}>总字数</span>}
|
||||
value={totalWords}
|
||||
prefix={<FileTextOutlined style={{ color: '#faad14' }} />}
|
||||
suffix="字"
|
||||
valueStyle={{ color: '#faad14', fontSize: window.innerWidth <= 768 ? 20 : 28, fontWeight: 'bold' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
|
||||
<Spin spinning={loading}>
|
||||
{!Array.isArray(projects) || projects.length === 0 ? (
|
||||
<Card
|
||||
variant="borderless"
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
borderRadius: 16,
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
<Empty
|
||||
description={
|
||||
<Space direction="vertical" size={16}>
|
||||
<Text style={{ fontSize: 16, color: '#8c8c8c' }}>
|
||||
还没有项目,开始创建你的第一个小说项目吧!
|
||||
</Text>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<RocketOutlined />}
|
||||
onClick={() => navigate('/wizard')}
|
||||
>
|
||||
向导创建
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
style={{ padding: '80px 0' }}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
{projects.map((project) => {
|
||||
const progress = getProgress(project.current_words, project.target_words || 0);
|
||||
const isWizardComplete = project.wizard_status === 'completed';
|
||||
|
||||
return (
|
||||
<Col {...gridConfig} key={project.id}>
|
||||
<Badge.Ribbon
|
||||
text={isWizardComplete ? getStatusTag(project.status) : <Tag color="orange" icon={<RocketOutlined />}>创建中</Tag>}
|
||||
color="transparent"
|
||||
style={{ top: 12, right: 12 }}
|
||||
>
|
||||
<Card
|
||||
hoverable
|
||||
variant="borderless"
|
||||
onClick={() => handleEnterProject(project.id)}
|
||||
style={cardStyles.project}
|
||||
styles={{ body: { padding: 0, overflow: 'hidden' } }}
|
||||
{...cardHoverHandlers}
|
||||
>
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
padding: window.innerWidth <= 768 ? '16px' : '24px',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: window.innerWidth <= 768 ? 8 : 12 }}>
|
||||
<BookOutlined style={{ fontSize: window.innerWidth <= 768 ? 20 : 28, color: '#fff' }} />
|
||||
<Title level={window.innerWidth <= 768 ? 5 : 4} style={{ margin: 0, color: '#fff', flex: 1 }} ellipsis>
|
||||
{project.title}
|
||||
</Title>
|
||||
</div>
|
||||
{project.genre && (
|
||||
<Tag color="rgba(255,255,255,0.3)" style={{ color: '#fff', border: 'none' }}>
|
||||
{project.genre}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: window.innerWidth <= 768 ? '16px' : '20px' }}>
|
||||
<Paragraph
|
||||
ellipsis={{ rows: 2 }}
|
||||
style={{
|
||||
color: 'rgba(0,0,0,0.65)',
|
||||
minHeight: 44,
|
||||
marginBottom: 16
|
||||
}}
|
||||
>
|
||||
{project.description || '暂无描述'}
|
||||
</Paragraph>
|
||||
|
||||
{isWizardComplete ? (
|
||||
<>
|
||||
{project.target_words && project.target_words > 0 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>完成进度</Text>
|
||||
<Text strong style={{ fontSize: 12 }}>{progress}%</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={progress}
|
||||
strokeColor={getProgressColor(progress)}
|
||||
showInfo={false}
|
||||
size={{ height: 8 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '12px 0',
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 8
|
||||
}}>
|
||||
<div style={{ fontSize: 20, fontWeight: 'bold', color: '#1890ff' }}>
|
||||
{(project.current_words / 1000).toFixed(1)}K
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>已写字数</Text>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '12px 0',
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 8
|
||||
}}>
|
||||
<div style={{ fontSize: 20, fontWeight: 'bold', color: '#52c41a' }}>
|
||||
{project.target_words ? (project.target_words / 1000).toFixed(0) + 'K' : '--'}
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>目标字数</Text>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
) : (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '24px 0',
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 8
|
||||
}}>
|
||||
<RocketOutlined style={{ fontSize: 32, color: '#faad14', marginBottom: 12 }} />
|
||||
<div style={{ color: '#faad14', fontWeight: 'bold', marginBottom: 4 }}>
|
||||
项目创建中
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
点击继续创建向导
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
marginTop: 16,
|
||||
paddingTop: 16,
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
<CalendarOutlined style={{ marginRight: 4 }} />
|
||||
{formatDate(project.updated_at)}
|
||||
</Text>
|
||||
<Space size={8}>
|
||||
<Tooltip title="删除">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(project.id);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Badge.Ribbon>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,455 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Card, Table, Tag, Button, Space, message, Modal, Form, Select, Slider, Input, Tabs } from 'antd';
|
||||
import { PlusOutlined, TeamOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import axios from 'axios';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface Relationship {
|
||||
id: string;
|
||||
character_from_id: string;
|
||||
character_to_id: string;
|
||||
relationship_name: string;
|
||||
intimacy_level: number;
|
||||
status: string;
|
||||
description?: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface RelationshipType {
|
||||
id: number;
|
||||
name: string;
|
||||
category: string;
|
||||
reverse_name?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
interface Character {
|
||||
id: string;
|
||||
name: string;
|
||||
is_organization: boolean;
|
||||
}
|
||||
|
||||
export default function Relationships() {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const { currentProject } = useStore();
|
||||
const [relationships, setRelationships] = useState<Relationship[]>([]);
|
||||
const [relationshipTypes, setRelationshipTypes] = useState<RelationshipType[]>([]);
|
||||
const [characters, setCharacters] = useState<Character[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(window.innerWidth <= 768);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
loadData();
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [relsRes, typesRes, charsRes] = await Promise.all([
|
||||
axios.get(`/api/relationships/project/${projectId}`),
|
||||
axios.get('/api/relationships/types'),
|
||||
axios.get(`/api/characters?project_id=${projectId}`)
|
||||
]);
|
||||
|
||||
setRelationships(relsRes.data);
|
||||
setRelationshipTypes(typesRes.data);
|
||||
setCharacters(charsRes.data.items || []);
|
||||
} catch (error) {
|
||||
message.error('加载数据失败');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateRelationship = async (values: {
|
||||
character_from_id: string;
|
||||
character_to_id: string;
|
||||
relationship_name: string;
|
||||
intimacy_level: number;
|
||||
status: string;
|
||||
description?: string;
|
||||
}) => {
|
||||
try {
|
||||
await axios.post('/api/relationships/', {
|
||||
project_id: projectId,
|
||||
...values
|
||||
});
|
||||
message.success('关系创建成功');
|
||||
setIsModalOpen(false);
|
||||
form.resetFields();
|
||||
loadData();
|
||||
} catch (error) {
|
||||
message.error('创建关系失败');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRelationship = async (id: string) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这条关系吗?',
|
||||
centered: true,
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await axios.delete(`/api/relationships/${id}`);
|
||||
message.success('关系删除成功');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getCharacterName = (id: string) => {
|
||||
const char = characters.find(c => c.id === id);
|
||||
return char?.name || '未知';
|
||||
};
|
||||
|
||||
const getIntimacyColor = (level: number) => {
|
||||
if (level >= 75) return 'green';
|
||||
if (level >= 50) return 'blue';
|
||||
if (level >= 25) return 'orange';
|
||||
return 'red';
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
active: 'green',
|
||||
broken: 'red',
|
||||
past: 'default',
|
||||
complicated: 'orange'
|
||||
};
|
||||
return colors[status] || 'default';
|
||||
};
|
||||
|
||||
const getCategoryColor = (category: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
family: 'magenta',
|
||||
social: 'blue',
|
||||
hostile: 'red',
|
||||
professional: 'cyan'
|
||||
};
|
||||
return colors[category] || 'default';
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '角色A',
|
||||
dataIndex: 'character_from_id',
|
||||
key: 'from',
|
||||
render: (id: string) => (
|
||||
<Tag icon={<UserOutlined />} color="blue">
|
||||
{getCharacterName(id)}
|
||||
</Tag>
|
||||
),
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '关系',
|
||||
dataIndex: 'relationship_name',
|
||||
key: 'relationship',
|
||||
render: (name: string) => <strong>{name}</strong>,
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '角色B',
|
||||
dataIndex: 'character_to_id',
|
||||
key: 'to',
|
||||
render: (id: string) => (
|
||||
<Tag icon={<UserOutlined />} color="purple">
|
||||
{getCharacterName(id)}
|
||||
</Tag>
|
||||
),
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '亲密度',
|
||||
dataIndex: 'intimacy_level',
|
||||
key: 'intimacy',
|
||||
render: (level: number) => (
|
||||
<Tag color={getIntimacyColor(level)}>{level}</Tag>
|
||||
),
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => (
|
||||
<Tag color={getStatusColor(status)}>{status}</Tag>
|
||||
),
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '来源',
|
||||
dataIndex: 'source',
|
||||
key: 'source',
|
||||
render: (source: string) => (
|
||||
<Tag>{source === 'ai' ? 'AI生成' : '手动创建'}</Tag>
|
||||
),
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: unknown, record: Relationship) => (
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
size="small"
|
||||
onClick={() => handleDeleteRelationship(record.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
),
|
||||
width: 80,
|
||||
fixed: isMobile ? ('right' as const) : undefined,
|
||||
},
|
||||
];
|
||||
|
||||
// 按类别分组关系类型
|
||||
const groupedTypes = relationshipTypes.reduce((acc, type) => {
|
||||
if (!acc[type.category]) {
|
||||
acc[type.category] = [];
|
||||
}
|
||||
acc[type.category].push(type);
|
||||
return acc;
|
||||
}, {} as Record<string, RelationshipType[]>);
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
family: '家族关系',
|
||||
social: '社交关系',
|
||||
professional: '职业关系',
|
||||
hostile: '敌对关系'
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card
|
||||
title={
|
||||
<Space wrap>
|
||||
<TeamOutlined />
|
||||
<span style={{ fontSize: isMobile ? 14 : 16 }}>关系管理</span>
|
||||
{!isMobile && <Tag color="blue">{currentProject?.title}</Tag>}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
>
|
||||
{isMobile ? '添加' : '添加关系'}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'list',
|
||||
label: `关系列表 (${relationships.length})`,
|
||||
children: (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={relationships}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: currentPage,
|
||||
pageSize: isMobile ? 10 : pageSize,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
position: ['bottomCenter'],
|
||||
showSizeChanger: !isMobile,
|
||||
showQuickJumper: !isMobile,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
simple: isMobile,
|
||||
onChange: (page, size) => {
|
||||
setCurrentPage(page);
|
||||
if (size !== pageSize) {
|
||||
setPageSize(size);
|
||||
setCurrentPage(1); // 切换每页条数时重置到第一页
|
||||
}
|
||||
},
|
||||
onShowSizeChange: (_, size) => {
|
||||
setPageSize(size);
|
||||
setCurrentPage(1);
|
||||
}
|
||||
}}
|
||||
scroll={{
|
||||
x: 700,
|
||||
y: isMobile ? 'calc(100vh - 360px)' : 'calc(100vh - 440px)'
|
||||
}}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'types',
|
||||
label: `关系类型 (${relationshipTypes.length})`,
|
||||
children: (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
gap: isMobile ? '12px' : '16px',
|
||||
maxHeight: isMobile ? 'calc(100vh - 400px)' : 'calc(100vh - 350px)',
|
||||
overflow: 'auto'
|
||||
}}>
|
||||
{Object.entries(groupedTypes).map(([category, types]) => (
|
||||
<Card
|
||||
key={category}
|
||||
size="small"
|
||||
title={categoryLabels[category] || category}
|
||||
headStyle={{ backgroundColor: '#f5f5f5' }}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{types.map(type => (
|
||||
<Tag key={type.id} color={getCategoryColor(category)}>
|
||||
{type.icon} {type.name}
|
||||
{type.reverse_name && ` ↔ ${type.reverse_name}`}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="添加关系"
|
||||
open={isModalOpen}
|
||||
onCancel={() => {
|
||||
setIsModalOpen(false);
|
||||
form.resetFields();
|
||||
}}
|
||||
footer={null}
|
||||
centered={!isMobile}
|
||||
width={isMobile ? '100%' : 600}
|
||||
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
|
||||
styles={isMobile ? { body: { maxHeight: 'calc(100vh - 110px)', overflowY: 'auto' } } : undefined}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleCreateRelationship}
|
||||
>
|
||||
<Form.Item
|
||||
name="character_from_id"
|
||||
label="角色A"
|
||||
rules={[{ required: true, message: '请选择角色A' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择角色"
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
options={characters
|
||||
.filter(c => !c.is_organization)
|
||||
.map(c => ({ label: c.name, value: c.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="relationship_name"
|
||||
label="关系类型"
|
||||
rules={[{ required: true, message: '请选择或输入关系类型' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择预定义类型或输入自定义"
|
||||
showSearch
|
||||
allowClear
|
||||
options={relationshipTypes.map(t => ({
|
||||
label: `${t.icon || ''} ${t.name} (${categoryLabels[t.category]})`,
|
||||
value: t.name
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="character_to_id"
|
||||
label="角色B"
|
||||
rules={[{ required: true, message: '请选择角色B' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择角色"
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
options={characters
|
||||
.filter(c => !c.is_organization)
|
||||
.map(c => ({ label: c.name, value: c.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="intimacy_level"
|
||||
label="亲密度"
|
||||
initialValue={50}
|
||||
>
|
||||
<Slider
|
||||
min={0}
|
||||
max={100}
|
||||
marks={{ 0: '0', 25: '25', 50: '50', 75: '75', 100: '100' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="status"
|
||||
label="状态"
|
||||
initialValue="active"
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="active">活跃</Select.Option>
|
||||
<Select.Option value="broken">破裂</Select.Option>
|
||||
<Select.Option value="past">过去</Select.Option>
|
||||
<Select.Option value="complicated">复杂</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="description" label="关系描述">
|
||||
<TextArea rows={3} placeholder="描述这段关系的细节..." />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||
<Button onClick={() => setIsModalOpen(false)}>取消</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
创建
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { Card, Descriptions, Empty, Typography } from 'antd';
|
||||
import { GlobalOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { cardStyles } from '../components/CardStyles';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
export default function WorldSetting() {
|
||||
const { currentProject } = useStore();
|
||||
|
||||
if (!currentProject) return null;
|
||||
|
||||
// 检查是否有世界设定信息
|
||||
const hasWorldSetting = currentProject.world_time_period ||
|
||||
currentProject.world_location ||
|
||||
currentProject.world_atmosphere ||
|
||||
currentProject.world_rules;
|
||||
|
||||
if (!hasWorldSetting) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{/* 固定头部 */}
|
||||
<div style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
backgroundColor: '#fff',
|
||||
padding: '16px 0',
|
||||
marginBottom: 16,
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<GlobalOutlined style={{ fontSize: 24, marginRight: 12, color: '#1890ff' }} />
|
||||
<h2 style={{ margin: 0 }}>世界设定</h2>
|
||||
</div>
|
||||
|
||||
{/* 可滚动内容区域 */}
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
<Empty
|
||||
description="暂无世界设定信息"
|
||||
style={{ marginTop: 60 }}
|
||||
>
|
||||
<Paragraph type="secondary">
|
||||
世界设定信息在创建项目向导中生成,用于构建小说的世界观背景。
|
||||
</Paragraph>
|
||||
</Empty>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{/* 固定头部 */}
|
||||
<div style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
backgroundColor: '#fff',
|
||||
padding: '16px 0',
|
||||
marginBottom: 24,
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<GlobalOutlined style={{ fontSize: 24, marginRight: 12, color: '#1890ff' }} />
|
||||
<h2 style={{ margin: 0 }}>世界设定</h2>
|
||||
</div>
|
||||
|
||||
{/* 可滚动内容区域 */}
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
<Card
|
||||
style={{
|
||||
...cardStyles.base,
|
||||
marginBottom: 16
|
||||
}}
|
||||
title={
|
||||
<span style={{ fontSize: 18, fontWeight: 500 }}>
|
||||
基础信息
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Descriptions bordered column={1} styles={{ label: { width: 120, fontWeight: 500 } }}>
|
||||
<Descriptions.Item label="小说名称">{currentProject.title}</Descriptions.Item>
|
||||
{currentProject.description && (
|
||||
<Descriptions.Item label="小说简介">{currentProject.description}</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="小说主题">{currentProject.theme || '未设定'}</Descriptions.Item>
|
||||
<Descriptions.Item label="小说类型">{currentProject.genre || '未设定'}</Descriptions.Item>
|
||||
<Descriptions.Item label="叙事视角">{currentProject.narrative_perspective || '未设定'}</Descriptions.Item>
|
||||
<Descriptions.Item label="目标字数">
|
||||
{currentProject.target_words ? `${currentProject.target_words.toLocaleString()} 字` : '未设定'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
style={{
|
||||
...cardStyles.base,
|
||||
marginBottom: 16
|
||||
}}
|
||||
title={
|
||||
<span style={{ fontSize: 18, fontWeight: 500 }}>
|
||||
<GlobalOutlined style={{ marginRight: 8 }} />
|
||||
小说世界观
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div style={{ padding: '16px 0' }}>
|
||||
{currentProject.world_time_period && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Title level={5} style={{ color: '#1890ff', marginBottom: 12 }}>
|
||||
时间设定
|
||||
</Title>
|
||||
<Paragraph style={{
|
||||
fontSize: 15,
|
||||
lineHeight: 1.8,
|
||||
padding: 16,
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 8,
|
||||
borderLeft: '4px solid #1890ff'
|
||||
}}>
|
||||
{currentProject.world_time_period}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentProject.world_location && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Title level={5} style={{ color: '#52c41a', marginBottom: 12 }}>
|
||||
地点设定
|
||||
</Title>
|
||||
<Paragraph style={{
|
||||
fontSize: 15,
|
||||
lineHeight: 1.8,
|
||||
padding: 16,
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 8,
|
||||
borderLeft: '4px solid #52c41a'
|
||||
}}>
|
||||
{currentProject.world_location}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentProject.world_atmosphere && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Title level={5} style={{ color: '#faad14', marginBottom: 12 }}>
|
||||
氛围设定
|
||||
</Title>
|
||||
<Paragraph style={{
|
||||
fontSize: 15,
|
||||
lineHeight: 1.8,
|
||||
padding: 16,
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 8,
|
||||
borderLeft: '4px solid #faad14'
|
||||
}}>
|
||||
{currentProject.world_atmosphere}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentProject.world_rules && (
|
||||
<div style={{ marginBottom: 0 }}>
|
||||
<Title level={5} style={{ color: '#f5222d', marginBottom: 12 }}>
|
||||
规则设定
|
||||
</Title>
|
||||
<Paragraph style={{
|
||||
fontSize: 15,
|
||||
lineHeight: 1.8,
|
||||
padding: 16,
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 8,
|
||||
borderLeft: '4px solid #f5222d'
|
||||
}}>
|
||||
{currentProject.world_rules}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import axios from 'axios';
|
||||
import { message } from 'antd';
|
||||
import { ssePost } from '../utils/sseClient';
|
||||
import type { SSEClientOptions } from '../utils/sseClient';
|
||||
import type {
|
||||
User,
|
||||
AuthUrlResponse,
|
||||
Project,
|
||||
ProjectCreate,
|
||||
ProjectUpdate,
|
||||
WorldBuildingResponse,
|
||||
Outline,
|
||||
OutlineCreate,
|
||||
OutlineUpdate,
|
||||
OutlineReorderRequest,
|
||||
Character,
|
||||
CharacterUpdate,
|
||||
Chapter,
|
||||
ChapterCreate,
|
||||
ChapterUpdate,
|
||||
GenerateOutlineRequest,
|
||||
GenerateCharacterRequest,
|
||||
PolishTextRequest,
|
||||
GenerateCharactersResponse,
|
||||
GenerateOutlineResponse,
|
||||
} from '../types';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 120000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
return response.data;
|
||||
},
|
||||
(error) => {
|
||||
let errorMessage = '请求失败';
|
||||
|
||||
if (error.response) {
|
||||
const status = error.response.status;
|
||||
const data = error.response.data;
|
||||
|
||||
switch (status) {
|
||||
case 400:
|
||||
errorMessage = data?.detail || '请求参数错误';
|
||||
break;
|
||||
case 401:
|
||||
errorMessage = '未授权,请先登录';
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
break;
|
||||
case 403:
|
||||
errorMessage = '没有权限访问';
|
||||
break;
|
||||
case 404:
|
||||
errorMessage = data?.detail || '请求的资源不存在';
|
||||
break;
|
||||
case 422:
|
||||
errorMessage = data?.detail || '请求参数验证失败';
|
||||
if (data?.errors) {
|
||||
console.error('验证错误详情:', data.errors);
|
||||
}
|
||||
break;
|
||||
case 500:
|
||||
errorMessage = data?.detail || '服务器内部错误';
|
||||
break;
|
||||
case 503:
|
||||
errorMessage = '服务暂时不可用,请稍后重试';
|
||||
break;
|
||||
default:
|
||||
errorMessage = data?.detail || data?.message || `请求失败 (${status})`;
|
||||
}
|
||||
} else if (error.request) {
|
||||
errorMessage = '网络错误,请检查网络连接';
|
||||
} else {
|
||||
errorMessage = error.message || '请求失败';
|
||||
}
|
||||
|
||||
message.error(errorMessage);
|
||||
console.error('API Error:', errorMessage, error);
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export const authApi = {
|
||||
getAuthConfig: () => api.get<unknown, { local_auth_enabled: boolean; linuxdo_enabled: boolean }>('/auth/config'),
|
||||
|
||||
localLogin: (username: string, password: string) =>
|
||||
api.post<unknown, { success: boolean; message: string; user: User }>('/auth/local/login', { username, password }),
|
||||
|
||||
getLinuxDOAuthUrl: () => api.get<unknown, AuthUrlResponse>('/auth/linuxdo/url'),
|
||||
|
||||
getCurrentUser: () => api.get<unknown, User>('/auth/user'),
|
||||
|
||||
logout: () => api.post('/auth/logout'),
|
||||
};
|
||||
|
||||
export const userApi = {
|
||||
getCurrentUser: () => api.get<unknown, User>('/users/current'),
|
||||
|
||||
listUsers: () => api.get<unknown, User[]>('/users'),
|
||||
|
||||
setAdmin: (userId: string, isAdmin: boolean) =>
|
||||
api.post('/users/set-admin', { user_id: userId, is_admin: isAdmin }),
|
||||
|
||||
deleteUser: (userId: string) => api.delete(`/users/${userId}`),
|
||||
|
||||
getUser: (userId: string) => api.get<unknown, User>(`/users/${userId}`),
|
||||
};
|
||||
|
||||
export const projectApi = {
|
||||
getProjects: () => api.get<unknown, Project[]>('/projects'),
|
||||
|
||||
getProject: (id: string) => api.get<unknown, Project>(`/projects/${id}`),
|
||||
|
||||
createProject: (data: ProjectCreate) => api.post<unknown, Project>('/projects', data),
|
||||
|
||||
updateProject: (id: string, data: ProjectUpdate) =>
|
||||
api.put<unknown, Project>(`/projects/${id}`, data),
|
||||
|
||||
deleteProject: (id: string) => api.delete(`/projects/${id}`),
|
||||
|
||||
exportProject: (id: string) => {
|
||||
window.open(`/api/projects/${id}/export`, '_blank');
|
||||
},
|
||||
};
|
||||
|
||||
export const outlineApi = {
|
||||
getOutlines: (projectId: string) =>
|
||||
api.get<unknown, { total: number; items: Outline[] }>(`/outlines/project/${projectId}`).then(res => res.items),
|
||||
|
||||
getOutline: (id: string) => api.get<unknown, Outline>(`/outlines/${id}`),
|
||||
|
||||
createOutline: (data: OutlineCreate) => api.post<unknown, Outline>('/outlines', data),
|
||||
|
||||
updateOutline: (id: string, data: OutlineUpdate) =>
|
||||
api.put<unknown, Outline>(`/outlines/${id}`, data),
|
||||
|
||||
deleteOutline: (id: string) => api.delete(`/outlines/${id}`),
|
||||
|
||||
reorderOutlines: (data: OutlineReorderRequest) =>
|
||||
api.post<unknown, { message: string; updated_outlines: number; updated_chapters: number }>('/outlines/reorder', data),
|
||||
|
||||
generateOutline: (data: GenerateOutlineRequest) =>
|
||||
api.post<unknown, { total: number; items: Outline[] }>('/outlines/generate', data).then(res => res.items),
|
||||
};
|
||||
|
||||
export const characterApi = {
|
||||
getCharacters: (projectId: string) =>
|
||||
api.get<unknown, Character[]>(`/characters/project/${projectId}`),
|
||||
|
||||
getCharacter: (id: string) => api.get<unknown, Character>(`/characters/${id}`),
|
||||
|
||||
updateCharacter: (id: string, data: CharacterUpdate) =>
|
||||
api.put<unknown, Character>(`/characters/${id}`, data),
|
||||
|
||||
deleteCharacter: (id: string) => api.delete(`/characters/${id}`),
|
||||
|
||||
generateCharacter: (data: GenerateCharacterRequest) =>
|
||||
api.post<unknown, Character>('/characters/generate', data),
|
||||
};
|
||||
|
||||
export const chapterApi = {
|
||||
getChapters: (projectId: string) =>
|
||||
api.get<unknown, Chapter[]>(`/chapters/project/${projectId}`),
|
||||
|
||||
getChapter: (id: string) => api.get<unknown, Chapter>(`/chapters/${id}`),
|
||||
|
||||
createChapter: (data: ChapterCreate) => api.post<unknown, Chapter>('/chapters', data),
|
||||
|
||||
updateChapter: (id: string, data: ChapterUpdate) =>
|
||||
api.put<unknown, Chapter>(`/chapters/${id}`, data),
|
||||
|
||||
deleteChapter: (id: string) => api.delete(`/chapters/${id}`),
|
||||
|
||||
checkCanGenerate: (chapterId: string) =>
|
||||
api.get<unknown, import('../types').ChapterCanGenerateResponse>(`/chapters/${chapterId}/can-generate`),
|
||||
|
||||
generateChapterContent: (chapterId: string) =>
|
||||
api.post<unknown, { content: string }>(`/chapters/${chapterId}/generate`, {}),
|
||||
};
|
||||
|
||||
export const polishApi = {
|
||||
polishText: (data: PolishTextRequest) =>
|
||||
api.post<unknown, { polished_text: string }>('/polish', data),
|
||||
|
||||
polishBatch: (texts: string[]) =>
|
||||
api.post<unknown, { polished_texts: string[] }>('/polish/batch', { texts }),
|
||||
};
|
||||
export default api;
|
||||
|
||||
|
||||
export const wizardStreamApi = {
|
||||
generateWorldBuildingStream: (
|
||||
data: {
|
||||
title: string;
|
||||
description: string;
|
||||
theme: string;
|
||||
genre: string | string[];
|
||||
narrative_perspective?: string;
|
||||
target_words?: number;
|
||||
chapter_count?: number;
|
||||
character_count?: number;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
},
|
||||
options?: SSEClientOptions
|
||||
) => ssePost<WorldBuildingResponse>(
|
||||
'/api/wizard-stream/world-building',
|
||||
data,
|
||||
options
|
||||
),
|
||||
|
||||
generateCharactersStream: (
|
||||
data: {
|
||||
project_id: string;
|
||||
count?: number;
|
||||
world_context?: Record<string, string>;
|
||||
theme?: string;
|
||||
genre?: string;
|
||||
requirements?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
},
|
||||
options?: SSEClientOptions
|
||||
) => ssePost<GenerateCharactersResponse>(
|
||||
'/api/wizard-stream/characters',
|
||||
data,
|
||||
options
|
||||
),
|
||||
|
||||
generateCompleteOutlineStream: (
|
||||
data: {
|
||||
project_id: string;
|
||||
chapter_count: number;
|
||||
narrative_perspective: string;
|
||||
target_words?: number;
|
||||
requirements?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
},
|
||||
options?: SSEClientOptions
|
||||
) => ssePost<GenerateOutlineResponse>(
|
||||
'/api/wizard-stream/outline',
|
||||
data,
|
||||
options
|
||||
),
|
||||
|
||||
updateWorldBuildingStream: (
|
||||
projectId: string,
|
||||
data: {
|
||||
time_period?: string;
|
||||
location?: string;
|
||||
atmosphere?: string;
|
||||
rules?: string;
|
||||
},
|
||||
options?: SSEClientOptions
|
||||
) => ssePost<WorldBuildingResponse>(
|
||||
`/api/wizard-stream/world-building/${projectId}`,
|
||||
data,
|
||||
options
|
||||
),
|
||||
|
||||
regenerateWorldBuildingStream: (
|
||||
projectId: string,
|
||||
data?: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
},
|
||||
options?: SSEClientOptions
|
||||
) => ssePost<WorldBuildingResponse>(
|
||||
`/api/wizard-stream/world-building/${projectId}/regenerate`,
|
||||
data || {},
|
||||
options
|
||||
),
|
||||
|
||||
cleanupWizardDataStream: (
|
||||
projectId: string,
|
||||
options?: SSEClientOptions
|
||||
) => ssePost<{ message: string; deleted: { characters: number; outlines: number; chapters: number } }>(
|
||||
`/api/wizard-stream/cleanup/${projectId}`,
|
||||
{},
|
||||
options
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 事件总线 - 用于跨组件/页面的数据同步通信
|
||||
*
|
||||
* 使用方式:
|
||||
* - eventBus.on('eventName', callback) - 监听事件
|
||||
* - eventBus.off('eventName', callback) - 取消监听
|
||||
* - eventBus.emit('eventName', data) - 触发事件
|
||||
* - eventBus.once('eventName', callback) - 一次性监听
|
||||
*/
|
||||
|
||||
type EventCallback = (data?: unknown) => void;
|
||||
|
||||
class EventBus {
|
||||
private events: Map<string, EventCallback[]> = new Map();
|
||||
|
||||
/**
|
||||
* 监听事件
|
||||
*/
|
||||
on(event: string, callback: EventCallback): void {
|
||||
if (!this.events.has(event)) {
|
||||
this.events.set(event, []);
|
||||
}
|
||||
this.events.get(event)!.push(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消监听事件
|
||||
*/
|
||||
off(event: string, callback: EventCallback): void {
|
||||
const callbacks = this.events.get(event);
|
||||
if (callbacks) {
|
||||
const index = callbacks.indexOf(callback);
|
||||
if (index > -1) {
|
||||
callbacks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发事件
|
||||
*/
|
||||
emit(event: string, data?: unknown): void {
|
||||
const callbacks = this.events.get(event);
|
||||
if (callbacks) {
|
||||
callbacks.forEach(cb => {
|
||||
try {
|
||||
cb(data);
|
||||
} catch (error) {
|
||||
console.error(`事件处理器执行失败 [${event}]:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次性监听事件
|
||||
*/
|
||||
once(event: string, callback: EventCallback): void {
|
||||
const onceCallback: EventCallback = (data) => {
|
||||
callback(data);
|
||||
this.off(event, onceCallback);
|
||||
};
|
||||
this.on(event, onceCallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除某个事件的所有监听器
|
||||
*/
|
||||
removeAllListeners(event?: string): void {
|
||||
if (event) {
|
||||
this.events.delete(event);
|
||||
} else {
|
||||
this.events.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取事件的监听器数量
|
||||
*/
|
||||
listenerCount(event: string): number {
|
||||
return this.events.get(event)?.length || 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const eventBus = new EventBus();
|
||||
|
||||
// 导出事件名称常量,避免字符串拼写错误
|
||||
export const EventNames = {
|
||||
// 项目相关事件
|
||||
PROJECT_CREATED: 'project:created',
|
||||
PROJECT_UPDATED: 'project:updated',
|
||||
PROJECT_DELETED: 'project:deleted',
|
||||
PROJECT_NEEDS_REFRESH: 'project:needsRefresh',
|
||||
|
||||
// 角色相关事件
|
||||
CHARACTER_CREATED: 'character:created',
|
||||
CHARACTER_UPDATED: 'character:updated',
|
||||
CHARACTER_DELETED: 'character:deleted',
|
||||
CHARACTER_NEEDS_REFRESH: 'character:needsRefresh',
|
||||
|
||||
// 大纲相关事件
|
||||
OUTLINE_CREATED: 'outline:created',
|
||||
OUTLINE_UPDATED: 'outline:updated',
|
||||
OUTLINE_DELETED: 'outline:deleted',
|
||||
OUTLINE_REORDERED: 'outline:reordered',
|
||||
OUTLINE_GENERATED: 'outline:generated',
|
||||
OUTLINE_NEEDS_REFRESH: 'outline:needsRefresh',
|
||||
|
||||
// 章节相关事件
|
||||
CHAPTER_CREATED: 'chapter:created',
|
||||
CHAPTER_UPDATED: 'chapter:updated',
|
||||
CHAPTER_DELETED: 'chapter:deleted',
|
||||
CHAPTER_NEEDS_REFRESH: 'chapter:needsRefresh',
|
||||
} as const;
|
||||
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* Store Hooks - 提供数据获取和自动同步功能
|
||||
* 这些 hooks 封装了数据获取逻辑,并自动更新 store
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { message } from 'antd';
|
||||
import { useStore } from './index';
|
||||
import { projectApi, outlineApi, characterApi, chapterApi } from '../services/api';
|
||||
import type {
|
||||
PaginationResponse,
|
||||
Outline,
|
||||
Character,
|
||||
Chapter,
|
||||
Project,
|
||||
ProjectCreate,
|
||||
ProjectUpdate,
|
||||
OutlineCreate,
|
||||
OutlineUpdate,
|
||||
ChapterCreate,
|
||||
ChapterUpdate,
|
||||
GenerateOutlineRequest,
|
||||
GenerateCharacterRequest
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* 项目数据同步 Hook
|
||||
*/
|
||||
export function useProjectSync() {
|
||||
const { setProjects, setLoading, addProject, updateProject, removeProject } = useStore();
|
||||
|
||||
// 刷新项目列表
|
||||
const refreshProjects = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await projectApi.getProjects();
|
||||
const projects = Array.isArray(data) ? data : (data as PaginationResponse<Project>).items || [];
|
||||
setProjects(projects);
|
||||
return projects;
|
||||
} catch (error) {
|
||||
console.error('刷新项目列表失败:', error);
|
||||
message.error('刷新项目列表失败');
|
||||
return [];
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [setProjects, setLoading]);
|
||||
|
||||
// 创建项目(带同步)
|
||||
const createProject = useCallback(async (data: ProjectCreate) => {
|
||||
try {
|
||||
const created = await projectApi.createProject(data);
|
||||
addProject(created);
|
||||
return created;
|
||||
} catch (error) {
|
||||
console.error('创建项目失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [addProject]);
|
||||
|
||||
// 更新项目(带同步)
|
||||
const updateProjectSync = useCallback(async (id: string, data: ProjectUpdate) => {
|
||||
try {
|
||||
const updated = await projectApi.updateProject(id, data);
|
||||
updateProject(id, updated);
|
||||
return updated;
|
||||
} catch (error) {
|
||||
console.error('更新项目失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [updateProject]);
|
||||
|
||||
// 删除项目(带同步)
|
||||
const deleteProject = useCallback(async (id: string) => {
|
||||
try {
|
||||
await projectApi.deleteProject(id);
|
||||
removeProject(id);
|
||||
} catch (error) {
|
||||
console.error('删除项目失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [removeProject]);
|
||||
|
||||
return {
|
||||
refreshProjects,
|
||||
createProject,
|
||||
updateProject: updateProjectSync,
|
||||
deleteProject,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色数据同步 Hook
|
||||
*/
|
||||
export function useCharacterSync() {
|
||||
const { currentProject, setCharacters, addCharacter, removeCharacter } = useStore();
|
||||
|
||||
// 刷新角色列表
|
||||
const refreshCharacters = useCallback(async (projectId?: string) => {
|
||||
const id = projectId || currentProject?.id;
|
||||
if (!id) return [];
|
||||
|
||||
try {
|
||||
const data = await characterApi.getCharacters(id);
|
||||
const characters = Array.isArray(data) ? data : (data as PaginationResponse<Character>).items || [];
|
||||
setCharacters(characters);
|
||||
return characters;
|
||||
} catch (error) {
|
||||
console.error('刷新角色列表失败:', error);
|
||||
message.error('刷新角色列表失败');
|
||||
return [];
|
||||
}
|
||||
}, [currentProject?.id, setCharacters]);
|
||||
|
||||
// 删除角色(带同步)
|
||||
const deleteCharacter = useCallback(async (id: string) => {
|
||||
try {
|
||||
await characterApi.deleteCharacter(id);
|
||||
removeCharacter(id);
|
||||
} catch (error) {
|
||||
console.error('删除角色失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [removeCharacter]);
|
||||
|
||||
// AI生成角色(带同步)
|
||||
const generateCharacter = useCallback(async (data: GenerateCharacterRequest) => {
|
||||
try {
|
||||
const generated = await characterApi.generateCharacter(data);
|
||||
addCharacter(generated);
|
||||
return generated;
|
||||
} catch (error) {
|
||||
console.error('AI生成角色失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [addCharacter]);
|
||||
|
||||
return {
|
||||
refreshCharacters,
|
||||
deleteCharacter,
|
||||
generateCharacter,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 大纲数据同步 Hook
|
||||
*/
|
||||
export function useOutlineSync() {
|
||||
const { currentProject, setOutlines, addOutline, updateOutline, removeOutline } = useStore();
|
||||
|
||||
// 刷新大纲列表
|
||||
const refreshOutlines = useCallback(async (projectId?: string) => {
|
||||
const id = projectId || currentProject?.id;
|
||||
if (!id) return [];
|
||||
|
||||
try {
|
||||
const data = await outlineApi.getOutlines(id);
|
||||
const outlines = Array.isArray(data) ? data : (data as PaginationResponse<Outline>).items || [];
|
||||
setOutlines(outlines);
|
||||
return outlines;
|
||||
} catch (error) {
|
||||
console.error('刷新大纲列表失败:', error);
|
||||
message.error('刷新大纲列表失败');
|
||||
return [];
|
||||
}
|
||||
}, [currentProject?.id, setOutlines]); // 添加 currentProject?.id 到依赖数组
|
||||
|
||||
// 创建大纲(带同步)
|
||||
const createOutline = useCallback(async (data: OutlineCreate) => {
|
||||
try {
|
||||
const created = await outlineApi.createOutline(data);
|
||||
addOutline(created);
|
||||
return created;
|
||||
} catch (error) {
|
||||
console.error('创建大纲失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [addOutline]);
|
||||
|
||||
// 更新大纲(带同步)
|
||||
const updateOutlineSync = useCallback(async (id: string, data: OutlineUpdate) => {
|
||||
try {
|
||||
const updated = await outlineApi.updateOutline(id, data);
|
||||
updateOutline(id, updated);
|
||||
return updated;
|
||||
} catch (error) {
|
||||
console.error('更新大纲失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [updateOutline]);
|
||||
|
||||
// 删除大纲(带同步)
|
||||
const deleteOutline = useCallback(async (id: string) => {
|
||||
try {
|
||||
await outlineApi.deleteOutline(id);
|
||||
removeOutline(id);
|
||||
} catch (error) {
|
||||
console.error('删除大纲失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [removeOutline]);
|
||||
|
||||
// 重排序大纲(带同步)
|
||||
const reorderOutlines = useCallback(async (orders: Array<{ id: string; order_index: number }>, projectId?: string) => {
|
||||
try {
|
||||
await outlineApi.reorderOutlines({ orders });
|
||||
// 重新获取完整列表以确保顺序正确
|
||||
const id = projectId || currentProject?.id;
|
||||
if (id) {
|
||||
const data = await outlineApi.getOutlines(id);
|
||||
const outlines = Array.isArray(data) ? data : (data as PaginationResponse<Outline>).items || [];
|
||||
setOutlines(outlines);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重排序大纲失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [currentProject?.id, setOutlines]); // 添加 currentProject?.id 到依赖数组
|
||||
|
||||
// AI生成大纲(带同步)
|
||||
const generateOutlines = useCallback(async (data: GenerateOutlineRequest) => {
|
||||
try {
|
||||
const result = await outlineApi.generateOutline(data);
|
||||
const outlines = Array.isArray(result) ? result : (result as PaginationResponse<Outline>).items || [];
|
||||
outlines.forEach((outline: Outline) => addOutline(outline));
|
||||
return outlines;
|
||||
} catch (error) {
|
||||
console.error('AI生成大纲失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [addOutline]);
|
||||
|
||||
return {
|
||||
refreshOutlines,
|
||||
createOutline,
|
||||
updateOutline: updateOutlineSync,
|
||||
deleteOutline,
|
||||
reorderOutlines,
|
||||
generateOutlines,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 章节数据同步 Hook
|
||||
*/
|
||||
export function useChapterSync() {
|
||||
const { currentProject, setChapters, addChapter, updateChapter, removeChapter } = useStore();
|
||||
|
||||
// 刷新章节列表
|
||||
const refreshChapters = useCallback(async (projectId?: string) => {
|
||||
const id = projectId || currentProject?.id;
|
||||
if (!id) return [];
|
||||
|
||||
try {
|
||||
const data = await chapterApi.getChapters(id);
|
||||
const chapters = Array.isArray(data) ? data : (data as PaginationResponse<Chapter>).items || [];
|
||||
setChapters(chapters);
|
||||
return chapters;
|
||||
} catch (error) {
|
||||
console.error('刷新章节列表失败:', error);
|
||||
message.error('刷新章节列表失败');
|
||||
return [];
|
||||
}
|
||||
}, [currentProject?.id, setChapters]); // 添加 currentProject?.id 到依赖数组
|
||||
|
||||
// 创建章节(带同步)
|
||||
const createChapter = useCallback(async (data: ChapterCreate) => {
|
||||
try {
|
||||
const created = await chapterApi.createChapter(data);
|
||||
addChapter(created);
|
||||
return created;
|
||||
} catch (error) {
|
||||
console.error('创建章节失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [addChapter]);
|
||||
|
||||
// 更新章节(带同步)
|
||||
const updateChapterSync = useCallback(async (id: string, data: ChapterUpdate) => {
|
||||
try {
|
||||
const updated = await chapterApi.updateChapter(id, data);
|
||||
updateChapter(id, updated);
|
||||
return updated;
|
||||
} catch (error) {
|
||||
console.error('更新章节失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [updateChapter]);
|
||||
|
||||
// 删除章节(带同步)
|
||||
const deleteChapter = useCallback(async (id: string) => {
|
||||
try {
|
||||
await chapterApi.deleteChapter(id);
|
||||
removeChapter(id);
|
||||
} catch (error) {
|
||||
console.error('删除章节失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [removeChapter]);
|
||||
|
||||
// AI生成章节内容(带同步)
|
||||
const generateChapterContent = useCallback(async (chapterId: string) => {
|
||||
try {
|
||||
const result = await chapterApi.generateChapterContent(chapterId);
|
||||
// 直接调用 API 更新
|
||||
const updated = await chapterApi.updateChapter(chapterId, { content: result.content });
|
||||
updateChapter(chapterId, updated);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('AI生成章节内容失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [updateChapter]);
|
||||
|
||||
// AI流式生成章节内容(带同步)
|
||||
const generateChapterContentStream = useCallback(async (
|
||||
chapterId: string,
|
||||
onProgress?: (content: string) => void
|
||||
) => {
|
||||
try {
|
||||
// 使用fetch处理流式响应
|
||||
const response = await fetch(`/api/chapters/${chapterId}/generate-stream`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
if (!reader) {
|
||||
throw new Error('无法获取响应流');
|
||||
}
|
||||
|
||||
let buffer = '';
|
||||
let fullContent = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// 处理缓冲区中的完整消息
|
||||
const lines = buffer.split('\n\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() === '' || line.startsWith(':')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const dataMatch = line.match(/^data: (.+)$/m);
|
||||
if (dataMatch) {
|
||||
const message = JSON.parse(dataMatch[1]);
|
||||
|
||||
if (message.type === 'content' && message.content) {
|
||||
fullContent += message.content;
|
||||
if (onProgress) {
|
||||
onProgress(fullContent);
|
||||
}
|
||||
} else if (message.type === 'error') {
|
||||
throw new Error(message.error || '生成失败');
|
||||
} else if (message.type === 'done') {
|
||||
// 生成完成,刷新章节数据
|
||||
await refreshChapters();
|
||||
return { content: fullContent, word_count: message.word_count };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析SSE消息失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { content: fullContent };
|
||||
} catch (error) {
|
||||
console.error('AI流式生成章节内容失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [refreshChapters]);
|
||||
|
||||
return {
|
||||
refreshChapters,
|
||||
createChapter,
|
||||
updateChapter: updateChapterSync,
|
||||
deleteChapter,
|
||||
generateChapterContent,
|
||||
generateChapterContentStream,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Project, Outline, Character, Chapter } from '../types';
|
||||
|
||||
interface AppState {
|
||||
currentProject: Project | null;
|
||||
setCurrentProject: (project: Project | null) => void;
|
||||
|
||||
projects: Project[];
|
||||
setProjects: (projects: Project[]) => void;
|
||||
addProject: (project: Project) => void;
|
||||
updateProject: (id: string, project: Partial<Project>) => void;
|
||||
removeProject: (id: string) => void;
|
||||
|
||||
outlines: Outline[];
|
||||
setOutlines: (outlines: Outline[]) => void;
|
||||
addOutline: (outline: Outline) => void;
|
||||
updateOutline: (id: string, outline: Partial<Outline>) => void;
|
||||
removeOutline: (id: string) => void;
|
||||
|
||||
characters: Character[];
|
||||
setCharacters: (characters: Character[]) => void;
|
||||
addCharacter: (character: Character) => void;
|
||||
updateCharacter: (id: string, character: Partial<Character>) => void;
|
||||
removeCharacter: (id: string) => void;
|
||||
|
||||
chapters: Chapter[];
|
||||
setChapters: (chapters: Chapter[]) => void;
|
||||
addChapter: (chapter: Chapter) => void;
|
||||
updateChapter: (id: string, chapter: Partial<Chapter>) => void;
|
||||
removeChapter: (id: string) => void;
|
||||
|
||||
currentChapter: Chapter | null;
|
||||
setCurrentChapter: (chapter: Chapter | null) => void;
|
||||
|
||||
loading: boolean;
|
||||
setLoading: (loading: boolean) => void;
|
||||
|
||||
lastUpdated: {
|
||||
projects?: number;
|
||||
outlines?: number;
|
||||
characters?: number;
|
||||
chapters?: number;
|
||||
};
|
||||
markUpdated: (key: 'projects' | 'outlines' | 'characters' | 'chapters') => void;
|
||||
|
||||
clearProjectData: () => void;
|
||||
}
|
||||
|
||||
export const useStore = create<AppState>((set) => ({
|
||||
currentProject: null,
|
||||
setCurrentProject: (project) => set({ currentProject: project }),
|
||||
|
||||
projects: [],
|
||||
setProjects: (projects) => set({ projects }),
|
||||
addProject: (project) => set((state) => ({
|
||||
projects: [...state.projects, project]
|
||||
})),
|
||||
updateProject: (id, updatedProject) => set((state) => ({
|
||||
projects: state.projects.map((p) =>
|
||||
p.id === id ? { ...p, ...updatedProject } : p
|
||||
),
|
||||
currentProject: state.currentProject?.id === id
|
||||
? { ...state.currentProject, ...updatedProject }
|
||||
: state.currentProject,
|
||||
})),
|
||||
removeProject: (id) => set((state) => ({
|
||||
projects: state.projects.filter((p) => p.id !== id),
|
||||
currentProject: state.currentProject?.id === id ? null : state.currentProject,
|
||||
})),
|
||||
|
||||
outlines: [],
|
||||
setOutlines: (outlines) => set({ outlines }),
|
||||
addOutline: (outline) => set((state) => ({
|
||||
outlines: [...state.outlines, outline]
|
||||
})),
|
||||
updateOutline: (id, updatedOutline) => set((state) => ({
|
||||
outlines: state.outlines.map((o) =>
|
||||
o.id === id ? { ...o, ...updatedOutline } : o
|
||||
),
|
||||
})),
|
||||
removeOutline: (id) => set((state) => ({
|
||||
outlines: state.outlines.filter((o) => o.id !== id),
|
||||
})),
|
||||
|
||||
characters: [],
|
||||
setCharacters: (characters) => set({ characters }),
|
||||
addCharacter: (character) => set((state) => ({
|
||||
characters: [...state.characters, character]
|
||||
})),
|
||||
updateCharacter: (id, updatedCharacter) => set((state) => ({
|
||||
characters: state.characters.map((c) =>
|
||||
c.id === id ? { ...c, ...updatedCharacter } : c
|
||||
),
|
||||
})),
|
||||
removeCharacter: (id) => set((state) => ({
|
||||
characters: state.characters.filter((c) => c.id !== id),
|
||||
})),
|
||||
|
||||
chapters: [],
|
||||
setChapters: (chapters) => set({ chapters }),
|
||||
addChapter: (chapter) => set((state) => ({
|
||||
chapters: [...state.chapters, chapter]
|
||||
})),
|
||||
updateChapter: (id, updatedChapter) => set((state) => ({
|
||||
chapters: state.chapters.map((c) =>
|
||||
c.id === id ? { ...c, ...updatedChapter } : c
|
||||
),
|
||||
currentChapter: state.currentChapter?.id === id
|
||||
? { ...state.currentChapter, ...updatedChapter }
|
||||
: state.currentChapter,
|
||||
})),
|
||||
removeChapter: (id) => set((state) => ({
|
||||
chapters: state.chapters.filter((c) => c.id !== id),
|
||||
currentChapter: state.currentChapter?.id === id ? null : state.currentChapter,
|
||||
})),
|
||||
|
||||
currentChapter: null,
|
||||
setCurrentChapter: (chapter) => set({ currentChapter: chapter }),
|
||||
|
||||
loading: false,
|
||||
setLoading: (loading) => set({ loading }),
|
||||
|
||||
lastUpdated: {},
|
||||
markUpdated: (key) => set((state) => ({
|
||||
lastUpdated: {
|
||||
...state.lastUpdated,
|
||||
[key]: Date.now(),
|
||||
},
|
||||
})),
|
||||
|
||||
clearProjectData: () => set({
|
||||
outlines: [],
|
||||
characters: [],
|
||||
chapters: [],
|
||||
currentChapter: null,
|
||||
}),
|
||||
}));
|
||||
@@ -0,0 +1,304 @@
|
||||
// 用户类型定义
|
||||
export interface User {
|
||||
user_id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
avatar_url?: string;
|
||||
trust_level: number;
|
||||
is_admin: boolean;
|
||||
linuxdo_id: string;
|
||||
created_at: string;
|
||||
last_login: string;
|
||||
}
|
||||
|
||||
// LinuxDO 授权 URL 响应
|
||||
export interface AuthUrlResponse {
|
||||
auth_url: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
// 项目类型定义
|
||||
export interface Project {
|
||||
id: string; // UUID字符串
|
||||
title: string;
|
||||
description?: string;
|
||||
theme?: string;
|
||||
genre?: string;
|
||||
target_words?: number;
|
||||
current_words: number;
|
||||
status: 'planning' | 'writing' | 'revising' | 'completed';
|
||||
wizard_status?: 'incomplete' | 'completed';
|
||||
wizard_step?: number;
|
||||
world_time_period?: string;
|
||||
world_location?: string;
|
||||
world_atmosphere?: string;
|
||||
world_rules?: string;
|
||||
chapter_count?: number;
|
||||
narrative_perspective?: string;
|
||||
character_count?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProjectCreate {
|
||||
title: string;
|
||||
description?: string;
|
||||
theme?: string;
|
||||
genre?: string;
|
||||
target_words?: number;
|
||||
wizard_status?: 'incomplete' | 'completed';
|
||||
wizard_step?: number;
|
||||
world_time_period?: string;
|
||||
world_location?: string;
|
||||
world_atmosphere?: string;
|
||||
world_rules?: string;
|
||||
}
|
||||
|
||||
export interface ProjectUpdate {
|
||||
title?: string;
|
||||
description?: string;
|
||||
theme?: string;
|
||||
genre?: string;
|
||||
target_words?: number;
|
||||
status?: 'planning' | 'writing' | 'revising' | 'completed';
|
||||
world_time_period?: string;
|
||||
world_location?: string;
|
||||
world_atmosphere?: string;
|
||||
world_rules?: string;
|
||||
chapter_count?: number;
|
||||
narrative_perspective?: string;
|
||||
character_count?: number;
|
||||
// current_words 由章节内容自动计算,不在此接口中
|
||||
}
|
||||
|
||||
// 向导专用的项目更新接口,包含向导流程控制字段
|
||||
export interface ProjectWizardUpdate extends ProjectUpdate {
|
||||
wizard_status?: 'incomplete' | 'completed';
|
||||
wizard_step?: number;
|
||||
}
|
||||
|
||||
// 项目创建向导
|
||||
export interface ProjectWizardRequest {
|
||||
title: string;
|
||||
theme: string;
|
||||
genre?: string;
|
||||
chapter_count: number;
|
||||
narrative_perspective: string;
|
||||
character_count?: number;
|
||||
target_words?: number;
|
||||
world_building?: {
|
||||
time_period: string;
|
||||
location: string;
|
||||
atmosphere: string;
|
||||
rules: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorldBuildingResponse {
|
||||
project_id: string;
|
||||
time_period: string;
|
||||
location: string;
|
||||
atmosphere: string;
|
||||
rules: string;
|
||||
}
|
||||
|
||||
// 大纲类型定义
|
||||
export interface Outline {
|
||||
id: string;
|
||||
project_id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
structure?: string;
|
||||
order_index: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface OutlineCreate {
|
||||
project_id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
structure?: string;
|
||||
order_index: number;
|
||||
}
|
||||
|
||||
export interface OutlineUpdate {
|
||||
title?: string;
|
||||
content?: string;
|
||||
// structure 暂不支持修改
|
||||
// order_index 只能通过 reorder 接口批量调整
|
||||
}
|
||||
|
||||
// 角色类型定义
|
||||
export interface Character {
|
||||
id: string;
|
||||
project_id: string;
|
||||
name: string;
|
||||
age?: string;
|
||||
gender?: string;
|
||||
is_organization: boolean;
|
||||
role_type?: string;
|
||||
personality?: string;
|
||||
background?: string;
|
||||
appearance?: string;
|
||||
relationships?: string;
|
||||
organization_type?: string;
|
||||
organization_purpose?: string;
|
||||
organization_members?: string;
|
||||
traits?: string;
|
||||
avatar_url?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CharacterUpdate {
|
||||
name?: string;
|
||||
age?: string;
|
||||
gender?: string;
|
||||
is_organization?: boolean;
|
||||
role_type?: string;
|
||||
personality?: string;
|
||||
background?: string;
|
||||
appearance?: string;
|
||||
relationships?: string;
|
||||
organization_type?: string;
|
||||
organization_purpose?: string;
|
||||
organization_members?: string;
|
||||
traits?: string;
|
||||
}
|
||||
|
||||
// 章节类型定义
|
||||
export interface Chapter {
|
||||
id: string;
|
||||
project_id: string;
|
||||
title: string;
|
||||
content?: string;
|
||||
summary?: string;
|
||||
chapter_number: number;
|
||||
word_count: number;
|
||||
status: 'draft' | 'writing' | 'completed';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ChapterCreate {
|
||||
project_id: string;
|
||||
title: string;
|
||||
chapter_number: number;
|
||||
content?: string;
|
||||
summary?: string;
|
||||
status?: 'draft' | 'writing' | 'completed';
|
||||
}
|
||||
|
||||
export interface ChapterUpdate {
|
||||
title?: string;
|
||||
content?: string;
|
||||
// chapter_number 不允许修改,由大纲顺序决定
|
||||
summary?: string;
|
||||
// word_count 自动计算,不允许手动修改
|
||||
status?: 'draft' | 'writing' | 'completed';
|
||||
}
|
||||
|
||||
// 章节生成检查响应
|
||||
export interface ChapterCanGenerateResponse {
|
||||
can_generate: boolean;
|
||||
reason: string;
|
||||
previous_chapters: {
|
||||
id: string;
|
||||
chapter_number: number;
|
||||
title: string;
|
||||
has_content: boolean;
|
||||
word_count: number;
|
||||
}[];
|
||||
chapter_number: number;
|
||||
}
|
||||
|
||||
// AI生成请求类型
|
||||
export interface GenerateOutlineRequest {
|
||||
project_id: string;
|
||||
genre?: string;
|
||||
theme: string;
|
||||
chapter_count: number;
|
||||
narrative_perspective: string;
|
||||
world_context?: Record<string, unknown>;
|
||||
characters_context?: Character[];
|
||||
target_words?: number;
|
||||
requirements?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
// 续写功能新增字段
|
||||
mode?: 'auto' | 'new' | 'continue';
|
||||
story_direction?: string;
|
||||
plot_stage?: 'development' | 'climax' | 'ending';
|
||||
keep_existing?: boolean;
|
||||
}
|
||||
|
||||
// 大纲重排序请求类型
|
||||
export interface OutlineReorderItem {
|
||||
id: string;
|
||||
order_index: number;
|
||||
}
|
||||
|
||||
export interface OutlineReorderRequest {
|
||||
orders: OutlineReorderItem[];
|
||||
}
|
||||
|
||||
export interface GenerateCharacterRequest {
|
||||
project_id: string;
|
||||
name?: string;
|
||||
role_type?: string;
|
||||
background?: string;
|
||||
requirements?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface PolishTextRequest {
|
||||
text: string;
|
||||
style?: string;
|
||||
}
|
||||
|
||||
// 向导API响应类型
|
||||
export interface GenerateCharactersResponse {
|
||||
characters: Character[];
|
||||
}
|
||||
|
||||
export interface GenerateOutlineResponse {
|
||||
outlines: Outline[];
|
||||
}
|
||||
|
||||
// API响应类型
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface PaginationResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
// 向导表单数据类型
|
||||
export interface WizardBasicInfo {
|
||||
title: string;
|
||||
description: string;
|
||||
theme: string;
|
||||
genre: string | string[];
|
||||
chapter_count: number;
|
||||
narrative_perspective: string;
|
||||
character_count?: number;
|
||||
target_words?: number;
|
||||
}
|
||||
|
||||
// API 错误响应类型
|
||||
export interface ApiError {
|
||||
response?: {
|
||||
data?: {
|
||||
detail?: string;
|
||||
};
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
export interface SSEMessage {
|
||||
type: 'progress' | 'chunk' | 'result' | 'error' | 'done';
|
||||
message?: string;
|
||||
progress?: number;
|
||||
status?: 'processing' | 'success' | 'error' | 'warning';
|
||||
content?: string;
|
||||
data?: any;
|
||||
error?: string;
|
||||
code?: number;
|
||||
}
|
||||
|
||||
export interface SSEClientOptions {
|
||||
onProgress?: (message: string, progress: number, status: string) => void;
|
||||
onChunk?: (content: string) => void;
|
||||
onResult?: (data: any) => void;
|
||||
onError?: (error: string, code?: number) => void;
|
||||
onComplete?: () => void;
|
||||
onConnectionError?: (error: Event) => void;
|
||||
}
|
||||
|
||||
export class SSEClient {
|
||||
private eventSource: EventSource | null = null;
|
||||
private url: string;
|
||||
private options: SSEClientOptions;
|
||||
private accumulatedContent: string = '';
|
||||
|
||||
constructor(url: string, options: SSEClientOptions = {}) {
|
||||
this.url = url;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
connect(): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.eventSource = new EventSource(this.url);
|
||||
|
||||
this.eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const message: SSEMessage = JSON.parse(event.data);
|
||||
this.handleMessage(message, resolve, reject);
|
||||
} catch (error) {
|
||||
console.error('解析SSE消息失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.eventSource.onerror = (error) => {
|
||||
console.error('SSE连接错误:', error);
|
||||
if (this.options.onConnectionError) {
|
||||
this.options.onConnectionError(error);
|
||||
}
|
||||
this.close();
|
||||
reject(new Error('SSE连接失败'));
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private handleMessage(message: SSEMessage, resolve: Function, reject: Function) {
|
||||
switch (message.type) {
|
||||
case 'progress':
|
||||
if (this.options.onProgress && message.message && message.progress !== undefined) {
|
||||
this.options.onProgress(message.message, message.progress, message.status || 'processing');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'chunk':
|
||||
if (message.content) {
|
||||
this.accumulatedContent += message.content;
|
||||
if (this.options.onChunk) {
|
||||
this.options.onChunk(message.content);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'result':
|
||||
if (this.options.onResult && message.data) {
|
||||
this.options.onResult(message.data);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
if (this.options.onError) {
|
||||
this.options.onError(message.error || '未知错误', message.code);
|
||||
}
|
||||
this.close();
|
||||
reject(new Error(message.error || '未知错误'));
|
||||
break;
|
||||
|
||||
case 'done':
|
||||
if (this.options.onComplete) {
|
||||
this.options.onComplete();
|
||||
}
|
||||
this.close();
|
||||
if (!this.options.onResult && this.accumulatedContent) {
|
||||
resolve({ content: this.accumulatedContent });
|
||||
} else {
|
||||
resolve(true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.eventSource) {
|
||||
this.eventSource.close();
|
||||
this.eventSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
getAccumulatedContent(): string {
|
||||
return this.accumulatedContent;
|
||||
}
|
||||
}
|
||||
|
||||
export class SSEPostClient {
|
||||
private url: string;
|
||||
private data: any;
|
||||
private options: SSEClientOptions;
|
||||
private abortController: AbortController | null = null;
|
||||
private accumulatedContent: string = '';
|
||||
|
||||
constructor(url: string, data: any, options: SSEClientOptions = {}) {
|
||||
this.url = url;
|
||||
this.data = data;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
async connect(): Promise<any> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
this.abortController = new AbortController();
|
||||
|
||||
const response = await fetch(this.url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(this.data),
|
||||
signal: this.abortController.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
if (!reader) {
|
||||
throw new Error('无法获取响应流');
|
||||
}
|
||||
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const lines = buffer.split('\n\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() === '' || line.startsWith(':')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const dataMatch = line.match(/^data: (.+)$/m);
|
||||
if (dataMatch) {
|
||||
const message: SSEMessage = JSON.parse(dataMatch[1]);
|
||||
await this.handleMessage(message, resolve, reject);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析SSE消息失败:', error, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
if (error.name === 'AbortError') {
|
||||
console.log('请求已取消');
|
||||
} else {
|
||||
console.error('SSE POST请求失败:', error);
|
||||
if (this.options.onError) {
|
||||
this.options.onError(error.message || '请求失败');
|
||||
}
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async handleMessage(message: SSEMessage, resolve: Function, reject: Function) {
|
||||
switch (message.type) {
|
||||
case 'progress':
|
||||
if (this.options.onProgress && message.message && message.progress !== undefined) {
|
||||
this.options.onProgress(message.message, message.progress, message.status || 'processing');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'chunk':
|
||||
if (message.content) {
|
||||
this.accumulatedContent += message.content;
|
||||
if (this.options.onChunk) {
|
||||
this.options.onChunk(message.content);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'result':
|
||||
if (this.options.onResult && message.data) {
|
||||
this.options.onResult(message.data);
|
||||
}
|
||||
(this as any).resultData = message.data;
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
if (this.options.onError) {
|
||||
this.options.onError(message.error || '未知错误', message.code);
|
||||
}
|
||||
reject(new Error(message.error || '未知错误'));
|
||||
break;
|
||||
|
||||
case 'done':
|
||||
if (this.options.onComplete) {
|
||||
this.options.onComplete();
|
||||
}
|
||||
if ((this as any).resultData) {
|
||||
resolve((this as any).resultData);
|
||||
} else if (this.accumulatedContent) {
|
||||
resolve({ content: this.accumulatedContent });
|
||||
} else {
|
||||
resolve(true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
abort() {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
}
|
||||
}
|
||||
|
||||
getAccumulatedContent(): string {
|
||||
return this.accumulatedContent;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ssePost<T = any>(
|
||||
url: string,
|
||||
data: any,
|
||||
options: SSEClientOptions = {}
|
||||
): Promise<T> {
|
||||
const client = new SSEPostClient(url, data, options);
|
||||
try {
|
||||
return await client.connect();
|
||||
} finally {
|
||||
client.abort();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user