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
+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>