init
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Spin, Result, Button } from 'antd';
|
||||
import { authApi } from '../services/api';
|
||||
|
||||
export default function AuthCallback() {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
try {
|
||||
// 后端会通过 Cookie 自动设置认证信息
|
||||
// 这里只需要验证登录状态
|
||||
await authApi.getCurrentUser();
|
||||
|
||||
setStatus('success');
|
||||
|
||||
// 从 sessionStorage 获取重定向地址
|
||||
const redirect = sessionStorage.getItem('login_redirect') || '/';
|
||||
sessionStorage.removeItem('login_redirect');
|
||||
|
||||
// 延迟一下再跳转,让用户看到成功提示
|
||||
setTimeout(() => {
|
||||
navigate(redirect);
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error);
|
||||
setStatus('error');
|
||||
setErrorMessage('登录失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
handleCallback();
|
||||
}, [navigate]);
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
}}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Spin size="large" />
|
||||
<div style={{ marginTop: 20, color: 'white', fontSize: 16 }}>
|
||||
正在处理登录...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
}}>
|
||||
<Result
|
||||
status="error"
|
||||
title="登录失败"
|
||||
subTitle={errorMessage}
|
||||
extra={
|
||||
<Button type="primary" onClick={() => navigate('/login')}>
|
||||
返回登录
|
||||
</Button>
|
||||
}
|
||||
style={{ background: 'white', padding: 40, borderRadius: 8 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
}}>
|
||||
<Result
|
||||
status="success"
|
||||
title="登录成功"
|
||||
subTitle="正在跳转..."
|
||||
style={{ background: 'white', padding: 40, borderRadius: 8 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, Tooltip } from 'antd';
|
||||
import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { useChapterSync } from '../store/hooks';
|
||||
import { projectApi } from '../services/api';
|
||||
import type { Chapter, ChapterUpdate, ApiError } from '../types';
|
||||
import { cardStyles } from '../components/CardStyles';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
export default function Chapters() {
|
||||
const { currentProject, chapters, setCurrentChapter, setCurrentProject } = useStore();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
const [isContinuing, setIsContinuing] = useState(false);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [editorForm] = Form.useForm();
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
|
||||
const contentTextAreaRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(window.innerWidth <= 768);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
refreshChapters,
|
||||
updateChapter,
|
||||
generateChapterContentStream
|
||||
} = useChapterSync();
|
||||
|
||||
useEffect(() => {
|
||||
if (currentProject?.id) {
|
||||
refreshChapters();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentProject?.id]);
|
||||
|
||||
if (!currentProject) return null;
|
||||
|
||||
const canGenerateChapter = (chapter: Chapter): boolean => {
|
||||
if (chapter.chapter_number === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const previousChapters = chapters.filter(
|
||||
c => c.chapter_number < chapter.chapter_number
|
||||
);
|
||||
|
||||
return previousChapters.every(c => c.content && c.content.trim() !== '');
|
||||
};
|
||||
|
||||
const getGenerateDisabledReason = (chapter: Chapter): string => {
|
||||
if (chapter.chapter_number === 1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const previousChapters = chapters.filter(
|
||||
c => c.chapter_number < chapter.chapter_number
|
||||
);
|
||||
|
||||
const incompleteChapters = previousChapters.filter(
|
||||
c => !c.content || c.content.trim() === ''
|
||||
);
|
||||
|
||||
if (incompleteChapters.length > 0) {
|
||||
const numbers = incompleteChapters.map(c => c.chapter_number).join('、');
|
||||
return `需要先完成前置章节:第 ${numbers} 章`;
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const handleOpenModal = (id: string) => {
|
||||
const chapter = chapters.find(c => c.id === id);
|
||||
if (chapter) {
|
||||
form.setFieldsValue(chapter);
|
||||
setEditingId(id);
|
||||
setIsModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: ChapterUpdate) => {
|
||||
if (!editingId) return;
|
||||
|
||||
try {
|
||||
await updateChapter(editingId, values);
|
||||
message.success('章节更新成功');
|
||||
setIsModalOpen(false);
|
||||
form.resetFields();
|
||||
} catch {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenEditor = (id: string) => {
|
||||
const chapter = chapters.find(c => c.id === id);
|
||||
if (chapter) {
|
||||
setCurrentChapter(chapter);
|
||||
editorForm.setFieldsValue({
|
||||
title: chapter.title,
|
||||
content: chapter.content,
|
||||
});
|
||||
setEditingId(id);
|
||||
setIsEditorOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditorSubmit = async (values: ChapterUpdate) => {
|
||||
if (!editingId || !currentProject) return;
|
||||
|
||||
try {
|
||||
await updateChapter(editingId, values);
|
||||
|
||||
// 刷新项目信息以更新总字数统计
|
||||
const updatedProject = await projectApi.getProject(currentProject.id);
|
||||
setCurrentProject(updatedProject);
|
||||
|
||||
message.success('章节保存成功');
|
||||
setIsEditorOpen(false);
|
||||
} catch {
|
||||
message.error('保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!editingId) return;
|
||||
|
||||
try {
|
||||
setIsContinuing(true);
|
||||
setIsGenerating(true);
|
||||
|
||||
await generateChapterContentStream(editingId, (content) => {
|
||||
editorForm.setFieldsValue({ content });
|
||||
|
||||
if (contentTextAreaRef.current) {
|
||||
const textArea = contentTextAreaRef.current.resizableTextArea?.textArea;
|
||||
if (textArea) {
|
||||
textArea.scrollTop = textArea.scrollHeight;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
message.success('AI创作成功');
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError;
|
||||
message.error('AI创作失败:' + (apiError.response?.data?.detail || apiError.message || '未知错误'));
|
||||
} finally {
|
||||
setIsContinuing(false);
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showGenerateModal = (chapter: Chapter) => {
|
||||
const previousChapters = chapters.filter(
|
||||
c => c.chapter_number < chapter.chapter_number
|
||||
).sort((a, b) => a.chapter_number - b.chapter_number);
|
||||
|
||||
const modal = Modal.confirm({
|
||||
title: 'AI创作章节内容',
|
||||
width: 700,
|
||||
centered: true,
|
||||
content: (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<p>AI将根据以下信息创作本章内容:</p>
|
||||
<ul>
|
||||
<li>章节大纲和要求</li>
|
||||
<li>项目的世界观设定</li>
|
||||
<li>相关角色信息</li>
|
||||
<li><strong>前面已完成章节的内容(确保剧情连贯)</strong></li>
|
||||
</ul>
|
||||
|
||||
{previousChapters.length > 0 && (
|
||||
<div style={{
|
||||
marginTop: 16,
|
||||
padding: 12,
|
||||
background: '#f0f5ff',
|
||||
borderRadius: 4,
|
||||
border: '1px solid #adc6ff'
|
||||
}}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500, color: '#1890ff' }}>
|
||||
📚 将引用的前置章节(共{previousChapters.length}章):
|
||||
</div>
|
||||
<div style={{ maxHeight: 150, overflowY: 'auto' }}>
|
||||
{previousChapters.map(ch => (
|
||||
<div key={ch.id} style={{ padding: '4px 0', fontSize: 13 }}>
|
||||
✓ 第{ch.chapter_number}章:{ch.title} ({ch.word_count || 0}字)
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: '#666' }}>
|
||||
💡 AI会参考这些章节内容,确保情节连贯、角色状态一致
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p style={{ color: '#ff4d4f', marginTop: 16, marginBottom: 0 }}>
|
||||
⚠️ 注意:此操作将覆盖当前章节内容
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
okText: '开始创作',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
modal.update({
|
||||
okButtonProps: { danger: true, loading: true },
|
||||
cancelButtonProps: { disabled: true },
|
||||
closable: false,
|
||||
maskClosable: false,
|
||||
keyboard: false,
|
||||
});
|
||||
|
||||
try {
|
||||
await handleGenerate();
|
||||
modal.destroy();
|
||||
} catch (error) {
|
||||
modal.update({
|
||||
okButtonProps: { danger: true, loading: false },
|
||||
cancelButtonProps: { disabled: false },
|
||||
closable: true,
|
||||
maskClosable: true,
|
||||
keyboard: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
onCancel: () => {
|
||||
if (isGenerating) {
|
||||
message.warning('AI正在创作中,请等待完成');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
'draft': 'default',
|
||||
'writing': 'processing',
|
||||
'completed': 'success',
|
||||
};
|
||||
return colors[status] || 'default';
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const texts: Record<string, string> = {
|
||||
'draft': '草稿',
|
||||
'writing': '创作中',
|
||||
'completed': '已完成',
|
||||
};
|
||||
return texts[status] || status;
|
||||
};
|
||||
|
||||
const sortedChapters = [...chapters].sort((a, b) => a.chapter_number - b.chapter_number);
|
||||
|
||||
const handleExport = () => {
|
||||
if (chapters.length === 0) {
|
||||
message.warning('当前项目没有章节,无法导出');
|
||||
return;
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: '导出项目章节',
|
||||
content: `确定要将《${currentProject.title}》的所有章节导出为TXT文件吗?`,
|
||||
centered: true,
|
||||
okText: '确定导出',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
try {
|
||||
projectApi.exportProject(currentProject.id);
|
||||
message.success('开始下载导出文件');
|
||||
} catch {
|
||||
message.error('导出失败,请重试');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
backgroundColor: '#fff',
|
||||
padding: isMobile ? '12px 0' : '16px 0',
|
||||
marginBottom: isMobile ? 12 : 16,
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
gap: isMobile ? 12 : 0,
|
||||
justifyContent: 'space-between',
|
||||
alignItems: isMobile ? 'stretch' : 'center'
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}>章节管理</h2>
|
||||
<Space direction={isMobile ? 'vertical' : 'horizontal'} style={{ width: isMobile ? '100%' : 'auto' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleExport}
|
||||
disabled={chapters.length === 0}
|
||||
block={isMobile}
|
||||
size={isMobile ? 'middle' : 'middle'}
|
||||
>
|
||||
导出为TXT
|
||||
</Button>
|
||||
{!isMobile && <Tag color="blue">章节由大纲管理,请在大纲页面添加/删除</Tag>}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{chapters.length === 0 ? (
|
||||
<Empty description="还没有章节,开始创作吧!" />
|
||||
) : (
|
||||
<Card style={cardStyles.base}>
|
||||
<List
|
||||
dataSource={sortedChapters}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
style={{
|
||||
padding: '16px 0',
|
||||
borderRadius: 8,
|
||||
transition: 'background 0.3s ease',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
alignItems: isMobile ? 'flex-start' : 'center'
|
||||
}}
|
||||
actions={isMobile ? undefined : [
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleOpenEditor(item.id)}
|
||||
>
|
||||
编辑内容
|
||||
</Button>,
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleOpenModal(item.id)}
|
||||
>
|
||||
修改信息
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div style={{ width: '100%' }}>
|
||||
<List.Item.Meta
|
||||
avatar={!isMobile && <FileTextOutlined style={{ fontSize: 32, color: '#1890ff' }} />}
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: isMobile ? 4 : 8, flexWrap: 'wrap', fontSize: isMobile ? 14 : 16 }}>
|
||||
<span>第{item.chapter_number}章:{item.title}</span>
|
||||
<Tag color={getStatusColor(item.status)}>{getStatusText(item.status)}</Tag>
|
||||
<Badge count={`${item.word_count || 0}字`} style={{ backgroundColor: '#52c41a' }} />
|
||||
{!canGenerateChapter(item) && (
|
||||
<Tooltip title={getGenerateDisabledReason(item)}>
|
||||
<Tag icon={<LockOutlined />} color="warning">
|
||||
需前置章节
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
item.content ? (
|
||||
<div style={{ marginTop: 8, color: 'rgba(0,0,0,0.65)', lineHeight: 1.6, fontSize: isMobile ? 12 : 14 }}>
|
||||
{item.content.substring(0, isMobile ? 80 : 150)}
|
||||
{item.content.length > (isMobile ? 80 : 150) && '...'}
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ color: 'rgba(0,0,0,0.45)', fontSize: isMobile ? 12 : 14 }}>暂无内容</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{isMobile && (
|
||||
<Space style={{ marginTop: 12, width: '100%', justifyContent: 'flex-end' }} wrap>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleOpenEditor(item.id)}
|
||||
size="small"
|
||||
title="编辑内容"
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<SettingOutlined />}
|
||||
onClick={() => handleOpenModal(item.id)}
|
||||
size="small"
|
||||
title="修改信息"
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={editingId ? '编辑章节信息' : '添加章节'}
|
||||
open={isModalOpen}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
footer={null}
|
||||
centered={!isMobile}
|
||||
width={isMobile ? 'calc(100% - 32px)' : 520}
|
||||
style={isMobile ? {
|
||||
top: 20,
|
||||
paddingBottom: 0,
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
margin: '0 16px'
|
||||
} : undefined}
|
||||
styles={{
|
||||
body: {
|
||||
maxHeight: isMobile ? 'calc(100vh - 150px)' : 'calc(80vh - 110px)',
|
||||
overflowY: 'auto'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item
|
||||
label="章节标题"
|
||||
name="title"
|
||||
tooltip="章节标题由大纲管理,建议在大纲页面统一修改"
|
||||
>
|
||||
<Input placeholder="输入章节标题" disabled />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="章节序号"
|
||||
name="chapter_number"
|
||||
tooltip="章节序号由大纲的顺序决定,无法修改。请在大纲页面使用上移/下移功能调整顺序"
|
||||
>
|
||||
<Input type="number" placeholder="章节排序序号" disabled />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="状态" name="status">
|
||||
<Select placeholder="选择状态">
|
||||
<Select.Option value="draft">草稿</Select.Option>
|
||||
<Select.Option value="writing">创作中</Select.Option>
|
||||
<Select.Option value="completed">已完成</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space style={{ float: 'right' }}>
|
||||
<Button onClick={() => setIsModalOpen(false)}>取消</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
更新
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="编辑章节内容"
|
||||
open={isEditorOpen}
|
||||
onCancel={() => {
|
||||
if (isGenerating) {
|
||||
message.warning('AI正在创作中,请等待完成后再关闭');
|
||||
return;
|
||||
}
|
||||
setIsEditorOpen(false);
|
||||
}}
|
||||
closable={!isGenerating}
|
||||
maskClosable={!isGenerating}
|
||||
keyboard={!isGenerating}
|
||||
width={isMobile ? 'calc(100% - 32px)' : '85%'}
|
||||
centered={!isMobile}
|
||||
style={isMobile ? {
|
||||
top: 20,
|
||||
paddingBottom: 0,
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
margin: '0 16px'
|
||||
} : undefined}
|
||||
styles={{
|
||||
body: {
|
||||
maxHeight: isMobile ? 'calc(100vh - 150px)' : 'calc(85vh - 110px)',
|
||||
overflowY: 'auto',
|
||||
padding: isMobile ? '16px 12px' : '8px'
|
||||
}
|
||||
}}
|
||||
footer={null}
|
||||
>
|
||||
<Form form={editorForm} layout="vertical" onFinish={handleEditorSubmit}>
|
||||
<Form.Item
|
||||
label="章节标题"
|
||||
tooltip="章节标题由大纲统一管理,建议在大纲页面修改以保持一致性"
|
||||
>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Form.Item
|
||||
name="title"
|
||||
noStyle
|
||||
>
|
||||
<Input size="large" disabled style={{ flex: 1 }} />
|
||||
</Form.Item>
|
||||
{editingId && (() => {
|
||||
const currentChapter = chapters.find(c => c.id === editingId);
|
||||
const canGenerate = currentChapter ? canGenerateChapter(currentChapter) : false;
|
||||
const disabledReason = currentChapter ? getGenerateDisabledReason(currentChapter) : '';
|
||||
|
||||
return (
|
||||
<Tooltip title={!canGenerate ? disabledReason : '根据大纲和前置章节内容创作'}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={canGenerate ? <ThunderboltOutlined /> : <LockOutlined />}
|
||||
onClick={() => currentChapter && showGenerateModal(currentChapter)}
|
||||
loading={isContinuing}
|
||||
disabled={!canGenerate}
|
||||
danger={!canGenerate}
|
||||
size="large"
|
||||
style={{ fontWeight: 'bold' }}
|
||||
>
|
||||
{isMobile ? 'AI创作' : 'AI创作章节内容'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
})()}
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="章节内容" name="content">
|
||||
<TextArea
|
||||
ref={contentTextAreaRef}
|
||||
rows={isMobile ? 12 : 20}
|
||||
placeholder="开始写作..."
|
||||
style={{ fontFamily: 'monospace', fontSize: isMobile ? 12 : 14 }}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space style={{ width: '100%', justifyContent: 'flex-end', flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'stretch' : 'center' }}>
|
||||
<Space style={{ width: isMobile ? '100%' : 'auto' }}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (isGenerating) {
|
||||
message.warning('AI正在创作中,请等待完成后再关闭');
|
||||
return;
|
||||
}
|
||||
setIsEditorOpen(false);
|
||||
}}
|
||||
block={isMobile}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
block={isMobile}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
保存章节
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button, Modal, Form, Input, Select, message, Row, Col, Empty, Tabs, Divider, Typography, Space } from 'antd';
|
||||
import { ThunderboltOutlined, UserOutlined, TeamOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { useCharacterSync } from '../store/hooks';
|
||||
import { characterGridConfig } from '../components/CardStyles';
|
||||
import { CharacterCard } from '../components/CharacterCard';
|
||||
import type { Character, CharacterUpdate } from '../types';
|
||||
import { characterApi } from '../services/api';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
export default function Characters() {
|
||||
const { currentProject, characters } = useStore();
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'all' | 'character' | 'organization'>('all');
|
||||
const [generateForm] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [editingCharacter, setEditingCharacter] = useState<Character | null>(null);
|
||||
|
||||
const {
|
||||
refreshCharacters,
|
||||
deleteCharacter,
|
||||
generateCharacter
|
||||
} = useCharacterSync();
|
||||
|
||||
useEffect(() => {
|
||||
if (currentProject?.id) {
|
||||
refreshCharacters();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentProject?.id]);
|
||||
|
||||
if (!currentProject) return null;
|
||||
|
||||
const handleDeleteCharacter = async (id: string) => {
|
||||
try {
|
||||
await deleteCharacter(id);
|
||||
message.success('删除成功');
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = async (values: { name?: string; role_type: string; background?: string }) => {
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
await generateCharacter({
|
||||
project_id: currentProject.id,
|
||||
name: values.name,
|
||||
role_type: values.role_type,
|
||||
background: values.background,
|
||||
});
|
||||
message.success('AI生成角色成功');
|
||||
Modal.destroyAll();
|
||||
} catch {
|
||||
message.error('AI生成失败');
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditCharacter = (character: Character) => {
|
||||
setEditingCharacter(character);
|
||||
editForm.setFieldsValue(character);
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleUpdateCharacter = async (values: CharacterUpdate) => {
|
||||
if (!editingCharacter) return;
|
||||
|
||||
try {
|
||||
await characterApi.updateCharacter(editingCharacter.id, values);
|
||||
message.success('更新成功');
|
||||
setIsEditModalOpen(false);
|
||||
editForm.resetFields();
|
||||
setEditingCharacter(null);
|
||||
await refreshCharacters();
|
||||
} catch {
|
||||
message.error('更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCharacterWrapper = (id: string) => {
|
||||
handleDeleteCharacter(id);
|
||||
};
|
||||
|
||||
const showGenerateModal = () => {
|
||||
Modal.confirm({
|
||||
title: 'AI生成角色',
|
||||
width: 600,
|
||||
centered: true,
|
||||
content: (
|
||||
<Form form={generateForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
label="角色名称"
|
||||
name="name"
|
||||
>
|
||||
<Input placeholder="如:张三、李四(可选,AI会自动生成)" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="角色定位"
|
||||
name="role_type"
|
||||
rules={[{ required: true, message: '请选择角色定位' }]}
|
||||
>
|
||||
<Select placeholder="选择角色定位">
|
||||
<Select.Option value="protagonist">主角</Select.Option>
|
||||
<Select.Option value="supporting">配角</Select.Option>
|
||||
<Select.Option value="antagonist">反派</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="背景设定" name="background">
|
||||
<TextArea rows={3} placeholder="简要描述角色背景和故事环境..." />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
okText: '生成',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
const values = await generateForm.validateFields();
|
||||
await handleGenerate(values);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const characterList = characters.filter(c => !c.is_organization);
|
||||
const organizationList = characters.filter(c => c.is_organization);
|
||||
|
||||
const getDisplayList = () => {
|
||||
if (activeTab === 'character') return characterList;
|
||||
if (activeTab === 'organization') return organizationList;
|
||||
return characters;
|
||||
};
|
||||
|
||||
const displayList = getDisplayList();
|
||||
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
backgroundColor: '#fff',
|
||||
padding: isMobile ? '12px 0' : '16px 0',
|
||||
marginBottom: isMobile ? 12 : 16,
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
gap: isMobile ? 12 : 0,
|
||||
justifyContent: 'space-between',
|
||||
alignItems: isMobile ? 'stretch' : 'center'
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}>角色与组织管理</h2>
|
||||
<Button
|
||||
type="dashed"
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={showGenerateModal}
|
||||
loading={isGenerating}
|
||||
block={isMobile}
|
||||
>
|
||||
AI生成角色
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{characters.length > 0 && (
|
||||
<div style={{
|
||||
position: 'sticky',
|
||||
top: isMobile ? 60 : 72,
|
||||
zIndex: 9,
|
||||
backgroundColor: '#fff',
|
||||
paddingBottom: 8,
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
}}>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => setActiveTab(key as 'all' | 'character' | 'organization')}
|
||||
items={[
|
||||
{
|
||||
key: 'all',
|
||||
label: `全部 (${characters.length})`,
|
||||
},
|
||||
{
|
||||
key: 'character',
|
||||
label: (
|
||||
<span>
|
||||
<UserOutlined /> 角色 ({characterList.length})
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'organization',
|
||||
label: (
|
||||
<span>
|
||||
<TeamOutlined /> 组织 ({organizationList.length})
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{characters.length === 0 ? (
|
||||
<Empty description="还没有角色或组织,开始创建吧!" />
|
||||
) : (
|
||||
<>
|
||||
<Row gutter={isMobile ? [8, 8] : characterGridConfig.gutter}>
|
||||
{activeTab === 'all' && (
|
||||
<>
|
||||
{characterList.length > 0 && (
|
||||
<>
|
||||
<Col span={24}>
|
||||
<Divider orientation="left">
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
<UserOutlined style={{ marginRight: 8 }} />
|
||||
角色 ({characterList.length})
|
||||
</Title>
|
||||
</Divider>
|
||||
</Col>
|
||||
{characterList.map((character) => (
|
||||
<Col
|
||||
xs={24}
|
||||
sm={characterGridConfig.sm}
|
||||
md={characterGridConfig.md}
|
||||
lg={characterGridConfig.lg}
|
||||
xl={characterGridConfig.xl}
|
||||
key={character.id}
|
||||
style={{ padding: isMobile ? '4px' : '8px' }}
|
||||
>
|
||||
<CharacterCard
|
||||
character={character}
|
||||
onEdit={handleEditCharacter}
|
||||
onDelete={handleDeleteCharacterWrapper}
|
||||
/>
|
||||
</Col>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{organizationList.length > 0 && (
|
||||
<>
|
||||
<Col span={24}>
|
||||
<Divider orientation="left">
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
<TeamOutlined style={{ marginRight: 8 }} />
|
||||
组织 ({organizationList.length})
|
||||
</Title>
|
||||
</Divider>
|
||||
</Col>
|
||||
{organizationList.map((org) => (
|
||||
<Col
|
||||
xs={24}
|
||||
sm={characterGridConfig.sm}
|
||||
md={characterGridConfig.md}
|
||||
lg={characterGridConfig.lg}
|
||||
xl={characterGridConfig.xl}
|
||||
key={org.id}
|
||||
style={{ padding: isMobile ? '4px' : '8px' }}
|
||||
>
|
||||
<CharacterCard
|
||||
character={org}
|
||||
onEdit={handleEditCharacter}
|
||||
onDelete={handleDeleteCharacterWrapper}
|
||||
/>
|
||||
</Col>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'character' && characterList.map((character) => (
|
||||
<Col
|
||||
xs={24}
|
||||
sm={characterGridConfig.sm}
|
||||
md={characterGridConfig.md}
|
||||
lg={characterGridConfig.lg}
|
||||
xl={characterGridConfig.xl}
|
||||
key={character.id}
|
||||
style={{ padding: isMobile ? '4px' : '8px' }}
|
||||
>
|
||||
<CharacterCard
|
||||
character={character}
|
||||
onEdit={handleEditCharacter}
|
||||
onDelete={handleDeleteCharacterWrapper}
|
||||
/>
|
||||
</Col>
|
||||
))}
|
||||
|
||||
{activeTab === 'organization' && organizationList.map((org) => (
|
||||
<Col
|
||||
xs={24}
|
||||
sm={characterGridConfig.sm}
|
||||
md={characterGridConfig.md}
|
||||
lg={characterGridConfig.lg}
|
||||
xl={characterGridConfig.xl}
|
||||
key={org.id}
|
||||
style={{ padding: isMobile ? '4px' : '8px' }}
|
||||
>
|
||||
<CharacterCard
|
||||
character={org}
|
||||
onEdit={handleEditCharacter}
|
||||
onDelete={handleDeleteCharacterWrapper}
|
||||
/>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{displayList.length === 0 && (
|
||||
<Empty
|
||||
description={
|
||||
activeTab === 'character'
|
||||
? '暂无角色'
|
||||
: activeTab === 'organization'
|
||||
? '暂无组织'
|
||||
: '暂无数据'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={editingCharacter?.is_organization ? '编辑组织' : '编辑角色'}
|
||||
open={isEditModalOpen}
|
||||
onCancel={() => {
|
||||
setIsEditModalOpen(false);
|
||||
editForm.resetFields();
|
||||
setEditingCharacter(null);
|
||||
}}
|
||||
footer={null}
|
||||
centered={!isMobile}
|
||||
width={isMobile ? '100%' : 600}
|
||||
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
|
||||
styles={isMobile ? { body: { maxHeight: 'calc(100vh - 110px)', overflowY: 'auto' } } : undefined}
|
||||
>
|
||||
<Form form={editForm} layout="vertical" onFinish={handleUpdateCharacter}>
|
||||
<Row gutter={16}>
|
||||
<Col span={editingCharacter?.is_organization ? 24 : 12}>
|
||||
<Form.Item
|
||||
label={editingCharacter?.is_organization ? '组织名称' : '角色名称'}
|
||||
name="name"
|
||||
rules={[{ required: true, message: `请输入${editingCharacter?.is_organization ? '组织' : '角色'}名称` }]}
|
||||
>
|
||||
<Input placeholder={`输入${editingCharacter?.is_organization ? '组织' : '角色'}名称`} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
{!editingCharacter?.is_organization && (
|
||||
<Col span={12}>
|
||||
<Form.Item label="角色定位" name="role_type">
|
||||
<Select>
|
||||
<Select.Option value="protagonist">主角</Select.Option>
|
||||
<Select.Option value="supporting">配角</Select.Option>
|
||||
<Select.Option value="antagonist">反派</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
{!editingCharacter?.is_organization && (
|
||||
<>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="年龄" name="age">
|
||||
<Input placeholder="如:25、30岁" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="性别" name="gender">
|
||||
<Select placeholder="选择性别">
|
||||
<Select.Option value="男">男</Select.Option>
|
||||
<Select.Option value="女">女</Select.Option>
|
||||
<Select.Option value="其他">其他</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item label="性格特点" name="personality">
|
||||
<TextArea rows={2} placeholder="描述角色的性格特点..." />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="外貌描写" name="appearance">
|
||||
<TextArea rows={2} placeholder="描述角色的外貌特征..." />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="人际关系" name="relationships">
|
||||
<TextArea rows={2} placeholder="描述角色与其他角色的关系..." />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{editingCharacter?.is_organization && (
|
||||
<>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="组织类型"
|
||||
name="organization_type"
|
||||
rules={[{ required: true, message: '请输入组织类型' }]}
|
||||
>
|
||||
<Input placeholder="如:帮派、公司、门派、学院" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="主要成员" name="organization_members">
|
||||
<Input placeholder="如:张三、李四、王五" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item
|
||||
label="组织目的"
|
||||
name="organization_purpose"
|
||||
rules={[{ required: true, message: '请输入组织目的' }]}
|
||||
>
|
||||
<TextArea rows={2} placeholder="描述组织的宗旨和目标..." />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Form.Item label={editingCharacter?.is_organization ? '组织背景' : '角色背景'} name="background">
|
||||
<TextArea rows={3} placeholder={`描述${editingCharacter?.is_organization ? '组织' : '角色'}的背景故事...`} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||
<Button onClick={() => {
|
||||
setIsEditModalOpen(false);
|
||||
editForm.resetFields();
|
||||
setEditingCharacter(null);
|
||||
}}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Card, Space, Typography, message, Spin, Form, Input, Tabs } from 'antd';
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||
import { authApi } from '../services/api';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [checking, setChecking] = useState(true);
|
||||
const [localAuthEnabled, setLocalAuthEnabled] = useState(false);
|
||||
const [linuxdoEnabled, setLinuxdoEnabled] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// 检查是否已登录和获取认证配置
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
await authApi.getCurrentUser();
|
||||
// 已登录,重定向到首页
|
||||
const redirect = searchParams.get('redirect') || '/';
|
||||
navigate(redirect);
|
||||
} catch {
|
||||
// 未登录,获取认证配置
|
||||
try {
|
||||
const config = await authApi.getAuthConfig();
|
||||
setLocalAuthEnabled(config.local_auth_enabled);
|
||||
setLinuxdoEnabled(config.linuxdo_enabled);
|
||||
} catch (error) {
|
||||
console.error('获取认证配置失败:', error);
|
||||
// 默认显示LinuxDO登录
|
||||
setLinuxdoEnabled(true);
|
||||
}
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
checkAuth();
|
||||
}, [navigate, searchParams]);
|
||||
|
||||
const handleLocalLogin = async (values: { username: string; password: string }) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await authApi.localLogin(values.username, values.password);
|
||||
|
||||
if (response.success) {
|
||||
message.success('登录成功!');
|
||||
const redirect = searchParams.get('redirect') || '/';
|
||||
navigate(redirect);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('本地登录失败:', error);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLinuxDOLogin = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await authApi.getLinuxDOAuthUrl();
|
||||
|
||||
// 保存重定向地址到 sessionStorage
|
||||
const redirect = searchParams.get('redirect');
|
||||
if (redirect) {
|
||||
sessionStorage.setItem('login_redirect', redirect);
|
||||
}
|
||||
|
||||
// 跳转到 LinuxDO 授权页面
|
||||
window.location.href = response.auth_url;
|
||||
} catch (error) {
|
||||
console.error('获取授权地址失败:', error);
|
||||
message.error('获取授权地址失败,请稍后重试');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
}}>
|
||||
<Spin size="large" style={{ color: '#fff' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 渲染本地登录表单
|
||||
const renderLocalLogin = () => (
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleLocalLogin}
|
||||
size="large"
|
||||
style={{ marginTop: '24px' }}
|
||||
>
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined style={{ color: '#999' }} />}
|
||||
placeholder="用户名"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined style={{ color: '#999' }} />}
|
||||
placeholder="密码"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
block
|
||||
style={{
|
||||
height: 48,
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 16px rgba(102, 126, 234, 0.4)',
|
||||
}}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
|
||||
// 渲染LinuxDO登录
|
||||
const renderLinuxDOLogin = () => (
|
||||
<div style={{ padding: '24px 0 8px' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={
|
||||
<img
|
||||
src="/favicon.ico"
|
||||
alt="LinuxDO"
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
marginRight: 8,
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
loading={loading}
|
||||
onClick={handleLinuxDOLogin}
|
||||
block
|
||||
style={{
|
||||
height: 52,
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 16px rgba(102, 126, 234, 0.4)',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 6px 24px rgba(102, 126, 234, 0.5)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 16px rgba(102, 126, 234, 0.4)';
|
||||
}}
|
||||
>
|
||||
使用 LinuxDO 登录
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
padding: '20px',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* 装饰性背景元素 */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '-10%',
|
||||
right: '-5%',
|
||||
width: '400px',
|
||||
height: '400px',
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(60px)',
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '-10%',
|
||||
left: '-5%',
|
||||
width: '350px',
|
||||
height: '350px',
|
||||
background: 'rgba(255, 255, 255, 0.08)',
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(60px)',
|
||||
}} />
|
||||
|
||||
<Card
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 420,
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.2)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
borderRadius: '16px',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
bodyStyle={{
|
||||
padding: '40px 32px',
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%', textAlign: 'center' }}>
|
||||
{/* Logo区域 */}
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<div style={{
|
||||
width: '72px',
|
||||
height: '72px',
|
||||
margin: '0 auto 20px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
borderRadius: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 8px 24px rgba(102, 126, 234, 0.4)',
|
||||
}}>
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt="Logo"
|
||||
style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
filter: 'brightness(0) invert(1)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Title level={2} style={{
|
||||
marginBottom: 8,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
fontWeight: 700,
|
||||
}}>
|
||||
AI小说创作助手
|
||||
</Title>
|
||||
<Paragraph style={{
|
||||
color: '#666',
|
||||
fontSize: '14px',
|
||||
marginBottom: 0,
|
||||
}}>
|
||||
{localAuthEnabled && linuxdoEnabled ? '选择登录方式' :
|
||||
localAuthEnabled ? '使用账户密码登录' :
|
||||
'使用 LinuxDO 账号登录'}
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{/* 登录方式 */}
|
||||
{localAuthEnabled && linuxdoEnabled ? (
|
||||
<Tabs
|
||||
defaultActiveKey="local"
|
||||
centered
|
||||
items={[
|
||||
{
|
||||
key: 'local',
|
||||
label: '账户密码',
|
||||
children: renderLocalLogin(),
|
||||
},
|
||||
{
|
||||
key: 'linuxdo',
|
||||
label: 'LinuxDO',
|
||||
children: renderLinuxDOLogin(),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : localAuthEnabled ? (
|
||||
renderLocalLogin()
|
||||
) : (
|
||||
renderLinuxDOLogin()
|
||||
)}
|
||||
|
||||
{/* 提示信息 */}
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
background: 'rgba(102, 126, 234, 0.08)',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid rgba(102, 126, 234, 0.1)',
|
||||
}}>
|
||||
<Paragraph style={{
|
||||
fontSize: 13,
|
||||
color: '#666',
|
||||
marginBottom: 0,
|
||||
lineHeight: 1.6,
|
||||
}}>
|
||||
🎉 首次登录将自动创建账号
|
||||
<br />
|
||||
🔒 每个用户拥有独立的数据空间
|
||||
</Paragraph>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Card, Table, Tag, Button, Space, message, Modal, Form, Select, InputNumber, Input, Descriptions } from 'antd';
|
||||
import { PlusOutlined, TeamOutlined, UserOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import axios from 'axios';
|
||||
|
||||
interface Organization {
|
||||
id: string;
|
||||
character_id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
purpose: string;
|
||||
member_count: number;
|
||||
power_level: number;
|
||||
location?: string;
|
||||
motto?: string;
|
||||
}
|
||||
|
||||
interface OrganizationMember {
|
||||
id: string;
|
||||
character_id: string;
|
||||
character_name: string;
|
||||
position: string;
|
||||
rank: number;
|
||||
loyalty: number;
|
||||
contribution: number;
|
||||
status: string;
|
||||
joined_at?: string;
|
||||
}
|
||||
|
||||
interface Character {
|
||||
id: string;
|
||||
name: string;
|
||||
is_organization: boolean;
|
||||
}
|
||||
|
||||
export default function Organizations() {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const { currentProject } = useStore();
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [selectedOrg, setSelectedOrg] = useState<Organization | null>(null);
|
||||
const [members, setMembers] = useState<OrganizationMember[]>([]);
|
||||
const [characters, setCharacters] = useState<Character[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isAddMemberModalOpen, setIsAddMemberModalOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(window.innerWidth <= 768);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
const loadOrganizations = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.get(`/api/organizations/project/${projectId}`);
|
||||
setOrganizations(res.data);
|
||||
if (res.data.length > 0 && !selectedOrg) {
|
||||
setSelectedOrg(res.data[0]);
|
||||
loadMembers(res.data[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('加载组织列表失败');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, selectedOrg]);
|
||||
|
||||
const loadCharacters = useCallback(async () => {
|
||||
try {
|
||||
const res = await axios.get(`/api/characters?project_id=${projectId}`);
|
||||
setCharacters(res.data.items || []);
|
||||
} catch (error) {
|
||||
console.error('加载角色列表失败', error);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
loadOrganizations();
|
||||
loadCharacters();
|
||||
}
|
||||
}, [projectId, loadOrganizations, loadCharacters]);
|
||||
|
||||
const loadMembers = async (orgId: string) => {
|
||||
try {
|
||||
const res = await axios.get(`/api/organizations/${orgId}/members`);
|
||||
setMembers(res.data);
|
||||
} catch (error) {
|
||||
message.error('加载成员列表失败');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectOrganization = (org: Organization) => {
|
||||
setSelectedOrg(org);
|
||||
loadMembers(org.id);
|
||||
};
|
||||
|
||||
const handleAddMember = async (values: Record<string, unknown>) => {
|
||||
if (!selectedOrg) return;
|
||||
|
||||
try {
|
||||
await axios.post(`/api/organizations/${selectedOrg.id}/members`, values);
|
||||
message.success('成员添加成功');
|
||||
setIsAddMemberModalOpen(false);
|
||||
form.resetFields();
|
||||
loadMembers(selectedOrg.id);
|
||||
loadOrganizations(); // 刷新成员计数
|
||||
} catch (error) {
|
||||
message.error('添加成员失败');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (memberId: string) => {
|
||||
Modal.confirm({
|
||||
title: '确认移除',
|
||||
content: '确定要移除该成员吗?',
|
||||
centered: true,
|
||||
okText: '移除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await axios.delete(`/api/organizations/members/${memberId}`);
|
||||
message.success('成员移除成功');
|
||||
if (selectedOrg) {
|
||||
loadMembers(selectedOrg.id);
|
||||
loadOrganizations(); // 刷新成员计数
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('移除失败');
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
active: 'green',
|
||||
retired: 'default',
|
||||
expelled: 'red',
|
||||
deceased: 'black'
|
||||
};
|
||||
return colors[status] || 'default';
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const texts: Record<string, string> = {
|
||||
active: '在职',
|
||||
retired: '退休',
|
||||
expelled: '除名',
|
||||
deceased: '已故'
|
||||
};
|
||||
return texts[status] || status;
|
||||
};
|
||||
|
||||
const memberColumns = [
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'character_name',
|
||||
key: 'name',
|
||||
render: (name: string) => (
|
||||
<Space>
|
||||
<UserOutlined />
|
||||
<span>{name}</span>
|
||||
</Space>
|
||||
),
|
||||
width: isMobile ? 80 : undefined,
|
||||
},
|
||||
{
|
||||
title: '职位',
|
||||
dataIndex: 'position',
|
||||
key: 'position',
|
||||
render: (position: string, record: OrganizationMember) => (
|
||||
<Tag color="blue">{position} {!isMobile && `(级别 ${record.rank})`}</Tag>
|
||||
),
|
||||
width: isMobile ? 80 : undefined,
|
||||
},
|
||||
...(!isMobile ? [
|
||||
{
|
||||
title: '忠诚度',
|
||||
dataIndex: 'loyalty',
|
||||
key: 'loyalty',
|
||||
render: (loyalty: number) => (
|
||||
<span style={{ color: loyalty >= 70 ? 'green' : loyalty >= 40 ? 'orange' : 'red' }}>
|
||||
{loyalty}%
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '贡献度',
|
||||
dataIndex: 'contribution',
|
||||
key: 'contribution',
|
||||
render: (contribution: number) => `${contribution}%`,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => (
|
||||
<Tag color={getStatusColor(status)}>{getStatusText(status)}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '加入时间',
|
||||
dataIndex: 'joined_at',
|
||||
key: 'joined_at',
|
||||
render: (time: string) => time || '-',
|
||||
}
|
||||
] : []),
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: unknown, record: OrganizationMember) => (
|
||||
<Space>
|
||||
{!isMobile && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleRemoveMember(record.id)}
|
||||
>
|
||||
{isMobile ? '删除' : '移除'}
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
width: isMobile ? 60 : undefined,
|
||||
fixed: isMobile ? 'right' as const : undefined,
|
||||
},
|
||||
];
|
||||
|
||||
// 过滤掉已是成员的角色
|
||||
const availableCharacters = characters.filter(
|
||||
c => !c.is_organization && !members.some(m => m.character_id === c.id)
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card
|
||||
title={
|
||||
<Space wrap>
|
||||
<TeamOutlined />
|
||||
<span style={{ fontSize: isMobile ? 14 : 16 }}>组织管理</span>
|
||||
{!isMobile && <Tag color="blue">{currentProject?.title}</Tag>}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<div style={{
|
||||
display: isMobile ? 'flex' : 'grid',
|
||||
flexDirection: isMobile ? 'column' : undefined,
|
||||
gridTemplateColumns: isMobile ? undefined : '300px 1fr',
|
||||
gap: isMobile ? '16px' : '24px',
|
||||
maxHeight: isMobile ? 'calc(100vh - 200px)' : undefined,
|
||||
overflowY: isMobile ? 'auto' : undefined
|
||||
}}>
|
||||
{/* 左侧:组织列表 */}
|
||||
<div>
|
||||
<Card
|
||||
size="small"
|
||||
title={`组织列表 (${organizations.length})`}
|
||||
loading={loading}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{organizations.map(org => (
|
||||
<Card
|
||||
key={org.id}
|
||||
size="small"
|
||||
hoverable
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
border: selectedOrg?.id === org.id ? '2px solid #1890ff' : '1px solid #d9d9d9'
|
||||
}}
|
||||
onClick={() => handleSelectOrganization(org)}
|
||||
>
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<strong>{org.name}</strong>
|
||||
<Tag>{org.type}</Tag>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>
|
||||
成员: {org.member_count} | 势力: {org.power_level}
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 右侧:组织详情和成员 */}
|
||||
<div style={{ minHeight: isMobile ? 'auto' : undefined }}>
|
||||
{selectedOrg ? (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<Card title="组织详情" size="small">
|
||||
<Descriptions column={isMobile ? 1 : 2} size="small">
|
||||
<Descriptions.Item label="组织名称">{selectedOrg.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">{selectedOrg.type}</Descriptions.Item>
|
||||
<Descriptions.Item label="成员数量">{selectedOrg.member_count}</Descriptions.Item>
|
||||
<Descriptions.Item label="势力等级">{selectedOrg.power_level}</Descriptions.Item>
|
||||
{selectedOrg.location && (
|
||||
<Descriptions.Item label="所在地">{selectedOrg.location}</Descriptions.Item>
|
||||
)}
|
||||
{selectedOrg.motto && (
|
||||
<Descriptions.Item label="宗旨" span={2}>{selectedOrg.motto}</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="目标/宗旨" span={2}>
|
||||
{selectedOrg.purpose}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={`组织成员 (${members.length})`}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setIsAddMemberModalOpen(true)}
|
||||
disabled={availableCharacters.length === 0}
|
||||
>
|
||||
添加成员
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
columns={memberColumns}
|
||||
dataSource={members}
|
||||
rowKey="id"
|
||||
pagination={isMobile ? { simple: true, pageSize: 10 } : false}
|
||||
size="small"
|
||||
scroll={isMobile ? { x: 'max-content', y: 400 } : undefined}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
) : (
|
||||
<Card>
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
|
||||
请从左侧选择一个组织查看详情
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 添加成员模态框 */}
|
||||
<Modal
|
||||
title="添加组织成员"
|
||||
open={isAddMemberModalOpen}
|
||||
onCancel={() => {
|
||||
setIsAddMemberModalOpen(false);
|
||||
form.resetFields();
|
||||
}}
|
||||
footer={null}
|
||||
centered={!isMobile}
|
||||
width={isMobile ? '100%' : 500}
|
||||
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
|
||||
styles={isMobile ? { body: { maxHeight: 'calc(100vh - 110px)', overflowY: 'auto' } } : undefined}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleAddMember}
|
||||
>
|
||||
<Form.Item
|
||||
name="character_id"
|
||||
label="选择角色"
|
||||
rules={[{ required: true, message: '请选择角色' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择要加入的角色"
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
options={availableCharacters.map(c => ({
|
||||
label: c.name,
|
||||
value: c.id
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="position"
|
||||
label="职位"
|
||||
rules={[{ required: true, message: '请输入职位' }]}
|
||||
>
|
||||
<Input placeholder="如:掌门、长老、弟子" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="rank"
|
||||
label="职位等级"
|
||||
initialValue={5}
|
||||
tooltip="数字越大等级越高"
|
||||
>
|
||||
<InputNumber min={0} max={10} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="loyalty"
|
||||
label="初始忠诚度"
|
||||
initialValue={50}
|
||||
>
|
||||
<InputNumber min={0} max={100} style={{ width: '100%' }} addonAfter="%" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="status"
|
||||
label="状态"
|
||||
initialValue="active"
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="active">在职</Select.Option>
|
||||
<Select.Option value="retired">退休</Select.Option>
|
||||
<Select.Option value="expelled">除名</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||
<Button onClick={() => setIsAddMemberModalOpen(false)}>取消</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
添加
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button, List, Modal, Form, Input, message, Empty, Space, Popconfirm, Card, Select, Radio, Tag } from 'antd';
|
||||
import { EditOutlined, DeleteOutlined, ThunderboltOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { useOutlineSync } from '../store/hooks';
|
||||
import { cardStyles } from '../components/CardStyles';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
export default function Outline() {
|
||||
const { currentProject, outlines } = useStore();
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [editForm] = Form.useForm();
|
||||
const [generateForm] = Form.useForm();
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(window.innerWidth <= 768);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// 使用同步 hooks(移除createOutline)
|
||||
const {
|
||||
refreshOutlines,
|
||||
updateOutline,
|
||||
deleteOutline,
|
||||
reorderOutlines,
|
||||
generateOutlines
|
||||
} = useOutlineSync();
|
||||
|
||||
// 初始加载大纲列表
|
||||
useEffect(() => {
|
||||
if (currentProject?.id) {
|
||||
refreshOutlines();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentProject?.id]); // 只依赖 ID,不依赖函数
|
||||
|
||||
// 移除事件监听,避免无限循环
|
||||
// Hook 内部已经更新了 store,不需要再次刷新
|
||||
|
||||
if (!currentProject) return null;
|
||||
|
||||
// 确保大纲按 order_index 排序
|
||||
const sortedOutlines = [...outlines].sort((a, b) => a.order_index - b.order_index);
|
||||
|
||||
const handleOpenEditModal = (id: string) => {
|
||||
const outline = outlines.find(o => o.id === id);
|
||||
if (outline) {
|
||||
editForm.setFieldsValue(outline);
|
||||
Modal.confirm({
|
||||
title: '编辑大纲',
|
||||
width: 600,
|
||||
centered: true,
|
||||
content: (
|
||||
<Form
|
||||
form={editForm}
|
||||
layout="vertical"
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Form.Item
|
||||
label="标题"
|
||||
name="title"
|
||||
rules={[{ required: true, message: '请输入标题' }]}
|
||||
>
|
||||
<Input placeholder="输入大纲标题" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="内容"
|
||||
name="content"
|
||||
rules={[{ required: true, message: '请输入内容' }]}
|
||||
>
|
||||
<TextArea rows={6} placeholder="输入大纲内容..." />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
okText: '更新',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
const values = await editForm.validateFields();
|
||||
try {
|
||||
await updateOutline(id, values);
|
||||
message.success('大纲更新成功');
|
||||
} catch {
|
||||
message.error('更新失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteOutline = async (id: string) => {
|
||||
try {
|
||||
await deleteOutline(id);
|
||||
message.success('删除成功');
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveUp = async (index: number) => {
|
||||
if (index === 0) return;
|
||||
|
||||
const items = Array.from(sortedOutlines);
|
||||
[items[index - 1], items[index]] = [items[index], items[index - 1]];
|
||||
|
||||
const newOrders = items.map((item, idx) => ({
|
||||
id: item.id,
|
||||
order_index: idx + 1
|
||||
}));
|
||||
|
||||
try {
|
||||
await reorderOutlines(newOrders);
|
||||
message.success('上移成功');
|
||||
} catch (error) {
|
||||
message.error('调整失败');
|
||||
console.error('重排序失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveDown = async (index: number) => {
|
||||
if (index === sortedOutlines.length - 1) return;
|
||||
|
||||
const items = Array.from(sortedOutlines);
|
||||
[items[index], items[index + 1]] = [items[index + 1], items[index]];
|
||||
|
||||
const newOrders = items.map((item, idx) => ({
|
||||
id: item.id,
|
||||
order_index: idx + 1
|
||||
}));
|
||||
|
||||
try {
|
||||
await reorderOutlines(newOrders);
|
||||
message.success('下移成功');
|
||||
} catch (error) {
|
||||
message.error('调整失败');
|
||||
console.error('重排序失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
interface GenerateFormValues {
|
||||
theme?: string;
|
||||
chapter_count?: number;
|
||||
narrative_perspective?: string;
|
||||
requirements?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
mode?: 'auto' | 'new' | 'continue';
|
||||
story_direction?: string;
|
||||
plot_stage?: 'development' | 'climax' | 'ending';
|
||||
keep_existing?: boolean;
|
||||
}
|
||||
|
||||
const handleGenerate = async (values: GenerateFormValues) => {
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
// 如果是全新生成模式,keep_existing应该为false
|
||||
const isNewMode = values.mode === 'new';
|
||||
const result = await generateOutlines({
|
||||
project_id: currentProject.id,
|
||||
genre: currentProject.genre || '通用',
|
||||
theme: values.theme || currentProject.theme || '',
|
||||
chapter_count: values.chapter_count || 5,
|
||||
narrative_perspective: values.narrative_perspective || currentProject.narrative_perspective || '第三人称',
|
||||
target_words: currentProject.target_words || 100000,
|
||||
requirements: values.requirements,
|
||||
// 续写参数
|
||||
mode: values.mode || 'auto',
|
||||
story_direction: values.story_direction,
|
||||
plot_stage: values.plot_stage || 'development',
|
||||
keep_existing: !isNewMode, // 全新生成模式下不保留旧大纲
|
||||
});
|
||||
message.success(`成功生成 ${result.length} 条大纲`);
|
||||
Modal.destroyAll();
|
||||
// 刷新大纲列表,确保显示最新数据
|
||||
await refreshOutlines();
|
||||
} catch (error) {
|
||||
console.error('AI生成失败:', error);
|
||||
message.error('AI生成失败');
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showGenerateModal = () => {
|
||||
const hasOutlines = outlines.length > 0;
|
||||
const initialMode = hasOutlines ? 'continue' : 'new';
|
||||
|
||||
Modal.confirm({
|
||||
title: hasOutlines ? (
|
||||
<Space>
|
||||
<span>AI生成/续写大纲</span>
|
||||
<Tag color="blue">当前已有 {outlines.length} 章</Tag>
|
||||
</Space>
|
||||
) : 'AI生成大纲',
|
||||
width: 700,
|
||||
centered: true,
|
||||
content: (
|
||||
<Form
|
||||
form={generateForm}
|
||||
layout="vertical"
|
||||
style={{ marginTop: 16 }}
|
||||
initialValues={{
|
||||
mode: initialMode,
|
||||
chapter_count: 5,
|
||||
narrative_perspective: currentProject.narrative_perspective || '第三人称',
|
||||
plot_stage: 'development',
|
||||
keep_existing: true,
|
||||
theme: currentProject.theme || '',
|
||||
}}
|
||||
>
|
||||
{hasOutlines && (
|
||||
<Form.Item
|
||||
label="生成模式"
|
||||
name="mode"
|
||||
tooltip="自动判断:根据是否有大纲自动选择;全新生成:删除旧大纲重新生成;续写模式:基于已有大纲继续创作"
|
||||
>
|
||||
<Radio.Group buttonStyle="solid">
|
||||
<Radio.Button value="auto">自动判断</Radio.Button>
|
||||
<Radio.Button value="new">全新生成</Radio.Button>
|
||||
<Radio.Button value="continue">续写模式</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) => prevValues.mode !== currentValues.mode}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const mode = getFieldValue('mode');
|
||||
const isContinue = mode === 'continue' || (mode === 'auto' && hasOutlines);
|
||||
|
||||
// 续写模式不显示主题输入,使用项目原有主题
|
||||
if (isContinue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 全新生成模式需要输入主题
|
||||
return (
|
||||
<Form.Item
|
||||
label="故事主题"
|
||||
name="theme"
|
||||
rules={[{ required: true, message: '请输入故事主题' }]}
|
||||
>
|
||||
<TextArea rows={3} placeholder="描述你的故事主题、核心设定和主要情节..." />
|
||||
</Form.Item>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) => prevValues.mode !== currentValues.mode}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const mode = getFieldValue('mode');
|
||||
const isContinue = mode === 'continue' || (mode === 'auto' && hasOutlines);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isContinue && (
|
||||
<>
|
||||
<Form.Item
|
||||
label="故事发展方向"
|
||||
name="story_direction"
|
||||
tooltip="告诉AI你希望故事接下来如何发展"
|
||||
>
|
||||
<TextArea
|
||||
rows={3}
|
||||
placeholder="例如:主角遇到新的挑战、引入新角色、揭示关键秘密等..."
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="情节阶段"
|
||||
name="plot_stage"
|
||||
tooltip="帮助AI理解当前故事所处的阶段"
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="development">发展阶段 - 继续展开情节</Select.Option>
|
||||
<Select.Option value="climax">高潮阶段 - 矛盾激化</Select.Option>
|
||||
<Select.Option value="ending">结局阶段 - 收束伏笔</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label={isContinue ? "续写章节数" : "章节数量"}
|
||||
name="chapter_count"
|
||||
rules={[{ required: true, message: '请输入章节数量' }]}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
placeholder={isContinue ? "建议5-10章" : "如:30"}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="叙事视角"
|
||||
name="narrative_perspective"
|
||||
rules={[{ required: true, message: '请选择叙事视角' }]}
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="第一人称">第一人称</Select.Option>
|
||||
<Select.Option value="第三人称">第三人称</Select.Option>
|
||||
<Select.Option value="全知视角">全知视角</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="其他要求" name="requirements">
|
||||
<TextArea rows={2} placeholder="其他特殊要求(可选)" />
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
okText: hasOutlines ? '开始续写' : '开始生成',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
const values = await generateForm.validateFields();
|
||||
await handleGenerate(values);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{/* 固定头部 */}
|
||||
<div style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
backgroundColor: '#fff',
|
||||
padding: isMobile ? '12px 0' : '16px 0',
|
||||
marginBottom: isMobile ? 12 : 16,
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
gap: isMobile ? 12 : 0,
|
||||
justifyContent: 'space-between',
|
||||
alignItems: isMobile ? 'stretch' : 'center'
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}>故事大纲</h2>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={showGenerateModal}
|
||||
loading={isGenerating}
|
||||
block={isMobile}
|
||||
>
|
||||
{isMobile ? 'AI生成/续写' : 'AI生成/续写大纲'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 可滚动内容区域 */}
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{outlines.length === 0 ? (
|
||||
<Empty description="还没有大纲,开始创建吧!" />
|
||||
) : (
|
||||
<Card style={cardStyles.base}>
|
||||
<List
|
||||
dataSource={sortedOutlines}
|
||||
renderItem={(item, index) => (
|
||||
<List.Item
|
||||
style={{
|
||||
padding: '16px 0',
|
||||
borderRadius: 8,
|
||||
transition: 'background 0.3s ease',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
alignItems: isMobile ? 'flex-start' : 'center'
|
||||
}}
|
||||
actions={isMobile ? undefined : [
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowUpOutlined />}
|
||||
onClick={() => handleMoveUp(index)}
|
||||
disabled={index === 0}
|
||||
title="上移"
|
||||
>
|
||||
上移
|
||||
</Button>,
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowDownOutlined />}
|
||||
onClick={() => handleMoveDown(index)}
|
||||
disabled={index === sortedOutlines.length - 1}
|
||||
title="下移"
|
||||
>
|
||||
下移
|
||||
</Button>,
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleOpenEditModal(item.id)}
|
||||
>
|
||||
编辑
|
||||
</Button>,
|
||||
<Popconfirm
|
||||
title="确定删除这条大纲吗?"
|
||||
onConfirm={() => handleDeleteOutline(item.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="text" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>,
|
||||
]}
|
||||
>
|
||||
<div style={{ width: '100%' }}>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<span style={{ fontSize: isMobile ? 14 : 16 }}>
|
||||
<span style={{ color: '#1890ff', marginRight: 8, fontWeight: 'bold' }}>
|
||||
第{item.order_index || '?'}章
|
||||
</span>
|
||||
{item.title}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
<div style={{ fontSize: isMobile ? 12 : 14 }}>
|
||||
{item.content}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 移动端:按钮显示在内容下方 */}
|
||||
{isMobile && (
|
||||
<Space style={{ marginTop: 12, width: '100%', justifyContent: 'flex-end' }} wrap>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowUpOutlined />}
|
||||
onClick={() => handleMoveUp(index)}
|
||||
disabled={index === 0}
|
||||
size="small"
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowDownOutlined />}
|
||||
onClick={() => handleMoveDown(index)}
|
||||
disabled={index === sortedOutlines.length - 1}
|
||||
size="small"
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleOpenEditModal(item.id)}
|
||||
size="small"
|
||||
/>
|
||||
<Popconfirm
|
||||
title="确定删除这条大纲吗?"
|
||||
onConfirm={() => handleDeleteOutline(item.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="text" danger icon={<DeleteOutlined />} size="small" />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, Input, Button, message, Space } from 'antd';
|
||||
import { ThunderboltOutlined } from '@ant-design/icons';
|
||||
import { polishApi } from '../services/api';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
export default function Polish() {
|
||||
const [originalText, setOriginalText] = useState('');
|
||||
const [polishedText, setPolishedText] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handlePolish = async () => {
|
||||
if (!originalText.trim()) {
|
||||
message.warning('请输入要去味的文本');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await polishApi.polishText({ text: originalText });
|
||||
setPolishedText(result.polished_text);
|
||||
message.success('AI去味完成');
|
||||
} catch {
|
||||
message.error('AI去味失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(polishedText);
|
||||
message.success('已复制到剪贴板');
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ marginBottom: 16 }}>AI去味工具</h2>
|
||||
<p style={{ color: 'rgba(0,0,0,0.45)', marginBottom: 24 }}>
|
||||
将AI生成的文本变得更自然、更像人类作家的手笔
|
||||
</p>
|
||||
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<Card title="原始文本" extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={handlePolish}
|
||||
loading={loading}
|
||||
>
|
||||
开始去味
|
||||
</Button>
|
||||
}>
|
||||
<TextArea
|
||||
rows={10}
|
||||
placeholder="粘贴或输入需要去味的文本..."
|
||||
value={originalText}
|
||||
onChange={(e) => setOriginalText(e.target.value)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{polishedText && (
|
||||
<Card title="去味后文本" extra={
|
||||
<Button onClick={handleCopy}>复制文本</Button>
|
||||
}>
|
||||
<TextArea
|
||||
rows={10}
|
||||
value={polishedText}
|
||||
readOnly
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useParams, useNavigate, Outlet, Link, useLocation } from 'react-router-dom';
|
||||
import { Layout, Menu, Spin, Button, Statistic, Row, Col, Card, Drawer } from 'antd';
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
FileTextOutlined,
|
||||
TeamOutlined,
|
||||
BookOutlined,
|
||||
// ToolOutlined,
|
||||
GlobalOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
ApartmentOutlined,
|
||||
BankOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { useCharacterSync, useOutlineSync, useChapterSync } from '../store/hooks';
|
||||
import { projectApi } from '../services/api';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
|
||||
// 判断是否为移动端
|
||||
const isMobile = () => window.innerWidth <= 768;
|
||||
|
||||
export default function ProjectDetail() {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [drawerVisible, setDrawerVisible] = useState(false);
|
||||
const [mobile, setMobile] = useState(isMobile());
|
||||
|
||||
// 监听窗口大小变化
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setMobile(isMobile());
|
||||
if (!isMobile()) {
|
||||
setDrawerVisible(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
const {
|
||||
currentProject,
|
||||
setCurrentProject,
|
||||
clearProjectData,
|
||||
loading,
|
||||
setLoading,
|
||||
outlines,
|
||||
characters,
|
||||
chapters,
|
||||
} = useStore();
|
||||
|
||||
// 使用同步 hooks
|
||||
const { refreshCharacters } = useCharacterSync();
|
||||
const { refreshOutlines } = useOutlineSync();
|
||||
const { refreshChapters } = useChapterSync();
|
||||
|
||||
useEffect(() => {
|
||||
const loadProjectData = async (id: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// 加载项目基本信息
|
||||
const project = await projectApi.getProject(id);
|
||||
setCurrentProject(project);
|
||||
|
||||
// 并行加载其他数据
|
||||
await Promise.all([
|
||||
refreshOutlines(id),
|
||||
refreshCharacters(id),
|
||||
refreshChapters(id),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('加载项目数据失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (projectId) {
|
||||
loadProjectData(projectId);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearProjectData();
|
||||
};
|
||||
}, [projectId, clearProjectData, setLoading, setCurrentProject, refreshOutlines, refreshCharacters, refreshChapters]);
|
||||
|
||||
// 移除事件监听,避免无限循环
|
||||
// Hook 内部已经更新了 store,不需要再次刷新
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
key: 'world-setting',
|
||||
icon: <GlobalOutlined />,
|
||||
label: <Link to={`/project/${projectId}/world-setting`}>世界设定</Link>,
|
||||
},
|
||||
{
|
||||
key: 'characters',
|
||||
icon: <TeamOutlined />,
|
||||
label: <Link to={`/project/${projectId}/characters`}>角色管理</Link>,
|
||||
},
|
||||
{
|
||||
key: 'relationships',
|
||||
icon: <ApartmentOutlined />,
|
||||
label: <Link to={`/project/${projectId}/relationships`}>关系管理</Link>,
|
||||
},
|
||||
{
|
||||
key: 'organizations',
|
||||
icon: <BankOutlined />,
|
||||
label: <Link to={`/project/${projectId}/organizations`}>组织管理</Link>,
|
||||
},
|
||||
{
|
||||
key: 'outline',
|
||||
icon: <FileTextOutlined />,
|
||||
label: <Link to={`/project/${projectId}/outline`}>大纲管理</Link>,
|
||||
},
|
||||
{
|
||||
key: 'chapters',
|
||||
icon: <BookOutlined />,
|
||||
label: <Link to={`/project/${projectId}/chapters`}>章节管理</Link>,
|
||||
},
|
||||
// {
|
||||
// key: 'polish',
|
||||
// icon: <ToolOutlined />,
|
||||
// label: <Link to={`/project/${projectId}/polish`}>AI去味</Link>,
|
||||
// },
|
||||
];
|
||||
|
||||
// 根据当前路径动态确定选中的菜单项
|
||||
const selectedKey = useMemo(() => {
|
||||
const path = location.pathname;
|
||||
if (path.includes('/world-setting')) return 'world-setting';
|
||||
if (path.includes('/relationships')) return 'relationships';
|
||||
if (path.includes('/organizations')) return 'organizations';
|
||||
if (path.includes('/outline')) return 'outline';
|
||||
if (path.includes('/characters')) return 'characters';
|
||||
if (path.includes('/chapters')) return 'chapters';
|
||||
// if (path.includes('/polish')) return 'polish';
|
||||
return 'world-setting'; // 默认选中世界设定
|
||||
}, [location.pathname]);
|
||||
|
||||
if (loading || !currentProject) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 渲染菜单内容
|
||||
const renderMenu = () => (
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden'
|
||||
}}>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey]}
|
||||
style={{
|
||||
borderRight: 0,
|
||||
paddingTop: '16px'
|
||||
}}
|
||||
items={menuItems}
|
||||
onClick={() => mobile && setDrawerVisible(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh', height: '100vh', overflow: 'hidden' }}>
|
||||
<Header style={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
padding: mobile ? '0 12px' : '0 24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
height: mobile ? 56 : 70
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', zIndex: 1 }}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={mobile ? <MenuUnfoldOutlined /> : (collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />)}
|
||||
onClick={() => mobile ? setDrawerVisible(true) : setCollapsed(!collapsed)}
|
||||
style={{
|
||||
fontSize: mobile ? '18px' : '20px',
|
||||
color: '#fff',
|
||||
width: mobile ? '36px' : '40px',
|
||||
height: mobile ? '36px' : '40px'
|
||||
}}
|
||||
/>
|
||||
{!mobile && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/')}
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
color: '#fff',
|
||||
height: '40px',
|
||||
padding: '0 16px'
|
||||
}}
|
||||
>
|
||||
返回主页
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h2 style={{
|
||||
margin: 0,
|
||||
color: '#fff',
|
||||
fontSize: mobile ? '16px' : '24px',
|
||||
fontWeight: 600,
|
||||
textShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
position: mobile ? 'static' : 'absolute',
|
||||
left: mobile ? 'auto' : '50%',
|
||||
transform: mobile ? 'none' : 'translateX(-50%)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
flex: mobile ? 1 : 'none',
|
||||
textAlign: mobile ? 'center' : 'left',
|
||||
paddingLeft: mobile ? '8px' : '0',
|
||||
paddingRight: mobile ? '8px' : '0'
|
||||
}}>
|
||||
{currentProject.title}
|
||||
</h2>
|
||||
|
||||
{mobile && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/')}
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: '#fff',
|
||||
height: '36px',
|
||||
padding: '0 8px',
|
||||
zIndex: 1
|
||||
}}
|
||||
>
|
||||
主页
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!mobile && (
|
||||
<Row gutter={12} style={{ width: '450px', justifyContent: 'flex-end', zIndex: 1 }}>
|
||||
<Col>
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.95)',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
minWidth: '80px',
|
||||
textAlign: 'center',
|
||||
padding: '4px 8px'
|
||||
}}
|
||||
styles={{ body: { padding: '8px' } }}
|
||||
>
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: '11px', color: '#666' }}>大纲</span>}
|
||||
value={outlines.length}
|
||||
suffix="条"
|
||||
valueStyle={{ fontSize: '16px', fontWeight: 600, color: '#667eea' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col>
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.95)',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
minWidth: '80px',
|
||||
textAlign: 'center',
|
||||
padding: '4px 8px'
|
||||
}}
|
||||
styles={{ body: { padding: '8px' } }}
|
||||
>
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: '11px', color: '#666' }}>角色</span>}
|
||||
value={characters.length}
|
||||
suffix="个"
|
||||
valueStyle={{ fontSize: '16px', fontWeight: 600, color: '#52c41a' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col>
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.95)',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
minWidth: '80px',
|
||||
textAlign: 'center',
|
||||
padding: '4px 8px'
|
||||
}}
|
||||
styles={{ body: { padding: '8px' } }}
|
||||
>
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: '11px', color: '#666' }}>章节</span>}
|
||||
value={chapters.length}
|
||||
suffix="章"
|
||||
valueStyle={{ fontSize: '16px', fontWeight: 600, color: '#1890ff' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col>
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.95)',
|
||||
borderRadius: '6px',
|
||||
border: 'none',
|
||||
minWidth: '80px',
|
||||
textAlign: 'center',
|
||||
padding: '4px 8px'
|
||||
}}
|
||||
styles={{ body: { padding: '8px' } }}
|
||||
>
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: '11px', color: '#666' }}>已写</span>}
|
||||
value={currentProject.current_words}
|
||||
suffix="字"
|
||||
valueStyle={{ fontSize: '16px', fontWeight: 600, color: '#fa8c16' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</Header>
|
||||
|
||||
<Layout style={{ marginTop: mobile ? 56 : 70 }}>
|
||||
{mobile ? (
|
||||
<Drawer
|
||||
title="导航菜单"
|
||||
placement="left"
|
||||
onClose={() => setDrawerVisible(false)}
|
||||
open={drawerVisible}
|
||||
width={280}
|
||||
styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column' } }}
|
||||
>
|
||||
{renderMenu()}
|
||||
</Drawer>
|
||||
) : (
|
||||
<Sider
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
onCollapse={setCollapsed}
|
||||
trigger={null}
|
||||
width={220}
|
||||
collapsedWidth={60}
|
||||
style={{
|
||||
background: '#fff',
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: 70,
|
||||
bottom: 0,
|
||||
overflow: 'hidden',
|
||||
boxShadow: '2px 0 12px rgba(0,0,0,0.08)',
|
||||
transition: 'all 0.2s',
|
||||
height: 'calc(100vh - 70px)'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
{renderMenu()}
|
||||
</div>
|
||||
</Sider>
|
||||
)}
|
||||
|
||||
<Layout style={{
|
||||
marginLeft: mobile ? 0 : (collapsed ? 60 : 220),
|
||||
transition: 'all 0.2s'
|
||||
}}>
|
||||
<Content
|
||||
style={{
|
||||
background: '#f5f7fa',
|
||||
padding: mobile ? 12 : 24,
|
||||
height: mobile ? 'calc(100vh - 56px)' : 'calc(100vh - 70px)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
background: '#fff',
|
||||
padding: mobile ? 12 : 24,
|
||||
borderRadius: mobile ? '8px' : '12px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, Button, Empty, Modal, message, Spin, Row, Col, Statistic, Space, Tag, Progress, Typography, Tooltip, Badge } from 'antd';
|
||||
import { EditOutlined, DeleteOutlined, BookOutlined, RocketOutlined, CalendarOutlined, FileTextOutlined, TrophyOutlined, FireOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { useProjectSync } from '../store/hooks';
|
||||
import type { ReactNode } from 'react';
|
||||
import { cardStyles, cardHoverHandlers, gridConfig } from '../components/CardStyles';
|
||||
import UserMenu from '../components/UserMenu';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
export default function ProjectList() {
|
||||
const navigate = useNavigate();
|
||||
const { projects, loading } = useStore();
|
||||
|
||||
const { refreshProjects, deleteProject } = useProjectSync();
|
||||
|
||||
useEffect(() => {
|
||||
refreshProjects();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (!document.hidden) {
|
||||
refreshProjects();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '删除项目将同时删除所有相关数据,此操作不可恢复。确定要删除吗?',
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
okType: 'danger',
|
||||
centered: true,
|
||||
...(isMobile && {
|
||||
style: { top: 'auto' }
|
||||
}),
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteProject(id);
|
||||
message.success('项目删除成功');
|
||||
} catch {
|
||||
message.error('删除项目失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnterProject = (id: string) => {
|
||||
const project = projects.find(p => p.id === id);
|
||||
if (project) {
|
||||
console.log('项目信息:', {
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
wizard_status: project.wizard_status,
|
||||
wizard_step: project.wizard_step
|
||||
});
|
||||
|
||||
if (project.wizard_status === 'incomplete' || !project.wizard_status) {
|
||||
console.log('向导未完成,跳转到向导页面');
|
||||
navigate(`/wizard?projectId=${id}&step=${project.wizard_step || 0}`);
|
||||
} else {
|
||||
console.log('向导已完成,进入项目管理界面');
|
||||
navigate(`/project/${id}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const statusConfig: Record<string, { color: string; text: string; icon: ReactNode }> = {
|
||||
planning: { color: 'blue', text: '规划中', icon: <CalendarOutlined /> },
|
||||
writing: { color: 'green', text: '创作中', icon: <EditOutlined /> },
|
||||
revising: { color: 'orange', text: '修改中', icon: <FileTextOutlined /> },
|
||||
completed: { color: 'purple', text: '已完成', icon: <TrophyOutlined /> },
|
||||
};
|
||||
const config = statusConfig[status] || statusConfig.planning;
|
||||
return (
|
||||
<Tag color={config.color} icon={config.icon}>
|
||||
{config.text}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
const getProgress = (current: number, target: number) => {
|
||||
if (!target) return 0;
|
||||
return Math.min(Math.round((current / target) * 100), 100);
|
||||
};
|
||||
|
||||
const getProgressColor = (progress: number) => {
|
||||
if (progress >= 80) return '#52c41a';
|
||||
if (progress >= 50) return '#1890ff';
|
||||
if (progress >= 20) return '#faad14';
|
||||
return '#ff4d4f';
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) return '今天';
|
||||
if (days === 1) return '昨天';
|
||||
if (days < 7) return `${days}天前`;
|
||||
if (days < 30) return `${Math.floor(days / 7)}周前`;
|
||||
return date.toLocaleDateString('zh-CN');
|
||||
};
|
||||
|
||||
const totalWords = projects.reduce((sum, p) => sum + (p.current_words || 0), 0);
|
||||
const activeProjects = projects.filter(p => p.status === 'writing').length;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
padding: window.innerWidth <= 768 ? '20px 16px' : '40px 24px'
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: 1400,
|
||||
margin: '0 auto',
|
||||
marginBottom: window.innerWidth <= 768 ? 20 : 40
|
||||
}}>
|
||||
<Card
|
||||
variant="borderless"
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
borderRadius: window.innerWidth <= 768 ? 12 : 16,
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
<Row align="middle" justify="space-between" gutter={[16, 16]}>
|
||||
<Col xs={24} sm={12} md={10}>
|
||||
<Space direction="vertical" size={4}>
|
||||
<Title level={window.innerWidth <= 768 ? 3 : 2} style={{ margin: 0 }}>
|
||||
<FireOutlined style={{ color: '#ff4d4f', marginRight: 8 }} />
|
||||
我的创作空间
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: window.innerWidth <= 768 ? 12 : 14 }}>
|
||||
开启你的小说创作之旅
|
||||
</Text>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={14} style={{ display: 'flex', justifyContent: window.innerWidth <= 768 ? 'space-between' : 'flex-end', alignItems: 'center', gap: 16 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size={window.innerWidth <= 768 ? 'middle' : 'large'}
|
||||
icon={<RocketOutlined />}
|
||||
onClick={() => navigate('/wizard')}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
border: 'none'
|
||||
}}
|
||||
>
|
||||
向导创建
|
||||
</Button>
|
||||
<UserMenu />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{projects.length > 0 && (
|
||||
<Row gutter={[16, 16]} style={{ marginTop: window.innerWidth <= 768 ? 16 : 24 }}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card variant="borderless" style={{ background: '#f0f5ff', borderRadius: 12 }}>
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: window.innerWidth <= 768 ? 12 : 14, color: '#595959' }}>总项目数</span>}
|
||||
value={projects.length}
|
||||
prefix={<BookOutlined style={{ color: '#1890ff' }} />}
|
||||
suffix="个"
|
||||
valueStyle={{ color: '#1890ff', fontSize: window.innerWidth <= 768 ? 20 : 28, fontWeight: 'bold' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card variant="borderless" style={{ background: '#f6ffed', borderRadius: 12 }}>
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: window.innerWidth <= 768 ? 12 : 14, color: '#595959' }}>创作中</span>}
|
||||
value={activeProjects}
|
||||
prefix={<EditOutlined style={{ color: '#52c41a' }} />}
|
||||
suffix="个"
|
||||
valueStyle={{ color: '#52c41a', fontSize: window.innerWidth <= 768 ? 20 : 28, fontWeight: 'bold' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card variant="borderless" style={{ background: '#fff7e6', borderRadius: 12 }}>
|
||||
<Statistic
|
||||
title={<span style={{ fontSize: window.innerWidth <= 768 ? 12 : 14, color: '#595959' }}>总字数</span>}
|
||||
value={totalWords}
|
||||
prefix={<FileTextOutlined style={{ color: '#faad14' }} />}
|
||||
suffix="字"
|
||||
valueStyle={{ color: '#faad14', fontSize: window.innerWidth <= 768 ? 20 : 28, fontWeight: 'bold' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
|
||||
<Spin spinning={loading}>
|
||||
{!Array.isArray(projects) || projects.length === 0 ? (
|
||||
<Card
|
||||
variant="borderless"
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
borderRadius: 16,
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
<Empty
|
||||
description={
|
||||
<Space direction="vertical" size={16}>
|
||||
<Text style={{ fontSize: 16, color: '#8c8c8c' }}>
|
||||
还没有项目,开始创建你的第一个小说项目吧!
|
||||
</Text>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<RocketOutlined />}
|
||||
onClick={() => navigate('/wizard')}
|
||||
>
|
||||
向导创建
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
style={{ padding: '80px 0' }}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
{projects.map((project) => {
|
||||
const progress = getProgress(project.current_words, project.target_words || 0);
|
||||
const isWizardComplete = project.wizard_status === 'completed';
|
||||
|
||||
return (
|
||||
<Col {...gridConfig} key={project.id}>
|
||||
<Badge.Ribbon
|
||||
text={isWizardComplete ? getStatusTag(project.status) : <Tag color="orange" icon={<RocketOutlined />}>创建中</Tag>}
|
||||
color="transparent"
|
||||
style={{ top: 12, right: 12 }}
|
||||
>
|
||||
<Card
|
||||
hoverable
|
||||
variant="borderless"
|
||||
onClick={() => handleEnterProject(project.id)}
|
||||
style={cardStyles.project}
|
||||
styles={{ body: { padding: 0, overflow: 'hidden' } }}
|
||||
{...cardHoverHandlers}
|
||||
>
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
padding: window.innerWidth <= 768 ? '16px' : '24px',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: window.innerWidth <= 768 ? 8 : 12 }}>
|
||||
<BookOutlined style={{ fontSize: window.innerWidth <= 768 ? 20 : 28, color: '#fff' }} />
|
||||
<Title level={window.innerWidth <= 768 ? 5 : 4} style={{ margin: 0, color: '#fff', flex: 1 }} ellipsis>
|
||||
{project.title}
|
||||
</Title>
|
||||
</div>
|
||||
{project.genre && (
|
||||
<Tag color="rgba(255,255,255,0.3)" style={{ color: '#fff', border: 'none' }}>
|
||||
{project.genre}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: window.innerWidth <= 768 ? '16px' : '20px' }}>
|
||||
<Paragraph
|
||||
ellipsis={{ rows: 2 }}
|
||||
style={{
|
||||
color: 'rgba(0,0,0,0.65)',
|
||||
minHeight: 44,
|
||||
marginBottom: 16
|
||||
}}
|
||||
>
|
||||
{project.description || '暂无描述'}
|
||||
</Paragraph>
|
||||
|
||||
{isWizardComplete ? (
|
||||
<>
|
||||
{project.target_words && project.target_words > 0 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>完成进度</Text>
|
||||
<Text strong style={{ fontSize: 12 }}>{progress}%</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={progress}
|
||||
strokeColor={getProgressColor(progress)}
|
||||
showInfo={false}
|
||||
size={{ height: 8 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '12px 0',
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 8
|
||||
}}>
|
||||
<div style={{ fontSize: 20, fontWeight: 'bold', color: '#1890ff' }}>
|
||||
{(project.current_words / 1000).toFixed(1)}K
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>已写字数</Text>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '12px 0',
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 8
|
||||
}}>
|
||||
<div style={{ fontSize: 20, fontWeight: 'bold', color: '#52c41a' }}>
|
||||
{project.target_words ? (project.target_words / 1000).toFixed(0) + 'K' : '--'}
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>目标字数</Text>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
) : (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '24px 0',
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 8
|
||||
}}>
|
||||
<RocketOutlined style={{ fontSize: 32, color: '#faad14', marginBottom: 12 }} />
|
||||
<div style={{ color: '#faad14', fontWeight: 'bold', marginBottom: 4 }}>
|
||||
项目创建中
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
点击继续创建向导
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
marginTop: 16,
|
||||
paddingTop: 16,
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
<CalendarOutlined style={{ marginRight: 4 }} />
|
||||
{formatDate(project.updated_at)}
|
||||
</Text>
|
||||
<Space size={8}>
|
||||
<Tooltip title="删除">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(project.id);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Badge.Ribbon>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,455 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Card, Table, Tag, Button, Space, message, Modal, Form, Select, Slider, Input, Tabs } from 'antd';
|
||||
import { PlusOutlined, TeamOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import axios from 'axios';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface Relationship {
|
||||
id: string;
|
||||
character_from_id: string;
|
||||
character_to_id: string;
|
||||
relationship_name: string;
|
||||
intimacy_level: number;
|
||||
status: string;
|
||||
description?: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface RelationshipType {
|
||||
id: number;
|
||||
name: string;
|
||||
category: string;
|
||||
reverse_name?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
interface Character {
|
||||
id: string;
|
||||
name: string;
|
||||
is_organization: boolean;
|
||||
}
|
||||
|
||||
export default function Relationships() {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const { currentProject } = useStore();
|
||||
const [relationships, setRelationships] = useState<Relationship[]>([]);
|
||||
const [relationshipTypes, setRelationshipTypes] = useState<RelationshipType[]>([]);
|
||||
const [characters, setCharacters] = useState<Character[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(window.innerWidth <= 768);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
loadData();
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [relsRes, typesRes, charsRes] = await Promise.all([
|
||||
axios.get(`/api/relationships/project/${projectId}`),
|
||||
axios.get('/api/relationships/types'),
|
||||
axios.get(`/api/characters?project_id=${projectId}`)
|
||||
]);
|
||||
|
||||
setRelationships(relsRes.data);
|
||||
setRelationshipTypes(typesRes.data);
|
||||
setCharacters(charsRes.data.items || []);
|
||||
} catch (error) {
|
||||
message.error('加载数据失败');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateRelationship = async (values: {
|
||||
character_from_id: string;
|
||||
character_to_id: string;
|
||||
relationship_name: string;
|
||||
intimacy_level: number;
|
||||
status: string;
|
||||
description?: string;
|
||||
}) => {
|
||||
try {
|
||||
await axios.post('/api/relationships/', {
|
||||
project_id: projectId,
|
||||
...values
|
||||
});
|
||||
message.success('关系创建成功');
|
||||
setIsModalOpen(false);
|
||||
form.resetFields();
|
||||
loadData();
|
||||
} catch (error) {
|
||||
message.error('创建关系失败');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRelationship = async (id: string) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这条关系吗?',
|
||||
centered: true,
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await axios.delete(`/api/relationships/${id}`);
|
||||
message.success('关系删除成功');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getCharacterName = (id: string) => {
|
||||
const char = characters.find(c => c.id === id);
|
||||
return char?.name || '未知';
|
||||
};
|
||||
|
||||
const getIntimacyColor = (level: number) => {
|
||||
if (level >= 75) return 'green';
|
||||
if (level >= 50) return 'blue';
|
||||
if (level >= 25) return 'orange';
|
||||
return 'red';
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
active: 'green',
|
||||
broken: 'red',
|
||||
past: 'default',
|
||||
complicated: 'orange'
|
||||
};
|
||||
return colors[status] || 'default';
|
||||
};
|
||||
|
||||
const getCategoryColor = (category: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
family: 'magenta',
|
||||
social: 'blue',
|
||||
hostile: 'red',
|
||||
professional: 'cyan'
|
||||
};
|
||||
return colors[category] || 'default';
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '角色A',
|
||||
dataIndex: 'character_from_id',
|
||||
key: 'from',
|
||||
render: (id: string) => (
|
||||
<Tag icon={<UserOutlined />} color="blue">
|
||||
{getCharacterName(id)}
|
||||
</Tag>
|
||||
),
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '关系',
|
||||
dataIndex: 'relationship_name',
|
||||
key: 'relationship',
|
||||
render: (name: string) => <strong>{name}</strong>,
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '角色B',
|
||||
dataIndex: 'character_to_id',
|
||||
key: 'to',
|
||||
render: (id: string) => (
|
||||
<Tag icon={<UserOutlined />} color="purple">
|
||||
{getCharacterName(id)}
|
||||
</Tag>
|
||||
),
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '亲密度',
|
||||
dataIndex: 'intimacy_level',
|
||||
key: 'intimacy',
|
||||
render: (level: number) => (
|
||||
<Tag color={getIntimacyColor(level)}>{level}</Tag>
|
||||
),
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => (
|
||||
<Tag color={getStatusColor(status)}>{status}</Tag>
|
||||
),
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '来源',
|
||||
dataIndex: 'source',
|
||||
key: 'source',
|
||||
render: (source: string) => (
|
||||
<Tag>{source === 'ai' ? 'AI生成' : '手动创建'}</Tag>
|
||||
),
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: unknown, record: Relationship) => (
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
size="small"
|
||||
onClick={() => handleDeleteRelationship(record.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
),
|
||||
width: 80,
|
||||
fixed: isMobile ? ('right' as const) : undefined,
|
||||
},
|
||||
];
|
||||
|
||||
// 按类别分组关系类型
|
||||
const groupedTypes = relationshipTypes.reduce((acc, type) => {
|
||||
if (!acc[type.category]) {
|
||||
acc[type.category] = [];
|
||||
}
|
||||
acc[type.category].push(type);
|
||||
return acc;
|
||||
}, {} as Record<string, RelationshipType[]>);
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
family: '家族关系',
|
||||
social: '社交关系',
|
||||
professional: '职业关系',
|
||||
hostile: '敌对关系'
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card
|
||||
title={
|
||||
<Space wrap>
|
||||
<TeamOutlined />
|
||||
<span style={{ fontSize: isMobile ? 14 : 16 }}>关系管理</span>
|
||||
{!isMobile && <Tag color="blue">{currentProject?.title}</Tag>}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
>
|
||||
{isMobile ? '添加' : '添加关系'}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'list',
|
||||
label: `关系列表 (${relationships.length})`,
|
||||
children: (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={relationships}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: currentPage,
|
||||
pageSize: isMobile ? 10 : pageSize,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
position: ['bottomCenter'],
|
||||
showSizeChanger: !isMobile,
|
||||
showQuickJumper: !isMobile,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
simple: isMobile,
|
||||
onChange: (page, size) => {
|
||||
setCurrentPage(page);
|
||||
if (size !== pageSize) {
|
||||
setPageSize(size);
|
||||
setCurrentPage(1); // 切换每页条数时重置到第一页
|
||||
}
|
||||
},
|
||||
onShowSizeChange: (_, size) => {
|
||||
setPageSize(size);
|
||||
setCurrentPage(1);
|
||||
}
|
||||
}}
|
||||
scroll={{
|
||||
x: 700,
|
||||
y: isMobile ? 'calc(100vh - 360px)' : 'calc(100vh - 440px)'
|
||||
}}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'types',
|
||||
label: `关系类型 (${relationshipTypes.length})`,
|
||||
children: (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
gap: isMobile ? '12px' : '16px',
|
||||
maxHeight: isMobile ? 'calc(100vh - 400px)' : 'calc(100vh - 350px)',
|
||||
overflow: 'auto'
|
||||
}}>
|
||||
{Object.entries(groupedTypes).map(([category, types]) => (
|
||||
<Card
|
||||
key={category}
|
||||
size="small"
|
||||
title={categoryLabels[category] || category}
|
||||
headStyle={{ backgroundColor: '#f5f5f5' }}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{types.map(type => (
|
||||
<Tag key={type.id} color={getCategoryColor(category)}>
|
||||
{type.icon} {type.name}
|
||||
{type.reverse_name && ` ↔ ${type.reverse_name}`}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="添加关系"
|
||||
open={isModalOpen}
|
||||
onCancel={() => {
|
||||
setIsModalOpen(false);
|
||||
form.resetFields();
|
||||
}}
|
||||
footer={null}
|
||||
centered={!isMobile}
|
||||
width={isMobile ? '100%' : 600}
|
||||
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
|
||||
styles={isMobile ? { body: { maxHeight: 'calc(100vh - 110px)', overflowY: 'auto' } } : undefined}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleCreateRelationship}
|
||||
>
|
||||
<Form.Item
|
||||
name="character_from_id"
|
||||
label="角色A"
|
||||
rules={[{ required: true, message: '请选择角色A' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择角色"
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
options={characters
|
||||
.filter(c => !c.is_organization)
|
||||
.map(c => ({ label: c.name, value: c.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="relationship_name"
|
||||
label="关系类型"
|
||||
rules={[{ required: true, message: '请选择或输入关系类型' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择预定义类型或输入自定义"
|
||||
showSearch
|
||||
allowClear
|
||||
options={relationshipTypes.map(t => ({
|
||||
label: `${t.icon || ''} ${t.name} (${categoryLabels[t.category]})`,
|
||||
value: t.name
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="character_to_id"
|
||||
label="角色B"
|
||||
rules={[{ required: true, message: '请选择角色B' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择角色"
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
options={characters
|
||||
.filter(c => !c.is_organization)
|
||||
.map(c => ({ label: c.name, value: c.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="intimacy_level"
|
||||
label="亲密度"
|
||||
initialValue={50}
|
||||
>
|
||||
<Slider
|
||||
min={0}
|
||||
max={100}
|
||||
marks={{ 0: '0', 25: '25', 50: '50', 75: '75', 100: '100' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="status"
|
||||
label="状态"
|
||||
initialValue="active"
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="active">活跃</Select.Option>
|
||||
<Select.Option value="broken">破裂</Select.Option>
|
||||
<Select.Option value="past">过去</Select.Option>
|
||||
<Select.Option value="complicated">复杂</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="description" label="关系描述">
|
||||
<TextArea rows={3} placeholder="描述这段关系的细节..." />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||
<Button onClick={() => setIsModalOpen(false)}>取消</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
创建
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { Card, Descriptions, Empty, Typography } from 'antd';
|
||||
import { GlobalOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { cardStyles } from '../components/CardStyles';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
export default function WorldSetting() {
|
||||
const { currentProject } = useStore();
|
||||
|
||||
if (!currentProject) return null;
|
||||
|
||||
// 检查是否有世界设定信息
|
||||
const hasWorldSetting = currentProject.world_time_period ||
|
||||
currentProject.world_location ||
|
||||
currentProject.world_atmosphere ||
|
||||
currentProject.world_rules;
|
||||
|
||||
if (!hasWorldSetting) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{/* 固定头部 */}
|
||||
<div style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
backgroundColor: '#fff',
|
||||
padding: '16px 0',
|
||||
marginBottom: 16,
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<GlobalOutlined style={{ fontSize: 24, marginRight: 12, color: '#1890ff' }} />
|
||||
<h2 style={{ margin: 0 }}>世界设定</h2>
|
||||
</div>
|
||||
|
||||
{/* 可滚动内容区域 */}
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
<Empty
|
||||
description="暂无世界设定信息"
|
||||
style={{ marginTop: 60 }}
|
||||
>
|
||||
<Paragraph type="secondary">
|
||||
世界设定信息在创建项目向导中生成,用于构建小说的世界观背景。
|
||||
</Paragraph>
|
||||
</Empty>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{/* 固定头部 */}
|
||||
<div style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 10,
|
||||
backgroundColor: '#fff',
|
||||
padding: '16px 0',
|
||||
marginBottom: 24,
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<GlobalOutlined style={{ fontSize: 24, marginRight: 12, color: '#1890ff' }} />
|
||||
<h2 style={{ margin: 0 }}>世界设定</h2>
|
||||
</div>
|
||||
|
||||
{/* 可滚动内容区域 */}
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
<Card
|
||||
style={{
|
||||
...cardStyles.base,
|
||||
marginBottom: 16
|
||||
}}
|
||||
title={
|
||||
<span style={{ fontSize: 18, fontWeight: 500 }}>
|
||||
基础信息
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Descriptions bordered column={1} styles={{ label: { width: 120, fontWeight: 500 } }}>
|
||||
<Descriptions.Item label="小说名称">{currentProject.title}</Descriptions.Item>
|
||||
{currentProject.description && (
|
||||
<Descriptions.Item label="小说简介">{currentProject.description}</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="小说主题">{currentProject.theme || '未设定'}</Descriptions.Item>
|
||||
<Descriptions.Item label="小说类型">{currentProject.genre || '未设定'}</Descriptions.Item>
|
||||
<Descriptions.Item label="叙事视角">{currentProject.narrative_perspective || '未设定'}</Descriptions.Item>
|
||||
<Descriptions.Item label="目标字数">
|
||||
{currentProject.target_words ? `${currentProject.target_words.toLocaleString()} 字` : '未设定'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
style={{
|
||||
...cardStyles.base,
|
||||
marginBottom: 16
|
||||
}}
|
||||
title={
|
||||
<span style={{ fontSize: 18, fontWeight: 500 }}>
|
||||
<GlobalOutlined style={{ marginRight: 8 }} />
|
||||
小说世界观
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div style={{ padding: '16px 0' }}>
|
||||
{currentProject.world_time_period && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Title level={5} style={{ color: '#1890ff', marginBottom: 12 }}>
|
||||
时间设定
|
||||
</Title>
|
||||
<Paragraph style={{
|
||||
fontSize: 15,
|
||||
lineHeight: 1.8,
|
||||
padding: 16,
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 8,
|
||||
borderLeft: '4px solid #1890ff'
|
||||
}}>
|
||||
{currentProject.world_time_period}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentProject.world_location && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Title level={5} style={{ color: '#52c41a', marginBottom: 12 }}>
|
||||
地点设定
|
||||
</Title>
|
||||
<Paragraph style={{
|
||||
fontSize: 15,
|
||||
lineHeight: 1.8,
|
||||
padding: 16,
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 8,
|
||||
borderLeft: '4px solid #52c41a'
|
||||
}}>
|
||||
{currentProject.world_location}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentProject.world_atmosphere && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Title level={5} style={{ color: '#faad14', marginBottom: 12 }}>
|
||||
氛围设定
|
||||
</Title>
|
||||
<Paragraph style={{
|
||||
fontSize: 15,
|
||||
lineHeight: 1.8,
|
||||
padding: 16,
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 8,
|
||||
borderLeft: '4px solid #faad14'
|
||||
}}>
|
||||
{currentProject.world_atmosphere}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentProject.world_rules && (
|
||||
<div style={{ marginBottom: 0 }}>
|
||||
<Title level={5} style={{ color: '#f5222d', marginBottom: 12 }}>
|
||||
规则设定
|
||||
</Title>
|
||||
<Paragraph style={{
|
||||
fontSize: 15,
|
||||
lineHeight: 1.8,
|
||||
padding: 16,
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 8,
|
||||
borderLeft: '4px solid #f5222d'
|
||||
}}>
|
||||
{currentProject.world_rules}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user