refactor: 后端代码重构,提取通用权限验证逻辑至common模块,减少代码冗余

This commit is contained in:
xiamuceer-j
2026-01-13 16:45:58 +08:00
parent 6f33e12ead
commit 46debab624
14 changed files with 907 additions and 716 deletions
+3
View File
@@ -108,6 +108,9 @@ launcher.py
launcher.spec
mumuainovel.md
logo.ico
.embed_cache
dist_embed/
embed_build.py
data/
+1 -20
View File
@@ -27,31 +27,12 @@ from app.schemas.career import (
from app.services.ai_service import AIService
from app.logger import get_logger
from app.api.settings import get_user_ai_service
from app.api.common import verify_project_access
router = APIRouter(prefix="/careers", tags=["职业管理"])
logger = get_logger(__name__)
async def verify_project_access(project_id: str, user_id: str, db: AsyncSession) -> Project:
"""验证用户是否有权访问指定项目"""
if not user_id:
raise HTTPException(status_code=401, detail="未登录")
result = await db.execute(
select(Project).where(
Project.id == project_id,
Project.user_id == user_id
)
)
project = result.scalar_one_or_none()
if not project:
logger.warning(f"项目访问被拒绝: project_id={project_id}, user_id={user_id}")
raise HTTPException(status_code=404, detail="项目不存在或无权访问")
return project
@router.get("", response_model=CareerListResponse, summary="获取职业列表")
async def get_careers(
project_id: str,
+1 -33
View File
@@ -10,6 +10,7 @@ from datetime import datetime
from asyncio import Queue, Lock
from app.database import get_db
from app.api.common import verify_project_access
from app.services.chapter_context_service import ChapterContextBuilder, FocusedMemoryRetriever
from app.models.chapter import Chapter
from app.models.project import Project
@@ -54,39 +55,6 @@ logger = get_logger(__name__)
db_write_locks: dict[str, Lock] = {}
async def verify_project_access(project_id: str, user_id: str, db: AsyncSession) -> Project:
"""
验证用户是否有权访问指定项目
Args:
project_id: 项目ID
user_id: 用户ID
db: 数据库会话
Returns:
Project: 项目对象
Raises:
HTTPException: 401 未登录,404 项目不存在或无权访问
"""
if not user_id:
raise HTTPException(status_code=401, detail="未登录")
result = await db.execute(
select(Project).where(
Project.id == project_id,
Project.user_id == user_id
)
)
project = result.scalar_one_or_none()
if not project:
logger.warning(f"项目访问被拒绝: project_id={project_id}, user_id={user_id}")
raise HTTPException(status_code=404, detail="项目不存在或无权访问")
return project
async def get_db_write_lock(user_id: str) -> Lock:
"""获取或创建用户的数据库写入锁"""
if user_id not in db_write_locks:
+100
View File
@@ -0,0 +1,100 @@
"""API 公共函数模块
包含跨 API 模块共享的通用函数和工具。
"""
from fastapi import HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional
from app.models.project import Project
from app.logger import get_logger
logger = get_logger(__name__)
async def verify_project_access(
project_id: str,
user_id: Optional[str],
db: AsyncSession
) -> Project:
"""
验证用户是否有权访问指定项目
统一的项目访问验证函数,确保:
1. 用户已登录
2. 项目存在
3. 用户有权访问该项目
Args:
project_id: 项目ID
user_id: 用户ID(从 request.state.user_id 获取)
db: 数据库会话
Returns:
Project: 验证通过后返回项目对象
Raises:
HTTPException:
- 401: 用户未登录
- 404: 项目不存在或用户无权访问
"""
if not user_id:
raise HTTPException(status_code=401, detail="未登录")
result = await db.execute(
select(Project).where(
Project.id == project_id,
Project.user_id == user_id
)
)
project = result.scalar_one_or_none()
if not project:
logger.warning(f"项目访问被拒绝: project_id={project_id}, user_id={user_id}")
raise HTTPException(status_code=404, detail="项目不存在或无权访问")
return project
def get_user_id(request: Request) -> Optional[str]:
"""
从请求中获取用户ID
这是一个便捷函数,用于从 request.state 中提取 user_id。
Args:
request: FastAPI 请求对象
Returns:
用户ID,如果未登录则返回 None
"""
return getattr(request.state, 'user_id', None)
async def verify_project_access_from_request(
project_id: str,
request: Request,
db: AsyncSession
) -> Project:
"""
从请求中验证项目访问权限(便捷函数)
结合 get_user_id 和 verify_project_access,简化调用。
Args:
project_id: 项目ID
request: FastAPI 请求对象
db: 数据库会话
Returns:
Project: 验证通过后返回项目对象
Raises:
HTTPException: 401/404
Usage:
project = await verify_project_access_from_request(project_id, request, db)
"""
user_id = get_user_id(request)
return await verify_project_access(project_id, user_id, db)
+1 -20
View File
@@ -12,32 +12,13 @@ from app.services.plot_analyzer import get_plot_analyzer
from app.services.ai_service import create_user_ai_service
from app.models.settings import Settings
from app.logger import get_logger
from app.api.common import verify_project_access
import uuid
logger = get_logger(__name__)
router = APIRouter(prefix="/api/memories", tags=["memories"])
async def verify_project_access(project_id: str, user_id: str, db: AsyncSession) -> Project:
"""验证用户是否有权访问指定项目"""
if not user_id:
raise HTTPException(status_code=401, detail="未登录")
result = await db.execute(
select(Project).where(
Project.id == project_id,
Project.user_id == user_id
)
)
project = result.scalar_one_or_none()
if not project:
logger.warning(f"项目访问被拒绝: project_id={project_id}, user_id={user_id}")
raise HTTPException(status_code=404, detail="项目不存在或无权访问")
return project
@router.post("/projects/{project_id}/analyze-chapter/{chapter_id}")
async def analyze_chapter(
project_id: str,
+1 -20
View File
@@ -27,31 +27,12 @@ from app.services.ai_service import AIService
from app.services.prompt_service import prompt_service, PromptService
from app.logger import get_logger
from app.api.settings import get_user_ai_service
from app.api.common import verify_project_access
router = APIRouter(prefix="/organizations", tags=["组织管理"])
logger = get_logger(__name__)
async def verify_project_access(project_id: str, user_id: str, db: AsyncSession) -> Project:
"""验证用户是否有权访问指定项目"""
if not user_id:
raise HTTPException(status_code=401, detail="未登录")
result = await db.execute(
select(Project).where(
Project.id == project_id,
Project.user_id == user_id
)
)
project = result.scalar_one_or_none()
if not project:
logger.warning(f"项目访问被拒绝: project_id={project_id}, user_id={user_id}")
raise HTTPException(status_code=404, detail="项目不存在或无权访问")
return project
class OrganizationGenerateRequest(BaseModel):
"""AI生成组织的请求模型"""
project_id: str = Field(..., description="项目ID")
+1 -33
View File
@@ -6,6 +6,7 @@ from typing import List, AsyncGenerator, Dict, Any
import json
from app.database import get_db
from app.api.common import verify_project_access
from app.models.outline import Outline
from app.models.project import Project
from app.models.chapter import Chapter
@@ -42,39 +43,6 @@ router = APIRouter(prefix="/outlines", tags=["大纲管理"])
logger = get_logger(__name__)
async def verify_project_access(project_id: str, user_id: str, db: AsyncSession) -> Project:
"""
验证用户是否有权访问指定项目
Args:
project_id: 项目ID
user_id: 用户ID
db: 数据库会话
Returns:
Project: 项目对象
Raises:
HTTPException: 401 未登录,404 项目不存在或无权访问
"""
if not user_id:
raise HTTPException(status_code=401, detail="未登录")
result = await db.execute(
select(Project).where(
Project.id == project_id,
Project.user_id == user_id
)
)
project = result.scalar_one_or_none()
if not project:
logger.warning(f"项目访问被拒绝: project_id={project_id}, user_id={user_id}")
raise HTTPException(status_code=404, detail="项目不存在或无权访问")
return project
def _build_chapters_brief(outlines: List[Outline], max_recent: int = 20) -> str:
"""构建章节概览字符串"""
target = outlines[-max_recent:] if len(outlines) > max_recent else outlines
+1 -20
View File
@@ -23,31 +23,12 @@ from app.schemas.relationship import (
RelationshipGraphLink
)
from app.logger import get_logger
from app.api.common import verify_project_access
router = APIRouter(prefix="/relationships", tags=["关系管理"])
logger = get_logger(__name__)
async def verify_project_access(project_id: str, user_id: str, db: AsyncSession) -> Project:
"""验证用户是否有权访问指定项目"""
if not user_id:
raise HTTPException(status_code=401, detail="未登录")
result = await db.execute(
select(Project).where(
Project.id == project_id,
Project.user_id == user_id
)
)
project = result.scalar_one_or_none()
if not project:
logger.warning(f"项目访问被拒绝: project_id={project_id}, user_id={user_id}")
raise HTTPException(status_code=404, detail="项目不存在或无权访问")
return project
@router.get("/types", response_model=List[RelationshipTypeResponse], summary="获取关系类型列表")
async def get_relationship_types(db: AsyncSession = Depends(get_db)):
"""获取所有预定义的关系类型"""
+2
View File
@@ -8,6 +8,7 @@ sqlalchemy==2.0.25
asyncpg==0.29.0 # PostgreSQL异步驱动
psycopg2-binary==2.9.9 # PostgreSQL同步驱动(用于迁移脚本)
alembic==1.14.0 # 数据库迁移工具
aiosqlite==0.22.1
# 数据验证
pydantic==2.12.4
@@ -20,6 +21,7 @@ anthropic==0.72.0
# 工具库
httpx==0.28.1
python-dotenv==1.1.0
psutil==6.1.1
# MCP官方库(Model Context Protocol Python SDK
mcp==1.22.0
+22 -1
View File
@@ -326,8 +326,29 @@ body {
font-size: 22px !important;
}
/* 折叠状态下隐藏文字但保持点击区域 */
.modern-sider.ant-layout-sider-collapsed .ant-menu-item .ant-menu-title-content {
display: none !important;
width: 0 !important;
overflow: hidden !important;
opacity: 0 !important;
margin: 0 !important;
padding: 0 !important;
}
/* 确保折叠状态下的 Link 覆盖整个菜单项区域 */
.modern-sider.ant-layout-sider-collapsed .ant-menu-item {
position: relative;
}
.modern-sider.ant-layout-sider-collapsed .ant-menu-item a {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
}
/* 选中项左侧指示条 */
+116 -116
View File
@@ -5,6 +5,7 @@ import { useStore } from '../store';
import { useChapterSync } from '../store/hooks';
import { projectApi, writingStyleApi, chapterApi } from '../services/api';
import type { Chapter, ChapterUpdate, ApiError, WritingStyle, AnalysisTask, ExpansionPlanData } from '../types';
import type { TextAreaRef } from 'antd/es/input/TextArea';
import ChapterAnalysis from '../components/ChapterAnalysis';
import ExpansionPlanEditor from '../components/ExpansionPlanEditor';
import { SSELoadingOverlay } from '../components/SSELoadingOverlay';
@@ -54,7 +55,7 @@ export default function Chapters() {
const [form] = Form.useForm();
const [editorForm] = Form.useForm();
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
const contentTextAreaRef = useRef<any>(null);
const contentTextAreaRef = useRef<TextAreaRef>(null);
const [writingStyles, setWritingStyles] = useState<WritingStyle[]>([]);
const [selectedStyleId, setSelectedStyleId] = useState<number | undefined>();
const [targetWordCount, setTargetWordCount] = useState<number>(getCachedWordCount);
@@ -124,12 +125,14 @@ export default function Chapters() {
// 清理轮询定时器
useEffect(() => {
const pollingIntervals = pollingIntervalsRef.current;
const batchPollingInterval = batchPollingIntervalRef.current;
return () => {
Object.values(pollingIntervalsRef.current).forEach(interval => {
Object.values(pollingIntervals).forEach(interval => {
clearInterval(interval);
});
if (batchPollingIntervalRef.current) {
clearInterval(batchPollingIntervalRef.current);
if (batchPollingInterval) {
clearInterval(batchPollingInterval);
}
};
}, []);
@@ -156,7 +159,7 @@ export default function Chapters() {
startPollingTask(chapter.id);
}
}
} catch (error) {
} catch {
// 404或其他错误表示没有分析任务,忽略
console.debug(`章节 ${chapter.id} 暂无分析任务`);
}
@@ -252,7 +255,7 @@ export default function Chapters() {
return settings.llm_model; // 返回模型名称
}
}
} catch (error) {
} catch {
console.log('获取模型列表失败,将使用默认模型');
}
}
@@ -339,6 +342,38 @@ export default function Chapters() {
}
};
// 按章节号排序并按大纲分组章节 (必须在早返回之前调用,避免违反 Hooks 规则)
const { sortedChapters, groupedChapters } = useMemo(() => {
const sorted = [...chapters].sort((a, b) => a.chapter_number - b.chapter_number);
const groups: Record<string, {
outlineId: string | null;
outlineTitle: string;
outlineOrder: number;
chapters: Chapter[];
}> = {};
sorted.forEach(chapter => {
const key = chapter.outline_id || 'uncategorized';
if (!groups[key]) {
groups[key] = {
outlineId: chapter.outline_id || null,
outlineTitle: chapter.outline_title || '未分类章节',
outlineOrder: chapter.outline_order ?? 999,
chapters: []
};
}
groups[key].chapters.push(chapter);
});
// 转换为数组并按大纲顺序排序
const grouped = Object.values(groups).sort((a, b) => a.outlineOrder - b.outlineOrder);
return { sortedChapters: sorted, groupedChapters: grouped };
}, [chapters]);
if (!currentProject) return null;
// 获取人称的中文显示文本
@@ -633,7 +668,7 @@ export default function Chapters() {
}
await handleGenerate();
instance.destroy();
} catch (error) {
} catch {
instance.update({
okButtonProps: { danger: true, loading: false },
cancelButtonProps: { disabled: false },
@@ -670,36 +705,6 @@ export default function Chapters() {
return texts[status] || status;
};
const sortedChapters = [...chapters].sort((a, b) => a.chapter_number - b.chapter_number);
// 按大纲分组章节
const groupedChapters = useMemo(() => {
const groups: Record<string, {
outlineId: string | null;
outlineTitle: string;
outlineOrder: number;
chapters: Chapter[];
}> = {};
sortedChapters.forEach(chapter => {
const key = chapter.outline_id || 'uncategorized';
if (!groups[key]) {
groups[key] = {
outlineId: chapter.outline_id || null,
outlineTitle: chapter.outline_title || '未分类章节',
outlineOrder: chapter.outline_order ?? 999,
chapters: []
};
}
groups[key].chapters.push(chapter);
});
// 转换为数组并按大纲顺序排序
return Object.values(groups).sort((a, b) => a.outlineOrder - b.outlineOrder);
}, [sortedChapters]);
const handleExport = () => {
if (chapters.length === 0) {
message.warning('当前项目没有章节,无法导出');
@@ -761,7 +766,14 @@ export default function Chapters() {
setBatchGenerating(true);
setBatchGenerateVisible(false); // 关闭配置对话框,避免遮挡进度弹窗
const requestBody: any = {
const requestBody: {
start_chapter_number: number;
count: number;
enable_analysis: boolean;
style_id: number;
target_word_count: number;
model?: string;
} = {
start_chapter_number: values.startChapterNumber,
count: values.count,
enable_analysis: true,
@@ -814,8 +826,9 @@ export default function Chapters() {
// 开始轮询任务状态
startBatchPolling(result.batch_id);
} catch (error: any) {
message.error('创建批量生成任务失败:' + (error.message || '未知错误'));
} catch (error: unknown) {
const err = error as Error;
message.error('创建批量生成任务失败:' + (err.message || '未知错误'));
setBatchGenerating(false);
setBatchGenerateVisible(false);
}
@@ -936,8 +949,9 @@ export default function Chapters() {
const updatedProject = await projectApi.getProject(currentProject.id);
setCurrentProject(updatedProject);
}
} catch (error: any) {
message.error('取消失败:' + (error.message || '未知错误'));
} catch (error: unknown) {
const err = error as Error;
message.error('取消失败:' + (err.message || '未知错误'));
}
};
@@ -1129,8 +1143,9 @@ export default function Chapters() {
setCurrentProject(updatedProject);
manualCreateForm.resetFields();
} catch (error: any) {
message.error('操作失败:' + (error.message || '未知错误'));
} catch (error: unknown) {
const err = error as Error;
message.error('操作失败:' + (err.message || '未知错误'));
throw error;
}
}
@@ -1154,8 +1169,9 @@ export default function Chapters() {
setCurrentProject(updatedProject);
manualCreateForm.resetFields();
} catch (error: any) {
message.error('创建失败:' + (error.message || '未知错误'));
} catch (error: unknown) {
const err = error as Error;
message.error('创建失败:' + (err.message || '未知错误'));
throw error;
}
}
@@ -1440,8 +1456,9 @@ export default function Chapters() {
}
message.success('章节删除成功');
} catch (error: any) {
message.error('删除章节失败:' + (error.message || '未知错误'));
} catch (error: unknown) {
const err = error as Error;
message.error('删除章节失败:' + (err.message || '未知错误'));
}
};
@@ -1478,8 +1495,9 @@ export default function Chapters() {
// 关闭编辑器
setPlanEditorVisible(false);
setEditingPlanChapter(null);
} catch (error: any) {
message.error('保存规划失败:' + (error.message || '未知错误'));
} catch (error: unknown) {
const err = error as Error;
message.error('保存规划失败:' + (err.message || '未知错误'));
throw error;
}
};
@@ -2193,7 +2211,7 @@ export default function Chapters() {
disabled={isGenerating}
style={{ width: '100%' }}
formatter={(value) => `${value}`}
parser={(value) => value?.replace(' 字', '') as any}
parser={(value) => parseInt(value?.replace(' 字', '') || '0', 10) as unknown as 500}
/>
</Form.Item>
@@ -2357,11 +2375,27 @@ export default function Chapters() {
setBatchGenerateVisible(false);
}
}}
footer={null}
width={600}
footer={!batchGenerating ? (
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => setBatchGenerateVisible(false)}>
</Button>
<Button type="primary" icon={<RocketOutlined />} onClick={() => batchForm.submit()}>
</Button>
</Space>
) : null}
width={700}
centered
closable={!batchGenerating}
maskClosable={!batchGenerating}
styles={{
body: {
maxHeight: 'calc(100vh - 260px)',
overflowY: 'auto',
overflowX: 'hidden'
}
}}
>
{!batchGenerating ? (
<Form
@@ -2371,32 +2405,28 @@ export default function Chapters() {
initialValues={{
startChapterNumber: sortedChapters.find(ch => !ch.content || ch.content.trim() === '')?.chapter_number || 1,
count: 5,
enableAnalysis: true, // 强制启用同步分析
enableAnalysis: true,
styleId: selectedStyleId,
targetWordCount: getCachedWordCount(),
model: selectedModel,
}}
>
<Alert
message="批量生成说明"
description={
<ul style={{ margin: '8px 0 0 0', paddingLeft: 20 }}>
<li></li>
<li>使</li>
<li></li>
</ul>
}
message="批量生成说明:严格按序生成 | 统一风格字数 | 任一失败则终止"
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
{/* 第一行:起始章节 + 生成数量 */}
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item
label="起始章节"
name="startChapterNumber"
rules={[{ required: true, message: '请选择起始章节' }]}
rules={[{ required: true, message: '请选择' }]}
style={{ flex: 1, marginBottom: 12 }}
>
<Select placeholder="选择起始章节" size="large">
<Select placeholder="选择起始章节">
{sortedChapters
.filter(ch => !ch.content || ch.content.trim() === '')
.filter(ch => canGenerateChapter(ch))
@@ -2411,33 +2441,30 @@ export default function Chapters() {
<Form.Item
label="生成数量"
name="count"
rules={[{ required: true, message: '请选择生成数量' }]}
rules={[{ required: true, message: '请选择' }]}
style={{ marginBottom: 12 }}
>
<Radio.Group buttonStyle="solid" size="large">
<Radio.Group buttonStyle="solid">
<Radio.Button value={5}>5</Radio.Button>
<Radio.Button value={10}>10</Radio.Button>
<Radio.Button value={15}>15</Radio.Button>
<Radio.Button value={20}>20</Radio.Button>
</Radio.Group>
</Form.Item>
</div>
{/* 第二行:写作风格 + 目标字数 */}
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item
label="写作风格"
name="styleId"
rules={[{ required: true, message: '请选择写作风格' }]}
tooltip="批量生成时所有章节使用相同的写作风格"
>
<Select
placeholder="请选择写作风格"
size="large"
showSearch
optionFilterProp="children"
rules={[{ required: true, message: '请选择' }]}
style={{ flex: 1, marginBottom: 12 }}
>
<Select placeholder="请选择写作风格" showSearch optionFilterProp="children">
{writingStyles.map(style => (
<Select.Option key={style.id} value={style.id}>
{style.name}
{style.is_default && ' (默认)'}
{style.description && ` - ${style.description}`}
{style.name}{style.is_default && ' (默认)'}
</Select.Option>
))}
</Select>
@@ -2445,21 +2472,18 @@ export default function Chapters() {
<Form.Item
label="目标字数"
tooltip="AI生成章节时的目标字数,实际生成字数可能略有偏差(修改后会自动记住)"
>
<Form.Item
name="targetWordCount"
rules={[{ required: true, message: '请设置目标字数' }]}
noStyle
rules={[{ required: true, message: '请设置' }]}
tooltip="修改后自动记住"
style={{ flex: 1, marginBottom: 12 }}
>
<InputNumber
min={500}
max={10000}
step={100}
size="large"
style={{ width: '100%' }}
formatter={(value) => `${value}`}
parser={(value) => value?.replace(' 字', '') as any}
parser={(value) => parseInt(value?.replace(' 字', '') || '0', 10) as unknown as 500}
onChange={(value) => {
if (value) {
setCachedWordCount(value);
@@ -2467,20 +2491,19 @@ export default function Chapters() {
}}
/>
</Form.Item>
<div style={{ color: '#666', fontSize: 12, marginTop: 4 }}>
500-10000
</div>
</Form.Item>
{/* 第三行:AI模型 + 同步分析 */}
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item
label="AI模型"
tooltip="批量生成时所有章节使用相同模型,不选则使用默认模型"
tooltip="不选则使用默认模型"
style={{ flex: 1, marginBottom: 12 }}
>
<Select
placeholder={batchSelectedModel ? `默认: ${availableModels.find(m => m.value === batchSelectedModel)?.label || batchSelectedModel}` : "使用默认模型"}
value={batchSelectedModel}
onChange={setBatchSelectedModel}
size="large"
allowClear
showSearch
optionFilterProp="label"
@@ -2488,47 +2511,24 @@ export default function Chapters() {
{availableModels.map(model => (
<Select.Option key={model.value} value={model.value} label={model.label}>
{model.label}
{model.value === batchSelectedModel && ' (默认)'}
</Select.Option>
))}
</Select>
<div style={{ color: '#666', fontSize: 12, marginTop: 4 }}>
{batchSelectedModel ? `当前默认模型: ${availableModels.find(m => m.value === batchSelectedModel)?.label || batchSelectedModel}` : '加载模型列表中...'}
</div>
</Form.Item>
<Form.Item
label="同步分析"
name="enableAnalysis"
tooltip="批量生成必须开启同步分析,确保角色职业信息和剧情状态的连贯"
tooltip="必须开启,确保剧情连贯"
style={{ marginBottom: 12 }}
>
<Radio.Group disabled>
<Radio value={true}>
<Space direction="vertical" size={0}>
<span style={{ fontSize: 12, color: '#52c41a' }}>
</span>
<span style={{ fontSize: 12, color: '#52c41a' }}>
</span>
<span style={{ fontSize: 12, color: '#ff9800' }}>
50%
</span>
</Space>
<span style={{ fontSize: 12, color: '#52c41a' }}> </span>
</Radio>
</Radio.Group>
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => setBatchGenerateVisible(false)}>
</Button>
<Button type="primary" htmlType="submit" icon={<RocketOutlined />}>
</Button>
</Space>
</Form.Item>
</div>
</Form>
) : (
<div>
File diff suppressed because it is too large Load Diff
+79 -35
View File
@@ -7,7 +7,7 @@ import { cardStyles } from '../components/CardStyles';
import { SSEPostClient } from '../utils/sseClient';
import { SSEProgressModal } from '../components/SSEProgressModal';
import { outlineApi, chapterApi, projectApi } from '../services/api';
import type { OutlineExpansionResponse, BatchOutlineExpansionResponse } from '../types';
import type { OutlineExpansionResponse, BatchOutlineExpansionResponse, ChapterPlanItem, ApiError } from '../types';
// 角色预测数据类型
interface PredictedCharacter {
@@ -64,6 +64,42 @@ interface OrganizationConfirmationData {
chapter_range: string;
}
// 大纲生成请求数据类型
interface OutlineGenerateRequestData {
project_id: string;
genre: string;
theme: string;
chapter_count: number;
narrative_perspective: string;
target_words: number;
requirements?: string;
mode: 'auto' | 'new' | 'continue';
story_direction?: string;
plot_stage: 'development' | 'climax' | 'ending';
enable_auto_characters: boolean;
require_character_confirmation: boolean;
enable_auto_organizations: boolean;
require_organization_confirmation: boolean;
model?: string;
provider?: string;
confirmed_characters?: PredictedCharacter[];
confirmed_organizations?: PredictedOrganization[];
}
// 跳过的大纲信息类型
interface SkippedOutlineInfo {
outline_id: string;
outline_title: string;
reason: string;
}
// 场景类型
interface SceneInfo {
location: string;
characters: string[];
purpose: string;
}
const { TextArea } = Input;
export default function Outline() {
@@ -84,7 +120,7 @@ export default function Outline() {
// 角色确认相关状态
const [characterConfirmData, setCharacterConfirmData] = useState<CharacterConfirmationData | null>(null);
const [characterConfirmVisible, setCharacterConfirmVisible] = useState(false);
const [pendingGenerateData, setPendingGenerateData] = useState<any>(null);
const [pendingGenerateData, setPendingGenerateData] = useState<OutlineGenerateRequestData | null>(null);
const [selectedCharacterIndices, setSelectedCharacterIndices] = useState<number[]>([]);
// 组织确认相关状态
@@ -274,7 +310,7 @@ export default function Outline() {
setSSEModalVisible(true);
// 准备请求数据
const requestData: any = {
const requestData: OutlineGenerateRequestData = {
project_id: currentProject.id,
genre: currentProject.genre || '通用',
theme: values.theme || currentProject.theme || '',
@@ -315,10 +351,10 @@ export default function Outline() {
setSSEMessage(msg);
setSSEProgress(progress);
},
onResult: (data: any) => {
onResult: (data: unknown) => {
console.log('生成完成,结果:', data);
},
onCharacterConfirmation: (data: any) => {
onCharacterConfirmation: (data: CharacterConfirmationData) => {
// ✨ 新增:处理角色确认事件
console.log('收到角色确认请求:', data);
// 关闭SSE进度Modal
@@ -332,7 +368,7 @@ export default function Outline() {
setCharacterConfirmData(data);
setCharacterConfirmVisible(true);
},
onOrganizationConfirmation: (data: any) => {
onOrganizationConfirmation: (data: OrganizationConfirmationData) => {
// ✨ 新增:处理组织确认事件
console.log('收到组织确认请求:', data);
// 关闭SSE进度Modal
@@ -396,7 +432,7 @@ export default function Outline() {
defaultModel = settings.llm_model;
}
}
} catch (error) {
} catch {
console.log('获取模型列表失败,将使用默认模型');
}
}
@@ -756,12 +792,13 @@ export default function Outline() {
message.success('大纲创建成功');
await refreshOutlines();
manualCreateForm.resetFields();
} catch (error: any) {
if (error.message === '序号重复') {
} catch (error: unknown) {
const err = error as Error;
if (err.message === '序号重复') {
// 序号重复错误已经显示了Modal,不需要再显示message
throw error;
}
message.error('创建失败:' + (error.message || '未知错误'));
message.error('创建失败:' + (err.message || '未知错误'));
throw error;
}
}
@@ -970,8 +1007,9 @@ export default function Outline() {
const updatedProject = await projectApi.getProject(currentProject.id);
setCurrentProject(updatedProject);
}
} catch (error: any) {
message.error(error.response?.data?.detail || '删除章节失败');
} catch (error: unknown) {
const apiError = error as ApiError;
message.error(apiError.response?.data?.detail || '删除章节失败');
}
};
@@ -1003,12 +1041,11 @@ export default function Outline() {
title: (
<Space style={{ flexWrap: 'wrap' }}>
<CheckCircleOutlined style={{ color: 'var(--color-success)' }} />
<span></span>
<span>{outlineTitle}</span>
</Space>
),
width: isMobile ? '95%' : 900,
centered: true,
okText: '关闭',
style: isMobile ? {
top: 20,
maxWidth: 'calc(100vw - 16px)',
@@ -1016,11 +1053,12 @@ export default function Outline() {
} : undefined,
styles: {
body: {
maxHeight: isMobile ? 'calc(100vh - 150px)' : 'calc(80vh - 110px)',
overflowY: 'auto'
maxHeight: isMobile ? 'calc(100vh - 200px)' : 'calc(80vh - 60px)',
overflowY: 'auto',
overflowX: 'hidden'
}
},
footer: (_: any, { OkBtn }: any) => (
footer: (
<Space wrap style={{ width: '100%', justifyContent: isMobile ? 'center' : 'flex-end' }}>
<Button
danger
@@ -1053,7 +1091,9 @@ export default function Outline() {
>
({data.chapter_count})
</Button>
<OkBtn />
<Button onClick={() => Modal.destroyAll()}>
</Button>
</Space>
),
content: (
@@ -1335,7 +1375,7 @@ export default function Outline() {
// 确认创建章节 - 使用缓存的规划数据,避免重复AI调用
const handleConfirmCreateChapters = async (
outlineId: string,
cachedPlans: any[]
cachedPlans: ChapterPlanItem[]
) => {
try {
setIsExpanding(true);
@@ -1449,7 +1489,7 @@ export default function Outline() {
setSSEMessage(msg);
setSSEProgress(progress);
},
onResult: (data: any) => {
onResult: (data: BatchOutlineExpansionResponse) => {
console.log('批量展开完成,结果:', data);
// 缓存AI生成的规划数据
setCachedBatchExpansionResponse(data);
@@ -1515,7 +1555,7 @@ export default function Outline() {
</div>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
{batchPreviewData.skipped_outlines.map((skipped: any, idx: number) => (
{batchPreviewData.skipped_outlines.map((skipped: SkippedOutlineInfo, idx: number) => (
<div key={idx} style={{ fontSize: 13, color: '#666' }}>
{skipped.outline_title} <Tag color="default" style={{ fontSize: 11 }}>{skipped.reason}</Tag>
</div>
@@ -1581,7 +1621,7 @@ export default function Outline() {
<List
size="small"
dataSource={batchPreviewData.expansion_results[selectedOutlineIdx].chapter_plans}
renderItem={(plan: any, idx: number) => (
renderItem={(plan: ChapterPlanItem, idx: number) => (
<List.Item
key={idx}
onClick={() => setSelectedChapterIdx(idx)}
@@ -1625,7 +1665,7 @@ export default function Outline() {
<Card size="small" title="关键事件" bordered={false}>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
{batchPreviewData.expansion_results[selectedOutlineIdx].chapter_plans[selectedChapterIdx].key_events.map((event: string, eventIdx: number) => (
{(batchPreviewData.expansion_results[selectedOutlineIdx].chapter_plans[selectedChapterIdx].key_events as string[]).map((event: string, eventIdx: number) => (
<div key={eventIdx}> {event}</div>
))}
</Space>
@@ -1633,7 +1673,7 @@ export default function Outline() {
<Card size="small" title="涉及角色" bordered={false}>
<Space wrap>
{batchPreviewData.expansion_results[selectedOutlineIdx].chapter_plans[selectedChapterIdx].character_focus.map((char: string, charIdx: number) => (
{(batchPreviewData.expansion_results[selectedOutlineIdx].chapter_plans[selectedChapterIdx].character_focus as string[]).map((char: string, charIdx: number) => (
<Tag key={charIdx} color="purple">{char}</Tag>
))}
</Space>
@@ -1642,7 +1682,7 @@ export default function Outline() {
{batchPreviewData.expansion_results[selectedOutlineIdx].chapter_plans[selectedChapterIdx].scenes && batchPreviewData.expansion_results[selectedOutlineIdx].chapter_plans[selectedChapterIdx].scenes!.length > 0 && (
<Card size="small" title="场景" bordered={false}>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
{batchPreviewData.expansion_results[selectedOutlineIdx].chapter_plans[selectedChapterIdx].scenes!.map((scene: any, sceneIdx: number) => (
{batchPreviewData.expansion_results[selectedOutlineIdx].chapter_plans[selectedChapterIdx].scenes!.map((scene: SceneInfo, sceneIdx: number) => (
<Card key={sceneIdx} size="small" style={{ backgroundColor: '#fafafa' }}>
<div><strong></strong>{scene.location}</div>
<div><strong></strong>{scene.characters.join('、')}</div>
@@ -1700,8 +1740,10 @@ export default function Outline() {
result.chapter_plans
);
totalCreated += response.chapters_created;
} catch (error: any) {
const errorMsg = error.response?.data?.detail || error.message || '未知错误';
} catch (error: unknown) {
const apiError = error as ApiError;
const err = error as Error;
const errorMsg = apiError.response?.data?.detail || err.message || '未知错误';
errors.push(`${result.outline_title}: ${errorMsg}`);
console.error(`创建大纲 ${result.outline_title} 的章节失败:`, error);
}
@@ -1766,7 +1808,7 @@ export default function Outline() {
setSSEMessage(msg);
setSSEProgress(progress);
},
onResult: (data: any) => {
onResult: (data: unknown) => {
console.log('生成完成,结果:', data);
},
onError: (error: string) => {
@@ -1784,7 +1826,7 @@ export default function Outline() {
// 刷新大纲列表
refreshOutlines();
},
onOrganizationConfirmation: (data: any) => {
onOrganizationConfirmation: (data: OrganizationConfirmationData) => {
// 处理可能的后续组织确认
console.log('收到组织确认请求:', data);
setSSEModalVisible(false);
@@ -1836,10 +1878,10 @@ export default function Outline() {
setSSEMessage(msg);
setSSEProgress(progress);
},
onResult: (data: any) => {
onResult: (data: unknown) => {
console.log('生成完成,结果:', data);
},
onOrganizationConfirmation: (data: any) => {
onOrganizationConfirmation: (data: OrganizationConfirmationData) => {
// 处理可能的后续组织确认
console.log('收到组织确认请求:', data);
setSSEModalVisible(false);
@@ -1893,7 +1935,8 @@ export default function Outline() {
// 准备请求数据,添加确认的组织
// ⚠️ 移除 confirmed_characters,避免重复创建角色
const { confirmed_characters, ...baseData } = pendingGenerateData;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { confirmed_characters: _unusedChars, ...baseData } = pendingGenerateData;
const requestData = {
...baseData,
confirmed_organizations: selectedOrganizations
@@ -1908,7 +1951,7 @@ export default function Outline() {
setSSEMessage(msg);
setSSEProgress(progress);
},
onResult: (data: any) => {
onResult: (data: unknown) => {
console.log('生成完成,结果:', data);
},
onError: (error: string) => {
@@ -1955,7 +1998,8 @@ export default function Outline() {
setSSEModalVisible(true);
// 准备请求数据,禁用自动组织引入
const { confirmed_characters, ...baseData } = pendingGenerateData;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { confirmed_characters: _unusedChars, ...baseData } = pendingGenerateData;
const requestData = {
...baseData,
enable_auto_organizations: false // 禁用自动组织引入
@@ -1970,7 +2014,7 @@ export default function Outline() {
setSSEMessage(msg);
setSSEProgress(progress);
},
onResult: (data: any) => {
onResult: (data: unknown) => {
console.log('生成完成,结果:', data);
},
onError: (error: string) => {
+17
View File
@@ -21,6 +21,23 @@ export default defineConfig({
build: {
outDir: '../backend/static',
emptyOutDir: true,
rollupOptions: {
output: {
// 手动分割代码块,将大型依赖库分离
manualChunks: {
// React 核心库
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
// Ant Design UI库(最大的依赖)
'vendor-antd': ['antd', '@ant-design/icons'],
// 其他工具库
'vendor-utils': ['axios', 'dayjs', 'zustand'],
// Diff查看器(较大的组件)
'vendor-diff': ['react-diff-viewer-continued'],
// 拖拽库
'vendor-dnd': ['react-beautiful-dnd'],
},
},
},
},
server: {
proxy: {