You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
761 lines
29 KiB
761 lines
29 KiB
import React, { useState, useEffect } from 'react';
|
|
import axios from 'axios';
|
|
import {
|
|
ShieldAlert,
|
|
Layers,
|
|
Newspaper,
|
|
History,
|
|
Plus,
|
|
Search,
|
|
Trash2,
|
|
Edit2,
|
|
Play,
|
|
X,
|
|
AlertTriangle,
|
|
RefreshCw,
|
|
ExternalLink,
|
|
Info
|
|
} from 'lucide-react';
|
|
|
|
function App() {
|
|
const [activeTab, setActiveTab] = useState('crawlers');
|
|
const [crawlers, setCrawlers] = useState([]);
|
|
const [ads, setAds] = useState([]);
|
|
const [logs, setLogs] = useState([]);
|
|
const [health, setHealth] = useState({ database: 'loading', redis: 'loading' });
|
|
|
|
// Loading states
|
|
const [loadingCrawlers, setLoadingCrawlers] = useState(true);
|
|
const [loadingAds, setLoadingAds] = useState(true);
|
|
const [loadingLogs, setLoadingLogs] = useState(true);
|
|
|
|
// Filters
|
|
const [filterTaskId, setFilterTaskId] = useState('');
|
|
const [filterOnlyFlagged, setFilterOnlyFlagged] = useState(false);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
|
|
// Modal states
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [editingCrawler, setEditingCrawler] = useState(null);
|
|
|
|
// Form state
|
|
const [formTitle, setFormTitle] = useState('');
|
|
const [formUrl, setFormUrl] = useState('');
|
|
const [formPrompt, setFormPrompt] = useState('');
|
|
const [formInterval, setFormInterval] = useState('15');
|
|
const [formTelegram, setFormTelegram] = useState('');
|
|
const [formStartHour, setFormStartHour] = useState('08:00');
|
|
const [formEndHour, setFormEndHour] = useState('23:00');
|
|
const [formIsActive, setFormIsActive] = useState(true);
|
|
const [formError, setFormError] = useState('');
|
|
|
|
// Toast alert
|
|
const [toast, setToast] = useState(null);
|
|
|
|
const showToast = (message, type = 'info') => {
|
|
setToast({ message, type });
|
|
setTimeout(() => {
|
|
setToast(null);
|
|
}, 4000);
|
|
};
|
|
|
|
// Initial Fetch & Health Check Polling
|
|
useEffect(() => {
|
|
fetchHealth();
|
|
fetchCrawlers();
|
|
|
|
const healthInterval = setInterval(fetchHealth, 15000);
|
|
return () => clearInterval(healthInterval);
|
|
}, []);
|
|
|
|
// Fetch ads and logs based on active tab and filtering criteria
|
|
useEffect(() => {
|
|
if (activeTab === 'ads') {
|
|
fetchAds();
|
|
} else if (activeTab === 'logs') {
|
|
fetchLogs();
|
|
}
|
|
}, [activeTab, filterTaskId, filterOnlyFlagged]);
|
|
|
|
// Dynamic status polling for active logs
|
|
useEffect(() => {
|
|
let logsInterval = null;
|
|
if (activeTab === 'logs' && logs.some(l => l.status === 'RUNNING')) {
|
|
logsInterval = setInterval(fetchLogs, 5000);
|
|
}
|
|
return () => {
|
|
if (logsInterval) clearInterval(logsInterval);
|
|
};
|
|
}, [activeTab, logs]);
|
|
|
|
const fetchHealth = async () => {
|
|
try {
|
|
const res = await axios.get('/api/health/');
|
|
setHealth({
|
|
database: res.data.database === 'healthy' ? 'healthy' : 'unhealthy',
|
|
redis: res.data.redis === 'healthy' ? 'healthy' : 'unhealthy'
|
|
});
|
|
} catch (err) {
|
|
setHealth({
|
|
database: err.response?.data?.database === 'healthy' ? 'healthy' : 'unhealthy',
|
|
redis: err.response?.data?.redis === 'healthy' ? 'healthy' : 'unhealthy'
|
|
});
|
|
}
|
|
};
|
|
|
|
const fetchCrawlers = async () => {
|
|
setLoadingCrawlers(true);
|
|
try {
|
|
const res = await axios.get('/api/crawlers/');
|
|
setCrawlers(res.data);
|
|
} catch (err) {
|
|
showToast('خطا در دریافت لیست کرالرها', 'danger');
|
|
console.error(err);
|
|
} finally {
|
|
setLoadingCrawlers(false);
|
|
}
|
|
};
|
|
|
|
const fetchAds = async () => {
|
|
setLoadingAds(true);
|
|
try {
|
|
let url = '/api/ads/';
|
|
const params = {};
|
|
if (filterTaskId) params.crawl_task = filterTaskId;
|
|
if (filterOnlyFlagged) params.is_flagged = 'true';
|
|
|
|
const res = await axios.get(url, { params });
|
|
setAds(res.data);
|
|
} catch (err) {
|
|
showToast('خطا در دریافت لیست آگهیها', 'danger');
|
|
console.error(err);
|
|
} finally {
|
|
setLoadingAds(false);
|
|
}
|
|
};
|
|
|
|
const fetchLogs = async () => {
|
|
setLoadingLogs(true);
|
|
try {
|
|
// In Django REST, runs are queried per-crawler. We fetch all crawlers,
|
|
// then request runs for each and merge/sort them.
|
|
const crawlersList = crawlers.length > 0 ? crawlers : (await axios.get('/api/crawlers/')).data;
|
|
const promises = crawlersList.map(async (c) => {
|
|
try {
|
|
const res = await axios.get(`/api/crawlers/${c.id}/runs/`);
|
|
return res.data.map(run => ({ ...run, crawler_title: c.title }));
|
|
} catch (e) {
|
|
return [];
|
|
}
|
|
});
|
|
const results = await Promise.all(promises);
|
|
const merged = results.flat().sort((a, b) => new Date(b.started_at) - new Date(a.started_at));
|
|
setLogs(merged);
|
|
} catch (err) {
|
|
showToast('خطا در دریافت تاریخچه اجرا', 'danger');
|
|
console.error(err);
|
|
} finally {
|
|
setLoadingLogs(false);
|
|
}
|
|
};
|
|
|
|
// Switch tabs
|
|
const switchTab = (tab) => {
|
|
setActiveTab(tab);
|
|
};
|
|
|
|
// Open Edit/Create Modal
|
|
const openCrawlerModal = (crawler = null) => {
|
|
setEditingCrawler(crawler);
|
|
setFormError('');
|
|
if (crawler) {
|
|
setFormTitle(crawler.title);
|
|
setFormUrl(crawler.divar_url);
|
|
setFormPrompt(crawler.detection_prompt);
|
|
setFormInterval(String(crawler.interval_minutes));
|
|
setFormTelegram(crawler.telegram_channel_id || '');
|
|
setFormStartHour(crawler.start_hour.substring(0, 5));
|
|
setFormEndHour(crawler.end_hour.substring(0, 5));
|
|
setFormIsActive(crawler.is_active);
|
|
} else {
|
|
setFormTitle('');
|
|
setFormUrl('');
|
|
setFormPrompt('');
|
|
setFormInterval('15');
|
|
setFormTelegram('');
|
|
setFormStartHour('08:00');
|
|
setFormEndHour('23:00');
|
|
setFormIsActive(true);
|
|
}
|
|
setModalOpen(true);
|
|
};
|
|
|
|
const closeCrawlerModal = () => {
|
|
setModalOpen(false);
|
|
setEditingCrawler(null);
|
|
};
|
|
|
|
const handleFormSubmit = async (e) => {
|
|
e.preventDefault();
|
|
setFormError('');
|
|
|
|
if (!formUrl.startsWith('https://divar.ir/s/')) {
|
|
setFormError("لینک دیوار باید با 'https://divar.ir/s/' شروع شود");
|
|
return;
|
|
}
|
|
|
|
const payload = {
|
|
title: formTitle,
|
|
divar_url: formUrl,
|
|
detection_prompt: formPrompt,
|
|
interval_minutes: parseInt(formInterval),
|
|
start_hour: `${formStartHour}:00`,
|
|
end_hour: `${formEndHour}:00`,
|
|
telegram_channel_id: formTelegram || null,
|
|
is_active: formIsActive
|
|
};
|
|
|
|
try {
|
|
if (editingCrawler) {
|
|
await axios.put(`/api/crawlers/${editingCrawler.id}/`, payload);
|
|
showToast('کرالر با موفقیت ویرایش شد', 'success');
|
|
} else {
|
|
await axios.post('/api/crawlers/', payload);
|
|
showToast('کرالر جدید با موفقیت ایجاد شد', 'success');
|
|
}
|
|
closeCrawlerModal();
|
|
fetchCrawlers();
|
|
} catch (err) {
|
|
const errorDetail = err.response?.data ? JSON.stringify(err.response.data) : 'خطا در ثبت اطلاعات';
|
|
setFormError(errorDetail);
|
|
}
|
|
};
|
|
|
|
const toggleCrawlerActive = async (crawler) => {
|
|
try {
|
|
await axios.patch(`/api/crawlers/${crawler.id}/`, {
|
|
is_active: !crawler.is_active
|
|
});
|
|
showToast(`کرالر ${!crawler.is_active ? 'فعال' : 'غیرفعال'} شد`, 'info');
|
|
fetchCrawlers();
|
|
} catch (err) {
|
|
showToast('خطا در تغییر وضعیت کرالر', 'danger');
|
|
}
|
|
};
|
|
|
|
const deleteCrawler = async (id) => {
|
|
if (!confirm('آیا از حذف این کرالر اطمینان دارید؟')) return;
|
|
try {
|
|
await axios.delete(`/api/crawlers/${id}/`);
|
|
showToast('کرالر با موفقیت حذف شد', 'success');
|
|
fetchCrawlers();
|
|
} catch (err) {
|
|
showToast('خطا در حذف کرالر', 'danger');
|
|
}
|
|
};
|
|
|
|
const triggerCrawler = async (id) => {
|
|
try {
|
|
const res = await axios.post(`/api/crawlers/${id}/trigger/`);
|
|
showToast('درخواست اجرای کرالر با موفقیت ارسال شد', 'success');
|
|
if (activeTab === 'logs') {
|
|
fetchLogs();
|
|
}
|
|
} catch (err) {
|
|
showToast('خطا در اجرای کرالر', 'danger');
|
|
}
|
|
};
|
|
|
|
// Format date helper
|
|
const formatDate = (isoStr) => {
|
|
if (!isoStr) return '-';
|
|
const date = new Date(isoStr);
|
|
return date.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }) + ' ' +
|
|
date.toLocaleDateString('fa-IR', { month: '2-digit', day: '2-digit' });
|
|
};
|
|
|
|
// Filter ads on client side based on Search Term
|
|
const getFilteredAds = () => {
|
|
if (!searchTerm) return ads;
|
|
const term = searchTerm.toLowerCase();
|
|
return ads.filter(adEval => {
|
|
const ad = adEval.ad;
|
|
return (
|
|
ad.title?.toLowerCase().includes(term) ||
|
|
ad.description?.toLowerCase().includes(term) ||
|
|
adEval.reason?.toLowerCase().includes(term)
|
|
);
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="app-container">
|
|
|
|
{/* Toast Notification */}
|
|
{toast && (
|
|
<div style={{
|
|
position: 'fixed',
|
|
top: '20px',
|
|
left: '20px',
|
|
backgroundColor: toast.type === 'success' ? 'var(--success)' : toast.type === 'danger' ? 'var(--danger)' : 'var(--accent)',
|
|
color: 'white',
|
|
padding: '10px 20px',
|
|
borderRadius: '8px',
|
|
zIndex: 9999,
|
|
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
|
direction: 'rtl',
|
|
animation: 'fadeIn 0.2s ease'
|
|
}}>
|
|
{toast.message}
|
|
</div>
|
|
)}
|
|
|
|
{/* Sidebar */}
|
|
<aside class="sidebar">
|
|
<div class="sidebar-brand">
|
|
<div class="brand-logo">
|
|
<ShieldAlert className="brand-icon" />
|
|
</div>
|
|
<div class="brand-info">
|
|
<h1>دیدبان دیوار</h1>
|
|
<span>کرال هوشمند با AI</span>
|
|
</div>
|
|
</div>
|
|
|
|
<nav class="sidebar-nav">
|
|
<button
|
|
className={`nav-item ${activeTab === 'crawlers' ? 'active' : ''}`}
|
|
onClick={() => switchTab('crawlers')}
|
|
>
|
|
<Layers />
|
|
<span>مدیریت کرالرها</span>
|
|
</button>
|
|
<button
|
|
className={`nav-item ${activeTab === 'ads' ? 'active' : ''}`}
|
|
onClick={() => switchTab('ads')}
|
|
>
|
|
<Newspaper />
|
|
<span>فید آگهیها</span>
|
|
</button>
|
|
<button
|
|
className={`nav-item ${activeTab === 'logs' ? 'active' : ''}`}
|
|
onClick={() => switchTab('logs')}
|
|
>
|
|
<History />
|
|
<span>تاریخچه اجرا</span>
|
|
</button>
|
|
</nav>
|
|
|
|
<div class="sidebar-footer">
|
|
<span class="version-label">نسخه ۱.۰.۰ (MVP)</span>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Main Content */}
|
|
<div className="main-content">
|
|
|
|
{/* Header */}
|
|
<header className="app-header">
|
|
<div className="header-title">
|
|
<h2>
|
|
{activeTab === 'crawlers' && 'مدیریت کرالرها'}
|
|
{activeTab === 'ads' && 'فید آگهیهای دیوار'}
|
|
{activeTab === 'logs' && 'تاریخچه اجرای کرالرها'}
|
|
</h2>
|
|
</div>
|
|
|
|
<div className="header-actions">
|
|
<div className="system-health">
|
|
<div className="health-item">
|
|
<span className={`dot ${health.database === 'healthy' ? 'green' : 'red'}`}></span>
|
|
<span>دیتابیس</span>
|
|
</div>
|
|
<div className="health-item">
|
|
<span className={`dot ${health.redis === 'healthy' ? 'green' : 'red'}`}></span>
|
|
<span>ردیس</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Viewport */}
|
|
<main className="view-viewport">
|
|
|
|
{/* TAB 1: Crawlers View */}
|
|
{activeTab === 'crawlers' && (
|
|
<div className="tab-view active">
|
|
<div className="view-actions-bar">
|
|
<button className="btn btn-primary" onClick={() => openCrawlerModal()}>
|
|
<Plus />
|
|
<span>تعریف کرالر جدید</span>
|
|
</button>
|
|
</div>
|
|
|
|
{loadingCrawlers ? (
|
|
<div className="loading-state">
|
|
<div className="spinner"></div>
|
|
<p>در حال بارگذاری کرالرها...</p>
|
|
</div>
|
|
) : crawlers.length === 0 ? (
|
|
<div className="empty-state">
|
|
<AlertTriangle />
|
|
<p>هیچ کرالری تعریف نشده است. جهت پایش دیوار، یک کرالر جدید ایجاد کنید.</p>
|
|
</div>
|
|
) : (
|
|
<div className="crawlers-grid">
|
|
{crawlers.map(c => (
|
|
<div className="card" key={c.id}>
|
|
<div className="card-header">
|
|
<h4 className="card-title" title={c.title}>{c.title}</h4>
|
|
<div className="toggle-group">
|
|
<label className="switch-container">
|
|
<input
|
|
type="checkbox"
|
|
checked={c.is_active}
|
|
onChange={() => toggleCrawlerActive(c)}
|
|
/>
|
|
<span className="switch-slider"></span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card-body">
|
|
<div className="card-field">
|
|
<span className="card-field-label">لینک فیلتر دیوار</span>
|
|
<span className="card-field-value url" title={c.divar_url}>{c.divar_url}</span>
|
|
</div>
|
|
<div className="card-field">
|
|
<span className="card-field-label">پرامپت ارزیابی AI</span>
|
|
<span className="card-field-value prompt" title={c.detection_prompt}>{c.detection_prompt}</span>
|
|
</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
|
<div className="card-field">
|
|
<span className="card-field-label">فاصله زمانی پایش</span>
|
|
<span className="card-field-value arabic-num-font">{c.interval_minutes} دقیقه</span>
|
|
</div>
|
|
<div className="card-field">
|
|
<span className="card-field-label">بازه زمانی مجاز</span>
|
|
<span className="card-field-value arabic-num-font">{c.start_hour.substring(0,5)} الی {c.end_hour.substring(0,5)}</span>
|
|
</div>
|
|
</div>
|
|
{c.telegram_channel_id && (
|
|
<div className="card-field">
|
|
<span className="card-field-label">کانال اعلانات تلگرام</span>
|
|
<span className="card-field-value font-mono">{c.telegram_channel_id}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="card-footer">
|
|
<button className="btn btn-secondary btn-icon-only" title="اجرای فوری" onClick={() => triggerCrawler(c.id)}>
|
|
<Play style={{ fill: 'currentColor' }} />
|
|
</button>
|
|
|
|
<div className="card-actions">
|
|
<button className="btn btn-secondary btn-icon-only" title="ویرایش" onClick={() => openCrawlerModal(c)}>
|
|
<Edit2 />
|
|
</button>
|
|
<button className="btn btn-danger btn-icon-only" title="حذف" onClick={() => deleteCrawler(c.id)}>
|
|
<Trash2 />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* TAB 2: Ads View */}
|
|
{activeTab === 'ads' && (
|
|
<div className="tab-view active">
|
|
<div className="ads-filters-bar">
|
|
<div className="search-box">
|
|
<Search className="search-icon" />
|
|
<input
|
|
type="text"
|
|
placeholder="جستجو در فید آگهیها..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="filters-group">
|
|
<div className="filter-item">
|
|
<label>فیلتر کرالر:</label>
|
|
<select value={filterTaskId} onChange={(e) => setFilterTaskId(e.target.value)}>
|
|
<option value="">همه کرالرها</option>
|
|
{crawlers.map(c => (
|
|
<option key={c.id} value={c.id}>{c.title}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="filter-item toggle-filter">
|
|
<label className="switch-container">
|
|
<input
|
|
type="checkbox"
|
|
checked={filterOnlyFlagged}
|
|
onChange={(e) => setFilterOnlyFlagged(e.target.checked)}
|
|
/>
|
|
<span class="switch-slider"></span>
|
|
</label>
|
|
<span className="toggle-label">فقط پرچمگذاری شده (AI)</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{loadingAds ? (
|
|
<div className="loading-state">
|
|
<div className="spinner"></div>
|
|
<p>در حال بارگذاری آگهیها...</p>
|
|
</div>
|
|
) : getFilteredAds().length === 0 ? (
|
|
<div className="empty-state">
|
|
<AlertTriangle />
|
|
<p>هیچ آگهی با معیارهای فیلتر یافت نشد.</p>
|
|
</div>
|
|
) : (
|
|
<div className="ads-grid">
|
|
{getFilteredAds().map(adEval => (
|
|
<div className={`card ad-card ${adEval.is_flagged ? 'flagged' : ''}`} key={adEval.id}>
|
|
<div className="ad-meta-top">
|
|
<span className="ad-published-at">{formatDate(adEval.evaluated_at)}</span>
|
|
{adEval.ad.price && (
|
|
<span className="ad-price">{adEval.ad.price}</span>
|
|
)}
|
|
</div>
|
|
|
|
<h4 className="card-title" style={{ maxWidth: '100%', marginBottom: '0.5rem' }}>
|
|
{adEval.ad.title}
|
|
</h4>
|
|
|
|
<p className="ad-desc">{adEval.ad.description}</p>
|
|
|
|
{adEval.is_flagged && (
|
|
<div className="ai-flag-box">
|
|
<div className="ai-flag-header">
|
|
<span>تحلیل هوش مصنوعی (پرچمگذاری شده)</span>
|
|
{adEval.confidence && (
|
|
<span className="ai-confidence-badge">اطمینان: {Math.round(adEval.confidence * 100)}٪</span>
|
|
)}
|
|
</div>
|
|
<p className="ai-flag-reason">{adEval.reason}</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="ad-tag-list">
|
|
<span className="ad-tag">توکن: {adEval.ad.divar_token}</span>
|
|
{adEval.ad.category && (
|
|
<span className="ad-tag">{adEval.ad.category}</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="card-footer" style={{ borderTop: '1px solid var(--border-color)', paddingTop: '1rem', marginTop: 'auto' }}>
|
|
<a href={adEval.ad.url} target="_blank" rel="noopener noreferrer" className="btn btn-secondary btn-icon-only" title="مشاهده در دیوار" style={{ color: 'var(--info)' }}>
|
|
<ExternalLink />
|
|
</a>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* TAB 3: Logs View */}
|
|
{activeTab === 'logs' && (
|
|
<div className="tab-view active">
|
|
{loadingLogs ? (
|
|
<div className="loading-state">
|
|
<div className="spinner"></div>
|
|
<p>در حال دریافت تاریخچه اجرا...</p>
|
|
</div>
|
|
) : logs.length === 0 ? (
|
|
<div className="empty-state">
|
|
<AlertTriangle />
|
|
<p>هیچ لاگ تاریخی ثبت نشده است.</p>
|
|
</div>
|
|
) : (
|
|
<div className="table-container">
|
|
<table className="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>کرالر</th>
|
|
<th>شروع اجرا</th>
|
|
<th>پایان اجرا</th>
|
|
<th>وضعیت</th>
|
|
<th>آگهی یافت شده</th>
|
|
<th>پردازش AI</th>
|
|
<th>پرچمگذاری شده</th>
|
|
<th>لاگ خطا</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{logs.map(run => (
|
|
<tr key={run.id}>
|
|
<td style={{ fontWeight: 600 }}>{run.crawler_title}</td>
|
|
<td className="arabic-num-font">{formatDate(run.started_at)}</td>
|
|
<td className="arabic-num-font">{formatDate(run.finished_at)}</td>
|
|
<td>
|
|
<span className={`badge-status ${run.status.toLowerCase()}`}>
|
|
{run.status === 'RUNNING' && 'در حال اجرا'}
|
|
{run.status === 'SUCCESS' && 'موفق'}
|
|
{run.status === 'FAILED' && 'ناموفق'}
|
|
</span>
|
|
</td>
|
|
<td className="arabic-num-font">{run.ads_fetched_count}</td>
|
|
<td className="arabic-num-font">{run.ads_evaluated_count}</td>
|
|
<td className="arabic-num-font" style={{ color: run.ads_flagged_count > 0 ? 'var(--accent)' : 'inherit', fontWeight: run.ads_flagged_count > 0 ? 700 : 'normal' }}>
|
|
{run.ads_flagged_count}
|
|
</td>
|
|
<td style={{ maxWidth: '180px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={run.error_log}>
|
|
{run.error_log || '-'}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
</main>
|
|
</div>
|
|
|
|
{/* Form Dialog Modal */}
|
|
<div className={`modal-overlay ${modalOpen ? 'open' : ''}`}>
|
|
<div className="modal-card">
|
|
<div className="modal-header">
|
|
<h3>{editingCrawler ? 'ویرایش کرالر' : 'تعریف کرالر جدید'}</h3>
|
|
<button className="close-btn" onClick={closeCrawlerModal}>
|
|
<X />
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleFormSubmit}>
|
|
<div className="modal-body">
|
|
{formError && (
|
|
<div style={{
|
|
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
|
border: '1px solid var(--danger)',
|
|
padding: '10px',
|
|
borderRadius: '6px',
|
|
fontSize: '0.85rem',
|
|
color: '#fca5a5'
|
|
}}>
|
|
{formError}
|
|
</div>
|
|
)}
|
|
|
|
<div className="form-group">
|
|
<label>عنوان کرالر</label>
|
|
<input
|
|
type="text"
|
|
value={formTitle}
|
|
onChange={(e) => setFormTitle(e.target.value)}
|
|
placeholder="مثال: رهن کامل فوری نیاوران"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label>لینک فیلتر دیوار (باید با https://divar.ir/s/ شروع شود)</label>
|
|
<input
|
|
type="url"
|
|
value={formUrl}
|
|
onChange={(e) => setFormUrl(e.target.value)}
|
|
placeholder="https://divar.ir/s/tehran/rent-apartment/niavaran?..."
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label>پرامپت ارزیابی هوش مصنوعی (AI)</label>
|
|
<textarea
|
|
value={formPrompt}
|
|
onChange={(e) => setFormPrompt(e.target.value)}
|
|
placeholder="بررسی کن آیا آگهی پول لازم و رهن کامل است یا خیر..."
|
|
rows={3}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<div className="form-group">
|
|
<label>بازه زمانی پایش</label>
|
|
<select value={formInterval} onChange={(e) => setFormInterval(e.target.value)}>
|
|
<option value="5">۵ دقیقه</option>
|
|
<option value="15">۱۵ دقیقه</option>
|
|
<option value="30">۳۰ دقیقه</option>
|
|
<option value="60">۱ ساعت</option>
|
|
<option value="120">۲ ساعت</option>
|
|
<option value="180">۳ ساعت</option>
|
|
<option value="240">۴ ساعت</option>
|
|
<option value="300">۵ ساعت</option>
|
|
<option value="360">۶ ساعت</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label>آیدی کانال تلگرام (اختیاری)</label>
|
|
<input
|
|
type="text"
|
|
value={formTelegram}
|
|
onChange={(e) => setFormTelegram(e.target.value)}
|
|
placeholder="مثال: @my_alerts"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<div className="form-group">
|
|
<label>ساعت شروع پنجره مجاز</label>
|
|
<input
|
|
type="time"
|
|
value={formStartHour}
|
|
onChange={(e) => setFormStartHour(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-group">
|
|
<label>ساعت پایان پنجره مجاز</label>
|
|
<input
|
|
type="time"
|
|
value={formEndHour}
|
|
onChange={(e) => setFormEndHour(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-group toggle-group">
|
|
<span className="toggle-label" style={{ fontSize: '0.85rem' }}>کرالر فعال باشد</span>
|
|
<label className="switch-container">
|
|
<input
|
|
type="checkbox"
|
|
checked={formIsActive}
|
|
onChange={(e) => setFormIsActive(e.target.checked)}
|
|
/>
|
|
<span class="switch-slider"></span>
|
|
</label>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div className="modal-footer">
|
|
<button type="button" className="btn btn-secondary" onClick={closeCrawlerModal}>انصراف</button>
|
|
<button type="submit" className="btn btn-primary">ذخیره</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App;
|