feature:1.新增角色/组织卡片导入导出功能,支持批量

This commit is contained in:
xiamuceer
2025-12-29 16:48:02 +08:00
parent f2158cd36e
commit 3b97e88128
10 changed files with 1068 additions and 114 deletions
+4 -2
View File
@@ -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<CharacterCardProps> = ({ character, onEdit, onDelete }) => {
export const CharacterCard: React.FC<CharacterCardProps> = ({ character, onEdit, onDelete, onExport }) => {
const getRoleTypeColor = (roleType?: string) => {
const roleColors: Record<string, string> = {
'protagonist': 'blue',
@@ -49,6 +50,7 @@ export const CharacterCard: React.FC<CharacterCardProps> = ({ character, onEdit,
}}
actions={[
...(onEdit ? [<EditOutlined key="edit" onClick={() => onEdit(character)} />] : []),
...(onExport ? [<ExportOutlined key="export" onClick={onExport} />] : []),
<Popconfirm
key="delete"
title={`确定删除这个${isOrganization ? '组织' : '角色'}吗?`}
+336 -23
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { Button, Modal, Form, Input, Select, message, Row, Col, Empty, Tabs, Divider, Typography, Space, InputNumber } from 'antd';
import { ThunderboltOutlined, UserOutlined, TeamOutlined, PlusOutlined } from '@ant-design/icons';
import { useState, useEffect, useRef } from 'react';
import { Button, Modal, Form, Input, Select, message, Row, Col, Empty, Tabs, Divider, Typography, Space, InputNumber, Checkbox } from 'antd';
import { ThunderboltOutlined, UserOutlined, TeamOutlined, PlusOutlined, ExportOutlined, ImportOutlined, DownloadOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { useCharacterSync } from '../store/hooks';
import { characterGridConfig } from '../components/CardStyles';
@@ -38,6 +38,9 @@ export default function Characters() {
const [editingCharacter, setEditingCharacter] = useState<Character | null>(null);
const [mainCareers, setMainCareers] = useState<Career[]>([]);
const [subCareers, setSubCareers] = useState<Career[]>([]);
const [selectedCharacters, setSelectedCharacters] = useState<string[]>([]);
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(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: (
<div>
{validation.errors.map((error, index) => (
<div key={index} style={{ color: 'red' }}> {error}</div>
))}
</div>
),
});
return;
}
// 显示预览对话框
modal.confirm({
title: '导入预览',
width: 500,
centered: true,
content: (
<div>
<p><strong>:</strong> {validation.version}</p>
<Divider style={{ margin: '12px 0' }} />
<p><strong>:</strong></p>
<ul style={{ marginLeft: 20 }}>
<li>: {validation.statistics.characters} </li>
<li>: {validation.statistics.organizations} </li>
</ul>
{validation.warnings.length > 0 && (
<>
<Divider style={{ margin: '12px 0' }} />
<p style={{ color: '#faad14' }}><strong> :</strong></p>
<ul style={{ marginLeft: 20 }}>
{validation.warnings.map((warning, index) => (
<li key={index} style={{ color: '#faad14' }}>{warning}</li>
))}
</ul>
</>
)}
</div>
),
okText: '确认导入',
cancelText: '取消',
onOk: async () => {
try {
const result = await characterApi.importCharacters(currentProject.id, file);
if (result.success) {
// 显示导入结果
modal.success({
title: '导入完成',
width: 600,
centered: true,
content: (
<div>
<p><strong> : {result.statistics.imported} </strong></p>
{result.details.imported_characters.length > 0 && (
<>
<p style={{ marginTop: 12, marginBottom: 4 }}>:</p>
<ul style={{ marginLeft: 20 }}>
{result.details.imported_characters.map((name, index) => (
<li key={index}>{name}</li>
))}
</ul>
</>
)}
{result.details.imported_organizations.length > 0 && (
<>
<p style={{ marginTop: 12, marginBottom: 4 }}>:</p>
<ul style={{ marginLeft: 20 }}>
{result.details.imported_organizations.map((name, index) => (
<li key={index}>{name}</li>
))}
</ul>
</>
)}
{result.statistics.skipped > 0 && (
<>
<Divider style={{ margin: '12px 0' }} />
<p style={{ color: '#faad14' }}> : {result.statistics.skipped} </p>
<ul style={{ marginLeft: 20 }}>
{result.details.skipped.map((name, index) => (
<li key={index} style={{ color: '#faad14' }}>{name}</li>
))}
</ul>
</>
)}
{result.warnings.length > 0 && (
<>
<Divider style={{ margin: '12px 0' }} />
<p style={{ color: '#faad14' }}> :</p>
<ul style={{ marginLeft: 20 }}>
{result.warnings.map((warning, index) => (
<li key={index} style={{ color: '#faad14' }}>{warning}</li>
))}
</ul>
</>
)}
{result.details.errors.length > 0 && (
<>
<Divider style={{ margin: '12px 0' }} />
<p style={{ color: 'red' }}> : {result.statistics.errors} </p>
<ul style={{ marginLeft: 20 }}>
{result.details.errors.map((error, index) => (
<li key={index} style={{ color: 'red' }}>{error}</li>
))}
</ul>
</>
)}
</div>
),
});
// 刷新列表
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生成组织
</Button>
<Button
icon={<ImportOutlined />}
onClick={() => setIsImportModalOpen(true)}
size={isMobile ? 'small' : 'middle'}
>
</Button>
{selectedCharacters.length > 0 && (
<Button
icon={<ExportOutlined />}
onClick={handleExportSelected}
size={isMobile ? 'small' : 'middle'}
>
({selectedCharacters.length})
</Button>
)}
</Space>
</div>
@@ -468,6 +669,39 @@ export default function Characters() {
</div>
)}
{/* 批量选择工具栏 */}
{characters.length > 0 && (
<div style={{
position: 'sticky',
top: isMobile ? 120 : 132,
zIndex: 8,
backgroundColor: 'var(--color-bg-container)',
paddingBottom: 8,
paddingTop: 8,
marginTop: 8,
borderBottom: selectedCharacters.length > 0 ? '1px solid var(--color-border-secondary)' : 'none',
}}>
<Space>
<Checkbox
checked={selectedCharacters.length === displayList.length && displayList.length > 0}
indeterminate={selectedCharacters.length > 0 && selectedCharacters.length < displayList.length}
onChange={toggleSelectAll}
>
{selectedCharacters.length > 0 ? `已选 ${selectedCharacters.length}` : '全选'}
</Checkbox>
{selectedCharacters.length > 0 && (
<Button
type="link"
size="small"
onClick={() => setSelectedCharacters([])}
>
</Button>
)}
</Space>
</div>
)}
<div style={{ flex: 1, overflowY: 'auto' }}>
{characters.length === 0 ? (
<Empty description="还没有角色或组织,开始创建吧!" />
@@ -496,11 +730,19 @@ export default function Characters() {
key={character.id}
style={{ padding: isMobile ? '4px' : '8px' }}
>
<CharacterCard
character={character}
onEdit={handleEditCharacter}
onDelete={handleDeleteCharacterWrapper}
/>
<div style={{ position: 'relative' }}>
<Checkbox
checked={selectedCharacters.includes(character.id)}
onChange={() => toggleSelectCharacter(character.id)}
style={{ position: 'absolute', top: 8, left: 8, zIndex: 1 }}
/>
<CharacterCard
character={character}
onEdit={handleEditCharacter}
onDelete={handleDeleteCharacterWrapper}
onExport={() => handleExportSingle(character.id)}
/>
</div>
</Col>
))}
</>
@@ -526,11 +768,19 @@ export default function Characters() {
key={org.id}
style={{ padding: isMobile ? '4px' : '8px' }}
>
<CharacterCard
character={org}
onEdit={handleEditCharacter}
onDelete={handleDeleteCharacterWrapper}
/>
<div style={{ position: 'relative' }}>
<Checkbox
checked={selectedCharacters.includes(org.id)}
onChange={() => toggleSelectCharacter(org.id)}
style={{ position: 'absolute', top: 8, left: 8, zIndex: 1 }}
/>
<CharacterCard
character={org}
onEdit={handleEditCharacter}
onDelete={handleDeleteCharacterWrapper}
onExport={() => handleExportSingle(org.id)}
/>
</div>
</Col>
))}
</>
@@ -548,11 +798,19 @@ export default function Characters() {
key={character.id}
style={{ padding: isMobile ? '4px' : '8px' }}
>
<CharacterCard
character={character}
onEdit={handleEditCharacter}
onDelete={handleDeleteCharacterWrapper}
/>
<div style={{ position: 'relative' }}>
<Checkbox
checked={selectedCharacters.includes(character.id)}
onChange={() => toggleSelectCharacter(character.id)}
style={{ position: 'absolute', top: 8, left: 8, zIndex: 1 }}
/>
<CharacterCard
character={character}
onEdit={handleEditCharacter}
onDelete={handleDeleteCharacterWrapper}
onExport={() => handleExportSingle(character.id)}
/>
</div>
</Col>
))}
@@ -566,11 +824,19 @@ export default function Characters() {
key={org.id}
style={{ padding: isMobile ? '4px' : '8px' }}
>
<CharacterCard
character={org}
onEdit={handleEditCharacter}
onDelete={handleDeleteCharacterWrapper}
/>
<div style={{ position: 'relative' }}>
<Checkbox
checked={selectedCharacters.includes(org.id)}
onChange={() => toggleSelectCharacter(org.id)}
style={{ position: 'absolute', top: 8, left: 8, zIndex: 1 }}
/>
<CharacterCard
character={org}
onEdit={handleEditCharacter}
onDelete={handleDeleteCharacterWrapper}
onExport={() => handleExportSingle(org.id)}
/>
</div>
</Col>
))}
</Row>
@@ -1093,6 +1359,53 @@ export default function Characters() {
</Form>
</Modal>
{/* 导入对话框 */}
<Modal
title="导入角色/组织"
open={isImportModalOpen}
onCancel={() => setIsImportModalOpen(false)}
footer={null}
width={500}
centered
>
<div style={{ textAlign: 'center', padding: '40px 20px' }}>
<DownloadOutlined style={{ fontSize: 48, color: '#1890ff', marginBottom: 16 }} />
<p style={{ fontSize: 16, marginBottom: 24 }}>
/JSON文件进行导入
</p>
<input
ref={fileInputRef}
type="file"
accept=".json"
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
handleFileSelect(file);
e.target.value = ''; // 清空input,允许重复选择同一文件
}
}}
/>
<Button
type="primary"
size="large"
icon={<ImportOutlined />}
onClick={() => fileInputRef.current?.click()}
>
</Button>
<Divider />
<div style={{ textAlign: 'left', fontSize: 12, color: '#666' }}>
<p style={{ marginBottom: 8 }}><strong></strong></p>
<ul style={{ marginLeft: 20 }}>
<li>.json格式的角色/</li>
<li>/</li>
<li></li>
</ul>
</div>
</div>
</Modal>
{/* SSE进度显示 */}
<SSELoadingOverlay
loading={isGenerating}
+50 -82
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { useParams, useNavigate, Outlet, Link, useLocation } from 'react-router-dom';
import { Layout, Menu, Spin, Button, Statistic, Row, Col, Card, Drawer } from 'antd';
import { Layout, Menu, Spin, Button, Drawer } from 'antd';
import {
ArrowLeftOutlined,
FileTextOutlined,
@@ -282,92 +282,60 @@ export default function ProjectDetail() {
{!mobile && (
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', zIndex: 1 }}>
<Row gutter={12} style={{ width: '450px', justifyContent: 'flex-end' }}>
<Col>
<Card
size="small"
<div style={{ display: 'flex', gap: '16px' }}>
{[
{ label: '大纲', value: outlines.length, unit: '条' },
{ label: '角色', value: characters.length, unit: '个' },
{ label: '章节', value: chapters.length, unit: '章' },
{ label: '已写', value: currentProject.current_words, unit: '字' },
].map((item, index) => (
<div
key={index}
style={{
background: 'var(--color-bg-container)',
borderRadius: '6px',
border: 'none',
minWidth: '80px',
textAlign: 'center',
padding: '4px 8px'
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backdropFilter: 'blur(4px)',
borderRadius: '28px',
minWidth: '56px',
height: '56px',
padding: '0 12px',
boxShadow: 'inset 0 0 15px rgba(255, 255, 255, 0.15), 0 4px 10px rgba(0, 0, 0, 0.1)',
cursor: 'default',
transition: 'all 0.3s ease',
}}
styles={{ body: { padding: '8px' } }}
>
<Statistic
title={<span style={{ fontSize: '11px', color: 'var(--color-text-secondary)' }}></span>}
value={outlines.length}
suffix="条"
valueStyle={{ fontSize: '16px', fontWeight: 600, color: 'var(--color-primary)' }}
/>
</Card>
</Col>
<Col>
<Card
size="small"
style={{
background: 'var(--color-bg-container)',
borderRadius: '6px',
border: 'none',
minWidth: '80px',
textAlign: 'center',
padding: '4px 8px'
onMouseEnter={(e) => {
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' } }}
>
<Statistic
title={<span style={{ fontSize: '11px', color: 'var(--color-text-secondary)' }}></span>}
value={characters.length}
suffix="个"
valueStyle={{ fontSize: '16px', fontWeight: 600, color: 'var(--color-success)' }}
/>
</Card>
</Col>
<Col>
<Card
size="small"
style={{
background: 'var(--color-bg-container)',
borderRadius: '6px',
border: 'none',
minWidth: '80px',
textAlign: 'center',
padding: '4px 8px'
onMouseLeave={(e) => {
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' } }}
>
<Statistic
title={<span style={{ fontSize: '11px', color: 'var(--color-text-secondary)' }}></span>}
value={chapters.length}
suffix="章"
valueStyle={{ fontSize: '16px', fontWeight: 600, color: 'var(--color-info)' }}
/>
</Card>
</Col>
<Col>
<Card
size="small"
style={{
background: 'var(--color-bg-container)',
borderRadius: '6px',
border: 'none',
minWidth: '80px',
textAlign: 'center',
padding: '4px 8px'
}}
styles={{ body: { padding: '8px' } }}
>
<Statistic
title={<span style={{ fontSize: '11px', color: 'var(--color-text-secondary)' }}></span>}
value={currentProject.current_words}
suffix="字"
valueStyle={{ fontSize: '16px', fontWeight: 600, color: 'var(--color-warning)' }}
/>
</Card>
</Col>
</Row>
<span style={{
fontSize: '11px',
color: 'rgba(255, 255, 255, 0.9)',
marginBottom: '2px',
lineHeight: 1
}}>
{item.label}
</span>
<span style={{
fontSize: '15px',
fontWeight: '600',
color: '#fff',
lineHeight: 1,
fontFamily: 'Monaco, monospace'
}}>
{item.value > 10000 ? (item.value / 10000).toFixed(1) + 'w' : item.value}
<span style={{ fontSize: '10px', marginLeft: '2px', opacity: 0.8 }}>{item.unit}</span>
</span>
</div>
))}
</div>
</div>
)}
</Header>
+74
View File
@@ -464,6 +464,80 @@ export const characterApi = {
generateCharacter: (data: GenerateCharacterRequest) =>
api.post<unknown, Character>('/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<unknown, {
valid: boolean;
version: string;
statistics: { characters: number; organizations: number };
errors: string[];
warnings: string[];
}>('/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<unknown, {
success: boolean;
message: string;
statistics: {
total: number;
imported: number;
skipped: number;
errors: number;
};
details: {
imported_characters: string[];
imported_organizations: string[];
skipped: string[];
errors: string[];
};
warnings: string[];
}>(`/characters/import?project_id=${projectId}`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
},
};
export const chapterApi = {