update:1.新增AI生成组织功能,扩展优化组织字段(所在地 代表颜色 格言/口号)
2.适配移动端项目管理-剧情分析UI页面
This commit is contained in:
@@ -59,9 +59,6 @@ RUN mkdir -p /app/data /app/logs /app/embedding
|
||||
# 这样可以避免首次运行时联网下载约420MB的模型文件
|
||||
COPY backend/embedding /app/embedding
|
||||
|
||||
# 复制环境变量示例文件
|
||||
COPY backend/.env.example ./.env.example
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8000
|
||||
|
||||
|
||||
+82
-23
@@ -553,36 +553,59 @@ async def analyze_chapter_background(
|
||||
)
|
||||
logger.info(f"✅ 添加{added_count}条记忆到向量库")
|
||||
|
||||
# 最终更新任务状态(写操作,需要锁)
|
||||
async with write_lock:
|
||||
task.progress = 100
|
||||
task.status = 'completed'
|
||||
task.completed_at = datetime.now()
|
||||
await db_session.commit()
|
||||
# 最终更新任务状态(写操作,需要锁)- 增加重试机制
|
||||
update_success = False
|
||||
for retry in range(3):
|
||||
try:
|
||||
async with write_lock:
|
||||
task.progress = 100
|
||||
task.status = 'completed'
|
||||
task.completed_at = datetime.now()
|
||||
await db_session.commit()
|
||||
update_success = True
|
||||
logger.info(f"✅ 章节分析完成: {chapter_id}, 提取{len(memories)}条记忆")
|
||||
break
|
||||
except Exception as commit_error:
|
||||
logger.error(f"❌ 提交任务完成状态失败(重试{retry+1}/3): {str(commit_error)}")
|
||||
if retry < 2:
|
||||
await asyncio.sleep(0.1)
|
||||
else:
|
||||
logger.error(f"❌ 无法更新任务为completed状态: {task_id}")
|
||||
# 即使失败也不抛出异常,因为分析本身已经完成
|
||||
|
||||
logger.info(f"✅ 章节分析完成: {chapter_id}, 提取{len(memories)}条记忆")
|
||||
if not update_success:
|
||||
logger.warning(f"⚠️ 章节分析完成但状态更新失败: {chapter_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 后台分析异常: {str(e)}", exc_info=True)
|
||||
# 确保任务状态被更新为failed(写操作,需要锁)
|
||||
if db_session:
|
||||
try:
|
||||
async with write_lock:
|
||||
task_result = await db_session.execute(
|
||||
select(AnalysisTask).where(AnalysisTask.id == task_id)
|
||||
)
|
||||
task = task_result.scalar_one_or_none()
|
||||
if task:
|
||||
task.status = 'failed'
|
||||
task.error_message = str(e)[:500]
|
||||
task.completed_at = datetime.now()
|
||||
task.progress = 0
|
||||
await db_session.commit()
|
||||
logger.info(f"✅ 任务状态已更新为failed: {task_id}")
|
||||
# 多次重试更新任务状态
|
||||
for retry in range(3):
|
||||
try:
|
||||
async with write_lock:
|
||||
# 重新获取任务(可能是旧会话导致的问题)
|
||||
task_result = await db_session.execute(
|
||||
select(AnalysisTask).where(AnalysisTask.id == task_id)
|
||||
)
|
||||
task = task_result.scalar_one_or_none()
|
||||
if task:
|
||||
task.status = 'failed'
|
||||
task.error_message = str(e)[:500]
|
||||
task.completed_at = datetime.now()
|
||||
task.progress = 0
|
||||
await db_session.commit()
|
||||
logger.info(f"✅ 任务状态已更新为failed: {task_id} (重试{retry+1}次)")
|
||||
break
|
||||
else:
|
||||
logger.error(f"❌ 无法找到任务进行状态更新: {task_id}")
|
||||
break
|
||||
except Exception as update_error:
|
||||
logger.error(f"❌ 更新任务状态失败(重试{retry+1}/3): {str(update_error)}")
|
||||
if retry < 2:
|
||||
await asyncio.sleep(0.1) # 短暂等待后重试
|
||||
else:
|
||||
logger.error(f"❌ 无法找到任务进行状态更新: {task_id}")
|
||||
except Exception as update_error:
|
||||
logger.error(f"❌ 更新任务状态失败: {str(update_error)}")
|
||||
logger.error(f"❌ 任务状态更新失败,已达到最大重试次数: {task_id}")
|
||||
finally:
|
||||
if db_session:
|
||||
await db_session.close()
|
||||
@@ -956,14 +979,21 @@ async def get_analysis_task_status(
|
||||
"""
|
||||
查询指定章节的最新分析任务状态
|
||||
|
||||
自动恢复机制:
|
||||
- 如果任务状态为running且超过1分钟未更新,自动标记为failed
|
||||
- 如果任务状态为pending且超过2分钟未启动,自动标记为failed
|
||||
|
||||
返回:
|
||||
- task_id: 任务ID
|
||||
- status: pending/running/completed/failed
|
||||
- progress: 0-100
|
||||
- error_message: 错误信息(如果失败)
|
||||
- auto_recovered: 是否被自动恢复
|
||||
- created_at: 创建时间
|
||||
- completed_at: 完成时间
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
# 获取该章节最新的分析任务
|
||||
result = await db.execute(
|
||||
select(AnalysisTask)
|
||||
@@ -976,12 +1006,41 @@ async def get_analysis_task_status(
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="未找到分析任务")
|
||||
|
||||
auto_recovered = False
|
||||
current_time = datetime.now()
|
||||
|
||||
# 自动恢复卡住的任务
|
||||
if task.status == 'running':
|
||||
# 如果任务在running状态超过1分钟,标记为失败
|
||||
if task.started_at and (current_time - task.started_at) > timedelta(minutes=1):
|
||||
task.status = 'failed'
|
||||
task.error_message = '任务超时(超过1分钟未完成,已自动恢复)'
|
||||
task.completed_at = current_time
|
||||
task.progress = 0
|
||||
auto_recovered = True
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
logger.warning(f"🔄 自动恢复卡住的任务: {task.id}, 章节: {chapter_id}")
|
||||
|
||||
elif task.status == 'pending':
|
||||
# 如果任务在pending状态超过2分钟仍未开始,标记为失败
|
||||
if task.created_at and (current_time - task.created_at) > timedelta(minutes=2):
|
||||
task.status = 'failed'
|
||||
task.error_message = '任务启动超时(超过2分钟未启动,已自动恢复)'
|
||||
task.completed_at = current_time
|
||||
task.progress = 0
|
||||
auto_recovered = True
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
logger.warning(f"🔄 自动恢复未启动的任务: {task.id}, 章节: {chapter_id}")
|
||||
|
||||
return {
|
||||
"task_id": task.id,
|
||||
"chapter_id": task.chapter_id,
|
||||
"status": task.status,
|
||||
"progress": task.progress,
|
||||
"error_message": task.error_message,
|
||||
"auto_recovered": auto_recovered,
|
||||
"created_at": task.created_at.isoformat() if task.created_at else None,
|
||||
"started_at": task.started_at.isoformat() if task.started_at else None,
|
||||
"completed_at": task.completed_at.isoformat() if task.completed_at else None
|
||||
|
||||
@@ -44,7 +44,50 @@ async def get_characters(
|
||||
)
|
||||
characters = result.scalars().all()
|
||||
|
||||
return CharacterListResponse(total=total, items=characters)
|
||||
# 为组织类型的角色填充Organization表的额外字段
|
||||
enriched_characters = []
|
||||
for char in characters:
|
||||
char_dict = {
|
||||
"id": char.id,
|
||||
"project_id": char.project_id,
|
||||
"name": char.name,
|
||||
"age": char.age,
|
||||
"gender": char.gender,
|
||||
"is_organization": char.is_organization,
|
||||
"role_type": char.role_type,
|
||||
"personality": char.personality,
|
||||
"background": char.background,
|
||||
"appearance": char.appearance,
|
||||
"relationships": char.relationships,
|
||||
"organization_type": char.organization_type,
|
||||
"organization_purpose": char.organization_purpose,
|
||||
"organization_members": char.organization_members,
|
||||
"traits": char.traits,
|
||||
"avatar_url": char.avatar_url,
|
||||
"created_at": char.created_at,
|
||||
"updated_at": char.updated_at,
|
||||
"power_level": None,
|
||||
"location": None,
|
||||
"motto": None,
|
||||
"color": None
|
||||
}
|
||||
|
||||
if char.is_organization:
|
||||
org_result = await db.execute(
|
||||
select(Organization).where(Organization.character_id == char.id)
|
||||
)
|
||||
org = org_result.scalar_one_or_none()
|
||||
if org:
|
||||
char_dict.update({
|
||||
"power_level": org.power_level,
|
||||
"location": org.location,
|
||||
"motto": org.motto,
|
||||
"color": org.color
|
||||
})
|
||||
|
||||
enriched_characters.append(char_dict)
|
||||
|
||||
return CharacterListResponse(total=total, items=enriched_characters)
|
||||
|
||||
|
||||
@router.get("/project/{project_id}", response_model=CharacterListResponse, summary="获取项目的所有角色")
|
||||
@@ -67,7 +110,50 @@ async def get_project_characters(
|
||||
)
|
||||
characters = result.scalars().all()
|
||||
|
||||
return CharacterListResponse(total=total, items=characters)
|
||||
# 为组织类型的角色填充Organization表的额外字段
|
||||
enriched_characters = []
|
||||
for char in characters:
|
||||
char_dict = {
|
||||
"id": char.id,
|
||||
"project_id": char.project_id,
|
||||
"name": char.name,
|
||||
"age": char.age,
|
||||
"gender": char.gender,
|
||||
"is_organization": char.is_organization,
|
||||
"role_type": char.role_type,
|
||||
"personality": char.personality,
|
||||
"background": char.background,
|
||||
"appearance": char.appearance,
|
||||
"relationships": char.relationships,
|
||||
"organization_type": char.organization_type,
|
||||
"organization_purpose": char.organization_purpose,
|
||||
"organization_members": char.organization_members,
|
||||
"traits": char.traits,
|
||||
"avatar_url": char.avatar_url,
|
||||
"created_at": char.created_at,
|
||||
"updated_at": char.updated_at,
|
||||
"power_level": None,
|
||||
"location": None,
|
||||
"motto": None,
|
||||
"color": None
|
||||
}
|
||||
|
||||
if char.is_organization:
|
||||
org_result = await db.execute(
|
||||
select(Organization).where(Organization.character_id == char.id)
|
||||
)
|
||||
org = org_result.scalar_one_or_none()
|
||||
if org:
|
||||
char_dict.update({
|
||||
"power_level": org.power_level,
|
||||
"location": org.location,
|
||||
"motto": org.motto,
|
||||
"color": org.color
|
||||
})
|
||||
|
||||
enriched_characters.append(char_dict)
|
||||
|
||||
return CharacterListResponse(total=total, items=enriched_characters)
|
||||
|
||||
|
||||
@router.get("/{character_id}", response_model=CharacterResponse, summary="获取角色详情")
|
||||
@@ -213,16 +299,12 @@ async def generate_character(
|
||||
logger.info(f" - 角色名:{request.name or 'AI生成'}")
|
||||
logger.info(f" - 角色定位:{request.role_type}")
|
||||
logger.info(f" - 背景设定:{request.background or '无'}")
|
||||
logger.info(f" - AI提供商:{request.provider or 'default'}")
|
||||
logger.info(f" - AI模型:{request.model or 'default'}")
|
||||
logger.info(f" - AI提供商:{user_ai_service.api_provider}")
|
||||
logger.info(f" - AI模型:{user_ai_service.default_model}")
|
||||
logger.info(f" - Prompt长度:{len(prompt)} 字符")
|
||||
|
||||
try:
|
||||
ai_response = await user_ai_service.generate_text(
|
||||
prompt=prompt,
|
||||
provider=request.provider,
|
||||
model=request.model
|
||||
)
|
||||
ai_response = await user_ai_service.generate_text(prompt=prompt)
|
||||
logger.info(f"✅ AI响应接收完成,长度:{len(ai_response) if ai_response else 0} 字符")
|
||||
except Exception as ai_error:
|
||||
logger.error(f"❌ AI服务调用异常:{str(ai_error)}")
|
||||
@@ -317,7 +399,8 @@ async def generate_character(
|
||||
member_count=0,
|
||||
power_level=character_data.get("power_level", 50),
|
||||
location=character_data.get("location"),
|
||||
motto=character_data.get("motto")
|
||||
motto=character_data.get("motto"),
|
||||
color=character_data.get("color")
|
||||
)
|
||||
db.add(organization)
|
||||
await db.flush()
|
||||
@@ -477,7 +560,7 @@ async def generate_character(
|
||||
project_id=request.project_id,
|
||||
prompt=prompt,
|
||||
generated_content=ai_response,
|
||||
model=request.model or "default"
|
||||
model=user_ai_service.default_model
|
||||
)
|
||||
db.add(history)
|
||||
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
import json
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.relationship import Organization, OrganizationMember
|
||||
from app.models.character import Character
|
||||
from app.models.project import Project
|
||||
from app.models.generation_history import GenerationHistory
|
||||
from app.schemas.relationship import (
|
||||
OrganizationCreate,
|
||||
OrganizationUpdate,
|
||||
@@ -17,12 +21,25 @@ from app.schemas.relationship import (
|
||||
OrganizationMemberResponse,
|
||||
OrganizationMemberDetailResponse
|
||||
)
|
||||
from app.schemas.character import CharacterResponse
|
||||
from app.services.ai_service import AIService
|
||||
from app.services.prompt_service import prompt_service
|
||||
from app.logger import get_logger
|
||||
from app.api.settings import get_user_ai_service
|
||||
|
||||
router = APIRouter(prefix="/organizations", tags=["组织管理"])
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class OrganizationGenerateRequest(BaseModel):
|
||||
"""AI生成组织的请求模型"""
|
||||
project_id: str = Field(..., description="项目ID")
|
||||
name: Optional[str] = Field(None, description="组织名称")
|
||||
organization_type: Optional[str] = Field(None, description="组织类型")
|
||||
background: Optional[str] = Field(None, description="组织背景")
|
||||
requirements: Optional[str] = Field(None, description="特殊要求")
|
||||
|
||||
|
||||
@router.get("/project/{project_id}", response_model=List[OrganizationDetailResponse], summary="获取项目的所有组织")
|
||||
async def get_project_organizations(
|
||||
project_id: str,
|
||||
@@ -339,3 +356,192 @@ async def remove_organization_member(
|
||||
|
||||
logger.info(f"移除成员成功:{member_id}")
|
||||
return {"message": "成员移除成功", "id": member_id}
|
||||
|
||||
@router.post("/generate", response_model=CharacterResponse, summary="AI生成组织")
|
||||
async def generate_organization(
|
||||
request: OrganizationGenerateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user_ai_service: AIService = Depends(get_user_ai_service)
|
||||
):
|
||||
"""
|
||||
使用AI生成组织设定
|
||||
|
||||
根据用户输入的信息,结合项目的世界观、主题等背景,
|
||||
AI会生成一个完整、详细的组织设定。
|
||||
|
||||
生成内容包括:组织名称、类型、特性、背景、目的、势力等级等
|
||||
"""
|
||||
# 验证项目是否存在并获取项目信息
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.id == request.project_id)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
try:
|
||||
# 获取已存在的角色和组织列表
|
||||
existing_chars_result = await db.execute(
|
||||
select(Character)
|
||||
.where(Character.project_id == request.project_id)
|
||||
.order_by(Character.created_at.desc())
|
||||
)
|
||||
existing_characters = existing_chars_result.scalars().all()
|
||||
|
||||
# 构建现有角色和组织信息摘要
|
||||
existing_info = ""
|
||||
character_list = []
|
||||
organization_list = []
|
||||
|
||||
if existing_characters:
|
||||
for c in existing_characters[:10]: # 最多显示10个
|
||||
if c.is_organization:
|
||||
organization_list.append(f"- {c.name} [{c.organization_type or '组织'}]")
|
||||
else:
|
||||
character_list.append(f"- {c.name}({c.role_type or '未知'})")
|
||||
|
||||
if character_list:
|
||||
existing_info += "\n已有角色:\n" + "\n".join(character_list)
|
||||
if organization_list:
|
||||
existing_info += "\n\n已有组织:\n" + "\n".join(organization_list)
|
||||
|
||||
# 构建项目上下文信息
|
||||
project_context = f"""
|
||||
项目信息:
|
||||
- 书名:{project.title}
|
||||
- 主题:{project.theme or '未设定'}
|
||||
- 类型:{project.genre or '未设定'}
|
||||
- 时间背景:{project.world_time_period or '未设定'}
|
||||
- 地理位置:{project.world_location or '未设定'}
|
||||
- 氛围基调:{project.world_atmosphere or '未设定'}
|
||||
- 世界规则:{project.world_rules or '未设定'}
|
||||
{existing_info}
|
||||
"""
|
||||
|
||||
# 构建用户输入信息
|
||||
user_input = f"""
|
||||
用户要求:
|
||||
- 组织名称:{request.name or '请AI生成'}
|
||||
- 组织类型:{request.organization_type or '请AI根据世界观决定'}
|
||||
- 背景设定:{request.background or '无特殊要求'}
|
||||
- 其他要求:{request.requirements or '无'}
|
||||
"""
|
||||
|
||||
# 使用统一的提示词服务
|
||||
prompt = prompt_service.get_single_organization_prompt(
|
||||
project_context=project_context,
|
||||
user_input=user_input
|
||||
)
|
||||
|
||||
# 调用AI生成组织
|
||||
logger.info(f"🎯 开始为项目 {request.project_id} 生成组织")
|
||||
logger.info(f" - 组织名:{request.name or 'AI生成'}")
|
||||
logger.info(f" - 组织类型:{request.organization_type or 'AI决定'}")
|
||||
logger.info(f" - 背景设定:{request.background or '无'}")
|
||||
logger.info(f" - AI提供商:{user_ai_service.api_provider}")
|
||||
logger.info(f" - AI模型:{user_ai_service.default_model}")
|
||||
logger.info(f" - Prompt长度:{len(prompt)} 字符")
|
||||
|
||||
try:
|
||||
ai_response = await user_ai_service.generate_text(prompt=prompt)
|
||||
logger.info(f"✅ AI响应接收完成,长度:{len(ai_response) if ai_response else 0} 字符")
|
||||
except Exception as ai_error:
|
||||
logger.error(f"❌ AI服务调用异常:{str(ai_error)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"AI服务调用失败:{str(ai_error)}"
|
||||
)
|
||||
|
||||
# 检查AI响应
|
||||
if not ai_response or not ai_response.strip():
|
||||
logger.error("❌ AI返回了空响应")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="AI服务返回空响应。请检查AI配置和网络连接。"
|
||||
)
|
||||
|
||||
logger.info(f"📝 开始清理AI响应")
|
||||
# 清理AI响应
|
||||
cleaned_response = ai_response.strip()
|
||||
if cleaned_response.startswith("```json"):
|
||||
cleaned_response = cleaned_response[7:]
|
||||
if cleaned_response.startswith("```"):
|
||||
cleaned_response = cleaned_response[3:]
|
||||
if cleaned_response.endswith("```"):
|
||||
cleaned_response = cleaned_response[:-3]
|
||||
cleaned_response = cleaned_response.strip()
|
||||
|
||||
logger.info(f" - 清理后长度:{len(cleaned_response)}")
|
||||
|
||||
# 解析AI响应
|
||||
logger.info(f"🔍 开始解析JSON")
|
||||
try:
|
||||
organization_data = json.loads(cleaned_response)
|
||||
logger.info(f"✅ JSON解析成功")
|
||||
logger.info(f" - 解析后的字段:{list(organization_data.keys())}")
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"❌ JSON解析失败:{str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"AI返回的内容无法解析为JSON。错误:{str(e)}"
|
||||
)
|
||||
|
||||
# 创建角色记录(组织也是角色的一种)
|
||||
character = Character(
|
||||
project_id=request.project_id,
|
||||
name=organization_data.get("name", request.name or "未命名组织"),
|
||||
is_organization=True,
|
||||
role_type="supporting", # 组织通常作为配角
|
||||
personality=organization_data.get("personality", ""),
|
||||
background=organization_data.get("background", ""),
|
||||
appearance=organization_data.get("appearance", ""),
|
||||
organization_type=organization_data.get("organization_type"),
|
||||
organization_purpose=organization_data.get("organization_purpose"),
|
||||
organization_members=json.dumps(
|
||||
organization_data.get("organization_members", []),
|
||||
ensure_ascii=False
|
||||
),
|
||||
traits=json.dumps(
|
||||
organization_data.get("traits", []),
|
||||
ensure_ascii=False
|
||||
)
|
||||
)
|
||||
db.add(character)
|
||||
await db.flush()
|
||||
|
||||
logger.info(f"✅ 组织角色创建成功:{character.name} (ID: {character.id})")
|
||||
|
||||
# 自动创建Organization详情记录
|
||||
organization = Organization(
|
||||
character_id=character.id,
|
||||
project_id=request.project_id,
|
||||
member_count=0,
|
||||
power_level=organization_data.get("power_level", 50),
|
||||
location=organization_data.get("location"),
|
||||
motto=organization_data.get("motto"),
|
||||
color=organization_data.get("color")
|
||||
)
|
||||
db.add(organization)
|
||||
await db.flush()
|
||||
|
||||
logger.info(f"✅ 组织详情创建成功:{character.name} (Org ID: {organization.id})")
|
||||
|
||||
# 记录生成历史
|
||||
history = GenerationHistory(
|
||||
project_id=request.project_id,
|
||||
prompt=prompt,
|
||||
generated_content=ai_response,
|
||||
model=user_ai_service.default_model
|
||||
)
|
||||
db.add(history)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(character)
|
||||
|
||||
logger.info(f"🎉 成功为项目 {request.project_id} 生成组织: {character.name}")
|
||||
|
||||
return character
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"生成组织失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"生成组织失败: {str(e)}")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""项目管理API"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, delete
|
||||
@@ -13,6 +13,7 @@ from app.models.outline import Outline
|
||||
from app.models.chapter import Chapter
|
||||
from app.models.generation_history import GenerationHistory
|
||||
from app.models.relationship import CharacterRelationship, Organization, OrganizationMember
|
||||
from app.models.memory import StoryMemory, PlotAnalysis
|
||||
from app.schemas.project import (
|
||||
ProjectCreate,
|
||||
ProjectUpdate,
|
||||
@@ -25,6 +26,7 @@ from app.schemas.import_export import (
|
||||
ImportResult
|
||||
)
|
||||
from app.services.import_export_service import ImportExportService
|
||||
from app.services.memory_service import memory_service
|
||||
from app.logger import get_logger
|
||||
from app.utils.data_consistency import (
|
||||
run_full_data_consistency_check,
|
||||
@@ -143,6 +145,7 @@ async def update_project(
|
||||
@router.delete("/{project_id}", summary="删除项目")
|
||||
async def delete_project(
|
||||
project_id: str,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
try:
|
||||
@@ -158,6 +161,19 @@ async def delete_project(
|
||||
|
||||
project_title = project.title
|
||||
|
||||
# 从认证中间件获取用户ID
|
||||
user_id = getattr(request.state, 'user_id', None)
|
||||
|
||||
# 删除向量数据库中的记忆
|
||||
if user_id:
|
||||
try:
|
||||
await memory_service.delete_project_memories(user_id, project_id)
|
||||
logger.info(f"✅ 向量数据库清理成功")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ 向量数据库清理失败(继续删除其他数据): {str(e)}")
|
||||
else:
|
||||
logger.warning(f"⚠️ 未找到用户ID,跳过向量数据库清理")
|
||||
|
||||
relationships_result = await db.execute(
|
||||
delete(CharacterRelationship).where(CharacterRelationship.project_id == project_id)
|
||||
)
|
||||
@@ -200,11 +216,14 @@ async def delete_project(
|
||||
)
|
||||
logger.debug(f"删除角色数: {characters_result.rowcount}")
|
||||
|
||||
# 注意:StoryMemory和PlotAnalysis会通过数据库级联删除自动清理
|
||||
# 但向量数据库已在上面手动清理
|
||||
|
||||
await db.delete(project)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"项目删除成功: {project_title}")
|
||||
return {"message": "项目及所有关联数据删除成功"}
|
||||
return {"message": "项目及所有关联数据(包括向量数据库)删除成功"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
||||
@@ -452,21 +452,24 @@ async def characters_generator(
|
||||
elif isinstance(char_data.get("relationships"), str):
|
||||
relationships_text = char_data.get("relationships")
|
||||
|
||||
# 判断是否为组织
|
||||
is_organization = char_data.get("is_organization", False)
|
||||
|
||||
character = Character(
|
||||
project_id=project_id,
|
||||
name=char_data.get("name", "未命名角色"),
|
||||
age=char_data.get("age"),
|
||||
gender=char_data.get("gender"),
|
||||
is_organization=char_data.get("is_organization", False),
|
||||
age=str(char_data.get("age", "")) if not is_organization else None,
|
||||
gender=char_data.get("gender") if not is_organization else None,
|
||||
is_organization=is_organization,
|
||||
role_type=char_data.get("role_type", "supporting"),
|
||||
personality=char_data.get("personality", ""),
|
||||
background=char_data.get("background", ""),
|
||||
appearance=char_data.get("appearance", ""),
|
||||
relationships=relationships_text,
|
||||
organization_type=char_data.get("organization_type"),
|
||||
organization_purpose=char_data.get("organization_purpose"),
|
||||
organization_members=json.dumps(char_data.get("organization_members", []), ensure_ascii=False),
|
||||
traits=json.dumps(char_data.get("traits", []), ensure_ascii=False)
|
||||
organization_type=char_data.get("organization_type") if is_organization else None,
|
||||
organization_purpose=char_data.get("organization_purpose") if is_organization else None,
|
||||
organization_members=json.dumps(char_data.get("organization_members", []), ensure_ascii=False) if is_organization else None,
|
||||
traits=json.dumps(char_data.get("traits", []), ensure_ascii=False) if char_data.get("traits") else None
|
||||
)
|
||||
db.add(character)
|
||||
created_characters.append((character, char_data))
|
||||
@@ -497,9 +500,10 @@ async def characters_generator(
|
||||
character_id=character.id,
|
||||
project_id=project_id,
|
||||
member_count=0, # 初始为0,后续添加成员时会更新
|
||||
power_level=char_data.get("power_level", 5),
|
||||
power_level=char_data.get("power_level", 50),
|
||||
location=char_data.get("location"),
|
||||
motto=char_data.get("motto")
|
||||
motto=char_data.get("motto"),
|
||||
color=char_data.get("color")
|
||||
)
|
||||
db.add(org)
|
||||
logger.info(f"向导创建组织记录:{character.name}")
|
||||
|
||||
@@ -46,6 +46,12 @@ class CharacterResponse(CharacterBase):
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
# 组织额外字段(从Organization表关联)
|
||||
power_level: Optional[int] = Field(None, description="组织势力等级(0-100)")
|
||||
location: Optional[str] = Field(None, description="组织所在地")
|
||||
motto: Optional[str] = Field(None, description="组织格言/口号")
|
||||
color: Optional[str] = Field(None, description="组织代表颜色")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -57,8 +63,6 @@ class CharacterGenerateRequest(BaseModel):
|
||||
role_type: Optional[str] = Field(None, description="角色类型")
|
||||
background: Optional[str] = Field(None, description="角色背景")
|
||||
requirements: Optional[str] = Field(None, description="特殊要求")
|
||||
provider: Optional[str] = Field(None, description="AI提供商")
|
||||
model: Optional[str] = Field(None, description="AI模型")
|
||||
|
||||
|
||||
class CharacterListResponse(BaseModel):
|
||||
|
||||
@@ -659,6 +659,44 @@ class MemoryService:
|
||||
logger.error(f"❌ 删除章节记忆失败: {str(e)}")
|
||||
return False
|
||||
|
||||
async def delete_project_memories(
|
||||
self,
|
||||
user_id: str,
|
||||
project_id: str
|
||||
) -> bool:
|
||||
"""
|
||||
删除指定项目的所有记忆(包括向量数据库)
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
project_id: 项目ID
|
||||
|
||||
Returns:
|
||||
是否删除成功
|
||||
"""
|
||||
try:
|
||||
# 生成collection名称
|
||||
user_hash = hashlib.sha256(user_id.encode()).hexdigest()[:8]
|
||||
project_hash = hashlib.sha256(project_id.encode()).hexdigest()[:8]
|
||||
collection_name = f"u_{user_hash}_p_{project_hash}"
|
||||
|
||||
# 删除整个collection(这会清理所有向量数据)
|
||||
try:
|
||||
self.client.delete_collection(name=collection_name)
|
||||
logger.info(f"🗑️ 已删除项目{project_id[:8]}的向量数据库collection: {collection_name}")
|
||||
return True
|
||||
except Exception as e:
|
||||
# 如果collection不存在,也算成功
|
||||
if "does not exist" in str(e).lower():
|
||||
logger.info(f"ℹ️ 项目{project_id[:8]}的collection不存在,无需删除")
|
||||
return True
|
||||
else:
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 删除项目记忆失败: {str(e)}")
|
||||
return False
|
||||
|
||||
async def update_memory(
|
||||
self,
|
||||
user_id: str,
|
||||
|
||||
@@ -169,7 +169,7 @@ class PromptService:
|
||||
- 至少1个主角(protagonist)
|
||||
- 多个配角(supporting)
|
||||
- 可以包含反派(antagonist)
|
||||
- 可以包含1-2个重要组织
|
||||
- 可以包含1-2个**高影响力的重要组织**(势力等级应在70-95之间)
|
||||
|
||||
要求:
|
||||
- 角色要符合世界观设定
|
||||
@@ -222,10 +222,21 @@ class PromptService:
|
||||
"organization_type": "组织类型",
|
||||
"organization_purpose": "组织目的",
|
||||
"organization_members": ["成员1", "成员2"],
|
||||
"power_level": 85,
|
||||
"location": "组织所在地或主要活动区域",
|
||||
"motto": "组织格言、口号或宗旨",
|
||||
"color": "组织代表颜色(如:深红色、金色、黑色等)",
|
||||
"traits": []
|
||||
}}
|
||||
]
|
||||
|
||||
**组织生成要求(重要):**
|
||||
- 组织必须是对故事有重大影响的势力
|
||||
- power_level应在70-95之间(高影响力组织)
|
||||
- 不要生成无关紧要的小组织或普通社团
|
||||
- 组织应该是推动剧情发展的关键力量
|
||||
- 可以是正派势力、中立势力或反派势力,但一定要有存在感
|
||||
|
||||
**关系类型参考(从中选择或自定义):**
|
||||
- 家族:父亲、母亲、兄弟、姐妹、子女、配偶、恋人
|
||||
- 社交:师父、徒弟、朋友、同学、同事、邻居、知己
|
||||
@@ -678,6 +689,91 @@ class PromptService:
|
||||
- 配角要有独特性,不能是工具人
|
||||
- 所有设定要为故事服务
|
||||
|
||||
再次强调:
|
||||
1. 只返回纯JSON对象,不要有```json```这样的标记
|
||||
2. 文本中不要使用中文引号(""),改用【】或《》
|
||||
3. 不要有任何额外的文字说明"""
|
||||
|
||||
# 单个组织生成提示词
|
||||
SINGLE_ORGANIZATION_GENERATION = """你是一位专业的组织设定师。请根据以下信息创建一个完整的组织/势力设定。
|
||||
|
||||
{project_context}
|
||||
|
||||
{user_input}
|
||||
|
||||
请生成一个完整的组织设定,包含以下所有信息:
|
||||
|
||||
1. **基本信息**:
|
||||
- 组织名称:如果用户未提供,请生成一个符合世界观的名称
|
||||
- 组织类型:如帮派、公司、门派、学院、政府机构、宗教组织等
|
||||
- 成立时间:具体时间或时间段
|
||||
|
||||
2. **组织特性**(150-200字):
|
||||
- 组织的核心理念和行事风格
|
||||
- 组织文化和价值观
|
||||
- 运作方式和管理模式
|
||||
- 特殊传统或规矩
|
||||
|
||||
3. **组织背景**(200-300字):
|
||||
- 建立历史和起源
|
||||
- 发展历程和重要事件
|
||||
- 目前的地位和影响力
|
||||
- 如何与项目主题关联
|
||||
- 融入用户提供的背景设定
|
||||
|
||||
4. **外在表现**(100-150字):
|
||||
- 总部或主要据点位置
|
||||
- 标志性建筑或场所
|
||||
- 组织标志、徽章、制服等
|
||||
- 可辨识的外在特征
|
||||
|
||||
5. **组织目的/宗旨**:
|
||||
- 明确的组织目标
|
||||
- 长期愿景
|
||||
- 行动准则
|
||||
|
||||
6. **势力等级**:
|
||||
- 在世界中的影响力(0-100)
|
||||
- 综合实力评估
|
||||
|
||||
7. **所在地点**:
|
||||
- 主要活动区域
|
||||
- 势力范围
|
||||
|
||||
**重要格式要求:**
|
||||
1. 只返回纯JSON格式,不要包含任何markdown标记、代码块标记或其他说明文字
|
||||
2. 不要在JSON字符串值中使用中文引号(""''),改用【】或《》
|
||||
3. 文本描述中的专有名词使用【】标记
|
||||
|
||||
请严格按照以下JSON格式返回:
|
||||
{{
|
||||
"name": "组织名称",
|
||||
"is_organization": true,
|
||||
"organization_type": "组织类型",
|
||||
"personality": "组织特性(150-200字)",
|
||||
"background": "组织背景(200-300字)",
|
||||
"appearance": "外在表现(100-150字)",
|
||||
"organization_purpose": "组织目的和宗旨",
|
||||
"power_level": 75,
|
||||
"location": "所在地点",
|
||||
"motto": "组织格言或口号",
|
||||
"traits": ["特征1", "特征2", "特征3"],
|
||||
"color": "组织代表颜色(如:深红色、金色、黑色等)",
|
||||
"organization_members": ["重要成员1", "重要成员2", "重要成员3"]
|
||||
}}
|
||||
|
||||
**组织设定要求:**
|
||||
- 组织要符合项目的世界观和主题
|
||||
- 目标和行动要合理,不能过于理想化或脸谱化
|
||||
- 要有存在的必要性,能推动故事发展
|
||||
- 内部要有层级和结构
|
||||
- 与其他势力要有互动关系
|
||||
|
||||
**说明**:
|
||||
1. power_level是0-100的整数,表示组织在世界中的影响力
|
||||
2. organization_members是组织内重要成员的名字列表(如果已有角色,可以关联)
|
||||
3. 所有文本描述要详细具体,避免空泛
|
||||
|
||||
再次强调:
|
||||
1. 只返回纯JSON对象,不要有```json```这样的标记
|
||||
2. 文本中不要使用中文引号(""),改用【】或《》
|
||||
@@ -939,6 +1035,15 @@ class PromptService:
|
||||
user_input=user_input
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_single_organization_prompt(cls, project_context: str, user_input: str) -> str:
|
||||
"""获取单个组织生成提示词"""
|
||||
return cls.format_prompt(
|
||||
cls.SINGLE_ORGANIZATION_GENERATION,
|
||||
project_context=project_context,
|
||||
user_input=user_input
|
||||
)
|
||||
|
||||
|
||||
# 创建全局提示词服务实例
|
||||
prompt_service = PromptService()
|
||||
@@ -32,6 +32,7 @@ interface AnnotatedTextProps {
|
||||
onAnnotationClick?: (annotation: MemoryAnnotation) => void;
|
||||
activeAnnotationId?: string;
|
||||
scrollToAnnotation?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
// 类型颜色映射
|
||||
@@ -60,6 +61,7 @@ const AnnotatedText: React.FC<AnnotatedTextProps> = ({
|
||||
onAnnotationClick,
|
||||
activeAnnotationId,
|
||||
scrollToAnnotation,
|
||||
style,
|
||||
}) => {
|
||||
const annotationRefs = useRef<Record<string, HTMLSpanElement | null>>({});
|
||||
|
||||
@@ -243,6 +245,7 @@ const AnnotatedText: React.FC<AnnotatedTextProps> = ({
|
||||
fontSize: 16,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{segments.map((segment, index) => renderAnnotatedSegment(segment, index))}
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import type { AnalysisTask, ChapterAnalysisResponse } from '../types';
|
||||
|
||||
// 判断是否为移动设备
|
||||
const isMobileDevice = () => window.innerWidth < 768;
|
||||
|
||||
interface ChapterAnalysisProps {
|
||||
chapterId: string;
|
||||
visible: boolean;
|
||||
@@ -25,14 +28,23 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
|
||||
const [analysis, setAnalysis] = useState<ChapterAnalysisResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isMobile, setIsMobile] = useState(isMobileDevice());
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && chapterId) {
|
||||
fetchAnalysisStatus();
|
||||
}
|
||||
|
||||
// 监听窗口大小变化
|
||||
const handleResize = () => {
|
||||
setIsMobile(isMobileDevice());
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// 清理函数:组件卸载或关闭时清除轮询
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
// 清除可能存在的轮询
|
||||
};
|
||||
}, [visible, chapterId]);
|
||||
@@ -194,10 +206,10 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
|
||||
label: '概览',
|
||||
icon: <TrophyOutlined />,
|
||||
children: (
|
||||
<div style={{ height: 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
|
||||
<Card title="整体评分" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<div style={{ height: isMobile ? 'calc(80vh - 180px)' : 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
|
||||
<Card title="整体评分" style={{ marginBottom: 16 }} size={isMobile ? 'small' : 'default'}>
|
||||
<Row gutter={isMobile ? 8 : 16}>
|
||||
<Col span={isMobile ? 12 : 6}>
|
||||
<Statistic
|
||||
title="整体质量"
|
||||
value={analysis_data.overall_quality_score || 0}
|
||||
@@ -205,21 +217,21 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
|
||||
valueStyle={{ color: '#3f8600' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Col span={isMobile ? 12 : 6}>
|
||||
<Statistic
|
||||
title="节奏把控"
|
||||
value={analysis_data.pacing_score || 0}
|
||||
suffix="/ 10"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Col span={isMobile ? 12 : 6}>
|
||||
<Statistic
|
||||
title="吸引力"
|
||||
value={analysis_data.engagement_score || 0}
|
||||
suffix="/ 10"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Col span={isMobile ? 12 : 6}>
|
||||
<Statistic
|
||||
title="连贯性"
|
||||
value={analysis_data.coherence_score || 0}
|
||||
@@ -230,15 +242,15 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
|
||||
</Card>
|
||||
|
||||
{analysis_data.analysis_report && (
|
||||
<Card title="分析摘要" style={{ marginBottom: 16 }}>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', fontFamily: 'inherit' }}>
|
||||
<Card title="分析摘要" style={{ marginBottom: 16 }} size={isMobile ? 'small' : 'default'}>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', fontFamily: 'inherit', fontSize: isMobile ? 13 : 14 }}>
|
||||
{analysis_data.analysis_report}
|
||||
</pre>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{analysis_data.suggestions && analysis_data.suggestions.length > 0 && (
|
||||
<Card title={<><BulbOutlined /> 改进建议</>}>
|
||||
<Card title={<><BulbOutlined /> 改进建议</>} size={isMobile ? 'small' : 'default'}>
|
||||
<List
|
||||
dataSource={analysis_data.suggestions}
|
||||
renderItem={(item, index) => (
|
||||
@@ -257,8 +269,8 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
|
||||
label: `钩子 (${analysis_data.hooks?.length || 0})`,
|
||||
icon: <ThunderboltOutlined />,
|
||||
children: (
|
||||
<div style={{ height: 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
|
||||
<Card>
|
||||
<div style={{ height: isMobile ? 'calc(80vh - 180px)' : 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
|
||||
<Card size={isMobile ? 'small' : 'default'}>
|
||||
{analysis_data.hooks && analysis_data.hooks.length > 0 ? (
|
||||
<List
|
||||
dataSource={analysis_data.hooks}
|
||||
@@ -289,8 +301,8 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
|
||||
label: `伏笔 (${analysis_data.foreshadows?.length || 0})`,
|
||||
icon: <FireOutlined />,
|
||||
children: (
|
||||
<div style={{ height: 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
|
||||
<Card>
|
||||
<div style={{ height: isMobile ? 'calc(80vh - 180px)' : 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
|
||||
<Card size={isMobile ? 'small' : 'default'}>
|
||||
{analysis_data.foreshadows && analysis_data.foreshadows.length > 0 ? (
|
||||
<List
|
||||
dataSource={analysis_data.foreshadows}
|
||||
@@ -326,18 +338,18 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
|
||||
label: '情感曲线',
|
||||
icon: <HeartOutlined />,
|
||||
children: (
|
||||
<div style={{ height: 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
|
||||
<Card>
|
||||
<div style={{ height: isMobile ? 'calc(80vh - 180px)' : 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
|
||||
<Card size={isMobile ? 'small' : 'default'}>
|
||||
{analysis_data.emotional_tone ? (
|
||||
<div>
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={12}>
|
||||
<Row gutter={isMobile ? 8 : 16} style={{ marginBottom: isMobile ? 16 : 24 }}>
|
||||
<Col span={isMobile ? 24 : 12}>
|
||||
<Statistic
|
||||
title="主导情绪"
|
||||
value={analysis_data.emotional_tone}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Col span={isMobile ? 24 : 12}>
|
||||
<Statistic
|
||||
title="情感强度"
|
||||
value={(analysis_data.emotional_intensity * 10).toFixed(1)}
|
||||
@@ -372,8 +384,8 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
|
||||
label: `角色 (${analysis_data.character_states?.length || 0})`,
|
||||
icon: <TeamOutlined />,
|
||||
children: (
|
||||
<div style={{ height: 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
|
||||
<Card>
|
||||
<div style={{ height: isMobile ? 'calc(80vh - 180px)' : 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
|
||||
<Card size={isMobile ? 'small' : 'default'}>
|
||||
{analysis_data.character_states && analysis_data.character_states.length > 0 ? (
|
||||
<List
|
||||
dataSource={analysis_data.character_states}
|
||||
@@ -414,8 +426,8 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
|
||||
label: `记忆 (${memories?.length || 0})`,
|
||||
icon: <FireOutlined />,
|
||||
children: (
|
||||
<div style={{ height: 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
|
||||
<Card>
|
||||
<div style={{ height: isMobile ? 'calc(80vh - 180px)' : 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
|
||||
<Card size={isMobile ? 'small' : 'default'}>
|
||||
{memories && memories.length > 0 ? (
|
||||
<List
|
||||
dataSource={memories}
|
||||
@@ -462,20 +474,25 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
|
||||
title="章节分析"
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
width="90%"
|
||||
centered
|
||||
width={isMobile ? '100%' : '90%'}
|
||||
centered={!isMobile}
|
||||
style={{
|
||||
maxWidth: '1400px',
|
||||
paddingBottom: 0
|
||||
maxWidth: isMobile ? '100%' : '1400px',
|
||||
paddingBottom: 0,
|
||||
top: isMobile ? 0 : undefined,
|
||||
margin: isMobile ? 0 : undefined,
|
||||
maxHeight: isMobile ? '100vh' : undefined
|
||||
}}
|
||||
styles={{
|
||||
body: {
|
||||
padding: '24px',
|
||||
paddingBottom: 0
|
||||
padding: isMobile ? '12px' : '24px',
|
||||
paddingBottom: 0,
|
||||
maxHeight: isMobile ? 'calc(100vh - 110px)' : undefined,
|
||||
overflowY: isMobile ? 'auto' : undefined
|
||||
}
|
||||
}}
|
||||
footer={[
|
||||
<Button key="close" onClick={onClose}>
|
||||
<Button key="close" onClick={onClose} size={isMobile ? 'small' : 'middle'}>
|
||||
关闭
|
||||
</Button>,
|
||||
!task && !loading && (
|
||||
@@ -485,6 +502,7 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={triggerAnalysis}
|
||||
loading={loading}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
>
|
||||
开始分析
|
||||
</Button>
|
||||
@@ -497,6 +515,7 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
|
||||
onClick={triggerAnalysis}
|
||||
loading={loading}
|
||||
danger
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
>
|
||||
重新分析
|
||||
</Button>
|
||||
@@ -508,6 +527,7 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={triggerAnalysis}
|
||||
loading={loading}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
>
|
||||
重新分析
|
||||
</Button>
|
||||
|
||||
@@ -122,6 +122,42 @@ export const CharacterCard: React.FC<CharacterCardProps> = ({ character, onEdit,
|
||||
<Tag color="cyan">{character.organization_type}</Tag>
|
||||
</div>
|
||||
)}
|
||||
{character.power_level !== undefined && character.power_level !== null && (
|
||||
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'center' }}>
|
||||
<Text type="secondary" style={{ flexShrink: 0 }}>势力等级:</Text>
|
||||
<Tag color={character.power_level >= 70 ? 'red' : character.power_level >= 50 ? 'orange' : 'default'}>
|
||||
{character.power_level}
|
||||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
{character.location && (
|
||||
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'flex-start' }}>
|
||||
<Text type="secondary" style={{ flexShrink: 0 }}>所在地:</Text>
|
||||
<Text
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
ellipsis={{ tooltip: character.location }}
|
||||
>
|
||||
{character.location}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{character.color && (
|
||||
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'flex-start' }}>
|
||||
<Text type="secondary" style={{ flexShrink: 0 }}>代表颜色:</Text>
|
||||
<Text style={{ flex: 1, minWidth: 0 }}>{character.color}</Text>
|
||||
</div>
|
||||
)}
|
||||
{character.motto && (
|
||||
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'flex-start' }}>
|
||||
<Text type="secondary" style={{ flexShrink: 0 }}>格言:</Text>
|
||||
<Text
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
ellipsis={{ tooltip: character.motto }}
|
||||
>
|
||||
{character.motto}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{character.organization_purpose && (
|
||||
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'flex-start' }}>
|
||||
<Text type="secondary" style={{ flexShrink: 0 }}>目的:</Text>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
MenuOutlined,
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import api from '../services/api';
|
||||
@@ -71,8 +72,20 @@ const ChapterAnalysis: React.FC = () => {
|
||||
const [showAnnotations, setShowAnnotations] = useState(true);
|
||||
const [activeAnnotationId, setActiveAnnotationId] = useState<string | undefined>();
|
||||
const [sidebarVisible, setSidebarVisible] = useState(false);
|
||||
const [chapterListVisible, setChapterListVisible] = useState(false);
|
||||
const [scrollToContentAnnotation, setScrollToContentAnnotation] = useState<string | undefined>();
|
||||
const [scrollToSidebarAnnotation, setScrollToSidebarAnnotation] = useState<string | undefined>();
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
|
||||
// 监听窗口大小变化
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// 加载章节列表
|
||||
useEffect(() => {
|
||||
@@ -128,6 +141,9 @@ const ChapterAnalysis: React.FC = () => {
|
||||
|
||||
const handleChapterSelect = (chapterId: string) => {
|
||||
loadChapterContent(chapterId);
|
||||
if (isMobile) {
|
||||
setChapterListVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviousChapter = () => {
|
||||
@@ -151,7 +167,7 @@ const ChapterAnalysis: React.FC = () => {
|
||||
// 清除滚动状态
|
||||
setTimeout(() => setScrollToSidebarAnnotation(undefined), 100);
|
||||
|
||||
if (window.innerWidth < 768) {
|
||||
if (isMobile) {
|
||||
setSidebarVisible(true);
|
||||
}
|
||||
} else {
|
||||
@@ -173,48 +189,102 @@ const ChapterAnalysis: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', height: '100%', gap: 16 }}>
|
||||
{/* 左侧章节列表 */}
|
||||
<Card
|
||||
title="章节列表"
|
||||
style={{ width: 280, height: '100%', overflow: 'hidden' }}
|
||||
bodyStyle={{ padding: 0, height: 'calc(100% - 57px)', overflow: 'auto' }}
|
||||
>
|
||||
{chapters.length === 0 ? (
|
||||
<Empty description="暂无章节" style={{ marginTop: 60 }} />
|
||||
) : (
|
||||
<List
|
||||
dataSource={chapters}
|
||||
renderItem={(chapter) => (
|
||||
<List.Item
|
||||
key={chapter.id}
|
||||
onClick={() => handleChapterSelect(chapter.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '12px 16px',
|
||||
background: selectedChapter?.id === chapter.id ? '#e6f7ff' : 'transparent',
|
||||
borderLeft: selectedChapter?.id === chapter.id ? '3px solid #1890ff' : '3px solid transparent',
|
||||
}}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<span style={{ fontSize: 14, fontWeight: selectedChapter?.id === chapter.id ? 600 : 400 }}>
|
||||
第{chapter.chapter_number}章: {chapter.title}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
<Space size={4}>
|
||||
<Tag color={chapter.content && chapter.content.trim() !== '' ? 'success' : 'default'}>
|
||||
{chapter.word_count || 0}字
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
gap: isMobile ? 0 : 16,
|
||||
flexDirection: isMobile ? 'column' : 'row'
|
||||
}}>
|
||||
{/* 左侧章节列表 - 桌面端 */}
|
||||
{!isMobile && (
|
||||
<Card
|
||||
title="章节列表"
|
||||
style={{ width: 280, height: '100%', overflow: 'hidden' }}
|
||||
bodyStyle={{ padding: 0, height: 'calc(100% - 57px)', overflow: 'auto' }}
|
||||
>
|
||||
{chapters.length === 0 ? (
|
||||
<Empty description="暂无章节" style={{ marginTop: 60 }} />
|
||||
) : (
|
||||
<List
|
||||
dataSource={chapters}
|
||||
renderItem={(chapter) => (
|
||||
<List.Item
|
||||
key={chapter.id}
|
||||
onClick={() => handleChapterSelect(chapter.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '12px 16px',
|
||||
background: selectedChapter?.id === chapter.id ? '#e6f7ff' : 'transparent',
|
||||
borderLeft: selectedChapter?.id === chapter.id ? '3px solid #1890ff' : '3px solid transparent',
|
||||
}}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<span style={{ fontSize: 14, fontWeight: selectedChapter?.id === chapter.id ? 600 : 400 }}>
|
||||
第{chapter.chapter_number}章: {chapter.title}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
<Space size={4}>
|
||||
<Tag color={chapter.content && chapter.content.trim() !== '' ? 'success' : 'default'}>
|
||||
{chapter.word_count || 0}字
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 移动端章节列表抽屉 */}
|
||||
{isMobile && (
|
||||
<Drawer
|
||||
title="章节列表"
|
||||
placement="left"
|
||||
onClose={() => setChapterListVisible(false)}
|
||||
open={chapterListVisible}
|
||||
width="85%"
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
{chapters.length === 0 ? (
|
||||
<Empty description="暂无章节" style={{ marginTop: 60 }} />
|
||||
) : (
|
||||
<List
|
||||
dataSource={chapters}
|
||||
renderItem={(chapter) => (
|
||||
<List.Item
|
||||
key={chapter.id}
|
||||
onClick={() => handleChapterSelect(chapter.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '12px 16px',
|
||||
background: selectedChapter?.id === chapter.id ? '#e6f7ff' : 'transparent',
|
||||
borderLeft: selectedChapter?.id === chapter.id ? '3px solid #1890ff' : '3px solid transparent',
|
||||
}}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<span style={{ fontSize: 14, fontWeight: selectedChapter?.id === chapter.id ? 600 : 400 }}>
|
||||
第{chapter.chapter_number}章: {chapter.title}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
<Space size={4}>
|
||||
<Tag color={chapter.content && chapter.content.trim() !== '' ? 'success' : 'default'}>
|
||||
{chapter.word_count || 0}字
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
)}
|
||||
|
||||
{/* 右侧内容区域 */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
@@ -225,54 +295,138 @@ const ChapterAnalysis: React.FC = () => {
|
||||
) : (
|
||||
<>
|
||||
{/* 工具栏 */}
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<LeftOutlined />}
|
||||
onClick={handlePreviousChapter}
|
||||
disabled={!navigation?.previous}
|
||||
title={navigation?.previous ? `上一章: ${navigation.previous.title}` : '已是第一章'}
|
||||
>
|
||||
上一章
|
||||
</Button>
|
||||
<span style={{ fontSize: 16, fontWeight: 600 }}>
|
||||
第{selectedChapter.chapter_number}章: {selectedChapter.title}
|
||||
</span>
|
||||
<Button
|
||||
icon={<RightOutlined />}
|
||||
onClick={handleNextChapter}
|
||||
disabled={!navigation?.next}
|
||||
title={navigation?.next ? `下一章: ${navigation.next.title}` : '已是最后一章'}
|
||||
>
|
||||
下一章
|
||||
</Button>
|
||||
</Space>
|
||||
<Card size="small" style={{ marginBottom: isMobile ? 8 : 16 }}>
|
||||
{isMobile ? (
|
||||
// 移动端布局:两行显示
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{/* 第一行:标题和翻页按钮 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 8
|
||||
}}>
|
||||
<Button
|
||||
icon={<LeftOutlined />}
|
||||
onClick={handlePreviousChapter}
|
||||
disabled={!navigation?.previous}
|
||||
title={navigation?.previous ? `上一章: ${navigation.previous.title}` : '已是第一章'}
|
||||
size="small"
|
||||
/>
|
||||
<span style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
padding: '0 8px'
|
||||
}}>
|
||||
第{selectedChapter.chapter_number}章: {selectedChapter.title}
|
||||
</span>
|
||||
<Button
|
||||
icon={<RightOutlined />}
|
||||
onClick={handleNextChapter}
|
||||
disabled={!navigation?.next}
|
||||
title={navigation?.next ? `下一章: ${navigation.next.title}` : '已是最后一章'}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Space>
|
||||
{hasAnnotations && (
|
||||
<>
|
||||
<Switch
|
||||
checked={showAnnotations}
|
||||
onChange={setShowAnnotations}
|
||||
checkedChildren={<EyeOutlined />}
|
||||
unCheckedChildren={<EyeInvisibleOutlined />}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: '#666' }}>显示标注</span>
|
||||
<Button
|
||||
icon={<MenuOutlined />}
|
||||
onClick={() => setSidebarVisible(true)}
|
||||
style={{ display: window.innerWidth < 768 ? 'inline-block' : 'none' }}
|
||||
>
|
||||
分析
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
{/* 第二行:章节、开关、分析按钮 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 8
|
||||
}}>
|
||||
<Button
|
||||
icon={<UnorderedListOutlined />}
|
||||
onClick={() => setChapterListVisible(true)}
|
||||
size="small"
|
||||
>
|
||||
章节
|
||||
</Button>
|
||||
|
||||
{hasAnnotations && (
|
||||
<>
|
||||
<Switch
|
||||
checked={showAnnotations}
|
||||
onChange={setShowAnnotations}
|
||||
checkedChildren={<EyeOutlined />}
|
||||
unCheckedChildren={<EyeInvisibleOutlined />}
|
||||
size="small"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
height: 16,
|
||||
minHeight: 16,
|
||||
lineHeight: '16px'
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
icon={<MenuOutlined />}
|
||||
onClick={() => setSidebarVisible(true)}
|
||||
size="small"
|
||||
>
|
||||
分析
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 桌面端布局:保持原样
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<LeftOutlined />}
|
||||
onClick={handlePreviousChapter}
|
||||
disabled={!navigation?.previous}
|
||||
title={navigation?.previous ? `上一章: ${navigation.previous.title}` : '已是第一章'}
|
||||
>
|
||||
上一章
|
||||
</Button>
|
||||
<span style={{ fontSize: 16, fontWeight: 600 }}>
|
||||
第{selectedChapter.chapter_number}章: {selectedChapter.title}
|
||||
</span>
|
||||
<Button
|
||||
icon={<RightOutlined />}
|
||||
onClick={handleNextChapter}
|
||||
disabled={!navigation?.next}
|
||||
title={navigation?.next ? `下一章: ${navigation.next.title}` : '已是最后一章'}
|
||||
>
|
||||
下一章
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Space>
|
||||
{hasAnnotations && (
|
||||
<>
|
||||
<Switch
|
||||
checked={showAnnotations}
|
||||
onChange={setShowAnnotations}
|
||||
checkedChildren={<EyeOutlined />}
|
||||
unCheckedChildren={<EyeInvisibleOutlined />}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: '#666' }}>显示标注</span>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasAnnotations && annotationsData && (
|
||||
<div style={{ marginTop: 12, fontSize: 12, color: '#999' }}>
|
||||
<div style={{
|
||||
marginTop: 12,
|
||||
fontSize: isMobile ? 11 : 12,
|
||||
color: '#999',
|
||||
lineHeight: 1.5
|
||||
}}>
|
||||
共有 {annotationsData.summary.total_annotations} 个标注:
|
||||
{annotationsData.summary.hooks > 0 && ` 🎣${annotationsData.summary.hooks}个钩子`}
|
||||
{annotationsData.summary.foreshadows > 0 &&
|
||||
@@ -286,10 +440,16 @@ const ChapterAnalysis: React.FC = () => {
|
||||
</Card>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div style={{ flex: 1, display: 'flex', gap: 16, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
gap: isMobile ? 0 : 16,
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* 章节内容 */}
|
||||
<Card
|
||||
style={{ flex: 1, overflow: 'auto' }}
|
||||
bodyStyle={{ padding: isMobile ? '12px' : '24px' }}
|
||||
loading={contentLoading}
|
||||
>
|
||||
{!contentLoading && (
|
||||
@@ -311,12 +471,16 @@ const ChapterAnalysis: React.FC = () => {
|
||||
onAnnotationClick={(annotation) => handleAnnotationClick(annotation, 'content')}
|
||||
activeAnnotationId={activeAnnotationId}
|
||||
scrollToAnnotation={scrollToContentAnnotation}
|
||||
style={{
|
||||
lineHeight: isMobile ? 1.8 : 2,
|
||||
fontSize: isMobile ? 14 : 16,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
lineHeight: 2,
|
||||
fontSize: 16,
|
||||
lineHeight: isMobile ? 1.8 : 2,
|
||||
fontSize: isMobile ? 14 : 16,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
@@ -329,7 +493,7 @@ const ChapterAnalysis: React.FC = () => {
|
||||
</Card>
|
||||
|
||||
{/* 右侧记忆侧边栏(桌面端) */}
|
||||
{hasAnnotations && annotationsData && window.innerWidth >= 768 && (
|
||||
{hasAnnotations && annotationsData && !isMobile && (
|
||||
<Card
|
||||
style={{ width: 400, overflow: 'auto' }}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
@@ -351,7 +515,7 @@ const ChapterAnalysis: React.FC = () => {
|
||||
placement="right"
|
||||
onClose={() => setSidebarVisible(false)}
|
||||
open={sidebarVisible}
|
||||
width="80%"
|
||||
width={isMobile ? '90%' : '80%'}
|
||||
>
|
||||
<MemorySidebar
|
||||
annotations={annotationsData.annotations}
|
||||
|
||||
@@ -425,6 +425,23 @@ export default function Characters() {
|
||||
>
|
||||
<TextArea rows={2} placeholder="描述组织的宗旨和目标..." />
|
||||
</Form.Item>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="所在地" name="location">
|
||||
<Input placeholder="组织的主要活动区域或总部位置" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="代表颜色" name="color">
|
||||
<Input placeholder="如:深红色、金色、黑色等" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item label="格言/口号" name="motto">
|
||||
<Input placeholder="组织的宗旨、格言或口号" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Card, Table, Tag, Button, Space, message, Modal, Form, Select, InputNumber, Input, Descriptions } from 'antd';
|
||||
import { PlusOutlined, TeamOutlined, UserOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { PlusOutlined, TeamOutlined, UserOutlined, EditOutlined, DeleteOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../store';
|
||||
import axios from 'axios';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface Organization {
|
||||
id: string;
|
||||
character_id: string;
|
||||
@@ -15,6 +17,7 @@ interface Organization {
|
||||
power_level: number;
|
||||
location?: string;
|
||||
motto?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface OrganizationMember {
|
||||
@@ -44,7 +47,11 @@ export default function Organizations() {
|
||||
const [characters, setCharacters] = useState<Character[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isAddMemberModalOpen, setIsAddMemberModalOpen] = useState(false);
|
||||
const [isEditOrgModalOpen, setIsEditOrgModalOpen] = useState(false);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [editOrgForm] = Form.useForm();
|
||||
const [generateForm] = Form.useForm();
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -144,6 +151,68 @@ export default function Organizations() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleGenerateOrganization = async (values: {
|
||||
name?: string;
|
||||
organization_type?: string;
|
||||
background?: string;
|
||||
requirements?: string;
|
||||
}) => {
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
await axios.post('/api/organizations/generate', {
|
||||
project_id: projectId,
|
||||
name: values.name,
|
||||
organization_type: values.organization_type,
|
||||
background: values.background,
|
||||
requirements: values.requirements,
|
||||
});
|
||||
message.success('AI生成组织成功');
|
||||
Modal.destroyAll();
|
||||
generateForm.resetFields();
|
||||
loadOrganizations();
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.detail || 'AI生成失败');
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showGenerateModal = () => {
|
||||
Modal.confirm({
|
||||
title: 'AI生成组织',
|
||||
width: 600,
|
||||
centered: !isMobile,
|
||||
content: (
|
||||
<Form form={generateForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
label="组织名称"
|
||||
name="name"
|
||||
>
|
||||
<Input placeholder="如:天剑门、黑龙会(可选,AI会自动生成)" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="组织类型"
|
||||
name="organization_type"
|
||||
>
|
||||
<Input placeholder="如:门派、帮派、公司、学院(可选,AI会根据世界观生成)" />
|
||||
</Form.Item>
|
||||
<Form.Item label="背景设定" name="background">
|
||||
<TextArea rows={3} placeholder="简要描述组织的背景和环境..." />
|
||||
</Form.Item>
|
||||
<Form.Item label="其他要求" name="requirements">
|
||||
<TextArea rows={2} placeholder="其他特殊要求..." />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
okText: '生成',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
const values = await generateForm.validateFields();
|
||||
await handleGenerateOrganization(values);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
active: 'green',
|
||||
@@ -263,6 +332,17 @@ export default function Organizations() {
|
||||
{!isMobile && <Tag color="blue">{currentProject?.title}</Tag>}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
type="dashed"
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={showGenerateModal}
|
||||
loading={isGenerating}
|
||||
size={isMobile ? 'small' : 'middle'}
|
||||
>
|
||||
AI生成组织
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div style={{
|
||||
display: isMobile ? 'flex' : 'grid',
|
||||
@@ -308,19 +388,53 @@ export default function Organizations() {
|
||||
<div style={{ minHeight: isMobile ? 'auto' : undefined }}>
|
||||
{selectedOrg ? (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<Card title="组织详情" size="small">
|
||||
<Card
|
||||
title="组织详情"
|
||||
size="small"
|
||||
extra={
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => {
|
||||
editOrgForm.setFieldsValue({
|
||||
power_level: selectedOrg.power_level,
|
||||
location: selectedOrg.location,
|
||||
motto: selectedOrg.motto,
|
||||
color: selectedOrg.color
|
||||
});
|
||||
setIsEditOrgModalOpen(true);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Descriptions column={isMobile ? 1 : 2} size="small">
|
||||
<Descriptions.Item label="组织名称">{selectedOrg.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">{selectedOrg.type}</Descriptions.Item>
|
||||
<Descriptions.Item label="成员数量">{selectedOrg.member_count}</Descriptions.Item>
|
||||
<Descriptions.Item label="势力等级">{selectedOrg.power_level}</Descriptions.Item>
|
||||
<Descriptions.Item label="势力等级">
|
||||
<Tag color={selectedOrg.power_level >= 70 ? 'red' : selectedOrg.power_level >= 50 ? 'orange' : 'default'}>
|
||||
{selectedOrg.power_level}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
{selectedOrg.location && (
|
||||
<Descriptions.Item label="所在地">{selectedOrg.location}</Descriptions.Item>
|
||||
<Descriptions.Item label="所在地" span={isMobile ? 1 : 2}>
|
||||
{selectedOrg.location}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{selectedOrg.color && (
|
||||
<Descriptions.Item label="代表颜色">
|
||||
{selectedOrg.color}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{selectedOrg.motto && (
|
||||
<Descriptions.Item label="宗旨" span={2}>{selectedOrg.motto}</Descriptions.Item>
|
||||
<Descriptions.Item label="格言/口号" span={2}>
|
||||
{selectedOrg.motto}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="目标/宗旨" span={2}>
|
||||
<Descriptions.Item label="组织目的" span={2}>
|
||||
{selectedOrg.purpose}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
@@ -445,6 +559,77 @@ export default function Organizations() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 编辑组织模态框 */}
|
||||
<Modal
|
||||
title="编辑组织信息"
|
||||
open={isEditOrgModalOpen}
|
||||
onCancel={() => {
|
||||
setIsEditOrgModalOpen(false);
|
||||
editOrgForm.resetFields();
|
||||
}}
|
||||
footer={null}
|
||||
centered={!isMobile}
|
||||
width={isMobile ? '100%' : 500}
|
||||
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
|
||||
styles={isMobile ? { body: { maxHeight: 'calc(100vh - 110px)', overflowY: 'auto' } } : undefined}
|
||||
>
|
||||
<Form
|
||||
form={editOrgForm}
|
||||
layout="vertical"
|
||||
onFinish={async (values) => {
|
||||
if (!selectedOrg) return;
|
||||
try {
|
||||
await axios.put(`/api/organizations/${selectedOrg.id}`, values);
|
||||
message.success('组织信息更新成功');
|
||||
setIsEditOrgModalOpen(false);
|
||||
loadOrganizations();
|
||||
} catch (error) {
|
||||
message.error('更新失败');
|
||||
console.error(error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="power_level"
|
||||
label="势力等级"
|
||||
rules={[{ required: true, message: '请输入势力等级' }]}
|
||||
tooltip="0-100的数值,表示组织的影响力"
|
||||
>
|
||||
<InputNumber min={0} max={100} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="location"
|
||||
label="所在地"
|
||||
>
|
||||
<Input placeholder="组织的主要活动区域或总部位置" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="motto"
|
||||
label="格言/口号"
|
||||
>
|
||||
<Input placeholder="组织的宗旨、格言或口号" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="color"
|
||||
label="代表颜色"
|
||||
>
|
||||
<Input placeholder="如:深红色、金色、黑色等" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||
<Button onClick={() => setIsEditOrgModalOpen(false)}>取消</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -974,7 +974,6 @@ export default function ProjectWizardNew() {
|
||||
styles={{
|
||||
body: {
|
||||
maxHeight: isMobile ? 'calc(100vh - 150px)' : 'calc(80vh - 110px)',
|
||||
overflowY: 'auto'
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -1051,12 +1050,29 @@ export default function ProjectWizardNew() {
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="主要成员" name="organization_members">
|
||||
<Input placeholder="如:张三、李四、王五" />
|
||||
<Form.Item label="势力等级" name="power_level">
|
||||
<InputNumber min={0} max={100} placeholder="0-100" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="所在地" name="location">
|
||||
<Input placeholder="组织所在地" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="代表颜色" name="color">
|
||||
<Input placeholder="如:深红色、金色" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item label="格言/口号" name="motto">
|
||||
<Input placeholder="组织的格言或口号" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="组织目的"
|
||||
name="organization_purpose"
|
||||
@@ -1064,6 +1080,10 @@ export default function ProjectWizardNew() {
|
||||
>
|
||||
<TextArea rows={2} placeholder="描述组织的宗旨和目标..." />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="主要成员" name="organization_members">
|
||||
<Input placeholder="如:张三、李四、王五" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -172,6 +172,11 @@ export interface Character {
|
||||
organization_members?: string;
|
||||
traits?: string;
|
||||
avatar_url?: string;
|
||||
// 组织扩展字段(从Organization表关联)
|
||||
power_level?: number;
|
||||
location?: string;
|
||||
motto?: string;
|
||||
color?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -190,6 +195,11 @@ export interface CharacterUpdate {
|
||||
organization_purpose?: string;
|
||||
organization_members?: string;
|
||||
traits?: string;
|
||||
// 组织扩展字段
|
||||
power_level?: number;
|
||||
location?: string;
|
||||
motto?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
// 章节类型定义
|
||||
|
||||
Reference in New Issue
Block a user