update:1.新增手动创建大纲和章节,编写章节规划内容

2.新增项目更新日志页面,同步GitHub更新日志
3.新增章节内容生成时,选择本次生成人称
4.修复1 - N模式下,章节标题无法修改的问题
5.修复章节管理界面,批量生成后没有更新页面内容和状态
This commit is contained in:
xiamuceer
2025-12-06 14:08:20 +08:00
parent 187feac671
commit f831d07864
15 changed files with 1398 additions and 131 deletions
+2 -2
View File
@@ -2,7 +2,7 @@
<div align="center">
![Version](https://img.shields.io/badge/version-1.0.10-blue.svg)
![Version](https://img.shields.io/badge/version-1.0.11-blue.svg)
![Python](https://img.shields.io/badge/python-3.11-blue.svg)
![FastAPI](https://img.shields.io/badge/FastAPI-0.109.0-green.svg)
![React](https://img.shields.io/badge/react-18.3.1-blue.svg)
@@ -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
```
#### 前端
+233
View File
@@ -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)}"
)
+57 -7
View File
@@ -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
# 更新项目字数
@@ -313,6 +313,47 @@ async def update_chapter(
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)
return 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 '未设定',
+18 -2
View File
@@ -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)
+3 -1
View File
@@ -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():
+1
View File
@@ -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):
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.0.10",
"version": "1.0.11",
"type": "module",
"scripts": {
"dev": "vite",
+2
View File
@@ -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() {
{/* <Route path="polish" element={<Polish />} /> */}
</Route>
</Routes>
<ChangelogFloatingButton />
</BrowserRouter>
</ConfigProvider>
);
+1
View File
@@ -291,6 +291,7 @@ export default function AppFooter() {
</Space>
)}
</div>
</div>
);
}
@@ -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 (
<div style={{ position: 'fixed', zIndex: 9999 }}>
<FloatButton
icon={<FileTextOutlined />}
type="primary"
tooltip="查看更新日志"
style={{
right: 24,
bottom: 100,
}}
onClick={() => setShowChangelog(true)}
/>
<ChangelogModal
visible={showChangelog}
onClose={() => setShowChangelog(false)}
/>
</div>
);
}
+325
View File
@@ -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<ChangelogEntry['type'], { icon: React.ReactNode; color: string; label: string }> = {
feature: { icon: <StarOutlined />, color: 'green', label: '新功能' },
fix: { icon: <BugOutlined />, color: 'red', label: '修复' },
docs: { icon: <FileTextOutlined />, color: 'blue', label: '文档' },
style: { icon: <BgColorsOutlined />, color: 'purple', label: '样式' },
refactor: { icon: <ThunderboltOutlined />, color: 'orange', label: '重构' },
perf: { icon: <ThunderboltOutlined />, color: 'gold', label: '性能' },
test: { icon: <ExperimentOutlined />, color: 'cyan', label: '测试' },
chore: { icon: <ToolOutlined />, color: 'default', label: '杂项' },
other: { icon: <QuestionCircleOutlined />, color: 'default', label: '其他' },
};
export default function ChangelogModal({ visible, onClose }: ChangelogModalProps) {
const [changelog, setChangelog] = useState<ChangelogEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<Modal
title={
<Space>
<GithubOutlined />
<span></span>
<Tooltip title="刷新">
<Button
type="text"
size="small"
icon={<ReloadOutlined />}
onClick={handleRefresh}
loading={loading}
/>
</Tooltip>
</Space>
}
open={visible}
onCancel={onClose}
footer={null}
width={800}
centered
styles={{
body: {
maxHeight: '70vh',
overflowY: 'auto',
padding: '24px',
},
}}
>
{error && (
<div style={{
padding: '16px',
marginBottom: '16px',
background: '#fff2e8',
border: '1px solid #ffbb96',
borderRadius: '4px',
color: '#d4380d',
}}>
{error}
</div>
)}
{loading && changelog.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<Spin size="large" tip="加载更新日志中..." />
</div>
) : changelog.length === 0 ? (
<Empty description="暂无更新日志" />
) : (
<>
{sortedDates.map(date => {
const entries = groupedChangelog.get(date) || [];
return (
<div key={date} style={{ marginBottom: '32px' }}>
<div style={{
fontSize: '16px',
fontWeight: 600,
color: '#1890ff',
marginBottom: '16px',
paddingBottom: '8px',
borderBottom: '2px solid #e8e8e8',
}}>
<ClockCircleOutlined style={{ marginRight: '8px' }} />
{formatDate(date)}
</div>
<Timeline>
{entries.map(entry => {
const config = typeConfig[entry.type] || typeConfig.other;
return (
<Timeline.Item
key={entry.id}
dot={
<div style={{
width: '24px',
height: '24px',
borderRadius: '50%',
background: '#fff',
border: `2px solid ${config.color === 'default' ? '#d9d9d9' : config.color}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
}}>
{config.icon}
</div>
}
>
<div style={{ marginLeft: '8px' }}>
<Space size="small" wrap>
<Tag color={config.color} icon={config.icon}>
{config.label}
</Tag>
{entry.scope && (
<Tag color="blue">{entry.scope}</Tag>
)}
<span style={{ color: '#999', fontSize: '12px' }}>
{formatTime(entry.date)}
</span>
</Space>
<div style={{
marginTop: '8px',
fontSize: '14px',
lineHeight: '1.6',
color: '#333',
}}>
{entry.message}
</div>
<Space size="small" style={{ marginTop: '8px' }}>
{entry.author.avatar && (
<Avatar size="small" src={entry.author.avatar} />
)}
<span style={{ color: '#666', fontSize: '13px' }}>
{entry.author.username || entry.author.name}
</span>
<a
href={entry.commitUrl}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: '12px' }}
>
</a>
</Space>
</div>
</Timeline.Item>
);
})}
</Timeline>
</div>
);
})}
{hasMore && (
<div style={{ textAlign: 'center', marginTop: '24px' }}>
<Button
type="default"
onClick={handleLoadMore}
loading={loading}
>
</Button>
</div>
)}
{!hasMore && changelog.length > 0 && (
<div style={{
textAlign: 'center',
color: '#999',
padding: '16px 0',
fontSize: '14px',
}}>
</div>
)}
</>
)}
<div style={{
marginTop: '24px',
padding: '12px',
background: '#f0f5ff',
borderRadius: '4px',
border: '1px solid #adc6ff',
fontSize: '13px',
color: '#1d39c4',
}}>
💡 GitHub
</div>
</Modal>
);
}
+354 -115
View File
@@ -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<Array<{value: string, label: string}>>([]);
const [selectedModel, setSelectedModel] = useState<string | undefined>();
const [batchSelectedModel, setBatchSelectedModel] = useState<string | undefined>(); // 批量生成的模型选择
const [temporaryNarrativePerspective, setTemporaryNarrativePerspective] = useState<string | undefined>(); // 临时人称选择
const [analysisVisible, setAnalysisVisible] = useState(false);
const [analysisChapterId, setAnalysisChapterId] = useState<string | null>(null);
// 分析任务状态管理
@@ -50,6 +51,7 @@ export default function Chapters() {
const [batchGenerating, setBatchGenerating] = useState(false);
const [batchTaskId, setBatchTaskId] = useState<string | null>(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<string, string> = {
'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: (
<Form
form={manualCreateForm}
layout="vertical"
initialValues={{
chapter_number: nextChapterNumber,
status: 'draft'
}}
style={{ marginTop: 16 }}
>
<Form.Item
label="章节序号"
name="chapter_number"
rules={[{ required: true, message: '请输入章节序号' }]}
tooltip="建议按顺序创建章节,确保内容连贯性"
>
<InputNumber min={1} style={{ width: '100%' }} placeholder="自动计算的下一个序号" />
</Form.Item>
<Form.Item
label="章节标题"
name="title"
rules={[{ required: true, message: '请输入标题' }]}
>
<Input placeholder="例如:第一章 初遇" />
</Form.Item>
<Form.Item
label="关联大纲"
name="outline_id"
rules={[{ required: true, message: '请选择关联的大纲' }]}
tooltip="one-to-many模式下,章节必须关联到大纲"
>
<Select placeholder="请选择所属大纲">
{sortedChapters.length > 0 && (() => {
// 从现有章节中提取大纲信息
const outlineMap = new Map();
sortedChapters.forEach(ch => {
if (ch.outline_id && ch.outline_title) {
outlineMap.set(ch.outline_id, {
id: ch.outline_id,
title: ch.outline_title,
order: ch.outline_order || 0
});
}
});
const uniqueOutlines = Array.from(outlineMap.values())
.sort((a, b) => a.order - b.order);
return uniqueOutlines.map(outline => (
<Select.Option key={outline.id} value={outline.id}>
{outline.order}{outline.title}
</Select.Option>
));
})()}
</Select>
</Form.Item>
<Form.Item
label="章节摘要(可选)"
name="summary"
tooltip="简要描述本章的主要内容和情节发展"
>
<TextArea
rows={4}
placeholder="简要描述本章内容..."
/>
</Form.Item>
<Form.Item
label="状态"
name="status"
>
<Select>
<Select.Option value="draft">稿</Select.Option>
<Select.Option value="writing"></Select.Option>
<Select.Option value="completed"></Select.Option>
</Select>
</Form.Item>
</Form>
),
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: <InfoCircleOutlined style={{ color: '#ff4d4f' }} />,
width: 500,
centered: true,
content: (
<div>
<p style={{ marginBottom: 12 }}>
<strong>{values.chapter_number}</strong>
</p>
<div style={{
padding: 12,
background: '#fff7e6',
borderRadius: 4,
border: '1px solid #ffd591',
marginBottom: 12
}}>
<div><strong></strong>{conflictChapter.title}</div>
<div><strong></strong>{getStatusText(conflictChapter.status)}</div>
<div><strong></strong>{conflictChapter.word_count || 0}</div>
{conflictChapter.outline_title && (
<div><strong></strong>{conflictChapter.outline_title}</div>
)}
</div>
<p style={{ color: '#ff4d4f', marginBottom: 8 }}>
</p>
<p style={{ fontSize: 12, color: '#666', marginBottom: 0 }}>
</p>
</div>
),
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() {
}}>
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}></h2>
<Space direction={isMobile ? 'vertical' : 'horizontal'} style={{ width: isMobile ? '100%' : 'auto' }}>
{currentProject.outline_mode === 'one-to-many' && (
<Button
icon={<PlusOutlined />}
onClick={showManualCreateChapterModal}
block={isMobile}
size={isMobile ? 'middle' : 'middle'}
>
</Button>
)}
<Button
type="primary"
icon={<RocketOutlined />}
@@ -1469,8 +1686,8 @@ export default function Chapters() {
</Tag>
</Tooltip>
)}
{item.expansion_plan && (
<Space size={4}>
<Space size={4}>
{item.expansion_plan && (
<Tooltip title="查看展开详情">
<InfoCircleOutlined
style={{ color: '#1890ff', cursor: 'pointer', fontSize: 16 }}
@@ -1480,17 +1697,17 @@ export default function Chapters() {
}}
/>
</Tooltip>
<Tooltip title="编辑规划信息">
<FormOutlined
style={{ color: '#52c41a', cursor: 'pointer', fontSize: 16 }}
onClick={(e) => {
e.stopPropagation();
handleOpenPlanEditor(item);
}}
/>
</Tooltip>
</Space>
)}
)}
<Tooltip title={item.expansion_plan ? "编辑规划信息" : "创建规划信息"}>
<FormOutlined
style={{ color: '#52c41a', cursor: 'pointer', fontSize: 16 }}
onClick={(e) => {
e.stopPropagation();
handleOpenPlanEditor(item);
}}
/>
</Tooltip>
</Space>
</Space>
</div>
}
@@ -1676,16 +1893,15 @@ export default function Chapters() {
footer={null}
>
<Form form={editorForm} layout="vertical" onFinish={handleEditorSubmit}>
{/* 章节标题和AI创作按钮 */}
<Form.Item
label="章节标题"
tooltip="章节标题由大纲统一管理,建议在大纲页面修改以保持一致性"
tooltip="(1-1模式请在大纲修改,1-N模式请使用修改按钮编辑)"
style={{ marginBottom: isMobile ? 16 : 12 }}
>
<Space.Compact style={{ width: '100%' }}>
<Form.Item
name="title"
noStyle
>
<Input size="large" disabled style={{ flex: 1 }} />
<Form.Item name="title" noStyle>
<Input disabled style={{ flex: 1 }} />
</Form.Item>
{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创作'}
</Button>
</Tooltip>
);
@@ -1712,82 +1927,106 @@ export default function Chapters() {
</Space.Compact>
</Form.Item>
<Form.Item
label="写作风格"
tooltip="选择AI创作时使用的写作风格,可在写作风格菜单中管理"
required
>
<Select
placeholder="请选择写作风格"
value={selectedStyleId}
onChange={setSelectedStyleId}
size="large"
disabled={isGenerating}
style={{ width: '100%' }}
status={!selectedStyleId ? 'error' : undefined}
{/* 第一行:写作风格 + 叙事角度 */}
<div style={{
display: isMobile ? 'block' : 'flex',
gap: isMobile ? 0 : 16,
marginBottom: isMobile ? 0 : 12
}}>
<Form.Item
label="写作风格"
tooltip="选择AI创作时使用的写作风格"
required
style={{ flex: 1, marginBottom: isMobile ? 16 : 0 }}
>
{writingStyles.map(style => (
<Select.Option key={style.id} value={style.id}>
{style.name}
{style.is_default && ' (默认)'}
{style.description && ` - ${style.description}`}
</Select.Option>
))}
</Select>
{!selectedStyleId && (
<div style={{ color: '#ff4d4f', fontSize: 12, marginTop: 4 }}>
</div>
)}
</Form.Item>
<Select
placeholder="请选择写作风格"
value={selectedStyleId}
onChange={setSelectedStyleId}
disabled={isGenerating}
status={!selectedStyleId ? 'error' : undefined}
>
{writingStyles.map(style => (
<Select.Option key={style.id} value={style.id}>
{style.name}{style.is_default && ' (默认)'}
</Select.Option>
))}
</Select>
{!selectedStyleId && (
<div style={{ color: '#ff4d4f', fontSize: 12, marginTop: 4 }}></div>
)}
</Form.Item>
<Form.Item
label="目标字数"
tooltip="AI生成章节时的目标字数,实际生成字数可能略有偏差"
>
<InputNumber
min={500}
max={10000}
step={100}
value={targetWordCount}
onChange={(value) => setTargetWordCount(value || 3000)}
size="large"
disabled={isGenerating}
style={{ width: '100%' }}
formatter={(value) => `${value}`}
parser={(value) => value?.replace(' 字', '') as any}
/>
<div style={{ color: '#666', fontSize: 12, marginTop: 4 }}>
500-100003000
</div>
</Form.Item>
<Form.Item
label="AI模型"
tooltip="选择用于生成章节内容的AI模型,不选择则使用默认模型"
>
<Select
placeholder={selectedModel ? `默认: ${availableModels.find(m => m.value === selectedModel)?.label || selectedModel}` : "使用默认模型"}
value={selectedModel}
onChange={setSelectedModel}
size="large"
allowClear
disabled={isGenerating}
style={{ width: '100%' }}
showSearch
optionFilterProp="label"
<Form.Item
label="叙事角度"
tooltip="第一人称(我)代入感强;第三人称(他/她)更客观;全知视角洞悉一切"
style={{ flex: 1, marginBottom: isMobile ? 16 : 0 }}
>
{availableModels.map(model => (
<Select.Option key={model.value} value={model.value} label={model.label}>
{model.label}
{model.value === selectedModel && ' (默认)'}
</Select.Option>
))}
</Select>
<div style={{ color: '#666', fontSize: 12, marginTop: 4 }}>
{selectedModel ? `当前默认模型: ${availableModels.find(m => m.value === selectedModel)?.label || selectedModel}` : '加载模型列表中...'}
</div>
</Form.Item>
<Select
placeholder={`项目默认: ${getNarrativePerspectiveText(currentProject?.narrative_perspective)}`}
value={temporaryNarrativePerspective}
onChange={setTemporaryNarrativePerspective}
allowClear
disabled={isGenerating}
>
<Select.Option value="first_person">()</Select.Option>
<Select.Option value="third_person">(/)</Select.Option>
<Select.Option value="omniscient"></Select.Option>
</Select>
{temporaryNarrativePerspective && (
<div style={{ color: '#52c41a', fontSize: 12, marginTop: 4 }}>
{getNarrativePerspectiveText(temporaryNarrativePerspective)}
</div>
)}
</Form.Item>
</div>
{/* 第二行:目标字数 + AI模型 */}
<div style={{
display: isMobile ? 'block' : 'flex',
gap: isMobile ? 0 : 16,
marginBottom: isMobile ? 16 : 12
}}>
<Form.Item
label="目标字数"
tooltip="AI生成章节时的目标字数,实际可能略有偏差"
style={{ flex: 1, marginBottom: isMobile ? 16 : 0 }}
>
<InputNumber
min={500}
max={10000}
step={100}
value={targetWordCount}
onChange={(value) => setTargetWordCount(value || 3000)}
disabled={isGenerating}
style={{ width: '100%' }}
formatter={(value) => `${value}`}
parser={(value) => value?.replace(' 字', '') as any}
/>
</Form.Item>
<Form.Item
label="AI模型"
tooltip="选择用于生成章节内容的AI模型,不选择则使用默认模型"
style={{ flex: 1, marginBottom: isMobile ? 16 : 0 }}
>
<Select
placeholder={selectedModel ? `默认: ${availableModels.find(m => m.value === selectedModel)?.label || selectedModel}` : "使用默认模型"}
value={selectedModel}
onChange={setSelectedModel}
allowClear
disabled={isGenerating}
showSearch
optionFilterProp="label"
>
{availableModels.map(model => (
<Select.Option key={model.value} value={model.value} label={model.label}>
{model.label}
</Select.Option>
))}
</Select>
</Form.Item>
</div>
<Form.Item label="章节内容" name="content">
<TextArea
+113 -1
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { Button, List, Modal, Form, Input, message, Empty, Space, Popconfirm, Card, Select, Radio, Tag, InputNumber, Tooltip, Tabs } from 'antd';
import { EditOutlined, DeleteOutlined, ThunderboltOutlined, BranchesOutlined, AppstoreAddOutlined, CheckCircleOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
import { EditOutlined, DeleteOutlined, ThunderboltOutlined, BranchesOutlined, AppstoreAddOutlined, CheckCircleOutlined, ExclamationCircleOutlined, PlusOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { useOutlineSync } from '../store/hooks';
import { cardStyles } from '../components/CardStyles';
@@ -18,6 +18,7 @@ export default function Outline() {
const [generateForm] = Form.useForm();
const [expansionForm] = Form.useForm();
const [batchExpansionForm] = Form.useForm();
const [manualCreateForm] = Form.useForm();
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
const [isExpanding, setIsExpanding] = useState(false);
@@ -444,6 +445,110 @@ export default function Outline() {
});
};
// 手动创建大纲
const showManualCreateOutlineModal = () => {
const nextOrderIndex = outlines.length > 0
? Math.max(...outlines.map(o => o.order_index)) + 1
: 1;
Modal.confirm({
title: '手动创建大纲',
width: 600,
centered: true,
content: (
<Form
form={manualCreateForm}
layout="vertical"
initialValues={{ order_index: nextOrderIndex }}
style={{ marginTop: 16 }}
>
<Form.Item
label="大纲序号"
name="order_index"
rules={[{ required: true, message: '请输入序号' }]}
tooltip={currentProject?.outline_mode === 'one-to-one' ? '在传统模式下,序号即章节编号' : '在细化模式下,序号为卷数'}
>
<InputNumber min={1} style={{ width: '100%' }} placeholder="自动计算的下一个序号" />
</Form.Item>
<Form.Item
label="大纲标题"
name="title"
rules={[{ required: true, message: '请输入标题' }]}
>
<Input placeholder={currentProject?.outline_mode === 'one-to-one' ? '例如:第一章 初入江湖' : '例如:第一卷 初入江湖'} />
</Form.Item>
<Form.Item
label="大纲内容"
name="content"
rules={[{ required: true, message: '请输入内容' }]}
>
<TextArea
rows={6}
placeholder="描述本章/卷的主要情节和发展方向..."
/>
</Form.Item>
</Form>
),
okText: '创建',
cancelText: '取消',
onOk: async () => {
const values = await manualCreateForm.validateFields();
// 校验序号是否重复
const existingOutline = outlines.find(o => o.order_index === values.order_index);
if (existingOutline) {
Modal.warning({
title: '序号冲突',
content: (
<div>
<p> <strong>{values.order_index}</strong> 使</p>
<div style={{
padding: 12,
background: '#fff7e6',
borderRadius: 4,
border: '1px solid #ffd591',
marginTop: 8
}}>
<div style={{ fontWeight: 500, color: '#fa8c16' }}>
{currentProject?.outline_mode === 'one-to-one'
? `${existingOutline.order_index}`
: `${existingOutline.order_index}`
}{existingOutline.title}
</div>
</div>
<p style={{ marginTop: 12, color: '#666' }}>
💡 使 <strong>{nextOrderIndex}</strong>使
</p>
</div>
),
okText: '我知道了',
centered: true
});
throw new Error('序号重复');
}
try {
await outlineApi.createOutline({
project_id: currentProject.id,
...values
});
message.success('大纲创建成功');
await refreshOutlines();
manualCreateForm.resetFields();
} catch (error: any) {
if (error.message === '序号重复') {
// 序号重复错误已经显示了Modal,不需要再显示message
throw error;
}
message.error('创建失败:' + (error.message || '未知错误'));
throw error;
}
}
});
};
// 展开单个大纲为多章 - 使用SSE显示进度
const handleExpandOutline = async (outlineId: string, outlineTitle: string) => {
try {
@@ -1459,6 +1564,13 @@ export default function Outline() {
)}
</div>
<Space size="small" wrap={isMobile}>
<Button
icon={<PlusOutlined />}
onClick={showManualCreateOutlineModal}
block={isMobile}
>
</Button>
<Button
type="primary"
icon={<ThunderboltOutlined />}
+256
View File
@@ -0,0 +1,256 @@
/**
* GitHub 提交日志获取服务
* 用于从 GitHub API 获取项目的提交历史并转换为更新日志
*/
export interface GitHubCommit {
sha: string;
commit: {
author: {
name: string;
email: string;
date: string;
};
message: string;
};
html_url: string;
author: {
login: string;
avatar_url: string;
} | null;
}
export interface ChangelogEntry {
id: string;
date: string;
version?: string;
author: {
name: string;
avatar?: string;
username?: string;
};
message: string;
commitUrl: string;
type: 'feature' | 'fix' | 'docs' | 'style' | 'refactor' | 'perf' | 'test' | 'chore' | 'other';
scope?: string;
}
const GITHUB_API_BASE = 'https://api.github.com';
const REPO_OWNER = 'xiamuceer-j';
const REPO_NAME = 'MuMuAINovel';
/**
* 从提交信息中解析类型和作用域
* 支持常见的提交信息格式:
* - type: message
* - type(scope): message
* - [type] message
*/
function parseCommitType(message: string): { type: ChangelogEntry['type']; scope?: string; cleanMessage: string } {
const lowerMessage = message.toLowerCase();
// 第一优先级:精确匹配 update: 开头(在正则之前检查)
if (lowerMessage.startsWith('update:')) {
const cleanMsg = message.replace(/^update:\s*/i, '');
return { type: 'feature', cleanMessage: cleanMsg };
}
// 第二优先级:匹配标准 conventional commits 格式 type: message 或 type(scope): message
const conventionalMatch = message.match(/^(feat|feature|fix|docs|style|refactor|perf|test|chore)(?:\(([^)]+)\))?\s*:\s*(.+)/i);
if (conventionalMatch) {
const typeStr = conventionalMatch[1].toLowerCase();
const mappedType = typeStr === 'feature' ? 'feature' : typeStr as ChangelogEntry['type'];
return {
type: mappedType,
scope: conventionalMatch[2],
cleanMessage: conventionalMatch[3],
};
}
// 第三优先级:匹配 [type] message 格式
const bracketMatch = message.match(/^\[(feat|feature|fix|docs|style|refactor|perf|test|chore|update)\]\s*(.+)/i);
if (bracketMatch) {
const typeStr = bracketMatch[1].toLowerCase();
const mappedType = (typeStr === 'update' || typeStr === 'feature') ? 'feature' : typeStr as ChangelogEntry['type'];
return {
type: mappedType,
cleanMessage: bracketMatch[2],
};
}
// 第四优先级:通过前缀精确匹配(避免误判)
if (lowerMessage.startsWith('fix:')|| lowerMessage.startsWith('fix')) {
const cleanMsg = message.replace(/^fix:\s*/i, '');
return { type: 'fix', cleanMessage: cleanMsg };
}
if (lowerMessage.startsWith('perf:')) {
const cleanMsg = message.replace(/^perf:\s*/i, '');
return { type: 'perf', cleanMessage: cleanMsg };
}
if (lowerMessage.startsWith('docs:')) {
const cleanMsg = message.replace(/^docs:\s*/i, '');
return { type: 'docs', cleanMessage: cleanMsg };
}
if (lowerMessage.startsWith('feat:') || lowerMessage.startsWith('feature:')) {
const cleanMsg = message.replace(/^(feat|feature):\s*/i, '');
return { type: 'feature', cleanMessage: cleanMsg };
}
// 第五优先级:关键词模糊匹配(仅当前面都不匹配时)
if (lowerMessage.includes('修复') || lowerMessage.includes('fix')) {
return { type: 'fix', cleanMessage: message };
}
if (lowerMessage.includes('优化') || lowerMessage.includes('perf')) {
return { type: 'perf', cleanMessage: message };
}
if (lowerMessage.includes('文档') || lowerMessage.includes('doc')) {
return { type: 'docs', cleanMessage: message };
}
if (lowerMessage.includes('新增') || lowerMessage.includes('添加') || lowerMessage.includes('增加')) {
return { type: 'feature', cleanMessage: message };
}
if (lowerMessage.includes('样式') || lowerMessage.includes('style')) {
return { type: 'style', cleanMessage: message };
}
if (lowerMessage.includes('重构') || lowerMessage.includes('refactor')) {
return { type: 'refactor', cleanMessage: message };
}
return { type: 'other', cleanMessage: message };
}
/**
* 获取GitHub提交历史
*/
export async function fetchGitHubCommits(page: number = 1, perPage: number = 30): Promise<GitHubCommit[]> {
try {
const url = `${GITHUB_API_BASE}/repos/${REPO_OWNER}/${REPO_NAME}/commits?author=${REPO_OWNER}&page=${page}&per_page=${perPage}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/vnd.github.v3+json',
},
cache: 'no-cache',
});
if (!response.ok) {
throw new Error(`GitHub API 请求失败: ${response.status} ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('获取 GitHub 提交历史失败:', error);
throw error;
}
}
/**
* 将GitHub提交转换为更新日志条目
*/
export function convertCommitsToChangelog(commits: GitHubCommit[]): ChangelogEntry[] {
return commits.map(commit => {
const { type, scope, cleanMessage } = parseCommitType(commit.commit.message);
return {
id: commit.sha,
date: commit.commit.author.date,
author: {
name: commit.commit.author.name,
avatar: commit.author?.avatar_url,
username: commit.author?.login,
},
message: cleanMessage,
commitUrl: commit.html_url,
type,
scope,
};
});
}
/**
* 获取格式化的更新日志
*/
export async function fetchChangelog(page: number = 1, perPage: number = 30): Promise<ChangelogEntry[]> {
const commits = await fetchGitHubCommits(page, perPage);
return convertCommitsToChangelog(commits);
}
/**
* 按日期分组更新日志
*/
export function groupChangelogByDate(entries: ChangelogEntry[]): Map<string, ChangelogEntry[]> {
const grouped = new Map<string, ChangelogEntry[]>();
entries.forEach(entry => {
const date = new Date(entry.date).toISOString().split('T')[0];
const existing = grouped.get(date) || [];
existing.push(entry);
grouped.set(date, existing);
});
return grouped;
}
/**
* 检查是否应该获取更新日志(避免频繁请求)
*/
export function shouldFetchChangelog(): boolean {
const lastFetch = localStorage.getItem('changelog_last_fetch');
if (!lastFetch) {
return true;
}
const lastFetchTime = new Date(lastFetch).getTime();
const now = Date.now();
const oneHourMs = 60 * 60 * 1000; // 1小时
return now - lastFetchTime >= oneHourMs;
}
/**
* 记录更新日志获取时间
*/
export function markChangelogFetched(): void {
localStorage.setItem('changelog_last_fetch', new Date().toISOString());
}
/**
* 获取缓存的更新日志
*/
export function getCachedChangelog(): ChangelogEntry[] | null {
const cached = localStorage.getItem('changelog_cache');
if (cached) {
try {
return JSON.parse(cached);
} catch {
return null;
}
}
return null;
}
/**
* 缓存更新日志
*/
export function cacheChangelog(entries: ChangelogEntry[]): void {
localStorage.setItem('changelog_cache', JSON.stringify(entries));
}
/**
* 清除更新日志缓存
* 用于强制刷新数据
*/
export function clearChangelogCache(): void {
localStorage.removeItem('changelog_cache');
localStorage.removeItem('changelog_last_fetch');
}
+4 -2
View File
@@ -287,7 +287,8 @@ export function useChapterSync() {
styleId?: number,
targetWordCount?: number,
onProgressUpdate?: (message: string, progress: number) => void,
model?: string
model?: string,
narrativePerspective?: string
) => {
try {
// 使用fetch处理流式响应
@@ -299,7 +300,8 @@ export function useChapterSync() {
body: JSON.stringify({
style_id: styleId,
target_word_count: targetWordCount,
model: model
model: model,
narrative_perspective: narrativePerspective
}),
});