update:1.新增手动创建大纲和章节,编写章节规划内容
2.新增项目更新日志页面,同步GitHub更新日志 3.新增章节内容生成时,选择本次生成人称 4.修复1 - N模式下,章节标题无法修改的问题 5.修复章节管理界面,批量生成后没有更新页面内容和状态
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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-10000字,默认3000字
|
||||
</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
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user