From 3b97e88128e96d6a834f4e6257bd40aaa21ba265 Mon Sep 17 00:00:00 2001 From: xiamuceer Date: Mon, 29 Dec 2025 16:48:02 +0800 Subject: [PATCH] =?UTF-8?q?feature:1.=E6=96=B0=E5=A2=9E=E8=A7=92=E8=89=B2/?= =?UTF-8?q?=E7=BB=84=E7=BB=87=E5=8D=A1=E7=89=87=E5=AF=BC=E5=85=A5=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81=E6=89=B9?= =?UTF-8?q?=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- backend/.env.example | 2 +- backend/app/api/characters.py | 165 ++++++- backend/app/schemas/import_export.py | 35 ++ backend/app/services/import_export_service.py | 403 +++++++++++++++++- frontend/package.json | 2 +- frontend/src/components/CharacterCard.tsx | 6 +- frontend/src/pages/Characters.tsx | 359 +++++++++++++++- frontend/src/pages/ProjectDetail.tsx | 132 +++--- frontend/src/services/api.ts | 74 ++++ 10 files changed, 1068 insertions(+), 114 deletions(-) diff --git a/README.md b/README.md index 7a64c23..0aff3ce 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@
-![Version](https://img.shields.io/badge/version-1.2.0-blue.svg) +![Version](https://img.shields.io/badge/version-1.2.3-blue.svg) ![Python](https://img.shields.io/badge/python-3.11-blue.svg) ![FastAPI](https://img.shields.io/badge/FastAPI-0.109.0-green.svg) ![React](https://img.shields.io/badge/react-18.3.1-blue.svg) @@ -78,10 +78,10 @@ - [x] **根据分析一键重写** - 根据分析建议重新生成 - [x] **Linux DO 自动创建账号** - OAuth 登录自动生成账号 - [x] **职业等级体系** - 自定义职业和等级系统,支持修仙境界、魔法等级等多种体系 +- [x] **角色/组织卡片导入导出** - 单独导出角色和组织卡片,支持跨项目数据共享 ### 📝 规划中功能 -- [ ] **角色/组织卡片导入导出** - 单独导出角色和组织卡片,支持跨项目数据共享 - [ ] **伏笔管理** - 智能追踪剧情伏笔,提醒未回收线索,可视化伏笔时间线 - [ ] **提示词工坊** - 社区驱动的 Prompt 模板分享平台,一键导入优质提示词 diff --git a/backend/.env.example b/backend/.env.example index f43cb6e..0f50deb 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -8,7 +8,7 @@ # 应用配置 # ========================================== APP_NAME=MuMuAINovel -APP_VERSION=1.2.2 +APP_VERSION=1.2.3 APP_HOST=0.0.0.0 APP_PORT=8000 DEBUG=false diff --git a/backend/app/api/characters.py b/backend/app/api/characters.py index 1357f85..02a7215 100644 --- a/backend/app/api/characters.py +++ b/backend/app/api/characters.py @@ -1,5 +1,6 @@ """角色管理API""" -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File +from fastapi.responses import JSONResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func import json @@ -20,6 +21,8 @@ from app.schemas.character import ( ) from app.services.ai_service import AIService from app.services.prompt_service import prompt_service, PromptService +from app.services.import_export_service import ImportExportService +from app.schemas.import_export import CharactersExportRequest, CharactersImportResult from app.logger import get_logger from app.api.settings import get_user_ai_service @@ -1306,3 +1309,163 @@ async def generate_character_stream( yield await SSEResponse.send_error(f"生成角色失败: {str(e)}") return create_sse_response(generate()) + + +@router.post("/export", summary="批量导出角色/组织") +async def export_characters( + export_request: CharactersExportRequest, + request: Request, + db: AsyncSession = Depends(get_db) +): + """ + 批量导出角色/组织为JSON格式 + + - 支持单个或多个角色/组织导出 + - 包含角色的所有信息(基础信息、职业、组织详情等) + - 返回JSON文件供下载 + """ + user_id = getattr(request.state, 'user_id', None) + if not user_id: + raise HTTPException(status_code=401, detail="未登录") + + if not export_request.character_ids: + raise HTTPException(status_code=400, detail="请至少选择一个角色/组织") + + try: + # 验证所有角色的权限 + for char_id in export_request.character_ids: + result = await db.execute( + select(Character).where(Character.id == char_id) + ) + character = result.scalar_one_or_none() + + if not character: + raise HTTPException(status_code=404, detail=f"角色不存在: {char_id}") + + # 验证项目权限 + await verify_project_access(character.project_id, user_id, db) + + # 执行导出 + export_data = await ImportExportService.export_characters( + character_ids=export_request.character_ids, + db=db + ) + + # 生成文件名 + from datetime import datetime + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + count = len(export_request.character_ids) + filename = f"characters_export_{count}_{timestamp}.json" + + logger.info(f"用户 {user_id} 导出了 {count} 个角色/组织") + + # 返回JSON文件 + return JSONResponse( + content=export_data, + headers={ + "Content-Disposition": f"attachment; filename={filename}", + "Content-Type": "application/json; charset=utf-8" + } + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"导出角色/组织失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"导出失败: {str(e)}") + + +@router.post("/import", response_model=CharactersImportResult, summary="导入角色/组织") +async def import_characters( + project_id: str, + file: UploadFile = File(...), + request: Request = None, + db: AsyncSession = Depends(get_db) +): + """ + 从JSON文件导入角色/组织 + + - 支持导入之前导出的角色/组织JSON文件 + - 自动处理重复名称(跳过) + - 验证职业ID的有效性 + - 自动创建组织详情记录 + """ + user_id = getattr(request.state, 'user_id', None) + if not user_id: + raise HTTPException(status_code=401, detail="未登录") + + # 验证项目权限 + await verify_project_access(project_id, user_id, db) + + # 验证文件类型 + if not file.filename.endswith('.json'): + raise HTTPException(status_code=400, detail="只支持JSON格式文件") + + try: + # 读取文件内容 + content = await file.read() + data = json.loads(content.decode('utf-8')) + + # 执行导入 + result = await ImportExportService.import_characters( + data=data, + project_id=project_id, + user_id=user_id, + db=db + ) + + logger.info(f"用户 {user_id} 导入角色/组织到项目 {project_id}: {result['message']}") + + return result + + except json.JSONDecodeError as e: + raise HTTPException(status_code=400, detail=f"JSON格式错误: {str(e)}") + except Exception as e: + logger.error(f"导入角色/组织失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"导入失败: {str(e)}") + + +@router.post("/validate-import", summary="验证导入文件") +async def validate_import( + file: UploadFile = File(...), + request: Request = None +): + """ + 验证角色/组织导入文件的格式和内容 + + - 检查文件格式 + - 验证版本兼容性 + - 统计数据量 + - 返回验证结果和警告信息 + """ + user_id = getattr(request.state, 'user_id', None) + if not user_id: + raise HTTPException(status_code=401, detail="未登录") + + # 验证文件类型 + if not file.filename.endswith('.json'): + raise HTTPException(status_code=400, detail="只支持JSON格式文件") + + try: + # 读取文件内容 + content = await file.read() + data = json.loads(content.decode('utf-8')) + + # 验证数据 + validation_result = ImportExportService.validate_characters_import(data) + + logger.info(f"用户 {user_id} 验证导入文件: {file.filename}") + + return validation_result + + except json.JSONDecodeError as e: + return { + "valid": False, + "version": "", + "statistics": {"characters": 0, "organizations": 0}, + "errors": [f"JSON格式错误: {str(e)}"], + "warnings": [] + } + except Exception as e: + logger.error(f"验证导入文件失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"验证失败: {str(e)}") diff --git a/backend/app/schemas/import_export.py b/backend/app/schemas/import_export.py index 85383f2..d936fa2 100644 --- a/backend/app/schemas/import_export.py +++ b/backend/app/schemas/import_export.py @@ -36,9 +36,20 @@ class CharacterExportData(BaseModel): personality: Optional[str] = None background: Optional[str] = None appearance: Optional[str] = None + relationships: Optional[str] = None traits: Optional[List[str]] = None organization_type: Optional[str] = None organization_purpose: Optional[str] = None + organization_members: Optional[str] = None + avatar_url: Optional[str] = None + main_career_id: Optional[str] = None + main_career_stage: Optional[int] = None + sub_careers: Optional[str] = None + # 组织专属字段 + power_level: Optional[int] = None + location: Optional[str] = None + motto: Optional[str] = None + color: Optional[str] = None created_at: Optional[str] = None @@ -138,4 +149,28 @@ class ImportResult(BaseModel): project_id: Optional[str] = None message: str statistics: Dict[str, int] = {} + details: Optional[Dict[str, List[str]]] = None + warnings: List[str] = [] + + +class CharactersExportRequest(BaseModel): + """角色/组织批量导出请求""" + character_ids: List[str] = Field(..., description="要导出的角色/组织ID列表") + + +class CharactersExportData(BaseModel): + """角色/组织批量导出数据""" + version: str = "1.0.0" + export_time: str + export_type: str = "characters" + count: int + data: List[CharacterExportData] + + +class CharactersImportResult(BaseModel): + """角色/组织导入结果""" + success: bool + message: str + statistics: Dict[str, int] + details: Dict[str, List[str]] warnings: List[str] = [] \ No newline at end of file diff --git a/backend/app/services/import_export_service.py b/backend/app/services/import_export_service.py index 36716c0..c27a7e1 100644 --- a/backend/app/services/import_export_service.py +++ b/backend/app/services/import_export_service.py @@ -1,7 +1,7 @@ """导入导出服务""" import json from datetime import datetime -from typing import Dict, List, Optional, Tuple +from typing import Dict, List, Optional, Tuple, Any from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from app.models.project import Project @@ -840,4 +840,403 @@ class ImportExportService: db.add(style) count += 1 - return count \ No newline at end of file + return count + + @staticmethod + async def export_characters( + character_ids: List[str], + db: AsyncSession + ) -> Dict[str, Any]: + """ + 导出角色/组织卡片 + + Args: + character_ids: 要导出的角色/组织ID列表 + db: 数据库会话 + + Returns: + Dict: 导出的角色数据 + """ + logger.info(f"开始导出角色/组织: {len(character_ids)} 个") + + # 查询角色数据 + result = await db.execute( + select(Character).where(Character.id.in_(character_ids)) + ) + characters = result.scalars().all() + + if not characters: + raise ValueError("未找到指定的角色/组织") + + # 导出角色数据 + exported_characters = [] + for char in characters: + # 解析 traits + traits = None + if char.traits: + try: + traits = json.loads(char.traits) if isinstance(char.traits, str) else char.traits + except: + traits = None + + # 基础角色数据 + char_data = { + "name": char.name, + "age": char.age, + "gender": char.gender, + "is_organization": char.is_organization or False, + "role_type": char.role_type, + "personality": char.personality, + "background": char.background, + "appearance": char.appearance, + "relationships": char.relationships, + "traits": traits, + "organization_type": char.organization_type, + "organization_purpose": char.organization_purpose, + "organization_members": char.organization_members, + "avatar_url": char.avatar_url, + "main_career_id": char.main_career_id, + "main_career_stage": char.main_career_stage, + "sub_careers": char.sub_careers, + "created_at": char.created_at.isoformat() if char.created_at else 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_data.update({ + "power_level": org.power_level, + "location": org.location, + "motto": org.motto, + "color": org.color + }) + + exported_characters.append(char_data) + + export_data = { + "version": ImportExportService.SUPPORTED_VERSION, + "export_time": datetime.utcnow().isoformat(), + "export_type": "characters", + "count": len(exported_characters), + "data": exported_characters + } + + logger.info(f"角色/组织导出完成: {len(exported_characters)} 个") + return export_data + + @staticmethod + async def import_characters( + data: Dict, + project_id: str, + user_id: str, + db: AsyncSession + ) -> Dict[str, Any]: + """ + 导入角色/组织卡片 + + Args: + data: 导入的JSON数据 + project_id: 目标项目ID + user_id: 用户ID + db: 数据库会话 + + Returns: + Dict: 导入结果 + """ + from app.models.career import CharacterCareer, Career + + warnings = [] + imported_characters = [] + imported_organizations = [] + skipped = [] + errors = [] + + try: + # 验证数据格式 + if "data" not in data: + raise ValueError("导入数据格式错误:缺少data字段") + + characters_data = data["data"] + if not isinstance(characters_data, list): + raise ValueError("导入数据格式错误:data字段必须是数组") + + # 验证项目权限 + project_result = await db.execute( + select(Project).where( + Project.id == project_id, + Project.user_id == user_id + ) + ) + project = project_result.scalar_one_or_none() + if not project: + raise ValueError("项目不存在或无权访问") + + logger.info(f"开始导入 {len(characters_data)} 个角色/组织到项目 {project_id}") + + # 处理每个角色/组织 + for idx, char_data in enumerate(characters_data): + try: + name = char_data.get("name") + if not name: + errors.append(f"第{idx+1}个角色缺少name字段") + continue + + # 检查重复名称 + existing_result = await db.execute( + select(Character).where( + Character.project_id == project_id, + Character.name == name + ) + ) + existing = existing_result.scalar_one_or_none() + + if existing: + warnings.append(f"角色'{name}'已存在,已跳过") + skipped.append(name) + continue + + # 处理traits + traits = char_data.get("traits") + if isinstance(traits, list): + traits = json.dumps(traits, ensure_ascii=False) + + is_organization = char_data.get("is_organization", False) + + # 创建角色 + character = Character( + project_id=project_id, + name=name, + age=char_data.get("age"), + gender=char_data.get("gender"), + is_organization=is_organization, + role_type=char_data.get("role_type"), + personality=char_data.get("personality"), + background=char_data.get("background"), + appearance=char_data.get("appearance"), + relationships=char_data.get("relationships"), + traits=traits, + organization_type=char_data.get("organization_type"), + organization_purpose=char_data.get("organization_purpose"), + organization_members=char_data.get("organization_members"), + avatar_url=char_data.get("avatar_url"), + main_career_id=None, # 职业ID需要验证后再设置 + main_career_stage=char_data.get("main_career_stage"), + sub_careers=None # 副职业需要验证后再设置 + ) + db.add(character) + await db.flush() # 获取character.id + + # 处理主职业(如果有) + main_career_id = char_data.get("main_career_id") + main_career_stage = char_data.get("main_career_stage") + + if main_career_id and not is_organization: + # 验证职业是否存在 + career_result = await db.execute( + select(Career).where( + Career.id == main_career_id, + Career.project_id == project_id, + Career.type == 'main' + ) + ) + career = career_result.scalar_one_or_none() + + if career: + character.main_career_id = main_career_id + character.main_career_stage = main_career_stage or 1 + + # 创建职业关联 + char_career = CharacterCareer( + character_id=character.id, + career_id=main_career_id, + career_type='main', + current_stage=main_career_stage or 1, + stage_progress=0 + ) + db.add(char_career) + else: + warnings.append(f"角色'{name}'的主职业ID不存在,已忽略职业信息") + + # 处理副职业(如果有) + sub_careers = char_data.get("sub_careers") + if sub_careers and not is_organization: + try: + sub_careers_data = json.loads(sub_careers) if isinstance(sub_careers, str) else sub_careers + + if isinstance(sub_careers_data, list): + valid_sub_careers = [] + + for sub_data in sub_careers_data[:2]: # 最多2个副职业 + if isinstance(sub_data, dict): + career_id = sub_data.get('career_id') + stage = sub_data.get('stage', 1) + + if career_id: + # 验证副职业是否存在 + career_result = await db.execute( + select(Career).where( + Career.id == career_id, + Career.project_id == project_id, + Career.type == 'sub' + ) + ) + career = career_result.scalar_one_or_none() + + if career: + valid_sub_careers.append({ + 'career_id': career_id, + 'stage': stage + }) + + # 创建副职业关联 + char_career = CharacterCareer( + character_id=character.id, + career_id=career_id, + career_type='sub', + current_stage=stage, + stage_progress=0 + ) + db.add(char_career) + + if valid_sub_careers: + character.sub_careers = json.dumps(valid_sub_careers, ensure_ascii=False) + elif sub_careers_data: + warnings.append(f"角色'{name}'的副职业ID不存在,已忽略副职业信息") + except Exception as e: + warnings.append(f"角色'{name}'的副职业数据解析失败: {str(e)}") + + # 如果是组织,创建Organization记录 + if is_organization: + organization = Organization( + character_id=character.id, + project_id=project_id, + member_count=0, + power_level=char_data.get("power_level", 50), + location=char_data.get("location"), + motto=char_data.get("motto"), + color=char_data.get("color") + ) + db.add(organization) + await db.flush() + imported_organizations.append(name) + else: + imported_characters.append(name) + + logger.info(f"导入{'组织' if is_organization else '角色'}成功: {name}") + + except Exception as e: + error_msg = f"导入角色'{char_data.get('name', f'第{idx+1}个')}'失败: {str(e)}" + logger.error(error_msg) + errors.append(error_msg) + continue + + # 提交事务 + await db.commit() + + total = len(imported_characters) + len(imported_organizations) + + result = { + "success": True, + "message": f"成功导入 {total} 个角色/组织", + "statistics": { + "total": len(characters_data), + "imported": total, + "skipped": len(skipped), + "errors": len(errors) + }, + "details": { + "imported_characters": imported_characters, + "imported_organizations": imported_organizations, + "skipped": skipped, + "errors": errors + }, + "warnings": warnings + } + + logger.info(f"角色/组织导入完成: 成功{total}个,跳过{len(skipped)}个,失败{len(errors)}个") + return result + + except Exception as e: + await db.rollback() + logger.error(f"导入角色/组织失败: {str(e)}", exc_info=True) + return { + "success": False, + "message": f"导入失败: {str(e)}", + "statistics": { + "total": len(characters_data) if "data" in data else 0, + "imported": len(imported_characters) + len(imported_organizations), + "skipped": len(skipped), + "errors": len(errors) + }, + "details": { + "imported_characters": imported_characters, + "imported_organizations": imported_organizations, + "skipped": skipped, + "errors": errors + }, + "warnings": warnings + } + + @staticmethod + def validate_characters_import(data: Dict) -> Dict[str, Any]: + """ + 验证角色/组织导入数据 + + Args: + data: 导入的JSON数据 + + Returns: + Dict: 验证结果 + """ + errors = [] + warnings = [] + + # 检查版本 + version = data.get("version", "") + if not version: + errors.append("缺少版本信息") + elif version != ImportExportService.SUPPORTED_VERSION: + warnings.append(f"版本不匹配: 导入文件版本为 {version}, 当前支持版本为 {ImportExportService.SUPPORTED_VERSION}") + + # 检查导出类型 + export_type = data.get("export_type", "") + if export_type != "characters": + errors.append(f"导出类型错误: 期望'characters',实际'{export_type}'") + + # 检查数据字段 + if "data" not in data: + errors.append("缺少data字段") + elif not isinstance(data["data"], list): + errors.append("data字段必须是数组") + else: + characters_data = data["data"] + + # 统计信息 + character_count = sum(1 for c in characters_data if not c.get("is_organization", False)) + org_count = sum(1 for c in characters_data if c.get("is_organization", False)) + + # 检查必填字段 + for idx, char_data in enumerate(characters_data): + if not char_data.get("name"): + errors.append(f"第{idx+1}个角色缺少name字段") + + statistics = { + "characters": character_count, + "organizations": org_count + } + + if "data" not in data or errors: + statistics = {"characters": 0, "organizations": 0} + + return { + "valid": len(errors) == 0, + "version": version, + "statistics": statistics, + "errors": errors, + "warnings": warnings + } \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 958b0aa..4f2ae72 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.2.2", + "version": "1.2.3", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/CharacterCard.tsx b/frontend/src/components/CharacterCard.tsx index 815815d..f985f3a 100644 --- a/frontend/src/components/CharacterCard.tsx +++ b/frontend/src/components/CharacterCard.tsx @@ -1,5 +1,5 @@ import { Card, Space, Tag, Typography, Popconfirm } from 'antd'; -import { EditOutlined, DeleteOutlined, UserOutlined, BankOutlined } from '@ant-design/icons'; +import { EditOutlined, DeleteOutlined, UserOutlined, BankOutlined, ExportOutlined } from '@ant-design/icons'; import { cardStyles } from './CardStyles'; import type { Character } from '../types'; @@ -9,9 +9,10 @@ interface CharacterCardProps { character: Character; onEdit?: (character: Character) => void; onDelete: (id: string) => void; + onExport?: () => void; } -export const CharacterCard: React.FC = ({ character, onEdit, onDelete }) => { +export const CharacterCard: React.FC = ({ character, onEdit, onDelete, onExport }) => { const getRoleTypeColor = (roleType?: string) => { const roleColors: Record = { 'protagonist': 'blue', @@ -49,6 +50,7 @@ export const CharacterCard: React.FC = ({ character, onEdit, }} actions={[ ...(onEdit ? [ onEdit(character)} />] : []), + ...(onExport ? [] : []), (null); const [mainCareers, setMainCareers] = useState([]); const [subCareers, setSubCareers] = useState([]); + const [selectedCharacters, setSelectedCharacters] = useState([]); + const [isImportModalOpen, setIsImportModalOpen] = useState(false); + const fileInputRef = useRef(null); const { refreshCharacters, @@ -278,6 +281,188 @@ export default function Characters() { handleDeleteCharacter(id); }; + // 导出选中的角色/组织 + const handleExportSelected = async () => { + if (selectedCharacters.length === 0) { + message.warning('请至少选择一个角色或组织'); + return; + } + + try { + await characterApi.exportCharacters(selectedCharacters); + message.success(`成功导出 ${selectedCharacters.length} 个角色/组织`); + setSelectedCharacters([]); + } catch (error) { + message.error('导出失败'); + console.error('导出错误:', error); + } + }; + + // 导出单个角色/组织 + const handleExportSingle = async (characterId: string) => { + try { + await characterApi.exportCharacters([characterId]); + message.success('导出成功'); + } catch (error) { + message.error('导出失败'); + console.error('导出错误:', error); + } + }; + + // 处理文件选择 + const handleFileSelect = async (file: File) => { + try { + // 验证文件 + const validation = await characterApi.validateImportCharacters(file); + + if (!validation.valid) { + modal.error({ + title: '文件验证失败', + centered: true, + content: ( +
+ {validation.errors.map((error, index) => ( +
• {error}
+ ))} +
+ ), + }); + return; + } + + // 显示预览对话框 + modal.confirm({ + title: '导入预览', + width: 500, + centered: true, + content: ( +
+

