core v1
This commit is contained in:
168
management/src/components/WifiPoolModal.tsx
Normal file
168
management/src/components/WifiPoolModal.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { apiFetchWifiPool } from '../api';
|
||||
import type { WifiPoolItem } from '../api';
|
||||
|
||||
function bssidKey(bssid: string): string {
|
||||
return bssid.toLowerCase().replace(/[^0-9a-f]/g, '');
|
||||
}
|
||||
|
||||
interface WifiPoolModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (ssid: string, bssid: string) => void;
|
||||
acceptedBssidKeys: string;
|
||||
}
|
||||
|
||||
export const WifiPoolModal: React.FC<WifiPoolModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
acceptedBssidKeys,
|
||||
}) => {
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedQ, setDebouncedQ] = useState('');
|
||||
const [items, setItems] = useState<WifiPoolItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const acceptedSet = useMemo(() => {
|
||||
return new Set(
|
||||
acceptedBssidKeys.split(',').map(s => s.trim()).filter(s => s.length === 12)
|
||||
);
|
||||
}, [acceptedBssidKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const t = setTimeout(() => setDebouncedQ(search.trim()), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [search, open]);
|
||||
|
||||
const loadPool = useCallback(async (q: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await apiFetchWifiPool(q, 80);
|
||||
setItems(res.data || []);
|
||||
} catch (e: any) {
|
||||
setItems([]);
|
||||
setError(e?.message || 'Không tải được kho WiFi');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
loadPool(debouncedQ);
|
||||
const interval = setInterval(() => loadPool(debouncedQ), 20000);
|
||||
return () => clearInterval(interval);
|
||||
}, [open, debouncedQ, loadPool]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSearch('');
|
||||
setDebouncedQ('');
|
||||
setItems([]);
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleSelect = (ssid: string, bssid: string) => {
|
||||
const key = bssidKey(bssid);
|
||||
if (!ssid.trim() || key.length !== 12 || acceptedSet.has(key)) return;
|
||||
onSelect(ssid, bssid);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay app-pool-overlay" onClick={onClose}>
|
||||
<div className="modal-container app-pool-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>Kho WiFi</h2>
|
||||
<p className="app-pool-modal-sub">
|
||||
Điểm phát thật từ máy sinh viên (SSID + BSSID). Chọn để thêm vào danh sách chấp nhận.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>Đóng</button>
|
||||
</div>
|
||||
|
||||
<div className="app-pool-toolbar">
|
||||
<input
|
||||
type="search"
|
||||
className="app-pool-search"
|
||||
placeholder="Tìm SSID hoặc BSSID..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => loadPool(debouncedQ)} disabled={loading}>
|
||||
{loading ? 'Đang tải...' : 'Làm mới'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="app-pool-body">
|
||||
{error ? (
|
||||
<div className="discovered-apps-empty discovered-apps-error">{error}</div>
|
||||
) : loading && items.length === 0 ? (
|
||||
<div className="app-pool-status">Đang tải...</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="app-pool-status">
|
||||
{debouncedQ ? `Không tìm thấy "${debouncedQ}"` : 'Chưa có WiFi nào trong kho.'}
|
||||
</div>
|
||||
) : (
|
||||
<table className="data-table app-pool-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>SSID</th>
|
||||
<th>BSSID (MAC)</th>
|
||||
<th>Lần ghi nhận</th>
|
||||
<th>Gần nhất</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(item => {
|
||||
const key = bssidKey(item.bssid || '');
|
||||
const added = key.length === 12 && acceptedSet.has(key);
|
||||
return (
|
||||
<tr key={item.id} className={added ? 'app-pool-row--added' : ''}>
|
||||
<td><code className="app-pool-kw">{item.ssid}</code></td>
|
||||
<td className="app-pool-muted" style={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||
{item.bssid || '—'}
|
||||
</td>
|
||||
<td className="app-pool-hit">{item.hitCount}×</td>
|
||||
<td className="app-pool-muted">
|
||||
{item.lastSeenAt ? new Date(item.lastSeenAt).toLocaleString('vi-VN') : '—'}
|
||||
</td>
|
||||
<td>
|
||||
{!item.bssid || key.length !== 12 ? (
|
||||
<span className="app-pool-muted">Thiếu BSSID</span>
|
||||
) : added ? (
|
||||
<span className="app-pool-added-tag">Đã có</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary app-pool-add-btn"
|
||||
onClick={() => handleSelect(item.ssid, item.bssid)}
|
||||
>
|
||||
+ Thêm
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="app-pool-footer">
|
||||
Hiển thị {items.length} kết quả{debouncedQ ? ` cho "${debouncedQ}"` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user