[Ingest] Full results page (all statuses) + inline quick-reject in queue
New /Admin/Ingested page lists every crawled item with its outcome, filterable by status (همه/در صف/پرچمخورده/منتشرشده/ردشده) with per-status counts and a link to the published shift or the review page. Linked from the run-history header and the admin panel nav. Plus an inline ✕رد (quick-discard) button on each queue/flagged row so admins can audit without opening the review page; full accept/reject stays on /Admin/Review. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -64,7 +64,10 @@
|
|||||||
<div>
|
<div>
|
||||||
@if (Model.Runs.Count > 0)
|
@if (Model.Runs.Count > 0)
|
||||||
{
|
{
|
||||||
<h2 style="font-size:20px; margin-top:0;">تاریخچه جمعآوری</h2>
|
<h2 style="font-size:20px; margin-top:0; display:flex; justify-content:space-between; align-items:center;">
|
||||||
|
تاریخچه جمعآوری
|
||||||
|
<a class="btn btn-outline" style="padding:5px 12px; font-size:13px;" asp-page="/Admin/Ingested">همه نتایج جمعآوری ←</a>
|
||||||
|
</h2>
|
||||||
<div class="card card-pad" style="margin-bottom:18px; overflow-x:auto;">
|
<div class="card card-pad" style="margin-bottom:18px; overflow-x:auto;">
|
||||||
<table style="width:100%; border-collapse:collapse; font-size:13px; white-space:nowrap;">
|
<table style="width:100%; border-collapse:collapse; font-size:13px; white-space:nowrap;">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ public class IndexModel : PageModel
|
|||||||
return RedirectToPage();
|
return RedirectToPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Fast triage — reject (discard) a queued/flagged item without opening the review page.</summary>
|
||||||
|
public async Task<IActionResult> OnPostQuickDiscardAsync(int id)
|
||||||
|
{
|
||||||
|
var raw = await _db.RawListings.FirstOrDefaultAsync(r => r.Id == id);
|
||||||
|
if (raw is not null) { raw.Status = RawListingStatus.Discarded; await _db.SaveChangesAsync(); }
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostRunIngestionAsync()
|
public async Task<IActionResult> OnPostRunIngestionAsync()
|
||||||
{
|
{
|
||||||
var s = await _ingest.RunAsync();
|
var s = await _ingest.RunAsync();
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
@page
|
||||||
|
@model JobsMedical.Web.Pages.Admin.IngestedModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "نتایج جمعآوری";
|
||||||
|
string P(int n) => JalaliDate.ToPersianDigits(n.ToString());
|
||||||
|
int C(JobsMedical.Web.Models.RawListingStatus s) => Model.Counts.GetValueOrDefault(s);
|
||||||
|
string Pill(string key, string label, int count) =>
|
||||||
|
$"<a class=\"ing-pill {(Model.Status == key || (Model.Status is null && key == "all") ? "active" : "")}\" href=\"?status={key}\">{label} ({P(count)})</a>";
|
||||||
|
}
|
||||||
|
|
||||||
|
<partial name="_PanelNav" />
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<div class="ing-filters">
|
||||||
|
@Html.Raw(Pill("all", "همه", Model.Counts.Values.Sum()))
|
||||||
|
@Html.Raw(Pill("new", "در صف", C(JobsMedical.Web.Models.RawListingStatus.New)))
|
||||||
|
@Html.Raw(Pill("flagged", "پرچمخورده", C(JobsMedical.Web.Models.RawListingStatus.Flagged)))
|
||||||
|
@Html.Raw(Pill("published", "منتشرشده", C(JobsMedical.Web.Models.RawListingStatus.Normalized)))
|
||||||
|
@Html.Raw(Pill("discarded", "ردشده/اسپم", C(JobsMedical.Web.Models.RawListingStatus.Discarded)))
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="muted" style="font-size:13px;">@P(Model.Total) نتیجه (نمایش حداکثر ۲۰۰ مورد اخیر).</p>
|
||||||
|
|
||||||
|
@if (Model.Items.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="card empty-state">موردی با این فیلتر نیست.</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var r in Model.Items)
|
||||||
|
{
|
||||||
|
var (cls, label) = r.Status switch
|
||||||
|
{
|
||||||
|
JobsMedical.Web.Models.RawListingStatus.New => ("badge-day", "در صف"),
|
||||||
|
JobsMedical.Web.Models.RawListingStatus.Flagged => ("badge-type", "پرچمخورده"),
|
||||||
|
JobsMedical.Web.Models.RawListingStatus.Normalized => ("badge-verified", "منتشر شد"),
|
||||||
|
_ => ("badge-gender", "رد شد"),
|
||||||
|
};
|
||||||
|
<div class="card card-pad" style="margin-bottom:10px;">
|
||||||
|
<div class="row" style="display:flex; justify-content:space-between; align-items:center; gap:8px; flex-wrap:wrap;">
|
||||||
|
<strong>@r.SourceChannel</strong>
|
||||||
|
<span style="display:flex; gap:6px; align-items:center;">
|
||||||
|
<span class="badge @cls">@label</span>
|
||||||
|
<span class="badge badge-type">اطمینان @P(r.Confidence)٪</span>
|
||||||
|
<span class="muted" style="font-size:12px;">@JalaliDate.ToLongDate(DateOnly.FromDateTime(r.FetchedAt))</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p style="margin:8px 0; white-space:pre-wrap; font-size:13.5px;">@(r.RawText.Length > 320 ? r.RawText.Substring(0,320) + "…" : r.RawText)</p>
|
||||||
|
@if (!string.IsNullOrEmpty(r.ValidationNotes)) { <p class="muted" style="font-size:12px; margin:0 0 6px;">⚠ @r.ValidationNotes</p> }
|
||||||
|
@if (r.Status == JobsMedical.Web.Models.RawListingStatus.New || r.Status == JobsMedical.Web.Models.RawListingStatus.Flagged)
|
||||||
|
{
|
||||||
|
<a class="btn btn-outline" style="padding:4px 12px; font-size:13px;" asp-page="/Admin/Review" asp-route-id="@r.Id">بررسی و انتشار ←</a>
|
||||||
|
}
|
||||||
|
else if (r.LinkedShiftId is int sid)
|
||||||
|
{
|
||||||
|
<a class="btn btn-outline" style="padding:4px 12px; font-size:13px;" asp-page="/Shifts/Details" asp-route-id="@sid" target="_blank">مشاهده آگهی منتشرشده</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using JobsMedical.Web.Data;
|
||||||
|
using JobsMedical.Web.Models;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace JobsMedical.Web.Pages.Admin;
|
||||||
|
|
||||||
|
/// <summary>Every crawled item with its outcome (queued / published / flagged / discarded),
|
||||||
|
/// filterable by status and source — the full audit trail of ingestion.</summary>
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public class IngestedModel : PageModel
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
public IngestedModel(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public List<RawListing> Items { get; private set; } = new();
|
||||||
|
public int Total { get; private set; }
|
||||||
|
public Dictionary<RawListingStatus, int> Counts { get; private set; } = new();
|
||||||
|
|
||||||
|
[BindProperty(SupportsGet = true)] public string? Status { get; set; } // new|flagged|published|discarded|all
|
||||||
|
[BindProperty(SupportsGet = true)] public string? Source { get; set; }
|
||||||
|
|
||||||
|
public async Task OnGetAsync()
|
||||||
|
{
|
||||||
|
Counts = await _db.RawListings.GroupBy(r => r.Status)
|
||||||
|
.Select(g => new { g.Key, C = g.Count() }).ToDictionaryAsync(x => x.Key, x => x.C);
|
||||||
|
|
||||||
|
var q = _db.RawListings.AsNoTracking().AsQueryable();
|
||||||
|
|
||||||
|
var st = Status?.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"new" => (RawListingStatus?)RawListingStatus.New,
|
||||||
|
"flagged" => RawListingStatus.Flagged,
|
||||||
|
"published" => RawListingStatus.Normalized,
|
||||||
|
"discarded" => RawListingStatus.Discarded,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
if (st is not null) q = q.Where(r => r.Status == st);
|
||||||
|
if (!string.IsNullOrWhiteSpace(Source)) q = q.Where(r => r.SourceChannel.Contains(Source));
|
||||||
|
|
||||||
|
Total = await q.CountAsync();
|
||||||
|
Items = await q.OrderByDescending(r => r.FetchedAt).Take(200).ToListAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
{
|
{
|
||||||
<a class="@(On("/Admin/Overview") ? "active" : null)" asp-page="/Admin/Overview">📊 داشبورد</a>
|
<a class="@(On("/Admin/Overview") ? "active" : null)" asp-page="/Admin/Overview">📊 داشبورد</a>
|
||||||
<a class="@(On("/Admin/Index") ? "active" : null)" asp-page="/Admin/Index">📥 صف آگهیها</a>
|
<a class="@(On("/Admin/Index") ? "active" : null)" asp-page="/Admin/Index">📥 صف آگهیها</a>
|
||||||
|
<a class="@(On("/Admin/Ingested") ? "active" : null)" asp-page="/Admin/Ingested">📜 نتایج جمعآوری</a>
|
||||||
<a class="@(On("/Admin/Facilities") ? "active" : null)" asp-page="/Admin/Facilities">🏥 مراکز</a>
|
<a class="@(On("/Admin/Facilities") ? "active" : null)" asp-page="/Admin/Facilities">🏥 مراکز</a>
|
||||||
<a class="@(On("/Admin/Users") ? "active" : null)" asp-page="/Admin/Users">👥 کاربران</a>
|
<a class="@(On("/Admin/Users") ? "active" : null)" asp-page="/Admin/Users">👥 کاربران</a>
|
||||||
<a class="@(On("/Admin/Reports") ? "active" : null)" asp-page="/Admin/Reports">🛡️ گزارشها</a>
|
<a class="@(On("/Admin/Reports") ? "active" : null)" asp-page="/Admin/Reports">🛡️ گزارشها</a>
|
||||||
|
|||||||
@@ -16,5 +16,11 @@
|
|||||||
{
|
{
|
||||||
<p class="muted" style="font-size:12.5px; margin:0 0 10px;">⚠ @Model.ValidationNotes</p>
|
<p class="muted" style="font-size:12.5px; margin:0 0 10px;">⚠ @Model.ValidationNotes</p>
|
||||||
}
|
}
|
||||||
|
<div style="display:flex; gap:8px; flex-wrap:wrap;">
|
||||||
<a class="btn btn-accent" asp-page="/Admin/Review" asp-route-id="@Model.Id">بررسی و انتشار ←</a>
|
<a class="btn btn-accent" asp-page="/Admin/Review" asp-route-id="@Model.Id">بررسی و انتشار ←</a>
|
||||||
|
<form method="post" asp-page="/Admin/Index" asp-page-handler="QuickDiscard" asp-route-id="@Model.Id"
|
||||||
|
onsubmit="return confirm('این آگهی رد و کنار گذاشته شود؟');">
|
||||||
|
<button type="submit" class="btn btn-outline" style="color:var(--danger); border-color:var(--danger);">✕ رد</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -111,6 +111,13 @@ a { color: inherit; text-decoration: none; }
|
|||||||
}
|
}
|
||||||
.panel-nav a:hover { background: var(--primary-soft); color: var(--primary-dark); }
|
.panel-nav a:hover { background: var(--primary-soft); color: var(--primary-dark); }
|
||||||
.panel-nav a.active { background: var(--primary); color: #fff; }
|
.panel-nav a.active { background: var(--primary); color: #fff; }
|
||||||
|
|
||||||
|
/* Ingestion-results filter pills */
|
||||||
|
.ing-filters { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; }
|
||||||
|
.ing-pill { padding: 7px 14px; border-radius: 999px; border: 1px solid var(--line);
|
||||||
|
font-weight: 600; font-size: 13px; color: var(--muted); white-space: nowrap; }
|
||||||
|
.ing-pill:hover { border-color: var(--primary); color: var(--primary); }
|
||||||
|
.ing-pill.active { background: var(--primary); color: #fff; border-color: var(--primary); }
|
||||||
.profile-dropdown {
|
.profile-dropdown {
|
||||||
position: absolute; top: calc(100% + 8px); inset-inline-end: 0; min-width: 230px; z-index: 60;
|
position: absolute; top: calc(100% + 8px); inset-inline-end: 0; min-width: 230px; z-index: 60;
|
||||||
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
|
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
|
||||||
|
|||||||
Reference in New Issue
Block a user