update: 1.新增职业管理模块和角色职业关联 2.章节分析自动更新角色职业状态 3.优化章节生成的角色信息构建 4.批量生成强制开启同步分析 5.章节内容批量生成增加系统提示
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -8,6 +8,7 @@ import ProjectDetail from './pages/ProjectDetail';
|
||||
import WorldSetting from './pages/WorldSetting';
|
||||
import Outline from './pages/Outline';
|
||||
import Characters from './pages/Characters';
|
||||
import Careers from './pages/Careers';
|
||||
import Relationships from './pages/Relationships';
|
||||
import Organizations from './pages/Organizations';
|
||||
import Chapters from './pages/Chapters';
|
||||
@@ -51,6 +52,7 @@ function App() {
|
||||
<Route path="/project/:projectId" element={<ProtectedRoute><ProjectDetail /></ProtectedRoute>}>
|
||||
<Route index element={<Navigate to="world-setting" replace />} />
|
||||
<Route path="world-setting" element={<WorldSetting />} />
|
||||
<Route path="careers" element={<Careers />} />
|
||||
<Route path="outline" element={<Outline />} />
|
||||
<Route path="characters" element={<Characters />} />
|
||||
<Route path="relationships" element={<Relationships />} />
|
||||
|
||||
@@ -32,6 +32,7 @@ type GenerationStep = 'pending' | 'processing' | 'completed' | 'error';
|
||||
|
||||
interface GenerationSteps {
|
||||
worldBuilding: GenerationStep;
|
||||
careers: GenerationStep;
|
||||
characters: GenerationStep;
|
||||
outline: GenerationStep;
|
||||
}
|
||||
@@ -55,6 +56,7 @@ export const AIProjectGenerator: React.FC<AIProjectGeneratorProps> = ({
|
||||
const [errorDetails, setErrorDetails] = useState<string>('');
|
||||
const [generationSteps, setGenerationSteps] = useState<GenerationSteps>({
|
||||
worldBuilding: 'pending',
|
||||
careers: 'pending',
|
||||
characters: 'pending',
|
||||
outline: 'pending'
|
||||
});
|
||||
@@ -126,12 +128,12 @@ export const AIProjectGenerator: React.FC<AIProjectGeneratorProps> = ({
|
||||
if (wizardStep === 0) {
|
||||
// 从世界观开始
|
||||
message.info('从世界观步骤开始生成...');
|
||||
setGenerationSteps({ worldBuilding: 'processing', characters: 'pending', outline: 'pending' });
|
||||
setGenerationSteps({ worldBuilding: 'processing', careers: 'pending', characters: 'pending', outline: 'pending' });
|
||||
await resumeFromWorldBuilding(data);
|
||||
} else if (wizardStep === 1) {
|
||||
// 世界观已完成,从角色开始
|
||||
message.info('世界观已完成,从角色步骤继续...');
|
||||
setGenerationSteps({ worldBuilding: 'completed', characters: 'processing', outline: 'pending' });
|
||||
setGenerationSteps({ worldBuilding: 'completed', careers: 'completed', characters: 'processing', outline: 'pending' });
|
||||
|
||||
// 获取世界观数据
|
||||
const worldResult = {
|
||||
@@ -148,7 +150,7 @@ export const AIProjectGenerator: React.FC<AIProjectGeneratorProps> = ({
|
||||
} else if (wizardStep === 2) {
|
||||
// 世界观和角色已完成,从大纲开始
|
||||
message.info('世界观和角色已完成,从大纲步骤继续...');
|
||||
setGenerationSteps({ worldBuilding: 'completed', characters: 'completed', outline: 'processing' });
|
||||
setGenerationSteps({ worldBuilding: 'completed', careers: 'completed', characters: 'completed', outline: 'processing' });
|
||||
setProgress(66);
|
||||
await resumeFromOutline(data, projectIdParam);
|
||||
} else {
|
||||
@@ -334,13 +336,31 @@ export const AIProjectGenerator: React.FC<AIProjectGeneratorProps> = ({
|
||||
},
|
||||
{
|
||||
onProgress: (msg, prog) => {
|
||||
setProgress(Math.floor(prog / 3));
|
||||
// 世界观生成占0%-20%,职业生成占20%-30%
|
||||
const baseProgress = Math.floor(prog / 5);
|
||||
setProgress(baseProgress);
|
||||
setProgressMessage(msg);
|
||||
|
||||
// 检测职业体系生成阶段 - 必须包含"职业体系"才算职业阶段
|
||||
if (msg.includes('职业体系')) {
|
||||
if (msg.includes('开始') || msg.includes('生成')) {
|
||||
// 职业开始时,世界观应该已完成
|
||||
setGenerationSteps(prev => ({
|
||||
...prev,
|
||||
worldBuilding: 'completed',
|
||||
careers: 'processing'
|
||||
}));
|
||||
}
|
||||
if (msg.includes('完成') || msg.includes('✅')) {
|
||||
setGenerationSteps(prev => ({ ...prev, careers: 'completed' }));
|
||||
}
|
||||
}
|
||||
},
|
||||
onResult: (result) => {
|
||||
setProjectId(result.project_id);
|
||||
setWorldBuildingResult(result);
|
||||
setGenerationSteps(prev => ({ ...prev, worldBuilding: 'completed' }));
|
||||
// 职业体系状态已在onProgress中更新
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('世界观生成失败:', error);
|
||||
@@ -383,7 +403,8 @@ export const AIProjectGenerator: React.FC<AIProjectGeneratorProps> = ({
|
||||
},
|
||||
{
|
||||
onProgress: (msg, prog) => {
|
||||
setProgress(33 + Math.floor(prog / 3));
|
||||
// 角色生成占40%-70%
|
||||
setProgress(40 + Math.floor(prog * 0.3));
|
||||
setProgressMessage(msg);
|
||||
},
|
||||
onResult: (result) => {
|
||||
@@ -416,7 +437,8 @@ export const AIProjectGenerator: React.FC<AIProjectGeneratorProps> = ({
|
||||
},
|
||||
{
|
||||
onProgress: (msg, prog) => {
|
||||
setProgress(66 + Math.floor(prog / 3));
|
||||
// 大纲生成占70%-100%
|
||||
setProgress(70 + Math.floor(prog * 0.3));
|
||||
setProgressMessage(msg);
|
||||
},
|
||||
onResult: () => {
|
||||
@@ -511,8 +533,23 @@ export const AIProjectGenerator: React.FC<AIProjectGeneratorProps> = ({
|
||||
},
|
||||
{
|
||||
onProgress: (msg, prog) => {
|
||||
setProgress(Math.floor(prog / 3));
|
||||
const baseProgress = Math.floor(prog / 5);
|
||||
setProgress(baseProgress);
|
||||
setProgressMessage(msg);
|
||||
|
||||
// 检测职业体系生成阶段
|
||||
if (msg.includes('职业体系')) {
|
||||
if (msg.includes('开始') || msg.includes('生成')) {
|
||||
setGenerationSteps(prev => ({
|
||||
...prev,
|
||||
worldBuilding: 'completed',
|
||||
careers: 'processing'
|
||||
}));
|
||||
}
|
||||
if (msg.includes('完成') || msg.includes('✅')) {
|
||||
setGenerationSteps(prev => ({ ...prev, careers: 'completed' }));
|
||||
}
|
||||
}
|
||||
},
|
||||
onResult: (result) => {
|
||||
setProjectId(result.project_id);
|
||||
@@ -755,6 +792,7 @@ export const AIProjectGenerator: React.FC<AIProjectGeneratorProps> = ({
|
||||
};
|
||||
|
||||
const hasError = generationSteps.worldBuilding === 'error' ||
|
||||
generationSteps.careers === 'error' ||
|
||||
generationSteps.characters === 'error' ||
|
||||
generationSteps.outline === 'error';
|
||||
|
||||
@@ -843,6 +881,7 @@ export const AIProjectGenerator: React.FC<AIProjectGeneratorProps> = ({
|
||||
>
|
||||
{[
|
||||
{ key: 'worldBuilding', label: '生成世界观', step: generationSteps.worldBuilding },
|
||||
{ key: 'careers', label: '生成职业体系', step: generationSteps.careers },
|
||||
{ key: 'characters', label: '生成角色', step: generationSteps.characters },
|
||||
{ key: 'outline', label: '生成大纲', step: generationSteps.outline },
|
||||
].map(({ key, label, step }) => {
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, Button, Modal, Form, Select, InputNumber, Input, message, Progress, Tag, Space, Divider, Typography } from 'antd';
|
||||
import { EditOutlined, PlusOutlined, DeleteOutlined, TrophyOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Text, Paragraph } = Typography;
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
|
||||
|
||||
interface CareerDetail {
|
||||
id: string;
|
||||
character_id: string;
|
||||
career_id: string;
|
||||
career_name: string;
|
||||
career_type: 'main' | 'sub';
|
||||
current_stage: number;
|
||||
stage_name: string;
|
||||
stage_description?: string;
|
||||
stage_progress: number;
|
||||
max_stage: number;
|
||||
started_at?: string;
|
||||
reached_current_stage_at?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
interface Career {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'main' | 'sub';
|
||||
max_stage: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
characterId: string;
|
||||
projectId: string;
|
||||
editable?: boolean;
|
||||
onUpdate?: () => void;
|
||||
}
|
||||
|
||||
export const CharacterCareerCard: React.FC<Props> = ({
|
||||
characterId,
|
||||
projectId,
|
||||
editable = false,
|
||||
onUpdate
|
||||
}) => {
|
||||
const [mainCareer, setMainCareer] = useState<CareerDetail | null>(null);
|
||||
const [subCareers, setSubCareers] = useState<CareerDetail[]>([]);
|
||||
const [allCareers, setAllCareers] = useState<Career[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [isMainModalOpen, setIsMainModalOpen] = useState(false);
|
||||
const [isSubModalOpen, setIsSubModalOpen] = useState(false);
|
||||
const [isProgressModalOpen, setIsProgressModalOpen] = useState(false);
|
||||
const [selectedCareer, setSelectedCareer] = useState<CareerDetail | null>(null);
|
||||
|
||||
const [mainForm] = Form.useForm();
|
||||
const [subForm] = Form.useForm();
|
||||
const [progressForm] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
fetchCharacterCareers();
|
||||
if (editable) {
|
||||
fetchAllCareers();
|
||||
}
|
||||
}, [characterId]);
|
||||
|
||||
const fetchCharacterCareers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get(
|
||||
`${API_BASE_URL}/api/careers/character/${characterId}/careers`,
|
||||
{ headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } }
|
||||
);
|
||||
setMainCareer(response.data.main_career || null);
|
||||
setSubCareers(response.data.sub_careers || []);
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.detail || '获取职业信息失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAllCareers = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE_URL}/api/careers`, {
|
||||
params: { project_id: projectId },
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
const main = response.data.main_careers || [];
|
||||
const sub = response.data.sub_careers || [];
|
||||
setAllCareers([...main, ...sub]);
|
||||
} catch (error: any) {
|
||||
console.error('获取职业列表失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetMainCareer = async (values: any) => {
|
||||
try {
|
||||
await axios.post(
|
||||
`${API_BASE_URL}/api/careers/character/${characterId}/careers/main`,
|
||||
values,
|
||||
{ headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } }
|
||||
);
|
||||
message.success('主职业设置成功');
|
||||
setIsMainModalOpen(false);
|
||||
mainForm.resetFields();
|
||||
fetchCharacterCareers();
|
||||
onUpdate?.();
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.detail || '设置主职业失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSubCareer = async (values: any) => {
|
||||
try {
|
||||
await axios.post(
|
||||
`${API_BASE_URL}/api/careers/character/${characterId}/careers/sub`,
|
||||
values,
|
||||
{ headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } }
|
||||
);
|
||||
message.success('副职业添加成功');
|
||||
setIsSubModalOpen(false);
|
||||
subForm.resetFields();
|
||||
fetchCharacterCareers();
|
||||
onUpdate?.();
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.detail || '添加副职业失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateProgress = async (values: any) => {
|
||||
if (!selectedCareer) return;
|
||||
|
||||
try {
|
||||
await axios.put(
|
||||
`${API_BASE_URL}/api/careers/character/${characterId}/careers/${selectedCareer.career_id}/stage`,
|
||||
values,
|
||||
{ headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } }
|
||||
);
|
||||
message.success('职业阶段更新成功');
|
||||
setIsProgressModalOpen(false);
|
||||
progressForm.resetFields();
|
||||
fetchCharacterCareers();
|
||||
onUpdate?.();
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.detail || '更新职业阶段失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveSubCareer = (careerId: string) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要移除这个副职业吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await axios.delete(
|
||||
`${API_BASE_URL}/api/careers/character/${characterId}/careers/${careerId}`,
|
||||
{ headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } }
|
||||
);
|
||||
message.success('副职业删除成功');
|
||||
fetchCharacterCareers();
|
||||
onUpdate?.();
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.detail || '删除副职业失败');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const openEditProgress = (career: CareerDetail) => {
|
||||
setSelectedCareer(career);
|
||||
progressForm.setFieldsValue({
|
||||
current_stage: career.current_stage,
|
||||
stage_progress: career.stage_progress,
|
||||
reached_current_stage_at: career.reached_current_stage_at || '',
|
||||
notes: career.notes || ''
|
||||
});
|
||||
setIsProgressModalOpen(true);
|
||||
};
|
||||
|
||||
const renderCareerInfo = (career: CareerDetail, isMain: boolean = false) => (
|
||||
<div key={career.id} style={{ marginBottom: 16 }}>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Space>
|
||||
<TrophyOutlined style={{ color: isMain ? '#1890ff' : '#8c8c8c' }} />
|
||||
<Text strong={isMain}>{career.career_name}</Text>
|
||||
{isMain && <Tag color="blue">主</Tag>}
|
||||
</Space>
|
||||
{editable && (
|
||||
<Space>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => openEditProgress(career)} />
|
||||
{!isMain && (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleRemoveSubCareer(career.career_id)}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
<div style={{ marginLeft: 24, marginTop: 8 }}>
|
||||
<Text type="secondary">
|
||||
{career.stage_name}(第{career.current_stage}/{career.max_stage}阶段)
|
||||
</Text>
|
||||
{career.stage_description && (
|
||||
<Paragraph type="secondary" style={{ fontSize: 12, marginTop: 4 }}>
|
||||
{career.stage_description}
|
||||
</Paragraph>
|
||||
)}
|
||||
<Progress
|
||||
percent={career.stage_progress}
|
||||
size="small"
|
||||
style={{ marginTop: 8 }}
|
||||
format={(percent) => `${percent}%`}
|
||||
/>
|
||||
{career.started_at && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
开始时间:{career.started_at}
|
||||
</Text>
|
||||
)}
|
||||
{career.notes && (
|
||||
<Paragraph type="secondary" style={{ fontSize: 12, marginTop: 4 }}>
|
||||
备注:{career.notes}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <Card loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<TrophyOutlined />
|
||||
职业信息
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
editable && !mainCareer && (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
mainForm.resetFields();
|
||||
setIsMainModalOpen(true);
|
||||
}}
|
||||
>
|
||||
设置主职业
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{mainCareer ? (
|
||||
<>
|
||||
{renderCareerInfo(mainCareer, true)}
|
||||
|
||||
{subCareers.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Text type="secondary">副职业</Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{subCareers.map(career => renderCareerInfo(career, false))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{editable && subCareers.length < 5 && (
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
subForm.resetFields();
|
||||
setIsSubModalOpen(true);
|
||||
}}
|
||||
>
|
||||
添加副职业
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: '20px 0' }}>
|
||||
暂无职业信息
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 设置主职业 */}
|
||||
<Modal
|
||||
title="设置主职业"
|
||||
open={isMainModalOpen}
|
||||
onCancel={() => setIsMainModalOpen(false)}
|
||||
footer={null}
|
||||
>
|
||||
<Form form={mainForm} layout="vertical" onFinish={handleSetMainCareer}>
|
||||
<Form.Item label="选择主职业" name="career_id" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择职业">
|
||||
{allCareers.filter(c => c.type === 'main').map(career => (
|
||||
<Select.Option key={career.id} value={career.id}>
|
||||
{career.name}({career.max_stage}个阶段)
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="当前阶段" name="current_stage" initialValue={1}>
|
||||
<InputNumber min={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="开始时间" name="started_at">
|
||||
<Input placeholder="如:修仙历3000年" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||
<Button onClick={() => setIsMainModalOpen(false)}>取消</Button>
|
||||
<Button type="primary" htmlType="submit">确定</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 添加副职业 */}
|
||||
<Modal
|
||||
title="添加副职业"
|
||||
open={isSubModalOpen}
|
||||
onCancel={() => setIsSubModalOpen(false)}
|
||||
footer={null}
|
||||
>
|
||||
<Form form={subForm} layout="vertical" onFinish={handleAddSubCareer}>
|
||||
<Form.Item label="选择副职业" name="career_id" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择职业">
|
||||
{allCareers.filter(c => c.type === 'sub').map(career => (
|
||||
<Select.Option key={career.id} value={career.id}>
|
||||
{career.name}({career.max_stage}个阶段)
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="当前阶段" name="current_stage" initialValue={1}>
|
||||
<InputNumber min={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="开始时间" name="started_at">
|
||||
<Input placeholder="如:修仙历3000年" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||
<Button onClick={() => setIsSubModalOpen(false)}>取消</Button>
|
||||
<Button type="primary" htmlType="submit">添加</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 更新职业进度 */}
|
||||
<Modal
|
||||
title="更新职业阶段"
|
||||
open={isProgressModalOpen}
|
||||
onCancel={() => setIsProgressModalOpen(false)}
|
||||
footer={null}
|
||||
>
|
||||
{selectedCareer && (
|
||||
<Form form={progressForm} layout="vertical" onFinish={handleUpdateProgress}>
|
||||
<Text>职业:{selectedCareer.career_name}</Text>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<Form.Item label="当前阶段" name="current_stage" rules={[{ required: true }]}>
|
||||
<InputNumber min={1} max={selectedCareer.max_stage} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="阶段进度(0-100)" name="stage_progress" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={100} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="到达时间" name="reached_current_stage_at">
|
||||
<Input placeholder="如:修仙历3001年" />
|
||||
</Form.Item>
|
||||
<Form.Item label="备注" name="notes">
|
||||
<TextArea rows={2} placeholder="如:突破至金丹期" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||
<Button onClick={() => setIsProgressModalOpen(false)}>取消</Button>
|
||||
<Button type="primary" htmlType="submit">更新</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CharacterCareerCard;
|
||||
@@ -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="示例: 1. 炼气期 - 初窥门径 2. 筑基期 - 根基稳固 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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -217,6 +217,13 @@ export interface Character {
|
||||
location?: string;
|
||||
motto?: string;
|
||||
color?: string;
|
||||
// 职业相关字段
|
||||
main_career_id?: string;
|
||||
main_career_stage?: number;
|
||||
sub_careers?: Array<{
|
||||
career_id: string;
|
||||
stage: number;
|
||||
}>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user