This commit is contained in:
xiamuceer
2025-10-30 11:14:43 +08:00
parent b97410d973
commit 0f6c2d344a
91 changed files with 22309 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
#root {
width: 100%;
margin: 0;
padding: 0;
}
+50
View File
@@ -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;
+1
View File
@@ -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

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