feature: 新增小说封面图片生成功能
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user