diff --git a/README.md b/README.md
index 7a64c23..0aff3ce 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
-
+



@@ -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生成组织
+ }
+ onClick={() => setIsImportModalOpen(true)}
+ size={isMobile ? 'small' : 'middle'}
+ >
+ 导入
+
+ {selectedCharacters.length > 0 && (
+ }
+ onClick={handleExportSelected}
+ size={isMobile ? 'small' : 'middle'}
+ >
+ 批量导出 ({selectedCharacters.length})
+
+ )}
@@ -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,允许重复选择同一文件
+ }
+ }}
+ />
+
}
+ onClick={() => fileInputRef.current?.click()}
+ >
+ 选择文件
+
+
+
+
说明:
+
+ - 支持导入.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