168 lines
5.6 KiB
TypeScript
168 lines
5.6 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import { apiFetchAppPool } from '../api';
|
||
import type { AppPoolItem } from '../api';
|
||
|
||
interface AppPoolModalProps {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
onSelect: (keyword: string) => void;
|
||
allowedApps: string;
|
||
}
|
||
|
||
const formatWhen = (iso: string) => {
|
||
if (!iso) return '—';
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return '—';
|
||
return d.toLocaleString('vi-VN', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||
};
|
||
|
||
export const AppPoolModal: React.FC<AppPoolModalProps> = ({
|
||
open,
|
||
onClose,
|
||
onSelect,
|
||
allowedApps,
|
||
}) => {
|
||
const [search, setSearch] = useState('');
|
||
const [debouncedQ, setDebouncedQ] = useState('');
|
||
const [items, setItems] = useState<AppPoolItem[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const allowedSet = useMemo(() => {
|
||
return new Set(
|
||
allowedApps.split(',').map(s => s.trim().toLowerCase()).filter(Boolean)
|
||
);
|
||
}, [allowedApps]);
|
||
|
||
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 apiFetchAppPool(q, 80);
|
||
setItems(res.data || []);
|
||
} catch (e: any) {
|
||
setItems([]);
|
||
setError(e?.message || 'Không tải được kho ứng dụng');
|
||
} 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 = (keyword: string) => {
|
||
const kw = keyword.trim().toLowerCase();
|
||
if (!kw || allowedSet.has(kw)) return;
|
||
onSelect(kw);
|
||
};
|
||
|
||
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 ứng dụng</h2>
|
||
<p className="app-pool-modal-sub">
|
||
Toàn hệ thống — app bị chặn từ mọi lớp. Chọn để thêm vào whitelist lớp hiện tại.
|
||
</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 theo tên app, keyword, tiêu đề cửa sổ..."
|
||
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ó app nào trong kho.'}
|
||
</div>
|
||
) : (
|
||
<table className="data-table app-pool-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Keyword</th>
|
||
<th>Tiến trình</th>
|
||
<th>Tiêu đề</th>
|
||
<th>Lần chặn</th>
|
||
<th>Gần nhất</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{items.map(app => {
|
||
const added = allowedSet.has(app.keyword.toLowerCase());
|
||
return (
|
||
<tr key={app.id} className={added ? 'app-pool-row--added' : ''}>
|
||
<td><code className="app-pool-kw">{app.keyword}</code></td>
|
||
<td className="app-pool-muted">{app.processName}</td>
|
||
<td className="app-pool-muted app-pool-title" title={app.windowTitle}>{app.windowTitle || '—'}</td>
|
||
<td className="app-pool-hit">{app.hitCount}×</td>
|
||
<td className="app-pool-muted">{formatWhen(app.lastSeenAt)}</td>
|
||
<td>
|
||
{added ? (
|
||
<span className="app-pool-added-tag">Đã có</span>
|
||
) : (
|
||
<button type="button" className="btn btn-primary app-pool-add-btn" onClick={() => handleSelect(app.keyword)}>
|
||
+ Thêm
|
||
</button>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
|
||
<div className="app-pool-footer">
|
||
Hiển thị {items.length} kết quả{debouncedQ ? ` cho "${debouncedQ}"` : ''} (tối đa 80 mỗi lần tải)
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|