Files
hamkadr/src/JobsMedical.Web/Pages/Admin/Settings.cshtml
T
soroush.asadi bb8c6c3be5
CI/CD / CI · dotnet build (push) Successful in 31s
CI/CD / Deploy · hamkadr (push) Successful in 3m15s
Add medboom.ir as an ingestion source (doctor/dentist-heavy, VPN-free)
New MedboomListingSource: a WordPress medical-classifieds board crawled like medjobs
(wp-sitemap.xml -> posts-post-N.xml, newest first), filtered to clinical-role slugs and
Tehran-only for launch. medboom skews toward doctors/dentists/pharmacists and carries both
hiring and availability posts, so it directly broadens the role mix the nurse-heavy Divar
content lacks. Iranian-hosted -> no proxy/VPN needed (relevant now that Telegram is off).

Wired like the other sources: AppSetting toggles (MedboomEnabled/MaxAds/UseProxy) + EF
migration, SettingsService persistence, admin Settings UI, DI registration. Off by default.
Validated against live data: Tehran clinical ads at named clinics (pharmacy/dental/etc.).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 11:18:56 +03:30

291 lines
24 KiB
Plaintext

@page
@model JobsMedical.Web.Pages.Admin.SettingsModel
@{
ViewData["Title"] = "تنظیمات سامانه";
}
<div class="page-head">
<div class="container">
<h1>تنظیمات سامانه</h1>
<p class="muted"><a asp-page="/Admin/Index">← بازگشت به صف</a></p>
</div>
</div>
<div class="container section">
@if (Model.Saved is not null) { <div class="alert alert-success">✓ @Model.Saved</div> }
@if (Model.DemoMsg is not null) { <div class="alert alert-success">@Model.DemoMsg</div> }
@if (Model.SmsTest is not null) { <div class="alert alert-success">@Model.SmsTest</div> }
@if (Model.ProxyTest is not null) { <div class="alert alert-success">@Model.ProxyTest</div> }
@if (Model.AiTest is not null)
{
<div class="alert @(Model.AiTest.StartsWith("✅") ? "alert-success" : "alert-error")"
style="white-space:pre-wrap; word-break:break-word;">@Model.AiTest</div>
}
<form method="post">
<div class="settings-layout">
<!-- Sidebar tabs -->
<nav class="settings-tabs" id="settingsTabs">
<button type="button" data-tab="publish" class="active"><span class="tab-ico">📢</span> انتشار و هوش مصنوعی</button>
<button type="button" data-tab="sources"><span class="tab-ico">📡</span> منابع جمع‌آوری</button>
<button type="button" data-tab="channels"><span class="tab-ico">🔔</span> کانال‌های اعلان</button>
<button type="button" data-tab="sms"><span class="tab-ico">✉️</span> پیامک</button>
<button type="button" data-tab="push"><span class="tab-ico">📲</span> پوش مرورگری</button>
<button type="button" data-tab="map"><span class="tab-ico">🗺️</span> نقشه</button>
<button type="button" data-tab="demo"><span class="tab-ico">🧪</span> حالت نمایشی</button>
</nav>
<!-- Panels -->
<div>
<!-- PUBLISH + AI -->
<section class="card card-pad settings-panel active" data-panel="publish">
<h3>حالت انتشار</h3>
<div class="filter-group">
<label>نحوه افزودن آگهی‌ها به سایت</label>
<select name="Mode">
<option value="0" selected="@(Model.Mode == JobsMedical.Web.Models.IngestionMode.Manual)">دستی — همه به صف بررسی می‌روند</option>
<option value="1" selected="@(Model.Mode == JobsMedical.Web.Models.IngestionMode.Automatic)">خودکار — موارد تأییدشده مستقیم منتشر می‌شوند</option>
</select>
</div>
<div class="filter-group">
<label>حداقل درصد اطمینان برای انتشار خودکار (بدون هوش مصنوعی)</label>
<input type="number" name="AutoPublishMinConfidence" min="0" max="100" value="@Model.AutoPublishMinConfidence" dir="ltr" />
<p class="muted" style="font-size:12px; margin:4px 0 0;">در حالت خودکار و بدون AI، آگهی‌هایی با اطمینان بالاتر از این مقدار خودکار منتشر می‌شوند.</p>
</div>
<hr style="border:none; border-top:1px solid var(--line); margin:18px 0;" />
<h3>لایه هوش مصنوعی (اختیاری)</h3>
<label class="toggle-row">
<input type="checkbox" name="AiEnabled" value="true" checked="@Model.AiEnabled" />
<span class="t-body"><span>فعال‌سازی بررسی با هوش مصنوعی قبل از انتشار</span>
<span class="t-hint">هر آگهی پیش از انتشار توسط مدل بررسی و تأیید/رد/ساختارمند می‌شود.</span></span>
</label>
<div class="filter-group">
<label>آدرس سرویس (سازگار با OpenAI)</label>
<input type="text" name="AiEndpoint" value="@Model.AiEndpoint" placeholder="https://host/v1/chat/completions" dir="ltr" />
<p class="muted" style="font-size:12px; margin:4px 0 0;">می‌تواند یک مدل self-hosted یا سرویس داخلی باشد (OpenAI/Anthropic در ایران مسدودند).</p>
</div>
<div class="filter-group" style="display:flex; gap:8px;">
<div style="flex:1;"><label>کلید API</label><input type="password" name="AiApiKey" value="@Model.AiApiKey" dir="ltr" /></div>
<div style="flex:1;"><label>نام مدل</label><input type="text" name="AiModel" value="@Model.AiModel" dir="ltr" /></div>
</div>
<div class="filter-group">
<label>دستور و چارچوب هوش مصنوعی (Prompt / Framework) — ثابت 🔒</label>
<textarea rows="14" dir="rtl" readonly
style="background:var(--bg); color:var(--muted); cursor:not-allowed;">@JobsMedical.Web.Models.AppSetting.DefaultPrompt</textarea>
<p class="muted" style="font-size:12px; margin:4px 0 0;">این دستور در کد ثابت شده و قابل ویرایش نیست تا دسته‌بندی و استخراج همیشه درست بماند. یک «اسکیمای خروجی JSON» هم به‌صورت خودکار به انتهای آن افزوده می‌شود.</p>
</div>
<label class="toggle-row">
<input type="checkbox" name="AiAutoApprove" value="true" checked="@Model.AiAutoApprove" />
<span class="t-body"><span>در حالت خودکار، آگهی‌هایی که AI تأیید می‌کند مستقیم منتشر شوند</span></span>
</label>
<label class="toggle-row">
<input type="checkbox" name="AiUseProxy" value="true" checked="@Model.AiUseProxy" />
<span class="t-body"><span>ارسال درخواست هوش مصنوعی از طریق پروکسی</span>
<span class="t-hint">برای دسترسی به سرویس‌هایی مثل OpenAI از داخل ایران؛ از همان آدرس پروکسی تب «منابع جمع‌آوری» استفاده می‌کند.</span></span>
</label>
<button type="submit" asp-page-handler="TestAi" class="btn btn-outline" style="margin-top:6px;">🤖 تست هوش مصنوعی (روی یک آگهی نمونه)</button>
<p class="muted" style="font-size:11px; margin:4px 0 0;">یک آگهی نمونه را به مدل می‌فرستد و تصمیم/استخراج آن را نشان می‌دهد. (ابتدا کلید و آدرس را ذخیره کن.)</p>
</section>
<!-- SOURCES -->
<section class="card card-pad settings-panel" data-panel="sources">
<h3>منابع جمع‌آوری</h3>
<label class="toggle-row">
<input type="checkbox" name="AutoIngestEnabled" value="true" checked="@Model.AutoIngestEnabled" />
<span class="t-body"><span>اجرای خودکار جمع‌آوری روی زمان‌بند</span></span>
</label>
<div class="filter-group">
<label>فاصله اجرای خودکار (دقیقه)</label>
<input type="number" name="IngestIntervalMinutes" min="1" value="@Model.IngestIntervalMinutes" dir="ltr" />
</div>
<p class="muted" style="font-size:12px; margin:0 0 4px;">هر منبع را جداگانه روشن/خاموش و تنظیم کن.</p>
<div class="source-box">
<label class="toggle-row">
<input type="checkbox" name="TelegramEnabled" value="true" checked="@Model.TelegramEnabled" />
<span class="t-body"><span>📨 تلگرام</span><span class="t-hint">کانال‌های عمومی — بدون توکن.</span></span>
</label>
<div class="filter-group">
<label>یوزرنیم کانال‌ها (هر خط یک کانال)</label>
<textarea name="TelegramChannels" rows="3" dir="ltr" placeholder="shift_channel&#10;another_channel">@Model.TelegramChannels</textarea>
<label class="proxy-toggle"><input type="checkbox" name="TelegramUseProxy" value="true" checked="@Model.TelegramUseProxy" /> از پروکسی استفاده شود</label>
</div>
</div>
<div class="source-box">
<label class="toggle-row">
<input type="checkbox" name="BaleEnabled" value="true" checked="@Model.BaleEnabled" />
<span class="t-body"><span>💬 بله</span><span class="t-hint">بات باید عضو کانال باشد.</span></span>
</label>
<div class="filter-group"><label>توکن بات بله</label><input type="password" name="BaleBotToken" value="@Model.BaleBotToken" dir="ltr" />
<label class="proxy-toggle"><input type="checkbox" name="BaleUseProxy" value="true" checked="@Model.BaleUseProxy" /> از پروکسی استفاده شود</label>
</div>
</div>
<div class="source-box">
<label class="toggle-row">
<input type="checkbox" name="DivarEnabled" value="true" checked="@Model.DivarEnabled" />
<span class="t-body"><span>🟥 دیوار</span></span>
</label>
<div class="filter-group" style="display:flex; gap:8px;">
<div style="flex:0 0 120px;"><label>شهر (slug)</label><input type="text" name="DivarCity" value="@Model.DivarCity" dir="ltr" placeholder="tehran" /></div>
<div style="flex:1;"><label>عبارت‌های جستجو (هر خط یکی)</label><textarea name="DivarQueries" rows="3">@Model.DivarQueries</textarea></div>
</div>
<label class="proxy-toggle"><input type="checkbox" name="DivarUseProxy" value="true" checked="@Model.DivarUseProxy" /> از پروکسی استفاده شود</label>
</div>
<div class="source-box">
<label class="toggle-row">
<input type="checkbox" name="MedjobsEnabled" value="true" checked="@Model.MedjobsEnabled" />
<span class="t-body"><span>🩺 مدجابز (medjobs.ir)</span><span class="t-hint">خواندن کامل آگهی‌ها از سایت‌مپ + استخراج شماره.</span></span>
</label>
<div class="filter-group"><label>حداکثر آگهی در هر اجرا</label><input type="number" name="MedjobsMaxAds" min="1" max="500" value="@Model.MedjobsMaxAds" dir="ltr" />
<label class="proxy-toggle"><input type="checkbox" name="MedjobsUseProxy" value="true" checked="@Model.MedjobsUseProxy" /> از پروکسی استفاده شود</label>
</div>
</div>
<div class="source-box">
<label class="toggle-row">
<input type="checkbox" name="IranEstekhdamEnabled" value="true" checked="@Model.IranEstekhdamEnabled" />
<span class="t-body"><span>🏥 ایران‌استخدام (iranestekhdam.ir)</span><span class="t-hint">آگهی‌های استخدامِ مراکز درمانیِ نام‌دار از سایت‌مپِ ماهانه؛ فقط نقش‌های بالینی.</span></span>
</label>
<div class="filter-group"><label>حداکثر آگهی در هر اجرا</label><input type="number" name="IranEstekhdamMaxAds" min="1" max="500" value="@Model.IranEstekhdamMaxAds" dir="ltr" />
<label class="proxy-toggle"><input type="checkbox" name="IranEstekhdamUseProxy" value="true" checked="@Model.IranEstekhdamUseProxy" /> از پروکسی استفاده شود</label>
</div>
</div>
<div class="source-box">
<label class="toggle-row">
<input type="checkbox" name="MedboomEnabled" value="true" checked="@Model.MedboomEnabled" />
<span class="t-body"><span>🩺 مدبوم (medboom.ir)</span><span class="t-hint">آگهی‌های علوم پزشکی (بیشتر پزشک/دندانپزشک)، استخدام و آماده‌به‌کار؛ بدون نیاز به فیلترشکن.</span></span>
</label>
<div class="filter-group"><label>حداکثر آگهی در هر اجرا</label><input type="number" name="MedboomMaxAds" min="1" max="500" value="@Model.MedboomMaxAds" dir="ltr" />
<label class="proxy-toggle"><input type="checkbox" name="MedboomUseProxy" value="true" checked="@Model.MedboomUseProxy" /> از پروکسی استفاده شود</label>
</div>
</div>
<div class="source-box">
<label class="toggle-row">
<input type="checkbox" name="WebsitesEnabled" value="true" checked="@Model.WebsitesEnabled" />
<span class="t-body"><span>🌐 وب‌سایت‌ها</span><span class="t-hint">آدرس‌های دلخواه.</span></span>
</label>
<div class="filter-group">
<label>آدرس صفحه‌ها (هر خط یک URL)</label>
<textarea name="WebsiteUrls" rows="3" dir="ltr" placeholder="https://example.ir/jobs/123">@Model.WebsiteUrls</textarea>
<label class="proxy-toggle"><input type="checkbox" name="WebsitesUseProxy" value="true" checked="@Model.WebsitesUseProxy" /> از پروکسی استفاده شود</label>
</div>
</div>
<div class="source-box">
<h4 style="margin:0 0 8px;">🛡️ پروکسی (Xray/V2Ray)</h4>
<div class="filter-group">
<label>آدرس پروکسی محلی</label>
<input type="text" name="IngestProxyUrl" value="@Model.IngestProxyUrl" dir="ltr" placeholder="socks5://xray:10808" />
<p class="muted" style="font-size:12px; margin:4px 0 0;">یک کلاینت Xray/V2Ray کانفیگ vmess/vless/trojan تو را به یک پروکسی محلی تبدیل می‌کند (socks5:// یا socks4:// یا http://). <strong>هر منبع جداگانه</strong> با تیکِ «از پروکسی استفاده شود» تعیین می‌کند که از این پروکسی عبور کند یا نه.</p>
<button type="submit" asp-page-handler="TestProxy" class="btn btn-outline" style="margin-top:8px;">🔌 تست اتصال VPN/پروکسی</button>
<p class="muted" style="font-size:11px; margin:4px 0 0;">از طریق پروکسی به یک سایت فیلترشده وصل می‌شود؛ موفقیت یعنی تونل برقرار است. (ابتدا آدرس را ذخیره کن.)</p>
</div>
</div>
</section>
<!-- CHANNELS -->
<section class="card card-pad settings-panel" data-panel="channels">
<h3>کانال‌های اعلان (فعال / غیرفعال)</h3>
<p class="muted" style="font-size:12px; margin-top:0;">روشن/خاموش‌کردن هر کانال ارسال اعلان به کاربران. کلیدها در تب‌های «پیامک» و «پوش مرورگری».</p>
<label class="toggle-row">
<input type="checkbox" name="WebNotificationsEnabled" value="true" checked="@Model.WebNotificationsEnabled" />
<span class="t-body"><span>اعلان‌های وب / درون‌برنامه‌ای (زنگوله + نوتیف زنده)</span>
<span class="t-hint">توصیه‌شده برای ایران — از سرور خودمان، بدون سرویس گوگل.</span></span>
</label>
<label class="toggle-row">
<input type="checkbox" name="SmsEnabled" value="true" checked="@Model.SmsEnabled" />
<span class="t-body"><span>پیامک (SMS) — کاوه‌نگار</span>
<span class="t-hint">برای کد ورود و اعلان‌های مهم.</span></span>
</label>
<label class="toggle-row">
<input type="checkbox" name="PushEnabled" value="true" checked="@Model.PushEnabled" />
<span class="t-body"><span>پوش مرورگر (Web Push)</span>
<span class="t-hint">برای اعلان هنگام بسته‌بودن برنامه؛ از سرویس مرورگر (گوگل) عبور می‌کند و در ایران اغلب فیلتر است.</span></span>
</label>
</section>
<!-- SMS -->
<section class="card card-pad settings-panel" data-panel="sms">
<h3>پیامک ورود (OTP) — کاوه‌نگار</h3>
<p class="muted" style="font-size:12px; margin-top:0;">روشن/خاموش‌کردن این کانال در تب «کانال‌های اعلان». (در صورت خاموش بودن، کد ورود روی صفحه نمایش داده می‌شود.)</p>
<div class="filter-group"><label>کلید API کاوه‌نگار</label><input type="password" name="SmsApiKey" value="@Model.SmsApiKey" dir="ltr" /></div>
<div class="filter-group" style="display:flex; gap:8px;">
<div style="flex:1;"><label>نام تمپلیت verify (ترجیحی)</label><input type="text" name="SmsTemplate" value="@Model.SmsTemplate" dir="ltr" placeholder="otp" /></div>
<div style="flex:1;"><label>خط ارسال (در نبود تمپلیت)</label><input type="text" name="SmsSender" value="@Model.SmsSender" dir="ltr" placeholder="10008..." /></div>
</div>
<p class="muted" style="font-size:12px;">روش پیشنهادی: تمپلیت verify/lookup با متغیر %token. اگر تمپلیت خالی باشد، پیامک ساده با خط ارسال فرستاده می‌شود.</p>
<hr style="border:none; border-top:1px solid var(--line); margin:14px 0;" />
<div class="filter-group" style="display:flex; gap:8px; align-items:end; flex-wrap:wrap;">
<div style="flex:1; min-width:160px;"><label>ارسال پیامک آزمایشی به</label><input type="tel" name="TestPhone" dir="ltr" placeholder="۰۹۱۲ ..." /></div>
<button type="submit" asp-page-handler="TestSms" class="btn btn-outline">ارسال آزمایشی</button>
</div>
</section>
<!-- PUSH -->
<section class="card card-pad settings-panel" data-panel="push">
<h3>اعلان‌ها (Web Push / PWA)</h3>
<p class="muted" style="font-size:12px; margin-top:0;">روشن/خاموش‌کردن این کانال در تب «کانال‌های اعلان». اینجا فقط کلیدهای VAPID را وارد کن.</p>
<div class="filter-group"><label>VAPID Public Key</label><input type="text" name="VapidPublicKey" value="@Model.VapidPublicKey" dir="ltr" /></div>
<div class="filter-group"><label>VAPID Private Key</label><input type="password" name="VapidPrivateKey" value="@Model.VapidPrivateKey" dir="ltr" /></div>
<div class="filter-group"><label>VAPID Subject</label><input type="text" name="VapidSubject" value="@Model.VapidSubject" dir="ltr" placeholder="mailto:admin@@hamkadr.ir" /></div>
<p class="muted" style="font-size:12px;">جفت‌کلید VAPID را یک‌بار بساز (web-push). بدون آن، اعلان محلی روی دستگاه کار می‌کند ولی ارسال از سرور نیاز به کلید دارد.</p>
</section>
<!-- MAP -->
<section class="card card-pad settings-panel" data-panel="map">
<h3>نقشه (نشان)</h3>
<div class="filter-group">
<label>کلید API نقشه نشان (web map.js)</label>
<input type="text" name="NeshanMapKey" value="@Model.NeshanMapKey" dir="ltr" placeholder="web.xxxxx" />
<p class="muted" style="font-size:12px; margin:4px 0 0;">برای انتخاب موقعیت روی نقشه در فرم ثبت مرکز. بدون کلید، فقط دکمه «موقعیت فعلی من» نمایش داده می‌شود.</p>
</div>
</section>
<!-- DEMO -->
<section class="card card-pad settings-panel" data-panel="demo">
<h3>حالت نمایشی (Demo)</h3>
<label class="toggle-row">
<input type="checkbox" name="DemoMode" value="true" checked="@Model.DemoMode" />
<span class="t-body"><span>حالت نمایشی فعال باشد</span>
<span class="t-hint">داده‌های نمونه پس از هر استقرار به‌صورت خودکار ساخته شوند.</span></span>
</label>
<p class="muted" style="font-size:13px;">ساخت/حذف فوری داده‌های نمونه‌ی تهران:</p>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<button type="submit" asp-page-handler="SeedDemo" class="btn btn-outline">ساخت داده نمونه</button>
<button type="submit" asp-page-handler="ClearDemo" class="btn btn-outline" style="color:var(--danger); border-color:var(--danger);" onclick="return confirm('همه داده‌های نمونه حذف شوند؟');">حذف داده نمونه</button>
</div>
</section>
<div class="settings-save">
<button type="submit" class="btn btn-accent btn-block btn-lg">ذخیره تنظیمات</button>
</div>
</div>
</div>
</form>
</div>
@section Scripts {
<script>
(function () {
var tabs = document.querySelectorAll('#settingsTabs button');
var panels = document.querySelectorAll('.settings-panel');
function show(name) {
tabs.forEach(function (t) { t.classList.toggle('active', t.dataset.tab === name); });
panels.forEach(function (p) { p.classList.toggle('active', p.dataset.panel === name); });
try { sessionStorage.setItem('adminTab', name); } catch (e) {}
}
tabs.forEach(function (t) { t.addEventListener('click', function () { show(t.dataset.tab); }); });
var saved; try { saved = sessionStorage.getItem('adminTab'); } catch (e) {}
if (saved && document.querySelector('[data-panel="' + saved + '"]')) show(saved);
})();
</script>
}