Search: Elasticsearch-style highlighted match snippets (results + typeahead)
- SearchHighlight.Snippet: extracts a ±70-char window around the first matching term and marks it (with ellipses) — the ES "highlight" fragment. - Result cards (shift/job/talent) now show that snippet from the matched description/tags when a query is present, so you SEE where the term hit (e.g. «…دارای مدرک <mark>mmt</mark>…») instead of just the role. - Typeahead suggestions gain a highlighted "sub" line (talent→tags, shift→city·specialty, job→facility·city) so matches show in the dropdown too. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,11 @@
|
|||||||
{
|
{
|
||||||
<div class="row"><span class="badge badge-distance">📍 @JalaliDate.ToPersianDigits(km.ToString("0.#")) کیلومتر از شما</span></div>
|
<div class="row"><span class="badge badge-distance">📍 @JalaliDate.ToPersianDigits(km.ToString("0.#")) کیلومتر از شما</span></div>
|
||||||
}
|
}
|
||||||
|
@{ var snip = JobsMedical.Web.Services.SearchHighlight.Snippet(Model.Description, q); }
|
||||||
|
@if (snip.Value.Length > 0)
|
||||||
|
{
|
||||||
|
<div class="search-snippet">@snip</div>
|
||||||
|
}
|
||||||
<div class="foot">
|
<div class="foot">
|
||||||
<span class="pay">@salary</span>
|
<span class="pay">@salary</span>
|
||||||
<span class="btn btn-outline" style="padding: 6px 14px;">جزئیات</span>
|
<span class="btn btn-outline" style="padding: 6px 14px;">جزئیات</span>
|
||||||
|
|||||||
@@ -259,8 +259,10 @@
|
|||||||
.then(function (items) {
|
.then(function (items) {
|
||||||
if (!items || !items.length) { hide(); return; }
|
if (!items || !items.length) { hide(); return; }
|
||||||
var html = items.map(function (it) {
|
var html = items.map(function (it) {
|
||||||
|
var sub = it.sub ? '<span class="ns-sub">' + hi(it.sub, q) + '</span>' : '';
|
||||||
return '<a href="' + it.url + '"><span class="ns-type">' + esc(it.type) +
|
return '<a href="' + it.url + '"><span class="ns-type">' + esc(it.type) +
|
||||||
'</span><span class="ns-label">' + hi(it.label, q) + '</span></a>';
|
'</span><span class="ns-text"><span class="ns-label">' + hi(it.label, q) +
|
||||||
|
'</span>' + sub + '</span></a>';
|
||||||
}).join('');
|
}).join('');
|
||||||
html += '<a class="ns-all" href="/Search?Q=' + encodeURIComponent(q) + '">همه نتایج برای «' + esc(q) + '» ←</a>';
|
html += '<a class="ns-all" href="/Search?Q=' + encodeURIComponent(q) + '">همه نتایج برای «' + esc(q) + '» ←</a>';
|
||||||
box.innerHTML = html;
|
box.innerHTML = html;
|
||||||
|
|||||||
@@ -35,6 +35,11 @@
|
|||||||
}
|
}
|
||||||
<div class="row">📅 @JalaliDate.WeekDayName(Model.Date)، @JalaliDate.ToLongDate(Model.Date)</div>
|
<div class="row">📅 @JalaliDate.WeekDayName(Model.Date)، @JalaliDate.ToLongDate(Model.Date)</div>
|
||||||
<div class="row">🕐 @JalaliDate.Time(Model.StartTime) تا @JalaliDate.Time(Model.EndTime)</div>
|
<div class="row">🕐 @JalaliDate.Time(Model.StartTime) تا @JalaliDate.Time(Model.EndTime)</div>
|
||||||
|
@{ var snip = JobsMedical.Web.Services.SearchHighlight.Snippet(Model.Description, q); }
|
||||||
|
@if (snip.Value.Length > 0)
|
||||||
|
{
|
||||||
|
<div class="search-snippet">@snip</div>
|
||||||
|
}
|
||||||
<partial name="_HourBar" model="Model" />
|
<partial name="_HourBar" model="Model" />
|
||||||
<div class="foot">
|
<div class="foot">
|
||||||
<span class="pay">@JalaliDate.PayLabel(Model.PayType, Model.PayAmount, Model.SharePercent)</span>
|
<span class="pay">@JalaliDate.PayLabel(Model.PayType, Model.PayAmount, Model.SharePercent)</span>
|
||||||
|
|||||||
@@ -40,6 +40,11 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="row">📍 @Model.City?.Name@(area is not null ? "، " + area : "")</div>
|
<div class="row">📍 @Model.City?.Name@(area is not null ? "، " + area : "")</div>
|
||||||
|
@{ var snip = JobsMedical.Web.Services.SearchHighlight.Snippet(Model.Description ?? Model.Tags, q); }
|
||||||
|
@if (snip.Value.Length > 0)
|
||||||
|
{
|
||||||
|
<div class="search-snippet">@snip</div>
|
||||||
|
}
|
||||||
@if (tags.Count > 0)
|
@if (tags.Count > 0)
|
||||||
{
|
{
|
||||||
<div class="tag-chips">
|
<div class="tag-chips">
|
||||||
|
|||||||
@@ -373,17 +373,17 @@ app.MapGet("/search/suggest", async (string? q, AppDbContext db) =>
|
|||||||
.Where(s => s.Status == ShiftStatus.Open && s.Date >= today &&
|
.Where(s => s.Status == ShiftStatus.Open && s.Date >= today &&
|
||||||
(EF.Functions.ILike(s.Facility.Name, like) || EF.Functions.ILike(s.Role.Name, like) || EF.Functions.ILike(s.SpecialtyRequired, like)))
|
(EF.Functions.ILike(s.Facility.Name, like) || EF.Functions.ILike(s.Role.Name, like) || EF.Functions.ILike(s.SpecialtyRequired, like)))
|
||||||
.OrderByDescending(s => s.CreatedAt).Take(5)
|
.OrderByDescending(s => s.CreatedAt).Take(5)
|
||||||
.Select(s => new SuggestItem("شیفت", s.Role.Name + " — " + s.Facility.Name, "/Shifts/Details/" + s.Id)).ToListAsync();
|
.Select(s => new SuggestItem("شیفت", s.Role.Name + " — " + s.Facility.Name, "/Shifts/Details/" + s.Id, s.Facility.City.Name + " · " + s.SpecialtyRequired)).ToListAsync();
|
||||||
var jobs = await db.JobOpenings
|
var jobs = await db.JobOpenings
|
||||||
.Where(j => j.Status == ShiftStatus.Open && j.CreatedAt >= jobCut &&
|
.Where(j => j.Status == ShiftStatus.Open && j.CreatedAt >= jobCut &&
|
||||||
(EF.Functions.ILike(j.Title, like) || EF.Functions.ILike(j.Facility.Name, like) || EF.Functions.ILike(j.Role.Name, like)))
|
(EF.Functions.ILike(j.Title, like) || EF.Functions.ILike(j.Facility.Name, like) || EF.Functions.ILike(j.Role.Name, like)))
|
||||||
.OrderByDescending(j => j.CreatedAt).Take(5)
|
.OrderByDescending(j => j.CreatedAt).Take(5)
|
||||||
.Select(j => new SuggestItem("استخدام", j.Title, "/Jobs/Details/" + j.Id)).ToListAsync();
|
.Select(j => new SuggestItem("استخدام", j.Title, "/Jobs/Details/" + j.Id, j.Facility.Name + " · " + j.Facility.City.Name)).ToListAsync();
|
||||||
var talent = await db.TalentListings
|
var talent = await db.TalentListings
|
||||||
.Where(t => t.Status == ShiftStatus.Open && t.CreatedAt >= talentCut &&
|
.Where(t => t.Status == ShiftStatus.Open && t.CreatedAt >= talentCut &&
|
||||||
(EF.Functions.ILike(t.Tags ?? "", like) || EF.Functions.ILike(t.Role.Name, like) || EF.Functions.ILike(t.City.Name, like) || EF.Functions.ILike(t.PersonName ?? "", like)))
|
(EF.Functions.ILike(t.Tags ?? "", like) || EF.Functions.ILike(t.Role.Name, like) || EF.Functions.ILike(t.City.Name, like) || EF.Functions.ILike(t.PersonName ?? "", like)))
|
||||||
.OrderByDescending(t => t.CreatedAt).Take(5)
|
.OrderByDescending(t => t.CreatedAt).Take(5)
|
||||||
.Select(t => new SuggestItem("آمادهبهکار", (t.PersonName ?? t.Role.Name) + " — " + t.City.Name, "/Talent/Details/" + t.Id)).ToListAsync();
|
.Select(t => new SuggestItem("آمادهبهکار", (t.PersonName ?? t.Role.Name) + " — " + t.City.Name, "/Talent/Details/" + t.Id, t.Tags)).ToListAsync();
|
||||||
|
|
||||||
// round-robin merge so all three types appear, capped at 5
|
// round-robin merge so all three types appear, capped at 5
|
||||||
var merged = new List<SuggestItem>();
|
var merged = new List<SuggestItem>();
|
||||||
@@ -398,5 +398,6 @@ app.MapGet("/search/suggest", async (string? q, AppDbContext db) =>
|
|||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
/// <summary>One typeahead suggestion row (lowercase props → camelCase JSON for the client).</summary>
|
/// <summary>One typeahead suggestion row (lowercase props → camelCase JSON for the client).
|
||||||
public record SuggestItem(string type, string label, string url);
|
/// <c>sub</c> is the matched-context line (tags/city/specialty) shown highlighted under the label.</summary>
|
||||||
|
public record SuggestItem(string type, string label, string url, string? sub = null);
|
||||||
|
|||||||
@@ -24,4 +24,33 @@ public static class SearchHighlight
|
|||||||
var marked = Regex.Replace(encoded, pattern, m => $"<mark>{m.Value}</mark>", RegexOptions.IgnoreCase);
|
var marked = Regex.Replace(encoded, pattern, m => $"<mark>{m.Value}</mark>", RegexOptions.IgnoreCase);
|
||||||
return new HtmlString(marked);
|
return new HtmlString(marked);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Elasticsearch-style highlight fragment: finds the first matching term in <paramref name="text"/>,
|
||||||
|
/// returns a window of ±<paramref name="radius"/> chars around it with the terms marked and ellipses
|
||||||
|
/// at the cut points. Empty when nothing matches (so callers can hide the line).
|
||||||
|
/// </summary>
|
||||||
|
public static HtmlString Snippet(string? text, string? query, int radius = 70)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(query)) return HtmlString.Empty;
|
||||||
|
var terms = query.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Where(t => t.Length >= 2).ToList();
|
||||||
|
if (terms.Count == 0) return HtmlString.Empty;
|
||||||
|
|
||||||
|
var flat = Regex.Replace(text, @"\s+", " ").Trim();
|
||||||
|
int idx = -1, matchLen = 0;
|
||||||
|
foreach (var t in terms)
|
||||||
|
{
|
||||||
|
var i = flat.IndexOf(t, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (i >= 0 && (idx < 0 || i < idx)) { idx = i; matchLen = t.Length; }
|
||||||
|
}
|
||||||
|
if (idx < 0) return HtmlString.Empty;
|
||||||
|
|
||||||
|
var start = Math.Max(0, idx - radius);
|
||||||
|
var end = Math.Min(flat.Length, idx + matchLen + radius);
|
||||||
|
var slice = flat.Substring(start, end - start).Trim();
|
||||||
|
var prefix = start > 0 ? "…" : "";
|
||||||
|
var suffix = end < flat.Length ? "…" : "";
|
||||||
|
return new HtmlString(prefix + Mark(slice, query).Value + suffix);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -294,7 +294,12 @@ mark { background: #fff3bf; color: inherit; padding: 0 2px; border-radius: 3px;
|
|||||||
.nav-search-results a:hover { background: var(--primary-soft); }
|
.nav-search-results a:hover { background: var(--primary-soft); }
|
||||||
.nav-search-results .ns-type { flex: 0 0 auto; font-size: 11px; font-weight: 700; color: var(--primary-dark);
|
.nav-search-results .ns-type { flex: 0 0 auto; font-size: 11px; font-weight: 700; color: var(--primary-dark);
|
||||||
background: var(--primary-soft); border-radius: 6px; padding: 2px 7px; }
|
background: var(--primary-soft); border-radius: 6px; padding: 2px 7px; }
|
||||||
.nav-search-results .ns-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.nav-search-results .ns-text { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
.nav-search-results .ns-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.nav-search-results .ns-sub { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
/* ES-style matched snippet shown under a search-result card */
|
||||||
|
.search-snippet { font-size: 12.5px; color: var(--muted); line-height: 1.6; margin: 4px 0 2px;
|
||||||
|
background: var(--bg); border-inline-start: 3px solid var(--primary-soft); padding: 5px 9px; border-radius: 6px; }
|
||||||
.nav-search-results .ns-all { font-weight: 700; color: var(--primary-dark); justify-content: center; }
|
.nav-search-results .ns-all { font-weight: 700; color: var(--primary-dark); justify-content: center; }
|
||||||
/* Big search box on the /Search page head */
|
/* Big search box on the /Search page head */
|
||||||
.search-hero { display: flex; gap: 8px; max-width: 640px; margin: 6px 0 4px; }
|
.search-hero { display: flex; gap: 8px; max-width: 640px; margin: 6px 0 4px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user