文件版本: {validation.version}

+ +

将要导入:

+
    +
  • 角色: {validation.statistics.characters} 个
  • +
  • 组织: {validation.statistics.organizations} 个
  • +
+ {validation.warnings.length > 0 && ( + <> + +

⚠️ 警告:

+
    + {validation.warnings.map((warning, index) => ( +
  • {warning}
  • + ))} +
+ + )} +
+ ), + okText: '确认导入', + cancelText: '取消', + onOk: async () => { + try { + const result = await characterApi.importCharacters(currentProject.id, file); + + if (result.success) { + // 显示导入结果 + modal.success({ + title: '导入完成', + width: 600, + centered: true, + content: ( +
+

✅ 成功导入: {result.statistics.imported} 个

+ {result.details.imported_characters.length > 0 && ( + <> +

角色:

+
    + {result.details.imported_characters.map((name, index) => ( +
  • {name}
  • + ))} +
+ + )} + {result.details.imported_organizations.length > 0 && ( + <> +

组织:

+
    + {result.details.imported_organizations.map((name, index) => ( +
  • {name}
  • + ))} +
+ + )} + {result.statistics.skipped > 0 && ( + <> + +

⚠️ 跳过: {result.statistics.skipped} 个

+
    + {result.details.skipped.map((name, index) => ( +
  • {name}
  • + ))} +
+ + )} + {result.warnings.length > 0 && ( + <> + +

⚠️ 警告:

+
    + {result.warnings.map((warning, index) => ( +
  • {warning}
  • + ))} +
+ + )} + {result.details.errors.length > 0 && ( + <> + +

❌ 失败: {result.statistics.errors} 个

+
    + {result.details.errors.map((error, index) => ( +
  • {error}
  • + ))} +
+ + )} +
+ ), + }); + + // 刷新列表 + await refreshCharacters(); + setIsImportModalOpen(false); + } else { + message.error(result.message || '导入失败'); + } + } catch (error: any) { + message.error(error.response?.data?.detail || '导入失败'); + console.error('导入错误:', error); + } + }, + }); + } catch (error: any) { + message.error(error.response?.data?.detail || '文件验证失败'); + console.error('验证错误:', error); + } + }; + + // 切换选择 + const toggleSelectCharacter = (id: string) => { + setSelectedCharacters(prev => + prev.includes(id) ? prev.filter(cid => cid !== id) : [...prev, id] + ); + }; + + // 全选/取消全选 + const toggleSelectAll = () => { + if (selectedCharacters.length === displayList.length) { + setSelectedCharacters([]); + } else { + setSelectedCharacters(displayList.map(c => c.id)); + } + }; + const showGenerateModal = () => { modal.confirm({ title: 'AI生成角色', @@ -427,6 +612,22 @@ export default function Characters() { > AI生成组织 + + {selectedCharacters.length > 0 && ( + + )}
@@ -468,6 +669,39 @@ export default function Characters() { )} + {/* 批量选择工具栏 */} + {characters.length > 0 && ( +
0 ? '1px solid var(--color-border-secondary)' : 'none', + }}> + + 0} + indeterminate={selectedCharacters.length > 0 && selectedCharacters.length < displayList.length} + onChange={toggleSelectAll} + > + {selectedCharacters.length > 0 ? `已选 ${selectedCharacters.length} 个` : '全选'} + + {selectedCharacters.length > 0 && ( + + )} + +
+ )} +
{characters.length === 0 ? ( @@ -496,11 +730,19 @@ export default function Characters() { key={character.id} style={{ padding: isMobile ? '4px' : '8px' }} > - +
+ toggleSelectCharacter(character.id)} + style={{ position: 'absolute', top: 8, left: 8, zIndex: 1 }} + /> + handleExportSingle(character.id)} + /> +
))} @@ -526,11 +768,19 @@ export default function Characters() { key={org.id} style={{ padding: isMobile ? '4px' : '8px' }} > - +
+ toggleSelectCharacter(org.id)} + style={{ position: 'absolute', top: 8, left: 8, zIndex: 1 }} + /> + handleExportSingle(org.id)} + /> +
))} @@ -548,11 +798,19 @@ export default function Characters() { key={character.id} style={{ padding: isMobile ? '4px' : '8px' }} > - +
+ toggleSelectCharacter(character.id)} + style={{ position: 'absolute', top: 8, left: 8, zIndex: 1 }} + /> + handleExportSingle(character.id)} + /> +
))} @@ -566,11 +824,19 @@ export default function Characters() { key={org.id} style={{ padding: isMobile ? '4px' : '8px' }} > - +
+ toggleSelectCharacter(org.id)} + style={{ position: 'absolute', top: 8, left: 8, zIndex: 1 }} + /> + handleExportSingle(org.id)} + /> +
))} @@ -1093,6 +1359,53 @@ export default function Characters() { + {/* 导入对话框 */} + setIsImportModalOpen(false)} + footer={null} + width={500} + centered + > +
+ +

