0.9.9
parent
79a780a4db
commit
f3a5f71e57
|
|
@ -8,6 +8,8 @@ import { Dashboard } from './pages/admin/Dashboard';
|
||||||
import { CelestialBodies } from './pages/admin/CelestialBodies';
|
import { CelestialBodies } from './pages/admin/CelestialBodies';
|
||||||
import { StaticData } from './pages/admin/StaticData';
|
import { StaticData } from './pages/admin/StaticData';
|
||||||
import { Users } from './pages/admin/Users';
|
import { Users } from './pages/admin/Users';
|
||||||
|
import { NASADownload } from './pages/admin/NASADownload';
|
||||||
|
import { SystemSettings } from './pages/admin/SystemSettings';
|
||||||
import { auth } from './utils/auth';
|
import { auth } from './utils/auth';
|
||||||
import { ToastProvider } from './contexts/ToastContext';
|
import { ToastProvider } from './contexts/ToastContext';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
@ -45,7 +47,8 @@ export function Router() {
|
||||||
<Route path="celestial-bodies" element={<CelestialBodies />} />
|
<Route path="celestial-bodies" element={<CelestialBodies />} />
|
||||||
<Route path="static-data" element={<StaticData />} />
|
<Route path="static-data" element={<StaticData />} />
|
||||||
<Route path="users" element={<Users />} />
|
<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>
|
</Route>
|
||||||
|
|
||||||
{/* Fallback */}
|
{/* Fallback */}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState } from 'react';
|
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';
|
import type { CelestialBody } from '../types';
|
||||||
|
|
||||||
interface ProbeListProps {
|
interface ProbeListProps {
|
||||||
|
|
@ -13,6 +13,7 @@ interface ProbeListProps {
|
||||||
export function ProbeList({ probes, planets, onBodySelect, selectedBody, onResetCamera }: ProbeListProps) {
|
export function ProbeList({ probes, planets, onBodySelect, selectedBody, onResetCamera }: ProbeListProps) {
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [expandedGroup, setExpandedGroup] = useState<string | null>(null); // 只允许一个分组展开
|
||||||
|
|
||||||
// Calculate distance for sorting
|
// Calculate distance for sorting
|
||||||
const calculateDistance = (body: CelestialBody) => {
|
const calculateDistance = (body: CelestialBody) => {
|
||||||
|
|
@ -22,7 +23,7 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
|
||||||
|
|
||||||
const processBodies = (list: CelestialBody[]) => {
|
const processBodies = (list: CelestialBody[]) => {
|
||||||
return list
|
return list
|
||||||
.filter(b =>
|
.filter(b =>
|
||||||
(b.name_zh || b.name).toLowerCase().includes(searchTerm.toLowerCase()) &&
|
(b.name_zh || b.name).toLowerCase().includes(searchTerm.toLowerCase()) &&
|
||||||
b.type !== 'star' // Exclude Sun from list
|
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);
|
.sort((a, b) => a.distance - b.distance);
|
||||||
};
|
};
|
||||||
|
|
||||||
const planetList = processBodies(planets);
|
// Group bodies by type
|
||||||
const probeList = processBodies(probes);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -78,44 +90,69 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* List Content */}
|
{/* 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 */}
|
{/* Planets Group */}
|
||||||
<div>
|
{planetList.length > 0 && (
|
||||||
<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] */}
|
<BodyGroup
|
||||||
<Globe size={12} />
|
title="行星"
|
||||||
行星 ({planetList.length})
|
icon={<Globe size={12} />}
|
||||||
</div>
|
count={planetList.length}
|
||||||
<div className="space-y-1">
|
bodies={planetList}
|
||||||
{planetList.map(({ body, distance }) => (
|
isExpanded={expandedGroup === 'planet'}
|
||||||
<BodyItem
|
onToggle={() => toggleGroup('planet')}
|
||||||
key={body.id}
|
selectedBody={selectedBody}
|
||||||
body={body}
|
onBodySelect={onBodySelect}
|
||||||
distance={distance}
|
/>
|
||||||
isSelected={selectedBody?.id === body.id}
|
)}
|
||||||
onClick={() => onBodySelect(body)}
|
|
||||||
/>
|
{/* Dwarf Planets Group */}
|
||||||
))}
|
{dwarfPlanetList.length > 0 && (
|
||||||
</div>
|
<BodyGroup
|
||||||
</div>
|
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 */}
|
{/* Probes Group */}
|
||||||
<div>
|
{probeList.length > 0 && (
|
||||||
<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] */}
|
<BodyGroup
|
||||||
<Rocket size={12} />
|
title="探测器"
|
||||||
探测器 ({probeList.length})
|
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>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -123,7 +160,7 @@ export function ProbeList({ probes, planets, onBodySelect, selectedBody, onReset
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||||
className={`
|
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
|
w-8 h-8 flex items-center justify-center
|
||||||
bg-black/80 backdrop-blur-md border border-white/10 rounded-full
|
bg-black/80 backdrop-blur-md border border-white/10 rounded-full
|
||||||
text-white hover:bg-blue-600 transition-all shadow-lg z-50
|
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 }: {
|
function BodyItem({ body, distance, isSelected, onClick }: {
|
||||||
body: CelestialBody,
|
body: CelestialBody,
|
||||||
distance: number,
|
distance: number,
|
||||||
|
|
|
||||||
|
|
@ -130,8 +130,7 @@ export function DataTable<T extends object>({
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
bodyStyle={{ padding: 0 }}
|
styles={{ body: { padding: 0 } }}
|
||||||
bordered={false}
|
|
||||||
>
|
>
|
||||||
<Table
|
<Table
|
||||||
columns={tableColumns}
|
columns={tableColumns}
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,8 @@ export function AdminLayout() {
|
||||||
padding: 24,
|
padding: 24,
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
minHeight: 280,
|
minHeight: 280,
|
||||||
|
overflow: 'auto',
|
||||||
|
maxHeight: 'calc(100vh - 64px - 32px)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
* Celestial Bodies Management Page
|
* Celestial Bodies Management Page
|
||||||
*/
|
*/
|
||||||
import { useState, useEffect } from 'react';
|
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 type { ColumnsType } from 'antd/es/table';
|
||||||
import { DataTable } from '../../components/admin/DataTable';
|
import { DataTable } from '../../components/admin/DataTable';
|
||||||
import { request } from '../../utils/request';
|
import { request } from '../../utils/request';
|
||||||
|
|
@ -14,6 +15,15 @@ interface CelestialBody {
|
||||||
type: string;
|
type: string;
|
||||||
description: string;
|
description: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
resources?: {
|
||||||
|
[key: string]: Array<{
|
||||||
|
id: number;
|
||||||
|
file_path: string;
|
||||||
|
file_size: number;
|
||||||
|
mime_type: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
has_resources?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CelestialBodies() {
|
export function CelestialBodies() {
|
||||||
|
|
@ -23,6 +33,8 @@ export function CelestialBodies() {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [editingRecord, setEditingRecord] = useState<CelestialBody | null>(null);
|
const [editingRecord, setEditingRecord] = useState<CelestialBody | null>(null);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
|
|
@ -57,11 +69,65 @@ export function CelestialBodies() {
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
setEditingRecord(null);
|
setEditingRecord(null);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
|
setSearchQuery('');
|
||||||
// Default values
|
// Default values
|
||||||
form.setFieldsValue({ is_active: true, type: 'probe' });
|
form.setFieldsValue({ is_active: true, type: 'probe' });
|
||||||
setIsModalOpen(true);
|
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
|
// Edit handler
|
||||||
const handleEdit = (record: CelestialBody) => {
|
const handleEdit = (record: CelestialBody) => {
|
||||||
setEditingRecord(record);
|
setEditingRecord(record);
|
||||||
|
|
@ -167,6 +233,20 @@ export function CelestialBodies() {
|
||||||
key: 'description',
|
key: 'description',
|
||||||
ellipsis: true,
|
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 (
|
return (
|
||||||
|
|
@ -192,19 +272,61 @@ export function CelestialBodies() {
|
||||||
open={isModalOpen}
|
open={isModalOpen}
|
||||||
onOk={handleModalOk}
|
onOk={handleModalOk}
|
||||||
onCancel={() => setIsModalOpen(false)}
|
onCancel={() => setIsModalOpen(false)}
|
||||||
|
width={800}
|
||||||
>
|
>
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout="vertical"
|
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
|
<Form.Item
|
||||||
name="id"
|
name="id"
|
||||||
label="JPL ID"
|
label="JPL Horizons ID"
|
||||||
rules={[{ required: true, message: '请输入JPL Horizons ID' }]}
|
rules={[{ required: true, message: '请输入JPL Horizons ID' }]}
|
||||||
>
|
>
|
||||||
<Input disabled={!!editingRecord} placeholder="例如:-31 (Voyager 1) 或 399 (Earth)" />
|
<Input disabled={!!editingRecord} placeholder="例如:-31 (Voyager 1) 或 399 (Earth)" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="name"
|
name="name"
|
||||||
label="英文名"
|
label="英文名"
|
||||||
|
|
@ -248,6 +370,37 @@ export function CelestialBodies() {
|
||||||
>
|
>
|
||||||
<Input.TextArea rows={4} />
|
<Input.TextArea rows={4} />
|
||||||
</Form.Item>
|
</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>
|
</Form>
|
||||||
</Modal>
|
</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