feat: add reports center, admin drill-down, disease filter + bug fixes + perf optimization
Frontend features:
- 报表中心 (ReportsCenter): list/detail views, diagnosis breakdown chart, CSV export
- 多级行政下钻 (AdminBreadcrumb): 湖北省→武汉市→区→街道 hierarchical drill-down
- 按病种筛选 (DiseaseFilter): multi-select diagnosis filter on monitoring + reports pages
Backend:
- Add /forecast/{days} endpoint, diagnosis filter params on cases endpoints
- Add /streets aggregation endpoint, enrich reports with real case data
- Extract shared case_loader module
Bug fixes (14):
- Fix missing /risk/forecast route (404), historyApi pointing to non-existent router
- Fix min_risk filter silently ignored in insights/hotspots
- Fix type mismatches: CaseTrendResponse, CaseStatsResponse shapes
- Fix silent .catch(() => {}) swallowing errors, fetchAlerts not clearing stale state
- Fix lru_cache caching exceptions, generateReport used cachedGet for write op
- Fix missing useEffect deps in Insights, DistrictComparison, ReportsCenter
Performance (9):
- Zustand selectors across 9 components (eliminate re-render cascades)
- Fix districtCases.sort() mutating store state, inline IIFE → memo'd component
- CaseLocationMap: React.memo, race protection, correct deps
- AlertCard: stable callbacks, TimelinePlayer: useMemo, TopNav: clock isolation
- SideNav: modules array to module scope, DiseaseFilter: memoized filter
This commit is contained in:
52
frontend/src/components/AdminBreadcrumb.tsx
Normal file
52
frontend/src/components/AdminBreadcrumb.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { useDrilldownStore } from '@/stores/drilldownStore';
|
||||
|
||||
const WUHAN_DISTRICTS = [
|
||||
'江岸区', '江汉区', '硚口区', '汉阳区', '武昌区', '青山区', '洪山区',
|
||||
'东西湖区', '汉南区', '蔡甸区', '江夏区', '黄陂区', '新洲区',
|
||||
];
|
||||
|
||||
export function AdminBreadcrumb() {
|
||||
const {
|
||||
currentLevel, selectedDistrict, selectedStreet,
|
||||
availableStreets, isLoadingStreets,
|
||||
drillDown, drillUp,
|
||||
} = useDrilldownStore();
|
||||
|
||||
const showStreetDropdown = currentLevel === 'district' || currentLevel === 'street';
|
||||
const hasStreets = availableStreets.length > 1;
|
||||
|
||||
const districtBtnCls = currentLevel === 'district' || currentLevel === 'street'
|
||||
? 'border-blue-300 text-blue-700 font-medium' : 'border-gray-300 text-gray-600';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<button onClick={() => drillUp()} className={`px-1.5 py-0.5 rounded hover:bg-gray-100 transition-colors ${currentLevel === 'province' ? 'text-blue-600 font-semibold bg-blue-50' : 'text-gray-600'}`}>
|
||||
湖北省
|
||||
</button>
|
||||
<ChevronRight className="w-3 h-3 text-gray-300" />
|
||||
<button onClick={() => currentLevel !== 'city' && drillUp()} className={`px-1.5 py-0.5 rounded hover:bg-gray-100 transition-colors ${currentLevel === 'city' ? 'text-blue-600 font-semibold bg-blue-50' : 'text-gray-600'}`}>
|
||||
武汉市
|
||||
</button>
|
||||
<ChevronRight className="w-3 h-3 text-gray-300" />
|
||||
<select value={selectedDistrict || ''} onChange={(e) => e.target.value ? drillDown('district', e.target.value) : drillUp()}
|
||||
className={`px-2 py-0.5 border rounded text-xs focus:outline-none focus:ring-2 focus:ring-blue-500 ${districtBtnCls}`}>
|
||||
<option value="">全部区</option>
|
||||
{WUHAN_DISTRICTS.map((d) => (<option key={d} value={d}>{d}</option>))}
|
||||
</select>
|
||||
{showStreetDropdown && (<>
|
||||
<ChevronRight className="w-3 h-3 text-gray-300" />
|
||||
{isLoadingStreets ? (<span className="text-gray-400 text-xs">加载街道...</span>)
|
||||
: hasStreets ? (
|
||||
<select value={selectedStreet || ''} onChange={(e) => e.target.value ? drillDown('street', e.target.value) : drillUp()}
|
||||
className={`px-2 py-0.5 border rounded text-xs focus:outline-none focus:ring-2 focus:ring-blue-500 ${currentLevel === 'street' ? 'border-blue-300 text-blue-700 font-medium' : 'border-gray-300 text-gray-600'}`}>
|
||||
<option value="">全部街道</option>
|
||||
{availableStreets.map((s) => (
|
||||
<option key={s.name} value={s.name}>{s.name} ({s.total_cases})</option>
|
||||
))}
|
||||
</select>
|
||||
) : (<span className="text-gray-400 text-[11px]">该区域暂无街道数据</span>)}
|
||||
</>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,30 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState, memo } from 'react';
|
||||
import L from 'leaflet';
|
||||
|
||||
interface CaseLocation {
|
||||
case_id: string;
|
||||
case_type: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
district: string;
|
||||
street: string;
|
||||
}
|
||||
import { geocodedApi } from '@/services/api';
|
||||
import type { GeocodedCase } from '@/types';
|
||||
|
||||
const WUHAN_CENTER: [number, number] = [30.59, 114.31];
|
||||
|
||||
export function CaseLocationMap({ height = '400px' }: { height?: string }) {
|
||||
interface CaseLocationMapProps {
|
||||
height?: string;
|
||||
district?: string | null;
|
||||
street?: string | null;
|
||||
}
|
||||
|
||||
function CaseLocationMapComponent({ height = '400px', district = null, street = null }: CaseLocationMapProps) {
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const mapInstanceRef = useRef<L.Map | null>(null);
|
||||
const layerRef = useRef<L.LayerGroup | null>(null);
|
||||
const cancelledRef = useRef(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [caseCount, setCaseCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || mapInstanceRef.current) return;
|
||||
|
||||
setIsLoading(true);
|
||||
cancelledRef.current = false;
|
||||
|
||||
const map = L.map(mapRef.current, {
|
||||
center: WUHAN_CENTER,
|
||||
zoom: 11,
|
||||
@@ -37,10 +40,10 @@ export function CaseLocationMap({ height = '400px' }: { height?: string }) {
|
||||
layerRef.current = L.layerGroup().addTo(map);
|
||||
|
||||
// Fetch case locations
|
||||
fetch('/api/geocoded/geocoded?limit=5000')
|
||||
.then((res) => res.json())
|
||||
geocodedApi.getGeocoded({ limit: 5000, district: district || undefined })
|
||||
.then((data) => {
|
||||
const cases: CaseLocation[] = data.cases || [];
|
||||
if (cancelledRef.current) return;
|
||||
const cases: GeocodedCase[] = data.cases || [];
|
||||
const layer = layerRef.current;
|
||||
if (!layer) return;
|
||||
|
||||
@@ -48,7 +51,7 @@ export function CaseLocationMap({ height = '400px' }: { height?: string }) {
|
||||
|
||||
// Deduplicate by case_id to avoid overlapping markers
|
||||
const seen = new Set<string>();
|
||||
const unique: CaseLocation[] = [];
|
||||
let unique: GeocodedCase[] = [];
|
||||
for (const c of cases) {
|
||||
if (!seen.has(c.case_id)) {
|
||||
seen.add(c.case_id);
|
||||
@@ -56,6 +59,13 @@ export function CaseLocationMap({ height = '400px' }: { height?: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Client-side street filtering
|
||||
if (street) {
|
||||
unique = unique.filter((c) => c.street === street);
|
||||
}
|
||||
|
||||
if (cancelledRef.current) return;
|
||||
|
||||
for (const c of unique) {
|
||||
if (!c.latitude || !c.longitude) continue;
|
||||
|
||||
@@ -88,13 +98,16 @@ export function CaseLocationMap({ height = '400px' }: { height?: string }) {
|
||||
map.fitBounds(bounds, { padding: [30, 30] });
|
||||
}
|
||||
})
|
||||
.catch(() => setIsLoading(false));
|
||||
.catch(() => {
|
||||
if (!cancelledRef.current) setIsLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
map.remove();
|
||||
mapInstanceRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
}, [district, street]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -114,3 +127,5 @@ export function CaseLocationMap({ height = '400px' }: { height?: string }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const CaseLocationMap = memo(CaseLocationMapComponent);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { memo, useEffect, useRef, useState, useCallback } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { caseApi } from '@/services/api';
|
||||
import { geocodedApi } from '@/services/api';
|
||||
import type { CaseGrid, GeocodedCase } from '@/types';
|
||||
|
||||
interface CaseMapProps {
|
||||
@@ -74,8 +74,8 @@ function CaseMapComponent({ height = '480px' }: CaseMapProps) {
|
||||
setError(null);
|
||||
try {
|
||||
const [gridRes, geoRes] = await Promise.all([
|
||||
caseApi.getGrid(),
|
||||
caseApi.getGeocoded(5000),
|
||||
geocodedApi.getGrid(),
|
||||
geocodedApi.getGeocoded({ limit: 5000 }),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setGrids(gridRes.grids || []);
|
||||
|
||||
124
frontend/src/components/DiseaseFilter.tsx
Normal file
124
frontend/src/components/DiseaseFilter.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { Search, ChevronDown, X } from 'lucide-react';
|
||||
import { useDiseaseStore } from '@/stores/diseaseStore';
|
||||
interface DiseaseFilterProps {
|
||||
onFilterChange?: (diagnoses: string[]) => void;
|
||||
}
|
||||
|
||||
export function DiseaseFilter({ onFilterChange }: DiseaseFilterProps) {
|
||||
const availableDiagnoses = useDiseaseStore((s) => s.availableDiagnoses);
|
||||
const selectedDiagnoses = useDiseaseStore((s) => s.selectedDiagnoses);
|
||||
const isLoading = useDiseaseStore((s) => s.isLoading);
|
||||
const fetchDiagnoses = useDiseaseStore((s) => s.fetchDiagnoses);
|
||||
const setSelectedDiagnoses = useDiseaseStore((s) => s.setSelectedDiagnoses);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDiagnoses();
|
||||
}, [fetchDiagnoses]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() =>
|
||||
availableDiagnoses.filter((d) =>
|
||||
d.toLowerCase().includes(search.toLowerCase())
|
||||
), [availableDiagnoses, search]);
|
||||
|
||||
const handleToggle = useCallback((diagnosis: string) => {
|
||||
const next = selectedDiagnoses.includes(diagnosis)
|
||||
? selectedDiagnoses.filter((d) => d !== diagnosis)
|
||||
: [...selectedDiagnoses, diagnosis];
|
||||
setSelectedDiagnoses(next);
|
||||
onFilterChange?.(next);
|
||||
}, [selectedDiagnoses, setSelectedDiagnoses, onFilterChange]);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
setSelectedDiagnoses([...availableDiagnoses]);
|
||||
onFilterChange?.([...availableDiagnoses]);
|
||||
}, [availableDiagnoses, setSelectedDiagnoses, onFilterChange]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setSelectedDiagnoses([]);
|
||||
onFilterChange?.([]);
|
||||
}, [setSelectedDiagnoses, onFilterChange]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 border border-gray-300 rounded-lg text-xs bg-white hover:border-gray-400 transition-colors min-w-[140px]"
|
||||
>
|
||||
<span className={selectedDiagnoses.length > 0 ? 'text-blue-600 font-medium' : 'text-gray-500'}>
|
||||
{selectedDiagnoses.length > 0 ? `已选 ${selectedDiagnoses.length} 项` : '按病种筛选'}
|
||||
</span>
|
||||
<ChevronDown className={`w-3.5 h-3.5 text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-full mt-1 left-0 w-64 bg-white border border-gray-200 rounded-lg shadow-lg z-50">
|
||||
{/* Search input */}
|
||||
<div className="p-2 border-b border-gray-100">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 bg-gray-50 rounded">
|
||||
<Search className="w-3 h-3 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="搜索诊断..."
|
||||
className="flex-1 bg-transparent text-xs outline-none"
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => setSearch('')} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="flex gap-1 px-2 py-1.5 border-b border-gray-100">
|
||||
<button onClick={handleSelectAll} className="text-[11px] text-blue-600 hover:text-blue-800 px-1">全选</button>
|
||||
<span className="text-gray-300">|</span>
|
||||
<button onClick={handleClear} className="text-[11px] text-gray-500 hover:text-gray-700 px-1">清空</button>
|
||||
</div>
|
||||
|
||||
{/* Options list */}
|
||||
<div className="max-h-48 overflow-y-auto p-1">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-4 text-xs text-gray-400">加载诊断列表...</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-center py-4 text-xs text-gray-400">
|
||||
{availableDiagnoses.length === 0 ? '暂无可选诊断' : '无匹配诊断'}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((diagnosis) => (
|
||||
<label
|
||||
key={diagnosis}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-50 cursor-pointer text-xs"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDiagnoses.includes(diagnosis)}
|
||||
onChange={() => handleToggle(diagnosis)}
|
||||
className="w-3.5 h-3.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-gray-700">{diagnosis}</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -66,7 +66,7 @@ function RiskMapComponent(props: RiskMapProps) {
|
||||
|
||||
useEffect(() => {
|
||||
callbacksRef.current = { onGridSelect, onClosePanel, onFullscreen, onForecastChange };
|
||||
});
|
||||
}, [onGridSelect, onClosePanel, onFullscreen, onForecastChange]);
|
||||
|
||||
const containerHeight = isFullscreen ? 'calc(100vh - 52px)' : '420px';
|
||||
|
||||
|
||||
@@ -6,6 +6,48 @@ interface SideNavProps {
|
||||
alertCount?: number;
|
||||
}
|
||||
|
||||
const modules: { id: string; label: string; icon: React.ReactNode; items: { id: string; label: string }[] }[] = [
|
||||
{
|
||||
id: 'monitoring',
|
||||
label: '监测',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z"/>
|
||||
</svg>
|
||||
),
|
||||
items: [
|
||||
{ id: 'monitoring', label: '监测面板' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'alert',
|
||||
label: '预警',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>
|
||||
</svg>
|
||||
),
|
||||
items: [
|
||||
{ id: 'alerts', label: '预警地图' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'analysis',
|
||||
label: '分析',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-7h2v7zm4 0h-2V7h2v10zm4 0h-2v-4h2v4z"/>
|
||||
</svg>
|
||||
),
|
||||
items: [
|
||||
{ id: 'trend-analysis', label: '趋势分析' },
|
||||
{ id: 'district-comparison', label: '区域对比' },
|
||||
{ id: 'insights', label: '智能洞察' },
|
||||
{ id: 'reports', label: '报表中心' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function SideNav({
|
||||
activePage,
|
||||
onPageChange,
|
||||
@@ -13,47 +55,6 @@ export function SideNav({
|
||||
}: SideNavProps) {
|
||||
const [expanded, setExpanded] = useState<string | null>('monitoring');
|
||||
|
||||
const modules: { id: string; label: string; icon: React.ReactNode; items: { id: string; label: string }[] }[] = [
|
||||
{
|
||||
id: 'monitoring',
|
||||
label: '监测',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z"/>
|
||||
</svg>
|
||||
),
|
||||
items: [
|
||||
{ id: 'monitoring', label: '监测面板' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'alert',
|
||||
label: '预警',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/>
|
||||
</svg>
|
||||
),
|
||||
items: [
|
||||
{ id: 'alerts', label: '预警地图' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'analysis',
|
||||
label: '分析',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 17H7v-7h2v7zm4 0h-2V7h2v10zm4 0h-2v-4h2v4z"/>
|
||||
</svg>
|
||||
),
|
||||
items: [
|
||||
{ id: 'trend-analysis', label: '趋势分析' },
|
||||
{ id: 'district-comparison', label: '区域对比' },
|
||||
{ id: 'insights', label: '智能洞察' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const handleItemClick = (moduleId: string, itemId: string) => {
|
||||
setExpanded(moduleId);
|
||||
onPageChange(itemId);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { Play, Pause, SkipBack, SkipForward } from 'lucide-react';
|
||||
|
||||
interface TimelinePlayerProps {
|
||||
@@ -40,9 +40,9 @@ export function TimelinePlayer({
|
||||
return dates;
|
||||
}, []);
|
||||
|
||||
const dateRange = generateDateRange(startDate, endDate);
|
||||
const currentIndex = dateRange.indexOf(currentDate);
|
||||
const progress = ((currentIndex + 1) / dateRange.length) * 100;
|
||||
const dateRange = useMemo(() => generateDateRange(startDate, endDate), [startDate, endDate, generateDateRange]);
|
||||
const currentIndex = useMemo(() => dateRange.indexOf(currentDate), [dateRange, currentDate]);
|
||||
const progress = useMemo(() => ((currentIndex + 1) / dateRange.length) * 100, [currentIndex, dateRange.length]);
|
||||
|
||||
const play = useCallback(() => {
|
||||
setPlaying(true);
|
||||
|
||||
@@ -4,15 +4,16 @@ interface TopNavProps {
|
||||
onLogout?: () => void;
|
||||
}
|
||||
|
||||
export function TopNav({ onLogout }: TopNavProps) {
|
||||
const [currentTime, setCurrentTime] = useState('');
|
||||
|
||||
function Clock() {
|
||||
const [time, setTime] = useState(new Date());
|
||||
useEffect(() => {
|
||||
const update = () => setCurrentTime(new Date().toLocaleString('zh-CN'));
|
||||
update();
|
||||
const timer = setInterval(update, 1000);
|
||||
return () => clearInterval(timer);
|
||||
const id = setInterval(() => setTime(new Date()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
return <span>{time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</span>;
|
||||
}
|
||||
|
||||
export function TopNav({ onLogout }: TopNavProps) {
|
||||
|
||||
return (
|
||||
<nav className="h-[52px] bg-bg-card border-b border-border flex items-center px-5 fixed top-0 left-0 right-0 z-50">
|
||||
@@ -35,7 +36,7 @@ export function TopNav({ onLogout }: TopNavProps) {
|
||||
|
||||
<div className="ml-auto flex items-center gap-5">
|
||||
<span className="text-[12px] text-text-muted">
|
||||
{currentTime}
|
||||
<Clock />
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-[13px] text-text-secondary">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
Reference in New Issue
Block a user