This is a viewer only at the moment see the article on how this works.
To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk
This is a preview from the server running through my markdig pipeline
Αυτό είναι απλά ένα γρήγορο άρθρο, όπως βασίζεται στα άλλα στην πλήρη σειρά αναζήτησης κειμένου, όπως η typeproof dropdown dropdown και Αναζήτηση πλήρους κειμένου Postgres. Σε αυτή την ανάρτηση, θα σας δείξω πώς να εφαρμόσετε μια απλή σελίδα αναζήτησης χρησιμοποιώντας HTMX και EF Core σε μια εφαρμογή ASP.NET Core.
Λοιπόν ναι, στην επικεφαλίδα του site έχω μια λειτουργία αναζήτησης που παρέχει το laward τύπου (όπου πληκτρολογείτε τα αποτελέσματα έρχονται σε πραγματικό χρόνο). Ωστόσο, το κρύβω σε mobile mode και ήθελα επίσης να μπορέσω να συνδέσω τα αποτελέσματα της αναζήτησης (όπως /Αναζήτηση/ουμάμι) σε μια ειδική σελίδα αναζήτησης. Αυτό δίνει μια καλύτερη εμπειρία χρήστη καθώς και την εργασία σε κινητές συσκευές.
Για να το κάνω αυτό, άλλαξα τον τρόπο με τον οποίο έκανα τις έρευνές μου. Δημιούργησα ένα... BlogSearchService
Αυτό βασίζεται στις δύο μεθόδους έρευνας πλήρους κειμένου μου. Αυτές δυστυχώς πρέπει να χωριστούν σε δύο μεθόδους λόγω του τρόπου με τον οποίο τα ερωτήματα είναι δομημένα με τις επεκτάσεις πλήρους αναζήτησης κειμένου Postgres, EF.Functions.WebSearchToTsQuery("english", processedQuery)
και EF.Functions.ToTsQuery("english", query + ":*")
.
Ο πρώτος παίρνει τους κατάλληλους όρους αναζήτησης και ο δεύτερος τους αναζητά wildcard.
private IQueryable<BlogPostEntity> QueryForSpaces(string processedQuery)
{
return context.BlogPosts
.Include(x => x.Categories)
.Include(x => x.LanguageEntity)
.AsNoTrackingWithIdentityResolution()
.Where(x =>
// Search using the precomputed SearchVector
(x.SearchVector.Matches(EF.Functions.WebSearchToTsQuery("english",
processedQuery)) // Use precomputed SearchVector for title and content
|| x.Categories.Any(c =>
EF.Functions.ToTsVector("english", c.Name)
.Matches(EF.Functions.WebSearchToTsQuery("english", processedQuery)))) // Search in categories
&& x.LanguageEntity.Name == "en") // Filter by language
.OrderByDescending(x =>
// Rank based on the precomputed SearchVector
x.SearchVector.Rank(EF.Functions.WebSearchToTsQuery("english",
processedQuery)));
}
private IQueryable<BlogPostEntity> QueryForWildCard(string query)
{
return context.BlogPosts
.Include(x => x.Categories)
.Include(x => x.LanguageEntity)
.AsNoTrackingWithIdentityResolution()
.Where(x =>
// Search using the precomputed SearchVector
(x.SearchVector.Matches(EF.Functions.ToTsQuery("english",
query + ":*")) // Use precomputed SearchVector for title and content
|| x.Categories.Any(c =>
EF.Functions.ToTsVector("english", c.Name)
.Matches(EF.Functions.ToTsQuery("english", query + ":*")))) // Search in categories
&& x.LanguageEntity.Name == "en") // Filter by language
.OrderByDescending(x =>
// Rank based on the precomputed SearchVector
x.SearchVector.Rank(EF.Functions.ToTsQuery("english",
query + ":*"))); // Use precomputed SearchVector for ranking
}
Και πάλι αυτά χρησιμοποιούν την προεπιλεγμένη μου SearchVector
στήλη η οποία ενημερώνεται σχετικά με τη μετά τη δημιουργία και την επικαιροποίηση. Αυτό δημιουργήθηκε στο... DbContext
χύ OnModelCreating
μέθοδος.
entity.Property(b => b.SearchVector)
.HasComputedColumnSql("to_tsvector('english', coalesce(\"Title\", '') || ' ' || coalesce(\"PlainTextContent\", ''))", stored: true);
entity.HasIndex(b => b.SearchVector)
.HasMethod("GIN");
Και πάλι το μειονέκτημα αυτής της προσέγγισης είναι ότι λειτουργεί μόνο για τα αγγλικά ως-είναι. Θα χρειαστώ μια ριζική ανοικοδόμηση της βάσης δεδομένων για να το κάνω να λειτουργήσει για άλλες γλώσσες (πιθανόν ένα τραπέζι για κάθε γλώσσα).
Στη συνέχεια, χρησιμοποιώ αυτές τις μεθόδους στην BlogSearchService
να επιστρέψει τα αποτελέσματα με βάση το ερώτημα αναζήτησης.
public async Task<PostListViewModel> GetPosts(string? query, int page = 1, int pageSize = 10)
{
if(string.IsNullOrEmpty(query))
{
return new PostListViewModel();
}
IQueryable<BlogPostEntity> blogPostQuery = query.Contains(" ") ? QueryForSpaces(query) : QueryForWildCard(query);
var totalPosts = await blogPostQuery.CountAsync();
var results = await blogPostQuery
.Select(x => x.ToListModel())
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return new PostListViewModel()
{
Posts = results,
TotalItems = totalPosts,
Page = page,
PageSize = pageSize
};
}
Χρησιμοποιώ τον απλό έλεγχο για το αν υπάρχουν κενά στο ερώτημα για να καθορίσω ποια μέθοδο να καλέσω.
Ο ελεγκτής αναζήτησης ακολουθεί το μοτίβο που χρησιμοποιώ για τους περισσότερους ελεγκτές μου όπου ανιχνεύει αν η κλήση προέρχεται από HTMX ή όχι για να επιτρέψει την αποστολή είτε μερικής είτε πλήρους σελίδας διάταξης (που σημαίνει ότι λειτουργεί για απευθείας πλοήγηση, καθώς και αιτήματα HTMX).
[Route("search")]
public class SearchController(
BaseControllerService baseControllerService,
BlogSearchService searchService,
ILogger<SearchController> logger)
: BaseController(baseControllerService, logger)
{
[HttpGet]
[Route("{query?}")]
public async Task<IActionResult> Search([FromRoute] string? query)
{
var searchResults = await searchService.GetPosts(query);
var searchModel = new SearchResultsModel
{
Query = query,
SearchResults = searchResults
};
searchModel = await PopulateBaseModel(searchModel);
searchModel.SearchResults.LinkUrl = Url.Action("SearchResults", "Search");
if (Request.IsHtmx()) return PartialView("SearchResults", searchModel);
return View("SearchResults", searchModel);
}
[HttpGet]
[Route("results")]
public async Task<IActionResult> SearchResults([Required] string query, int page = 1, int pageSize = 10)
{
var searchResults = await searchService.GetPosts(query, page, pageSize);
var searchModel = new SearchResultsModel
{
Query = query,
SearchResults = searchResults
};
searchModel = await PopulateBaseModel(searchModel);
searchModel.SearchResults.LinkUrl = Url.Action("SearchResults", "Search");
if (Request.IsHtmx()) return PartialView("_SearchResultsPartial", searchModel.SearchResults);
return View("SearchResults", searchModel);
}
}
Αυτό είναι ολόκληρο το χειριστήριο, μπορείτε να δείτε ότι έχω δύο δράσεις, μία που επιστρέφει τη σελίδα (προαιρετικά γεμάτη αποτελέσματα) και μία που επιστρέφει μόνο τα αποτελέσματα για αιτήματα HTMX.
if (Request.IsHtmx()) return PartialView("_SearchResultsPartial", searchModel.SearchResults);
Μπορείτε να δείτε ότι αυτό προαιρετικά επιστρέφει το _SearchResultsPartial
δείτε εάν το αίτημα είναι αίτηση HTMX για αποτελέσματα.
Αυτή είναι μια αρκετά απλή Αρχική άποψη που έχει paging bits και τα αποτελέσματα.
@model Mostlylucid.Models.Blog.PostListViewModel
<div class="pt-2" id="content">
@if (Model.Posts?.Any() is true)
{
<div class="inline-flex w-full items-center justify-center print:!hidden">
@if (Model.TotalItems > Model.PageSize)
{
<pager
x-ref="pager"
link-url="@Model.LinkUrl"
hx-boost="true"
hx-target="#content"
hx-swap="show:none"
page="@Model.Page"
page-size="@Model.PageSize"
total-items="@Model.TotalItems"
hx-headers='{"pagerequest": "true"}'>
</pager>
}
<partial name="_Pager" model="Model"/>
</div>
@foreach (var post in Model.Posts)
{
<partial name="_ListPost" model="post"/>
}
}
</div>
Χρησιμοποιώ το ίδιο. _ListPost
Μερική προβολή όπου χρειάζομαι για να καταγράφω αναρτήσεις.
@model Mostlylucid.Models.Blog.PostListModel
<div class="border-b border-grey-lighter pb-8 mb-8">
<a asp-controller="Blog" asp-action="Show" hx-boost="true" hx-swap="show:window:top" hx-target="#contentcontainer" asp-route-slug="@Model.Slug"
class="block font-body text-lg font-semibold transition-colors hover:text-green text-blue-dark dark:text-white dark:hover:text-secondary">@Model.Title</a>
<div class="flex flex-wrap space-x-2 items-center py-4 print:!hidden">
@foreach (var category in Model.Categories)
{
<partial name="_Category" model="category"/>
}
@{ var languageModel = (Model.Slug, Model.Languages, Model.Language); }
<partial name="_LanguageList" model="languageModel"/>
</div>
<div class="block font-body text-black dark:text-white">@Model.Summary</div>
<div class="flex items-center pt-4">
<p class="pr-2 font-body font-light text-primary light:text-black dark:text-white">
@Model.PublishedDate.ToString("f")
</p>
<span class="font-body text-grey dark:text-white">//</span>
<p class="pl-2 font-body font-light text-primary light:text-black dark:text-white">
@Model.ReadingTime
</p>
</div>
</div>
Και πάλι η χρήση του HTMX εδώ είναι πολύ απλή. Απλά συνδέω το κουμπί (αυτή τη φορά αποφάσισα να μην αλλάξω την είσοδο στο πληκτρολόγιο / αλλάξω το URL) και να στείλω το αίτημα όταν κάνετε κλικ στο κουμπί.
I include the question in the request using hx-include
και στοχεύστε το #content
div για την αντικατάσταση των αποτελεσμάτων.
<div class="flex items-center gap-2 bg-neutral-500 bg-opacity-10 p-2 rounded-lg">
<button
hx-get="@Url.Action("SearchResults", "Search")"
hx-target="#content"
hx-include="[name='query']"
hx-swap="outerHTML"
class="btn btn-outline btn-sm flex items-center gap-2 text-black dark:text-white">
Search
<i class="bx bx-search text-lg"></i>
</button>
<input
type="text"
placeholder="Search..."
value="@Model.Query"
name="query"
class="input input-sm border-0 grow text-black dark:text-white bg-transparent focus:outline-none"/>
</div>
Έτσι, ακολουθώντας ορισμένα σχόλια από ΚαλίντCity name (optional, probably does not need a translation) Αποφάσισα να ενισχύσω αυτή τη λειτουργία για να μπορέσω η αναζήτηση να είναι:
Στο μέλλον θα πρέπει να προσθέσετε το μέγεθος της σελίδας λειτουργικότητα πίσω σε? είναι ένα δίδυμο ενός hack και πρέπει να υποστηρίξει περαιτέρω απαιτήσεις.
Για να το κάνω αυτό, πρώτα τύλιξα την εισαγωγή σε μια μορφή και χρησιμοποίησα Alpine.js για να υποβάλλω τη φόρμα όταν ένας χρήστης πληκτρολογεί.
Μπορείς να δεις ότι χρησιμοποιώ x-data
να δημιουργήσω μια αντιδραστική μεταβλητή για το ερώτημα και μετά να ελέγξω το μήκος της ερώτησης για να καθορίσω αν θα υποβάλω το έντυπο.
<form
x-data="{ query: '@Model.Query', checkSubmit() { if (this.query.length > 2) { $refs.searchButton.click(); } } }"
class="flex items-center gap-2 bg-neutral-500 bg-opacity-10 p-2 rounded-lg"
action="@Url.Action("Search", "Search")"
hx-push-url="true"
hx-boost="true"
hx-target="#content"
hx-swap="outerHTML show:window:top"
hx-headers='{"pagerequest": "true"}'>
<button
type="submit"
x-ref="searchButton"
class="btn btn-outline btn-sm flex items-center gap-2 text-black dark:text-white">
Search
<i class="bx bx-search text-lg"></i>
</button>
<input
type="text"
placeholder="Search..."
name="query"
value="@Model.Query"
x-model="query"
x-on:input.debounce.200ms="checkSubmit"
x-on:keydown.enter.prevent="$refs.searchButton.click()"
class="input input-sm border-0 grow text-black dark:text-white bg-transparent focus:outline-none"
/>
</form>
Για την επαναχρησιμοποίηση της ίδιας δράσης του ελεγκτή, έθεσα επίσης το pagerequest
header για να δείξει ότι αυτό είναι ένα paginated αίτημα.
Χρησιμοποιώ επίσης τα Alpine.js x-on:keydown.enter.prevent
για να ενεργοποιήσετε το κουμπί κάντε κλικ όταν πατήσετε το πλήκτρο Enter και μια αποβολή στην είσοδο για να αποτρέψετε πάρα πολλά αιτήματα.
Στο χειριστήριο αφαίρεσα τη δράση SearchResults και αντ' αυτού πρόσθεσα περισσότερη "κατανόηση" στο κύριο Search
δράση για τη διαχείριση τόσο της αρχικής αναζήτησης όσο και των επισημοποιημένων αιτημάτων.
Εδώ μπορείτε να δείτε ότι προσθέτω μια επιπλέον παράμετρο που ονομάζεται pagerequest
για να προσδιοριστεί αν πρόκειται για αίτημα που έχει επικολληθεί ή όχι και να αναφερθεί ότι αυτό θα πρέπει να κατοικείται από τη συλλογή κεφαλίδων.
[HttpGet]
[Route("")]
public async Task<IActionResult> Search(string? query, int page = 1, int pageSize = 10,[FromHeader] bool pagerequest=false)
{
var searchResults = await searchService.GetPosts(query, page, pageSize);
var searchModel = new SearchResultsModel
{
Query = query,
SearchResults = searchResults
};
searchModel = await PopulateBaseModel(searchModel);
var linkUrl = Url.Action("Search", "Search");
searchModel.SearchResults.LinkUrl = linkUrl;
if(pagerequest && Request.IsHtmx()) return PartialView("_SearchResultsPartial", searchModel.SearchResults);
if (Request.IsHtmx()) return PartialView("SearchResults", searchModel);
return View("SearchResults", searchModel);
}
Στη συνέχεια, παίρνω τα αποτελέσματα και ανιχνεύω αυτή την κεφαλίδα για να καθορίσει ποια προβολή / μερική προβολή για να επιστρέψει.
Πρόσθεσα περαιτέρω μια ξεχωριστή αποστρατευμένη δράση για να χειριστεί τους ομοίους του /search/umami
για να ανακατευθύνεστε στην κύρια σελίδα αναζήτησης με το ερώτημα.
[HttpGet]
[Route("{query}")]
public IActionResult InitialSearch([FromRoute] string query)
{
return RedirectToAction("Search", new { query });
}
Λοιπόν, αρκετά απλό, σωστά; Πρόκειται για μια απλή εφαρμογή μιας σελίδας αναζήτησης χρησιμοποιώντας HTMX και EF Core σε μια εφαρμογή ASP.NET Core. Μπορείτε εύκολα να επεκτείνετε αυτό για να περιλαμβάνει περισσότερα χαρακτηριστικά όπως φιλτράρισμα, διαλογή, ή ακόμη και ενσωμάτωση με άλλες υπηρεσίες αναζήτησης. Το βασικό takeaway είναι πώς να μόχλευση HTMX για μια ομαλή εμπειρία χρήστη, διατηρώντας παράλληλα το backend λογική καθαρή και αποτελεσματική.