Files
MuMuAINovel/frontend/src/components/CharacterCareerCard.tsx
T

398 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;