feature:新增拆书导入续写功能,给当年的ta一个结局。

This commit is contained in:
xiamuceer-j
2026-03-04 16:28:16 +08:00
parent ad19c773f0
commit 536bd198b4
8 changed files with 3859 additions and 15 deletions
+918
View File
@@ -0,0 +1,918 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Button,
Card,
Col,
Collapse,
Empty,
Input,
InputNumber,
List,
message,
Progress,
Row,
Select,
Space,
Spin,
Steps,
Tag,
Typography,
Upload,
} from 'antd';
import type { UploadFile } from 'antd/es/upload/interface';
import { InboxOutlined, PlayCircleOutlined, ReloadOutlined, StopOutlined, WarningOutlined, RedoOutlined } from '@ant-design/icons';
import { bookImportApi } from '../services/api';
import type {
BookImportApplyPayload,
BookImportPreview,
BookImportStepFailure,
BookImportTask,
} from '../types';
const { Text } = Typography;
const { Dragger } = Upload;
const { TextArea } = Input;
const BOOK_IMPORT_CACHE_KEY = 'book_import_page_cache_v1';
type BookImportPageCache = {
taskId: string | null;
taskStatus: BookImportTask | null;
preview: BookImportPreview | null;
applyProgress: number;
applyMessage: string;
applyError: string | null;
isApplyComplete: boolean;
cachedAt: number;
};
function loadBookImportCache(): BookImportPageCache | null {
try {
const raw = sessionStorage.getItem(BOOK_IMPORT_CACHE_KEY);
if (!raw) return null;
return JSON.parse(raw) as BookImportPageCache;
} catch (error) {
console.warn('读取拆书页面缓存失败:', error);
return null;
}
}
function saveBookImportCache(cache: BookImportPageCache) {
try {
sessionStorage.setItem(BOOK_IMPORT_CACHE_KEY, JSON.stringify(cache));
} catch (error) {
const isQuotaExceeded =
error instanceof DOMException &&
(error.name === 'QuotaExceededError' || error.name === 'NS_ERROR_DOM_QUOTA_REACHED');
if (isQuotaExceeded) {
// 发生容量溢出时降级为轻量缓存(不保存预览正文),避免持续报错
try {
const lightweightCache: BookImportPageCache = {
...cache,
preview: null,
};
sessionStorage.setItem(BOOK_IMPORT_CACHE_KEY, JSON.stringify(lightweightCache));
return;
} catch (fallbackError) {
console.warn('写入轻量拆书页面缓存失败:', fallbackError);
try {
sessionStorage.removeItem(BOOK_IMPORT_CACHE_KEY);
} catch {
// ignore
}
}
}
console.warn('写入拆书页面缓存失败:', error);
}
}
function clearBookImportCache() {
try {
sessionStorage.removeItem(BOOK_IMPORT_CACHE_KEY);
} catch (error) {
console.warn('清理拆书页面缓存失败:', error);
}
}
function isNotFoundError(error: unknown): boolean {
if (!error || typeof error !== 'object') return false;
const maybeError = error as { response?: { status?: number } };
return maybeError.response?.status === 404;
}
export default function BookImport() {
const navigate = useNavigate();
const [file, setFile] = useState<File | null>(null);
const [taskId, setTaskId] = useState<string | null>(null);
const [taskStatus, setTaskStatus] = useState<BookImportTask | null>(null);
const [preview, setPreview] = useState<BookImportPreview | null>(null);
const [creatingTask, setCreatingTask] = useState(false);
const [loadingPreview, setLoadingPreview] = useState(false);
const [applying, setApplying] = useState(false);
const [applyProgress, setApplyProgress] = useState(0);
const [applyMessage, setApplyMessage] = useState('');
const [applyError, setApplyError] = useState<string | null>(null);
const [isApplyComplete, setIsApplyComplete] = useState(false);
const [cacheReady, setCacheReady] = useState(false);
// 步骤级失败和重试相关状态
const [failedSteps, setFailedSteps] = useState<BookImportStepFailure[]>([]);
const [retrying, setRetrying] = useState(false);
const [retryProgress, setRetryProgress] = useState(0);
const [retryMessage, setRetryMessage] = useState('');
const importedProjectId = useRef<string | null>(null);
const isTaskTerminal = useMemo(() => {
return !!taskStatus && ['completed', 'failed', 'cancelled'].includes(taskStatus.status);
}, [taskStatus]);
const currentStep = useMemo(() => {
if (!taskId) return 0;
if (taskStatus && ['pending', 'running'].includes(taskStatus.status)) return 1;
if (applying || isApplyComplete) return 3; // 新增生成导入步骤
if (preview) return 2;
return 1;
}, [taskId, taskStatus, preview, applying, isApplyComplete]);
useEffect(() => {
const cache = loadBookImportCache();
if (cache) {
const cacheAgeMs = typeof cache.cachedAt === 'number'
? Date.now() - cache.cachedAt
: Number.POSITIVE_INFINITY;
// 超过6小时的缓存直接视为失效,避免后端重启后继续使用旧taskId
if (cacheAgeMs > 6 * 60 * 60 * 1000) {
clearBookImportCache();
} else {
setTaskId(cache.taskId);
setTaskStatus(cache.taskStatus);
setPreview(cache.preview);
setApplyProgress(cache.applyProgress);
setApplyError(cache.applyError);
setIsApplyComplete(cache.isApplyComplete);
setApplyMessage(
cache.applyMessage || (cache.applyProgress > 0 && !cache.isApplyComplete
? '已恢复页面缓存,请重新点击“确认导入”继续。'
: '')
);
message.info('已恢复拆书导入页面缓存');
}
}
setCacheReady(true);
}, []);
useEffect(() => {
if (!cacheReady) return;
// 导入完成后必须清理缓存,避免后续回到页面时恢复到旧任务状态
if (isApplyComplete) {
clearBookImportCache();
return;
}
const hasCacheData = Boolean(
taskId ||
taskStatus ||
preview ||
applyError ||
applyProgress > 0 ||
applyMessage
);
if (!hasCacheData) {
clearBookImportCache();
return;
}
saveBookImportCache({
taskId,
taskStatus,
// preview 含完整章节正文,体积大,容易触发 sessionStorage 配额限制
// 页面恢复时可根据 taskId + taskStatus 重新拉取 preview
preview: null,
applyProgress,
applyMessage,
applyError,
isApplyComplete,
cachedAt: Date.now(),
});
}, [
cacheReady,
taskId,
taskStatus,
preview,
applyProgress,
applyMessage,
applyError,
isApplyComplete,
]);
useEffect(() => {
if (!taskId) return;
if (isTaskTerminal) return;
const timer = setInterval(async () => {
try {
const status = await bookImportApi.getTaskStatus(taskId);
setTaskStatus(status);
} catch (error) {
console.error('轮询任务状态失败:', error);
if (isNotFoundError(error)) {
clearBookImportCache();
setTaskId(null);
setTaskStatus(null);
setPreview(null);
setApplyProgress(0);
setApplyMessage('');
setApplyError(null);
setIsApplyComplete(false);
message.warning('拆书任务已失效(可能因服务重启),请重新上传TXT并开始解析');
}
}
}, 1500);
return () => clearInterval(timer);
}, [taskId, isTaskTerminal]);
useEffect(() => {
const fetchPreview = async () => {
if (!taskId || !taskStatus) return;
if (taskStatus.status !== 'completed' || preview) return;
try {
setLoadingPreview(true);
const data = await bookImportApi.getPreview(taskId);
setPreview(data);
} catch (error) {
console.error('获取预览失败:', error);
if (isNotFoundError(error)) {
clearBookImportCache();
setTaskId(null);
setTaskStatus(null);
setPreview(null);
setApplyProgress(0);
setApplyMessage('');
setApplyError(null);
setIsApplyComplete(false);
message.warning('拆书任务预览不存在(可能因服务重启),已清空缓存,请重新上传TXT');
} else {
message.error('获取预览失败');
}
} finally {
setLoadingPreview(false);
}
};
fetchPreview();
}, [taskId, taskStatus, preview]);
const startTask = async () => {
if (!file) {
message.warning('请先选择 TXT 文件');
return;
}
try {
setCreatingTask(true);
setPreview(null);
setTaskStatus(null);
const response = await bookImportApi.createTask({
file,
});
setTaskId(response.task_id);
message.success('拆书任务已创建');
} catch (error) {
console.error('创建任务失败:', error);
message.error('创建拆书任务失败');
} finally {
setCreatingTask(false);
}
};
const refreshStatus = async () => {
if (!taskId) return;
try {
const status = await bookImportApi.getTaskStatus(taskId);
setTaskStatus(status);
} catch (error) {
console.error('刷新状态失败:', error);
if (isNotFoundError(error)) {
clearBookImportCache();
setTaskId(null);
setTaskStatus(null);
setPreview(null);
setApplyProgress(0);
setApplyMessage('');
setApplyError(null);
setIsApplyComplete(false);
message.warning('任务不存在,已清空本地缓存,请重新创建拆书任务');
}
}
};
const cancelTask = async () => {
if (!taskId) return;
try {
await bookImportApi.cancelTask(taskId);
message.success('任务已取消');
await refreshStatus();
} catch (error) {
console.error('取消任务失败:', error);
message.error('取消任务失败');
}
};
const applyImport = async () => {
if (!taskId || !preview) return;
const payload: BookImportApplyPayload = {
project_suggestion: preview.project_suggestion,
chapters: preview.chapters,
outlines: preview.outlines,
import_mode: 'append',
};
try {
setApplying(true);
setApplyProgress(0);
setApplyMessage('准备导入...');
setApplyError(null);
setIsApplyComplete(false);
setFailedSteps([]);
await bookImportApi.applyImportStream(
taskId,
payload,
{
onProgress: (msg, prog, status) => {
// 检查是否是步骤失败的特殊消息
if (status === 'step_failures') {
try {
const parsed = JSON.parse(msg);
if (parsed.failed_steps && Array.isArray(parsed.failed_steps)) {
setFailedSteps(parsed.failed_steps as BookImportStepFailure[]);
}
} catch {
// 不是JSON,忽略
}
return;
}
setApplyProgress(prog);
setApplyMessage(msg);
},
onResult: (result) => {
importedProjectId.current = result.project_id;
const generatedCareers = result.statistics?.generated_careers ?? 0;
const generatedEntities = result.statistics?.generated_entities ?? 0;
// 检查最终是否有失败步骤
setIsApplyComplete(true);
// 如果没有失败步骤才自动跳转
// 注意:这里需要延迟一帧来等待 failedSteps 的更新
setTimeout(() => {
setFailedSteps(prev => {
if (prev.length === 0) {
message.success(`导入成功:已生成职业${generatedCareers}个,角色/组织${generatedEntities}`);
clearBookImportCache();
setTimeout(() => {
navigate(`/project/${result.project_id}/chapters`);
}, 1000);
} else {
message.warning(`导入完成,但有 ${prev.length} 个生成步骤失败,可点击重试`);
}
return prev;
});
}, 100);
},
onError: (error) => {
console.error('导入过程发生错误:', error);
setApplyError(`导入失败: ${error}`);
message.error(`导入失败: ${error}`);
setApplying(false);
},
onComplete: () => {
setApplyProgress(100);
setApplyMessage('导入完成!');
}
}
);
} catch (error) {
console.error('确认导入失败:', error);
setApplyError('确认导入失败,无法连接到服务器');
message.error('确认导入失败');
setApplying(false);
}
};
const retryFailedSteps = useCallback(async () => {
if (!taskId || failedSteps.length === 0) return;
const stepsToRetry = failedSteps.map(f => f.step_name);
try {
setRetrying(true);
setRetryProgress(0);
setRetryMessage('正在重试失败的生成步骤...');
await bookImportApi.retryFailedStepsStream(
taskId,
stepsToRetry,
{
onProgress: (msg, prog, status) => {
if (status === 'step_failures') {
try {
const parsed = JSON.parse(msg);
if (parsed.failed_steps && Array.isArray(parsed.failed_steps)) {
setFailedSteps(parsed.failed_steps as BookImportStepFailure[]);
}
} catch {
// 不是JSON,忽略
}
return;
}
setRetryProgress(prog);
setRetryMessage(msg);
},
onResult: (result) => {
if (result.still_failed && result.still_failed.length > 0) {
setFailedSteps(result.still_failed);
message.warning(`重试完成,仍有 ${result.still_failed.length} 个步骤失败`);
} else {
setFailedSteps([]);
message.success('所有步骤重试成功!');
clearBookImportCache();
const projectId = result.project_id || importedProjectId.current;
if (projectId) {
setTimeout(() => {
navigate(`/project/${projectId}/chapters`);
}, 1000);
}
}
},
onError: (error) => {
console.error('重试失败:', error);
message.error(`重试失败: ${error}`);
},
onComplete: () => {
setRetrying(false);
setRetryProgress(100);
setRetryMessage('重试完成');
}
}
);
} catch (error) {
console.error('重试请求失败:', error);
message.error('重试请求失败,无法连接到服务器');
setRetrying(false);
}
}, [taskId, failedSteps, navigate]);
const skipFailedSteps = useCallback(() => {
setFailedSteps([]);
clearBookImportCache();
const projectId = importedProjectId.current;
if (projectId) {
message.info('已跳过失败步骤,正在跳转到项目...');
navigate(`/project/${projectId}/chapters`);
}
}, [navigate]);
const updateChapter = (index: number, patch: Partial<BookImportPreview['chapters'][number]>) => {
setPreview(prev => {
if (!prev) return prev;
const next = [...prev.chapters];
next[index] = { ...next[index], ...patch };
return { ...prev, chapters: next };
});
};
return (
<div style={{ height: '100%', overflow: 'auto', paddingRight: 8 }}>
<Card style={{ marginBottom: 16 }}>
<Steps
current={currentStep}
items={[
{ title: '上传文件' },
{ title: '解析中' },
{ title: '预览修改' },
{ title: '生成导入' },
]}
/>
</Card>
{currentStep === 0 && (
<Card title="上传 TXT 并开始解析" style={{ marginBottom: 16 }}>
<Space direction="vertical" style={{ width: '100%' }} size={16}>
<Dragger
accept=".txt"
multiple={false}
beforeUpload={(f) => {
setFile(f);
return false;
}}
onRemove={() => {
setFile(null);
}}
fileList={
file
? [
{
uid: 'selected-txt',
name: file.name,
status: 'done',
} as UploadFile,
]
: []
}
style={{ padding: '8px 0' }}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text"> TXT </p>
<p className="ant-upload-hint"> .txt 50MB</p>
</Dragger>
<Space>
<Button
type="primary"
icon={<PlayCircleOutlined />}
loading={creatingTask}
onClick={startTask}
>
</Button>
{taskId && (
<Tag color="blue">ID: {taskId}</Tag>
)}
</Space>
</Space>
</Card>
)}
{currentStep === 1 && (
<Card title="解析任务状态" style={{ marginBottom: 16 }}>
{!taskId ? (
<Empty description="尚未创建任务" />
) : (
<div style={{ textAlign: 'center', padding: '24px 0' }}>
<Progress
type="circle"
percent={taskStatus?.progress || 0}
status={
taskStatus?.status === 'failed' ? 'exception' :
taskStatus?.status === 'completed' ? 'success' :
'active'
}
/>
<div style={{ marginTop: 24 }}>
<Text strong style={{ fontSize: 16 }}>
{taskStatus?.status === 'pending' && '等待调度...'}
{taskStatus?.status === 'running' && '正在解析TXT文件...'}
{taskStatus?.status === 'completed' && '解析完成!正在生成预览...'}
{taskStatus?.status === 'failed' && '解析失败'}
{taskStatus?.status === 'cancelled' && '已取消'}
</Text>
{taskStatus?.message && (
<div style={{ marginTop: 8 }}>
<Text type="secondary">{taskStatus.message}</Text>
</div>
)}
</div>
{taskStatus?.error && (
<Alert type="error" message={taskStatus.error} showIcon style={{ marginTop: 16, textAlign: 'left' }} />
)}
<Space style={{ marginTop: 24 }}>
<Button icon={<ReloadOutlined />} onClick={refreshStatus}></Button>
{taskStatus && ['pending', 'running'].includes(taskStatus.status) && (
<Button danger icon={<StopOutlined />} onClick={cancelTask}></Button>
)}
</Space>
</div>
)}
</Card>
)}
{currentStep === 2 && (
<>
<Card
title="预览修正"
extra={
<Button
type="primary"
loading={applying}
disabled={!preview}
onClick={applyImport}
>
</Button>
}
style={{ marginBottom: 16 }}
>
<Spin spinning={loadingPreview}>
{!preview ? (
<Empty description="解析完成后将显示预览数据" />
) : (
<div style={{ maxHeight: '60vh', overflowY: 'auto', paddingRight: 8 }}>
<Space direction="vertical" style={{ width: '100%' }} size={16}>
{preview.warnings.length > 0 && (
<Alert
type="warning"
showIcon
message="检测到告警"
description={
<ul style={{ margin: 0, paddingLeft: 20 }}>
{preview.warnings.map((w, idx) => (
<li key={`${w.code}-${idx}`}>[{w.level}] {w.message}</li>
))}
</ul>
}
/>
)}
<Card
size="small"
title="项目信息"
>
<Row gutter={12}>
<Col xs={24} md={12}>
<Text></Text>
<Input
value={preview.project_suggestion.title}
onChange={(e) =>
setPreview(prev => prev ? ({
...prev,
project_suggestion: { ...prev.project_suggestion, title: e.target.value },
}) : prev)
}
/>
</Col>
<Col xs={24} md={12}>
<Text></Text>
<Input
value={preview.project_suggestion.genre}
onChange={(e) =>
setPreview(prev => prev ? ({
...prev,
project_suggestion: { ...prev.project_suggestion, genre: e.target.value },
}) : prev)
}
/>
</Col>
<Col xs={24}>
<Text></Text>
<TextArea
rows={3}
value={preview.project_suggestion.theme}
onChange={(e) =>
setPreview(prev => prev ? ({
...prev,
project_suggestion: { ...prev.project_suggestion, theme: e.target.value },
}) : prev)
}
/>
</Col>
<Col xs={24}>
<Text></Text>
<TextArea
rows={3}
value={preview.project_suggestion.description}
onChange={(e) =>
setPreview(prev => prev ? ({
...prev,
project_suggestion: { ...prev.project_suggestion, description: e.target.value },
}) : prev)
}
/>
</Col>
<Col xs={24} md={12}>
<Text></Text>
<Select
style={{ width: '100%' }}
value={preview.project_suggestion.narrative_perspective}
onChange={(v) =>
setPreview(prev => prev ? ({
...prev,
project_suggestion: { ...prev.project_suggestion, narrative_perspective: v },
}) : prev)
}
options={[
{ value: '第一人称', label: '第一人称' },
{ value: '第三人称', label: '第三人称' },
{ value: '全知视角', label: '全知视角' },
]}
/>
</Col>
<Col xs={24} md={12}>
<Text></Text>
<InputNumber
style={{ width: '100%' }}
min={1000}
step={1000}
value={preview.project_suggestion.target_words}
onChange={(v) =>
setPreview(prev => prev ? ({
...prev,
project_suggestion: {
...prev.project_suggestion,
target_words: Number(v || 100000),
},
}) : prev)
}
/>
</Col>
</Row>
</Card>
<Card size="small" title={`章节(${preview.chapters.length}`}>
<Collapse
items={preview.chapters.map((ch, idx) => ({
key: String(idx),
label: `${ch.chapter_number} 章 · ${ch.title}`,
children: (
<Space direction="vertical" style={{ width: '100%' }}>
<Input
value={ch.title}
addonBefore="标题"
onChange={(e) => updateChapter(idx, { title: e.target.value })}
/>
<TextArea
rows={2}
value={ch.summary}
placeholder="章节摘要"
onChange={(e) => updateChapter(idx, { summary: e.target.value })}
/>
<TextArea
rows={8}
value={ch.content}
placeholder="章节正文"
onChange={(e) => updateChapter(idx, { content: e.target.value })}
/>
</Space>
),
}))}
/>
</Card>
</Space>
</div>
)}
</Spin>
</Card>
</>
)}
{currentStep === 3 && (
<Card title="生成导入进度" style={{ marginBottom: 16 }}>
<div style={{ textAlign: 'center', padding: '40px 20px', maxWidth: 600, margin: '0 auto' }}>
<Typography.Title level={4} style={{ marginBottom: 32 }}>
{retrying ? '正在重试失败的生成步骤' : (failedSteps.length > 0 && isApplyComplete ? '导入完成,部分步骤需要重试' : '正在为您生成并导入项目内容')}
</Typography.Title>
<Progress
percent={retrying ? retryProgress : applyProgress}
status={
applyError ? 'exception' :
(failedSteps.length > 0 && isApplyComplete && !retrying) ? 'exception' :
(isApplyComplete && failedSteps.length === 0) ? 'success' :
'active'
}
strokeColor={{
'0%': 'var(--color-primary)',
'100%': failedSteps.length > 0 ? '#faad14' : 'var(--color-primary-active)',
}}
style={{ marginBottom: 24 }}
/>
<Typography.Paragraph
style={{
fontSize: 16,
marginBottom: 32,
color: applyError ? 'var(--color-error)' :
(failedSteps.length > 0 && isApplyComplete && !retrying) ? '#faad14' :
'var(--color-text-secondary)'
}}
>
{retrying ? retryMessage : (applyError || applyMessage)}
</Typography.Paragraph>
{applyError && (
<Alert
type="error"
message="导入出错"
description={applyError}
showIcon
style={{ textAlign: 'left', marginBottom: 24 }}
/>
)}
{/* 步骤失败提示与重试UI */}
{failedSteps.length > 0 && isApplyComplete && !retrying && (
<div style={{ textAlign: 'left', marginBottom: 24 }}>
<Alert
type="warning"
icon={<WarningOutlined />}
showIcon
message={`${failedSteps.length} 个生成步骤失败`}
description={
<div>
<Typography.Paragraph style={{ marginBottom: 12, color: 'rgba(0,0,0,0.65)' }}>
AI生成步骤未能完成
</Typography.Paragraph>
<List
size="small"
bordered
dataSource={failedSteps}
renderItem={(item) => (
<List.Item
style={{ padding: '8px 12px' }}
>
<List.Item.Meta
title={
<Space>
<Tag color="error">{item.step_label}</Tag>
{(item.retry_count ?? 0) > 0 && (
<Tag color="orange"> {item.retry_count} </Tag>
)}
</Space>
}
description={
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{item.error.length > 120 ? item.error.slice(0, 120) + '...' : item.error}
</Typography.Text>
}
/>
</List.Item>
)}
/>
<Space style={{ marginTop: 16, display: 'flex', justifyContent: 'center' }}>
<Button
type="primary"
icon={<RedoOutlined />}
onClick={retryFailedSteps}
loading={retrying}
>
</Button>
<Button onClick={skipFailedSteps}>
</Button>
</Space>
</div>
}
style={{ marginBottom: 16 }}
/>
</div>
)}
{/* 重试进行中 */}
{retrying && (
<div style={{ marginBottom: 24 }}>
<Spin spinning={retrying}>
<Alert
type="info"
showIcon
message="正在重试..."
description={retryMessage}
style={{ textAlign: 'left' }}
/>
</Spin>
</div>
)}
{!failedSteps.length && !retrying && (
<div style={{
background: 'var(--color-bg-layout)',
padding: 16,
borderRadius: 8,
textAlign: 'left',
marginTop: 32
}}>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
AI会自动帮您补全<br />
<br />
<br />
<br />
{isApplyComplete ? '所有步骤已完成,即将自动跳转。' : '请耐心等待,完成后将自动跳转。'}
</Typography.Text>
</div>
)}
</div>
</Card>
)}
</div>
);
}
+53 -14
View File
@@ -13,6 +13,7 @@ import ChangelogFloatingButton from '../components/ChangelogFloatingButton';
import SettingsPage from './Settings';
import MCPPluginsPage from './MCPPlugins';
import PromptTemplates from './PromptTemplates';
import BookImport from './BookImport';
const { Title, Text, Paragraph } = Typography;
@@ -39,7 +40,7 @@ const formatWordCount = (count: number): string => {
export default function ProjectList() {
const navigate = useNavigate();
const { projects, loading } = useStore();
const [activeView, setActiveView] = useState<'projects' | 'settings' | 'mcp' | 'prompts'>('projects');
const [activeView, setActiveView] = useState<'projects' | 'settings' | 'mcp' | 'prompts' | 'book-import'>('projects');
const [drawerVisible, setDrawerVisible] = useState(false);
const [modal, contextHolder] = Modal.useModal();
const [showApiTip, setShowApiTip] = useState(true);
@@ -400,27 +401,27 @@ export default function ProjectList() {
<div style={{ padding: '0 12px', fontSize: 12, color: 'rgba(0,0,0,0.45)', marginBottom: 8, marginTop: 16 }}></div>
<div
onClick={() => setActiveView('prompts')}
onClick={() => setActiveView('book-import')}
style={{
padding: '10px 16px',
fontSize: 14,
cursor: 'pointer',
borderRadius: 4,
color: activeView === 'prompts' ? 'var(--color-primary)' : 'rgba(0,0,0,0.85)',
background: activeView === 'prompts' ? '#e6f7ff' : 'transparent',
color: activeView === 'book-import' ? 'var(--color-primary)' : 'rgba(0,0,0,0.85)',
background: activeView === 'book-import' ? '#e6f7ff' : 'transparent',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: 10,
transition: 'all 0.3s',
marginBottom: 4,
borderRight: activeView === 'prompts' ? '3px solid var(--color-primary)' : '3px solid transparent'
borderRight: activeView === 'book-import' ? '3px solid var(--color-primary)' : '3px solid transparent'
}}
onMouseEnter={e => activeView !== 'prompts' && (e.currentTarget.style.background = 'rgba(0,0,0,0.04)')}
onMouseLeave={e => activeView !== 'prompts' && (e.currentTarget.style.background = 'transparent')}
onMouseEnter={e => activeView !== 'book-import' && (e.currentTarget.style.background = 'rgba(0,0,0,0.04)')}
onMouseLeave={e => activeView !== 'book-import' && (e.currentTarget.style.background = 'transparent')}
>
<FileSearchOutlined />
<UploadOutlined />
</div>
<div
onClick={() => setActiveView('mcp')}
@@ -445,6 +446,29 @@ export default function ProjectList() {
<ApiOutlined />
MCP
</div>
<div
onClick={() => setActiveView('prompts')}
style={{
padding: '10px 16px',
fontSize: 14,
cursor: 'pointer',
borderRadius: 4,
color: activeView === 'prompts' ? 'var(--color-primary)' : 'rgba(0,0,0,0.85)',
background: activeView === 'prompts' ? '#e6f7ff' : 'transparent',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: 10,
transition: 'all 0.3s',
marginBottom: 4,
borderRight: activeView === 'prompts' ? '3px solid var(--color-primary)' : '3px solid transparent'
}}
onMouseEnter={e => activeView !== 'prompts' && (e.currentTarget.style.background = 'rgba(0,0,0,0.04)')}
onMouseLeave={e => activeView !== 'prompts' && (e.currentTarget.style.background = 'transparent')}
>
<FileSearchOutlined />
</div>
<div style={{ padding: '0 12px', fontSize: 12, color: 'rgba(0,0,0,0.45)', marginBottom: 8, marginTop: 16 }}></div>
<div
@@ -530,6 +554,7 @@ export default function ProjectList() {
}}>
{activeView === 'projects' ? '我的书架' :
activeView === 'prompts' ? '提示词模板' :
activeView === 'book-import' ? '拆书导入' :
activeView === 'mcp' ? 'MCP 插件' : 'API 设置'}
</span>
</div>
@@ -577,7 +602,7 @@ export default function ProjectList() {
selectedKeys={[activeView]}
style={{ borderRight: 0, paddingTop: 8 }}
onClick={({ key }) => {
setActiveView(key as 'projects' | 'settings' | 'mcp' | 'prompts');
setActiveView(key as 'projects' | 'settings' | 'mcp' | 'prompts' | 'book-import');
setDrawerVisible(false);
}}
items={[
@@ -591,15 +616,20 @@ export default function ProjectList() {
label: '创作工具',
children: [
{
key: 'prompts',
icon: <FileSearchOutlined />,
label: '提示词管理',
key: 'book-import',
icon: <UploadOutlined />,
label: '拆书导入',
},
{
key: 'mcp',
icon: <ApiOutlined />,
label: 'MCP 插件',
},
{
key: 'prompts',
icon: <FileSearchOutlined />,
label: '提示词管理',
},
],
},
{
@@ -676,6 +706,7 @@ export default function ProjectList() {
}}>
{activeView === 'projects' ? '我的书架' :
activeView === 'prompts' ? '提示词模板' :
activeView === 'book-import' ? '拆书导入' :
activeView === 'mcp' ? 'MCP 插件' : 'API 设置'}
</h2>
@@ -743,7 +774,9 @@ export default function ProjectList() {
style={{
flex: 1,
overflowY: 'auto',
padding: activeView === 'projects' ? `${isMobile ? 16 : 24}px ${isMobile ? 16 : 32}px` : 0,
padding: (activeView === 'projects' || activeView === 'book-import')
? `${isMobile ? 16 : 24}px ${isMobile ? 16 : 32}px`
: 0,
background: 'var(--color-bg-base)',
}}
>
@@ -751,6 +784,12 @@ export default function ProjectList() {
{activeView === 'mcp' && <MCPPluginsPage />}
{activeView === 'prompts' && <PromptTemplates />}
{activeView === 'book-import' && (
<div style={{ maxWidth: 1200, margin: '0 auto', paddingBottom: 60 }}>
<BookImport />
</div>
)}
{activeView === 'projects' && (
<div style={{ maxWidth: 1600, margin: '0 auto', paddingBottom: 60 }}>