main
mula.liu 2025-11-30 13:25:41 +08:00
parent 79a780a4db
commit f3a5f71e57
7 changed files with 1086 additions and 45 deletions

View File

@ -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 */}

View File

@ -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,

View File

@ -130,8 +130,7 @@ export function DataTable<T extends object>({
)}
</Space>
}
bodyStyle={{ padding: 0 }}
bordered={false}
styles={{ body: { padding: 0 } }}
>
<Table
columns={tableColumns}

View File

@ -165,6 +165,8 @@ export function AdminLayout() {
padding: 24,
background: '#fff',
minHeight: 280,
overflow: 'auto',
maxHeight: 'calc(100vh - 64px - 32px)',
}}
>
<Outlet />

View File

@ -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>
</>

View File

@ -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>
);
}

View File

@ -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>
</>
);
}