Files
ai-novel/src/pages/NovelList.tsx

414 lines
13 KiB
TypeScript
Raw Normal View History

2026-04-16 21:32:21 +08:00
import React, { useState, useEffect } from 'react';
import { Card, Button, Modal, Form, Input, message, Popconfirm, Row, Col, Tag, Select } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, BookOutlined, EyeOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { storage } from '../utils/indexedDB';
import { Novel } from '../types';
const { TextArea } = Input;
const { Option } = Select;
// 小说题材选项
const NOVEL_GENRES = [
'穿越',
'都市',
'修仙',
'武侠',
'玄幻',
'科幻',
'言情',
'历史',
'游戏',
'灵异',
'军事',
'悬疑',
'其他'
];
const NovelList: React.FC = () => {
const [novels, setNovels] = useState<Novel[]>([]);
const [isModalVisible, setIsModalVisible] = useState(false);
const [viewModalVisible, setViewModalVisible] = useState(false);
const [editingNovel, setEditingNovel] = useState<Novel | null>(null);
const [viewingNovel, setViewingNovel] = useState<Novel | null>(null);
const [form] = Form.useForm();
const navigate = useNavigate();
useEffect(() => {
loadNovels();
}, []);
const loadNovels = async () => {
try {
const loadedNovels = await storage.getNovels();
setNovels(loadedNovels);
} catch (error) {
console.error('加载小说列表失败:', error);
message.error('加载小说列表失败');
}
};
const handleAddNovel = () => {
setEditingNovel(null);
form.resetFields();
setIsModalVisible(true);
};
const handleViewNovel = (novel: Novel) => {
setViewingNovel(novel);
setViewModalVisible(true);
};
const handleEditNovel = (novel: Novel) => {
setEditingNovel(novel);
form.setFieldsValue({
title: novel.title,
genre: novel.genre === '未分类' ? undefined : novel.genre,
outline: novel.outline
});
setIsModalVisible(true);
};
const handleDeleteNovel = async (id: string) => {
try {
await storage.deleteNovel(id);
await loadNovels();
message.success('小说删除成功');
} catch (error) {
console.error('删除小说失败:', error);
message.error('删除失败,请重试');
}
};
const handleModalOk = async () => {
try {
const values = await form.validateFields();
if (editingNovel) {
await storage.updateNovel(editingNovel.id, {
...values,
genre: values.genre || '未分类'
});
message.success('小说更新成功');
} else {
const newNovel: Novel = {
id: Date.now().toString(),
title: values.title,
genre: values.genre || '未分类',
outline: values.outline || '',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
await storage.addNovel(newNovel);
message.success('小说创建成功请前往AI生成页面完善设定');
}
await loadNovels();
setIsModalVisible(false);
form.resetFields();
} catch (error) {
console.error('保存小说失败:', error);
message.error('保存失败,请重试');
}
};
const handleModalCancel = () => {
setIsModalVisible(false);
form.resetFields();
setEditingNovel(null);
};
const handleCardClick = (id: string) => {
navigate(`/novels/${id}`);
};
return (
<div>
<div style={{ marginBottom: '24px' }}>
<h2 style={{ margin: 0, fontSize: '20px', fontWeight: 600, color: '#262626' }}></h2>
<p style={{ margin: '8px 0 0 0', color: '#8c8c8c', fontSize: '14px' }}> AI </p>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<div style={{ fontSize: '14px', color: '#595959' }}>
{novels.length}
</div>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAddNovel}
size="large"
style={{ borderRadius: '6px' }}
>
</Button>
</div>
{novels.length === 0 ? (
<div style={{
textAlign: 'center',
padding: '80px 20px',
background: 'white',
borderRadius: '8px',
border: '1px dashed #d9d9d9'
}}>
<BookOutlined style={{ fontSize: '48px', color: '#d9d9d9', marginBottom: '16px' }} />
<div style={{ fontSize: '16px', color: '#595959', marginBottom: '8px' }}>
</div>
<div style={{ fontSize: '14px', color: '#8c8c8c' }}>
"新建小说"
</div>
</div>
) : (
<Row gutter={[16, 16]}>
{novels.map((novel) => (
<Col xs={24} sm={12} md={8} lg={6} key={novel.id}>
<Card
hoverable
style={{
height: '100%',
borderRadius: '8px',
border: '1px solid #f0f0f0',
transition: 'all 0.3s ease'
}}
onClick={() => handleCardClick(novel.id)}
actions={[
<EyeOutlined
key="view"
style={{ color: '#52c41a' }}
onClick={(e) => {
e.stopPropagation();
handleViewNovel(novel);
}}
/>,
<EditOutlined
key="edit"
style={{ color: '#1890ff' }}
onClick={(e) => {
e.stopPropagation();
handleEditNovel(novel);
}}
/>,
<Popconfirm
title="确认删除"
description="确定要删除这部小说吗?所有章节内容也将被删除。"
onConfirm={(e) => {
e?.stopPropagation();
handleDeleteNovel(novel.id);
}}
onCancel={(e) => e?.stopPropagation()}
okText="确定"
cancelText="取消"
>
<DeleteOutlined
key="delete"
style={{ color: '#ff4d4f' }}
onClick={(e) => e.stopPropagation()}
/>
</Popconfirm>
]}
>
<Card.Meta
avatar={
<div style={{
width: '40px',
height: '40px',
borderRadius: '8px',
background: '#e6f7ff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<BookOutlined style={{ fontSize: '20px', color: '#1890ff' }} />
</div>
}
title={
<div style={{
fontSize: '16px',
fontWeight: 500,
color: '#262626',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{novel.title}
</div>
}
description={
<div>
<div style={{ marginBottom: '8px' }}>
<Tag color="blue">{novel.genre}</Tag>
{novel.generatedSettings && (
<Tag color="green"></Tag>
)}
</div>
<div style={{
fontSize: '12px',
color: '#8c8c8c',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
lineHeight: '1.5'
}}>
{novel.outline || '暂无大纲'}
</div>
<div style={{ marginTop: '8px', fontSize: '11px', color: '#bfbfbf' }}>
{new Date(novel.createdAt).toLocaleDateString()}
</div>
</div>
}
/>
</Card>
</Col>
))}
</Row>
)}
<Modal
title={editingNovel ? '编辑小说' : '新建小说'}
open={isModalVisible}
onOk={handleModalOk}
onCancel={handleModalCancel}
width={600}
okText="确定"
cancelText="取消"
>
<Form
form={form}
layout="vertical"
>
<Form.Item
label="书名"
name="title"
rules={[{ required: true, message: '请输入书名' }]}
>
<Input placeholder="请输入书名" />
</Form.Item>
<Form.Item
label="题材类型"
name="genre"
rules={[{ required: true, message: '请选择题材类型' }]}
>
<Select
placeholder="请选择题材类型"
size="large"
showSearch
allowClear
>
{NOVEL_GENRES.map(genre => (
<Option key={genre} value={genre}>
{genre}
</Option>
))}
</Select>
</Form.Item>
<Form.Item
label="初步想法"
name="outline"
extra="可以简单描述您的初步想法也可以留空后续让AI帮您生成"
>
<TextArea
rows={4}
placeholder="简单描述您的创作想法..."
/>
</Form.Item>
</Form>
</Modal>
<Modal
title="小说详情"
open={viewModalVisible}
onCancel={() => setViewModalVisible(false)}
width={700}
footer={[
<Button key="close" onClick={() => setViewModalVisible(false)}>
</Button>
]}
>
{viewingNovel && (
<div style={{ padding: '16px 0' }}>
<div style={{ marginBottom: '24px' }}>
<div style={{ fontSize: '14px', color: '#595959', marginBottom: '8px' }}>
</div>
<div style={{
padding: '12px',
background: '#f5f5f5',
borderRadius: '6px',
fontSize: '16px',
fontWeight: 500
}}>
{viewingNovel.title}
</div>
</div>
<div style={{ marginBottom: '24px' }}>
<div style={{ fontSize: '14px', color: '#595959', marginBottom: '8px' }}>
</div>
<div style={{
padding: '12px',
background: '#f5f5f5',
borderRadius: '6px'
}}>
<Tag color="blue">{viewingNovel.genre}</Tag>
</div>
</div>
{viewingNovel.generatedSettings && (
<>
<div style={{ marginBottom: '24px' }}>
<div style={{ fontSize: '14px', color: '#595959', marginBottom: '8px' }}>
AI生成设定
</div>
<div style={{
padding: '12px',
background: '#f0f9ff',
borderRadius: '6px',
marginBottom: '12px'
}}>
<div><strong></strong>{viewingNovel.generatedSettings.targetWordCount}</div>
<div><strong></strong>{viewingNovel.generatedSettings.chapterCount}</div>
</div>
</div>
</>
)}
<div style={{ marginBottom: '24px' }}>
<div style={{ fontSize: '14px', color: '#595959', marginBottom: '8px' }}>
</div>
<div style={{
padding: '12px',
background: '#f5f5f5',
borderRadius: '6px',
lineHeight: '1.8',
maxHeight: '200px',
overflowY: 'auto'
}}>
{viewingNovel.outline || '暂无大纲'}
</div>
</div>
<div style={{ marginTop: '24px', paddingTop: '16px', borderTop: '1px solid #f0f0f0' }}>
<div style={{ fontSize: '12px', color: '#8c8c8c' }}>
{new Date(viewingNovel.createdAt).toLocaleString()}
</div>
<div style={{ fontSize: '12px', color: '#8c8c8c', marginTop: '4px' }}>
{new Date(viewingNovel.updatedAt).toLocaleString()}
</div>
</div>
</div>
)}
</Modal>
</div>
);
};
export default NovelList;