update:1.更新根据分析建议重新生成章节内容

This commit is contained in:
xiamuceer
2025-11-11 19:50:12 +08:00
parent 5b46d657f3
commit 913edd0cce
30 changed files with 3896 additions and 1928 deletions
+98 -1
View File
@@ -10,9 +10,12 @@ import {
CheckCircleOutlined,
ClockCircleOutlined,
CloseCircleOutlined,
ReloadOutlined
ReloadOutlined,
EditOutlined
} from '@ant-design/icons';
import type { AnalysisTask, ChapterAnalysisResponse } from '../types';
import ChapterRegenerationModal from './ChapterRegenerationModal';
import ChapterContentComparison from './ChapterContentComparison';
// 判断是否为移动设备
const isMobileDevice = () => window.innerWidth < 768;
@@ -29,6 +32,11 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isMobile, setIsMobile] = useState(isMobileDevice());
const [regenerationModalVisible, setRegenerationModalVisible] = useState(false);
const [comparisonModalVisible, setComparisonModalVisible] = useState(false);
const [chapterInfo, setChapterInfo] = useState<{ title: string; chapter_number: number; content: string } | null>(null);
const [newGeneratedContent, setNewGeneratedContent] = useState('');
const [newContentWordCount, setNewContentWordCount] = useState(0);
useEffect(() => {
if (visible && chapterId) {
@@ -54,6 +62,17 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
setLoading(true);
setError(null);
// 同时获取章节信息
const chapterResponse = await fetch(`/api/chapters/${chapterId}`);
if (chapterResponse.ok) {
const chapterData = await chapterResponse.json();
setChapterInfo({
title: chapterData.title,
chapter_number: chapterData.chapter_number,
content: chapterData.content || ''
});
}
const response = await fetch(`/api/chapters/${chapterId}/analysis/status`);
if (response.status === 404) {
@@ -199,6 +218,17 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
);
};
// 将分析建议转换为重新生成组件需要的格式
const convertSuggestionsForRegeneration = () => {
if (!analysis?.analysis?.suggestions) return [];
return analysis.analysis.suggestions.map((suggestion, index) => ({
category: '改进建议',
content: suggestion,
priority: index < 3 ? 'high' : 'medium'
}));
};
const renderAnalysisResult = () => {
if (!analysis) return null;
@@ -215,6 +245,29 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
icon: <TrophyOutlined />,
children: (
<div style={{ height: isMobile ? 'calc(80vh - 180px)' : 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
{/* 根据建议重新生成按钮 */}
{analysis_data.suggestions && analysis_data.suggestions.length > 0 && (
<Alert
message="发现改进建议"
description={
<div>
<p style={{ marginBottom: 12 }}>AI已分析出 {analysis_data.suggestions.length} </p>
<Button
type="primary"
icon={<EditOutlined />}
onClick={() => setRegenerationModalVisible(true)}
size={isMobile ? 'small' : 'middle'}
>
</Button>
</div>
}
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
)}
<Card title="整体评分" style={{ marginBottom: 16 }} size={isMobile ? 'small' : 'default'}>
<Row gutter={isMobile ? 8 : 16}>
<Col span={isMobile ? 12 : 6}>
@@ -560,6 +613,50 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
{task && task.status !== 'completed' && renderProgress()}
{task && task.status === 'completed' && analysis && renderAnalysisResult()}
{/* 重新生成Modal */}
{chapterInfo && (
<ChapterRegenerationModal
visible={regenerationModalVisible}
onCancel={() => setRegenerationModalVisible(false)}
onSuccess={(newContent: string, wordCount: number) => {
// 保存新生成的内容
setNewGeneratedContent(newContent);
setNewContentWordCount(wordCount);
// 关闭重新生成对话框
setRegenerationModalVisible(false);
// 打开对比界面
setComparisonModalVisible(true);
}}
chapterId={chapterId}
chapterTitle={chapterInfo.title}
chapterNumber={chapterInfo.chapter_number}
suggestions={convertSuggestionsForRegeneration()}
hasAnalysis={true}
/>
)}
{/* 内容对比组件 */}
{chapterInfo && comparisonModalVisible && (
<ChapterContentComparison
visible={comparisonModalVisible}
onClose={() => setComparisonModalVisible(false)}
chapterId={chapterId}
chapterTitle={chapterInfo.title}
originalContent={chapterInfo.content}
newContent={newGeneratedContent}
wordCount={newContentWordCount}
onApply={() => {
// 应用新内容后刷新章节信息
fetchAnalysisStatus();
}}
onDiscard={() => {
// 放弃新内容,清空状态
setNewGeneratedContent('');
setNewContentWordCount(0);
}}
/>
)}
</Modal>
);
}
@@ -0,0 +1,218 @@
import React, { useState } from 'react';
import { Modal, Button, Card, Statistic, Row, Col, message } from 'antd';
import { CheckOutlined, CloseOutlined, SwapOutlined } from '@ant-design/icons';
import ReactDiffViewer from 'react-diff-viewer-continued';
interface ChapterContentComparisonProps {
visible: boolean;
onClose: () => void;
chapterId: string;
chapterTitle: string;
originalContent: string;
newContent: string;
wordCount: number;
onApply: () => void;
onDiscard: () => void;
}
const ChapterContentComparison: React.FC<ChapterContentComparisonProps> = ({
visible,
onClose,
chapterId,
chapterTitle,
originalContent,
newContent,
wordCount,
onApply,
onDiscard
}) => {
const [applying, setApplying] = useState(false);
const [viewMode, setViewMode] = useState<'split' | 'unified'>('split');
const originalWordCount = originalContent.length;
const wordCountDiff = wordCount - originalWordCount;
const wordCountDiffPercent = ((wordCountDiff / originalWordCount) * 100).toFixed(1);
const handleApply = async () => {
setApplying(true);
try {
const response = await fetch(`/api/chapters/${chapterId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
content: newContent
})
});
if (!response.ok) {
throw new Error('应用新内容失败');
}
message.success('新内容已应用!正在触发章节分析...');
// 触发章节分析
try {
const analysisResponse = await fetch(`/api/chapters/${chapterId}/analyze`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
if (analysisResponse.ok) {
message.success('章节分析已开始,请稍后查看结果');
} else {
message.warning('章节分析触发失败,您可以手动触发分析');
}
} catch (analysisError) {
console.error('触发分析失败:', analysisError);
message.warning('章节分析触发失败,您可以手动触发分析');
}
onApply();
onClose();
} catch (error: any) {
message.error(error.message || '应用失败');
} finally {
setApplying(false);
}
};
const handleDiscard = () => {
Modal.confirm({
title: '确认放弃',
content: '确定要放弃新生成的内容吗?此操作不可恢复。',
okText: '确定放弃',
cancelText: '取消',
okButtonProps: { danger: true },
onOk: () => {
onDiscard();
onClose();
message.info('已放弃新内容');
}
});
};
return (
<Modal
title={`内容对比 - ${chapterTitle}`}
open={visible}
onCancel={onClose}
width="95%"
centered
style={{ maxWidth: 1600 }}
footer={[
<Button
key="discard"
danger
icon={<CloseOutlined />}
onClick={handleDiscard}
>
</Button>,
<Button
key="toggle"
icon={<SwapOutlined />}
onClick={() => setViewMode(viewMode === 'split' ? 'unified' : 'split')}
>
</Button>,
<Button
key="apply"
type="primary"
icon={<CheckOutlined />}
loading={applying}
onClick={handleApply}
>
</Button>
]}
>
{/* 统计信息 */}
<Card size="small" style={{ marginBottom: 16 }}>
<Row gutter={16}>
<Col span={6}>
<Statistic
title="原内容字数"
value={originalWordCount}
suffix="字"
/>
</Col>
<Col span={6}>
<Statistic
title="新内容字数"
value={wordCount}
suffix="字"
/>
</Col>
<Col span={6}>
<Statistic
title="字数变化"
value={wordCountDiff}
suffix="字"
valueStyle={{ color: wordCountDiff > 0 ? '#3f8600' : '#cf1322' }}
prefix={wordCountDiff > 0 ? '+' : ''}
/>
</Col>
<Col span={6}>
<Statistic
title="变化比例"
value={wordCountDiffPercent}
suffix="%"
valueStyle={{ color: Math.abs(parseFloat(wordCountDiffPercent)) < 10 ? '#1890ff' : '#faad14' }}
prefix={wordCountDiff > 0 ? '+' : ''}
/>
</Col>
</Row>
</Card>
{/* 内容对比 */}
<div style={{
maxHeight: 'calc(90vh - 300px)',
overflow: 'auto',
border: '1px solid #d9d9d9',
borderRadius: 4
}}>
<ReactDiffViewer
oldValue={originalContent}
newValue={newContent}
splitView={viewMode === 'split'}
leftTitle="原内容"
rightTitle="新内容"
showDiffOnly={false}
useDarkTheme={false}
styles={{
variables: {
light: {
diffViewerBackground: '#fff',
addedBackground: '#e6ffed',
addedColor: '#24292e',
removedBackground: '#ffeef0',
removedColor: '#24292e',
wordAddedBackground: '#acf2bd',
wordRemovedBackground: '#fdb8c0',
addedGutterBackground: '#cdffd8',
removedGutterBackground: '#ffdce0',
gutterBackground: '#f6f8fa',
gutterBackgroundDark: '#f3f4f6',
highlightBackground: '#fffbdd',
highlightGutterBackground: '#fff5b1',
},
},
line: {
padding: '10px 2px',
fontSize: '14px',
lineHeight: '20px',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word'
}
}}
/>
</div>
</Modal>
);
};
export default ChapterContentComparison;
@@ -0,0 +1,402 @@
import React, { useState, useEffect } from 'react';
import {
Modal,
Form,
Input,
Button,
Checkbox,
InputNumber,
Space,
Alert,
Divider,
Progress,
Tag,
message,
Collapse,
Card,
Radio
} from 'antd';
import {
ReloadOutlined,
CheckCircleOutlined,
CloseCircleOutlined
} from '@ant-design/icons';
import { ssePost } from '../utils/sseClient';
const { TextArea } = Input;
const { Panel } = Collapse;
interface Suggestion {
category: string;
content: string;
priority: string;
}
interface ChapterRegenerationModalProps {
visible: boolean;
onCancel: () => void;
onSuccess: (newContent: string, wordCount: number) => void;
chapterId: string;
chapterTitle: string;
chapterNumber: number;
suggestions?: Suggestion[];
hasAnalysis: boolean;
}
const ChapterRegenerationModal: React.FC<ChapterRegenerationModalProps> = ({
visible,
onCancel,
onSuccess,
chapterId,
chapterTitle,
chapterNumber,
suggestions = [],
hasAnalysis
}) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState<'idle' | 'generating' | 'success' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState('');
const [wordCount, setWordCount] = useState(0);
const [selectedSuggestions, setSelectedSuggestions] = useState<number[]>([]);
const [modificationSource, setModificationSource] = useState<'custom' | 'analysis_suggestions' | 'mixed'>('custom');
useEffect(() => {
if (visible) {
// 重置状态
setStatus('idle');
setProgress(0);
setErrorMessage('');
setWordCount(0);
setSelectedSuggestions([]);
// 如果有分析建议,默认选择混合模式
if (hasAnalysis && suggestions.length > 0) {
setModificationSource('mixed');
} else {
setModificationSource('custom');
}
// 设置默认值
form.setFieldsValue({
modification_source: hasAnalysis && suggestions.length > 0 ? 'mixed' : 'custom',
target_word_count: 3000,
preserve_structure: false,
preserve_character_traits: true,
focus_areas: []
});
}
}, [visible, hasAnalysis, suggestions.length, form]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
// 验证至少提供一种修改指令
if (values.modification_source === 'custom' && !values.custom_instructions?.trim()) {
message.error('请输入自定义修改要求');
return;
}
if (values.modification_source === 'analysis_suggestions' && selectedSuggestions.length === 0) {
message.error('请选择至少一条分析建议');
return;
}
if (values.modification_source === 'mixed' &&
selectedSuggestions.length === 0 &&
!values.custom_instructions?.trim()) {
message.error('请至少选择一条建议或输入自定义要求');
return;
}
setLoading(true);
setStatus('generating');
setProgress(0);
setWordCount(0);
// 构建请求数据
const requestData: any = {
modification_source: values.modification_source,
custom_instructions: values.custom_instructions,
selected_suggestion_indices: selectedSuggestions,
preserve_elements: {
preserve_structure: values.preserve_structure,
preserve_dialogues: values.preserve_dialogues || [],
preserve_plot_points: values.preserve_plot_points || [],
preserve_character_traits: values.preserve_character_traits
},
style_id: values.style_id,
target_word_count: values.target_word_count,
focus_areas: values.focus_areas || []
};
let accumulatedContent = '';
let currentWordCount = 0;
// 使用SSE流式生成
await ssePost(
`/api/chapters/${chapterId}/regenerate-stream`,
requestData,
{
onProgress: (_msg: string, prog: number, _status: string, wordCount?: number) => {
// 后端发送的进度消息
setProgress(prog);
// 如果后端提供了word_count,使用它;否则使用累积的字数
if (wordCount !== undefined) {
setWordCount(wordCount);
currentWordCount = wordCount;
}
},
onChunk: (content: string) => {
// 累积内容块
accumulatedContent += content;
// 仅作为备用字数统计
currentWordCount = accumulatedContent.length;
// 不再自己计算进度,完全依赖后端发送的progress消息
},
onResult: (data: any) => {
// 生成完成,确保使用最新的累积内容
setProgress(100);
setStatus('success');
const finalWordCount = data.word_count || currentWordCount;
setWordCount(finalWordCount);
message.success('重新生成完成!');
// 直接调用onSuccess打开对比界面,传递最终的累积内容
setTimeout(() => {
onSuccess(accumulatedContent, finalWordCount);
}, 500);
},
onComplete: () => {
// SSE完成
},
onError: (error: string, code?: number) => {
console.error('SSE Error:', error, code);
setStatus('error');
setErrorMessage(error || '生成失败');
message.error('重新生成失败: ' + (error || '未知错误'));
}
}
);
} catch (error: any) {
console.error('提交失败:', error);
setStatus('error');
setErrorMessage(error.message || '提交失败');
message.error('操作失败: ' + (error.message || '未知错误'));
} finally {
setLoading(false);
}
};
const handleSuggestionSelect = (index: number, checked: boolean) => {
if (checked) {
setSelectedSuggestions([...selectedSuggestions, index]);
} else {
setSelectedSuggestions(selectedSuggestions.filter(i => i !== index));
}
};
const handleCancel = () => {
if (loading) {
Modal.confirm({
title: '确认取消',
content: '生成正在进行中,确定要取消吗?',
onOk: () => {
setLoading(false);
setStatus('idle');
onCancel();
}
});
} else {
onCancel();
}
};
return (
<Modal
title={`重新生成章节 - 第${chapterNumber}章:${chapterTitle}`}
open={visible}
onCancel={handleCancel}
width={800}
centered
footer={
status === 'success' ? null : (
[
<Button key="cancel" onClick={handleCancel} disabled={loading}>
</Button>,
<Button
key="submit"
type="primary"
onClick={handleSubmit}
loading={loading}
icon={<ReloadOutlined />}
>
</Button>
]
)
}
>
{status === 'generating' && (
<Alert
message="正在重新生成中..."
description={
<div>
<Progress percent={progress} status="active" />
<div style={{ marginTop: 8, fontSize: 12, color: '#666' }}>
{wordCount}
</div>
</div>
}
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
)}
{status === 'success' && (
<Alert
message="重新生成成功!"
description={`共生成 ${wordCount}`}
type="success"
showIcon
icon={<CheckCircleOutlined />}
style={{ marginBottom: 16 }}
/>
)}
{status === 'error' && (
<Alert
message="生成失败"
description={errorMessage}
type="error"
showIcon
icon={<CloseCircleOutlined />}
style={{ marginBottom: 16 }}
/>
)}
<Form
form={form}
layout="vertical"
disabled={loading || status === 'success'}
>
{/* 修改来源 */}
<Form.Item
name="modification_source"
label="修改来源"
rules={[{ required: true, message: '请选择修改来源' }]}
>
<Radio.Group onChange={(e) => setModificationSource(e.target.value)}>
<Radio value="custom"></Radio>
{hasAnalysis && suggestions.length > 0 && (
<>
<Radio value="analysis_suggestions"></Radio>
<Radio value="mixed"></Radio>
</>
)}
</Radio.Group>
</Form.Item>
{/* 分析建议选择 */}
{hasAnalysis && suggestions.length > 0 &&
(modificationSource === 'analysis_suggestions' || modificationSource === 'mixed') && (
<Form.Item label={`选择分析建议 (${selectedSuggestions.length}/${suggestions.length})`}>
<Card size="small" style={{ maxHeight: 300, overflow: 'auto' }}>
<Space direction="vertical" style={{ width: '100%' }}>
{suggestions.map((suggestion, index) => (
<Checkbox
key={index}
checked={selectedSuggestions.includes(index)}
onChange={(e) => handleSuggestionSelect(index, e.target.checked)}
>
<Space>
<Tag color={
suggestion.priority === 'high' ? 'red' :
suggestion.priority === 'medium' ? 'orange' : 'blue'
}>
{suggestion.category}
</Tag>
<span style={{ fontSize: 13 }}>{suggestion.content}</span>
</Space>
</Checkbox>
))}
</Space>
</Card>
</Form.Item>
)}
{/* 自定义修改要求 */}
{(modificationSource === 'custom' || modificationSource === 'mixed') && (
<Form.Item
name="custom_instructions"
label="自定义修改要求"
tooltip="描述你希望如何改进这个章节"
>
<TextArea
rows={4}
placeholder="例如:增强情感渲染,让主角的内心戏更加细腻..."
showCount
maxLength={1000}
/>
</Form.Item>
)}
{/* 高级选项 */}
<Collapse ghost>
<Panel header="高级选项" key="advanced">
{/* 重点优化方向 */}
<Form.Item
name="focus_areas"
label="重点优化方向"
>
<Checkbox.Group>
<Space direction="vertical">
<Checkbox value="pacing"></Checkbox>
<Checkbox value="emotion"></Checkbox>
<Checkbox value="description"></Checkbox>
<Checkbox value="dialogue"></Checkbox>
<Checkbox value="conflict"></Checkbox>
</Space>
</Checkbox.Group>
</Form.Item>
<Divider />
{/* 保留元素 */}
<Form.Item label="保留元素">
<Space direction="vertical" style={{ width: '100%' }}>
<Form.Item name="preserve_structure" valuePropName="checked" noStyle>
<Checkbox></Checkbox>
</Form.Item>
<Form.Item name="preserve_character_traits" valuePropName="checked" noStyle>
<Checkbox></Checkbox>
</Form.Item>
</Space>
</Form.Item>
<Divider />
{/* 生成参数 */}
<Form.Item
name="target_word_count"
label="目标字数"
tooltip="生成内容的目标字数,实际字数可能有±20%的浮动"
>
<InputNumber min={500} max={10000} step={500} style={{ width: '100%' }} />
</Form.Item>
</Panel>
</Collapse>
</Form>
</Modal>
);
};
export default ChapterRegenerationModal;
+100 -2
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { Dropdown, Avatar, Space, Typography, message, Modal, Table, Button, Tag, Popconfirm, Pagination } from 'antd';
import { UserOutlined, LogoutOutlined, TeamOutlined, CrownOutlined } from '@ant-design/icons';
import { Dropdown, Avatar, Space, Typography, message, Modal, Table, Button, Tag, Popconfirm, Pagination, Form, Input } from 'antd';
import { UserOutlined, LogoutOutlined, TeamOutlined, CrownOutlined, LockOutlined } from '@ant-design/icons';
import { authApi, userApi } from '../services/api';
import type { User } from '../types';
import type { MenuProps } from 'antd';
@@ -10,10 +10,13 @@ const { Text } = Typography;
export default function UserMenu() {
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [showUserManagement, setShowUserManagement] = useState(false);
const [showChangePassword, setShowChangePassword] = useState(false);
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [changePasswordForm] = Form.useForm();
const [changingPassword, setChangingPassword] = useState(false);
useEffect(() => {
loadCurrentUser();
@@ -84,6 +87,21 @@ export default function UserMenu() {
}
};
const handleChangePassword = async (values: { oldPassword: string; newPassword: string }) => {
try {
setChangingPassword(true);
await authApi.setPassword(values.newPassword);
message.success('密码修改成功');
setShowChangePassword(false);
changePasswordForm.resetFields();
} catch (error: any) {
console.error('修改密码失败:', error);
message.error(error.response?.data?.detail || '修改密码失败');
} finally {
setChangingPassword(false);
}
};
const menuItems: MenuProps['items'] = [
{
key: 'user-info',
@@ -110,6 +128,15 @@ export default function UserMenu() {
}, {
type: 'divider' as const,
}] : []),
{
key: 'change-password',
icon: <LockOutlined />,
label: '修改密码',
onClick: () => setShowChangePassword(true),
},
{
type: 'divider',
},
{
key: 'logout',
icon: <LogoutOutlined />,
@@ -341,6 +368,77 @@ export default function UserMenu() {
</div>
</div>
</Modal>
<Modal
title="修改密码"
open={showChangePassword}
onCancel={() => {
setShowChangePassword(false);
changePasswordForm.resetFields();
}}
footer={null}
width={480}
centered
>
<Form
form={changePasswordForm}
layout="vertical"
onFinish={handleChangePassword}
autoComplete="off"
>
<Form.Item
label="新密码"
name="newPassword"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码至少6个字符' },
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请输入新密码(至少6个字符)"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item
label="确认密码"
name="confirmPassword"
dependencies={['newPassword']}
rules={[
{ required: true, message: '请确认新密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('newPassword') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致'));
},
}),
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请再次输入新密码"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => {
setShowChangePassword(false);
changePasswordForm.resetFields();
}}>
</Button>
<Button type="primary" htmlType="submit" loading={changingPassword}>
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
</>
);
}
+140 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Spin, Result, Button } from 'antd';
import { Spin, Result, Button, Modal, Input, message } from 'antd';
import { authApi } from '../services/api';
import AnnouncementModal from '../components/AnnouncementModal';
@@ -9,6 +9,11 @@ export default function AuthCallback() {
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
const [errorMessage, setErrorMessage] = useState('');
const [showAnnouncement, setShowAnnouncement] = useState(false);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const [passwordStatus, setPasswordStatus] = useState<any>(null);
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [settingPassword, setSettingPassword] = useState(false);
useEffect(() => {
const handleCallback = async () => {
@@ -17,8 +22,21 @@ export default function AuthCallback() {
// 这里只需要验证登录状态
await authApi.getCurrentUser();
// 检查密码状态
const pwdStatus = await authApi.getPasswordStatus();
setPasswordStatus(pwdStatus);
setStatus('success');
// 只有在用户完全没有密码时才显示密码设置提示
// 如果已经有密码(无论是默认密码还是自定义密码),都不再提示
if (!pwdStatus.has_password) {
setTimeout(() => {
setShowPasswordModal(true);
}, 1000);
return;
}
// 从 sessionStorage 获取重定向地址
const redirect = sessionStorage.getItem('login_redirect') || '/';
sessionStorage.removeItem('login_redirect');
@@ -105,6 +123,70 @@ export default function AuthCallback() {
localStorage.setItem('announcement_do_not_show_until', tomorrow.getTime().toString());
};
const handleSetPassword = async () => {
if (!newPassword) {
message.error('请输入新密码');
return;
}
if (newPassword.length < 6) {
message.error('密码长度至少为6个字符');
return;
}
if (newPassword !== confirmPassword) {
message.error('两次输入的密码不一致');
return;
}
setSettingPassword(true);
try {
await authApi.setPassword(newPassword);
message.success('密码设置成功');
setShowPasswordModal(false);
// 继续后续流程
const redirect = sessionStorage.getItem('login_redirect') || '/';
sessionStorage.removeItem('login_redirect');
const doNotShowUntil = localStorage.getItem('announcement_do_not_show_until');
const now = new Date().getTime();
if (!doNotShowUntil || now > parseInt(doNotShowUntil)) {
setTimeout(() => {
setShowAnnouncement(true);
}, 500);
} else {
setTimeout(() => {
navigate(redirect);
}, 500);
}
} catch (error) {
message.error('密码设置失败,请重试');
} finally {
setSettingPassword(false);
}
};
const handleSkipPasswordSetting = () => {
setShowPasswordModal(false);
// 继续后续流程
const redirect = sessionStorage.getItem('login_redirect') || '/';
sessionStorage.removeItem('login_redirect');
const doNotShowUntil = localStorage.getItem('announcement_do_not_show_until');
const now = new Date().getTime();
if (!doNotShowUntil || now > parseInt(doNotShowUntil)) {
setTimeout(() => {
setShowAnnouncement(true);
}, 500);
} else {
setTimeout(() => {
navigate(redirect);
}, 500);
}
};
return (
<>
<AnnouncementModal
@@ -112,6 +194,62 @@ export default function AuthCallback() {
onClose={handleAnnouncementClose}
onDoNotShowToday={handleDoNotShowToday}
/>
<Modal
title="设置账号密码"
open={showPasswordModal}
centered
onOk={handleSetPassword}
onCancel={handleSkipPasswordSetting}
confirmLoading={settingPassword}
okText="设置密码"
cancelText="暂不设置"
width={500}
>
<div style={{ marginBottom: 20 }}>
<p> Linux DO </p>
<p>使</p>
{passwordStatus?.default_password && (
<div style={{
background: '#f0f2f5',
padding: 12,
borderRadius: 4,
marginTop: 12
}}>
<strong></strong>{passwordStatus.username}<br/>
<strong></strong><code style={{
background: '#fff',
padding: '2px 8px',
borderRadius: 3,
color: '#1890ff',
fontSize: 14
}}>{passwordStatus.default_password}</code>
</div>
)}
</div>
<div style={{ marginTop: 20 }}>
<div style={{ marginBottom: 12 }}>
<label>6</label>
<Input.Password
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="请输入新密码"
style={{ marginTop: 4 }}
/>
</div>
<div>
<label></label>
<Input.Password
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="请再次输入密码"
style={{ marginTop: 4 }}
/>
</div>
</div>
</Modal>
<div style={{
display: 'flex',
justifyContent: 'center',
@@ -122,7 +260,7 @@ export default function AuthCallback() {
<Result
status="success"
title="登录成功"
subTitle={showAnnouncement ? "欢迎使用..." : "正在跳转..."}
subTitle={showPasswordModal ? "请设置账号密码..." : (showAnnouncement ? "欢迎使用..." : "正在跳转...")}
style={{ background: 'white', padding: 40, borderRadius: 8 }}
/>
</div>
+1
View File
@@ -600,6 +600,7 @@ export default function MCPPluginsPage() {
<Modal
title={editingPlugin ? '编辑插件' : '添加插件'}
open={modalVisible}
centered
onCancel={() => {
setModalVisible(false);
form.resetFields();
+45 -80
View File
@@ -73,23 +73,8 @@ export default function ProjectList() {
};
const handleEnterProject = (id: string) => {
const project = projects.find(p => p.id === id);
if (project) {
console.log('项目信息:', {
id: project.id,
title: project.title,
wizard_status: project.wizard_status,
wizard_step: project.wizard_step
});
if (project.wizard_status === 'incomplete' || !project.wizard_status) {
console.log('向导未完成,跳转到向导页面');
navigate(`/wizard?projectId=${id}&step=${project.wizard_step || 0}`);
} else {
console.log('向导已完成,进入项目管理界面');
navigate(`/project/${id}`);
}
}
// 简化后直接进入项目,不再检查向导状态
navigate(`/project/${id}`);
};
const getStatusTag = (status: string) => {
@@ -207,8 +192,8 @@ export default function ProjectList() {
setSelectedProjectIds([]);
};
// 获取可导出的项目(过滤掉向导未完成的项目)
const exportableProjects = projects.filter(p => p.wizard_status === 'completed');
// 获取所有可导出的项目
const exportableProjects = projects;
// 关闭导出对话框
const handleCloseExportModal = () => {
@@ -631,12 +616,11 @@ export default function ProjectList() {
<Row gutter={[16, 16]}>
{projects.map((project) => {
const progress = getProgress(project.current_words, project.target_words || 0);
const isWizardComplete = project.wizard_status === 'completed';
return (
<Col {...gridConfig} key={project.id}>
<Badge.Ribbon
text={isWizardComplete ? getStatusTag(project.status) : <Tag color="orange" icon={<RocketOutlined />}></Tag>}
text={getStatusTag(project.status)}
color="transparent"
style={{ top: 12, right: 12 }}
>
@@ -680,69 +664,50 @@ export default function ProjectList() {
{project.description || '暂无描述'}
</Paragraph>
{isWizardComplete ? (
<>
{project.target_words && project.target_words > 0 && (
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
<Text strong style={{ fontSize: 12 }}>{progress}%</Text>
</div>
<Progress
percent={progress}
strokeColor={getProgressColor(progress)}
showInfo={false}
size={{ height: 8 }}
/>
</div>
)}
<Row gutter={12}>
<Col span={12}>
<div style={{
textAlign: 'center',
padding: '12px 0',
background: '#f5f5f5',
borderRadius: 8
}}>
<div style={{ fontSize: 20, fontWeight: 'bold', color: '#1890ff' }}>
{(project.current_words / 1000).toFixed(1)}K
</div>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
</div>
</Col>
<Col span={12}>
<div style={{
textAlign: 'center',
padding: '12px 0',
background: '#f5f5f5',
borderRadius: 8
}}>
<div style={{ fontSize: 20, fontWeight: 'bold', color: '#52c41a' }}>
{project.target_words ? (project.target_words / 1000).toFixed(0) + 'K' : '--'}
</div>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
</div>
</Col>
</Row>
</>
) : (
<div style={{
textAlign: 'center',
padding: '24px 0',
background: '#f5f5f5',
borderRadius: 8
}}>
<RocketOutlined style={{ fontSize: 32, color: '#faad14', marginBottom: 12 }} />
<div style={{ color: '#faad14', fontWeight: 'bold', marginBottom: 4 }}>
{project.target_words && project.target_words > 0 && (
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
<Text strong style={{ fontSize: 12 }}>{progress}%</Text>
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
<Progress
percent={progress}
strokeColor={getProgressColor(progress)}
showInfo={false}
size={{ height: 8 }}
/>
</div>
)}
<Row gutter={12}>
<Col span={12}>
<div style={{
textAlign: 'center',
padding: '12px 0',
background: '#f5f5f5',
borderRadius: 8
}}>
<div style={{ fontSize: 20, fontWeight: 'bold', color: '#1890ff' }}>
{(project.current_words / 1000).toFixed(1)}K
</div>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
</div>
</Col>
<Col span={12}>
<div style={{
textAlign: 'center',
padding: '12px 0',
background: '#f5f5f5',
borderRadius: 8
}}>
<div style={{ fontSize: 20, fontWeight: 'bold', color: '#52c41a' }}>
{project.target_words ? (project.target_words / 1000).toFixed(0) + 'K' : '--'}
</div>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
</div>
</Col>
</Row>
<div style={{
marginTop: 16,
paddingTop: 16,
File diff suppressed because it is too large Load Diff
+126 -6
View File
@@ -1,12 +1,18 @@
import { Card, Descriptions, Empty, Typography } from 'antd';
import { GlobalOutlined } from '@ant-design/icons';
import { Card, Descriptions, Empty, Typography, Button, Modal, Form, Input, message } from 'antd';
import { GlobalOutlined, EditOutlined } from '@ant-design/icons';
import { useState } from 'react';
import { useStore } from '../store';
import { cardStyles } from '../components/CardStyles';
import { projectApi } from '../services/api';
const { Title, Paragraph } = Typography;
const { TextArea } = Input;
export default function WorldSetting() {
const { currentProject } = useStore();
const { currentProject, setCurrentProject } = useStore();
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
const [editForm] = Form.useForm();
const [isSaving, setIsSaving] = useState(false);
if (!currentProject) return null;
@@ -62,10 +68,28 @@ export default function WorldSetting() {
marginBottom: 24,
borderBottom: '1px solid #f0f0f0',
display: 'flex',
alignItems: 'center'
alignItems: 'center',
justifyContent: 'space-between'
}}>
<GlobalOutlined style={{ fontSize: 24, marginRight: 12, color: '#1890ff' }} />
<h2 style={{ margin: 0 }}></h2>
<div style={{ display: 'flex', alignItems: 'center' }}>
<GlobalOutlined style={{ fontSize: 24, marginRight: 12, color: '#1890ff' }} />
<h2 style={{ margin: 0 }}></h2>
</div>
<Button
type="primary"
icon={<EditOutlined />}
onClick={() => {
editForm.setFieldsValue({
world_time_period: currentProject.world_time_period || '',
world_location: currentProject.world_location || '',
world_atmosphere: currentProject.world_atmosphere || '',
world_rules: currentProject.world_rules || '',
});
setIsEditModalVisible(true);
}}
>
</Button>
</div>
{/* 可滚动内容区域 */}
@@ -182,6 +206,102 @@ export default function WorldSetting() {
</div>
</Card>
</div>
{/* 编辑世界观模态框 */}
<Modal
title="编辑世界观"
open={isEditModalVisible}
centered
onCancel={() => {
setIsEditModalVisible(false);
editForm.resetFields();
}}
onOk={async () => {
try {
const values = await editForm.validateFields();
setIsSaving(true);
const updatedProject = await projectApi.updateProject(currentProject.id, {
world_time_period: values.world_time_period,
world_location: values.world_location,
world_atmosphere: values.world_atmosphere,
world_rules: values.world_rules,
});
setCurrentProject(updatedProject);
message.success('世界观更新成功');
setIsEditModalVisible(false);
editForm.resetFields();
} catch (error) {
console.error('更新世界观失败:', error);
message.error('更新失败,请重试');
} finally {
setIsSaving(false);
}
}}
confirmLoading={isSaving}
width={800}
okText="保存"
cancelText="取消"
>
<Form
form={editForm}
layout="vertical"
style={{ marginTop: 16 }}
>
<Form.Item
label="时间设定"
name="world_time_period"
rules={[{ required: true, message: '请输入时间设定' }]}
>
<TextArea
rows={4}
placeholder="描述故事发生的时代背景..."
showCount
maxLength={1000}
/>
</Form.Item>
<Form.Item
label="地点设定"
name="world_location"
rules={[{ required: true, message: '请输入地点设定' }]}
>
<TextArea
rows={4}
placeholder="描述故事发生的地理位置和环境..."
showCount
maxLength={1000}
/>
</Form.Item>
<Form.Item
label="氛围设定"
name="world_atmosphere"
rules={[{ required: true, message: '请输入氛围设定' }]}
>
<TextArea
rows={4}
placeholder="描述故事的整体氛围和基调..."
showCount
maxLength={1000}
/>
</Form.Item>
<Form.Item
label="规则设定"
name="world_rules"
rules={[{ required: true, message: '请输入规则设定' }]}
>
<TextArea
rows={4}
placeholder="描述这个世界的特殊规则和设定..."
showCount
maxLength={1000}
/>
</Form.Item>
</Form>
</Modal>
</div>
);
}
+30
View File
@@ -123,10 +123,23 @@ export const authApi = {
localLogin: (username: string, password: string) =>
api.post<unknown, { success: boolean; message: string; user: User }>('/auth/local/login', { username, password }),
bindAccountLogin: (username: string, password: string) =>
api.post<unknown, { success: boolean; message: string; user: User }>('/auth/bind/login', { username, password }),
getLinuxDOAuthUrl: () => api.get<unknown, AuthUrlResponse>('/auth/linuxdo/url'),
getCurrentUser: () => api.get<unknown, User>('/auth/user'),
getPasswordStatus: () => api.get<unknown, {
has_password: boolean;
has_custom_password: boolean;
username: string | null;
default_password: string | null;
}>('/auth/password/status'),
setPassword: (password: string) =>
api.post<unknown, { success: boolean; message: string }>('/auth/password/set', { password }),
refreshSession: () => api.post<unknown, { message: string; expire_at: number; remaining_minutes: number }>('/auth/refresh'),
logout: () => api.post('/auth/logout'),
@@ -306,6 +319,23 @@ export const chapterApi = {
checkCanGenerate: (chapterId: string) =>
api.get<unknown, import('../types').ChapterCanGenerateResponse>(`/chapters/${chapterId}/can-generate`),
// 章节重新生成相关
getRegenerationTasks: (chapterId: string, limit?: number) =>
api.get<unknown, {
chapter_id: string;
total: number;
tasks: Array<{
task_id: string;
status: string;
version_number: number | null;
version_note: string | null;
original_word_count: number | null;
regenerated_word_count: number | null;
created_at: string | null;
completed_at: string | null;
}>;
}>(`/chapters/${chapterId}/regeneration/tasks`, { params: { limit } }),
};
export const writingStyleApi = {
+16 -5
View File
@@ -2,6 +2,7 @@ export interface SSEMessage {
type: 'progress' | 'chunk' | 'result' | 'error' | 'done';
message?: string;
progress?: number;
word_count?: number;
status?: 'processing' | 'success' | 'error' | 'warning';
content?: string;
data?: any;
@@ -10,7 +11,7 @@ export interface SSEMessage {
}
export interface SSEClientOptions {
onProgress?: (message: string, progress: number, status: string) => void;
onProgress?: (message: string, progress: number, status: string, wordCount?: number) => void;
onChunk?: (content: string) => void;
onResult?: (data: any) => void;
onError?: (error: string, code?: number) => void;
@@ -61,8 +62,13 @@ export class SSEClient {
private handleMessage(message: SSEMessage, resolve: Function, reject: Function) {
switch (message.type) {
case 'progress':
if (this.options.onProgress && message.message && message.progress !== undefined) {
this.options.onProgress(message.message, message.progress, message.status || 'processing');
if (this.options.onProgress && message.progress !== undefined) {
this.options.onProgress(
message.message || '',
message.progress,
message.status || 'processing',
message.word_count
);
}
break;
@@ -201,8 +207,13 @@ export class SSEPostClient {
private async handleMessage(message: SSEMessage, resolve: Function, reject: Function) {
switch (message.type) {
case 'progress':
if (this.options.onProgress && message.message && message.progress !== undefined) {
this.options.onProgress(message.message, message.progress, message.status || 'processing');
if (this.options.onProgress && message.progress !== undefined) {
this.options.onProgress(
message.message || '',
message.progress,
message.status || 'processing',
message.word_count
);
}
break;