style: 将Tooltip组件替换为原生title属性,统一提示样式
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
# 应用配置
|
||||
# ==========================================
|
||||
APP_NAME=MuMuAINovel
|
||||
APP_VERSION=1.2.3
|
||||
APP_VERSION=1.2.4
|
||||
APP_HOST=0.0.0.0
|
||||
APP_PORT=8000
|
||||
DEBUG=false
|
||||
|
||||
@@ -7,7 +7,7 @@ import json
|
||||
from app.database import get_db
|
||||
from app.services.ai_service import AIService
|
||||
from app.api.settings import get_user_ai_service
|
||||
from app.services.prompt_service import prompt_service, PromptService
|
||||
from app.services.prompt_service import PromptService
|
||||
from app.logger import get_logger
|
||||
|
||||
router = APIRouter(prefix="/inspiration", tags=["灵感模式"])
|
||||
@@ -441,17 +441,13 @@ async def quick_generate(
|
||||
existing_text = "\n".join(existing_info) if existing_info else "暂无信息"
|
||||
|
||||
# 获取自定义提示词模板
|
||||
prompt_template_str = await PromptService.get_template("INSPIRATION_QUICK_COMPLETE", user_id, db)
|
||||
system_template = await PromptService.get_template("INSPIRATION_QUICK_COMPLETE", user_id, db)
|
||||
|
||||
# 格式化提示词
|
||||
try:
|
||||
prompts = json.loads(prompt_template_str)
|
||||
# 格式化参数
|
||||
prompts["system"] = prompts["system"].replace("{existing}", existing_text)
|
||||
prompts["user"] = prompts["user"].replace("{existing}", existing_text)
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
# 降级使用原有方法
|
||||
prompts = prompt_service.get_inspiration_quick_complete_prompt(existing=existing_text)
|
||||
prompts = {
|
||||
"system": PromptService.format_prompt(system_template, existing=existing_text),
|
||||
"user": "请补全小说信息"
|
||||
}
|
||||
|
||||
# 调用AI - 流式生成并累积文本
|
||||
accumulated_text = ""
|
||||
|
||||
+188
-12
@@ -1052,13 +1052,24 @@ async def _continue_outline(
|
||||
mcp_references=mcp_reference_materials
|
||||
)
|
||||
|
||||
# 调用AI生成当前批次
|
||||
# 调用AI生成当前批次(带重试机制)
|
||||
logger.info(f"正在调用AI流式生成第{batch_num + 1}批...")
|
||||
|
||||
max_retries = 2
|
||||
retry_count = 0
|
||||
outline_data = None
|
||||
|
||||
while retry_count <= max_retries:
|
||||
accumulated_text = ""
|
||||
chunk_count = 0
|
||||
|
||||
# 第一次使用原始prompt,重试时添加格式强调
|
||||
current_prompt = prompt if retry_count == 0 else (
|
||||
prompt + "\n\n【重要提醒】请确保返回完整的JSON数组,不要截断。每个章节对象必须包含完整的title、summary等字段。"
|
||||
)
|
||||
|
||||
async for chunk in user_ai_service.generate_text_stream(
|
||||
prompt=prompt,
|
||||
prompt=current_prompt,
|
||||
provider=request.provider,
|
||||
model=request.model
|
||||
):
|
||||
@@ -1071,7 +1082,19 @@ async def _continue_outline(
|
||||
ai_response = {"content": ai_content}
|
||||
|
||||
# 解析响应
|
||||
outline_data = _parse_ai_response(ai_content)
|
||||
try:
|
||||
outline_data = _parse_ai_response(ai_content, raise_on_error=True)
|
||||
break # 解析成功,跳出循环
|
||||
|
||||
except JSONParseError as e:
|
||||
retry_count += 1
|
||||
if retry_count > max_retries:
|
||||
# 超过最大重试次数,使用fallback数据
|
||||
logger.error(f"❌ 第{batch_num + 1}批解析失败,已达最大重试次数({max_retries}),使用fallback数据")
|
||||
outline_data = _parse_ai_response(ai_content, raise_on_error=False)
|
||||
break
|
||||
|
||||
logger.warning(f"⚠️ 第{batch_num + 1}批JSON解析失败(第{retry_count}次),正在重试...")
|
||||
|
||||
# 保存当前批次的大纲
|
||||
batch_outlines = await _save_outlines(
|
||||
@@ -1111,8 +1134,27 @@ async def _continue_outline(
|
||||
return OutlineListResponse(total=len(all_outlines), items=all_outlines)
|
||||
|
||||
|
||||
def _parse_ai_response(ai_response: str) -> list:
|
||||
"""解析AI响应为章节数据列表(使用统一的JSON清洗方法)"""
|
||||
class JSONParseError(Exception):
|
||||
"""JSON解析失败异常,用于触发重试"""
|
||||
def __init__(self, message: str, original_content: str = ""):
|
||||
super().__init__(message)
|
||||
self.original_content = original_content
|
||||
|
||||
|
||||
def _parse_ai_response(ai_response: str, raise_on_error: bool = False) -> list:
|
||||
"""
|
||||
解析AI响应为章节数据列表(使用统一的JSON清洗方法)
|
||||
|
||||
Args:
|
||||
ai_response: AI返回的原始文本
|
||||
raise_on_error: 如果为True,解析失败时抛出异常而不是返回fallback数据
|
||||
|
||||
Returns:
|
||||
解析后的章节数据列表
|
||||
|
||||
Raises:
|
||||
JSONParseError: 当raise_on_error=True且解析失败时抛出
|
||||
"""
|
||||
try:
|
||||
# 使用统一的JSON清洗方法(从AIService导入)
|
||||
from app.services.ai_service import AIService
|
||||
@@ -1129,19 +1171,49 @@ def _parse_ai_response(ai_response: str) -> list:
|
||||
else:
|
||||
outline_data = [outline_data]
|
||||
|
||||
logger.info(f"✅ 成功解析 {len(outline_data)} 个章节数据")
|
||||
return outline_data
|
||||
# 验证解析结果是否有效(至少有一个有效章节)
|
||||
valid_chapters = [
|
||||
ch for ch in outline_data
|
||||
if isinstance(ch, dict) and (ch.get("title") or ch.get("summary") or ch.get("content"))
|
||||
]
|
||||
|
||||
if not valid_chapters:
|
||||
error_msg = "解析结果无效:未找到有效的章节数据"
|
||||
logger.error(f"❌ {error_msg}")
|
||||
if raise_on_error:
|
||||
raise JSONParseError(error_msg, ai_response)
|
||||
return [{
|
||||
"title": "AI生成的大纲",
|
||||
"content": ai_response[:1000],
|
||||
"summary": ai_response[:1000]
|
||||
}]
|
||||
|
||||
logger.info(f"✅ 成功解析 {len(valid_chapters)} 个章节数据")
|
||||
return valid_chapters
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
error_msg = f"JSON解析失败: {e}"
|
||||
logger.error(f"❌ AI响应解析失败: {e}")
|
||||
|
||||
if raise_on_error:
|
||||
raise JSONParseError(error_msg, ai_response)
|
||||
|
||||
# 返回一个包含原始内容的章节
|
||||
return [{
|
||||
"title": "AI生成的大纲",
|
||||
"content": ai_response[:1000],
|
||||
"summary": ai_response[:1000]
|
||||
}]
|
||||
except JSONParseError:
|
||||
# 重新抛出JSONParseError
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 解析异常: {str(e)}")
|
||||
error_msg = f"解析异常: {str(e)}"
|
||||
logger.error(f"❌ {error_msg}")
|
||||
|
||||
if raise_on_error:
|
||||
raise JSONParseError(error_msg, ai_response)
|
||||
|
||||
return [{
|
||||
"title": "解析异常的大纲",
|
||||
"content": "系统错误",
|
||||
@@ -1389,8 +1461,60 @@ async def new_outline_generator(
|
||||
ai_content = accumulated_text
|
||||
ai_response = {"content": ai_content}
|
||||
|
||||
# 解析响应
|
||||
outline_data = _parse_ai_response(ai_content)
|
||||
# 解析响应(带重试机制)
|
||||
max_retries = 2
|
||||
retry_count = 0
|
||||
outline_data = None
|
||||
|
||||
while retry_count <= max_retries:
|
||||
try:
|
||||
# 使用 raise_on_error=True,解析失败时抛出异常
|
||||
outline_data = _parse_ai_response(ai_content, raise_on_error=True)
|
||||
break # 解析成功,跳出循环
|
||||
|
||||
except JSONParseError as e:
|
||||
retry_count += 1
|
||||
if retry_count > max_retries:
|
||||
# 超过最大重试次数,使用fallback数据
|
||||
logger.error(f"❌ 大纲解析失败,已达最大重试次数({max_retries}),使用fallback数据")
|
||||
yield await SSEResponse.send_progress(
|
||||
f"⚠️ 解析失败,使用备用数据",
|
||||
96.5
|
||||
)
|
||||
outline_data = _parse_ai_response(ai_content, raise_on_error=False)
|
||||
break
|
||||
|
||||
logger.warning(f"⚠️ JSON解析失败(第{retry_count}次),正在重试...")
|
||||
yield await SSEResponse.send_progress(
|
||||
f"⚠️ 解析失败,正在重试({retry_count}/{max_retries})...",
|
||||
96
|
||||
)
|
||||
|
||||
# 重新调用AI生成
|
||||
accumulated_text = ""
|
||||
chunk_count = 0
|
||||
|
||||
# 在prompt中添加格式强调
|
||||
retry_prompt = prompt + "\n\n【重要提醒】请确保返回完整的JSON数组,不要截断。每个章节对象必须包含完整的title、summary等字段。"
|
||||
|
||||
async for chunk in user_ai_service.generate_text_stream(
|
||||
prompt=retry_prompt,
|
||||
provider=provider_param,
|
||||
model=model_param
|
||||
):
|
||||
chunk_count += 1
|
||||
accumulated_text += chunk
|
||||
|
||||
# 发送内容块
|
||||
yield await SSEResponse.send_chunk(chunk)
|
||||
|
||||
# 每20个块发送心跳
|
||||
if chunk_count % 20 == 0:
|
||||
yield await SSEResponse.send_heartbeat()
|
||||
|
||||
ai_content = accumulated_text
|
||||
ai_response = {"content": ai_content}
|
||||
logger.info(f"🔄 重试生成完成,累计{len(ai_content)}字符")
|
||||
|
||||
# 全新生成模式:删除旧大纲和关联的所有章节
|
||||
yield await SSEResponse.send_progress("清理旧大纲和章节...", 97)
|
||||
@@ -1919,8 +2043,60 @@ async def continue_outline_generator(
|
||||
ai_content = accumulated_text
|
||||
ai_response = {"content": ai_content}
|
||||
|
||||
# 解析响应
|
||||
outline_data = _parse_ai_response(ai_content)
|
||||
# 解析响应(带重试机制)
|
||||
max_retries = 2
|
||||
retry_count = 0
|
||||
outline_data = None
|
||||
|
||||
while retry_count <= max_retries:
|
||||
try:
|
||||
# 使用 raise_on_error=True,解析失败时抛出异常
|
||||
outline_data = _parse_ai_response(ai_content, raise_on_error=True)
|
||||
break # 解析成功,跳出循环
|
||||
|
||||
except JSONParseError as e:
|
||||
retry_count += 1
|
||||
if retry_count > max_retries:
|
||||
# 超过最大重试次数,使用fallback数据
|
||||
logger.error(f"❌ 第{batch_num + 1}批解析失败,已达最大重试次数({max_retries}),使用fallback数据")
|
||||
yield await SSEResponse.send_progress(
|
||||
f"⚠️ 第{str(batch_num + 1)}批解析失败,使用备用数据",
|
||||
batch_progress + 11
|
||||
)
|
||||
outline_data = _parse_ai_response(ai_content, raise_on_error=False)
|
||||
break
|
||||
|
||||
logger.warning(f"⚠️ 第{batch_num + 1}批JSON解析失败(第{retry_count}次),正在重试...")
|
||||
yield await SSEResponse.send_progress(
|
||||
f"⚠️ 第{str(batch_num + 1)}批解析失败,正在重试({retry_count}/{max_retries})...",
|
||||
batch_progress + 10.5
|
||||
)
|
||||
|
||||
# 重新调用AI生成
|
||||
accumulated_text = ""
|
||||
chunk_count = 0
|
||||
|
||||
# 在prompt中添加格式强调
|
||||
retry_prompt = prompt + "\n\n【重要提醒】请确保返回完整的JSON数组,不要截断。每个章节对象必须包含完整的title、summary等字段。"
|
||||
|
||||
async for chunk in user_ai_service.generate_text_stream(
|
||||
prompt=retry_prompt,
|
||||
provider=provider_param,
|
||||
model=model_param
|
||||
):
|
||||
chunk_count += 1
|
||||
accumulated_text += chunk
|
||||
|
||||
# 发送内容块
|
||||
yield await SSEResponse.send_chunk(chunk)
|
||||
|
||||
# 每20个块发送心跳
|
||||
if chunk_count % 20 == 0:
|
||||
yield await SSEResponse.send_heartbeat()
|
||||
|
||||
ai_content = accumulated_text
|
||||
ai_response = {"content": ai_content}
|
||||
logger.info(f"🔄 第{batch_num + 1}批重试生成完成,累计{len(ai_content)}字符")
|
||||
|
||||
# 保存当前批次的大纲
|
||||
batch_outlines = await _save_outlines(
|
||||
|
||||
@@ -71,12 +71,24 @@ class ChapterRegenerator:
|
||||
logger.info(f"🎯 提示词构建完成,开始AI生成")
|
||||
yield {'type': 'progress', 'progress': 15, 'message': '开始AI生成内容...'}
|
||||
|
||||
# 3. 流式生成新内容,同时跟踪进度
|
||||
# 3. 构建系统提示词(注入写作风格)
|
||||
system_prompt_with_style = None
|
||||
if style_content:
|
||||
system_prompt_with_style = f"""【🎨 写作风格要求 - 最高优先级】
|
||||
|
||||
{style_content}
|
||||
|
||||
⚠️ 请严格遵循上述写作风格要求进行重写,这是最重要的指令!
|
||||
确保在整个章节重写过程中始终保持风格的一致性。"""
|
||||
logger.info(f"✅ 已将写作风格注入系统提示词({len(style_content)}字符)")
|
||||
|
||||
# 4. 流式生成新内容,同时跟踪进度
|
||||
target_word_count = regenerate_request.target_word_count
|
||||
accumulated_length = 0
|
||||
|
||||
async for chunk in self.ai_service.generate_text_stream(
|
||||
prompt=full_prompt,
|
||||
system_prompt=system_prompt_with_style,
|
||||
temperature=0.7
|
||||
):
|
||||
# 发送内容块
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.2.3",
|
||||
"version": "1.2.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 167 KiB |
@@ -50,7 +50,7 @@ function App() {
|
||||
<Route path="/user-management" element={<ProtectedRoute><UserManagement /></ProtectedRoute>} />
|
||||
<Route path="/chapters/:chapterId/reader" element={<ProtectedRoute><ChapterReader /></ProtectedRoute>} />
|
||||
<Route path="/project/:projectId" element={<ProtectedRoute><ProjectDetail /></ProtectedRoute>}>
|
||||
<Route index element={<Navigate to="world-setting" replace />} />
|
||||
<Route index element={<Navigate to="sponsor" replace />} />
|
||||
<Route path="world-setting" element={<WorldSetting />} />
|
||||
<Route path="careers" element={<Careers />} />
|
||||
<Route path="outline" element={<Outline />} />
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useMemo, useEffect, useRef } from 'react';
|
||||
import { Tooltip } from 'antd';
|
||||
|
||||
// 标注数据类型
|
||||
export interface MemoryAnnotation {
|
||||
@@ -219,73 +218,15 @@ const AnnotatedText: React.FC<AnnotatedTextProps> = ({
|
||||
const icon = TYPE_ICONS[annotation.type];
|
||||
const isActive = activeAnnotationId === annotation.id;
|
||||
|
||||
// 🔧 工具提示内容:如果有多个标注,显示所有标注信息
|
||||
const tooltipContent = (
|
||||
<div style={{ maxWidth: 350 }}>
|
||||
{annotations && annotations.length > 1 ? (
|
||||
// 多个标注
|
||||
<div>
|
||||
<div style={{ fontWeight: 'bold', marginBottom: 8, borderBottom: '1px solid rgba(255,255,255,0.3)', paddingBottom: 4 }}>
|
||||
📍 此处有 {annotations.length} 个标注
|
||||
</div>
|
||||
{annotations.map((ann, idx) => (
|
||||
<div key={ann.id} style={{
|
||||
marginBottom: idx < annotations.length - 1 ? 8 : 0,
|
||||
paddingBottom: idx < annotations.length - 1 ? 8 : 0,
|
||||
borderBottom: idx < annotations.length - 1 ? '1px solid rgba(255,255,255,0.1)' : 'none'
|
||||
}}>
|
||||
<div style={{ fontWeight: 'bold', marginBottom: 4, fontSize: 13 }}>
|
||||
{TYPE_ICONS[ann.type]} {ann.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, opacity: 0.9 }}>
|
||||
{ann.content.slice(0, 80)}
|
||||
{ann.content.length > 80 ? '...' : ''}
|
||||
</div>
|
||||
<div style={{ marginTop: 4, fontSize: 10, opacity: 0.7 }}>
|
||||
重要性: {(ann.importance * 10).toFixed(1)}/10
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// 单个标注
|
||||
<div>
|
||||
<div style={{ fontWeight: 'bold', marginBottom: 4 }}>
|
||||
{icon} {annotation.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, opacity: 0.9 }}>
|
||||
{annotation.content.slice(0, 100)}
|
||||
{annotation.content.length > 100 ? '...' : ''}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 11, opacity: 0.7 }}>
|
||||
重要性: {(annotation.importance * 10).toFixed(1)}/10
|
||||
</div>
|
||||
{annotation.tags && annotation.tags.length > 0 && (
|
||||
<div style={{ marginTop: 4, fontSize: 11 }}>
|
||||
{annotation.tags.map((tag, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
background: 'rgba(255,255,255,0.2)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: 3,
|
||||
marginRight: 4,
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
// 简化工具提示内容,不再使用复杂的React元素,改为纯文本或移除Tooltip
|
||||
const tooltipText = annotations && annotations.length > 1
|
||||
? `此处有 ${annotations.length} 个标注`
|
||||
: `${annotation.title}: ${annotation.content.slice(0, 100)}${annotation.content.length > 100 ? '...' : ''}`;
|
||||
|
||||
return (
|
||||
<Tooltip key={index} title={tooltipContent} placement="top">
|
||||
<span
|
||||
key={index}
|
||||
title={tooltipText}
|
||||
ref={(el) => {
|
||||
if (annotation) {
|
||||
annotationRefs.current[annotation.id] = el;
|
||||
@@ -325,7 +266,6 @@ const AnnotatedText: React.FC<AnnotatedTextProps> = ({
|
||||
{icon}
|
||||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Typography, Space, Divider, Badge, Tooltip, Button } from 'antd';
|
||||
import { Typography, Space, Divider, Badge, Button } from 'antd';
|
||||
import { GithubOutlined, CopyrightOutlined, HeartFilled, ClockCircleOutlined, GiftOutlined } from '@ant-design/icons';
|
||||
import { VERSION_INFO, getVersionString } from '../config/version';
|
||||
import { checkLatestVersion } from '../services/versionService';
|
||||
@@ -70,7 +70,6 @@ export default function AppFooter() {
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<Badge dot={hasUpdate} offset={[-8, 2]}>
|
||||
<Tooltip title={hasUpdate ? `发现新版本 v${latestVersion},点击查看` : '当前版本'}>
|
||||
<Text
|
||||
onClick={handleVersionClick}
|
||||
style={{
|
||||
@@ -81,11 +80,11 @@ export default function AppFooter() {
|
||||
color: 'var(--color-primary)',
|
||||
cursor: hasUpdate ? 'pointer' : 'default',
|
||||
}}
|
||||
title={hasUpdate ? `发现新版本 v${latestVersion},点击查看` : '当前版本'}
|
||||
>
|
||||
<strong style={{ color: 'var(--color-text-primary)' }}>{VERSION_INFO.projectName}</strong>
|
||||
<span>{getVersionString()}</span>
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Badge>
|
||||
<Divider type="vertical" style={{ margin: '0 4px', borderColor: 'var(--color-border)' }} />
|
||||
<Button
|
||||
@@ -144,7 +143,6 @@ export default function AppFooter() {
|
||||
>
|
||||
{/* 版本信息 */}
|
||||
<Badge dot={hasUpdate} offset={[-8, 2]}>
|
||||
<Tooltip title={hasUpdate ? `发现新版本 v${latestVersion},点击查看` : '当前版本'}>
|
||||
<Text
|
||||
onClick={handleVersionClick}
|
||||
style={{
|
||||
@@ -167,11 +165,11 @@ export default function AppFooter() {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}
|
||||
}}
|
||||
title={hasUpdate ? `发现新版本 v${latestVersion},点击查看` : '当前版本'}
|
||||
>
|
||||
<strong style={{ color: 'var(--color-text-primary)' }}>{VERSION_INFO.projectName}</strong>
|
||||
<span>{getVersionString()}</span>
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Badge>
|
||||
|
||||
{/* GitHub 链接 */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Modal, Timeline, Tag, Avatar, Empty, Spin, Button, Space, Tooltip } from 'antd';
|
||||
import { Modal, Timeline, Tag, Avatar, Empty, Spin, Button, Space } from 'antd';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
BugOutlined,
|
||||
@@ -130,15 +130,14 @@ export default function ChangelogModal({ visible, onClose }: ChangelogModalProps
|
||||
<Space>
|
||||
<GithubOutlined />
|
||||
<span>更新日志</span>
|
||||
<Tooltip title="刷新">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleRefresh}
|
||||
loading={loading}
|
||||
title="刷新"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
}
|
||||
open={visible}
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
/**
|
||||
* 章节阅读器组件
|
||||
* 提供沉浸式阅读体验,支持主题切换、字体调节、翻页导航等功能
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Modal, Button, Slider, Radio, Space, Typography, Spin, message } from 'antd';
|
||||
import {
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
SettingOutlined,
|
||||
FontSizeOutlined,
|
||||
BgColorsOutlined,
|
||||
CloseOutlined,
|
||||
ColumnHeightOutlined
|
||||
} from '@ant-design/icons';
|
||||
import type { Chapter } from '../types';
|
||||
|
||||
// 阅读器设置接口
|
||||
interface ReaderSettings {
|
||||
fontSize: number; // 字体大小
|
||||
theme: 'light' | 'sepia' | 'dark'; // 主题模式
|
||||
lineHeight: number; // 行高
|
||||
}
|
||||
|
||||
// 组件属性接口
|
||||
interface ChapterReaderProps {
|
||||
visible: boolean; // 是否显示
|
||||
chapter: Chapter; // 当前章节
|
||||
onClose: () => void; // 关闭回调
|
||||
onChapterChange: (chapterId: string) => void; // 章节切换回调
|
||||
}
|
||||
|
||||
// 导航信息接口
|
||||
interface NavigationInfo {
|
||||
previous: { id: string; chapter_number: number; title: string } | null;
|
||||
next: { id: string; chapter_number: number; title: string } | null;
|
||||
current: { id: string; chapter_number: number; title: string };
|
||||
}
|
||||
|
||||
// 主题样式配置
|
||||
const themeStyles = {
|
||||
light: {
|
||||
bg: '#ffffff',
|
||||
text: '#333333',
|
||||
headerBg: '#fafafa',
|
||||
border: '#e8e8e8'
|
||||
},
|
||||
sepia: {
|
||||
bg: '#f5e6c8',
|
||||
text: '#5b4636',
|
||||
headerBg: '#e8d9b8',
|
||||
border: '#d4c5a5'
|
||||
},
|
||||
dark: {
|
||||
bg: '#1a1a1a',
|
||||
text: '#cccccc',
|
||||
headerBg: '#252525',
|
||||
border: '#333333'
|
||||
}
|
||||
};
|
||||
|
||||
// 本地存储key
|
||||
const SETTINGS_STORAGE_KEY = 'chapter-reader-settings';
|
||||
|
||||
// 从本地存储加载设置
|
||||
const loadSettings = (): ReaderSettings => {
|
||||
try {
|
||||
const saved = localStorage.getItem(SETTINGS_STORAGE_KEY);
|
||||
if (saved) {
|
||||
return JSON.parse(saved);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('加载阅读器设置失败:', e);
|
||||
}
|
||||
return {
|
||||
fontSize: 18,
|
||||
theme: 'light',
|
||||
lineHeight: 1.8
|
||||
};
|
||||
};
|
||||
|
||||
// 保存设置到本地存储
|
||||
const saveSettings = (settings: ReaderSettings) => {
|
||||
try {
|
||||
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
|
||||
} catch (e) {
|
||||
console.warn('保存阅读器设置失败:', e);
|
||||
}
|
||||
};
|
||||
|
||||
export default function ChapterReader({
|
||||
visible,
|
||||
chapter,
|
||||
onClose,
|
||||
onChapterChange
|
||||
}: ChapterReaderProps) {
|
||||
// 阅读器设置
|
||||
const [settings, setSettings] = useState<ReaderSettings>(loadSettings);
|
||||
|
||||
// 导航信息
|
||||
const [navigation, setNavigation] = useState<NavigationInfo | null>(null);
|
||||
|
||||
// 加载状态
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 设置面板显示状态
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
|
||||
// 移动端检测
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
|
||||
|
||||
// 响应式检测
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(window.innerWidth <= 768);
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// 获取章节导航信息
|
||||
useEffect(() => {
|
||||
if (visible && chapter?.id) {
|
||||
setLoading(true);
|
||||
fetch(`/api/chapters/${chapter.id}/navigation`)
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error('获取导航失败');
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
setNavigation(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('获取导航信息失败:', err);
|
||||
message.error('获取章节导航信息失败');
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [visible, chapter?.id]);
|
||||
|
||||
// 保存设置变更
|
||||
useEffect(() => {
|
||||
saveSettings(settings);
|
||||
}, [settings]);
|
||||
|
||||
// 上一章
|
||||
const handlePrevious = useCallback(() => {
|
||||
if (navigation?.previous) {
|
||||
setLoading(true);
|
||||
onChapterChange(navigation.previous.id);
|
||||
}
|
||||
}, [navigation?.previous, onChapterChange]);
|
||||
|
||||
// 下一章
|
||||
const handleNext = useCallback(() => {
|
||||
if (navigation?.next) {
|
||||
setLoading(true);
|
||||
onChapterChange(navigation.next.id);
|
||||
}
|
||||
}, [navigation?.next, onChapterChange]);
|
||||
|
||||
// 键盘快捷键
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!visible) return;
|
||||
|
||||
// 忽略输入框中的按键
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
handlePrevious();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
handleNext();
|
||||
break;
|
||||
case 'Escape':
|
||||
onClose();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [visible, handlePrevious, handleNext, onClose]);
|
||||
|
||||
// 章节变化后自动回到顶部
|
||||
useEffect(() => {
|
||||
if (chapter?.id) {
|
||||
setLoading(false);
|
||||
// 找到滚动容器并滚动到顶部
|
||||
const scrollContainer = document.querySelector('.reader-scroll-container');
|
||||
if (scrollContainer) {
|
||||
scrollContainer.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
}, [chapter?.id]);
|
||||
|
||||
// 当前主题样式
|
||||
const currentTheme = themeStyles[settings.theme];
|
||||
|
||||
// 更新设置的便捷函数
|
||||
const updateSettings = (key: keyof ReaderSettings, value: number | string) => {
|
||||
setSettings(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width="100%"
|
||||
style={{
|
||||
maxWidth: '100vw',
|
||||
top: 0,
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
height: '100vh',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
styles={{
|
||||
content: {
|
||||
height: '100vh',
|
||||
borderRadius: 0,
|
||||
boxShadow: 'none',
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
padding: 0,
|
||||
background: currentTheme.bg,
|
||||
overflow: 'hidden',
|
||||
height: '100%',
|
||||
scrollbarWidth: 'thin',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}
|
||||
}}
|
||||
closable={false}
|
||||
maskClosable={false}
|
||||
>
|
||||
{/* 顶部工具栏 */}
|
||||
<div style={{
|
||||
flex: 'none',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: isMobile ? '10px 12px' : '12px 20px',
|
||||
borderBottom: `1px solid ${currentTheme.border}`,
|
||||
background: currentTheme.headerBg,
|
||||
zIndex: 10
|
||||
}}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={onClose}
|
||||
style={{ color: currentTheme.text }}
|
||||
>
|
||||
{!isMobile && '关闭'}
|
||||
</Button>
|
||||
|
||||
<Typography.Title
|
||||
level={5}
|
||||
style={{
|
||||
margin: 0,
|
||||
color: currentTheme.text,
|
||||
maxWidth: isMobile ? '60%' : '70%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: isMobile ? 14 : 16
|
||||
}}
|
||||
>
|
||||
第{chapter.chapter_number}章:{chapter.title}
|
||||
</Typography.Title>
|
||||
|
||||
<Button
|
||||
type={showSettings ? 'primary' : 'text'}
|
||||
icon={<SettingOutlined />}
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
style={{ color: showSettings ? undefined : currentTheme.text }}
|
||||
title="阅读设置"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 设置面板 */}
|
||||
{showSettings && (
|
||||
<div style={{
|
||||
padding: isMobile ? '12px 16px' : '16px 24px',
|
||||
borderBottom: `1px solid ${currentTheme.border}`,
|
||||
background: currentTheme.headerBg
|
||||
}}>
|
||||
<Space
|
||||
direction={isMobile ? 'vertical' : 'horizontal'}
|
||||
size="large"
|
||||
style={{ width: '100%' }}
|
||||
wrap
|
||||
>
|
||||
{/* 字体大小 */}
|
||||
<div style={{ minWidth: isMobile ? '100%' : 200 }}>
|
||||
<Space style={{ marginBottom: 8, color: currentTheme.text }}>
|
||||
<FontSizeOutlined />
|
||||
<span>字体大小: {settings.fontSize}px</span>
|
||||
</Space>
|
||||
<Slider
|
||||
min={14}
|
||||
max={28}
|
||||
value={settings.fontSize}
|
||||
onChange={v => updateSettings('fontSize', v)}
|
||||
style={{ margin: '8px 0' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 行高 */}
|
||||
<div style={{ minWidth: isMobile ? '100%' : 200 }}>
|
||||
<Space style={{ marginBottom: 8, color: currentTheme.text }}>
|
||||
<ColumnHeightOutlined />
|
||||
<span>行高: {settings.lineHeight}</span>
|
||||
</Space>
|
||||
<Slider
|
||||
min={1.4}
|
||||
max={2.5}
|
||||
step={0.1}
|
||||
value={settings.lineHeight}
|
||||
onChange={v => updateSettings('lineHeight', v)}
|
||||
style={{ margin: '8px 0' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 主题 */}
|
||||
<div>
|
||||
<Space style={{ marginBottom: 8, color: currentTheme.text }}>
|
||||
<BgColorsOutlined />
|
||||
<span>主题</span>
|
||||
</Space>
|
||||
<div>
|
||||
<Radio.Group
|
||||
value={settings.theme}
|
||||
onChange={e => updateSettings('theme', e.target.value)}
|
||||
buttonStyle="solid"
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
>
|
||||
<Radio.Button value="light">日间</Radio.Button>
|
||||
<Radio.Button value="sepia">护眼</Radio.Button>
|
||||
<Radio.Button value="dark">夜间</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 章节内容区域 */}
|
||||
<div
|
||||
className="reader-scroll-container"
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
position: 'relative',
|
||||
scrollBehavior: 'smooth'
|
||||
}}
|
||||
>
|
||||
<Spin spinning={loading} tip="加载中...">
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 1000,
|
||||
margin: '0 auto',
|
||||
padding: isMobile ? '24px 16px 40px' : '40px 60px 40px',
|
||||
minHeight: '100%',
|
||||
fontSize: settings.fontSize,
|
||||
lineHeight: settings.lineHeight,
|
||||
color: currentTheme.text,
|
||||
whiteSpace: 'pre-wrap',
|
||||
textAlign: 'justify',
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'break-word'
|
||||
}}
|
||||
>
|
||||
{chapter.content ? (
|
||||
// 按段落渲染内容,优化阅读体验
|
||||
chapter.content.split('\n').map((paragraph, index) => (
|
||||
paragraph.trim() ? (
|
||||
<p
|
||||
key={index}
|
||||
style={{
|
||||
textIndent: '2em',
|
||||
margin: 0,
|
||||
marginBottom: '0.8em'
|
||||
}}
|
||||
>
|
||||
{paragraph}
|
||||
</p>
|
||||
) : (
|
||||
<br key={index} />
|
||||
)
|
||||
))
|
||||
) : (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '60px 20px',
|
||||
color: currentTheme.text,
|
||||
opacity: 0.6
|
||||
}}>
|
||||
暂无内容
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Spin>
|
||||
</div>
|
||||
|
||||
{/* 底部导航栏 */}
|
||||
<div style={{
|
||||
flex: 'none',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: isMobile ? '12px 16px' : '16px 24px',
|
||||
borderTop: `1px solid ${currentTheme.border}`,
|
||||
background: currentTheme.headerBg,
|
||||
zIndex: 100
|
||||
}}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<LeftOutlined />}
|
||||
disabled={!navigation?.previous || loading}
|
||||
onClick={handlePrevious}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
>
|
||||
{!isMobile && '上一章'}
|
||||
</Button>
|
||||
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
color: currentTheme.text,
|
||||
fontSize: isMobile ? 12 : 14
|
||||
}}>
|
||||
<div>{chapter.word_count || 0} 字</div>
|
||||
{navigation && (
|
||||
<div style={{ fontSize: isMobile ? 10 : 12, opacity: 0.7 }}>
|
||||
{navigation.previous ? `← ${navigation.previous.title}` : '已是第一章'}
|
||||
{' | '}
|
||||
{navigation.next ? `${navigation.next.title} →` : '已是最后一章'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!navigation?.next || loading}
|
||||
onClick={handleNext}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
>
|
||||
{!isMobile && '下一章'}
|
||||
<RightOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
Select,
|
||||
message,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Spin,
|
||||
Empty,
|
||||
Alert,
|
||||
@@ -307,9 +306,7 @@ export default function MCPPluginsPage() {
|
||||
return <Tag color="success" icon={<CheckCircleOutlined />}>运行中</Tag>;
|
||||
case 'error':
|
||||
return (
|
||||
<Tooltip title={plugin.last_error}>
|
||||
<Tag color="error" icon={<CloseCircleOutlined />}>错误</Tag>
|
||||
</Tooltip>
|
||||
<Tag color="error" icon={<CloseCircleOutlined />} title={plugin.last_error}>错误</Tag>
|
||||
);
|
||||
default:
|
||||
return <Tag color="default">未激活</Tag>;
|
||||
@@ -553,8 +550,8 @@ export default function MCPPluginsPage() {
|
||||
</div>
|
||||
|
||||
<Space size="small" wrap>
|
||||
<Tooltip title={plugin.enabled ? '禁用插件' : '启用插件'}>
|
||||
<Switch
|
||||
title={plugin.enabled ? '禁用插件' : '启用插件'}
|
||||
checked={plugin.enabled}
|
||||
onChange={(checked) => handleToggle(plugin, checked)}
|
||||
size={isMobile ? 'small' : 'default'}
|
||||
@@ -565,38 +562,33 @@ export default function MCPPluginsPage() {
|
||||
lineHeight: isMobile ? '16px' : '22px'
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="测试连接">
|
||||
<Button
|
||||
title="测试连接"
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => handleTest(plugin.id)}
|
||||
loading={testingPluginId === plugin.id}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="查看工具">
|
||||
<Button
|
||||
title="查看工具"
|
||||
icon={<ToolOutlined />}
|
||||
onClick={() => handleViewTools(plugin.id)}
|
||||
disabled={!plugin.enabled || plugin.status !== 'active'}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="编辑">
|
||||
<Button
|
||||
title="编辑"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(plugin)}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="删除">
|
||||
<Button
|
||||
title="删除"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleDelete(plugin)}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { Button, List, Modal, Form, Input, message, Empty, Space, Popconfirm, Card, Select, Radio, Tag, InputNumber, Tabs } from 'antd';
|
||||
import { EditOutlined, DeleteOutlined, ThunderboltOutlined, BranchesOutlined, AppstoreAddOutlined, CheckCircleOutlined, ExclamationCircleOutlined, PlusOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import { useOutlineSync } from '../store/hooks';
|
||||
@@ -1932,16 +1932,15 @@ export default function Outline() {
|
||||
{isMobile ? 'AI生成/续写' : 'AI生成/续写大纲'}
|
||||
</Button>
|
||||
{outlines.length > 0 && currentProject?.outline_mode === 'one-to-many' && (
|
||||
<Tooltip title="将所有大纲展开为多章,实现从大纲到章节的一对多关系">
|
||||
<Button
|
||||
icon={<AppstoreAddOutlined />}
|
||||
onClick={handleBatchExpandOutlines}
|
||||
loading={isExpanding}
|
||||
disabled={isGenerating}
|
||||
title="将所有大纲展开为多章,实现从大纲到章节的一对多关系"
|
||||
>
|
||||
{isMobile ? '批量展开' : '批量展开为多章'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
@@ -1965,16 +1964,16 @@ export default function Outline() {
|
||||
}}
|
||||
actions={isMobile ? undefined : [
|
||||
...(currentProject?.outline_mode === 'one-to-many' ? [
|
||||
<Tooltip title="展开为多章">
|
||||
<Button
|
||||
key="expand"
|
||||
type="text"
|
||||
icon={<BranchesOutlined />}
|
||||
onClick={() => handleExpandOutline(item.id, item.title)}
|
||||
loading={isExpanding}
|
||||
title="展开为多章"
|
||||
>
|
||||
展开
|
||||
</Button>
|
||||
</Tooltip>
|
||||
] : []), // 一对一模式:不显示任何展开/创建按钮
|
||||
<Button
|
||||
type="text"
|
||||
@@ -2034,15 +2033,14 @@ export default function Outline() {
|
||||
/>
|
||||
{/* 一对多模式:显示展开按钮 */}
|
||||
{currentProject?.outline_mode === 'one-to-many' && (
|
||||
<Tooltip title="展开为多章">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<BranchesOutlined />}
|
||||
onClick={() => handleExpandOutline(item.id, item.title)}
|
||||
loading={isExpanding}
|
||||
size="small"
|
||||
title="展开为多章"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* 一对一模式:不显示任何展开/创建按钮 */}
|
||||
<Popconfirm
|
||||
|
||||
@@ -95,6 +95,11 @@ export default function ProjectDetail() {
|
||||
// Hook 内部已经更新了 store,不需要再次刷新
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
key: 'sponsor',
|
||||
icon: <HeartOutlined />,
|
||||
label: <Link to={`/project/${projectId}/sponsor`}>赞助支持</Link>,
|
||||
},
|
||||
{
|
||||
key: 'world-setting',
|
||||
icon: <GlobalOutlined />,
|
||||
@@ -145,11 +150,6 @@ export default function ProjectDetail() {
|
||||
// icon: <ToolOutlined />,
|
||||
// label: <Link to={`/project/${projectId}/polish`}>AI去味</Link>,
|
||||
// },
|
||||
{
|
||||
key: 'sponsor',
|
||||
icon: <HeartOutlined />,
|
||||
label: <Link to={`/project/${projectId}/sponsor`}>赞助支持</Link>,
|
||||
},
|
||||
];
|
||||
|
||||
// 根据当前路径动态确定选中的菜单项
|
||||
@@ -166,7 +166,7 @@ export default function ProjectDetail() {
|
||||
if (path.includes('/writing-styles')) return 'writing-styles';
|
||||
if (path.includes('/sponsor')) return 'sponsor';
|
||||
// if (path.includes('/polish')) return 'polish';
|
||||
return 'world-setting'; // 默认选中世界设定
|
||||
return 'sponsor'; // 默认选中赞助支持
|
||||
}, [location.pathname]);
|
||||
|
||||
if (loading || !currentProject) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, Button, Empty, Modal, message, Spin, Row, Col, Statistic, Space, Tag, Progress, Typography, Tooltip, Badge, Alert, Upload, Checkbox, Divider, Switch, Dropdown, Form, Input, InputNumber } from 'antd';
|
||||
import { Card, Button, Empty, Modal, message, Spin, Row, Col, Statistic, Space, Tag, Progress, Typography, Badge, Alert, Upload, Checkbox, Divider, Switch, Dropdown, Form, Input, InputNumber } from 'antd';
|
||||
import { EditOutlined, DeleteOutlined, BookOutlined, RocketOutlined, CalendarOutlined, FileTextOutlined, TrophyOutlined, FireOutlined, SettingOutlined, InfoCircleOutlined, CloseOutlined, UploadOutlined, DownloadOutlined, ApiOutlined, MoreOutlined, BulbOutlined, LoadingOutlined, FileSearchOutlined } from '@ant-design/icons';
|
||||
import { projectApi } from '../services/api';
|
||||
import { useStore } from '../store';
|
||||
@@ -1031,7 +1031,6 @@ export default function ProjectList() {
|
||||
{formatDate(project.updated_at)}
|
||||
</Text>
|
||||
<Space size={4}>
|
||||
<Tooltip title="编辑">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
@@ -1046,8 +1045,6 @@ export default function ProjectList() {
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="删除">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
@@ -1062,7 +1059,6 @@ export default function ProjectList() {
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1249,9 +1245,10 @@ export default function ProjectList() {
|
||||
}}
|
||||
/>
|
||||
<Text style={{ fontSize: window.innerWidth <= 768 ? 13 : 14 }}>包含写作风格</Text>
|
||||
<Tooltip title="导出项目关联的写作风格数据">
|
||||
<InfoCircleOutlined style={{ color: '#999', fontSize: window.innerWidth <= 768 ? 12 : 14 }} />
|
||||
</Tooltip>
|
||||
<InfoCircleOutlined
|
||||
title="导出项目关联的写作风格数据"
|
||||
style={{ color: '#999', fontSize: window.innerWidth <= 768 ? 12 : 14 }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Switch
|
||||
@@ -1266,9 +1263,10 @@ export default function ProjectList() {
|
||||
}}
|
||||
/>
|
||||
<Text style={{ fontSize: window.innerWidth <= 768 ? 13 : 14 }}>包含生成历史</Text>
|
||||
<Tooltip title="导出AI生成的历史记录(最多100条)">
|
||||
<InfoCircleOutlined style={{ color: '#999', fontSize: window.innerWidth <= 768 ? 12 : 14 }} />
|
||||
</Tooltip>
|
||||
<InfoCircleOutlined
|
||||
title="导出AI生成的历史记录(最多100条)"
|
||||
style={{ color: '#999', fontSize: window.innerWidth <= 768 ? 12 : 14 }}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, Form, Input, Button, Select, Slider, InputNumber, message, Space, Typography, Spin, Modal, Tooltip, Alert, Grid, Tabs, List, Tag, Popconfirm, Empty, Row, Col } from 'antd';
|
||||
import { Card, Form, Input, Button, Select, Slider, InputNumber, message, Space, Typography, Spin, Modal, Alert, Grid, Tabs, List, Tag, Popconfirm, Empty, Row, Col } from 'antd';
|
||||
import { SettingOutlined, SaveOutlined, DeleteOutlined, ReloadOutlined, ArrowLeftOutlined, InfoCircleOutlined, CheckCircleOutlined, CloseCircleOutlined, ThunderboltOutlined, PlusOutlined, EditOutlined, CopyOutlined } from '@ant-design/icons';
|
||||
import { settingsApi } from '../services/api';
|
||||
import type { SettingsUpdate, APIKeyPreset, PresetCreateRequest, APIKeyPresetConfig } from '../types';
|
||||
@@ -544,16 +544,15 @@ export default function SettingsPage() {
|
||||
激活
|
||||
</Button>
|
||||
),
|
||||
<Tooltip title="测试连接">
|
||||
<Button
|
||||
key="test"
|
||||
type="link"
|
||||
icon={<ThunderboltOutlined />}
|
||||
loading={testingPresetId === preset.id}
|
||||
onClick={() => handlePresetTest(preset.id)}
|
||||
>
|
||||
测试
|
||||
</Button>
|
||||
</Tooltip>,
|
||||
</Button>,
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EditOutlined />}
|
||||
@@ -766,9 +765,10 @@ export default function SettingsPage() {
|
||||
label={
|
||||
<Space size={4}>
|
||||
<span>API 提供商</span>
|
||||
<Tooltip title="选择你的AI服务提供商">
|
||||
<InfoCircleOutlined style={{ color: 'var(--color-text-secondary)', fontSize: isMobile ? '12px' : '14px' }} />
|
||||
</Tooltip>
|
||||
<InfoCircleOutlined
|
||||
title="选择你的AI服务提供商"
|
||||
style={{ color: 'var(--color-text-secondary)', fontSize: isMobile ? '12px' : '14px' }}
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
name="api_provider"
|
||||
@@ -787,9 +787,10 @@ export default function SettingsPage() {
|
||||
label={
|
||||
<Space size={4}>
|
||||
<span>API 密钥</span>
|
||||
<Tooltip title="你的API密钥,将加密存储">
|
||||
<InfoCircleOutlined style={{ color: 'var(--color-text-secondary)', fontSize: isMobile ? '12px' : '14px' }} />
|
||||
</Tooltip>
|
||||
<InfoCircleOutlined
|
||||
title="你的API密钥,将加密存储"
|
||||
style={{ color: 'var(--color-text-secondary)', fontSize: isMobile ? '12px' : '14px' }}
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
name="api_key"
|
||||
@@ -806,9 +807,10 @@ export default function SettingsPage() {
|
||||
label={
|
||||
<Space size={4}>
|
||||
<span>API 地址</span>
|
||||
<Tooltip title="API的基础URL地址">
|
||||
<InfoCircleOutlined style={{ color: 'var(--color-text-secondary)', fontSize: isMobile ? '12px' : '14px' }} />
|
||||
</Tooltip>
|
||||
<InfoCircleOutlined
|
||||
title="API的基础URL地址"
|
||||
style={{ color: 'var(--color-text-secondary)', fontSize: isMobile ? '12px' : '14px' }}
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
name="api_base_url"
|
||||
@@ -827,9 +829,10 @@ export default function SettingsPage() {
|
||||
label={
|
||||
<Space size={4}>
|
||||
<span>模型名称</span>
|
||||
<Tooltip title="AI模型的名称,如 gpt-4, gpt-3.5-turbo">
|
||||
<InfoCircleOutlined style={{ color: 'var(--color-text-secondary)', fontSize: isMobile ? '12px' : '14px' }} />
|
||||
</Tooltip>
|
||||
<InfoCircleOutlined
|
||||
title="AI模型的名称,如 gpt-4, gpt-3.5-turbo"
|
||||
style={{ color: 'var(--color-text-secondary)', fontSize: isMobile ? '12px' : '14px' }}
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
name="llm_model"
|
||||
@@ -931,9 +934,10 @@ export default function SettingsPage() {
|
||||
label={
|
||||
<Space size={4}>
|
||||
<span>温度参数</span>
|
||||
<Tooltip title="控制输出的随机性,值越高越随机(0.0-2.0)">
|
||||
<InfoCircleOutlined style={{ color: 'var(--color-text-secondary)', fontSize: isMobile ? '12px' : '14px' }} />
|
||||
</Tooltip>
|
||||
<InfoCircleOutlined
|
||||
title="控制输出的随机性,值越高越随机(0.0-2.0)"
|
||||
style={{ color: 'var(--color-text-secondary)', fontSize: isMobile ? '12px' : '14px' }}
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
name="temperature"
|
||||
@@ -955,9 +959,10 @@ export default function SettingsPage() {
|
||||
label={
|
||||
<Space size={4}>
|
||||
<span>最大 Token 数</span>
|
||||
<Tooltip title="单次请求的最大token数量">
|
||||
<InfoCircleOutlined style={{ color: 'var(--color-text-secondary)', fontSize: isMobile ? '12px' : '14px' }} />
|
||||
</Tooltip>
|
||||
<InfoCircleOutlined
|
||||
title="单次请求的最大token数量"
|
||||
style={{ color: 'var(--color-text-secondary)', fontSize: isMobile ? '12px' : '14px' }}
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
name="max_tokens"
|
||||
@@ -978,9 +983,10 @@ export default function SettingsPage() {
|
||||
label={
|
||||
<Space size={4}>
|
||||
<span>系统提示词</span>
|
||||
<Tooltip title="设置全局系统提示词,每次AI调用时都会自动使用。可用于设定AI的角色、语言风格等">
|
||||
<InfoCircleOutlined style={{ color: 'var(--color-text-secondary)', fontSize: isMobile ? '12px' : '14px' }} />
|
||||
</Tooltip>
|
||||
<InfoCircleOutlined
|
||||
title="设置全局系统提示词,每次AI调用时都会自动使用。可用于设定AI的角色、语言风格等"
|
||||
style={{ color: 'var(--color-text-secondary)', fontSize: isMobile ? '12px' : '14px' }}
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
name="system_prompt"
|
||||
|
||||
@@ -21,9 +21,9 @@ interface SponsorOption {
|
||||
const sponsorOptions: SponsorOption[] = [
|
||||
{ amount: 5, label: '🌶️ 一包辣条', image: '/5.png', description: '¥5' },
|
||||
{ amount: 10, label: '🍱 一顿拼好饭', image: '/10.png', description: '¥10' },
|
||||
{ amount: 20, label: '🧋 一杯奶茶', image: '/20.png', description: '¥20' },
|
||||
{ amount: 20, label: '🧋 一杯咖啡', image: '/20.png', description: '¥20' },
|
||||
{ amount: 50, label: '🍖 一次烧烤', image: '/50.png', description: '¥50' },
|
||||
{ amount: 'custom', label: '💰 任意金额', image: '/xx.png', description: '自定义' },
|
||||
{ amount: 99, label: '🍲 一顿海底捞', image: '/99.png', description: '¥99' },
|
||||
];
|
||||
|
||||
const benefits = [
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
Typography,
|
||||
Badge,
|
||||
InputNumber,
|
||||
Tooltip,
|
||||
Row,
|
||||
Col,
|
||||
Pagination,
|
||||
@@ -350,7 +349,6 @@ export default function UserManagement() {
|
||||
// 桌面端:保持原有按钮样式
|
||||
return (
|
||||
<Space size="small">
|
||||
<Tooltip title="编辑用户">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@@ -359,9 +357,7 @@ export default function UserManagement() {
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="重置密码">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@@ -370,7 +366,6 @@ export default function UserManagement() {
|
||||
>
|
||||
重置密码
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Popconfirm
|
||||
title={`确定${isActive ? '禁用' : '启用'}该用户吗?`}
|
||||
@@ -378,7 +373,6 @@ export default function UserManagement() {
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Tooltip title={isActive ? '禁用用户' : '启用用户'}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@@ -387,7 +381,6 @@ export default function UserManagement() {
|
||||
>
|
||||
{isActive ? '禁用' : '启用'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
|
||||
{!record.is_admin && (
|
||||
@@ -398,7 +391,6 @@ export default function UserManagement() {
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Tooltip title="删除用户">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@@ -407,7 +399,6 @@ export default function UserManagement() {
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
@@ -12,8 +12,7 @@ import {
|
||||
Empty,
|
||||
Typography,
|
||||
Row,
|
||||
Col,
|
||||
Tooltip
|
||||
Col
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
@@ -232,8 +231,8 @@ export default function WritingStyles() {
|
||||
padding: '16px',
|
||||
}}
|
||||
actions={[
|
||||
<Tooltip key="default" title={style.is_default ? '当前默认' : '设为默认'}>
|
||||
<span
|
||||
key="default"
|
||||
onClick={() => !style.is_default && handleSetDefault(style.id)}
|
||||
style={{ cursor: style.is_default ? 'default' : 'pointer' }}
|
||||
>
|
||||
@@ -242,18 +241,16 @@ export default function WritingStyles() {
|
||||
) : (
|
||||
<StarOutlined style={{ fontSize: 18 }} />
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>,
|
||||
<Tooltip key="edit" title={style.user_id === null ? '预设风格不可编辑' : '编辑'}>
|
||||
</span>,
|
||||
<EditOutlined
|
||||
key="edit"
|
||||
onClick={() => style.user_id !== null && handleEdit(style)}
|
||||
style={{
|
||||
fontSize: 18,
|
||||
cursor: style.user_id === null ? 'not-allowed' : 'pointer',
|
||||
color: style.user_id === null ? '#ccc' : undefined
|
||||
}}
|
||||
/>
|
||||
</Tooltip>,
|
||||
/>,
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title="确定删除这个风格吗?"
|
||||
@@ -263,11 +260,6 @@ export default function WritingStyles() {
|
||||
cancelText="取消"
|
||||
disabled={style.user_id === null}
|
||||
>
|
||||
<Tooltip title={
|
||||
style.user_id === null
|
||||
? '预设风格不可删除'
|
||||
: '删除'
|
||||
}>
|
||||
<DeleteOutlined
|
||||
style={{
|
||||
fontSize: 18,
|
||||
@@ -275,7 +267,6 @@ export default function WritingStyles() {
|
||||
cursor: style.user_id === null ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popconfirm>,
|
||||
]}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user