update:更新自定义写作风格模块

This commit is contained in:
xiamuceer
2025-10-31 17:23:25 +08:00
parent b5be954112
commit e94e81c5f4
21 changed files with 1550 additions and 326 deletions
+2
View File
@@ -10,6 +10,7 @@ import Characters from './pages/Characters';
import Relationships from './pages/Relationships';
import Organizations from './pages/Organizations';
import Chapters from './pages/Chapters';
import WritingStyles from './pages/WritingStyles';
import Settings from './pages/Settings';
// import Polish from './pages/Polish';
import Login from './pages/Login';
@@ -41,6 +42,7 @@ function App() {
<Route path="relationships" element={<Relationships />} />
<Route path="organizations" element={<Organizations />} />
<Route path="chapters" element={<Chapters />} />
<Route path="writing-styles" element={<WritingStyles />} />
{/* <Route path="polish" element={<Polish />} /> */}
</Route>
</Routes>
+69 -3
View File
@@ -3,8 +3,8 @@ import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge,
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 { projectApi, writingStyleApi } from '../services/api';
import type { Chapter, ChapterUpdate, ApiError, WritingStyle } from '../types';
import { cardStyles } from '../components/CardStyles';
const { TextArea } = Input;
@@ -20,6 +20,8 @@ export default function Chapters() {
const [editorForm] = Form.useForm();
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
const contentTextAreaRef = useRef<any>(null);
const [writingStyles, setWritingStyles] = useState<WritingStyle[]>([]);
const [selectedStyleId, setSelectedStyleId] = useState<number | undefined>();
useEffect(() => {
const handleResize = () => {
@@ -39,10 +41,29 @@ export default function Chapters() {
useEffect(() => {
if (currentProject?.id) {
refreshChapters();
loadWritingStyles();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentProject?.id]);
const loadWritingStyles = async () => {
if (!currentProject?.id) return;
try {
const response = await writingStyleApi.getProjectStyles(currentProject.id);
setWritingStyles(response.styles);
// 设置默认风格为初始选中
const defaultStyle = response.styles.find(s => s.is_default);
if (defaultStyle) {
setSelectedStyleId(defaultStyle.id);
}
} catch (error) {
console.error('加载写作风格失败:', error);
message.error('加载写作风格失败');
}
};
if (!currentProject) return null;
const canGenerateChapter = (chapter: Chapter): boolean => {
@@ -146,7 +167,7 @@ export default function Chapters() {
textArea.scrollTop = textArea.scrollHeight;
}
}
});
}, selectedStyleId);
message.success('AI创作成功');
} catch (error) {
@@ -163,6 +184,8 @@ export default function Chapters() {
c => c.chapter_number < chapter.chapter_number
).sort((a, b) => a.chapter_number - b.chapter_number);
const selectedStyle = writingStyles.find(s => s.id === selectedStyleId);
const modal = Modal.confirm({
title: 'AI创作章节内容',
width: 700,
@@ -175,6 +198,9 @@ export default function Chapters() {
<li></li>
<li></li>
<li><strong></strong></li>
{selectedStyle && (
<li><strong>{selectedStyle.name}</strong></li>
)}
</ul>
{previousChapters.length > 0 && (
@@ -219,6 +245,17 @@ export default function Chapters() {
});
try {
if (!selectedStyleId) {
message.error('请先选择写作风格');
modal.update({
okButtonProps: { danger: true, loading: false },
cancelButtonProps: { disabled: false },
closable: true,
maskClosable: true,
keyboard: true,
});
return;
}
await handleGenerate();
modal.destroy();
} catch (error) {
@@ -526,6 +563,35 @@ export default function Chapters() {
</Space.Compact>
</Form.Item>
<Form.Item
label="写作风格"
tooltip="选择AI创作时使用的写作风格,可在写作风格菜单中管理"
required
>
<Select
placeholder="请选择写作风格"
value={selectedStyleId}
onChange={setSelectedStyleId}
size="large"
disabled={isGenerating}
style={{ width: '100%' }}
status={!selectedStyleId ? 'error' : undefined}
>
{writingStyles.map(style => (
<Select.Option key={style.id} value={style.id}>
{style.name}
{style.is_default && ' (默认)'}
{style.description && ` - ${style.description}`}
</Select.Option>
))}
</Select>
{!selectedStyleId && (
<div style={{ color: '#ff4d4f', fontSize: 12, marginTop: 4 }}>
</div>
)}
</Form.Item>
<Form.Item label="章节内容" name="content">
<TextArea
ref={contentTextAreaRef}
+7
View File
@@ -12,6 +12,7 @@ import {
MenuUnfoldOutlined,
ApartmentOutlined,
BankOutlined,
EditOutlined,
} from '@ant-design/icons';
import { useStore } from '../store';
import { useCharacterSync, useOutlineSync, useChapterSync } from '../store/hooks';
@@ -121,6 +122,11 @@ export default function ProjectDetail() {
icon: <BookOutlined />,
label: <Link to={`/project/${projectId}/chapters`}></Link>,
},
{
key: 'writing-styles',
icon: <EditOutlined />,
label: <Link to={`/project/${projectId}/writing-styles`}></Link>,
},
// {
// key: 'polish',
// icon: <ToolOutlined />,
@@ -137,6 +143,7 @@ export default function ProjectDetail() {
if (path.includes('/outline')) return 'outline';
if (path.includes('/characters')) return 'characters';
if (path.includes('/chapters')) return 'chapters';
if (path.includes('/writing-styles')) return 'writing-styles';
// if (path.includes('/polish')) return 'polish';
return 'world-setting'; // 默认选中世界设定
}, [location.pathname]);
+169 -92
View File
@@ -1,15 +1,18 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, Form, Input, Button, Select, Slider, InputNumber, message, Space, Typography, Spin, Modal, Tooltip, Alert } from 'antd';
import { Card, Form, Input, Button, Select, Slider, InputNumber, message, Space, Typography, Spin, Modal, Tooltip, Alert, Grid } from 'antd';
import { SettingOutlined, SaveOutlined, DeleteOutlined, ReloadOutlined, ArrowLeftOutlined, InfoCircleOutlined} from '@ant-design/icons';
import { settingsApi } from '../services/api';
import type { SettingsUpdate } from '../types';
const { Title, Paragraph } = Typography;
const { Option } = Select;
const { useBreakpoint } = Grid;
export default function SettingsPage() {
const navigate = useNavigate();
const screens = useBreakpoint();
const isMobile = !screens.md; // md断点是768px
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(true);
@@ -179,34 +182,62 @@ export default function SettingsPage() {
<div style={{
minHeight: '100vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: window.innerWidth <= 768 ? '20px 16px' : '40px 24px'
padding: isMobile ? '16px 12px' : '40px 24px'
}}>
<div style={{ maxWidth: 800, margin: '0 auto' }}>
<div style={{
maxWidth: isMobile ? '100%' : 800,
margin: '0 auto'
}}>
<Card
variant="borderless"
style={{
background: 'rgba(255, 255, 255, 0.95)',
borderRadius: window.innerWidth <= 768 ? 12 : 16,
borderRadius: isMobile ? 12 : 16,
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.1)',
}}
styles={{
body: {
padding: isMobile ? '16px' : '24px'
}
}}
>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Space direction="vertical" size={isMobile ? 'middle' : 'large'} style={{ width: '100%' }}>
{/* 标题栏 */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Space>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: '8px'
}}>
<Space size={isMobile ? 'small' : 'middle'}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/')}
type="text"
size={isMobile ? 'middle' : 'large'}
/>
<Title level={window.innerWidth <= 768 ? 3 : 2} style={{ margin: 0 }}>
<Title
level={isMobile ? 4 : 2}
style={{
margin: 0,
fontSize: isMobile ? '18px' : undefined
}}
>
<SettingOutlined style={{ marginRight: 8, color: '#667eea' }} />
AI API
{isMobile ? 'API 设置' : 'AI API 设置'}
</Title>
</Space>
</div>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
<Paragraph
type="secondary"
style={{
marginBottom: 0,
fontSize: isMobile ? '13px' : '14px',
lineHeight: isMobile ? '1.5' : '1.6'
}}
>
AI API接口参数AI功能
</Paragraph>
@@ -215,7 +246,7 @@ export default function SettingsPage() {
<Alert
message="使用 .env 文件中的默认配置"
description={
<div>
<div style={{ fontSize: isMobile ? '12px' : '14px' }}>
<p style={{ margin: '8px 0' }}>
<code>.env</code>
</p>
@@ -226,7 +257,7 @@ export default function SettingsPage() {
}
type="info"
showIcon
style={{ marginBottom: 16 }}
style={{ marginBottom: isMobile ? 12 : 16 }}
/>
)}
@@ -236,7 +267,7 @@ export default function SettingsPage() {
message="使用已保存的个人配置"
type="success"
showIcon
style={{ marginBottom: 16 }}
style={{ marginBottom: isMobile ? 12 : 16 }}
/>
)}
@@ -250,17 +281,17 @@ export default function SettingsPage() {
>
<Form.Item
label={
<Space>
<Space size={4}>
<span>API </span>
<Tooltip title="选择你的AI服务提供商">
<InfoCircleOutlined style={{ color: '#8c8c8c' }} />
<InfoCircleOutlined style={{ color: '#8c8c8c', fontSize: isMobile ? '12px' : '14px' }} />
</Tooltip>
</Space>
}
name="api_provider"
rules={[{ required: true, message: '请选择API提供商' }]}
>
<Select size="large" onChange={handleProviderChange}>
<Select size={isMobile ? 'middle' : 'large'} onChange={handleProviderChange}>
{apiProviders.map(provider => (
<Option key={provider.value} value={provider.value}>
{provider.label}
@@ -271,10 +302,10 @@ export default function SettingsPage() {
<Form.Item
label={
<Space>
<Space size={4}>
<span>API </span>
<Tooltip title="你的API密钥,将加密存储">
<InfoCircleOutlined style={{ color: '#8c8c8c' }} />
<InfoCircleOutlined style={{ color: '#8c8c8c', fontSize: isMobile ? '12px' : '14px' }} />
</Tooltip>
</Space>
}
@@ -282,7 +313,7 @@ export default function SettingsPage() {
rules={[{ required: true, message: '请输入API密钥' }]}
>
<Input.Password
size="large"
size={isMobile ? 'middle' : 'large'}
placeholder="sk-..."
autoComplete="new-password"
/>
@@ -290,10 +321,10 @@ export default function SettingsPage() {
<Form.Item
label={
<Space>
<Space size={4}>
<span>API </span>
<Tooltip title="API的基础URL地址">
<InfoCircleOutlined style={{ color: '#8c8c8c' }} />
<InfoCircleOutlined style={{ color: '#8c8c8c', fontSize: isMobile ? '12px' : '14px' }} />
</Tooltip>
</Space>
}
@@ -304,17 +335,17 @@ export default function SettingsPage() {
]}
>
<Input
size="large"
size={isMobile ? 'middle' : 'large'}
placeholder="https://api.openai.com/v1"
/>
</Form.Item>
<Form.Item
label={
<Space>
<Space size={4}>
<span></span>
<Tooltip title="AI模型的名称,如 gpt-4, gpt-3.5-turbo">
<InfoCircleOutlined style={{ color: '#8c8c8c' }} />
<InfoCircleOutlined style={{ color: '#8c8c8c', fontSize: isMobile ? '12px' : '14px' }} />
</Tooltip>
</Space>
}
@@ -322,9 +353,9 @@ export default function SettingsPage() {
rules={[{ required: true, message: '请输入或选择模型名称' }]}
>
<Select
size="large"
size={isMobile ? 'middle' : 'large'}
showSearch
placeholder="输入模型名称或点击获取"
placeholder={isMobile ? "选择模型" : "输入模型名称或点击获取"}
optionFilterProp="label"
loading={fetchingModels}
onFocus={handleModelSelectFocus}
@@ -336,17 +367,17 @@ export default function SettingsPage() {
<>
{menu}
{fetchingModels && (
<div style={{ padding: '8px 12px', color: '#8c8c8c', textAlign: 'center' }}>
<div style={{ padding: '8px 12px', color: '#8c8c8c', textAlign: 'center', fontSize: isMobile ? '12px' : '14px' }}>
<Spin size="small" /> ...
</div>
)}
{!fetchingModels && modelOptions.length === 0 && modelsFetched && (
<div style={{ padding: '8px 12px', color: '#ff4d4f', textAlign: 'center' }}>
<div style={{ padding: '8px 12px', color: '#ff4d4f', textAlign: 'center', fontSize: isMobile ? '12px' : '14px' }}>
API
</div>
)}
{!fetchingModels && modelOptions.length === 0 && !modelsFetched && (
<div style={{ padding: '8px 12px', color: '#8c8c8c', textAlign: 'center' }}>
<div style={{ padding: '8px 12px', color: '#8c8c8c', textAlign: 'center', fontSize: isMobile ? '12px' : '14px' }}>
</div>
)}
@@ -354,44 +385,46 @@ export default function SettingsPage() {
)}
notFoundContent={
fetchingModels ? (
<div style={{ padding: '8px 12px', textAlign: 'center' }}>
<div style={{ padding: '8px 12px', textAlign: 'center', fontSize: isMobile ? '12px' : '14px' }}>
<Spin size="small" /> ...
</div>
) : (
<div style={{ padding: '8px 12px', color: '#8c8c8c', textAlign: 'center' }}>
<div style={{ padding: '8px 12px', color: '#8c8c8c', textAlign: 'center', fontSize: isMobile ? '12px' : '14px' }}>
</div>
)
}
suffixIcon={
<div
onClick={(e) => {
e.stopPropagation();
if (!fetchingModels) {
setModelsFetched(false);
handleFetchModels(false);
}
}}
style={{
cursor: fetchingModels ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
padding: '0 4px',
height: '100%',
marginRight: -8
}}
title="重新获取模型列表"
>
<Button
type="text"
size="small"
icon={<ReloadOutlined />}
loading={fetchingModels}
style={{ pointerEvents: 'none' }}
!isMobile ? (
<div
onClick={(e) => {
e.stopPropagation();
if (!fetchingModels) {
setModelsFetched(false);
handleFetchModels(false);
}
}}
style={{
cursor: fetchingModels ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
padding: '0 4px',
height: '100%',
marginRight: -8
}}
title="重新获取模型列表"
>
</Button>
</div>
<Button
type="text"
size="small"
icon={<ReloadOutlined />}
loading={fetchingModels}
style={{ pointerEvents: 'none' }}
>
</Button>
</div>
) : undefined
}
options={modelOptions.map(model => ({
value: model.value,
@@ -400,9 +433,9 @@ export default function SettingsPage() {
}))}
optionRender={(option) => (
<div>
<div style={{ fontWeight: 500 }}>{option.data.label}</div>
<div style={{ fontWeight: 500, fontSize: isMobile ? '13px' : '14px' }}>{option.data.label}</div>
{option.data.description && (
<div style={{ fontSize: '12px', color: '#8c8c8c' }}>
<div style={{ fontSize: isMobile ? '11px' : '12px', color: '#8c8c8c', marginTop: '2px' }}>
{option.data.description}
</div>
)}
@@ -413,10 +446,10 @@ export default function SettingsPage() {
<Form.Item
label={
<Space>
<Space size={4}>
<span></span>
<Tooltip title="控制输出的随机性,值越高越随机(0.0-2.0)">
<InfoCircleOutlined style={{ color: '#8c8c8c' }} />
<InfoCircleOutlined style={{ color: '#8c8c8c', fontSize: isMobile ? '12px' : '14px' }} />
</Tooltip>
</Space>
}
@@ -427,20 +460,20 @@ export default function SettingsPage() {
max={2}
step={0.1}
marks={{
0: '0.0',
0.7: '0.7',
1: '1.0',
2: '2.0'
0: { style: { fontSize: isMobile ? '11px' : '12px' }, label: '0.0' },
0.7: { style: { fontSize: isMobile ? '11px' : '12px' }, label: '0.7' },
1: { style: { fontSize: isMobile ? '11px' : '12px' }, label: '1.0' },
2: { style: { fontSize: isMobile ? '11px' : '12px' }, label: '2.0' }
}}
/>
</Form.Item>
<Form.Item
label={
<Space>
<Space size={4}>
<span> Token </span>
<Tooltip title="单次请求的最大token数量">
<InfoCircleOutlined style={{ color: '#8c8c8c' }} />
<InfoCircleOutlined style={{ color: '#8c8c8c', fontSize: isMobile ? '12px' : '14px' }} />
</Tooltip>
</Space>
}
@@ -451,7 +484,7 @@ export default function SettingsPage() {
]}
>
<InputNumber
size="large"
size={isMobile ? 'middle' : 'large'}
style={{ width: '100%' }}
min={1}
max={32000}
@@ -460,42 +493,86 @@ export default function SettingsPage() {
</Form.Item>
{/* 操作按钮 */}
<Form.Item style={{ marginBottom: 0, marginTop: 32 }}>
<Space size="middle" style={{ width: '100%', justifyContent: 'space-between' }}>
<Space>
<Form.Item style={{ marginBottom: 0, marginTop: isMobile ? 24 : 32 }}>
{isMobile ? (
// 移动端:垂直堆叠布局
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Button
type="primary"
size="large"
icon={<SaveOutlined />}
htmlType="submit"
loading={loading}
block
style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none'
border: 'none',
height: '44px'
}}
>
</Button>
<Button
size="large"
icon={<ReloadOutlined />}
onClick={handleReset}
>
</Button>
<Space size="middle" style={{ width: '100%' }}>
<Button
size="large"
icon={<ReloadOutlined />}
onClick={handleReset}
style={{ flex: 1, height: '44px' }}
>
</Button>
{hasSettings && (
<Button
danger
size="large"
icon={<DeleteOutlined />}
onClick={handleDelete}
loading={loading}
style={{ flex: 1, height: '44px' }}
>
</Button>
)}
</Space>
</Space>
{hasSettings && (
<Button
danger
size="large"
icon={<DeleteOutlined />}
onClick={handleDelete}
loading={loading}
>
</Button>
)}
</Space>
) : (
// 桌面端:原有的水平布局
<Space size="middle" style={{ width: '100%', justifyContent: 'space-between' }}>
<Space>
<Button
type="primary"
size="large"
icon={<SaveOutlined />}
htmlType="submit"
loading={loading}
style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none'
}}
>
</Button>
<Button
size="large"
icon={<ReloadOutlined />}
onClick={handleReset}
>
</Button>
</Space>
{hasSettings && (
<Button
danger
size="large"
icon={<DeleteOutlined />}
onClick={handleDelete}
loading={loading}
>
</Button>
)}
</Space>
)}
</Form.Item>
</Form>
</Spin>
+436
View File
@@ -0,0 +1,436 @@
import { useState, useEffect } from 'react';
import {
Button,
Modal,
Form,
Input,
message,
Card,
Space,
Tag,
Popconfirm,
Empty,
Typography,
Row,
Col,
Tooltip
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
StarOutlined,
StarFilled
} from '@ant-design/icons';
import { useStore } from '../store';
import { writingStyleApi } from '../services/api';
import type { WritingStyle, WritingStyleCreate, WritingStyleUpdate } from '../types';
const { TextArea } = Input;
const { Text, Paragraph } = Typography;
export default function WritingStyles() {
const { currentProject } = useStore();
const [styles, setStyles] = useState<WritingStyle[]>([]);
const [loading, setLoading] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [editingStyle, setEditingStyle] = useState<WritingStyle | null>(null);
const [createForm] = Form.useForm();
const [editForm] = Form.useForm();
const isMobile = window.innerWidth <= 768;
// 卡片网格配置
const gridConfig = {
gutter: isMobile ? 8 : 16, // 卡片之间的间距
xs: 24,
sm: 24,
md: 12,
lg: 8,
xl: 6,
};
// 加载项目风格
useEffect(() => {
if (currentProject?.id) {
loadProjectStyles();
}
}, [currentProject?.id]);
const loadProjectStyles = async () => {
if (!currentProject?.id) return;
try {
setLoading(true);
const response = await writingStyleApi.getProjectStyles(currentProject.id);
// 对风格列表进行排序:默认风格优先,然后按原有顺序
const sortedStyles = (response.styles || []).sort((a, b) => {
// 默认风格排在前面
if (a.is_default && !b.is_default) return -1;
if (!a.is_default && b.is_default) return 1;
return 0;
});
setStyles(sortedStyles);
} catch {
message.error('加载风格列表失败');
} finally {
setLoading(false);
}
};
const handleCreate = async (values: { name: string; description?: string; prompt_content: string }) => {
if (!currentProject?.id) return;
try {
const createData: WritingStyleCreate = {
project_id: currentProject.id,
name: values.name,
style_type: 'custom',
description: values.description,
prompt_content: values.prompt_content,
};
await writingStyleApi.createStyle(createData);
message.success('创建成功');
setIsCreateModalOpen(false);
createForm.resetFields();
await loadProjectStyles();
} catch {
message.error('创建失败');
}
};
const handleEdit = (style: WritingStyle) => {
setEditingStyle(style);
editForm.setFieldsValue({
name: style.name,
description: style.description,
prompt_content: style.prompt_content,
});
setIsEditModalOpen(true);
};
const handleUpdate = async (values: WritingStyleUpdate) => {
if (!editingStyle) return;
try {
await writingStyleApi.updateStyle(editingStyle.id, values);
message.success('更新成功');
setIsEditModalOpen(false);
editForm.resetFields();
setEditingStyle(null);
await loadProjectStyles();
} catch {
message.error('更新失败');
}
};
const handleDelete = async (styleId: number) => {
try {
await writingStyleApi.deleteStyle(styleId);
message.success('删除成功');
await loadProjectStyles();
} catch {
message.error('删除失败');
}
};
const handleSetDefault = async (styleId: number) => {
if (!currentProject?.id) return;
try {
await writingStyleApi.setDefaultStyle(styleId, currentProject.id);
message.success('设置默认风格成功');
await loadProjectStyles();
} catch {
message.error('设置失败');
}
};
const showCreateModal = () => {
createForm.resetFields();
setIsCreateModalOpen(true);
};
if (!currentProject) return null;
const getStyleTypeColor = (styleType: string) => {
return styleType === 'preset' ? 'blue' : 'purple';
};
const getStyleTypeLabel = (styleType: string) => {
return styleType === 'preset' ? '预设' : '自定义';
};
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={<PlusOutlined />}
onClick={showCreateModal}
block={isMobile}
>
</Button>
</div>
<div style={{ flex: 1, overflowY: 'auto' }}>
{styles.length === 0 ? (
<Empty description="暂无风格数据" />
) : (
<Row
gutter={[0, gridConfig.gutter]}
style={{ marginLeft: 0, marginRight: 0 }}
>
{styles.map((style) => (
<Col
xs={gridConfig.xs}
sm={gridConfig.sm}
md={gridConfig.md}
lg={gridConfig.lg}
xl={gridConfig.xl}
key={style.id}
style={{
paddingLeft: 0,
paddingRight: gridConfig.gutter / 2,
marginBottom: gridConfig.gutter
}}
>
<Card
hoverable
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
borderRadius: 12,
border: style.is_default ? '2px solid #1890ff' : '1px solid #f0f0f0',
}}
bodyStyle={{
flex: 1,
display: 'flex',
flexDirection: 'column',
padding: '16px',
}}
actions={[
<Tooltip key="default" title={style.is_default ? '当前默认' : '设为默认'}>
<span
onClick={() => !style.is_default && handleSetDefault(style.id)}
style={{ cursor: style.is_default ? 'default' : 'pointer' }}
>
{style.is_default ? (
<StarFilled style={{ color: '#faad14', fontSize: 18 }} />
) : (
<StarOutlined style={{ fontSize: 18 }} />
)}
</span>
</Tooltip>,
<Tooltip key="edit" title={style.project_id === null ? '预设风格不可编辑' : '编辑'}>
<EditOutlined
onClick={() => style.project_id !== null && handleEdit(style)}
style={{
fontSize: 18,
cursor: style.project_id === null ? 'not-allowed' : 'pointer',
color: style.project_id === null ? '#ccc' : undefined
}}
/>
</Tooltip>,
<Popconfirm
key="delete"
title="确定删除这个风格吗?"
description={style.is_default ? '这是默认风格,删除后需要设置新的默认风格' : undefined}
onConfirm={() => handleDelete(style.id)}
okText="确定"
cancelText="取消"
disabled={style.project_id === null || styles.length === 1}
>
<Tooltip title={
style.project_id === null
? '预设风格不可删除'
: styles.length === 1
? '至少保留一个风格'
: '删除'
}>
<DeleteOutlined
style={{
fontSize: 18,
color: (style.project_id === null || styles.length === 1) ? '#ccc' : undefined,
cursor: (style.project_id === null || styles.length === 1) ? 'not-allowed' : 'pointer'
}}
/>
</Tooltip>
</Popconfirm>,
]}
>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<Space style={{ marginBottom: 12 }} wrap>
<Text strong style={{ fontSize: 16 }}>{style.name}</Text>
<Tag color={getStyleTypeColor(style.style_type)}>
{getStyleTypeLabel(style.style_type)}
</Tag>
{style.is_default && <Tag color="gold"></Tag>}
</Space>
{style.description && (
<Paragraph
type="secondary"
style={{ fontSize: 13, marginBottom: 12 }}
ellipsis={{ rows: 2, tooltip: style.description }}
>
{style.description}
</Paragraph>
)}
<Paragraph
type="secondary"
style={{
fontSize: 12,
marginBottom: 0,
backgroundColor: '#fafafa',
padding: 8,
borderRadius: 4,
flex: 1,
minHeight: 60,
}}
ellipsis={{ rows: 3, tooltip: style.prompt_content }}
>
{style.prompt_content}
</Paragraph>
</div>
</Card>
</Col>
))}
</Row>
)}
</div>
{/* 创建自定义风格 Modal */}
<Modal
title="创建自定义风格"
open={isCreateModalOpen}
onCancel={() => {
setIsCreateModalOpen(false);
createForm.resetFields();
}}
footer={null}
centered={!isMobile}
width={isMobile ? '100%' : 600}
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
>
<Form
form={createForm}
layout="vertical"
onFinish={handleCreate}
style={{ marginTop: 16 }}
>
<Form.Item
label="风格名称"
name="name"
rules={[{ required: true, message: '请输入风格名称' }]}
>
<Input placeholder="如:武侠风、科幻风" />
</Form.Item>
<Form.Item label="风格描述" name="description">
<TextArea rows={2} placeholder="简要描述这个风格的特点..." />
</Form.Item>
<Form.Item
label="提示词内容"
name="prompt_content"
rules={[{ required: true, message: '请输入提示词内容' }]}
>
<TextArea
rows={6}
placeholder="输入风格的提示词,用于引导AI生成符合该风格的内容..."
/>
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => {
setIsCreateModalOpen(false);
createForm.resetFields();
}}>
</Button>
<Button type="primary" htmlType="submit" loading={loading}>
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
{/* 编辑风格 Modal */}
<Modal
title="编辑写作风格"
open={isEditModalOpen}
onCancel={() => {
setIsEditModalOpen(false);
editForm.resetFields();
setEditingStyle(null);
}}
footer={null}
centered={!isMobile}
width={isMobile ? '100%' : 600}
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
>
<Form form={editForm} layout="vertical" onFinish={handleUpdate} style={{ marginTop: 16 }}>
<Form.Item
label="风格名称"
name="name"
rules={[{ required: true, message: '请输入风格名称' }]}
>
<Input placeholder="输入风格名称" />
</Form.Item>
<Form.Item label="风格描述" name="description">
<TextArea rows={2} placeholder="简要描述这个风格的特点..." />
</Form.Item>
<Form.Item
label="提示词内容"
name="prompt_content"
rules={[{ required: true, message: '请输入提示词内容' }]}
>
<TextArea
rows={6}
placeholder="输入风格的提示词..."
/>
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => {
setIsEditModalOpen(false);
editForm.resetFields();
setEditingStyle(null);
}}>
</Button>
<Button type="primary" htmlType="submit" loading={loading}>
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
</div>
);
}
+34 -2
View File
@@ -25,6 +25,11 @@ import type {
GenerateOutlineResponse,
Settings,
SettingsUpdate,
WritingStyle,
WritingStyleCreate,
WritingStyleUpdate,
PresetStyle,
WritingStyleListResponse,
} from '../types';
const api = axios.create({
@@ -208,9 +213,36 @@ export const chapterApi = {
checkCanGenerate: (chapterId: string) =>
api.get<unknown, import('../types').ChapterCanGenerateResponse>(`/chapters/${chapterId}/can-generate`),
};
export const writingStyleApi = {
// 获取预设风格列表
getPresetStyles: () =>
api.get<unknown, PresetStyle[]>('/writing-styles/presets/list'),
generateChapterContent: (chapterId: string) =>
api.post<unknown, { content: string }>(`/chapters/${chapterId}/generate`, {}),
// 获取项目的所有风格
getProjectStyles: (projectId: string) =>
api.get<unknown, WritingStyleListResponse>(`/writing-styles/project/${projectId}`),
// 创建新风格(基于预设或自定义)
createStyle: (data: WritingStyleCreate) =>
api.post<unknown, WritingStyle>('/writing-styles', data),
// 更新风格
updateStyle: (styleId: number, data: WritingStyleUpdate) =>
api.put<unknown, WritingStyle>(`/writing-styles/${styleId}`, data),
// 删除风格
deleteStyle: (styleId: number) =>
api.delete<unknown, { message: string }>(`/writing-styles/${styleId}`),
// 设置默认风格
setDefaultStyle: (styleId: number, projectId: string) =>
api.post<unknown, WritingStyle>(`/writing-styles/${styleId}/set-default`, { project_id: projectId }),
// 为项目初始化默认风格(如果没有任何风格)
initializeDefaultStyles: (projectId: string) =>
api.post<unknown, WritingStyleListResponse>(`/writing-styles/project/${projectId}/initialize`, {}),
};
export const polishApi = {
+3 -16
View File
@@ -298,24 +298,11 @@ export function useChapterSync() {
}
}, [removeChapter]);
// AI生成章节内容(带同步)
const generateChapterContent = useCallback(async (chapterId: string) => {
try {
const result = await chapterApi.generateChapterContent(chapterId);
// 直接调用 API 更新
const updated = await chapterApi.updateChapter(chapterId, { content: result.content });
updateChapter(chapterId, updated);
return result;
} catch (error) {
console.error('AI生成章节内容失败:', error);
throw error;
}
}, [updateChapter]);
// AI流式生成章节内容(带同步)
const generateChapterContentStream = useCallback(async (
chapterId: string,
onProgress?: (content: string) => void
onProgress?: (content: string) => void,
styleId?: number
) => {
try {
// 使用fetch处理流式响应
@@ -324,6 +311,7 @@ export function useChapterSync() {
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(styleId ? { style_id: styleId } : {}),
});
if (!response.ok) {
@@ -394,7 +382,6 @@ export function useChapterSync() {
createChapter,
updateChapter: updateChapterSync,
deleteChapter,
generateChapterContent,
generateChapterContentStream,
};
}
+44
View File
@@ -298,6 +298,50 @@ export interface ApiResponse<T> {
message?: string;
}
// 写作风格类型定义
export interface WritingStyle {
id: number;
project_id: string;
name: string;
style_type: 'preset' | 'custom';
preset_id?: string;
description?: string;
prompt_content: string;
is_default: boolean;
order_index: number;
created_at: string;
updated_at: string;
}
export interface WritingStyleCreate {
project_id: string;
name: string;
style_type: 'preset' | 'custom';
preset_id?: string;
description?: string;
prompt_content: string;
is_default?: boolean;
}
export interface WritingStyleUpdate {
name?: string;
description?: string;
prompt_content?: string;
order_index?: number;
}
export interface PresetStyle {
id: string;
name: string;
description: string;
prompt_content: string;
}
export interface WritingStyleListResponse {
styles: WritingStyle[];
total: number;
}
export interface PaginationResponse<T> {
items: T[];
total: number;