diff --git a/README.md b/README.md
index f6dcbf4..725fef5 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
-
+



@@ -149,7 +149,7 @@ docker run -d --name postgres \
postgres:18-alpine
# 启动后端
-python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
+python -m uvicorn app.main:app --host localhost --port 8000 --reload
```
#### 前端
diff --git a/backend/app/api/changelog.py b/backend/app/api/changelog.py
new file mode 100644
index 0000000..86adb61
--- /dev/null
+++ b/backend/app/api/changelog.py
@@ -0,0 +1,233 @@
+"""
+更新日志API
+提供GitHub提交历史的缓存和代理服务
+"""
+from fastapi import APIRouter, HTTPException, Query
+from typing import List, Optional
+import httpx
+from datetime import datetime, timedelta
+from pydantic import BaseModel
+import logging
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+# GitHub API配置
+GITHUB_API_BASE = "https://api.github.com"
+REPO_OWNER = "xiamuceer-j"
+REPO_NAME = "MuMuAINovel"
+
+# 缓存配置
+_cache = {
+ "data": None,
+ "timestamp": None,
+ "ttl": timedelta(hours=1) # 缓存1小时
+}
+
+
+class GitHubAuthor(BaseModel):
+ """GitHub作者信息"""
+ name: str
+ email: str
+ date: str
+
+
+class GitHubCommitInfo(BaseModel):
+ """GitHub提交信息"""
+ author: GitHubAuthor
+ message: str
+
+
+class GitHubUser(BaseModel):
+ """GitHub用户信息"""
+ login: str
+ avatar_url: str
+
+
+class GitHubCommit(BaseModel):
+ """GitHub提交数据"""
+ sha: str
+ commit: GitHubCommitInfo
+ html_url: str
+ author: Optional[GitHubUser] = None
+
+
+class ChangelogResponse(BaseModel):
+ """更新日志响应"""
+ commits: List[GitHubCommit]
+ cached: bool
+ cache_time: Optional[str] = None
+
+
+def is_cache_valid() -> bool:
+ """检查缓存是否有效"""
+ if _cache["data"] is None or _cache["timestamp"] is None:
+ return False
+
+ now = datetime.now()
+ cache_age = now - _cache["timestamp"]
+
+ return cache_age < _cache["ttl"]
+
+
+async def fetch_github_commits(page: int = 1, per_page: int = 30) -> List[dict]:
+ """从GitHub API获取提交历史"""
+ url = f"{GITHUB_API_BASE}/repos/{REPO_OWNER}/{REPO_NAME}/commits"
+ params = {
+ "author": REPO_OWNER,
+ "page": page,
+ "per_page": per_page
+ }
+
+ headers = {
+ "Accept": "application/vnd.github.v3+json",
+ "User-Agent": "MuMuAINovel-App"
+ }
+
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ response = await client.get(url, params=params, headers=headers)
+ response.raise_for_status()
+ return response.json()
+ except httpx.HTTPError as e:
+ logger.error(f"GitHub API请求失败: {str(e)}")
+ raise HTTPException(
+ status_code=502,
+ detail=f"获取GitHub提交历史失败: {str(e)}"
+ )
+
+
+@router.get("/changelog", response_model=ChangelogResponse)
+async def get_changelog(
+ page: int = Query(1, ge=1, description="页码"),
+ per_page: int = Query(30, ge=1, le=100, description="每页数量")
+):
+ """
+ 获取更新日志
+
+ 从GitHub获取项目的提交历史,支持缓存以减少API调用
+
+ - **page**: 页码,从1开始
+ - **per_page**: 每页返回的提交数量,最大100
+ """
+ try:
+ # 只缓存第一页
+ if page == 1 and is_cache_valid():
+ logger.info("使用缓存的更新日志")
+ return ChangelogResponse(
+ commits=_cache["data"],
+ cached=True,
+ cache_time=_cache["timestamp"].isoformat()
+ )
+
+ # 从GitHub获取数据
+ logger.info(f"从GitHub获取更新日志 (page={page}, per_page={per_page})")
+ commits_data = await fetch_github_commits(page, per_page)
+
+ # 解析数据
+ commits = []
+ for commit_data in commits_data:
+ try:
+ commit = GitHubCommit(
+ sha=commit_data["sha"],
+ commit=GitHubCommitInfo(
+ author=GitHubAuthor(
+ name=commit_data["commit"]["author"]["name"],
+ email=commit_data["commit"]["author"]["email"],
+ date=commit_data["commit"]["author"]["date"]
+ ),
+ message=commit_data["commit"]["message"]
+ ),
+ html_url=commit_data["html_url"],
+ author=GitHubUser(
+ login=commit_data["author"]["login"],
+ avatar_url=commit_data["author"]["avatar_url"]
+ ) if commit_data.get("author") else None
+ )
+ commits.append(commit)
+ except (KeyError, TypeError) as e:
+ logger.warning(f"解析提交数据失败: {str(e)}")
+ continue
+
+ # 缓存第一页数据
+ if page == 1:
+ _cache["data"] = commits
+ _cache["timestamp"] = datetime.now()
+ logger.info("已缓存更新日志")
+
+ return ChangelogResponse(
+ commits=commits,
+ cached=False,
+ cache_time=None
+ )
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"获取更新日志时发生错误: {str(e)}")
+ raise HTTPException(
+ status_code=500,
+ detail=f"获取更新日志失败: {str(e)}"
+ )
+
+
+@router.post("/changelog/refresh")
+async def refresh_changelog():
+ """
+ 刷新更新日志缓存
+
+ 强制从GitHub重新获取最新的提交历史
+ """
+ try:
+ logger.info("刷新更新日志缓存")
+
+ # 清除缓存
+ _cache["data"] = None
+ _cache["timestamp"] = None
+
+ # 重新获取
+ commits_data = await fetch_github_commits(1, 30)
+
+ # 解析数据
+ commits = []
+ for commit_data in commits_data:
+ try:
+ commit = GitHubCommit(
+ sha=commit_data["sha"],
+ commit=GitHubCommitInfo(
+ author=GitHubAuthor(
+ name=commit_data["commit"]["author"]["name"],
+ email=commit_data["commit"]["author"]["email"],
+ date=commit_data["commit"]["author"]["date"]
+ ),
+ message=commit_data["commit"]["message"]
+ ),
+ html_url=commit_data["html_url"],
+ author=GitHubUser(
+ login=commit_data["author"]["login"],
+ avatar_url=commit_data["author"]["avatar_url"]
+ ) if commit_data.get("author") else None
+ )
+ commits.append(commit)
+ except (KeyError, TypeError) as e:
+ logger.warning(f"解析提交数据失败: {str(e)}")
+ continue
+
+ # 更新缓存
+ _cache["data"] = commits
+ _cache["timestamp"] = datetime.now()
+
+ return {
+ "success": True,
+ "message": "缓存已刷新",
+ "commit_count": len(commits),
+ "cache_time": _cache["timestamp"].isoformat()
+ }
+
+ except Exception as e:
+ logger.error(f"刷新缓存时发生错误: {str(e)}")
+ raise HTTPException(
+ status_code=500,
+ detail=f"刷新缓存失败: {str(e)}"
+ )
\ No newline at end of file
diff --git a/backend/app/api/chapters.py b/backend/app/api/chapters.py
index 6e90595..ccafe99 100644
--- a/backend/app/api/chapters.py
+++ b/backend/app/api/chapters.py
@@ -104,8 +104,8 @@ async def create_chapter(
user_id = getattr(request.state, 'user_id', None)
project = await verify_project_access(chapter.project_id, user_id, db)
- # 计算字数
- word_count = len(chapter.content)
+ # 计算字数(处理content可能为None的情况)
+ word_count = len(chapter.content) if chapter.content else 0
db_chapter = Chapter(
**chapter.model_dump(),
@@ -300,9 +300,9 @@ async def update_chapter(
for field, value in update_data.items():
setattr(chapter, field, value)
- # 如果内容更新了,重新计算字数
- if "content" in update_data and chapter.content:
- new_word_count = len(chapter.content)
+ # 如果内容更新了,重新计算字数(包括清空内容的情况)
+ if "content" in update_data:
+ new_word_count = len(chapter.content) if chapter.content else 0
chapter.word_count = new_word_count
# 更新项目字数
@@ -312,6 +312,47 @@ async def update_chapter(
project = result.scalar_one_or_none()
if project:
project.current_words = project.current_words - old_word_count + new_word_count
+
+ # 如果内容被清空,清理相关数据
+ if not chapter.content or chapter.content.strip() == "":
+ chapter.status = "draft"
+
+ # 清理分析任务
+ analysis_tasks_result = await db.execute(
+ select(AnalysisTask).where(AnalysisTask.chapter_id == chapter_id)
+ )
+ analysis_tasks = analysis_tasks_result.scalars().all()
+ for task in analysis_tasks:
+ await db.delete(task)
+
+ # 清理分析结果
+ plot_analysis_result = await db.execute(
+ select(PlotAnalysis).where(PlotAnalysis.chapter_id == chapter_id)
+ )
+ plot_analyses = plot_analysis_result.scalars().all()
+ for analysis in plot_analyses:
+ await db.delete(analysis)
+
+ # 清理故事记忆(关系数据库)
+ story_memories_result = await db.execute(
+ select(StoryMemory).where(StoryMemory.chapter_id == chapter_id)
+ )
+ story_memories = story_memories_result.scalars().all()
+ for memory in story_memories:
+ await db.delete(memory)
+
+ # 清理向量数据库中的记忆数据
+ try:
+ await memory_service.delete_chapter_memories(
+ user_id=user_id,
+ project_id=chapter.project_id,
+ chapter_id=chapter_id
+ )
+ logger.info(f"✅ 已清理章节 {chapter_id[:8]} 的向量记忆数据")
+ except Exception as e:
+ logger.warning(f"⚠️ 清理向量记忆数据失败: {str(e)}")
+
+ logger.info(f"🗑️ 章节 {chapter_id[:8]} 内容已清空,已清理分析和记忆数据")
await db.commit()
await db.refresh(chapter)
@@ -954,6 +995,7 @@ async def generate_chapter_content_stream(
target_word_count = generate_request.target_word_count or 3000
enable_mcp = generate_request.enable_mcp if hasattr(generate_request, 'enable_mcp') else True
custom_model = generate_request.model if hasattr(generate_request, 'model') else None
+ temp_narrative_perspective = generate_request.narrative_perspective if hasattr(generate_request, 'narrative_perspective') else None
# 预先验证章节存在性(使用临时会话)
async for temp_db in get_db(request):
try:
@@ -1195,6 +1237,14 @@ async def generate_chapter_content_stream(
logger.warning(f"⚠️ MCP工具调用失败,降级为基础模式: {str(e)}")
yield f"data: {json.dumps({'type': 'progress', 'message': '⚠️ MCP工具暂时不可用,使用基础模式', 'progress': 32}, ensure_ascii=False)}\n\n"
+ # 🎭 确定使用的叙事人称(临时指定 > 项目默认 > 系统默认)
+ chapter_perspective = (
+ temp_narrative_perspective or
+ project.narrative_perspective or
+ '第三人称'
+ )
+ logger.info(f"📝 使用叙事人称: {chapter_perspective}")
+
# 📋 根据大纲模式构建差异化的章节大纲上下文
chapter_outline_content = ""
if outline_mode == 'one-to-one':
@@ -1245,7 +1295,7 @@ async def generate_chapter_content_stream(
title=project.title,
theme=project.theme or '',
genre=project.genre or '',
- narrative_perspective=project.narrative_perspective or '第三人称',
+ narrative_perspective=chapter_perspective,
time_period=project.world_time_period or '未设定',
location=project.world_location or '未设定',
atmosphere=project.world_atmosphere or '未设定',
@@ -1278,7 +1328,7 @@ async def generate_chapter_content_stream(
title=project.title,
theme=project.theme or '',
genre=project.genre or '',
- narrative_perspective=project.narrative_perspective or '第三人称',
+ narrative_perspective=chapter_perspective,
time_period=project.world_time_period or '未设定',
location=project.world_location or '未设定',
atmosphere=project.world_atmosphere or '未设定',
diff --git a/backend/app/api/outlines.py b/backend/app/api/outlines.py
index db9ed7b..72609e5 100644
--- a/backend/app/api/outlines.py
+++ b/backend/app/api/outlines.py
@@ -75,14 +75,30 @@ async def create_outline(
request: Request,
db: AsyncSession = Depends(get_db)
):
- """创建新的章节大纲(不自动创建章节,需通过展开功能生成章节)"""
+ """创建新的章节大纲(one-to-one模式会自动创建对应章节)"""
# 验证用户权限
user_id = getattr(request.state, 'user_id', None)
- await verify_project_access(outline.project_id, user_id, db)
+ project = await verify_project_access(outline.project_id, user_id, db)
# 创建大纲
db_outline = Outline(**outline.model_dump())
db.add(db_outline)
+ await db.flush() # 确保大纲有ID
+
+ # 如果是one-to-one模式,自动创建对应的章节
+ if project.outline_mode == 'one-to-one':
+ chapter = Chapter(
+ project_id=outline.project_id,
+ title=db_outline.title,
+ summary=db_outline.content,
+ chapter_number=db_outline.order_index,
+ sub_index=1,
+ outline_id=None, # one-to-one模式不关联outline_id
+ status='pending',
+ content=""
+ )
+ db.add(chapter)
+ logger.info(f"一对一模式:为手动创建的大纲 {db_outline.title} (序号{db_outline.order_index}) 自动创建了对应章节")
await db.commit()
await db.refresh(db_outline)
diff --git a/backend/app/main.py b/backend/app/main.py
index eed4af5..069195e 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -142,7 +142,8 @@ from app.api import (
projects, outlines, characters, chapters,
wizard_stream, relationships, organizations,
auth, users, settings, writing_styles, memories,
- mcp_plugins, admin, inspiration, prompt_templates
+ mcp_plugins, admin, inspiration, prompt_templates,
+ changelog
)
app.include_router(auth.router, prefix="/api")
@@ -162,6 +163,7 @@ app.include_router(writing_styles.router, prefix="/api")
app.include_router(memories.router) # 记忆管理API (已包含/api前缀)
app.include_router(mcp_plugins.router, prefix="/api") # MCP插件管理API
app.include_router(prompt_templates.router, prefix="/api") # 提示词模板管理API
+app.include_router(changelog.router, prefix="/api") # 更新日志API
static_dir = Path(__file__).parent.parent / "static"
if static_dir.exists():
diff --git a/backend/app/schemas/chapter.py b/backend/app/schemas/chapter.py
index 2427a10..fe72893 100644
--- a/backend/app/schemas/chapter.py
+++ b/backend/app/schemas/chapter.py
@@ -79,6 +79,7 @@ class ChapterGenerateRequest(BaseModel):
)
enable_mcp: bool = Field(True, description="是否启用MCP工具增强(搜索参考资料)")
model: Optional[str] = Field(None, description="指定使用的AI模型,不提供则使用用户默认模型")
+ narrative_perspective: Optional[str] = Field(None, description="临时人称视角:first_person/third_person/omniscient,不提供则使用项目默认")
class BatchGenerateRequest(BaseModel):
diff --git a/frontend/package.json b/frontend/package.json
index a6a66e3..a2eec9c 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
- "version": "1.0.10",
+ "version": "1.0.11",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 59741ec..40f0af6 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -23,6 +23,7 @@ import Login from './pages/Login';
import AuthCallback from './pages/AuthCallback';
import ProtectedRoute from './components/ProtectedRoute';
import AppFooter from './components/AppFooter';
+import ChangelogFloatingButton from './components/ChangelogFloatingButton';
import './App.css';
function App() {
@@ -60,6 +61,7 @@ function App() {
{/* } /> */}
+
);
diff --git a/frontend/src/components/AppFooter.tsx b/frontend/src/components/AppFooter.tsx
index 636b818..97ddb6a 100644
--- a/frontend/src/components/AppFooter.tsx
+++ b/frontend/src/components/AppFooter.tsx
@@ -291,6 +291,7 @@ export default function AppFooter() {
)}
+
);
}
\ No newline at end of file
diff --git a/frontend/src/components/ChangelogFloatingButton.tsx b/frontend/src/components/ChangelogFloatingButton.tsx
new file mode 100644
index 0000000..0cd5445
--- /dev/null
+++ b/frontend/src/components/ChangelogFloatingButton.tsx
@@ -0,0 +1,28 @@
+import { useState } from 'react';
+import { FloatButton } from 'antd';
+import { FileTextOutlined } from '@ant-design/icons';
+import ChangelogModal from './ChangelogModal';
+
+export default function ChangelogFloatingButton() {
+ const [showChangelog, setShowChangelog] = useState(false);
+
+ return (
+
+ }
+ type="primary"
+ tooltip="查看更新日志"
+ style={{
+ right: 24,
+ bottom: 100,
+ }}
+ onClick={() => setShowChangelog(true)}
+ />
+
+ setShowChangelog(false)}
+ />
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/ChangelogModal.tsx b/frontend/src/components/ChangelogModal.tsx
new file mode 100644
index 0000000..eec22dd
--- /dev/null
+++ b/frontend/src/components/ChangelogModal.tsx
@@ -0,0 +1,325 @@
+import { Modal, Timeline, Tag, Avatar, Empty, Spin, Button, Space, Tooltip } from 'antd';
+import { useState, useEffect } from 'react';
+import {
+ BugOutlined,
+ StarOutlined,
+ FileTextOutlined,
+ BgColorsOutlined,
+ ThunderboltOutlined,
+ ExperimentOutlined,
+ ToolOutlined,
+ QuestionCircleOutlined,
+ GithubOutlined,
+ ReloadOutlined,
+ ClockCircleOutlined,
+} from '@ant-design/icons';
+import {
+ fetchChangelog,
+ groupChangelogByDate,
+ getCachedChangelog,
+ cacheChangelog,
+ markChangelogFetched,
+ shouldFetchChangelog,
+ clearChangelogCache,
+ type ChangelogEntry,
+} from '../services/changelogService';
+
+interface ChangelogModalProps {
+ visible: boolean;
+ onClose: () => void;
+}
+
+// 提交类型图标和颜色配置
+const typeConfig: Record = {
+ feature: { icon: , color: 'green', label: '新功能' },
+ fix: { icon: , color: 'red', label: '修复' },
+ docs: { icon: , color: 'blue', label: '文档' },
+ style: { icon: , color: 'purple', label: '样式' },
+ refactor: { icon: , color: 'orange', label: '重构' },
+ perf: { icon: , color: 'gold', label: '性能' },
+ test: { icon: , color: 'cyan', label: '测试' },
+ chore: { icon: , color: 'default', label: '杂项' },
+ other: { icon: , color: 'default', label: '其他' },
+};
+
+export default function ChangelogModal({ visible, onClose }: ChangelogModalProps) {
+ const [changelog, setChangelog] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [page, setPage] = useState(1);
+ const [hasMore, setHasMore] = useState(true);
+
+ // 加载更新日志
+ const loadChangelog = async (pageNum: number = 1, append: boolean = false) => {
+ setLoading(true);
+ setError(null);
+
+ try {
+ // 如果是第一页,先尝试使用缓存
+ if (pageNum === 1 && !append) {
+ const cached = getCachedChangelog();
+ if (cached && cached.length > 0) {
+ setChangelog(cached);
+
+ // 后台刷新
+ if (shouldFetchChangelog()) {
+ fetchChangelog(pageNum, 30)
+ .then(entries => {
+ setChangelog(entries);
+ cacheChangelog(entries);
+ markChangelogFetched();
+ })
+ .catch(console.error);
+ }
+
+ setLoading(false);
+ return;
+ }
+ }
+
+ const entries = await fetchChangelog(pageNum, 30);
+
+ if (entries.length === 0) {
+ setHasMore(false);
+ } else {
+ if (append) {
+ setChangelog(prev => [...prev, ...entries]);
+ } else {
+ setChangelog(entries);
+ // 缓存第一页数据
+ if (pageNum === 1) {
+ cacheChangelog(entries);
+ markChangelogFetched();
+ }
+ }
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : '获取更新日志失败');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // 初始加载
+ useEffect(() => {
+ if (visible) {
+ loadChangelog(1, false);
+ setPage(1);
+ setHasMore(true);
+ }
+ }, [visible]);
+
+ // 加载更多
+ const handleLoadMore = () => {
+ const nextPage = page + 1;
+ setPage(nextPage);
+ loadChangelog(nextPage, true);
+ };
+
+ // 刷新(清除缓存并重新加载)
+ const handleRefresh = () => {
+ clearChangelogCache();
+ setPage(1);
+ setHasMore(true);
+ loadChangelog(1, false);
+ };
+
+ // 按日期分组
+ const groupedChangelog = groupChangelogByDate(changelog);
+ const sortedDates = Array.from(groupedChangelog.keys()).sort((a, b) => b.localeCompare(a));
+
+ // 格式化日期
+ const formatDate = (dateStr: string) => {
+ const date = new Date(dateStr);
+ const now = new Date();
+ const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
+
+ if (diffDays === 0) return '今天';
+ if (diffDays === 1) return '昨天';
+ if (diffDays < 7) return `${diffDays} 天前`;
+
+ return date.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' });
+ };
+
+ // 格式化时间
+ const formatTime = (dateStr: string) => {
+ return new Date(dateStr).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
+ };
+
+ return (
+
+
+ 更新日志
+
+ }
+ onClick={handleRefresh}
+ loading={loading}
+ />
+
+
+ }
+ open={visible}
+ onCancel={onClose}
+ footer={null}
+ width={800}
+ centered
+ styles={{
+ body: {
+ maxHeight: '70vh',
+ overflowY: 'auto',
+ padding: '24px',
+ },
+ }}
+ >
+ {error && (
+
+ {error}
+
+ )}
+
+ {loading && changelog.length === 0 ? (
+
+
+
+ ) : changelog.length === 0 ? (
+
+ ) : (
+ <>
+ {sortedDates.map(date => {
+ const entries = groupedChangelog.get(date) || [];
+
+ return (
+
+
+
+ {formatDate(date)}
+
+
+
+ {entries.map(entry => {
+ const config = typeConfig[entry.type] || typeConfig.other;
+
+ return (
+
+ {config.icon}
+
+ }
+ >
+
+
+
+ {config.label}
+
+ {entry.scope && (
+ {entry.scope}
+ )}
+
+ {formatTime(entry.date)}
+
+
+
+
+ {entry.message}
+
+
+
+ {entry.author.avatar && (
+
+ )}
+
+ {entry.author.username || entry.author.name}
+
+
+ 查看提交
+
+
+
+
+ );
+ })}
+
+
+ );
+ })}
+
+ {hasMore && (
+
+
+
+ )}
+
+ {!hasMore && changelog.length > 0 && (
+
+ 已显示所有更新日志
+
+ )}
+ >
+ )}
+
+
+ 💡 提示:更新日志每小时自动刷新一次,数据来源于 GitHub 提交历史
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/pages/Chapters.tsx b/frontend/src/pages/Chapters.tsx
index 67238dc..29b46da 100644
--- a/frontend/src/pages/Chapters.tsx
+++ b/frontend/src/pages/Chapters.tsx
@@ -1,9 +1,9 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, Tooltip, InputNumber, Alert, Radio, Descriptions, Collapse, Popconfirm, FloatButton } from 'antd';
-import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined, RocketOutlined, StopOutlined, InfoCircleOutlined, CaretRightOutlined, DeleteOutlined, BookOutlined, FormOutlined } from '@ant-design/icons';
+import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined, RocketOutlined, StopOutlined, InfoCircleOutlined, CaretRightOutlined, DeleteOutlined, BookOutlined, FormOutlined, PlusOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { useChapterSync } from '../store/hooks';
-import { projectApi, writingStyleApi } from '../services/api';
+import { projectApi, writingStyleApi, chapterApi } from '../services/api';
import type { Chapter, ChapterUpdate, ApiError, WritingStyle, AnalysisTask, ExpansionPlanData } from '../types';
import ChapterAnalysis from '../components/ChapterAnalysis';
import ExpansionPlanEditor from '../components/ExpansionPlanEditor';
@@ -30,6 +30,7 @@ export default function Chapters() {
const [availableModels, setAvailableModels] = useState>([]);
const [selectedModel, setSelectedModel] = useState();
const [batchSelectedModel, setBatchSelectedModel] = useState(); // 批量生成的模型选择
+ const [temporaryNarrativePerspective, setTemporaryNarrativePerspective] = useState(); // 临时人称选择
const [analysisVisible, setAnalysisVisible] = useState(false);
const [analysisChapterId, setAnalysisChapterId] = useState(null);
// 分析任务状态管理
@@ -50,6 +51,7 @@ export default function Chapters() {
const [batchGenerating, setBatchGenerating] = useState(false);
const [batchTaskId, setBatchTaskId] = useState(null);
const [batchForm] = Form.useForm();
+ const [manualCreateForm] = Form.useForm();
const [batchProgress, setBatchProgress] = useState<{
status: string;
total: number;
@@ -260,6 +262,16 @@ export default function Chapters() {
if (!currentProject) return null;
+ // 获取人称的中文显示文本
+ const getNarrativePerspectiveText = (perspective?: string): string => {
+ const texts: Record = {
+ 'first_person': '第一人称(我)',
+ 'third_person': '第三人称(他/她)',
+ 'omniscient': '全知视角',
+ };
+ return texts[perspective || ''] || '第三人称(默认)';
+ };
+
const canGenerateChapter = (chapter: Chapter): boolean => {
if (chapter.chapter_number === 1) {
return true;
@@ -328,6 +340,7 @@ export default function Chapters() {
content: chapter.content,
});
setEditingId(id);
+ setTemporaryNarrativePerspective(undefined); // 重置人称选择
setIsEditorOpen(true);
// 打开编辑窗口时加载模型列表
loadAvailableModels();
@@ -379,7 +392,8 @@ export default function Chapters() {
setSingleChapterProgress(progressValue);
setSingleChapterProgressMessage(progressMsg);
},
- selectedModel // 传递选中的模型
+ selectedModel, // 传递选中的模型
+ temporaryNarrativePerspective // 传递临时人称参数
);
message.success('AI创作成功,正在分析章节内容...');
@@ -692,6 +706,12 @@ export default function Chapters() {
current_chapter_number: status.current_chapter_number,
});
+ // 每次轮询时刷新章节列表和分析状态,实时显示新生成的章节和分析进度
+ if (status.completed > 0) {
+ refreshChapters();
+ loadAnalysisTasks();
+ }
+
// 任务完成或失败,停止轮询
if (status.status === 'completed' || status.status === 'failed' || status.status === 'cancelled') {
if (batchPollingIntervalRef.current) {
@@ -701,11 +721,12 @@ export default function Chapters() {
setBatchGenerating(false);
+ // 立即刷新章节列表和分析任务状态(在显示消息前)
+ await refreshChapters();
+ await loadAnalysisTasks();
+
if (status.status === 'completed') {
message.success(`批量生成完成!成功生成 ${status.completed} 章`);
- // 刷新章节列表
- refreshChapters();
- loadAnalysisTasks();
} else if (status.status === 'failed') {
message.error(`批量生成失败:${status.error_message || '未知错误'}`);
} else if (status.status === 'cancelled') {
@@ -745,6 +766,10 @@ export default function Chapters() {
}
message.success('批量生成已取消');
+
+ // 取消后立即刷新章节列表和分析任务,显示已生成的章节
+ await refreshChapters();
+ await loadAnalysisTasks();
} catch (error: any) {
message.error('取消失败:' + (error.message || '未知错误'));
}
@@ -790,6 +815,200 @@ export default function Chapters() {
setBatchGenerateVisible(true);
};
+ // 手动创建章节(仅one-to-many模式)
+ const showManualCreateChapterModal = () => {
+ // 计算下一个章节号
+ const nextChapterNumber = chapters.length > 0
+ ? Math.max(...chapters.map(c => c.chapter_number)) + 1
+ : 1;
+
+ Modal.confirm({
+ title: '手动创建章节',
+ width: 600,
+ centered: true,
+ content: (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+ okText: '创建',
+ cancelText: '取消',
+ onOk: async () => {
+ const values = await manualCreateForm.validateFields();
+
+ // 检查章节序号是否已存在
+ const conflictChapter = chapters.find(
+ ch => ch.chapter_number === values.chapter_number
+ );
+
+ if (conflictChapter) {
+ // 显示冲突提示Modal
+ Modal.confirm({
+ title: '章节序号冲突',
+ icon: ,
+ width: 500,
+ centered: true,
+ content: (
+
+
+ 第 {values.chapter_number} 章已存在:
+
+
+
标题:{conflictChapter.title}
+
状态:{getStatusText(conflictChapter.status)}
+
字数:{conflictChapter.word_count || 0}字
+ {conflictChapter.outline_title && (
+
所属大纲:{conflictChapter.outline_title}
+ )}
+
+
+ ⚠️ 是否删除旧章节并创建新章节?
+
+
+ 删除后将无法恢复,章节内容和分析结果都将被删除。
+
+
+ ),
+ okText: '删除并创建',
+ okButtonProps: { danger: true },
+ cancelText: '取消',
+ onOk: async () => {
+ try {
+ // 先删除旧章节
+ await handleDeleteChapter(conflictChapter.id);
+
+ // 等待一小段时间确保删除完成
+ await new Promise(resolve => setTimeout(resolve, 300));
+
+ // 创建新章节
+ await chapterApi.createChapter({
+ project_id: currentProject.id,
+ ...values
+ });
+
+ message.success('已删除旧章节并创建新章节');
+ await refreshChapters();
+
+ // 刷新项目信息以更新字数统计
+ const updatedProject = await projectApi.getProject(currentProject.id);
+ setCurrentProject(updatedProject);
+
+ manualCreateForm.resetFields();
+ } catch (error: any) {
+ message.error('操作失败:' + (error.message || '未知错误'));
+ throw error;
+ }
+ }
+ });
+
+ // 阻止外层Modal关闭
+ return Promise.reject();
+ }
+
+ // 没有冲突,直接创建
+ try {
+ await chapterApi.createChapter({
+ project_id: currentProject.id,
+ ...values
+ });
+ message.success('章节创建成功');
+ await refreshChapters();
+
+ // 刷新项目信息以更新字数统计
+ const updatedProject = await projectApi.getProject(currentProject.id);
+ setCurrentProject(updatedProject);
+
+ manualCreateForm.resetFields();
+ } catch (error: any) {
+ message.error('创建失败:' + (error.message || '未知错误'));
+ throw error;
+ }
+ }
+ });
+ };
+
// 渲染分析状态标签
const renderAnalysisStatus = (chapterId: string) => {
const task = analysisTasksMap[chapterId];
@@ -1077,21 +1296,9 @@ export default function Chapters() {
// 打开规划编辑器
const handleOpenPlanEditor = (chapter: Chapter) => {
- // 检查是否有规划数据
- if (!chapter.expansion_plan) {
- message.warning('该章节暂无规划信息');
- return;
- }
-
- try {
- // 尝试解析JSON,验证数据有效性
- JSON.parse(chapter.expansion_plan);
- setEditingPlanChapter(chapter);
- setPlanEditorVisible(true);
- } catch (error) {
- console.error('规划数据格式错误:', error);
- message.error('规划数据格式错误,无法编辑');
- }
+ // 直接打开编辑器,如果没有规划数据则创建新的
+ setEditingPlanChapter(chapter);
+ setPlanEditorVisible(true);
};
// 保存规划信息
@@ -1157,6 +1364,16 @@ export default function Chapters() {
}}>
章节管理
+ {currentProject.outline_mode === 'one-to-many' && (
+ }
+ onClick={showManualCreateChapterModal}
+ block={isMobile}
+ size={isMobile ? 'middle' : 'middle'}
+ >
+ 手动创建
+
+ )}
}
@@ -1469,8 +1686,8 @@ export default function Chapters() {
)}
- {item.expansion_plan && (
-
+
+ {item.expansion_plan && (
-
- {
- e.stopPropagation();
- handleOpenPlanEditor(item);
- }}
- />
-
-
- )}
+ )}
+
+ {
+ e.stopPropagation();
+ handleOpenPlanEditor(item);
+ }}
+ />
+
+
}
@@ -1676,16 +1893,15 @@ export default function Chapters() {
footer={null}
>
-
-
+
+
{editingId && (() => {
const currentChapter = chapters.find(c => c.id === editingId);
@@ -1701,10 +1917,9 @@ export default function Chapters() {
loading={isContinuing}
disabled={!canGenerate}
danger={!canGenerate}
- size="large"
style={{ fontWeight: 'bold' }}
>
- {isMobile ? 'AI创作' : 'AI创作章节内容'}
+ {isMobile ? 'AI' : 'AI创作'}
);
@@ -1712,82 +1927,106 @@ export default function Chapters() {
-
-
- {!selectedStyleId && (
-
- 请选择写作风格
-
- )}
-
+
+ {!selectedStyleId && (
+ 请选择写作风格
+ )}
+
-
- setTargetWordCount(value || 3000)}
- size="large"
- disabled={isGenerating}
- style={{ width: '100%' }}
- formatter={(value) => `${value} 字`}
- parser={(value) => value?.replace(' 字', '') as any}
- />
-
- 建议范围:500-10000字,默认3000字
-
-
-
-
-
-
- {selectedModel ? `当前默认模型: ${availableModels.find(m => m.value === selectedModel)?.label || selectedModel}` : '加载模型列表中...'}
-
-
+
+ {temporaryNarrativePerspective && (
+
+ ✓ {getNarrativePerspectiveText(temporaryNarrativePerspective)}
+
+ )}
+
+
+
+ {/* 第二行:目标字数 + AI模型 */}
+
+
+ setTargetWordCount(value || 3000)}
+ disabled={isGenerating}
+ style={{ width: '100%' }}
+ formatter={(value) => `${value} 字`}
+ parser={(value) => value?.replace(' 字', '') as any}
+ />
+
+
+
+
+
+