feature: 新增小说封面图片生成功能

This commit is contained in:
xiamuceer
2026-03-16 11:34:07 +08:00
parent 2ca4c9cd27
commit 411f906545
22 changed files with 1516 additions and 205 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ const promptTemplateBaseShadow = `
export const bookshelfCardStyles = {
container: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(270px, 1fr))',
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
gap: '20px 18px',
padding: '8px 0 16px',
alignItems: 'stretch',
+290 -190
View File
@@ -1,5 +1,6 @@
import { Card, Button, Spin, Space, Tag, Typography, Alert, Tooltip, theme } from 'antd';
import { BookOutlined, RocketOutlined, BulbOutlined, UploadOutlined, DownloadOutlined, LoadingOutlined, CalendarOutlined, DeleteOutlined, CheckCircleOutlined, EditOutlined, PauseCircleOutlined } from '@ant-design/icons';
import { Card, Button, Spin, Space, Tag, Typography, Alert, theme } from 'antd';
import { BookOutlined, RocketOutlined, BulbOutlined, UploadOutlined, DownloadOutlined, LoadingOutlined, CalendarOutlined, DeleteOutlined, CheckCircleOutlined, EditOutlined, PauseCircleOutlined, PictureOutlined, SwapOutlined, ReloadOutlined } from '@ant-design/icons';
import { useState } from 'react';
import type { ReactNode } from 'react';
import type { Project } from '../types';
import { bookshelfCardStyles, bookshelfCardHoverHandlers } from '../components/CardStyles';
@@ -21,6 +22,8 @@ interface BookshelfPageProps {
onOpenInspiration: () => void;
onEnterProject: (project: Project) => void;
onDeleteProject: (projectId: string) => void;
onGenerateCover: (project: Project, overwrite?: boolean) => void | Promise<void>;
onDownloadCover: (project: Project) => void;
formatWordCount: (count: number) => string;
getProgress: (current: number, target: number) => number;
getProgressColor: (progress: number) => string;
@@ -43,6 +46,8 @@ export default function BookshelfPage({
onOpenInspiration,
onEnterProject,
onDeleteProject,
onGenerateCover,
onDownloadCover,
formatWordCount,
getProgress,
getProgressColor,
@@ -53,6 +58,8 @@ export default function BookshelfPage({
const { resolvedMode } = useThemeMode();
const isDark = resolvedMode === 'dark';
const alphaColor = (color: string, alpha: number) => `color-mix(in srgb, ${color} ${(alpha * 100).toFixed(0)}%, transparent)`;
const [flippedProjectIds, setFlippedProjectIds] = useState<Record<string, boolean>>({});
const [coverGeneratingIds, setCoverGeneratingIds] = useState<Record<string, boolean>>({});
const mobileBookHeight = 460;
const desktopBookHeight = 430;
const mobileSpineWidth = 32;
@@ -105,6 +112,26 @@ export default function BookshelfPage({
return <EditOutlined style={commonStyle} />;
};
const toggleProjectFace = (projectId: string) => {
setFlippedProjectIds((prev) => ({ ...prev, [projectId]: !prev[projectId] }));
};
const handleGenerateCoverClick = async (event: React.MouseEvent<HTMLElement>, project: Project, overwrite: boolean = true) => {
event.stopPropagation();
if (coverGeneratingIds[project.id]) {
return;
}
setCoverGeneratingIds((prev) => ({ ...prev, [project.id]: true }));
try {
await onGenerateCover(project, overwrite);
} finally {
setCoverGeneratingIds((prev) => ({ ...prev, [project.id]: false }));
}
};
return (
<div>
<Card
@@ -333,6 +360,12 @@ export default function BookshelfPage({
const ribbonStatusIcon = getRibbonStatusIcon(displayStatus, isWizardIncomplete, isCompleted);
const isFlipped = !!flippedProjectIds[project.id];
const coverActionLoading = !!coverGeneratingIds[project.id];
const coverReady = project.cover_status === 'ready' && !!project.cover_image_url;
const coverGenerating = project.cover_status === 'generating' || coverActionLoading;
const coverFailed = project.cover_status === 'failed' && !coverActionLoading;
return (
<div key={project.id} style={{ position: 'relative', width: '100%', minWidth: 0, minHeight: isMobile ? mobileBookHeight : desktopBookHeight }}>
<Card
@@ -340,7 +373,7 @@ export default function BookshelfPage({
style={{ ...bookshelfCardStyles.projectCard, minHeight: isMobile ? mobileBookHeight : desktopBookHeight }}
styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column' } }}
{...bookshelfCardHoverHandlers}
onClick={() => onEnterProject(project)}
onClick={() => !isFlipped && onEnterProject(project)}
data-card-style="bookshelf-book"
data-book-kind="project"
>
@@ -384,200 +417,267 @@ export default function BookshelfPage({
minHeight: isMobile ? mobileBookHeight : desktopBookHeight,
padding: isMobile ? '18px 16px 14px 38px' : '26px 24px 18px 42px',
}}>
<div style={{ marginBottom: isMobile ? 10 : 12, paddingRight: isMobile ? 18 : 30 }}>
<div style={{
display: 'flex',
alignItems: 'flex-start',
gap: 8,
marginBottom: isMobile ? 8 : 10,
minHeight: isMobile ? 50 : 58,
}}>
<BookOutlined style={{
fontSize: isMobile ? 14 : 16,
color: alphaColor(token.colorText, 0.4),
marginTop: 2,
flexShrink: 0,
}} />
<Tooltip title={project.title}>
<div style={{
fontSize: isMobile ? 18 : 22,
fontWeight: 700,
color: token.colorText,
lineHeight: 1.3,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
wordBreak: 'break-word',
fontFamily: 'Georgia, "Times New Roman", "Noto Serif SC", serif',
}}>
{project.title}
</div>
</Tooltip>
</div>
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: 6,
minHeight: isMobile ? 20 : 22,
alignItems: 'flex-start',
}}>
{tags.length > 0 ? tags.slice(0, 3).map((tag: string, idx: number) => (
<Tag key={idx} style={{
margin: 0,
padding: isMobile ? '0 7px' : '0 8px',
borderRadius: 4,
border: `1px solid ${alphaColor(token.colorSuccess, 0.18)}`,
background: alphaColor(token.colorSuccess, 0.08),
color: token.colorSuccess,
fontSize: isMobile ? 10 : 11,
lineHeight: isMobile ? '18px' : '20px',
fontWeight: 500,
}}>
{tag}
</Tag>
)) : (
<Tag style={{
margin: 0,
padding: isMobile ? '0 7px' : '0 8px',
borderRadius: 4,
border: `1px solid ${alphaColor(token.colorSuccess, 0.18)}`,
background: alphaColor(token.colorSuccess, 0.08),
color: token.colorSuccess,
fontSize: isMobile ? 10 : 11,
lineHeight: isMobile ? '18px' : '20px',
fontWeight: 500,
}}>
</Tag>
)}
</div>
</div>
<Paragraph
ellipsis={{ rows: isMobile ? 3 : 3 }}
style={{
fontSize: isMobile ? 12 : 13,
color: token.colorTextSecondary,
marginBottom: isMobile ? 12 : 16,
lineHeight: 1.7,
flexGrow: 1,
}}
>
{project.description || '暂无描述...'}
</Paragraph>
<div style={{ marginBottom: isMobile ? 14 : 18 }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6,
fontSize: isMobile ? 11 : 12,
}}>
<span style={{ color: token.colorTextTertiary }}></span>
<span style={{ color: progressColor, fontWeight: 700 }}>{progress}%</span>
</div>
<div style={{
height: 6,
width: '100%',
borderRadius: 999,
overflow: 'hidden',
background: alphaColor(token.colorText, 0.06),
}}>
<div style={{
width: `${progress}%`,
height: '100%',
borderRadius: 999,
background: progressColor,
transition: 'width 0.3s ease',
}} />
</div>
</div>
<div style={{
marginBottom: isMobile ? 12 : 16,
padding: isMobile ? '12px 10px' : '14px 12px',
background: `linear-gradient(180deg, ${alphaColor(token.colorBgContainer, 0.94)} 0%, ${alphaColor(token.colorFillSecondary, 0.78)} 100%)`,
borderRadius: 10,
border: `1px solid ${alphaColor(token.colorText, 0.06)}`,
boxShadow: `inset 0 1px 2px ${alphaColor(token.colorText, 0.08)}`,
}}>
<div style={{ display: 'flex', alignItems: 'stretch', textAlign: 'center' }}>
<div style={{ flex: 1 }}>
<div style={{
fontSize: isMobile ? 22 : 26,
fontWeight: 700,
color: token.colorText,
lineHeight: 1.1,
fontFamily: 'Georgia, "Times New Roman", serif',
}}>
{formatWordCount(project.current_words || 0)}
</div>
<div style={{
fontSize: isMobile ? 10 : 11,
color: token.colorTextTertiary,
marginTop: 4,
}}>
</div>
</div>
<div style={{
width: 1,
margin: '0 12px',
background: alphaColor(token.colorText, 0.1),
}} />
<div style={{ flex: 1 }}>
<div style={{
fontSize: isMobile ? 22 : 26,
fontWeight: 700,
color: progress >= 100 ? token.colorSuccess : progressColor,
lineHeight: 1.1,
fontFamily: 'Georgia, "Times New Roman", serif',
}}>
{formatWordCount(project.target_words || 0)}
</div>
<div style={{
fontSize: isMobile ? 10 : 11,
color: token.colorTextTertiary,
marginTop: 4,
}}>
</div>
</div>
</div>
</div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: isMobile ? 10 : 12,
borderTop: `1px solid ${alphaColor(token.colorText, 0.06)}`,
color: token.colorTextTertiary,
marginTop: 'auto',
}}>
<Space size={4} style={{ fontSize: isMobile ? 11 : 12, color: token.colorTextTertiary }}>
<CalendarOutlined style={{ fontSize: isMobile ? 10 : 12 }} />
{formatDate(project.updated_at)}
</Space>
<div style={{ display: 'flex', justifyContent: 'flex-start', marginBottom: 8 }}>
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined style={{ fontSize: isMobile ? 12 : 14 }} />}
icon={isFlipped ? <BookOutlined /> : <SwapOutlined />}
onClick={(e) => {
e.stopPropagation();
onDeleteProject(project.id);
toggleProjectFace(project.id);
}}
style={{
padding: isMobile ? '2px 4px' : '4px 8px',
borderRadius: 8,
}}
/>
>
{isFlipped ? '返回书本' : '查看封面'}
</Button>
</div>
{isFlipped ? (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, justifyContent: 'center' }}>
{coverReady ? (
<>
<div style={{ flex: 1, minHeight: 240, borderRadius: 12, overflow: 'hidden', background: alphaColor(token.colorText, 0.06), border: `1px solid ${alphaColor(token.colorText, 0.08)}` }}>
<img src={project.cover_image_url} alt={`${project.title} cover`} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
</div>
<Space wrap>
<Button icon={<DownloadOutlined />} onClick={(e) => { e.stopPropagation(); onDownloadCover(project); }} disabled={coverActionLoading}></Button>
<Button
icon={<ReloadOutlined />}
loading={coverActionLoading}
onClick={(e) => void handleGenerateCoverClick(e, project, true)}
>
{coverActionLoading ? '重新生成中...' : '重新生成'}
</Button>
</Space>
</>
) : coverGenerating ? (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
<LoadingOutlined spin style={{ fontSize: 28, color: token.colorPrimary }} />
<div style={{ color: token.colorTextSecondary }}>...</div>
</div>
) : (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12, textAlign: 'center' }}>
<PictureOutlined style={{ fontSize: 36, color: token.colorTextTertiary }} />
{coverFailed ? (
<>
<div style={{ color: token.colorError }}></div>
<div style={{ color: token.colorTextSecondary, fontSize: 12 }}>{project.cover_error || '请稍后重试'}</div>
<Button
type="primary"
icon={<ReloadOutlined />}
loading={coverActionLoading}
onClick={(e) => void handleGenerateCoverClick(e, project, true)}
>
{coverActionLoading ? '重新生成中...' : '重新生成'}
</Button>
</>
) : (
<Button
type="primary"
icon={<PictureOutlined />}
loading={coverActionLoading}
onClick={(e) => void handleGenerateCoverClick(e, project, true)}
>
{coverActionLoading ? '生成中...' : '生成封面'}
</Button>
)}
</div>
)}
</div>
) : (
<>
<div style={{ marginBottom: isMobile ? 10 : 12, paddingRight: isMobile ? 18 : 30 }}>
<div style={{
display: 'flex',
alignItems: 'flex-start',
gap: 8,
marginBottom: isMobile ? 8 : 10,
minHeight: isMobile ? 50 : 58,
}}>
<BookOutlined style={{
fontSize: isMobile ? 14 : 16,
color: alphaColor(token.colorText, 0.4),
marginTop: 2,
flexShrink: 0,
}} />
<div style={{
fontSize: isMobile ? 18 : 22,
fontWeight: 700,
color: token.colorText,
lineHeight: 1.3,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
wordBreak: 'break-word',
fontFamily: 'Georgia, "Times New Roman", "Noto Serif SC", serif',
}}>
{project.title}
</div>
</div>
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: 6,
minHeight: isMobile ? 20 : 22,
alignItems: 'flex-start',
}}>
{tags.length > 0 ? tags.slice(0, 3).map((tag: string, idx: number) => (
<Tag key={idx} style={{
margin: 0,
padding: isMobile ? '0 7px' : '0 8px',
borderRadius: 4,
border: `1px solid ${alphaColor(token.colorSuccess, 0.18)}`,
background: alphaColor(token.colorSuccess, 0.08),
color: token.colorSuccess,
fontSize: isMobile ? 10 : 11,
lineHeight: isMobile ? '18px' : '20px',
fontWeight: 500,
}}>
{tag}
</Tag>
)) : (
<Tag style={{
margin: 0,
padding: isMobile ? '0 7px' : '0 8px',
borderRadius: 4,
border: `1px solid ${alphaColor(token.colorSuccess, 0.18)}`,
background: alphaColor(token.colorSuccess, 0.08),
color: token.colorSuccess,
fontSize: isMobile ? 10 : 11,
lineHeight: isMobile ? '18px' : '20px',
fontWeight: 500,
}}>
</Tag>
)}
</div>
</div>
<Paragraph
ellipsis={{ rows: isMobile ? 3 : 3 }}
style={{
fontSize: isMobile ? 12 : 13,
color: token.colorTextSecondary,
marginBottom: isMobile ? 12 : 16,
lineHeight: 1.7,
flexGrow: 1,
}}
>
{project.description || '暂无描述...'}
</Paragraph>
<div style={{ marginBottom: isMobile ? 14 : 18 }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6,
fontSize: isMobile ? 11 : 12,
}}>
<span style={{ color: token.colorTextTertiary }}></span>
<span style={{ color: progressColor, fontWeight: 700 }}>{progress}%</span>
</div>
<div style={{
height: 6,
width: '100%',
borderRadius: 999,
overflow: 'hidden',
background: alphaColor(token.colorText, 0.06),
}}>
<div style={{
width: `${progress}%`,
height: '100%',
borderRadius: 999,
background: progressColor,
transition: 'width 0.3s ease',
}} />
</div>
</div>
<div style={{
marginBottom: isMobile ? 12 : 16,
padding: isMobile ? '12px 10px' : '14px 12px',
background: `linear-gradient(180deg, ${alphaColor(token.colorBgContainer, 0.94)} 0%, ${alphaColor(token.colorFillSecondary, 0.78)} 100%)`,
borderRadius: 10,
border: `1px solid ${alphaColor(token.colorText, 0.06)}`,
boxShadow: `inset 0 1px 2px ${alphaColor(token.colorText, 0.08)}`,
}}>
<div style={{ display: 'flex', alignItems: 'stretch', textAlign: 'center' }}>
<div style={{ flex: 1 }}>
<div style={{
fontSize: isMobile ? 22 : 26,
fontWeight: 700,
color: token.colorText,
lineHeight: 1.1,
fontFamily: 'Georgia, "Times New Roman", serif',
}}>
{formatWordCount(project.current_words || 0)}
</div>
<div style={{
fontSize: isMobile ? 10 : 11,
color: token.colorTextTertiary,
marginTop: 4,
}}>
</div>
</div>
<div style={{
width: 1,
margin: '0 12px',
background: alphaColor(token.colorText, 0.1),
}} />
<div style={{ flex: 1 }}>
<div style={{
fontSize: isMobile ? 22 : 26,
fontWeight: 700,
color: progress >= 100 ? token.colorSuccess : progressColor,
lineHeight: 1.1,
fontFamily: 'Georgia, "Times New Roman", serif',
}}>
{formatWordCount(project.target_words || 0)}
</div>
<div style={{
fontSize: isMobile ? 10 : 11,
color: token.colorTextTertiary,
marginTop: 4,
}}>
</div>
</div>
</div>
</div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: isMobile ? 10 : 12,
borderTop: `1px solid ${alphaColor(token.colorText, 0.06)}`,
color: token.colorTextTertiary,
marginTop: 'auto',
}}>
<Space size={4} style={{ fontSize: isMobile ? 11 : 12, color: token.colorTextTertiary }}>
<CalendarOutlined style={{ fontSize: isMobile ? 10 : 12 }} />
{formatDate(project.updated_at)}
</Space>
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined style={{ fontSize: isMobile ? 12 : 14 }} />}
onClick={(e) => {
e.stopPropagation();
onDeleteProject(project.id);
}}
style={{
padding: isMobile ? '2px 4px' : '4px 8px',
borderRadius: 8,
}}
/>
</div>
</>
)}
</div>
</Card>
</div>
+24
View File
@@ -172,6 +172,28 @@ export default function ProjectList() {
}
};
const handleGenerateCover = async (project: Project, overwrite: boolean = true) => {
try {
message.loading({ content: `正在为《${project.title}》生成封面...`, key: `cover-${project.id}` });
await projectApi.generateCover(project.id, overwrite);
message.success({ content: `${project.title}》封面生成成功`, key: `cover-${project.id}` });
await refreshProjects();
} catch (error) {
console.error('生成封面失败:', error);
message.error({ content: `${project.title}》封面生成失败`, key: `cover-${project.id}` });
}
};
const handleDownloadCover = async (project: Project) => {
try {
await projectApi.downloadCover(project.id, `${project.title}-cover.png`);
message.success(`${project.title}》封面已开始下载`);
} catch (error) {
console.error('下载封面失败:', error);
message.error('下载封面失败');
}
};
const getStatusTag = (status: string) => {
const statusConfig: Record<string, { color: string; text: string; icon: ReactNode }> = {
planning: { color: 'blue', text: '规划', icon: <CalendarOutlined /> },
@@ -837,6 +859,8 @@ export default function ProjectList() {
onOpenInspiration={() => navigate('/inspiration')}
onEnterProject={handleEnterProject}
onDeleteProject={handleDelete}
onGenerateCover={handleGenerateCover}
onDownloadCover={handleDownloadCover}
formatWordCount={formatWordCount}
getProgress={getProgress}
getProgressColor={getProgressColor}
+213 -10
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { Card, Form, Input, Button, Select, Slider, InputNumber, message, Space, Typography, Spin, Modal, Alert, Grid, Tabs, List, Tag, Popconfirm, Empty, Row, Col, theme } from 'antd';
import { SaveOutlined, DeleteOutlined, ReloadOutlined, InfoCircleOutlined, CheckCircleOutlined, CloseCircleOutlined, ThunderboltOutlined, PlusOutlined, EditOutlined, CopyOutlined, WarningOutlined } from '@ant-design/icons';
import { SaveOutlined, DeleteOutlined, ReloadOutlined, InfoCircleOutlined, CheckCircleOutlined, CloseCircleOutlined, ThunderboltOutlined, PlusOutlined, EditOutlined, CopyOutlined, WarningOutlined, PictureOutlined } from '@ant-design/icons';
import { settingsApi, mcpPluginApi } from '../services/api';
import type { SettingsUpdate, APIKeyPreset, PresetCreateRequest, APIKeyPresetConfig } from '../types';
import { eventBus, EventNames } from '../store/eventBus';
@@ -35,6 +35,13 @@ export default function SettingsPage() {
suggestions?: string[];
} | null>(null);
const [showTestResult, setShowTestResult] = useState(false);
const [testingCoverApi, setTestingCoverApi] = useState(false);
const [coverTestResult, setCoverTestResult] = useState<{
success: boolean;
message: string;
provider?: string;
model?: string;
} | null>(null);
// 预设相关状态
const [activeTab, setActiveTab] = useState('current');
@@ -265,19 +272,27 @@ export default function SettingsPage() {
});
};
const mumuTextDefaultUrl = 'https://api.mumuverse.space/v1';
const mumuRegisterUrl = 'https://api.mumuverse.space/register?aff=4NN8';
const mumuCoverBaseUrlOptions = [
{ value: 'https://api.mumuverse.space/v1beta', label: 'https://api.mumuverse.space/v1beta', defaultModel: 'gemini-3.1-flash-image-preview' },
{ value: 'https://api.mumuverse.space/v1', label: 'https://api.mumuverse.space/v1', defaultModel: 'gpt-image-1.5' },
];
const apiProviders = [
{ value: 'openai', label: 'OpenAI Compatible', defaultUrl: 'https://api.openai.com/v1' },
// { value: 'anthropic', label: 'Anthropic (Claude)', defaultUrl: 'https://api.anthropic.com' },
{ value: 'gemini', label: 'Google Gemini', defaultUrl: 'https://generativelanguage.googleapis.com/v1beta' },
{
value: 'mumu',
label: 'MuMuのAPI',
defaultUrl: 'https://api.mumuverse.space/v1',
defaultUrl: mumuTextDefaultUrl,
defaultModel: 'gemini-3-flash-preview'
},
{ value: 'openai', label: 'OpenAI Compatible', defaultUrl: 'https://api.openai.com/v1' },
// { value: 'anthropic', label: 'Anthropic (Claude)', defaultUrl: 'https://api.anthropic.com' },
{ value: 'gemini', label: 'Google Gemini', defaultUrl: 'https://generativelanguage.googleapis.com/v1beta' },
];
const selectedProvider = Form.useWatch('api_provider', form);
const selectedCoverProvider = Form.useWatch('cover_api_provider', form);
const selectedPresetProvider = Form.useWatch('api_provider', presetForm);
const handleProviderChange = (value: string) => {
@@ -298,6 +313,83 @@ export default function SettingsPage() {
setModelsFetched(false);
};
const coverApiProviders = [
{
value: 'mumu',
label: 'MuMuのAPI',
defaultUrl: mumuCoverBaseUrlOptions[0].value,
defaultModel: mumuCoverBaseUrlOptions[0].defaultModel,
},
{ value: 'gemini', label: 'Google Gemini', defaultUrl: 'https://generativelanguage.googleapis.com/v1beta' },
{ value: 'grok', label: 'Grok', defaultUrl: 'https://api.x.ai/v1' },
];
const handleCoverProviderChange = (value: string) => {
const provider = coverApiProviders.find(p => p.value === value);
if (!provider) {
setCoverTestResult(null);
return;
}
const nextValues: Record<string, string> = {};
if (provider.defaultUrl) {
nextValues.cover_api_base_url = provider.defaultUrl;
}
if (provider.value === 'mumu') {
nextValues.cover_api_key = '';
nextValues.cover_image_model = provider.defaultModel || mumuCoverBaseUrlOptions[0].defaultModel;
}
form.setFieldsValue(nextValues);
setCoverTestResult(null);
};
const handleMumuCoverBaseUrlChange = (value: string) => {
const option = mumuCoverBaseUrlOptions.find(item => item.value === value);
form.setFieldsValue({
cover_api_base_url: value,
cover_image_model: option?.defaultModel || mumuCoverBaseUrlOptions[0].defaultModel,
});
setCoverTestResult(null);
};
const handleCoverTestConnection = async () => {
const coverApiProvider = form.getFieldValue('cover_api_provider');
const coverApiKey = form.getFieldValue('cover_api_key');
const coverApiBaseUrl = form.getFieldValue('cover_api_base_url');
const coverImageModel = form.getFieldValue('cover_image_model');
if (!coverApiProvider || !coverApiKey || !coverImageModel) {
message.warning('请先填写完整的封面图片配置信息');
return;
}
setTestingCoverApi(true);
setCoverTestResult(null);
try {
const result = await settingsApi.testCoverConnection({
cover_api_provider: coverApiProvider,
cover_api_key: coverApiKey,
cover_api_base_url: coverApiBaseUrl,
cover_image_model: coverImageModel,
});
setCoverTestResult(result);
if (result.success) {
message.success('封面图片接口测试成功');
} else {
message.error(result.message || '封面图片接口测试失败');
}
} catch (error) {
console.error('封面图片接口测试失败:', error);
setCoverTestResult({
success: false,
message: '封面图片接口测试失败',
});
} finally {
setTestingCoverApi(false);
}
};
const handleFetchModels = async (silent: boolean = false) => {
const apiKey = form.getFieldValue('api_key');
const apiBaseUrl = form.getFieldValue('api_base_url');
@@ -1002,7 +1094,7 @@ export default function SettingsPage() {
items={[
{
key: 'current',
label: '当前配置',
label: <Space size={6}><ThunderboltOutlined /></Space>,
children: (
<Space direction="vertical" size={isMobile ? 'middle' : 'large'} style={{ width: '100%' }}>
@@ -1079,7 +1171,7 @@ export default function SettingsPage() {
<div>
<Button
type="primary"
onClick={() => window.open('https://api.mumuverse.space/register?aff=4NN8', '_blank', 'noopener,noreferrer')}
onClick={() => window.open(mumuRegisterUrl, '_blank', 'noopener,noreferrer')}
>
MuMuのAPI
</Button>
@@ -1552,9 +1644,120 @@ export default function SettingsPage() {
</Space>
),
},
{
key: 'cover',
label: <Space size={6}><PictureOutlined /></Space>,
children: (
<Spin spinning={initialLoading}>
<Form form={form} layout="vertical" onFinish={handleSave} autoComplete="off">
<Form.Item name="cover_enabled" valuePropName="checked" style={{ marginBottom: 16 }}>
<Select
size={isMobile ? 'middle' : 'large'}
onChange={(value) => form.setFieldsValue({ cover_enabled: value === 'enabled' })}
value={form.getFieldValue('cover_enabled') ? 'enabled' : 'disabled'}
options={[
{ value: 'enabled', label: '启用封面图片生成' },
{ value: 'disabled', label: '停用封面图片生成' },
]}
/>
</Form.Item>
<Form.Item label="封面图片 Provider" name="cover_api_provider" rules={[{ required: true, message: '请选择封面图片 Provider' }]}>
<Select size={isMobile ? 'middle' : 'large'} onChange={handleCoverProviderChange}>
{coverApiProviders.map(provider => (
<Option key={provider.value} value={provider.value}>{provider.label}</Option>
))}
</Select>
</Form.Item>
{selectedCoverProvider === 'mumu' && (
<Alert
type="info"
showIcon
message="MuMuのAPI 专属适配器"
description={
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Text>
MuMuのAPI API Key MuMuのAPI
</Text>
<div>
<Button
type="primary"
onClick={() => window.open(mumuRegisterUrl, '_blank', 'noopener,noreferrer')}
>
MuMuのAPI
</Button>
</div>
</Space>
}
style={{ marginBottom: 16 }}
/>
)}
<Form.Item label="封面图片 API Key" name="cover_api_key" rules={[{ required: true, message: '请输入封面图片 API Key' }]}>
<Input.Password size={isMobile ? 'middle' : 'large'} placeholder={selectedCoverProvider === 'mumu' ? '请输入 MuMuのAPI Key' : '输入封面图片 API Key'} autoComplete="new-password" />
</Form.Item>
<Form.Item label="封面图片 API 地址" name="cover_api_base_url" rules={[{ type: 'url', message: '请输入有效的URL' }]}>
{selectedCoverProvider === 'mumu' ? (
<Select
size={isMobile ? 'middle' : 'large'}
onChange={handleMumuCoverBaseUrlChange}
options={mumuCoverBaseUrlOptions.map(option => ({
value: option.value,
label: option.label,
}))}
/>
) : (
<Input size={isMobile ? 'middle' : 'large'} placeholder={selectedCoverProvider === 'grok' ? 'https://api.x.ai/v1' : 'https://generativelanguage.googleapis.com/v1beta'} />
)}
</Form.Item>
<Form.Item label="封面图片模型" name="cover_image_model" rules={[{ required: true, message: '请输入封面图片模型名称' }]}>
<Input
size={isMobile ? 'middle' : 'large'}
placeholder={selectedCoverProvider === 'mumu'
? '选择地址后自动填入推荐模型'
: selectedCoverProvider === 'grok'
? 'grok-2-image'
: 'gemini-2.0-flash-exp-image-generation'}
/>
</Form.Item>
{coverTestResult && (
<Alert
type={coverTestResult.success ? 'success' : 'error'}
showIcon
message={coverTestResult.message}
description={coverTestResult.success ? `Provider: ${coverTestResult.provider || '-'} / Model: ${coverTestResult.model || '-'}` : undefined}
style={{ marginBottom: 16 }}
/>
)}
<Form.Item style={{ marginBottom: 0, marginTop: 24 }}>
<Space wrap style={{ width: '100%', justifyContent: 'space-between' }}>
<Space wrap>
<Button
icon={<ThunderboltOutlined />}
onClick={handleCoverTestConnection}
loading={testingCoverApi}
style={{ borderColor: token.colorSuccess, color: token.colorSuccess, fontWeight: 500 }}
>
{testingCoverApi ? '测试中...' : '测试封面接口'}
</Button>
<Button icon={<ReloadOutlined />} onClick={handleReset}></Button>
</Space>
<Button type="primary" icon={<SaveOutlined />} htmlType="submit" loading={loading}></Button>
</Space>
</Form.Item>
</Form>
</Spin>
),
},
{
key: 'presets',
label: '配置预设',
label: <Space size={6}><CopyOutlined /></Space>,
children: renderPresetsList(),
},
]}
@@ -1606,9 +1809,9 @@ export default function SettingsPage() {
style={{ marginBottom: 16 }}
>
<Select placeholder="选择提供商" onChange={handlePresetProviderChange}>
<Select.Option value="mumu">MuMuのAPI</Select.Option>
<Select.Option value="openai">OpenAI</Select.Option>
<Select.Option value="gemini">Google Gemini</Select.Option>
<Select.Option value="mumu">MuMuのAPI</Select.Option>
</Select>
</Form.Item>
@@ -1625,7 +1828,7 @@ export default function SettingsPage() {
<div>
<Button
type="primary"
onClick={() => window.open('https://api.mumuverse.space/register?aff=4NN8', '_blank', 'noopener,noreferrer')}
onClick={() => window.open(mumuRegisterUrl, '_blank', 'noopener,noreferrer')}
>
MuMuのAPI
</Button>
+45
View File
@@ -218,6 +218,14 @@ export const settingsApi = {
suggestions?: string[];
}>('/settings/test', params),
testCoverConnection: (params: { cover_api_provider: string; cover_api_key: string; cover_api_base_url?: string; cover_image_model: string }) =>
api.post<unknown, {
success: boolean;
message: string;
provider?: string;
model?: string;
}>('/settings/cover/test', params),
checkFunctionCalling: (params: { api_key: string; api_base_url: string; provider: string; llm_model: string }) =>
api.post<unknown, {
success: boolean;
@@ -296,6 +304,43 @@ export const projectApi = {
deleteProject: (id: string) => api.delete(`/projects/${id}`),
generateCover: (id: string, overwrite: boolean = true) =>
api.post<unknown, {
project_id: string;
cover_status: string;
cover_image_url?: string;
cover_prompt?: string;
provider?: string;
model?: string;
message: string;
}>(`/projects/${id}/cover/generate`, { overwrite }),
downloadCover: async (id: string, filename?: string) => {
const response = await axios.get(`/api/projects/${id}/cover/download`, {
responseType: 'blob',
withCredentials: true,
});
const contentDisposition = response.headers['content-disposition'];
let finalFilename = filename || 'novel-cover.png';
if (contentDisposition) {
const utf8Match = /filename\*=UTF-8''(.+)/.exec(contentDisposition);
const basicMatch = /filename="?([^";]+)"?/.exec(contentDisposition);
if (utf8Match?.[1]) {
finalFilename = decodeURIComponent(utf8Match[1]);
} else if (basicMatch?.[1]) {
finalFilename = basicMatch[1];
}
}
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', finalFilename);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
},
exportProject: (id: string) => {
window.open(`/api/projects/${id}/export`, '_blank');
},
+15
View File
@@ -22,6 +22,11 @@ export interface Settings {
temperature: number;
max_tokens: number;
system_prompt?: string;
cover_api_provider?: string;
cover_api_key?: string;
cover_api_base_url?: string;
cover_image_model?: string;
cover_enabled?: boolean;
preferences?: string;
created_at: string;
updated_at: string;
@@ -35,6 +40,11 @@ export interface SettingsUpdate {
temperature?: number;
max_tokens?: number;
system_prompt?: string;
cover_api_provider?: string;
cover_api_key?: string;
cover_api_base_url?: string;
cover_image_model?: string;
cover_enabled?: boolean;
preferences?: string;
}
@@ -102,6 +112,11 @@ export interface Project {
chapter_count?: number;
narrative_perspective?: string;
character_count?: number;
cover_image_url?: string;
cover_prompt?: string;
cover_status?: 'none' | 'generating' | 'ready' | 'failed';
cover_error?: string;
cover_updated_at?: string;
created_at: string;
updated_at: string;
}
+4
View File
@@ -44,6 +44,10 @@ export default defineConfig({
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/generated-assets': {
target: 'http://localhost:8000',
changeOrigin: true,
}
}
}