+ 选择之前导出的角色/组织JSON文件进行导入 +

+ { + const file = e.target.files?.[0]; + if (file) { + handleFileSelect(file); + e.target.value = ''; // 清空input,允许重复选择同一文件 + } + }} + /> + + +
+

说明:

+
    +
  • 支持导入.json格式的角色/组织文件
  • +
  • 重复名称的角色/组织将被跳过
  • +
  • 职业信息如不存在将被忽略
  • +
+
+
+
+ {/* SSE进度显示 */} - - - + {[ + { label: '大纲', value: outlines.length, unit: '条' }, + { label: '角色', value: characters.length, unit: '个' }, + { label: '章节', value: chapters.length, unit: '章' }, + { label: '已写', value: currentProject.current_words, unit: '字' }, + ].map((item, index) => ( +
- 大纲} - value={outlines.length} - suffix="条" - valueStyle={{ fontSize: '16px', fontWeight: 600, color: 'var(--color-primary)' }} - /> - - - - { + e.currentTarget.style.transform = 'translateY(-3px) scale(1.02)'; + e.currentTarget.style.boxShadow = 'inset 0 0 20px rgba(255, 255, 255, 0.25), 0 8px 16px rgba(0, 0, 0, 0.15)'; + e.currentTarget.style.border = '1px solid rgba(255, 255, 255, 0.1)'; }} - styles={{ body: { padding: '8px' } }} - > - 角色} - value={characters.length} - suffix="个" - valueStyle={{ fontSize: '16px', fontWeight: 600, color: 'var(--color-success)' }} - /> - - - - { + e.currentTarget.style.transform = 'translateY(0) scale(1)'; + e.currentTarget.style.boxShadow = 'inset 0 0 15px rgba(255, 255, 255, 0.15), 0 4px 10px rgba(0, 0, 0, 0.1)'; }} - styles={{ body: { padding: '8px' } }} > - 章节} - value={chapters.length} - suffix="章" - valueStyle={{ fontSize: '16px', fontWeight: 600, color: 'var(--color-info)' }} - /> - - - - - 已写} - value={currentProject.current_words} - suffix="字" - valueStyle={{ fontSize: '16px', fontWeight: 600, color: 'var(--color-warning)' }} - /> - - - + + {item.label} + + + {item.value > 10000 ? (item.value / 10000).toFixed(1) + 'w' : item.value} + {item.unit} + +
+ ))} +
)} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 93d69d5..1fb0f86 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -464,6 +464,80 @@ export const characterApi = { generateCharacter: (data: GenerateCharacterRequest) => api.post('/characters/generate', data), + + // 导出角色/组织 + exportCharacters: async (characterIds: string[]) => { + const response = await axios.post( + '/api/characters/export', + { character_ids: characterIds }, + { + responseType: 'blob', + headers: { + 'Content-Type': 'application/json', + }, + } + ); + + // 从响应头获取文件名 + const contentDisposition = response.headers['content-disposition']; + let filename = 'characters_export.json'; + if (contentDisposition) { + const matches = /filename=(.+)/.exec(contentDisposition); + if (matches && matches[1]) { + filename = matches[1]; + } + } + + // 创建下载链接 + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', filename); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(url); + }, + + // 验证导入文件 + validateImportCharacters: (file: File) => { + const formData = new FormData(); + formData.append('file', file); + return api.post('/characters/validate-import', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + }, + + // 导入角色/组织 + importCharacters: (projectId: string, file: File) => { + const formData = new FormData(); + formData.append('file', file); + return api.post(`/characters/import?project_id=${projectId}`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + }, }; export const chapterApi = {