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
+211 -17
View File
@@ -9,6 +9,7 @@ import hashlib
from datetime import datetime, timedelta, timezone
from app.services.oauth_service import LinuxDOOAuthService
from app.user_manager import user_manager
from app.user_password import password_manager
from app.database import init_db
from app.logger import get_logger
from app.config import settings
@@ -49,6 +50,25 @@ class LocalLoginResponse(BaseModel):
user: Optional[dict] = None
class SetPasswordRequest(BaseModel):
"""设置密码请求"""
password: str
class SetPasswordResponse(BaseModel):
"""设置密码响应"""
success: bool
message: str
class PasswordStatusResponse(BaseModel):
"""密码状态响应"""
has_password: bool
has_custom_password: bool
username: Optional[str] = None
default_password: Optional[str] = None
@router.get("/config")
async def get_auth_config():
"""获取认证配置信息"""
@@ -60,30 +80,77 @@ async def get_auth_config():
@router.post("/local/login", response_model=LocalLoginResponse)
async def local_login(request: LocalLoginRequest, response: Response):
"""本地账户登录"""
"""本地账户登录(支持.env配置的管理员账号和Linux DO授权后绑定的账号)"""
# 检查是否启用本地登录
if not settings.LOCAL_AUTH_ENABLED:
raise HTTPException(status_code=403, detail="本地账户登录未启用")
# 检查是否配置了本地账户
if not settings.LOCAL_AUTH_USERNAME or not settings.LOCAL_AUTH_PASSWORD:
raise HTTPException(status_code=500, detail="本地账户未配置")
logger.info(f"[本地登录] 尝试登录用户名: {request.username}")
# 验证用户名和密码
if request.username != settings.LOCAL_AUTH_USERNAME or request.password != settings.LOCAL_AUTH_PASSWORD:
raise HTTPException(status_code=401, detail="用户名或密码错误")
# 首先尝试查找 Linux DO 授权后绑定的账号
all_users = await user_manager.get_all_users()
target_user = None
# 生成本地用户ID(使用用户名的hash)
user_id = f"local_{hashlib.md5(request.username.encode()).hexdigest()[:16]}"
for user in all_users:
# 同时检查 users 表的 username 和 user_passwords 表的 username
password_username = await password_manager.get_username(user.user_id)
if user.username == request.username or password_username == request.username:
target_user = user
logger.info(f"[本地登录] 找到 Linux DO 授权用户: {user.user_id}")
break
# 创建或更新本地用户
user = await user_manager.create_or_update_from_linuxdo(
linuxdo_id=user_id,
username=request.username,
display_name=settings.LOCAL_AUTH_DISPLAY_NAME,
avatar_url=None,
trust_level=9 # 本地用户给予高信任级别
)
# 如果找到了 Linux DO 授权的用户
if target_user:
# 检查是否有密码
if not await password_manager.has_password(target_user.user_id):
logger.warning(f"[本地登录] 用户 {target_user.user_id} 没有设置密码")
raise HTTPException(status_code=401, detail="用户名或密码错误")
# 验证密码
if not await password_manager.verify_password(target_user.user_id, request.password):
logger.warning(f"[本地登录] 用户 {target_user.user_id} 密码验证失败")
raise HTTPException(status_code=401, detail="用户名或密码错误")
logger.info(f"[本地登录] Linux DO 授权用户 {target_user.user_id} 登录成功")
user = target_user
else:
# 没有找到 Linux DO 用户,尝试 .env 配置的管理员账号
logger.info(f"[本地登录] 未找到 Linux DO 用户,检查 .env 管理员账号")
# 检查是否配置了本地账户
if not settings.LOCAL_AUTH_USERNAME or not settings.LOCAL_AUTH_PASSWORD:
raise HTTPException(status_code=401, detail="用户名或密码错误")
# 生成本地用户ID(使用用户名的hash)
user_id = f"local_{hashlib.md5(request.username.encode()).hexdigest()[:16]}"
# 检查用户是否存在
user = await user_manager.get_user(user_id)
# 如果用户不存在,使用.env中的默认密码验证
if not user:
# 验证用户名和密码(使用.env配置)
if request.username != settings.LOCAL_AUTH_USERNAME or request.password != settings.LOCAL_AUTH_PASSWORD:
raise HTTPException(status_code=401, detail="用户名或密码错误")
# 创建本地用户
user = await user_manager.create_or_update_from_linuxdo(
linuxdo_id=user_id,
username=request.username,
display_name=settings.LOCAL_AUTH_DISPLAY_NAME,
avatar_url=None,
trust_level=9 # 本地用户给予高信任级别
)
# 为新用户设置默认密码到数据库
await password_manager.set_password(user.user_id, request.username, request.password)
logger.info(f"[本地登录] 管理员用户 {user.user_id} 初始密码已设置到数据库")
else:
# 用户已存在,使用数据库中的密码验证
if not await password_manager.verify_password(user.user_id, request.password):
raise HTTPException(status_code=401, detail="用户名或密码错误")
logger.info(f"[本地登录] 管理员用户 {user.user_id} 登录成功")
# 初始化用户数据库
try:
@@ -189,6 +256,11 @@ async def _handle_callback(
trust_level=trust_level
)
# 3.1. 自动绑定密码(如果还没有设置)
if not await password_manager.has_password(user.user_id):
default_password = await password_manager.set_password(user.user_id, username)
logger.info(f"用户 {user.user_id} ({username}) 自动绑定默认密码: {default_password}")
# 3.5. 初始化用户数据库(如果是新用户)
try:
await init_db(user.user_id)
@@ -338,3 +410,125 @@ async def get_current_user(request: Request):
raise HTTPException(status_code=401, detail="未登录")
return request.state.user.dict()
@router.get("/password/status", response_model=PasswordStatusResponse)
async def get_password_status(request: Request):
"""获取当前用户的密码状态"""
if not hasattr(request.state, "user") or not request.state.user:
raise HTTPException(status_code=401, detail="未登录")
user = request.state.user
has_password = await password_manager.has_password(user.user_id)
has_custom = await password_manager.has_custom_password(user.user_id)
username = await password_manager.get_username(user.user_id)
# 如果使用默认密码,返回默认密码供用户查看
default_password = None
if has_password and not has_custom:
default_password = f"{user.username}@666"
return PasswordStatusResponse(
has_password=has_password,
has_custom_password=has_custom,
username=username or user.username,
default_password=default_password
)
@router.post("/password/set", response_model=SetPasswordResponse)
async def set_user_password(request: Request, password_req: SetPasswordRequest):
"""设置当前用户的密码"""
if not hasattr(request.state, "user") or not request.state.user:
raise HTTPException(status_code=401, detail="未登录")
user = request.state.user
# 验证密码强度(至少6个字符)
if len(password_req.password) < 6:
raise HTTPException(status_code=400, detail="密码长度至少为6个字符")
# 设置密码
await password_manager.set_password(user.user_id, user.username, password_req.password)
logger.info(f"用户 {user.user_id} ({user.username}) 设置了自定义密码")
return SetPasswordResponse(
success=True,
message="密码设置成功"
)
@router.post("/bind/login", response_model=LocalLoginResponse)
async def bind_account_login(request: LocalLoginRequest, response: Response):
"""使用绑定的账号密码登录(LinuxDO授权后绑定的账号)"""
# 查找用户
all_users = await user_manager.get_all_users()
target_user = None
logger.info(f"[绑定账号登录] 尝试登录用户名: {request.username}")
logger.info(f"[绑定账号登录] 当前共有 {len(all_users)} 个用户")
for user in all_users:
# 同时检查 users 表的 username 和 user_passwords 表的 username
password_username = await password_manager.get_username(user.user_id)
logger.info(f"[绑定账号登录] 检查用户 {user.user_id}: users.username={user.username}, passwords.username={password_username}")
if user.username == request.username or password_username == request.username:
target_user = user
logger.info(f"[绑定账号登录] 找到匹配用户: {user.user_id}")
break
if not target_user:
logger.warning(f"[绑定账号登录] 用户名 {request.username} 未找到")
raise HTTPException(status_code=401, detail="用户名或密码错误")
# 检查是否有密码记录
has_pwd = await password_manager.has_password(target_user.user_id)
if not has_pwd:
logger.warning(f"[绑定账号登录] 用户 {target_user.user_id} 没有设置密码")
raise HTTPException(status_code=401, detail="用户名或密码错误")
# 验证密码
is_valid = await password_manager.verify_password(target_user.user_id, request.password)
logger.info(f"[绑定账号登录] 用户 {target_user.user_id} 密码验证结果: {is_valid}")
if not is_valid:
raise HTTPException(status_code=401, detail="用户名或密码错误")
# 初始化用户数据库
try:
await init_db(target_user.user_id)
logger.info(f"绑定账号用户 {target_user.user_id} 数据库初始化成功")
except Exception as e:
logger.error(f"绑定账号用户 {target_user.user_id} 数据库初始化失败: {e}")
# 设置 Cookie2小时有效)
max_age = settings.SESSION_EXPIRE_MINUTES * 60
response.set_cookie(
key="user_id",
value=target_user.user_id,
max_age=max_age,
httponly=True,
samesite="lax"
)
# 设置过期时间戳 Cookie(用于前端判断)
china_now = get_china_now()
expire_time = china_now + timedelta(minutes=settings.SESSION_EXPIRE_MINUTES)
expire_at = int(expire_time.timestamp())
logger.info(f"✅ [绑定账号登录] 用户 {target_user.user_id} ({request.username}) 登录成功,会话有效期 {settings.SESSION_EXPIRE_MINUTES} 分钟")
response.set_cookie(
key="session_expire_at",
value=str(expire_at),
max_age=max_age,
httponly=False, # 前端需要读取
samesite="lax"
)
return LocalLoginResponse(
success=True,
message="登录成功",
user=target_user.dict()
)
+296 -10
View File
@@ -1,6 +1,5 @@
"""章节管理API"""
from fastapi import APIRouter, Depends, HTTPException, Request, Query, BackgroundTasks
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
import json
@@ -19,6 +18,7 @@ from app.models.writing_style import WritingStyle
from app.models.analysis_task import AnalysisTask
from app.models.memory import PlotAnalysis, StoryMemory
from app.models.batch_generation_task import BatchGenerationTask
from app.models.regeneration_task import RegenerationTask
from app.schemas.chapter import (
ChapterCreate,
ChapterUpdate,
@@ -29,12 +29,19 @@ from app.schemas.chapter import (
BatchGenerateResponse,
BatchGenerateStatusResponse
)
from app.schemas.regeneration import (
ChapterRegenerateRequest,
RegenerationTaskResponse,
RegenerationTaskStatus
)
from app.services.ai_service import AIService
from app.services.prompt_service import prompt_service
from app.services.plot_analyzer import PlotAnalyzer
from app.services.memory_service import memory_service
from app.services.chapter_regenerator import ChapterRegenerator
from app.logger import get_logger
from app.api.settings import get_user_ai_service
from app.utils.sse_response import create_sse_response
router = APIRouter(prefix="/chapters", tags=["章节管理"])
logger = get_logger(__name__)
@@ -1284,15 +1291,7 @@ async def generate_chapter_content_stream(
except:
pass
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no"
}
)
return create_sse_response(event_generator())
@router.get("/{chapter_id}/analysis/status", summary="查询章节分析任务状态")
@@ -2293,3 +2292,290 @@ async def generate_single_chapter_for_batch(
await db_session.refresh(chapter)
logger.info(f"✅ 单章节生成完成: 第{chapter.chapter_number}章,共 {new_word_count}")
# ==================== 章节重新生成相关API ====================
@router.post("/{chapter_id}/regenerate-stream", summary="流式重新生成章节内容")
async def regenerate_chapter_stream(
chapter_id: str,
request: Request,
regenerate_request: ChapterRegenerateRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
user_ai_service: AIService = Depends(get_user_ai_service)
):
"""
根据分析建议或自定义指令重新生成章节内容(流式返回)
工作流程:
1. 验证章节和分析结果
2. 创建重新生成任务
3. 构建修改指令
4. 流式生成新内容
5. 保存为版本历史
6. 可选自动应用
"""
user_id = getattr(request.state, 'user_id', None)
if not user_id:
raise HTTPException(status_code=401, detail="未登录")
# 验证章节存在
chapter_result = await db.execute(
select(Chapter).where(Chapter.id == chapter_id)
)
chapter = chapter_result.scalar_one_or_none()
if not chapter:
raise HTTPException(status_code=404, detail="章节不存在")
if not chapter.content or chapter.content.strip() == "":
raise HTTPException(status_code=400, detail="章节内容为空,无法重新生成")
# 验证用户权限
await verify_project_access(chapter.project_id, user_id, db)
# 获取分析结果(如果使用分析建议)
analysis = None
if regenerate_request.modification_source in ['analysis_suggestions', 'mixed']:
analysis_result = await db.execute(
select(PlotAnalysis)
.where(PlotAnalysis.chapter_id == chapter_id)
.order_by(PlotAnalysis.created_at.desc())
.limit(1)
)
analysis = analysis_result.scalar_one_or_none()
if not analysis:
raise HTTPException(status_code=404, detail="该章节暂无分析结果")
# 预先获取项目上下文数据
async for temp_db in get_db(request):
try:
# 获取项目信息
project_result = await temp_db.execute(
select(Project).where(Project.id == chapter.project_id)
)
project = project_result.scalar_one_or_none()
# 获取角色信息
characters_result = await temp_db.execute(
select(Character).where(Character.project_id == chapter.project_id)
)
characters = characters_result.scalars().all()
# 获取章节大纲
outline_result = await temp_db.execute(
select(Outline)
.where(Outline.project_id == chapter.project_id)
.where(Outline.order_index == chapter.chapter_number)
)
outline = outline_result.scalar_one_or_none()
# 构建项目上下文
project_context = {
'project_title': project.title if project else '未知',
'genre': project.genre if project else '未设定',
'theme': project.theme if project else '未设定',
'narrative_perspective': project.narrative_perspective if project else '第三人称',
'time_period': project.world_time_period if project else '未设定',
'location': project.world_location if project else '未设定',
'atmosphere': project.world_atmosphere if project else '未设定',
'characters_info': "\n".join([
f"- {c.name}({'组织' if c.is_organization else '角色'}, {c.role_type}): {c.personality[:100] if c.personality else ''}"
for c in characters
]) if characters else '暂无角色信息',
'chapter_outline': outline.content if outline else chapter.summary or '暂无大纲',
'previous_context': '' # 可以后续扩展添加前置章节上下文
}
finally:
await temp_db.close()
break
async def event_generator():
"""流式生成事件生成器"""
db_session = None
db_committed = False
try:
# 创建独立数据库会话
async for db_session in get_db(request):
# 发送开始事件
yield f"data: {json.dumps({'type': 'start', 'message': '开始重新生成章节...'}, ensure_ascii=False)}\n\n"
# 创建重新生成任务
regen_task = RegenerationTask(
chapter_id=chapter_id,
analysis_id=analysis.id if analysis else None,
user_id=user_id,
project_id=chapter.project_id,
modification_instructions="", # 稍后填充
original_suggestions=analysis.suggestions if analysis else None,
selected_suggestion_indices=regenerate_request.selected_suggestion_indices,
custom_instructions=regenerate_request.custom_instructions,
style_id=regenerate_request.style_id,
target_word_count=regenerate_request.target_word_count,
focus_areas=regenerate_request.focus_areas,
preserve_elements=regenerate_request.preserve_elements.model_dump() if regenerate_request.preserve_elements else None,
status='running',
original_content=chapter.content,
original_word_count=chapter.word_count or len(chapter.content),
version_note=regenerate_request.version_note,
started_at=datetime.now()
)
db_session.add(regen_task)
await db_session.commit()
await db_session.refresh(regen_task)
task_id = regen_task.id
logger.info(f"📝 创建重新生成任务: {task_id}")
yield f"data: {json.dumps({'type': 'task_created', 'task_id': task_id}, ensure_ascii=False)}\n\n"
# 初始化重新生成器
regenerator = ChapterRegenerator(user_ai_service)
# 流式生成新内容
full_content = ""
async for event in regenerator.regenerate_with_feedback(
chapter=chapter,
analysis=analysis,
regenerate_request=regenerate_request,
project_context=project_context
):
# 处理不同类型的事件
if event['type'] == 'chunk':
# 内容块
chunk = event['content']
full_content += chunk
yield f"data: {json.dumps({'type': 'chunk', 'content': chunk}, ensure_ascii=False)}\n\n"
elif event['type'] == 'progress':
# 进度更新
progress_data = {
'type': 'progress',
'progress': event.get('progress', 0),
'message': event.get('message', ''),
'word_count': event.get('word_count', 0)
}
yield f"data: {json.dumps(progress_data, ensure_ascii=False)}\n\n"
await asyncio.sleep(0)
# 更新任务状态
regen_task.status = 'completed'
regen_task.regenerated_content = full_content
regen_task.regenerated_word_count = len(full_content)
regen_task.completed_at = datetime.now()
# 计算差异统计
diff_stats = regenerator.calculate_content_diff(chapter.content, full_content)
await db_session.commit()
db_committed = True
# 先发送结果数据
result_data = {
'type': 'result',
'data': {
'task_id': task_id,
'word_count': len(full_content),
'version_number': regen_task.version_number,
'auto_applied': regenerate_request.auto_apply,
'diff_stats': diff_stats
}
}
yield f"data: {json.dumps(result_data, ensure_ascii=False)}\n\n"
# 再发送完成事件
completion_data = {
'type': 'done',
'message': '重新生成完成'
}
yield f"data: {json.dumps(completion_data, ensure_ascii=False)}\n\n"
logger.info(f"✅ 章节重新生成完成: {chapter_id}, 任务: {task_id}")
break
except Exception as e:
logger.error(f"❌ 重新生成失败: {str(e)}", exc_info=True)
# 更新任务状态为失败
if db_session and not db_committed:
try:
task_result = await db_session.execute(
select(RegenerationTask).where(RegenerationTask.chapter_id == chapter_id)
.order_by(RegenerationTask.created_at.desc()).limit(1)
)
task = task_result.scalar_one_or_none()
if task:
task.status = 'failed'
task.error_message = str(e)[:500]
task.completed_at = datetime.now()
await db_session.commit()
except Exception as update_error:
logger.error(f"更新任务失败状态失败: {str(update_error)}")
yield f"data: {json.dumps({'type': 'error', 'error': str(e)}, ensure_ascii=False)}\n\n"
finally:
if db_session:
try:
if not db_committed and db_session.in_transaction():
await db_session.rollback()
await db_session.close()
except Exception as close_error:
logger.error(f"关闭数据库会话失败: {str(close_error)}")
return create_sse_response(event_generator())
@router.get("/{chapter_id}/regeneration/tasks", summary="获取章节的重新生成任务列表")
async def get_regeneration_tasks(
chapter_id: str,
request: Request,
limit: int = Query(10, ge=1, le=50),
db: AsyncSession = Depends(get_db)
):
"""获取指定章节的重新生成任务历史"""
user_id = getattr(request.state, 'user_id', None)
# 验证章节存在和权限
chapter_result = await db.execute(
select(Chapter).where(Chapter.id == chapter_id)
)
chapter = chapter_result.scalar_one_or_none()
if not chapter:
raise HTTPException(status_code=404, detail="章节不存在")
await verify_project_access(chapter.project_id, user_id, db)
# 获取任务列表
result = await db.execute(
select(RegenerationTask)
.where(RegenerationTask.chapter_id == chapter_id)
.order_by(RegenerationTask.created_at.desc())
.limit(limit)
)
tasks = result.scalars().all()
return {
"chapter_id": chapter_id,
"total": len(tasks),
"tasks": [
{
"task_id": task.id,
"status": task.status,
"version_number": task.version_number,
"version_note": task.version_note,
"original_word_count": task.original_word_count,
"regenerated_word_count": task.regenerated_word_count,
"created_at": task.created_at.isoformat() if task.created_at else None,
"completed_at": task.completed_at.isoformat() if task.completed_at else None
}
for task in tasks
]
}
-383
View File
@@ -1135,386 +1135,3 @@ async def generate_outline_stream(
"""
return create_sse_response(outline_generator(data, db, user_ai_service))
async def update_world_building_generator(
project_id: str,
data: Dict[str, Any],
db: AsyncSession
) -> AsyncGenerator[str, None]:
"""更新世界观流式生成器"""
db_committed = False
try:
yield await SSEResponse.send_progress("开始更新世界观...", 10)
# 获取项目
result = await db.execute(
select(Project).where(Project.id == project_id)
)
project = result.scalar_one_or_none()
if not project:
yield await SSEResponse.send_error("项目不存在", 404)
return
yield await SSEResponse.send_progress("验证数据...", 30)
# 更新世界观字段
if "time_period" in data:
project.world_time_period = data["time_period"]
if "location" in data:
project.world_location = data["location"]
if "atmosphere" in data:
project.world_atmosphere = data["atmosphere"]
if "rules" in data:
project.world_rules = data["rules"]
yield await SSEResponse.send_progress("保存到数据库...", 70)
await db.commit()
db_committed = True
await db.refresh(project)
# 发送结果
yield await SSEResponse.send_result({
"project_id": project.id,
"time_period": project.world_time_period,
"location": project.world_location,
"atmosphere": project.world_atmosphere,
"rules": project.world_rules
})
yield await SSEResponse.send_progress("完成!", 100, "success")
yield await SSEResponse.send_done()
except GeneratorExit:
logger.warning("更新世界观生成器被提前关闭")
if not db_committed and db.in_transaction():
await db.rollback()
logger.info("更新世界观事务已回滚(GeneratorExit")
except Exception as e:
logger.error(f"更新世界观失败: {str(e)}")
if not db_committed and db.in_transaction():
await db.rollback()
logger.info("更新世界观事务已回滚(异常)")
yield await SSEResponse.send_error(f"更新失败: {str(e)}")
@router.post("/world-building/{project_id}", summary="流式更新世界观")
async def update_world_building_stream(
project_id: str,
data: Dict[str, Any],
db: AsyncSession = Depends(get_db)
):
"""
使用SSE流式更新项目的世界观信息
请求体格式:
{
"time_period": "时间背景",
"location": "地理位置",
"atmosphere": "氛围基调",
"rules": "世界规则"
}
"""
return create_sse_response(update_world_building_generator(project_id, data, db))
async def regenerate_world_building_generator(
project_id: str,
data: Dict[str, Any],
db: AsyncSession,
user_ai_service: AIService
) -> AsyncGenerator[str, None]:
"""重新生成世界观流式生成器 - 支持MCP工具增强"""
db_committed = False
try:
yield await SSEResponse.send_progress("开始重新生成世界观...", 10)
# 获取项目
result = await db.execute(
select(Project).where(Project.id == project_id)
)
project = result.scalar_one_or_none()
if not project:
yield await SSEResponse.send_error("项目不存在", 404)
return
provider = data.get("provider")
model = data.get("model")
enable_mcp = data.get("enable_mcp", True) # 默认启用MCP
user_id = data.get("user_id") # 从中间件注入
# 获取基础提示词
yield await SSEResponse.send_progress("准备AI提示词...", 15)
base_prompt = prompt_service.get_world_building_prompt(
title=project.title,
theme=project.theme or "",
genre=project.genre or ""
)
# MCP工具增强:收集参考资料
reference_materials = ""
if enable_mcp and user_id:
try:
yield await SSEResponse.send_progress("🔍 尝试使用MCP工具收集参考资料...", 18)
# 直接调用MCP增强的AI,内部会自动检查和加载工具
# 构建资料收集提示词
planning_prompt = f"""你正在为小说《{project.title}》重新设计世界观。
【小说信息】
- 题材:{project.genre or '未设定'}
- 主题:{project.theme or '未设定'}
【任务】
请使用可用工具搜索相关背景资料,帮助构建更真实、更有深度的世界观设定。
你可以查询:
1. 历史背景(如果是历史题材)
2. 地理环境和文化特征
3. 相关领域的专业知识
4. 类似作品的设定参考
请根据题材特点,有针对性地查询2-3个关键问题。"""
# 调用MCP增强的AI(非流式,最多2轮工具调用)
planning_result = await user_ai_service.generate_text_with_mcp(
prompt=planning_prompt,
user_id=user_id,
db_session=db,
enable_mcp=True,
max_tool_rounds=2,
tool_choice="auto",
provider=None,
model=None
)
# 提取参考资料
if planning_result.get("tool_calls_made", 0) > 0:
yield await SSEResponse.send_progress(
f"✅ MCP工具调用成功({planning_result['tool_calls_made']}次)",
25
)
reference_materials = planning_result.get("content", "")
else:
yield await SSEResponse.send_progress("ℹ️ 未使用MCP工具(无可用工具或不需要)", 25)
except Exception as e:
logger.warning(f"MCP工具调用失败(降级处理): {e}")
yield await SSEResponse.send_progress("⚠️ MCP工具暂时不可用,使用基础模式", 25)
# 构建增强提示词
if reference_materials:
enhanced_prompt = f"""{base_prompt}
【参考资料】
以下是通过MCP工具收集的真实背景资料,请参考这些信息构建更真实的世界观:
{reference_materials}
请结合上述资料,生成符合历史/现实的世界观设定。"""
final_prompt = enhanced_prompt
yield await SSEResponse.send_progress("💡 已整合参考资料,开始重新生成世界观...", 30)
else:
final_prompt = base_prompt
yield await SSEResponse.send_progress("正在调用AI生成...", 30)
# 流式生成世界观
accumulated_text = ""
chunk_count = 0
async for chunk in user_ai_service.generate_text_stream(
prompt=final_prompt,
provider=provider,
model=model
):
chunk_count += 1
accumulated_text += chunk
# 发送内容块
yield await SSEResponse.send_chunk(chunk)
# 定期更新进度
if chunk_count % 5 == 0:
progress = min(30 + (chunk_count // 5), 70)
yield await SSEResponse.send_progress(f"生成中... ({len(accumulated_text)}字符)", progress)
# 每20个块发送心跳
if chunk_count % 20 == 0:
yield await SSEResponse.send_heartbeat()
# 解析结果
yield await SSEResponse.send_progress("解析AI返回结果...", 80)
world_data = {}
try:
cleaned_text = accumulated_text.strip()
# 移除markdown代码块标记
if cleaned_text.startswith('```json'):
cleaned_text = cleaned_text[7:].lstrip('\n\r')
elif cleaned_text.startswith('```'):
cleaned_text = cleaned_text[3:].lstrip('\n\r')
if cleaned_text.endswith('```'):
cleaned_text = cleaned_text[:-3].rstrip('\n\r')
cleaned_text = cleaned_text.strip()
world_data = json.loads(cleaned_text)
except json.JSONDecodeError as e:
logger.error(f"AI返回非JSON格式: {e}")
logger.info(world_data)
world_data = {
"time_period": "AI返回格式错误,请重试",
"location": "AI返回格式错误,请重试",
"atmosphere": "AI返回格式错误,请重试",
"rules": "AI返回格式错误,请重试"
}
# 更新项目世界观
yield await SSEResponse.send_progress("保存到数据库...", 90)
project.world_time_period = world_data.get("time_period")
project.world_location = world_data.get("location")
project.world_atmosphere = world_data.get("atmosphere")
project.world_rules = world_data.get("rules")
await db.commit()
db_committed = True
await db.refresh(project)
# 发送结果
yield await SSEResponse.send_result({
"project_id": project.id,
"time_period": project.world_time_period,
"location": project.world_location,
"atmosphere": project.world_atmosphere,
"rules": project.world_rules
})
yield await SSEResponse.send_progress("完成!", 100, "success")
yield await SSEResponse.send_done()
except GeneratorExit:
logger.warning("重新生成世界观生成器被提前关闭")
if not db_committed and db.in_transaction():
await db.rollback()
logger.info("重新生成世界观事务已回滚(GeneratorExit")
except Exception as e:
logger.error(f"重新生成世界观失败: {str(e)}")
if not db_committed and db.in_transaction():
await db.rollback()
logger.info("重新生成世界观事务已回滚(异常)")
yield await SSEResponse.send_error(f"重新生成失败: {str(e)}")
@router.post("/world-building/{project_id}/regenerate", summary="流式重新生成世界观")
async def regenerate_world_building_stream(
request: Request,
project_id: str,
data: Dict[str, Any],
db: AsyncSession = Depends(get_db),
user_ai_service: AIService = Depends(get_user_ai_service)
):
"""
使用SSE流式重新生成项目的世界观
请求体格式:
{
"provider": "AI提供商(可选)",
"model": "模型名称(可选)"
}
"""
# 从中间件注入user_id到data中
if hasattr(request.state, 'user_id'):
data['user_id'] = request.state.user_id
return create_sse_response(regenerate_world_building_generator(project_id, data, db, user_ai_service))
async def cleanup_wizard_data_generator(
project_id: str,
db: AsyncSession
) -> AsyncGenerator[str, None]:
"""清理向导数据流式生成器"""
db_committed = False
try:
yield await SSEResponse.send_progress("开始清理向导数据...", 10)
# 获取项目
result = await db.execute(
select(Project).where(Project.id == project_id)
)
project = result.scalar_one_or_none()
if not project:
yield await SSEResponse.send_error("项目不存在", 404)
return
# 删除相关的角色
yield await SSEResponse.send_progress("删除角色数据...", 30)
characters = await db.execute(
select(Character).where(Character.project_id == project_id)
)
char_count = 0
for character in characters.scalars():
await db.delete(character)
char_count += 1
# 删除相关的大纲
yield await SSEResponse.send_progress("删除大纲数据...", 50)
outlines = await db.execute(
select(Outline).where(Outline.project_id == project_id)
)
outline_count = 0
for outline in outlines.scalars():
await db.delete(outline)
outline_count += 1
# 删除相关的章节
yield await SSEResponse.send_progress("删除章节数据...", 70)
chapters = await db.execute(
select(Chapter).where(Chapter.project_id == project_id)
)
chapter_count = 0
for chapter in chapters.scalars():
await db.delete(chapter)
chapter_count += 1
# 删除项目
yield await SSEResponse.send_progress("删除项目...", 85)
await db.delete(project)
yield await SSEResponse.send_progress("提交数据库更改...", 95)
await db.commit()
db_committed = True
# 发送结果
yield await SSEResponse.send_result({
"message": "项目及相关数据已清理",
"deleted": {
"characters": char_count,
"outlines": outline_count,
"chapters": chapter_count
}
})
yield await SSEResponse.send_progress("完成!", 100, "success")
yield await SSEResponse.send_done()
except GeneratorExit:
logger.warning("清理向导数据生成器被提前关闭")
if not db_committed and db.in_transaction():
await db.rollback()
logger.info("清理向导数据事务已回滚(GeneratorExit")
except Exception as e:
logger.error(f"清理数据失败: {str(e)}")
if not db_committed and db.in_transaction():
await db.rollback()
logger.info("清理向导数据事务已回滚(异常)")
yield await SSEResponse.send_error(f"清理失败: {str(e)}")
@router.post("/cleanup/{project_id}", summary="流式清理向导数据")
async def cleanup_wizard_data_stream(
project_id: str,
db: AsyncSession = Depends(get_db)
):
"""
使用SSE流式清理向导过程中创建的项目及相关数据
用于返回上一步时清理已生成的内容
"""
return create_sse_response(cleanup_wizard_data_generator(project_id, db))
+1
View File
@@ -110,6 +110,7 @@ class Settings(BaseSettings):
class Config:
env_file = ".env"
case_sensitive = False
extra = "ignore" # 忽略未定义的环境变量,避免验证错误
# 创建全局配置实例
+2 -1
View File
@@ -21,7 +21,8 @@ from app.models import (
Project, Outline, Character, Chapter, GenerationHistory,
Settings, WritingStyle, ProjectDefaultStyle,
RelationshipType, CharacterRelationship, Organization, OrganizationMember,
StoryMemory, PlotAnalysis, AnalysisTask, BatchGenerationTask
StoryMemory, PlotAnalysis, AnalysisTask, BatchGenerationTask,
RegenerationTask
)
# 引擎缓存:每个用户一个引擎
+6 -1
View File
@@ -12,6 +12,8 @@ from app.models.memory import StoryMemory, PlotAnalysis
from app.models.writing_style import WritingStyle
from app.models.project_default_style import ProjectDefaultStyle
from app.models.mcp_plugin import MCPPlugin
from app.models.user import User, UserPassword
from app.models.regeneration_task import RegenerationTask
__all__ = [
"Project",
@@ -30,5 +32,8 @@ __all__ = [
"PlotAnalysis",
"WritingStyle",
"ProjectDefaultStyle",
"MCPPlugin"
"MCPPlugin",
"User",
"UserPassword",
"RegenerationTask"
]
+51
View File
@@ -0,0 +1,51 @@
"""章节重新生成任务模型"""
from sqlalchemy import Column, String, Text, Integer, DateTime, ForeignKey, JSON, Boolean
from sqlalchemy.sql import func
from app.database import Base
import uuid
class RegenerationTask(Base):
"""章节重新生成任务表"""
__tablename__ = "regeneration_tasks"
# 基本信息
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
chapter_id = Column(String(36), ForeignKey('chapters.id', ondelete='CASCADE'), nullable=False, index=True)
analysis_id = Column(String(36), nullable=True, comment="关联的分析结果ID")
user_id = Column(String(50), nullable=False, index=True)
project_id = Column(String(36), nullable=False, index=True)
# 修改指令
modification_instructions = Column(Text, nullable=False, comment="综合修改指令")
original_suggestions = Column(JSON, comment="来自分析的原始建议列表")
selected_suggestion_indices = Column(JSON, comment="用户选择的建议索引")
custom_instructions = Column(Text, comment="用户自定义修改意见")
# 生成参数
style_id = Column(Integer, nullable=True, comment="写作风格ID")
target_word_count = Column(Integer, default=3000, comment="目标字数")
focus_areas = Column(JSON, comment="重点优化方向")
preserve_elements = Column(JSON, comment="需要保留的元素配置")
# 状态跟踪
status = Column(String(20), default='pending', comment="pending/running/completed/failed")
progress = Column(Integer, default=0, comment="进度 0-100")
error_message = Column(Text, nullable=True)
# 内容版本
original_content = Column(Text, comment="原始章节内容快照")
original_word_count = Column(Integer, comment="原始字数")
regenerated_content = Column(Text, comment="重新生成的内容")
regenerated_word_count = Column(Integer, comment="新内容字数")
version_number = Column(Integer, default=1, comment="版本号")
version_note = Column(String(500), comment="版本说明")
# 时间戳
created_at = Column(DateTime, server_default=func.now())
started_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
def __repr__(self):
return f"<RegenerationTask(id={self.id[:8]}..., chapter_id={self.chapter_id[:8]}..., status={self.status})>"
+47
View File
@@ -0,0 +1,47 @@
"""
用户数据模型 - 存储用户基本信息
"""
from sqlalchemy import Column, String, Integer, Boolean, DateTime
from sqlalchemy.sql import func
from app.database import Base
class User(Base):
"""用户模型 - 存储OAuth和本地用户信息"""
__tablename__ = "users"
user_id = Column(String(100), primary_key=True, index=True, comment="用户ID,格式:linuxdo_{id} 或 local_{id}")
username = Column(String(100), nullable=False, index=True, comment="用户名")
display_name = Column(String(200), nullable=False, comment="显示名称")
avatar_url = Column(String(500), nullable=True, comment="头像URL")
trust_level = Column(Integer, default=0, comment="信任等级(仅用于显示)")
is_admin = Column(Boolean, default=False, comment="是否为管理员")
linuxdo_id = Column(String(100), nullable=False, unique=True, index=True, comment="LinuxDO用户ID或本地用户ID")
created_at = Column(DateTime(timezone=True), server_default=func.now(), comment="创建时间")
last_login = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), comment="最后登录时间")
def to_dict(self):
"""转换为字典"""
return {
"user_id": self.user_id,
"username": self.username,
"display_name": self.display_name,
"avatar_url": self.avatar_url,
"trust_level": self.trust_level,
"is_admin": self.is_admin,
"linuxdo_id": self.linuxdo_id,
"created_at": self.created_at.isoformat() if self.created_at else None,
"last_login": self.last_login.isoformat() if self.last_login else None,
}
class UserPassword(Base):
"""用户密码模型 - 存储用户密码信息"""
__tablename__ = "user_passwords"
user_id = Column(String(100), primary_key=True, index=True, comment="用户ID")
username = Column(String(100), nullable=False, comment="用户名")
password_hash = Column(String(64), nullable=False, comment="密码哈希(SHA256")
has_custom_password = Column(Boolean, default=False, comment="是否为自定义密码")
created_at = Column(DateTime(timezone=True), server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), comment="更新时间")
+65
View File
@@ -0,0 +1,65 @@
"""章节重新生成相关的Schema定义"""
from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
from datetime import datetime
class PreserveElementsConfig(BaseModel):
"""保留元素配置"""
preserve_structure: bool = Field(False, description="是否保留整体结构")
preserve_dialogues: List[str] = Field(default_factory=list, description="需要保留的对话片段关键词")
preserve_plot_points: List[str] = Field(default_factory=list, description="需要保留的情节点关键词")
preserve_character_traits: bool = Field(True, description="保持角色性格一致")
class ChapterRegenerateRequest(BaseModel):
"""章节重新生成请求"""
# 修改来源
modification_source: str = Field("custom", description="修改来源: custom/analysis_suggestions/mixed")
# 基于分析建议
selected_suggestion_indices: Optional[List[int]] = Field(None, description="选中的建议索引列表")
# 自定义修改指令
custom_instructions: Optional[str] = Field(None, description="用户自定义的修改要求")
# 保留配置
preserve_elements: Optional[PreserveElementsConfig] = Field(None, description="保留元素配置")
# 生成参数
style_id: Optional[int] = Field(None, description="写作风格ID")
target_word_count: int = Field(3000, description="目标字数", ge=500, le=10000)
focus_areas: List[str] = Field(default_factory=list, description="重点优化方向")
# 版本管理
save_as_version: bool = Field(True, description="是否保存为新版本")
version_note: Optional[str] = Field(None, description="版本说明", max_length=500)
auto_apply: bool = Field(False, description="是否自动应用(替换当前内容)")
class RegenerationTaskResponse(BaseModel):
"""重新生成任务响应"""
task_id: str
chapter_id: str
status: str
message: str
estimated_time_seconds: int = 120
class RegenerationTaskStatus(BaseModel):
"""重新生成任务状态"""
task_id: str
chapter_id: str
status: str
progress: int
error_message: Optional[str] = None
created_at: Optional[datetime] = None
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
# 结果信息
original_word_count: Optional[int] = None
regenerated_word_count: Optional[int] = None
version_number: Optional[int] = None
+308
View File
@@ -0,0 +1,308 @@
"""章节重新生成服务"""
from typing import Dict, Any, AsyncGenerator, Optional, List
from app.services.ai_service import AIService
from app.services.prompt_service import prompt_service
from app.models.chapter import Chapter
from app.models.memory import PlotAnalysis
from app.schemas.regeneration import ChapterRegenerateRequest, PreserveElementsConfig
from app.logger import get_logger
import difflib
logger = get_logger(__name__)
class ChapterRegenerator:
"""章节重新生成服务"""
def __init__(self, ai_service: AIService):
self.ai_service = ai_service
logger.info("✅ ChapterRegenerator初始化成功")
async def regenerate_with_feedback(
self,
chapter: Chapter,
analysis: Optional[PlotAnalysis],
regenerate_request: ChapterRegenerateRequest,
project_context: Dict[str, Any]
) -> AsyncGenerator[Dict[str, Any], None]:
"""
根据反馈重新生成章节流式
Args:
chapter: 原始章节对象
analysis: 分析结果可选
regenerate_request: 重新生成请求参数
project_context: 项目上下文项目信息角色大纲等
Yields:
包含类型和数据的字典: {'type': 'progress'/'chunk', 'data': ...}
"""
try:
logger.info(f"🔄 开始重新生成章节: 第{chapter.chapter_number}")
# 1. 构建修改指令
yield {'type': 'progress', 'progress': 5, 'message': '正在构建修改指令...'}
modification_instructions = self._build_modification_instructions(
analysis=analysis,
regenerate_request=regenerate_request
)
logger.info(f"📝 修改指令构建完成,长度: {len(modification_instructions)}字符")
# 2. 构建完整提示词
yield {'type': 'progress', 'progress': 10, 'message': '正在构建生成提示词...'}
full_prompt = self._build_regeneration_prompt(
chapter=chapter,
modification_instructions=modification_instructions,
project_context=project_context,
regenerate_request=regenerate_request
)
logger.info(f"🎯 提示词构建完成,开始AI生成")
yield {'type': 'progress', 'progress': 15, 'message': '开始AI生成内容...'}
# 3. 流式生成新内容,同时跟踪进度
target_word_count = regenerate_request.target_word_count
accumulated_length = 0
async for chunk in self.ai_service.generate_text_stream(
prompt=full_prompt,
temperature=0.7
):
# 发送内容块
yield {'type': 'chunk', 'content': chunk}
# 更新累积字数并计算进度(15%-95%)
accumulated_length += len(chunk)
# 进度从15%开始,到95%结束,为后处理预留5%
generation_progress = min(15 + (accumulated_length / target_word_count) * 80, 95)
yield {'type': 'progress', 'progress': int(generation_progress), 'word_count': accumulated_length}
logger.info(f"✅ 章节重新生成完成,共生成 {accumulated_length}")
yield {'type': 'progress', 'progress': 100, 'message': '生成完成'}
except Exception as e:
logger.error(f"❌ 重新生成失败: {str(e)}", exc_info=True)
raise
def _build_modification_instructions(
self,
analysis: Optional[PlotAnalysis],
regenerate_request: ChapterRegenerateRequest
) -> str:
"""构建修改指令"""
instructions = []
# 标题
instructions.append("# 章节修改指令\n")
# 1. 来自分析的建议
if (analysis and
regenerate_request.selected_suggestion_indices and
analysis.suggestions):
instructions.append("## 📋 需要改进的问题(来自AI分析):\n")
for idx in regenerate_request.selected_suggestion_indices:
if 0 <= idx < len(analysis.suggestions):
suggestion = analysis.suggestions[idx]
instructions.append(f"{idx + 1}. {suggestion}")
instructions.append("")
# 2. 用户自定义指令
if regenerate_request.custom_instructions:
instructions.append("## ✍️ 用户自定义修改要求:\n")
instructions.append(regenerate_request.custom_instructions)
instructions.append("")
# 3. 重点优化方向
if regenerate_request.focus_areas:
instructions.append("## 🎯 重点优化方向:\n")
focus_map = {
"pacing": "节奏把控 - 调整叙事速度,避免拖沓或过快",
"emotion": "情感渲染 - 深化人物情感表达,增强感染力",
"description": "场景描写 - 丰富环境细节,增强画面感",
"dialogue": "对话质量 - 让对话更自然真实,推动剧情",
"conflict": "冲突强度 - 强化矛盾冲突,提升戏剧张力"
}
for area in regenerate_request.focus_areas:
if area in focus_map:
instructions.append(f"- {focus_map[area]}")
instructions.append("")
# 4. 保留要求
if regenerate_request.preserve_elements:
preserve = regenerate_request.preserve_elements
instructions.append("## 🔒 必须保留的元素:\n")
if preserve.preserve_structure:
instructions.append("- 保持原章节的整体结构和情节框架")
if preserve.preserve_dialogues:
instructions.append("- 必须保留以下关键对话:")
for dialogue in preserve.preserve_dialogues:
instructions.append(f" * {dialogue}")
if preserve.preserve_plot_points:
instructions.append("- 必须保留以下关键情节点:")
for plot in preserve.preserve_plot_points:
instructions.append(f" * {plot}")
if preserve.preserve_character_traits:
instructions.append("- 保持所有角色的性格特征和行为模式一致")
instructions.append("")
return "\n".join(instructions)
def _build_regeneration_prompt(
self,
chapter: Chapter,
modification_instructions: str,
project_context: Dict[str, Any],
regenerate_request: ChapterRegenerateRequest
) -> str:
"""构建完整的重新生成提示词"""
prompt_parts = []
# 系统角色
prompt_parts.append("""你是一位经验丰富的专业小说编辑和作家。现在需要根据反馈意见重新创作一个章节。
你的任务是
1. 仔细理解原章节的内容和意图
2. 认真分析所有的修改要求
3. 在保持故事连贯性的前提下创作一个改进后的新版本
4. 确保新版本在艺术性和可读性上都有明显提升
---
""")
# 原始章节信息
prompt_parts.append(f"""## 📖 原始章节信息
**章节**{chapter.chapter_number}
**标题**{chapter.title}
**字数**{chapter.word_count}
**原始内容**
{chapter.content}
---
""")
# 修改指令
prompt_parts.append(modification_instructions)
prompt_parts.append("\n---\n")
# 项目背景信息
prompt_parts.append(f"""## 🌍 项目背景信息
**小说标题**{project_context.get('project_title', '未知')}
**题材**{project_context.get('genre', '未设定')}
**主题**{project_context.get('theme', '未设定')}
**叙事视角**{project_context.get('narrative_perspective', '第三人称')}
**世界观设定**
- 时代背景{project_context.get('time_period', '未设定')}
- 地理位置{project_context.get('location', '未设定')}
- 氛围基调{project_context.get('atmosphere', '未设定')}
---
""")
# 角色信息
if project_context.get('characters_info'):
prompt_parts.append(f"""## 👥 角色信息
{project_context['characters_info']}
---
""")
# 章节大纲
if project_context.get('chapter_outline'):
prompt_parts.append(f"""## 📝 本章大纲
{project_context['chapter_outline']}
---
""")
# 前置章节上下文
if project_context.get('previous_context'):
prompt_parts.append(f"""## 📚 前置章节上下文
{project_context['previous_context']}
---
""")
# 创作要求
prompt_parts.append(f"""## ✨ 创作要求
1. **解决问题**针对上述修改指令中提到的所有问题进行改进
2. **保持连贯**确保与前后章节的情节人物风格保持一致
3. **提升质量**在节奏情感描写等方面明显优于原版
4. **保留精华**保持原章节中优秀的部分和关键情节
5. **字数控制**目标字数约{regenerate_request.target_word_count}可适当浮动±20%
---
## 🎬 开始创作
请现在开始创作改进后的新版本章节内容
**重要提示**
- 直接输出章节正文内容从故事内容开始写
- **不要**输出章节标题"第X章""第X章:XXX"
- **不要**输出任何额外的说明注释或元数据
- 只需要纯粹的故事正文内容
现在开始
""")
return "\n".join(prompt_parts)
def calculate_content_diff(
self,
original_content: str,
new_content: str
) -> Dict[str, Any]:
"""
计算两个版本的差异
Returns:
差异统计信息
"""
# 基本统计
diff_stats = {
'original_length': len(original_content),
'new_length': len(new_content),
'length_change': len(new_content) - len(original_content),
'length_change_percent': round((len(new_content) - len(original_content)) / len(original_content) * 100, 2) if len(original_content) > 0 else 0
}
# 计算相似度
similarity = difflib.SequenceMatcher(None, original_content, new_content).ratio()
diff_stats['similarity'] = round(similarity * 100, 2)
diff_stats['difference'] = round((1 - similarity) * 100, 2)
# 段落统计
original_paragraphs = [p for p in original_content.split('\n\n') if p.strip()]
new_paragraphs = [p for p in new_content.split('\n\n') if p.strip()]
diff_stats['original_paragraph_count'] = len(original_paragraphs)
diff_stats['new_paragraph_count'] = len(new_paragraphs)
return diff_stats
# 全局实例
_regenerator_instance = None
def get_chapter_regenerator(ai_service: AIService) -> ChapterRegenerator:
"""获取章节重新生成器实例"""
global _regenerator_instance
if _regenerator_instance is None:
_regenerator_instance = ChapterRegenerator(ai_service)
return _regenerator_instance
+133 -201
View File
@@ -1,106 +1,49 @@
"""
用户管理模块 - 支持 LinuxDO OAuth2
用户管理模块 - 使用数据库存储
"""
import json
import os
import asyncio
from datetime import datetime
from typing import Optional, Dict, List
from typing import Optional, List
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from pydantic import BaseModel
from app.config import settings, DATA_DIR
from app.config import settings
class User(BaseModel):
"""用户模型"""
user_id: str # 格式: linuxdo_{linuxdo_id}
"""用户数据传输对象"""
user_id: str
username: str
display_name: str
avatar_url: Optional[str] = None
trust_level: int = 0 # 仅用于显示
is_admin: bool = False # 手动设置的管理员权限
linuxdo_id: str # LinuxDO 用户 ID
trust_level: int = 0
is_admin: bool = False
linuxdo_id: str
created_at: str
last_login: str
class UserManager:
"""用户管理器 - 线程安全版本"""
USERS_FILE = str(DATA_DIR / "users.json")
ADMINS_FILE = str(DATA_DIR / "admins.json")
"""用户管理器 - 使用数据库存储(PostgreSQL共享库)"""
def __init__(self):
"""初始化用户管理器"""
# DATA_DIR 已在 config.py 中创建,无需重复创建
# 添加文件锁保护并发读写
self._users_lock = asyncio.Lock()
self._admins_lock = asyncio.Lock()
self._ensure_files_exist()
pass
def _ensure_files_exist(self):
"""确保必要的文件存在"""
if not os.path.exists(self.USERS_FILE):
with open(self.USERS_FILE, "w", encoding="utf-8") as f:
json.dump({}, f, ensure_ascii=False, indent=2)
async def _get_session(self) -> AsyncSession:
"""获取数据库会话 - 使用共享的PostgreSQL引擎"""
from app.database import get_engine
if not os.path.exists(self.ADMINS_FILE):
with open(self.ADMINS_FILE, "w", encoding="utf-8") as f:
json.dump({"admins": []}, f, ensure_ascii=False, indent=2)
# 使用共享的PostgreSQL引擎(user_id使用特殊标识)
engine = await get_engine("_global_users_")
def _load_users_unsafe(self) -> Dict[str, dict]:
"""加载用户数据(不加锁,内部使用)"""
try:
with open(self.USERS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
print(f"加载用户数据失败: {e}")
return {}
session_maker = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False
)
def _save_users_unsafe(self, users: Dict[str, dict]):
"""保存用户数据(不加锁,内部使用)"""
try:
with open(self.USERS_FILE, "w", encoding="utf-8") as f:
json.dump(users, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"保存用户数据失败: {e}")
async def _load_users(self) -> Dict[str, dict]:
"""加载用户数据(加锁)"""
async with self._users_lock:
return self._load_users_unsafe()
async def _save_users(self, users: Dict[str, dict]):
"""保存用户数据(加锁)"""
async with self._users_lock:
self._save_users_unsafe(users)
def _load_admin_list_unsafe(self) -> List[str]:
"""加载管理员列表(不加锁,内部使用)"""
try:
with open(self.ADMINS_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
return data.get("admins", [])
except Exception as e:
print(f"加载管理员列表失败: {e}")
return []
def _save_admin_list_unsafe(self, admin_list: List[str]):
"""保存管理员列表(不加锁,内部使用)"""
try:
with open(self.ADMINS_FILE, "w", encoding="utf-8") as f:
json.dump({"admins": admin_list}, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"保存管理员列表失败: {e}")
async def _load_admin_list(self) -> List[str]:
"""加载管理员列表(加锁)"""
async with self._admins_lock:
return self._load_admin_list_unsafe()
async def _save_admin_list(self, admin_list: List[str]):
"""保存管理员列表(加锁)"""
async with self._admins_lock:
self._save_admin_list_unsafe(admin_list)
return session_maker()
async def create_or_update_from_linuxdo(
self,
@@ -111,106 +54,97 @@ class UserManager:
trust_level: int
) -> User:
"""
LinuxDO 用户信息创建或更新用户线程安全
LinuxDO 用户信息创建或更新用户
Args:
linuxdo_id: LinuxDO 用户 ID本地用户时为 local_xxx 格式
username: 用户名
display_name: 显示名称
avatar_url: 头像 URL
trust_level: 信任等级 (仅用于显示)
trust_level: 信任等级
Returns:
用户对象
"""
# 如果已经是 local_ 开头,直接使用;否则添加 linuxdo_ 前缀
from app.models.user import User as UserModel
# 生成 user_id
if linuxdo_id.startswith("local_"):
user_id = linuxdo_id
else:
user_id = f"linuxdo_{linuxdo_id}"
# 使用锁保护整个读-改-写操作
async with self._users_lock:
async with self._admins_lock:
users = self._load_users_unsafe()
admin_list = self._load_admin_list_unsafe()
async with await self._get_session() as session:
# 查询用户是否存在
result = await session.execute(
select(UserModel).where(UserModel.user_id == user_id)
)
user = result.scalar_one_or_none()
now = datetime.now().isoformat()
# 检查是否为初始管理员或本地用户
initial_admin_id = settings.INITIAL_ADMIN_LINUXDO_ID
is_initial_admin = (initial_admin_id and linuxdo_id == initial_admin_id)
is_local_user = user_id.startswith("local_")
is_admin = is_initial_admin or is_local_user
# 检查是否为初始管理员
initial_admin_id = settings.INITIAL_ADMIN_LINUXDO_ID
is_initial_admin = (initial_admin_id and linuxdo_id == initial_admin_id)
if user:
# 更新现有用户
user.username = username
user.display_name = display_name
user.avatar_url = avatar_url
user.trust_level = trust_level
user.last_login = datetime.now()
# 检查是否为本地用户(所有 local_ 开头的用户默认为管理员
is_local_user = user_id.startswith("local_")
# 更新管理员状态
if is_admin and not user.is_admin:
user.is_admin = True
else:
# 创建新用户
user = UserModel(
user_id=user_id,
username=username,
display_name=display_name,
avatar_url=avatar_url,
trust_level=trust_level,
is_admin=is_admin,
linuxdo_id=linuxdo_id,
created_at=datetime.now(),
last_login=datetime.now()
)
session.add(user)
if user_id in users:
# 更新现有用户
user_data = users[user_id]
user_data["username"] = username
user_data["display_name"] = display_name
user_data["avatar_url"] = avatar_url
user_data["trust_level"] = trust_level
user_data["last_login"] = now
await session.commit()
await session.refresh(user)
# 如果是初始管理员或本地用户且还不在管理员列表中,添加进去
if (is_initial_admin or is_local_user) and user_id not in admin_list:
admin_list.append(user_id)
self._save_admin_list_unsafe(admin_list)
user_data["is_admin"] = True
else:
# 从管理员列表同步 is_admin 状态
user_data["is_admin"] = user_id in admin_list
else:
# 创建新用户(本地用户默认为管理员)
is_admin = is_initial_admin or is_local_user
if is_admin and user_id not in admin_list:
admin_list.append(user_id)
self._save_admin_list_unsafe(admin_list)
user_data = {
"user_id": user_id,
"username": username,
"display_name": display_name,
"avatar_url": avatar_url,
"trust_level": trust_level,
"is_admin": is_admin,
"linuxdo_id": linuxdo_id,
"created_at": now,
"last_login": now
}
users[user_id] = user_data
self._save_users_unsafe(users)
return User(**user_data)
return User(**user.to_dict())
async def get_user(self, user_id: str) -> Optional[User]:
"""获取用户(线程安全)"""
users = await self._load_users()
user_data = users.get(user_id)
if user_data:
# 同步管理员状态
admin_list = await self._load_admin_list()
user_data["is_admin"] = user_id in admin_list
return User(**user_data)
return None
"""获取用户"""
from app.models.user import User as UserModel
async with await self._get_session() as session:
result = await session.execute(
select(UserModel).where(UserModel.user_id == user_id)
)
user = result.scalar_one_or_none()
if user:
return User(**user.to_dict())
return None
async def get_all_users(self) -> List[User]:
"""获取所有用户(线程安全)"""
users = await self._load_users()
admin_list = await self._load_admin_list()
"""获取所有用户"""
from app.models.user import User as UserModel
user_list = []
for user_data in users.values():
# 同步管理员状态
user_data["is_admin"] = user_data["user_id"] in admin_list
user_list.append(User(**user_data))
async with await self._get_session() as session:
result = await session.execute(select(UserModel))
users = result.scalars().all()
return user_list
return [User(**user.to_dict()) for user in users]
async def set_admin(self, user_id: str, is_admin: bool) -> bool:
"""
设置用户的管理员权限线程安全
设置用户的管理员权限
Args:
user_id: 用户 ID
@@ -219,38 +153,35 @@ class UserManager:
Returns:
是否成功
"""
# 使用锁保护整个读-改-写操作
async with self._users_lock:
async with self._admins_lock:
users = self._load_users_unsafe()
if user_id not in users:
from app.models.user import User as UserModel
async with await self._get_session() as session:
result = await session.execute(
select(UserModel).where(UserModel.user_id == user_id)
)
user = result.scalar_one_or_none()
if not user:
return False
if not is_admin:
# 撤销管理员权限时,确保至少保留一个管理员
admin_result = await session.execute(
select(UserModel).where(UserModel.is_admin == True)
)
admin_count = len(admin_result.scalars().all())
if admin_count <= 1:
return False
admin_list = self._load_admin_list_unsafe()
user.is_admin = is_admin
await session.commit()
if is_admin:
# 授予管理员权限
if user_id not in admin_list:
admin_list.append(user_id)
self._save_admin_list_unsafe(admin_list)
else:
# 撤销管理员权限
if user_id in admin_list:
# 确保至少保留一个管理员
if len(admin_list) <= 1:
return False
admin_list.remove(user_id)
self._save_admin_list_unsafe(admin_list)
# 更新用户数据中的 is_admin 字段
users[user_id]["is_admin"] = is_admin
self._save_users_unsafe(users)
return True
return True
async def delete_user(self, user_id: str) -> bool:
"""
删除用户线程安全
删除用户
Args:
user_id: 用户 ID
@@ -258,36 +189,37 @@ class UserManager:
Returns:
是否成功
"""
# 使用锁保护整个读-改-写操作
async with self._users_lock:
async with self._admins_lock:
users = self._load_users_unsafe()
if user_id not in users:
return False
from app.models.user import User as UserModel
# 不能删除管理员
admin_list = self._load_admin_list_unsafe()
if user_id in admin_list:
return False
async with await self._get_session() as session:
result = await session.execute(
select(UserModel).where(UserModel.user_id == user_id)
)
user = result.scalar_one_or_none()
# 删除用户数据
del users[user_id]
self._save_users_unsafe(users)
if not user:
return False
# 删除用户数据库文件(在锁外执行,避免阻塞)
db_file = str(DATA_DIR / f"ai_story_user_{user_id}.db")
if os.path.exists(db_file):
try:
os.remove(db_file)
except Exception as e:
print(f"删除用户数据库文件失败: {e}")
# 不能删除管理员
if user.is_admin:
return False
return True
await session.delete(user)
await session.commit()
return True
async def is_admin(self, user_id: str) -> bool:
"""检查用户是否为管理员(线程安全)"""
admin_list = await self._load_admin_list()
return user_id in admin_list
"""检查用户是否为管理员"""
from app.models.user import User as UserModel
async with await self._get_session() as session:
result = await session.execute(
select(UserModel).where(UserModel.user_id == user_id)
)
user = result.scalar_one_or_none()
return user.is_admin if user else False
# 全局用户管理器实例
+178
View File
@@ -0,0 +1,178 @@
"""
用户密码管理模块 - 使用数据库存储
"""
import asyncio
import hashlib
from typing import Optional
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from app.config import settings
class UserPasswordManager:
"""用户密码管理器 - 使用数据库存储(PostgreSQL共享库)"""
def __init__(self):
"""初始化密码管理器"""
pass
async def _get_session(self) -> AsyncSession:
"""获取数据库会话 - 使用共享的PostgreSQL引擎"""
from app.database import get_engine
# 使用共享的PostgreSQL引擎(user_id使用特殊标识)
engine = await get_engine("_global_users_")
session_maker = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False
)
return session_maker()
def _hash_password(self, password: str) -> str:
"""密码哈希"""
return hashlib.sha256(password.encode()).hexdigest()
async def set_password(self, user_id: str, username: str, password: Optional[str] = None) -> str:
"""
设置用户密码
Args:
user_id: 用户ID
username: 用户名
password: 密码如果为None则使用默认密码username+@666
Returns:
实际使用的密码明文仅用于首次设置时返回给用户
"""
from app.models.user import UserPassword as UserPasswordModel
# 如果没有提供密码,使用默认密码
actual_password = password if password else f"{username}@666"
async with await self._get_session() as session:
# 查询密码记录是否存在
result = await session.execute(
select(UserPasswordModel).where(UserPasswordModel.user_id == user_id)
)
pwd_record = result.scalar_one_or_none()
if pwd_record:
# 更新现有密码
pwd_record.username = username
pwd_record.password_hash = self._hash_password(actual_password)
pwd_record.has_custom_password = password is not None
pwd_record.updated_at = datetime.now()
else:
# 创建新密码记录
pwd_record = UserPasswordModel(
user_id=user_id,
username=username,
password_hash=self._hash_password(actual_password),
has_custom_password=password is not None,
created_at=datetime.now(),
updated_at=datetime.now()
)
session.add(pwd_record)
await session.commit()
return actual_password
async def verify_password(self, user_id: str, password: str) -> bool:
"""
验证用户密码
Args:
user_id: 用户ID
password: 待验证的密码
Returns:
是否验证通过
"""
from app.models.user import UserPassword as UserPasswordModel
async with await self._get_session() as session:
result = await session.execute(
select(UserPasswordModel).where(UserPasswordModel.user_id == user_id)
)
pwd_record = result.scalar_one_or_none()
if not pwd_record:
return False
password_hash = self._hash_password(password)
return pwd_record.password_hash == password_hash
async def has_password(self, user_id: str) -> bool:
"""
检查用户是否已设置密码
Args:
user_id: 用户ID
Returns:
是否已设置密码
"""
from app.models.user import UserPassword as UserPasswordModel
async with await self._get_session() as session:
result = await session.execute(
select(UserPasswordModel).where(UserPasswordModel.user_id == user_id)
)
pwd_record = result.scalar_one_or_none()
return pwd_record is not None
async def has_custom_password(self, user_id: str) -> bool:
"""
检查用户是否设置了自定义密码非默认密码
Args:
user_id: 用户ID
Returns:
是否使用自定义密码
"""
from app.models.user import UserPassword as UserPasswordModel
async with await self._get_session() as session:
result = await session.execute(
select(UserPasswordModel).where(UserPasswordModel.user_id == user_id)
)
pwd_record = result.scalar_one_or_none()
if not pwd_record:
return False
return pwd_record.has_custom_password
async def get_username(self, user_id: str) -> Optional[str]:
"""
获取用户名
Args:
user_id: 用户ID
Returns:
用户名如果不存在返回None
"""
from app.models.user import UserPassword as UserPasswordModel
async with await self._get_session() as session:
result = await session.execute(
select(UserPasswordModel).where(UserPasswordModel.user_id == user_id)
)
pwd_record = result.scalar_one_or_none()
if not pwd_record:
return None
return pwd_record.username
# 全局密码管理器实例
password_manager = UserPasswordManager()
+11 -1
View File
@@ -158,8 +158,18 @@ def create_sse_response(generator: AsyncGenerator[str, None]) -> StreamingRespon
Returns:
StreamingResponse对象
"""
async def wrapper():
"""包装生成器以捕获StreamingResponse初始化时的GeneratorExit"""
try:
async for chunk in generator:
yield chunk
except GeneratorExit:
# StreamingResponse在初始化时会进行类型检查,导致GeneratorExit
# 这是正常行为,不需要记录警告
pass
return StreamingResponse(
generator,
wrapper(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
@@ -0,0 +1,73 @@
-- 创建章节重新生成任务表
-- 用于支持根据AI分析建议重新生成章节内容的功能
-- 创建重新生成任务表
CREATE TABLE IF NOT EXISTS regeneration_tasks (
id VARCHAR(36) PRIMARY KEY,
chapter_id VARCHAR(36) NOT NULL,
analysis_id VARCHAR(36),
user_id VARCHAR(100) NOT NULL,
project_id VARCHAR(36) NOT NULL,
-- 修改指令
modification_instructions TEXT NOT NULL,
original_suggestions JSON,
selected_suggestion_indices JSON,
custom_instructions TEXT,
-- 生成配置
style_id INTEGER,
target_word_count INTEGER DEFAULT 3000,
focus_areas JSON,
preserve_elements JSON,
-- 任务状态
status VARCHAR(20) DEFAULT 'pending',
progress INTEGER DEFAULT 0,
error_message TEXT,
-- 内容数据
original_content TEXT,
original_word_count INTEGER,
regenerated_content TEXT,
regenerated_word_count INTEGER,
-- 版本信息
version_number INTEGER DEFAULT 1,
version_note TEXT,
-- 时间戳
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMP,
completed_at TIMESTAMP,
-- 外键约束
CONSTRAINT fk_regeneration_chapter FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE,
CONSTRAINT fk_regeneration_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
CONSTRAINT fk_regeneration_analysis FOREIGN KEY (analysis_id) REFERENCES analysis_tasks(id) ON DELETE SET NULL,
CONSTRAINT fk_regeneration_style FOREIGN KEY (style_id) REFERENCES writing_styles(id) ON DELETE SET NULL
);
-- 创建索引以提升查询性能
CREATE INDEX IF NOT EXISTS idx_regeneration_tasks_chapter ON regeneration_tasks(chapter_id);
CREATE INDEX IF NOT EXISTS idx_regeneration_tasks_project ON regeneration_tasks(project_id);
CREATE INDEX IF NOT EXISTS idx_regeneration_tasks_user ON regeneration_tasks(user_id);
CREATE INDEX IF NOT EXISTS idx_regeneration_tasks_status ON regeneration_tasks(status);
CREATE INDEX IF NOT EXISTS idx_regeneration_tasks_created ON regeneration_tasks(created_at DESC);
-- 添加注释
COMMENT ON TABLE regeneration_tasks IS '章节重新生成任务表,记录每次根据AI建议重新生成章节的任务';
COMMENT ON COLUMN regeneration_tasks.modification_instructions IS '合并后的完整修改指令';
COMMENT ON COLUMN regeneration_tasks.original_suggestions IS '原始AI分析建议列表';
COMMENT ON COLUMN regeneration_tasks.selected_suggestion_indices IS '用户选择的建议索引';
COMMENT ON COLUMN regeneration_tasks.preserve_elements IS '需要保留的元素配置(JSON)';
COMMENT ON COLUMN regeneration_tasks.focus_areas IS '重点优化方向列表(JSON)';
-- 修复外键约束(合并自 fix_all_missing_columns.sql
-- 删除可能存在问题的外键约束
ALTER TABLE regeneration_tasks
DROP CONSTRAINT IF EXISTS fk_regeneration_analysis;
-- 完成提示
SELECT '✅ 重新生成任务表创建完成,外键约束已修复' AS status;
+224
View File
@@ -0,0 +1,224 @@
"""
用户数据迁移脚本 - 从JSON文件迁移到数据库
"""
import asyncio
import json
import os
import sys
from pathlib import Path
# 添加项目根目录到 Python 路径
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
from app.user_manager import user_manager
from app.user_password import password_manager
from app.config import DATA_DIR
async def migrate_users():
"""迁移用户数据"""
users_file = DATA_DIR / "users.json"
if not users_file.exists():
print("❌ 用户数据文件不存在,跳过迁移")
return 0
try:
with open(users_file, "r", encoding="utf-8") as f:
users_data = json.load(f)
if not users_data:
print("️ 用户数据为空,跳过迁移")
return 0
migrated_count = 0
for user_id, user_info in users_data.items():
try:
# 迁移用户基本信息
await user_manager.create_or_update_from_linuxdo(
linuxdo_id=user_info["linuxdo_id"],
username=user_info["username"],
display_name=user_info["display_name"],
avatar_url=user_info.get("avatar_url"),
trust_level=user_info.get("trust_level", 0)
)
# 如果用户是管理员,设置管理员权限
if user_info.get("is_admin", False):
await user_manager.set_admin(user_id, True)
migrated_count += 1
print(f"✅ 迁移用户: {user_info['username']} ({user_id})")
except Exception as e:
print(f"❌ 迁移用户 {user_id} 失败: {e}")
print(f"\n✅ 用户数据迁移完成: {migrated_count}/{len(users_data)} 个用户")
# 备份原文件
backup_file = DATA_DIR / "users.json.backup"
os.rename(users_file, backup_file)
print(f"📦 原文件已备份到: {backup_file}")
return migrated_count
except Exception as e:
print(f"❌ 迁移用户数据失败: {e}")
return 0
async def migrate_passwords():
"""迁移密码数据"""
passwords_file = DATA_DIR / "user_passwords.json"
if not passwords_file.exists():
print("❌ 密码数据文件不存在,跳过迁移")
return 0
try:
with open(passwords_file, "r", encoding="utf-8") as f:
passwords_data = json.load(f)
if not passwords_data:
print("️ 密码数据为空,跳过迁移")
return 0
migrated_count = 0
for user_id, pwd_info in passwords_data.items():
try:
# 直接插入密码记录(已经是哈希值)
from app.models.user import UserPassword
from app.user_password import password_manager as pm
async with await pm._get_session() as session:
from sqlalchemy import select
# 检查是否已存在
result = await session.execute(
select(UserPassword).where(UserPassword.user_id == user_id)
)
existing = result.scalar_one_or_none()
if existing:
print(f"️ 密码已存在,跳过: {pwd_info['username']} ({user_id})")
continue
# 创建密码记录
from datetime import datetime
pwd_record = UserPassword(
user_id=user_id,
username=pwd_info["username"],
password_hash=pwd_info["password_hash"],
has_custom_password=pwd_info.get("has_custom_password", False),
created_at=datetime.now(),
updated_at=datetime.now()
)
session.add(pwd_record)
await session.commit()
migrated_count += 1
print(f"✅ 迁移密码: {pwd_info['username']} ({user_id})")
except Exception as e:
print(f"❌ 迁移密码 {user_id} 失败: {e}")
print(f"\n✅ 密码数据迁移完成: {migrated_count}/{len(passwords_data)} 个密码")
# 备份原文件
backup_file = DATA_DIR / "user_passwords.json.backup"
os.rename(passwords_file, backup_file)
print(f"📦 原文件已备份到: {backup_file}")
return migrated_count
except Exception as e:
print(f"❌ 迁移密码数据失败: {e}")
return 0
async def migrate_admins():
"""迁移管理员列表"""
admins_file = DATA_DIR / "admins.json"
if not admins_file.exists():
print("❌ 管理员数据文件不存在,跳过迁移")
return 0
try:
with open(admins_file, "r", encoding="utf-8") as f:
admins_data = json.load(f)
admin_list = admins_data.get("admins", [])
if not admin_list:
print("️ 管理员列表为空,跳过迁移")
return 0
migrated_count = 0
for user_id in admin_list:
try:
# 设置管理员权限
success = await user_manager.set_admin(user_id, True)
if success:
migrated_count += 1
print(f"✅ 设置管理员: {user_id}")
else:
print(f"⚠️ 用户不存在或已是管理员: {user_id}")
except Exception as e:
print(f"❌ 设置管理员 {user_id} 失败: {e}")
print(f"\n✅ 管理员数据迁移完成: {migrated_count}/{len(admin_list)} 个管理员")
# 备份原文件
backup_file = DATA_DIR / "admins.json.backup"
os.rename(admins_file, backup_file)
print(f"📦 原文件已备份到: {backup_file}")
return migrated_count
except Exception as e:
print(f"❌ 迁移管理员数据失败: {e}")
return 0
async def main():
"""主函数"""
print("=" * 60)
print("用户数据迁移工具 - JSON 到数据库")
print("=" * 60)
print()
# 迁移用户
print("📋 步骤 1/3: 迁移用户数据")
print("-" * 60)
user_count = await migrate_users()
print()
# 迁移密码
print("📋 步骤 2/3: 迁移密码数据")
print("-" * 60)
pwd_count = await migrate_passwords()
print()
# 迁移管理员
print("📋 步骤 3/3: 迁移管理员数据")
print("-" * 60)
admin_count = await migrate_admins()
print()
# 总结
print("=" * 60)
print("迁移完成")
print("=" * 60)
print(f"✅ 用户: {user_count}")
print(f"✅ 密码: {pwd_count}")
print(f"✅ 管理员: {admin_count}")
print()
print("💡 提示: 原文件已备份为 .backup 后缀")
print("💡 如需回滚,请删除数据库文件并恢复 .backup 文件")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,300 @@
"""
用户数据迁移脚本 - 从JSON文件迁移到PostgreSQL数据库
使用方法:
python migrate_users_to_postgres.py
python migrate_users_to_postgres.py --db-url postgresql+asyncpg://user:pass@localhost/dbname
"""
import asyncio
import json
import os
import sys
import argparse
from pathlib import Path
from datetime import datetime
# 添加项目根目录到 Python 路径
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from app.config import settings, DATA_DIR
async def create_tables(engine):
"""创建用户相关表"""
from app.database import Base
from app.models.user import User, UserPassword
print("📋 创建数据库表...")
async with engine.begin() as conn:
# 只创建用户相关的表
await conn.run_sync(User.metadata.create_all)
await conn.run_sync(UserPassword.metadata.create_all)
print("✅ 表创建成功")
async def migrate_users(session):
"""迁移用户数据"""
from app.models.user import User as UserModel
users_file = DATA_DIR / "users.json"
if not users_file.exists():
print("️ 用户数据文件不存在,跳过迁移")
return 0
try:
with open(users_file, "r", encoding="utf-8") as f:
users_data = json.load(f)
if not users_data:
print("️ 用户数据为空,跳过迁移")
return 0
migrated_count = 0
for user_id, user_info in users_data.items():
try:
# 检查用户是否已存在
result = await session.execute(
select(UserModel).where(UserModel.user_id == user_id)
)
existing = result.scalar_one_or_none()
if existing:
print(f"️ 用户已存在,跳过: {user_info['username']} ({user_id})")
continue
# 创建用户记录
user = UserModel(
user_id=user_id,
username=user_info["username"],
display_name=user_info["display_name"],
avatar_url=user_info.get("avatar_url"),
trust_level=user_info.get("trust_level", 0),
is_admin=user_info.get("is_admin", False),
linuxdo_id=user_info["linuxdo_id"],
created_at=datetime.fromisoformat(user_info.get("created_at", datetime.now().isoformat())),
last_login=datetime.fromisoformat(user_info.get("last_login", datetime.now().isoformat()))
)
session.add(user)
migrated_count += 1
print(f"✅ 迁移用户: {user_info['username']} ({user_id})")
except Exception as e:
print(f"❌ 迁移用户 {user_id} 失败: {e}")
await session.commit()
print(f"\n✅ 用户数据迁移完成: {migrated_count}/{len(users_data)} 个用户")
return migrated_count
except Exception as e:
print(f"❌ 迁移用户数据失败: {e}")
await session.rollback()
return 0
async def migrate_passwords(session):
"""迁移密码数据"""
from app.models.user import UserPassword
passwords_file = DATA_DIR / "user_passwords.json"
if not passwords_file.exists():
print("️ 密码数据文件不存在,跳过迁移")
return 0
try:
with open(passwords_file, "r", encoding="utf-8") as f:
passwords_data = json.load(f)
if not passwords_data:
print("️ 密码数据为空,跳过迁移")
return 0
migrated_count = 0
for user_id, pwd_info in passwords_data.items():
try:
# 检查密码是否已存在
result = await session.execute(
select(UserPassword).where(UserPassword.user_id == user_id)
)
existing = result.scalar_one_or_none()
if existing:
print(f"️ 密码已存在,跳过: {pwd_info['username']} ({user_id})")
continue
# 创建密码记录
pwd_record = UserPassword(
user_id=user_id,
username=pwd_info["username"],
password_hash=pwd_info["password_hash"],
has_custom_password=pwd_info.get("has_custom_password", False),
created_at=datetime.now(),
updated_at=datetime.now()
)
session.add(pwd_record)
migrated_count += 1
print(f"✅ 迁移密码: {pwd_info['username']} ({user_id})")
except Exception as e:
print(f"❌ 迁移密码 {user_id} 失败: {e}")
await session.commit()
print(f"\n✅ 密码数据迁移完成: {migrated_count}/{len(passwords_data)} 个密码")
return migrated_count
except Exception as e:
print(f"❌ 迁移密码数据失败: {e}")
await session.rollback()
return 0
async def backup_json_files():
"""备份原始JSON文件"""
files_to_backup = ["users.json", "user_passwords.json", "admins.json"]
print("\n📦 备份原始文件...")
for filename in files_to_backup:
source = DATA_DIR / filename
if source.exists():
backup = DATA_DIR / f"{filename}.backup.{datetime.now().strftime('%Y%m%d_%H%M%S')}"
import shutil
shutil.copy2(source, backup)
print(f"✅ 备份: {filename} -> {backup.name}")
async def main(db_url=None):
"""主函数
Args:
db_url: 可选的数据库URL如果不提供则使用配置文件中的
"""
print("=" * 70)
print("用户数据迁移工具 - JSON 到 PostgreSQL")
print("=" * 70)
print()
# 确定使用的数据库URL
target_db_url = db_url if db_url else settings.database_url
# 检查数据库配置
if "postgresql" not in target_db_url:
print("❌ 错误: 未指定 PostgreSQL 数据库")
if not db_url:
print(f" 当前配置: {settings.database_url}")
print(" 请使用 --db-url 参数指定PostgreSQL数据库,或在 .env 中配置 DATABASE_URL")
else:
print(f" 提供的URL: {target_db_url}")
print()
print("示例:")
print(" python migrate_users_to_postgres.py --db-url postgresql+asyncpg://user:pass@localhost/dbname")
return
# 隐藏密码部分显示
display_url = target_db_url
if '@' in display_url:
parts = display_url.split('@')
if ':' in parts[0]:
user_part = parts[0].split(':')[0]
display_url = f"{user_part}:****@{parts[1]}"
print(f"📊 目标数据库: {display_url}")
print()
try:
# 创建数据库引擎
engine = create_async_engine(
target_db_url,
echo=False,
future=True,
pool_pre_ping=True,
)
# 创建表
await create_tables(engine)
print()
# 创建会话
async_session = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False
)
# 迁移用户
print("📋 步骤 1/2: 迁移用户数据")
print("-" * 70)
async with async_session() as session:
user_count = await migrate_users(session)
print()
# 迁移密码
print("📋 步骤 2/2: 迁移密码数据")
print("-" * 70)
async with async_session() as session:
pwd_count = await migrate_passwords(session)
print()
# 备份原文件
await backup_json_files()
print()
# 总结
print("=" * 70)
print("迁移完成")
print("=" * 70)
print(f"✅ 用户: {user_count}")
print(f"✅ 密码: {pwd_count}")
print()
print("💡 提示:")
print(" - 原文件已备份(带时间戳)")
print(" - 可以安全删除 users.json 和 user_passwords.json")
print(" - 如需回滚,请从备份文件恢复")
print()
# 关闭引擎
await engine.dispose()
except Exception as e:
print(f"\n❌ 迁移过程出错: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
# 解析命令行参数
parser = argparse.ArgumentParser(
description="迁移用户数据从JSON到PostgreSQL数据库",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
# 使用 .env 配置的数据库
python migrate_users_to_postgres.py
# 指定数据库URL
python migrate_users_to_postgres.py --db-url postgresql+asyncpg://user:pass@localhost/dbname
# 使用环境变量
DATABASE_URL=postgresql+asyncpg://user:pass@localhost/db python migrate_users_to_postgres.py
"""
)
parser.add_argument(
"--db-url",
type=str,
help="PostgreSQL数据库连接URL (格式: postgresql+asyncpg://user:password@host:port/database)",
default=None
)
args = parser.parse_args()
# 运行迁移
asyncio.run(main(db_url=args.db_url))
+156
View File
@@ -0,0 +1,156 @@
# 安装和测试指南
## 1. 安装前端依赖
在完成所有代码更改后,需要安装新添加的npm包:
```bash
cd frontend
npm install
```
这将安装以下新依赖:
- `react-diff-viewer-continued`: 用于版本对比的diff查看器
## 2. 重启后端服务
由于修改了数据模型,需要重启后端服务以加载新的模型定义:
```bash
# 在项目根目录
cd backend
python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
## 3. 启动前端开发服务器
```bash
cd frontend
npm run dev
```
## 4. 测试功能流程
### 4.1 基本流程测试
1. **打开章节列表**
- 进入任意项目
- 查看章节列表
2. **分析章节**
- 点击某个章节的"分析"按钮
- 等待AI分析完成
- 查看分析结果和改进建议
3. **重新生成章节**
- 在分析结果页面,点击"根据建议重新生成"
- 选择要应用的建议
- 可以添加自定义修改要求
- 配置生成参数(字数、保留元素等)
- 勾选"保存为版本历史"(不勾选自动应用)
- 点击"开始重新生成"
- 观察流式生成过程
4. **查看版本对比**
- 生成完成后,点击"查看版本对比"按钮
- 进入版本管理器界面
5. **版本管理操作**
- **版本列表**: 查看所有历史版本
- **预览版本**: 点击"预览"查看某个版本的完整内容
- **对比版本**:
- 点击第一个版本的"对比"按钮
- 再点击第二个版本的"对比"按钮
- 自动切换到"对比"标签页
- 查看并排diff对比
- **恢复版本**: 点击"恢复"将章节内容还原到该版本
- **删除版本**: 删除不需要的历史版本(当前激活版本不能删除)
### 4.2 测试场景
#### 场景1:优化情感描写
1. 分析一个章节
2. 查看建议中关于情感的建议
3. 重新生成时选择情感相关建议
4. 设置重点优化方向为"情感渲染"
5. 生成后对比新旧版本的差异
#### 场景2:调整节奏
1. 选择节奏问题的建议
2. 添加自定义指令:"加快前半部分节奏,增强紧张感"
3. 设置目标字数适当减少(如从3000减到2500)
4. 生成后查看结构变化
#### 场景3:版本管理
1. 对同一章节重新生成多次(使用不同建议)
2. 在版本管理器中浏览所有版本
3. 对比不同版本的差异
4. 选择最满意的版本恢复
## 5. 验证清单
- [ ] 依赖安装无错误
- [ ] 前后端服务正常启动
- [ ] 章节分析功能正常
- [ ] 重新生成功能正常
- [ ] 流式生成显示正常
- [ ] 版本保存成功
- [ ] 版本列表显示正确
- [ ] 版本预览功能正常
- [ ] 版本对比diff显示正确
- [ ] 版本恢复功能正常
- [ ] 版本删除功能正常
- [ ] 移动端适配正常
## 6. 常见问题
### Q1: 依赖安装失败
```bash
# 清除缓存重试
npm cache clean --force
npm install
```
### Q2: 后端启动报错
- 检查是否运行了数据库迁移脚本
- 确认模型定义与数据库表结构一致
### Q3: 版本对比不显示
- 检查浏览器控制台是否有JavaScript错误
- 确认react-diff-viewer-continued正确安装
### Q4: 重新生成后看不到新内容
- 检查是否勾选了"自动应用"
- 查看版本管理器中是否有新版本记录
## 7. 性能优化建议
1. **首次加载优化**
- react-diff-viewer-continued是较大的依赖
- 可以考虑代码分割(lazy loading
2. **版本列表优化**
- 如果版本过多,考虑分页加载
- 添加版本数量限制提示
3. **diff计算优化**
- 对于超长文本,可以限制diff行数
- 添加加载提示
## 8. 下一步优化方向
1. **AI质量评分对比**
- 在版本对比时显示质量分数变化
- 自动标注改进/退步的指标
2. **批量操作**
- 支持批量删除历史版本
- 支持版本导出/导入
3. **协作功能**
- 版本评论和讨论
- 多人协作编辑
4. **智能推荐**
- 基于历史生成结果推荐最佳配置
- 学习用户偏好自动调整参数
+337 -23
View File
@@ -14,6 +14,7 @@
"dayjs": "^1.11.13",
"react": "^18.3.1",
"react-beautiful-dnd": "^13.1.1",
"react-diff-viewer-continued": "^3.4.0",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"zustand": "^5.0.8"
@@ -135,7 +136,6 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.27.1",
@@ -191,7 +191,6 @@
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz",
"integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.3",
@@ -225,7 +224,6 @@
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -235,7 +233,6 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
"integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.27.1",
@@ -277,7 +274,6 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -287,7 +283,6 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -321,7 +316,6 @@
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz",
"integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.4"
@@ -378,7 +372,6 @@
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
@@ -393,7 +386,6 @@
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz",
"integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
@@ -412,7 +404,6 @@
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz",
"integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
@@ -422,18 +413,136 @@
"node": ">=6.9.0"
}
},
"node_modules/@emotion/babel-plugin": {
"version": "11.13.5",
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
"integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==",
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.16.7",
"@babel/runtime": "^7.18.3",
"@emotion/hash": "^0.9.2",
"@emotion/memoize": "^0.9.0",
"@emotion/serialize": "^1.3.3",
"babel-plugin-macros": "^3.1.0",
"convert-source-map": "^1.5.0",
"escape-string-regexp": "^4.0.0",
"find-root": "^1.1.0",
"source-map": "^0.5.7",
"stylis": "4.2.0"
}
},
"node_modules/@emotion/babel-plugin/node_modules/@emotion/hash": {
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
"license": "MIT"
},
"node_modules/@emotion/babel-plugin/node_modules/convert-source-map": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"license": "MIT"
},
"node_modules/@emotion/babel-plugin/node_modules/stylis": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
"license": "MIT"
},
"node_modules/@emotion/cache": {
"version": "11.14.0",
"resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz",
"integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==",
"license": "MIT",
"dependencies": {
"@emotion/memoize": "^0.9.0",
"@emotion/sheet": "^1.4.0",
"@emotion/utils": "^1.4.2",
"@emotion/weak-memoize": "^0.4.0",
"stylis": "4.2.0"
}
},
"node_modules/@emotion/cache/node_modules/stylis": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
"license": "MIT"
},
"node_modules/@emotion/css": {
"version": "11.13.5",
"resolved": "https://registry.npmjs.org/@emotion/css/-/css-11.13.5.tgz",
"integrity": "sha512-wQdD0Xhkn3Qy2VNcIzbLP9MR8TafI0MJb7BEAXKp+w4+XqErksWR4OXomuDzPsN4InLdGhVe6EYcn2ZIUCpB8w==",
"license": "MIT",
"dependencies": {
"@emotion/babel-plugin": "^11.13.5",
"@emotion/cache": "^11.13.5",
"@emotion/serialize": "^1.3.3",
"@emotion/sheet": "^1.4.0",
"@emotion/utils": "^1.4.2"
}
},
"node_modules/@emotion/hash": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz",
"integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==",
"license": "MIT"
},
"node_modules/@emotion/memoize": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz",
"integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==",
"license": "MIT"
},
"node_modules/@emotion/serialize": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz",
"integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==",
"license": "MIT",
"dependencies": {
"@emotion/hash": "^0.9.2",
"@emotion/memoize": "^0.9.0",
"@emotion/unitless": "^0.10.0",
"@emotion/utils": "^1.4.2",
"csstype": "^3.0.2"
}
},
"node_modules/@emotion/serialize/node_modules/@emotion/hash": {
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
"license": "MIT"
},
"node_modules/@emotion/serialize/node_modules/@emotion/unitless": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz",
"integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==",
"license": "MIT"
},
"node_modules/@emotion/sheet": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz",
"integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==",
"license": "MIT"
},
"node_modules/@emotion/unitless": {
"version": "0.7.5",
"resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz",
"integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==",
"license": "MIT"
},
"node_modules/@emotion/utils": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
"integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==",
"license": "MIT"
},
"node_modules/@emotion/weak-memoize": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz",
"integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==",
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz",
@@ -1089,7 +1198,6 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -1111,7 +1219,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -1121,14 +1228,12 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -1726,6 +1831,12 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/parse-json": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
"license": "MIT"
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -2211,6 +2322,21 @@
"proxy-from-env": "^1.1.0"
}
},
"node_modules/babel-plugin-macros": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
"integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5",
"cosmiconfig": "^7.0.0",
"resolve": "^1.19.0"
},
"engines": {
"node": ">=10",
"npm": ">=6"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -2303,7 +2429,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -2414,6 +2539,31 @@
"toggle-selection": "^1.0.6"
}
},
"node_modules/cosmiconfig": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
"integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==",
"license": "MIT",
"dependencies": {
"@types/parse-json": "^4.0.0",
"import-fresh": "^3.2.1",
"parse-json": "^5.0.0",
"path-type": "^4.0.0",
"yaml": "^1.10.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/cosmiconfig/node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"license": "ISC",
"engines": {
"node": ">= 6"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2454,7 +2604,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -2484,6 +2633,15 @@
"node": ">=0.4.0"
}
},
"node_modules/diff": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz",
"integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2505,6 +2663,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/error-ex": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
"integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.2.1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -2606,7 +2773,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -2879,6 +3045,12 @@
"node": ">=8"
}
},
"node_modules/find-root": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
"integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==",
"license": "MIT"
},
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -3147,7 +3319,6 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"parent-module": "^1.0.0",
@@ -3170,6 +3341,27 @@
"node": ">=0.8.19"
}
},
"node_modules/is-arrayish": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
"license": "MIT"
},
"node_modules/is-core-module": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
"license": "MIT",
"dependencies": {
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -3233,7 +3425,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"dev": true,
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
@@ -3249,6 +3440,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-parse-even-better-errors": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"license": "MIT"
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -3309,6 +3506,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT"
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -3431,7 +3634,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -3530,7 +3732,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
"dev": true,
"license": "MIT",
"dependencies": {
"callsites": "^3.0.0"
@@ -3539,6 +3740,24 @@
"node": ">=6"
}
},
"node_modules/parse-json": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.0.0",
"error-ex": "^1.3.1",
"json-parse-even-better-errors": "^2.3.0",
"lines-and-columns": "^1.1.6"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -3559,11 +3778,25 @@
"node": ">=8"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"license": "MIT"
},
"node_modules/path-type": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
@@ -4316,6 +4549,32 @@
"react-dom": "^16.8.5 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-diff-viewer-continued": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/react-diff-viewer-continued/-/react-diff-viewer-continued-3.4.0.tgz",
"integrity": "sha512-kMZmUyb3Pv5L9vUtCfIGYsdOHs8mUojblGy1U1Sm0D7FhAOEsH9QhnngEIRo5hXWIPNGupNRJls1TJ6Eqx84eg==",
"license": "MIT",
"dependencies": {
"@emotion/css": "^11.11.2",
"classnames": "^2.3.2",
"diff": "^5.1.0",
"memoize-one": "^6.0.0",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">= 8"
},
"peerDependencies": {
"react": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-diff-viewer-continued/node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
@@ -4423,11 +4682,30 @@
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
"integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
"license": "MIT",
"dependencies": {
"is-core-module": "^2.16.1",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
@@ -4561,6 +4839,15 @@
"node": ">=8"
}
},
"node_modules/source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
"integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -4609,6 +4896,18 @@
"node": ">=8"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/throttle-debounce": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
@@ -4951,6 +5250,21 @@
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+1
View File
@@ -16,6 +16,7 @@
"dayjs": "^1.11.13",
"react": "^18.3.1",
"react-beautiful-dnd": "^13.1.1",
"react-diff-viewer-continued": "^3.4.0",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"zustand": "^5.0.8"
+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;