update: 1.新增职业管理模块和角色职业关联 2.章节分析自动更新角色职业状态 3.优化章节生成的角色信息构建 4.批量生成强制开启同步分析 5.章节内容批量生成增加系统提示

This commit is contained in:
xiamuceer
2025-12-22 19:53:31 +08:00
parent 6886d903fe
commit b2dec41464
25 changed files with 4635 additions and 89 deletions
+433
View File
@@ -0,0 +1,433 @@
import { useState, useEffect } from 'react';
import { Button, Modal, Form, Input, Select, message, Row, Col, Empty, Tabs, Card, Tag, Space, Divider, Typography, InputNumber } from 'antd';
import { ThunderboltOutlined, PlusOutlined, EditOutlined, DeleteOutlined, TrophyOutlined } from '@ant-design/icons';
import { useParams } from 'react-router-dom';
import axios from 'axios';
import SSEProgressModal from '../components/SSEProgressModal';
const { TextArea } = Input;
const { Title, Text, Paragraph } = Typography;
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
interface CareerStage {
level: number;
name: string;
description?: string;
}
interface Career {
id: string;
project_id: string;
name: string;
type: 'main' | 'sub';
description?: string;
category?: string;
stages: CareerStage[];
max_stage: number;
requirements?: string;
special_abilities?: string;
worldview_rules?: string;
source: string;
}
export default function Careers() {
const { projectId } = useParams<{ projectId: string }>();
const [mainCareers, setMainCareers] = useState<Career[]>([]);
const [subCareers, setSubCareers] = useState<Career[]>([]);
const [, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isAIModalOpen, setIsAIModalOpen] = useState(false);
const [editingCareer, setEditingCareer] = useState<Career | null>(null);
const [form] = Form.useForm();
const [aiForm] = Form.useForm();
// AI生成状态
const [aiGenerating, setAiGenerating] = useState(false);
const [aiProgress, setAiProgress] = useState(0);
const [aiMessage, setAiMessage] = useState('');
useEffect(() => {
if (projectId) {
fetchCareers();
}
}, [projectId]);
const fetchCareers = async () => {
try {
setLoading(true);
const response = await axios.get(`${API_BASE_URL}/api/careers`, {
params: { project_id: projectId },
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
});
setMainCareers(response.data.main_careers || []);
setSubCareers(response.data.sub_careers || []);
} catch (error: any) {
message.error(error.response?.data?.detail || '获取职业列表失败');
} finally {
setLoading(false);
}
};
const handleOpenModal = (career?: Career) => {
if (career) {
setEditingCareer(career);
form.setFieldsValue({
...career,
stages: career.stages.map(s => `${s.level}. ${s.name}${s.description ? ` - ${s.description}` : ''}`).join('\n')
});
} else {
setEditingCareer(null);
form.resetFields();
}
setIsModalOpen(true);
};
const handleSubmit = async (values: any) => {
try {
// 解析阶段数据
const stagesText = values.stages || '';
const stages: CareerStage[] = stagesText.split('\n')
.filter((line: string) => line.trim())
.map((line: string, index: number) => {
const match = line.match(/^(\d+)\.\s*([^-]+)(?:\s*-\s*(.*))?$/);
if (match) {
return {
level: parseInt(match[1]),
name: match[2].trim(),
description: match[3]?.trim() || ''
};
}
return {
level: index + 1,
name: line.trim(),
description: ''
};
});
const data = {
...values,
stages,
max_stage: stages.length
};
if (editingCareer) {
await axios.put(`${API_BASE_URL}/api/careers/${editingCareer.id}`, data, {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
});
message.success('职业更新成功');
} else {
await axios.post(`${API_BASE_URL}/api/careers`, {
...data,
project_id: projectId,
source: 'manual'
}, {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
});
message.success('职业创建成功');
}
setIsModalOpen(false);
form.resetFields();
fetchCareers();
} catch (error: any) {
message.error(error.response?.data?.detail || '操作失败');
}
};
const handleDelete = async (id: string) => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这个职业吗?如果有角色使用了该职业,将无法删除。',
onOk: async () => {
try {
await axios.delete(`${API_BASE_URL}/api/careers/${id}`, {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
});
message.success('职业删除成功');
fetchCareers();
} catch (error: any) {
message.error(error.response?.data?.detail || '删除失败');
}
}
});
};
const handleAIGenerate = async (values: any) => {
setIsAIModalOpen(false);
setAiGenerating(true);
setAiProgress(0);
setAiMessage('开始生成新职业...');
try {
const eventSource = new EventSource(
`${API_BASE_URL}/api/careers/generate-system?` +
new URLSearchParams({
project_id: projectId || '',
main_career_count: values.main_career_count.toString(),
sub_career_count: values.sub_career_count.toString(),
enable_mcp: 'false'
}).toString()
);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'progress') {
setAiProgress(data.progress || 0);
setAiMessage(data.message || '');
} else if (data.type === 'done') {
eventSource.close();
setTimeout(() => {
setAiGenerating(false);
message.success('AI新职业生成完成!');
fetchCareers();
}, 1000);
} else if (data.type === 'error') {
eventSource.close();
setAiGenerating(false);
message.error(data.message || '生成失败');
}
} catch (e) {
console.error('解析SSE数据失败:', e);
}
};
eventSource.onerror = () => {
eventSource.close();
setAiGenerating(false);
message.error('连接中断,生成失败');
};
} catch (err: any) {
setAiGenerating(false);
message.error(err.message || '启动生成失败');
}
};
const renderCareerCard = (career: Career) => (
<Card
key={career.id}
title={
<Space>
<TrophyOutlined />
{career.name}
<Tag color={career.source === 'ai' ? 'blue' : 'default'}>
{career.source === 'ai' ? 'AI生成' : '手动创建'}
</Tag>
{career.category && <Tag>{career.category}</Tag>}
</Space>
}
extra={
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => handleOpenModal(career)} />
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(career.id)} />
</Space>
}
style={{ marginBottom: 16 }}
>
<Paragraph ellipsis={{ rows: 2 }}>{career.description || '暂无描述'}</Paragraph>
<Divider style={{ margin: '12px 0' }} />
<Text strong>{career.max_stage}</Text>
<div style={{ maxHeight: 120, overflowY: 'auto', marginTop: 8 }}>
{career.stages.slice(0, 5).map(stage => (
<div key={stage.level} style={{ marginLeft: 16, marginBottom: 4 }}>
<Text type="secondary">{stage.level}. {stage.name}</Text>
{stage.description && <Text type="secondary" style={{ fontSize: 12 }}> - {stage.description}</Text>}
</div>
))}
{career.stages.length > 5 && (
<Text type="secondary" style={{ marginLeft: 16 }}>...{career.stages.length - 5}</Text>
)}
</div>
{career.special_abilities && (
<>
<Divider style={{ margin: '12px 0' }} />
<Text strong></Text>
<Paragraph ellipsis={{ rows: 2 }} style={{ marginTop: 4 }}>{career.special_abilities}</Paragraph>
</>
)}
</Card>
);
const tabItems = [
{
key: 'main',
label: `主职业 (${mainCareers.length})`,
children: mainCareers.length > 0 ? (
<div>{mainCareers.map(renderCareerCard)}</div>
) : (
<Empty description="还没有主职业" />
)
},
{
key: 'sub',
label: `副职业 (${subCareers.length})`,
children: subCareers.length > 0 ? (
<div>{subCareers.map(renderCareerCard)}</div>
) : (
<Empty description="还没有副职业" />
)
}
];
return (
<div style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}>
{/* 固定头部 */}
<div style={{
padding: '16px 16px 0 16px',
flexShrink: 0
}}>
<div style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: '12px'
}}>
<Title level={3} style={{ margin: 0 }}></Title>
<Space wrap>
<Button
type="dashed"
icon={<ThunderboltOutlined />}
onClick={() => {
aiForm.resetFields();
setIsAIModalOpen(true);
}}
>
AI生成新职业
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => handleOpenModal()}
>
</Button>
</Space>
</div>
</div>
{/* 可滚动的内容区域 */}
<div style={{
flex: 1,
overflow: 'auto',
padding: '0 16px 16px 16px'
}}>
<Tabs items={tabItems} />
</div>
{/* 创建/编辑对话框 */}
<Modal
title={editingCareer ? '编辑职业' : '新增职业'}
open={isModalOpen}
onCancel={() => {
setIsModalOpen(false);
form.resetFields();
}}
footer={null}
width={700}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Row gutter={16}>
<Col span={16}>
<Form.Item label="职业名称" name="name" rules={[{ required: true }]}>
<Input placeholder="如:剑修、炼丹师" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="类型" name="type" rules={[{ required: true }]} initialValue="main">
<Select>
<Select.Option value="main"></Select.Option>
<Select.Option value="sub"></Select.Option>
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item label="职业描述" name="description">
<TextArea rows={2} placeholder="描述这个职业..." />
</Form.Item>
<Form.Item label="职业分类" name="category">
<Input placeholder="如:战斗系、生产系、辅助系" />
</Form.Item>
<Form.Item label="职业阶段" name="stages" tooltip="每行一个阶段,格式:1. 阶段名 - 描述">
<TextArea
rows={8}
placeholder="示例:&#10;1. 炼气期 - 初窥门径&#10;2. 筑基期 - 根基稳固&#10;3. 金丹期 - 凝结金丹"
/>
</Form.Item>
<Form.Item label="职业要求" name="requirements">
<TextArea rows={2} placeholder="需要什么条件才能修炼..." />
</Form.Item>
<Form.Item label="特殊能力" name="special_abilities">
<TextArea rows={2} placeholder="这个职业的特殊能力..." />
</Form.Item>
<Form.Item label="世界观规则" name="worldview_rules">
<TextArea rows={2} placeholder="如何融入世界观..." />
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => setIsModalOpen(false)}></Button>
<Button type="primary" htmlType="submit">
{editingCareer ? '更新' : '创建'}
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
{/* AI生成对话框 */}
<Modal
title="AI生成新职业(增量式)"
open={isAIModalOpen}
onCancel={() => setIsAIModalOpen(false)}
footer={null}
>
<Form form={aiForm} layout="vertical" onFinish={handleAIGenerate}>
<Paragraph type="secondary">
AI将分析当前世界观和已有职业
<br />
💡
</Paragraph>
<Divider style={{ margin: '12px 0' }} />
<Form.Item label="本次新增主职业数量" name="main_career_count" initialValue={3}>
<InputNumber min={1} max={10} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="本次新增副职业数量" name="sub_career_count" initialValue={5}>
<InputNumber min={0} max={15} style={{ width: '100%' }} />
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => setIsAIModalOpen(false)}></Button>
<Button type="primary" icon={<ThunderboltOutlined />} htmlType="submit">
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
{/* AI生成进度 */}
<SSEProgressModal
visible={aiGenerating}
progress={aiProgress}
message={aiMessage}
title="AI生成新职业中..."
onCancel={() => setAiGenerating(false)}
/>
</div>
);
}
+123 -13
View File
@@ -261,6 +261,48 @@ export default function Chapters() {
}
};
// 🔔 显示浏览器通知
const showBrowserNotification = (title: string, body: string, type: 'success' | 'error' | 'info' = 'info') => {
// 检查浏览器是否支持通知
if (!('Notification' in window)) {
console.log('浏览器不支持通知功能');
return;
}
// 检查通知权限
if (Notification.permission === 'granted') {
// 选择图标
const icon = type === 'success' ? '/logo.svg' : type === 'error' ? '/favicon.ico' : '/logo.svg';
const notification = new Notification(title, {
body,
icon,
badge: '/favicon.ico',
tag: 'batch-generation', // 相同tag会替换旧通知
requireInteraction: false, // 自动关闭
silent: false, // 播放提示音
});
// 点击通知时聚焦到窗口
notification.onclick = () => {
window.focus();
notification.close();
};
// 5秒后自动关闭
setTimeout(() => {
notification.close();
}, 5000);
} else if (Notification.permission !== 'denied') {
// 如果权限未被明确拒绝,尝试请求权限
Notification.requestPermission().then(permission => {
if (permission === 'granted') {
showBrowserNotification(title, body, type);
}
});
}
};
if (!currentProject) return null;
// 获取人称的中文显示文本
@@ -282,7 +324,24 @@ export default function Chapters() {
c => c.chapter_number < chapter.chapter_number
);
return previousChapters.every(c => c.content && c.content.trim() !== '');
// 检查所有前置章节是否有内容
const allHaveContent = previousChapters.every(c => c.content && c.content.trim() !== '');
if (!allHaveContent) {
return false;
}
// 检查所有前置章节是否分析成功
const allAnalyzed = previousChapters.every(c => {
const task = analysisTasksMap[c.id];
// 如果没有分析任务或分析失败,则不允许生成
if (!task || !task.has_task) {
return false;
}
// 只有completed状态才算分析成功
return task.status === 'completed';
});
return allAnalyzed;
};
const getGenerateDisabledReason = (chapter: Chapter): string => {
@@ -294,6 +353,7 @@ export default function Chapters() {
c => c.chapter_number < chapter.chapter_number
);
// 首先检查是否有未完成内容的章节
const incompleteChapters = previousChapters.filter(
c => !c.content || c.content.trim() === ''
);
@@ -303,6 +363,36 @@ export default function Chapters() {
return `需要先完成前置章节:第 ${numbers}`;
}
// 检查是否有未分析或分析失败的章节
const unanalyzedChapters = previousChapters.filter(c => {
const task = analysisTasksMap[c.id];
if (!task || !task.has_task) {
return true; // 没有分析任务
}
return task.status !== 'completed'; // 分析未完成或失败
});
if (unanalyzedChapters.length > 0) {
const numbers = unanalyzedChapters.map(c => c.chapter_number).join('、');
const reasons = unanalyzedChapters.map(c => {
const task = analysisTasksMap[c.id];
if (!task || !task.has_task) {
return '未分析';
}
if (task.status === 'pending') {
return '等待分析';
}
if (task.status === 'running') {
return '分析中';
}
if (task.status === 'failed') {
return '分析失败';
}
return '状态未知';
});
return `需要先分析前置章节:第 ${numbers} 章 (${reasons.join('、')})`;
}
return '';
};
@@ -638,7 +728,7 @@ export default function Chapters() {
const requestBody: any = {
start_chapter_number: values.startChapterNumber,
count: values.count,
enable_analysis: values.enableAnalysis,
enable_analysis: true,
style_id: styleId,
target_word_count: wordCount,
};
@@ -678,6 +768,13 @@ export default function Chapters() {
message.success(`批量生成任务已创建,预计需要 ${result.estimated_time_minutes} 分钟`);
// 🔔 触发浏览器通知(任务开始)
showBrowserNotification(
'批量生成已启动',
`开始生成 ${result.chapters_to_generate.length} 章,预计需要 ${result.estimated_time_minutes} 分钟`,
'info'
);
// 开始轮询任务状态
startBatchPolling(result.batch_id);
@@ -740,8 +837,20 @@ export default function Chapters() {
if (status.status === 'completed') {
message.success(`批量生成完成!成功生成 ${status.completed}`);
// 🔔 触发浏览器通知
showBrowserNotification(
'批量生成完成',
`${currentProject?.title || '项目'}》成功生成 ${status.completed} 章节`,
'success'
);
} else if (status.status === 'failed') {
message.error(`批量生成失败:${status.error_message || '未知错误'}`);
// 🔔 触发浏览器通知
showBrowserNotification(
'批量生成失败',
status.error_message || '未知错误',
'error'
);
} else if (status.status === 'cancelled') {
message.warning('批量生成已取消');
}
@@ -2199,7 +2308,7 @@ export default function Chapters() {
initialValues={{
startChapterNumber: sortedChapters.find(ch => !ch.content || ch.content.trim() === '')?.chapter_number || 1,
count: 5,
enableAnalysis: false,
enableAnalysis: true, // 强制启用同步分析
styleId: selectedStyleId,
targetWordCount: 3000,
model: selectedModel,
@@ -2323,19 +2432,20 @@ export default function Chapters() {
<Form.Item
label="同步分析"
name="enableAnalysis"
tooltip="开启后每章生成完立即分析,会增加约50%耗时,但能提升后续章节质量"
tooltip="批量生成必须开启同步分析,确保角色职业信息和剧情状态的连贯性"
>
<Radio.Group>
<Radio value={false}>
<Space direction="vertical" size={0}>
<span></span>
<span style={{ fontSize: 12, color: '#666' }}></span>
</Space>
</Radio>
<Radio.Group disabled>
<Radio value={true}>
<Space direction="vertical" size={0}>
<span></span>
<span style={{ fontSize: 12, color: '#ff9800' }}>50%</span>
<span style={{ fontSize: 12, color: '#52c41a' }}>
</span>
<span style={{ fontSize: 12, color: '#52c41a' }}>
</span>
<span style={{ fontSize: 12, color: '#ff9800' }}>
50%
</span>
</Space>
</Radio>
</Radio.Group>
+273 -6
View File
@@ -6,13 +6,21 @@ import { useCharacterSync } from '../store/hooks';
import { characterGridConfig } from '../components/CardStyles';
import { CharacterCard } from '../components/CharacterCard';
import { SSELoadingOverlay } from '../components/SSELoadingOverlay';
import type { Character, CharacterUpdate } from '../types';
import type { Character } from '../types';
import { characterApi } from '../services/api';
import { SSEPostClient } from '../utils/sseClient';
import axios from 'axios';
const { Title } = Typography;
const { TextArea } = Input;
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
interface Career {
id: string;
name: string;
type: 'main' | 'sub';
max_stage: number;
}
export default function Characters() {
const { currentProject, characters } = useStore();
@@ -28,6 +36,8 @@ export default function Characters() {
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [createType, setCreateType] = useState<'character' | 'organization'>('character');
const [editingCharacter, setEditingCharacter] = useState<Character | null>(null);
const [mainCareers, setMainCareers] = useState<Career[]>([]);
const [subCareers, setSubCareers] = useState<Career[]>([]);
const {
refreshCharacters,
@@ -37,11 +47,26 @@ export default function Characters() {
useEffect(() => {
if (currentProject?.id) {
refreshCharacters();
fetchCareers();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentProject?.id]);
const [modal, contextHolder] = Modal.useModal();
const fetchCareers = async () => {
if (!currentProject?.id) return;
try {
const response = await axios.get(`${API_BASE_URL}/api/careers`, {
params: { project_id: currentProject.id },
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
});
setMainCareers(response.data.main_careers || []);
setSubCareers(response.data.sub_careers || []);
} catch (error) {
console.error('获取职业列表失败:', error);
}
};
if (!currentProject) return null;
const handleDeleteCharacter = async (id: string) => {
@@ -170,6 +195,17 @@ export default function Characters() {
createData.appearance = values.appearance;
createData.relationships = values.relationships;
createData.background = values.background;
// 职业字段
if (values.main_career_id) {
createData.main_career_id = values.main_career_id;
createData.main_career_stage = values.main_career_stage || 1;
}
// 处理副职业数据
if (values.sub_career_data && Array.isArray(values.sub_career_data) && values.sub_career_data.length > 0) {
createData.sub_careers = JSON.stringify(values.sub_career_data);
}
} else {
// 组织字段
createData.organization_type = values.organization_type;
@@ -195,21 +231,45 @@ export default function Characters() {
const handleEditCharacter = (character: Character) => {
setEditingCharacter(character);
editForm.setFieldsValue(character);
// 提取副职业数据(包含职业ID和阶段)
const subCareerData = character.sub_careers?.map((sc: any) => ({
career_id: sc.career_id,
stage: sc.stage || 1
})) || [];
editForm.setFieldsValue({
...character,
sub_career_data: subCareerData
});
setIsEditModalOpen(true);
};
const handleUpdateCharacter = async (values: CharacterUpdate) => {
const handleUpdateCharacter = async (values: any) => {
if (!editingCharacter) return;
try {
await characterApi.updateCharacter(editingCharacter.id, values);
const updateData: any = { ...values };
// 处理副职业数据
const subCareerData = updateData.sub_career_data;
delete updateData.sub_career_data;
// 转换为sub_careers格式
if (subCareerData && Array.isArray(subCareerData) && subCareerData.length > 0) {
updateData.sub_careers = JSON.stringify(subCareerData);
} else {
updateData.sub_careers = JSON.stringify([]);
}
await characterApi.updateCharacter(editingCharacter.id, updateData);
message.success('更新成功');
setIsEditModalOpen(false);
editForm.resetFields();
setEditingCharacter(null);
await refreshCharacters();
} catch {
} catch (error) {
console.error('更新失败:', error);
message.error('更新失败');
}
};
@@ -657,6 +717,109 @@ export default function Characters() {
<TextArea rows={3} placeholder={`描述${editingCharacter?.is_organization ? '组织' : '角色'}的背景故事...`} />
</Form.Item>
{!editingCharacter?.is_organization && (mainCareers.length > 0 || subCareers.length > 0) && (
<>
<Divider></Divider>
{mainCareers.length > 0 && (
<Row gutter={16}>
<Col span={16}>
<Form.Item label="主职业" name="main_career_id" tooltip="角色的主要修炼职业">
<Select placeholder="选择主职业" allowClear>
{mainCareers.map(career => (
<Select.Option key={career.id} value={career.id}>
{career.name}{career.max_stage}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="当前阶段" name="main_career_stage" tooltip="主职业当前修炼到的阶段">
<InputNumber
min={1}
max={editForm.getFieldValue('main_career_id') ?
mainCareers.find(c => c.id === editForm.getFieldValue('main_career_id'))?.max_stage || 10
: 10}
style={{ width: '100%' }}
placeholder="阶段"
/>
</Form.Item>
</Col>
</Row>
)}
{subCareers.length > 0 && (
<Form.List name="sub_career_data">
{(fields, { add, remove }) => (
<>
<div style={{ marginBottom: 8 }}>
<Typography.Text strong></Typography.Text>
</div>
<div style={{ maxHeight: '100px', overflowY: 'auto', overflowX: 'hidden', marginBottom: 8, paddingRight: 8 }}>
{fields.map((field) => (
<Row key={field.key} gutter={8} style={{ marginBottom: 8 }}>
<Col span={16}>
<Form.Item
{...field}
name={[field.name, 'career_id']}
rules={[{ required: true, message: '请选择副职业' }]}
style={{ marginBottom: 0 }}
>
<Select placeholder="选择副职业">
{subCareers.map(career => (
<Select.Option key={career.id} value={career.id}>
{career.name}{career.max_stage}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item
{...field}
name={[field.name, 'stage']}
rules={[{ required: true, message: '请输入阶段' }]}
style={{ marginBottom: 0 }}
>
<InputNumber
min={1}
max={(() => {
const careerId = editForm.getFieldValue(['sub_career_data', field.name, 'career_id']);
const career = subCareers.find(c => c.id === careerId);
return career?.max_stage || 10;
})()}
placeholder="阶段"
style={{ width: '100%' }}
/>
</Form.Item>
</Col>
<Col span={2}>
<Button
type="text"
danger
onClick={() => remove(field.name)}
style={{ width: '100%' }}
>
</Button>
</Col>
</Row>
))}
</div>
<Button
type="dashed"
onClick={() => add({ career_id: undefined, stage: 1 })}
block
style={{ marginTop: 8 }}
>
+
</Button>
</>
)}
</Form.List>
)}
</>
)}
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => {
@@ -747,6 +910,110 @@ export default function Characters() {
<Form.Item label="角色背景" name="background">
<TextArea rows={3} placeholder="描述角色的背景故事..." />
</Form.Item>
{/* 职业信息 */}
{(mainCareers.length > 0 || subCareers.length > 0) && (
<>
<Divider></Divider>
{mainCareers.length > 0 && (
<Row gutter={16}>
<Col span={16}>
<Form.Item label="主职业" name="main_career_id" tooltip="角色的主要修炼职业">
<Select placeholder="选择主职业" allowClear>
{mainCareers.map(career => (
<Select.Option key={career.id} value={career.id}>
{career.name}{career.max_stage}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="当前阶段" name="main_career_stage" tooltip="主职业当前修炼到的阶段">
<InputNumber
min={1}
max={createForm.getFieldValue('main_career_id') ?
mainCareers.find(c => c.id === createForm.getFieldValue('main_career_id'))?.max_stage || 10
: 10}
style={{ width: '100%' }}
placeholder="阶段"
/>
</Form.Item>
</Col>
</Row>
)}
{subCareers.length > 0 && (
<Form.List name="sub_career_data">
{(fields, { add, remove }) => (
<>
<div style={{ marginBottom: 8 }}>
<Typography.Text strong></Typography.Text>
</div>
<div style={{ maxHeight: '100px', overflowY: 'auto', overflowX: 'hidden', marginBottom: 8, paddingRight: 8 }}>
{fields.map((field) => (
<Row key={field.key} gutter={8} style={{ marginBottom: 8 }}>
<Col span={16}>
<Form.Item
{...field}
name={[field.name, 'career_id']}
rules={[{ required: true, message: '请选择副职业' }]}
style={{ marginBottom: 0 }}
>
<Select placeholder="选择副职业">
{subCareers.map(career => (
<Select.Option key={career.id} value={career.id}>
{career.name}{career.max_stage}
</Select.Option>
))}
</Select>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item
{...field}
name={[field.name, 'stage']}
rules={[{ required: true, message: '请输入阶段' }]}
style={{ marginBottom: 0 }}
>
<InputNumber
min={1}
max={(() => {
const careerId = createForm.getFieldValue(['sub_career_data', field.name, 'career_id']);
const career = subCareers.find(c => c.id === careerId);
return career?.max_stage || 10;
})()}
placeholder="阶段"
style={{ width: '100%' }}
/>
</Form.Item>
</Col>
<Col span={2}>
<Button
type="text"
danger
onClick={() => remove(field.name)}
style={{ width: '100%' }}
>
</Button>
</Col>
</Row>
))}
</div>
<Button
type="dashed"
onClick={() => add({ career_id: undefined, stage: 1 })}
block
style={{ marginTop: 8 }}
>
+
</Button>
</>
)}
</Form.List>
)}
</>
)}
</>
) : (
<>
+7
View File
@@ -15,6 +15,7 @@ import {
EditOutlined,
FundOutlined,
HeartOutlined,
TrophyOutlined,
} from '@ant-design/icons';
import { useStore } from '../store';
import { useCharacterSync, useOutlineSync, useChapterSync } from '../store/hooks';
@@ -99,6 +100,11 @@ export default function ProjectDetail() {
icon: <GlobalOutlined />,
label: <Link to={`/project/${projectId}/world-setting`}></Link>,
},
{
key: 'careers',
icon: <TrophyOutlined />,
label: <Link to={`/project/${projectId}/careers`}></Link>,
},
{
key: 'characters',
icon: <TeamOutlined />,
@@ -150,6 +156,7 @@ export default function ProjectDetail() {
const selectedKey = useMemo(() => {
const path = location.pathname;
if (path.includes('/world-setting')) return 'world-setting';
if (path.includes('/careers')) return 'careers';
if (path.includes('/relationships')) return 'relationships';
if (path.includes('/organizations')) return 'organizations';
if (path.includes('/outline')) return 'outline';