0.9.9
parent
79a780a4db
commit
f3a5f71e57
|
|
@ -8,6 +8,8 @@ import { Dashboard } from './pages/admin/Dashboard';
|
|||
import { CelestialBodies } from './pages/admin/CelestialBodies';
|
||||
import { StaticData } from './pages/admin/StaticData';
|
||||
import { Users } from './pages/admin/Users';
|
||||
import { NASADownload } from './pages/admin/NASADownload';
|
||||
import { SystemSettings } from './pages/admin/SystemSettings';
|
||||
import { auth } from './utils/auth';
|
||||
import { ToastProvider } from './contexts/ToastContext';
|
||||
import App from './App';
|
||||
|
|
@ -45,7 +47,8 @@ export function Router() {
|
|||
<Route path="celestial-bodies" element={<CelestialBodies />} />
|
||||
<Route path="static-data" element={<StaticData />} />
|
||||
<Route path="users" element={<Users />} />
|
||||
<Route path="nasa-data" element={<div><h1>NASA数据下载管理</h1><p>开发中...</p></div>} />
|
||||
<Route path="nasa-data" element={<NASADownload />} />
|
||||
<Route path="settings" element={<SystemSettings />} />
|
||||
</Route>
|
||||
|
||||
{/* Fallback */}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Search, Globe, Rocket } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, ChevronDown, ChevronUp, Search, Globe, Rocket, Moon, Asterisk } from 'lucide-react';
|
||||
import type { CelestialBody } from '../types';
|
||||
|
||||
interface ProbeListProps {
|
||||
|
|
@ -13,6 +13,7 @@ interface ProbeListProps {
|
|||
export function ProbeList({ probes, planets, onBodySelect, selectedBody, onResetCamera }: ProbeListProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [expandedGroup, setExpandedGroup] = useState<string | null>(null); // 只允许一个分组展开
|
||||
|
||||
// Calculate distance for sorting
|
||||
const calculateDistance = (body: CelestialBody) => {
|
||||
|
|
@ -22,7 +23,7 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
|
|||
|
||||
const processBodies = (list: CelestialBody[]) => {
|
||||
return list
|
||||
.filter(b =>
|
||||
.filter(b =>
|
||||
(b.name_zh || b.name).toLowerCase().includes(searchTerm.toLowerCase()) &&
|
||||
b.type !== 'star' // Exclude Sun from list
|
||||
)
|
||||
|
|
@ -33,8 +34,19 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
|
|||
.sort((a, b) => a.distance - b.distance);
|
||||
};
|
||||
|
||||
const planetList = processBodies(planets);
|
||||
const probeList = processBodies(probes);
|
||||
// Group bodies by type
|
||||
const allBodies = [...planets, ...probes];
|
||||
const processedBodies = processBodies(allBodies);
|
||||
|
||||
const planetList = processedBodies.filter(({ body }) => body.type === 'planet');
|
||||
const dwarfPlanetList = processedBodies.filter(({ body }) => body.type === 'dwarf_planet');
|
||||
const satelliteList = processedBodies.filter(({ body }) => body.type === 'satellite');
|
||||
const probeList = processedBodies.filter(({ body }) => body.type === 'probe');
|
||||
|
||||
const toggleGroup = (groupName: string) => {
|
||||
// 如果点击的是当前展开的分组,则收起;否则切换到新分组
|
||||
setExpandedGroup(prev => prev === groupName ? null : groupName);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -78,44 +90,69 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
|
|||
</div>
|
||||
|
||||
{/* List Content */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-4">
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-2">
|
||||
{/* Planets Group */}
|
||||
<div>
|
||||
<div className="px-2 mb-2 flex items-center gap-2 text-[10px] font-bold text-gray-400 uppercase tracking-wider"> {/* text-xs -> text-[10px] */}
|
||||
<Globe size={12} />
|
||||
行星 ({planetList.length})
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{planetList.map(({ body, distance }) => (
|
||||
<BodyItem
|
||||
key={body.id}
|
||||
body={body}
|
||||
distance={distance}
|
||||
isSelected={selectedBody?.id === body.id}
|
||||
onClick={() => onBodySelect(body)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{planetList.length > 0 && (
|
||||
<BodyGroup
|
||||
title="行星"
|
||||
icon={<Globe size={12} />}
|
||||
count={planetList.length}
|
||||
bodies={planetList}
|
||||
isExpanded={expandedGroup === 'planet'}
|
||||
onToggle={() => toggleGroup('planet')}
|
||||
selectedBody={selectedBody}
|
||||
onBodySelect={onBodySelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Dwarf Planets Group */}
|
||||
{dwarfPlanetList.length > 0 && (
|
||||
<BodyGroup
|
||||
title="矮行星"
|
||||
icon={<Asterisk size={12} />}
|
||||
count={dwarfPlanetList.length}
|
||||
bodies={dwarfPlanetList}
|
||||
isExpanded={expandedGroup === 'dwarf_planet'}
|
||||
onToggle={() => toggleGroup('dwarf_planet')}
|
||||
selectedBody={selectedBody}
|
||||
onBodySelect={onBodySelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Satellites Group */}
|
||||
{satelliteList.length > 0 && (
|
||||
<BodyGroup
|
||||
title="卫星"
|
||||
icon={<Moon size={12} />}
|
||||
count={satelliteList.length}
|
||||
bodies={satelliteList}
|
||||
isExpanded={expandedGroup === 'satellite'}
|
||||
onToggle={() => toggleGroup('satellite')}
|
||||
selectedBody={selectedBody}
|
||||
onBodySelect={onBodySelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Probes Group */}
|
||||
<div>
|
||||
<div className="px-2 mb-2 flex items-center gap-2 text-[10px] font-bold text-gray-400 uppercase tracking-wider"> {/* text-xs -> text-[10px] */}
|
||||
<Rocket size={12} />
|
||||
探测器 ({probeList.length})
|
||||
{probeList.length > 0 && (
|
||||
<BodyGroup
|
||||
title="探测器"
|
||||
icon={<Rocket size={12} />}
|
||||
count={probeList.length}
|
||||
bodies={probeList}
|
||||
isExpanded={expandedGroup === 'probe'}
|
||||
onToggle={() => toggleGroup('probe')}
|
||||
selectedBody={selectedBody}
|
||||
onBodySelect={onBodySelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* No results message */}
|
||||
{processedBodies.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500 text-xs">
|
||||
未找到匹配的天体
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{probeList.map(({ body, distance }) => (
|
||||
<BodyItem
|
||||
key={body.id}
|
||||
body={body}
|
||||
distance={distance}
|
||||
isSelected={selectedBody?.id === body.id}
|
||||
onClick={() => onBodySelect(body)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -123,7 +160,7 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
|
|||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className={`
|
||||
absolute top-0 ${isCollapsed ? 'left-0' : '-right-4'}
|
||||
absolute top-0 ${isCollapsed ? 'left-0' : '-right-4'}
|
||||
w-8 h-8 flex items-center justify-center
|
||||
bg-black/80 backdrop-blur-md border border-white/10 rounded-full
|
||||
text-white hover:bg-blue-600 transition-all shadow-lg z-50
|
||||
|
|
@ -137,6 +174,59 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
|
|||
);
|
||||
}
|
||||
|
||||
// Group component for collapsible body lists
|
||||
function BodyGroup({
|
||||
title,
|
||||
icon,
|
||||
count,
|
||||
bodies,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
selectedBody,
|
||||
onBodySelect
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
count: number;
|
||||
bodies: Array<{ body: CelestialBody; distance: number }>;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
selectedBody: CelestialBody | null;
|
||||
onBodySelect: (body: CelestialBody) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="border border-white/5 rounded-lg overflow-hidden bg-white/5">
|
||||
{/* Group Header */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full px-2 py-2 flex items-center justify-between hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[10px] font-bold text-gray-300 uppercase tracking-wider">
|
||||
{icon}
|
||||
{title}
|
||||
<span className="text-gray-500">({count})</span>
|
||||
</div>
|
||||
{isExpanded ? <ChevronUp size={14} className="text-gray-400" /> : <ChevronDown size={14} className="text-gray-400" />}
|
||||
</button>
|
||||
|
||||
{/* Group Content */}
|
||||
{isExpanded && (
|
||||
<div className="px-1 pb-1 space-y-1">
|
||||
{bodies.map(({ body, distance }) => (
|
||||
<BodyItem
|
||||
key={body.id}
|
||||
body={body}
|
||||
distance={distance}
|
||||
isSelected={selectedBody?.id === body.id}
|
||||
onClick={() => onBodySelect(body)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BodyItem({ body, distance, isSelected, onClick }: {
|
||||
body: CelestialBody,
|
||||
distance: number,
|
||||
|
|
|
|||
|
|
@ -130,8 +130,7 @@ export function DataTable<T extends object>({
|
|||
)}
|
||||
</Space>
|
||||
}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
bordered={false}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
<Table
|
||||
columns={tableColumns}
|
||||
|
|
|
|||
|
|
@ -165,6 +165,8 @@ export function AdminLayout() {
|
|||
padding: 24,
|
||||
background: '#fff',
|
||||
minHeight: 280,
|
||||
overflow: 'auto',
|
||||
maxHeight: 'calc(100vh - 64px - 32px)',
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
* Celestial Bodies Management Page
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { message, Modal, Form, Input, Select, Switch, InputNumber } from 'antd';
|
||||
import { message, Modal, Form, Input, Select, Switch, InputNumber, Tag, Badge, Descriptions, Button, Space, Alert } from 'antd';
|
||||
import { CheckCircleOutlined, CloseCircleOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { DataTable } from '../../components/admin/DataTable';
|
||||
import { request } from '../../utils/request';
|
||||
|
|
@ -14,6 +15,15 @@ interface CelestialBody {
|
|||
type: string;
|
||||
description: string;
|
||||
is_active: boolean;
|
||||
resources?: {
|
||||
[key: string]: Array<{
|
||||
id: number;
|
||||
file_path: string;
|
||||
file_size: number;
|
||||
mime_type: string;
|
||||
}>;
|
||||
};
|
||||
has_resources?: boolean;
|
||||
}
|
||||
|
||||
export function CelestialBodies() {
|
||||
|
|
@ -23,6 +33,8 @@ export function CelestialBodies() {
|
|||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingRecord, setEditingRecord] = useState<CelestialBody | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
|
@ -57,11 +69,65 @@ export function CelestialBodies() {
|
|||
const handleAdd = () => {
|
||||
setEditingRecord(null);
|
||||
form.resetFields();
|
||||
setSearchQuery('');
|
||||
// Default values
|
||||
form.setFieldsValue({ is_active: true, type: 'probe' });
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// Search NASA Horizons by name
|
||||
const handleNASASearch = async () => {
|
||||
if (!searchQuery.trim()) {
|
||||
message.warning('请输入天体名称或ID');
|
||||
return;
|
||||
}
|
||||
|
||||
setSearching(true);
|
||||
try {
|
||||
const { data: result } = await request.get('/celestial/search', {
|
||||
params: { name: searchQuery }
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// Auto-fill form with search results
|
||||
form.setFieldsValue({
|
||||
id: result.data.id,
|
||||
name: result.data.name,
|
||||
});
|
||||
|
||||
// Check if ID looks like it might not be a proper numeric ID
|
||||
const isNumericId = /^-?\d+$/.test(result.data.id);
|
||||
|
||||
if (isNumericId) {
|
||||
message.success(`找到天体: ${result.data.full_name}`);
|
||||
} else {
|
||||
// Warn user that ID might need manual correction
|
||||
Modal.warning({
|
||||
title: '找到天体,但请确认 ID',
|
||||
content: (
|
||||
<div>
|
||||
<p>找到天体: <strong>{result.data.full_name}</strong></p>
|
||||
<p>自动填充的 ID 为: <strong>{result.data.id}</strong></p>
|
||||
<p style={{ color: '#faad14' }}>
|
||||
⚠️ 建议: 如果知道该天体的数字 ID,请手动修改为数字 ID(如:-48)以便后续查询位置数据。
|
||||
</p>
|
||||
<p style={{ fontSize: '12px', color: '#888' }}>
|
||||
提示:您可以在 <a href="https://ssd.jpl.nasa.gov/horizons/" target="_blank" rel="noopener noreferrer">NASA Horizons</a> 网站查询准确的数字 ID
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
message.error(result.error || '查询失败');
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.detail || '查询失败');
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Edit handler
|
||||
const handleEdit = (record: CelestialBody) => {
|
||||
setEditingRecord(record);
|
||||
|
|
@ -167,6 +233,20 @@ export function CelestialBodies() {
|
|||
key: 'description',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '资源配置',
|
||||
key: 'resources',
|
||||
width: 120,
|
||||
render: (_, record) => {
|
||||
if (record.has_resources) {
|
||||
const resourceTypes = Object.keys(record.resources || {});
|
||||
return (
|
||||
<Badge status="success" text={`${resourceTypes.length} 类`} />
|
||||
);
|
||||
}
|
||||
return <Badge status="default" text="未配置" />;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
@ -192,19 +272,61 @@ export function CelestialBodies() {
|
|||
open={isModalOpen}
|
||||
onOk={handleModalOk}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
width={800}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
>
|
||||
{!editingRecord && (
|
||||
<>
|
||||
<Alert
|
||||
title="智能搜索提示"
|
||||
description={
|
||||
<div>
|
||||
<p>推荐使用 <strong>JPL Horizons 数字 ID</strong> 进行搜索,可获得最准确的结果。</p>
|
||||
<p style={{ marginTop: 4 }}>
|
||||
示例:Hubble 的 ID 是 <code>-48</code>,Voyager 1 的 ID 是 <code>-31</code>
|
||||
</p>
|
||||
<p style={{ marginTop: 4, fontSize: '12px', color: '#666' }}>
|
||||
不知道 ID?可以先用名称搜索,系统会尽量提取 ID,或提示您手动确认。
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<Form.Item label="从 NASA 数据库搜索">
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
placeholder="输入数字 ID (推荐, 如: -48) 或名称 (如: Hubble)"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onPressEnter={handleNASASearch}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SearchOutlined />}
|
||||
onClick={handleNASASearch}
|
||||
loading={searching}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
name="id"
|
||||
label="JPL ID"
|
||||
label="JPL Horizons ID"
|
||||
rules={[{ required: true, message: '请输入JPL Horizons ID' }]}
|
||||
>
|
||||
<Input disabled={!!editingRecord} placeholder="例如:-31 (Voyager 1) 或 399 (Earth)" />
|
||||
</Form.Item>
|
||||
|
||||
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="英文名"
|
||||
|
|
@ -248,6 +370,37 @@ export function CelestialBodies() {
|
|||
>
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
|
||||
{editingRecord && editingRecord.has_resources && (
|
||||
<Form.Item label="已配置资源">
|
||||
<Descriptions bordered size="small" column={1}>
|
||||
{Object.entries(editingRecord.resources || {}).map(([type, resources]) => (
|
||||
<Descriptions.Item
|
||||
key={type}
|
||||
label={
|
||||
type === 'texture' ? '纹理' :
|
||||
type === 'model' ? '模型' :
|
||||
type === 'icon' ? '图标' :
|
||||
type === 'thumbnail' ? '缩略图' :
|
||||
type === 'data' ? '数据' : type
|
||||
}
|
||||
>
|
||||
{resources.map((res: any, idx: number) => (
|
||||
<div key={res.id} style={{ marginBottom: idx < resources.length - 1 ? 8 : 0 }}>
|
||||
<Tag color="blue">{res.file_path}</Tag>
|
||||
<span style={{ fontSize: 12, color: '#888' }}>
|
||||
({(res.file_size / 1024).toFixed(2)} KB)
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: '#888' }}>
|
||||
提示:资源文件管理功能即将推出,敬请期待
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,373 @@
|
|||
/**
|
||||
* NASA Data Download Page
|
||||
* Downloads position data for celestial bodies from NASA Horizons API
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
Card,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
Button,
|
||||
message,
|
||||
Badge,
|
||||
Spin,
|
||||
Typography,
|
||||
Collapse,
|
||||
Space,
|
||||
Progress,
|
||||
Calendar,
|
||||
Alert
|
||||
} from 'antd';
|
||||
import {
|
||||
DownloadOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
LoadingOutlined
|
||||
} from '@ant-design/icons';
|
||||
import type { CheckboxChangeEvent } from 'antd/es/checkbox';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import dayjs from 'dayjs';
|
||||
import isBetween from 'dayjs/plugin/isBetween';
|
||||
import { request } from '../../utils/request';
|
||||
|
||||
// Extend dayjs with isBetween plugin
|
||||
dayjs.extend(isBetween);
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
interface CelestialBody {
|
||||
id: string;
|
||||
name: string;
|
||||
name_zh: string;
|
||||
type: string;
|
||||
is_active: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface GroupedBodies {
|
||||
[type: string]: CelestialBody[];
|
||||
}
|
||||
|
||||
export function NASADownload() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [bodies, setBodies] = useState<GroupedBodies>({});
|
||||
const [selectedBodies, setSelectedBodies] = useState<string[]>([]);
|
||||
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>([
|
||||
dayjs().subtract(1, 'month').startOf('month'),
|
||||
dayjs().subtract(1, 'month').endOf('month')
|
||||
]);
|
||||
const [availableDates, setAvailableDates] = useState<Set<string>>(new Set());
|
||||
const [loadingDates, setLoadingDates] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState({ current: 0, total: 0 });
|
||||
|
||||
// Type name mapping
|
||||
const typeNames: Record<string, string> = {
|
||||
star: '恒星',
|
||||
planet: '行星',
|
||||
dwarf_planet: '矮行星',
|
||||
satellite: '卫星',
|
||||
probe: '探测器',
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadBodies();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedBodies.length > 0) {
|
||||
loadAvailableDates();
|
||||
} else {
|
||||
setAvailableDates(new Set());
|
||||
}
|
||||
}, [selectedBodies, dateRange]);
|
||||
|
||||
const loadBodies = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await request.get('/celestial/positions/download/bodies');
|
||||
setBodies(data.bodies || {});
|
||||
} catch (error) {
|
||||
message.error('加载天体列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAvailableDates = async () => {
|
||||
if (selectedBodies.length === 0) return;
|
||||
|
||||
setLoadingDates(true);
|
||||
try {
|
||||
const allDates = new Set<string>();
|
||||
|
||||
// Load available dates for the first selected body
|
||||
const bodyId = selectedBodies[0];
|
||||
const startDate = dateRange[0].format('YYYY-MM-DD');
|
||||
const endDate = dateRange[1].format('YYYY-MM-DD');
|
||||
|
||||
const { data } = await request.get('/celestial/positions/download/status', {
|
||||
params: {
|
||||
body_id: bodyId,
|
||||
start_date: startDate,
|
||||
end_date: endDate
|
||||
}
|
||||
});
|
||||
|
||||
data.available_dates.forEach((date: string) => {
|
||||
allDates.add(date);
|
||||
});
|
||||
|
||||
setAvailableDates(allDates);
|
||||
} catch (error) {
|
||||
message.error('加载数据状态失败');
|
||||
} finally {
|
||||
setLoadingDates(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBodySelect = (bodyId: string, checked: boolean) => {
|
||||
setSelectedBodies(prev =>
|
||||
checked ? [...prev, bodyId] : prev.filter(id => id !== bodyId)
|
||||
);
|
||||
};
|
||||
|
||||
const handleTypeSelectAll = (type: string, checked: boolean) => {
|
||||
const typeBodyIds = bodies[type].map(b => b.id);
|
||||
setSelectedBodies(prev => {
|
||||
if (checked) {
|
||||
return [...new Set([...prev, ...typeBodyIds])];
|
||||
} else {
|
||||
return prev.filter(id => !typeBodyIds.includes(id));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDateRangeChange = (dates: any) => {
|
||||
if (dates && dates[0] && dates[1]) {
|
||||
setDateRange([dates[0], dates[1]]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (selectedDate?: Dayjs) => {
|
||||
if (selectedBodies.length === 0) {
|
||||
message.warning('请先选择至少一个天体');
|
||||
return;
|
||||
}
|
||||
|
||||
let datesToDownload: string[] = [];
|
||||
|
||||
if (selectedDate) {
|
||||
// Download single date
|
||||
datesToDownload = [selectedDate.format('YYYY-MM-DD')];
|
||||
} else {
|
||||
// Download all dates in range
|
||||
const start = dateRange[0];
|
||||
const end = dateRange[1];
|
||||
let current = start;
|
||||
|
||||
while (current.isBefore(end) || current.isSame(end, 'day')) {
|
||||
datesToDownload.push(current.format('YYYY-MM-DD'));
|
||||
current = current.add(1, 'day');
|
||||
}
|
||||
}
|
||||
|
||||
setDownloading(true);
|
||||
setDownloadProgress({ current: 0, total: datesToDownload.length });
|
||||
|
||||
try {
|
||||
const { data } = await request.post('/celestial/positions/download', {
|
||||
body_ids: selectedBodies,
|
||||
dates: datesToDownload
|
||||
});
|
||||
|
||||
setDownloadProgress({ current: datesToDownload.length, total: datesToDownload.length });
|
||||
|
||||
if (data.total_success > 0) {
|
||||
message.success(`成功下载 ${data.total_success} 条数据${data.total_failed > 0 ? `,${data.total_failed} 条失败` : ''}`);
|
||||
// Reload available dates to show newly downloaded data
|
||||
loadAvailableDates();
|
||||
} else {
|
||||
message.error('下载失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('下载请求失败');
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
setDownloadProgress({ current: 0, total: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
// Custom calendar cell renderer
|
||||
const dateCellRender = (value: Dayjs) => {
|
||||
const dateStr = value.format('YYYY-MM-DD');
|
||||
const hasData = availableDates.has(dateStr);
|
||||
const inRange = value.isBetween(dateRange[0], dateRange[1], 'day', '[]');
|
||||
|
||||
if (!inRange) return null;
|
||||
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '4px 0' }}>
|
||||
{hasData ? (
|
||||
<Badge status="success" text="" />
|
||||
) : (
|
||||
<Badge status="default" text="" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const disabledDate = (current: Dayjs) => {
|
||||
// Cannot select dates in the future
|
||||
return current && current.isAfter(dayjs(), 'day');
|
||||
};
|
||||
|
||||
const handleCalendarDateClick = (date: Dayjs) => {
|
||||
const dateStr = date.format('YYYY-MM-DD');
|
||||
const hasData = availableDates.has(dateStr);
|
||||
const inRange = date.isBetween(dateRange[0], dateRange[1], 'day', '[]');
|
||||
|
||||
if (!inRange) {
|
||||
message.warning('请选择在日期范围内的日期');
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasData) {
|
||||
message.info('该日期已有数据');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedBodies.length === 0) {
|
||||
message.warning('请先选择天体');
|
||||
return;
|
||||
}
|
||||
|
||||
handleDownload(date);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Alert
|
||||
title="数据下载说明"
|
||||
description="选择左侧天体,右侧日历将显示数据可用性。点击未下载的日期可下载该天的位置数据(00:00 UTC)。"
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<Row gutter={16}>
|
||||
{/* Left: Body Selection */}
|
||||
<Col span={8}>
|
||||
<Card
|
||||
title="选择天体"
|
||||
loading={loading}
|
||||
extra={
|
||||
<Text type="secondary">
|
||||
已选择: {selectedBodies.length}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Collapse
|
||||
defaultActiveKey={['planet', 'dwarf_planet']}
|
||||
items={Object.entries(bodies).map(([type, typeBodies]) => ({
|
||||
key: type,
|
||||
label: (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>{typeNames[type] || type}</span>
|
||||
<Checkbox
|
||||
checked={typeBodies.every(b => selectedBodies.includes(b.id))}
|
||||
indeterminate={
|
||||
typeBodies.some(b => selectedBodies.includes(b.id)) &&
|
||||
!typeBodies.every(b => selectedBodies.includes(b.id))
|
||||
}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
handleTypeSelectAll(type, e.target.checked);
|
||||
}}
|
||||
>
|
||||
全选
|
||||
</Checkbox>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{typeBodies.map((body) => (
|
||||
<Checkbox
|
||||
key={body.id}
|
||||
checked={selectedBodies.includes(body.id)}
|
||||
onChange={(e) => handleBodySelect(body.id, e.target.checked)}
|
||||
>
|
||||
{body.name_zh || body.name}
|
||||
{!body.is_active && <Badge status="default" text="(未激活)" style={{ marginLeft: 8 }} />}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Right: Date Selection and Calendar */}
|
||||
<Col span={16}>
|
||||
<Card
|
||||
title="选择下载日期"
|
||||
extra={
|
||||
<Space>
|
||||
<RangePicker
|
||||
value={dateRange}
|
||||
onChange={handleDateRangeChange}
|
||||
disabledDate={disabledDate}
|
||||
format="YYYY-MM-DD"
|
||||
allowClear={false}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => handleDownload()}
|
||||
disabled={selectedBodies.length === 0}
|
||||
loading={downloading}
|
||||
>
|
||||
下载范围内所有日期
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Spin spinning={loadingDates} indicator={<LoadingOutlined spin />}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Badge status="success" text="已有数据" />
|
||||
<Badge status="default" text="无数据(点击下载)" />
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{downloading && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Progress
|
||||
percent={Math.round((downloadProgress.current / downloadProgress.total) * 100)}
|
||||
status="active"
|
||||
/>
|
||||
<Text type="secondary">
|
||||
正在下载: {downloadProgress.current} / {downloadProgress.total}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Calendar
|
||||
fullscreen={false}
|
||||
value={dateRange[0]}
|
||||
onSelect={handleCalendarDateClick}
|
||||
cellRender={dateCellRender}
|
||||
disabledDate={disabledDate}
|
||||
validRange={[dateRange[0], dateRange[1]]}
|
||||
/>
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,421 @@
|
|||
/**
|
||||
* System Settings Management Page
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { message, Modal, Form, Input, InputNumber, Switch, Select, Button, Card, Descriptions, Badge, Space, Popconfirm, Alert, Divider } from 'antd';
|
||||
import { ReloadOutlined, ClearOutlined, WarningOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { DataTable } from '../../components/admin/DataTable';
|
||||
import { request } from '../../utils/request';
|
||||
|
||||
interface SystemSetting {
|
||||
id: number;
|
||||
key: string;
|
||||
value: any;
|
||||
raw_value: string;
|
||||
value_type: 'string' | 'int' | 'float' | 'bool' | 'json';
|
||||
category: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
is_public: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
const CATEGORY_MAP: Record<string, string> = {
|
||||
visualization: '可视化',
|
||||
cache: '缓存',
|
||||
ui: '界面',
|
||||
api: 'API',
|
||||
general: '常规',
|
||||
};
|
||||
|
||||
export function SystemSettings() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState<SystemSetting[]>([]);
|
||||
const [filteredData, setFilteredData] = useState<SystemSetting[]>([]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingRecord, setEditingRecord] = useState<SystemSetting | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [clearingCache, setClearingCache] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data: result } = await request.get('/system/settings');
|
||||
setData(result.settings || []);
|
||||
setFilteredData(result.settings || []);
|
||||
} catch (error) {
|
||||
message.error('加载数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Search handler
|
||||
const handleSearch = (keyword: string) => {
|
||||
const lowerKeyword = keyword.toLowerCase();
|
||||
const filtered = data.filter(
|
||||
(item) =>
|
||||
item.key.toLowerCase().includes(lowerKeyword) ||
|
||||
item.label?.toLowerCase().includes(lowerKeyword) ||
|
||||
item.category?.toLowerCase().includes(lowerKeyword)
|
||||
);
|
||||
setFilteredData(filtered);
|
||||
};
|
||||
|
||||
// Add handler
|
||||
const handleAdd = () => {
|
||||
setEditingRecord(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ value_type: 'string', category: 'general', is_public: false });
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// Edit handler
|
||||
const handleEdit = (record: SystemSetting) => {
|
||||
setEditingRecord(record);
|
||||
form.setFieldsValue({
|
||||
key: record.key,
|
||||
value: record.value,
|
||||
value_type: record.value_type,
|
||||
category: record.category,
|
||||
label: record.label,
|
||||
description: record.description,
|
||||
is_public: record.is_public,
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// Delete handler
|
||||
const handleDelete = async (record: SystemSetting) => {
|
||||
try {
|
||||
await request.delete(`/system/settings/${record.key}`);
|
||||
message.success('删除成功');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
// Form submit
|
||||
const handleModalOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
if (editingRecord) {
|
||||
// Update
|
||||
await request.put(`/system/settings/${editingRecord.key}`, values);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
// Create
|
||||
await request.post('/system/settings', values);
|
||||
message.success('创建成功');
|
||||
}
|
||||
|
||||
setIsModalOpen(false);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Clear all caches
|
||||
const handleClearCache = async () => {
|
||||
setClearingCache(true);
|
||||
try {
|
||||
const { data } = await request.post('/system/cache/clear');
|
||||
message.success(
|
||||
<>
|
||||
<div>{data.message}</div>
|
||||
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
|
||||
位置缓存: {data.redis_cache.positions_keys} 个键 | NASA缓存: {data.redis_cache.nasa_keys} 个键
|
||||
</div>
|
||||
</>,
|
||||
5
|
||||
);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
message.error('清除缓存失败');
|
||||
} finally {
|
||||
setClearingCache(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<SystemSetting> = [
|
||||
{
|
||||
title: '参数键',
|
||||
dataIndex: 'key',
|
||||
key: 'key',
|
||||
width: 220,
|
||||
fixed: 'left',
|
||||
render: (key: string, record) => (
|
||||
<div>
|
||||
<div style={{ fontFamily: 'monospace', fontWeight: 500 }}>{key}</div>
|
||||
{record.is_public && (
|
||||
<Badge status="success" text="前端可访问" style={{ fontSize: 11 }} />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'label',
|
||||
key: 'label',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '当前值',
|
||||
dataIndex: 'value',
|
||||
key: 'value',
|
||||
width: 150,
|
||||
render: (value: any, record) => {
|
||||
if (record.value_type === 'bool') {
|
||||
return <Badge status={value ? 'success' : 'default'} text={value ? '是' : '否'} />;
|
||||
}
|
||||
return <span style={{ fontWeight: 500 }}>{String(value)}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'value_type',
|
||||
key: 'value_type',
|
||||
width: 90,
|
||||
filters: [
|
||||
{ text: '字符串', value: 'string' },
|
||||
{ text: '整数', value: 'int' },
|
||||
{ text: '浮点数', value: 'float' },
|
||||
{ text: '布尔值', value: 'bool' },
|
||||
{ text: 'JSON', value: 'json' },
|
||||
],
|
||||
onFilter: (value, record) => record.value_type === value,
|
||||
render: (type: string) => {
|
||||
const typeMap: Record<string, string> = {
|
||||
string: '字符串',
|
||||
int: '整数',
|
||||
float: '浮点数',
|
||||
bool: '布尔值',
|
||||
json: 'JSON',
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
width: 100,
|
||||
filters: Object.keys(CATEGORY_MAP).map((key) => ({
|
||||
text: CATEGORY_MAP[key],
|
||||
value: key,
|
||||
})),
|
||||
onFilter: (value, record) => record.category === value,
|
||||
render: (category: string) => CATEGORY_MAP[category] || category,
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
ellipsis: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Cache Management Card */}
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<ClearOutlined />
|
||||
<span>缓存管理</span>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
styles={{ body: { padding: 16 } }}
|
||||
>
|
||||
<Alert
|
||||
title="重要操作说明"
|
||||
description={
|
||||
<div>
|
||||
<p style={{ marginBottom: 8 }}>
|
||||
清除缓存会清空所有内存缓存和 Redis 缓存,包括:
|
||||
</p>
|
||||
<ul style={{ marginBottom: 0, paddingLeft: 20 }}>
|
||||
<li>位置数据缓存(当前位置和历史位置)</li>
|
||||
<li>NASA API 响应缓存</li>
|
||||
<li>所有其他临时缓存数据</li>
|
||||
</ul>
|
||||
<p style={{ marginTop: 8, marginBottom: 0, color: '#fa8c16' }}>
|
||||
<WarningOutlined /> 清除后下次查询会重新从数据库或 NASA API 获取数据,可能会较慢
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<Space>
|
||||
<Popconfirm
|
||||
title="确认清除所有缓存?"
|
||||
description="此操作会清空所有缓存数据,下次查询可能会较慢"
|
||||
onConfirm={handleClearCache}
|
||||
okText="确认清除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
type="primary"
|
||||
icon={<ClearOutlined />}
|
||||
loading={clearingCache}
|
||||
>
|
||||
清除所有缓存
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
|
||||
<Button icon={<ReloadOutlined />} onClick={loadData}>
|
||||
刷新数据
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Settings Table */}
|
||||
<DataTable
|
||||
title="系统参数"
|
||||
columns={columns}
|
||||
dataSource={filteredData}
|
||||
loading={loading}
|
||||
total={filteredData.length}
|
||||
onSearch={handleSearch}
|
||||
onAdd={handleAdd}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
rowKey="id"
|
||||
pageSize={15}
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editingRecord ? '编辑参数' : '新增参数'}
|
||||
open={isModalOpen}
|
||||
onOk={handleModalOk}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
width={700}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
>
|
||||
<Form.Item
|
||||
name="key"
|
||||
label="参数键"
|
||||
rules={[{ required: true, message: '请输入参数键' }]}
|
||||
>
|
||||
<Input disabled={!!editingRecord} placeholder="例如:timeline_interval_days" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="label"
|
||||
label="参数名称"
|
||||
rules={[{ required: true, message: '请输入参数名称' }]}
|
||||
>
|
||||
<Input placeholder="例如:时间轴播放间隔(天)" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="value_type"
|
||||
label="数据类型"
|
||||
rules={[{ required: true, message: '请选择数据类型' }]}
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="string">字符串</Select.Option>
|
||||
<Select.Option value="int">整数</Select.Option>
|
||||
<Select.Option value="float">浮点数</Select.Option>
|
||||
<Select.Option value="bool">布尔值</Select.Option>
|
||||
<Select.Option value="json">JSON</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) =>
|
||||
prevValues.value_type !== currentValues.value_type
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const valueType = getFieldValue('value_type');
|
||||
if (valueType === 'bool') {
|
||||
return (
|
||||
<Form.Item
|
||||
name="value"
|
||||
label="参数值"
|
||||
valuePropName="checked"
|
||||
rules={[{ required: true, message: '请设置参数值' }]}
|
||||
>
|
||||
<Switch checkedChildren="是" unCheckedChildren="否" />
|
||||
</Form.Item>
|
||||
);
|
||||
} else if (valueType === 'int' || valueType === 'float') {
|
||||
return (
|
||||
<Form.Item
|
||||
name="value"
|
||||
label="参数值"
|
||||
rules={[{ required: true, message: '请输入参数值' }]}
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} step={valueType === 'float' ? 0.1 : 1} />
|
||||
</Form.Item>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Form.Item
|
||||
name="value"
|
||||
label="参数值"
|
||||
rules={[{ required: true, message: '请输入参数值' }]}
|
||||
>
|
||||
<Input.TextArea rows={3} placeholder={valueType === 'json' ? 'JSON 格式数据' : '参数值'} />
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="category"
|
||||
label="分类"
|
||||
rules={[{ required: true, message: '请选择分类' }]}
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="visualization">可视化</Select.Option>
|
||||
<Select.Option value="cache">缓存</Select.Option>
|
||||
<Select.Option value="ui">界面</Select.Option>
|
||||
<Select.Option value="api">API</Select.Option>
|
||||
<Select.Option value="general">常规</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="is_public"
|
||||
label="前端可访问"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch checkedChildren="是" unCheckedChildren="否" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="描述"
|
||||
>
|
||||
<Input.TextArea rows={3} placeholder="参数说明" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue