1use std::sync::Arc;
11
12use leptos::ev::KeyboardEvent;
13use leptos::html;
14use leptos::prelude::*;
15use leptos::task::spawn_local;
16use leptos_meta::Title;
17use leptos_router::hooks::use_query_map;
18use leptos_router::{lazy_route, LazyRoute};
19
20use crate::api::error::ApiError;
21use crate::api::posts::{list_tags, search_posts, PostListResponse};
22use crate::components::mascot::Mascot;
23use crate::components::palette::{contains_ci, highlight, snippet};
24use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
25use crate::components::skeletons::search_skeleton::SearchSkeleton;
26use crate::components::ui::{
27 Composer, ComposerInput, ComposerSend, Icon, IconName, LoadError, SegmentedControl,
28 SegmentedItem,
29};
30use crate::models::post::{PostListItem, Tag};
31use crate::pages::archives::ranked_tags;
32use crate::routes::Route;
33use crate::utils::time::sleep_ms;
34
35const SEARCH_DEBOUNCE_MS: u32 = 350;
36const RESULT_LIMIT: usize = 50;
38const SUGGESTED_TAGS: usize = 8;
40const SNIPPET_RADIUS: usize = 40;
42const RESULT_TAGS: usize = 4;
44
45#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
46enum SortOrder {
47 #[default]
48 Relevance,
49 Latest,
50}
51
52impl SortOrder {
53 fn value(self) -> &'static str {
54 match self {
55 Self::Relevance => "relevance",
56 Self::Latest => "latest",
57 }
58 }
59
60 fn from_value(value: &str) -> Self {
61 if value == "latest" {
62 Self::Latest
63 } else {
64 Self::Relevance
65 }
66 }
67}
68
69type SearchResult = Result<PostListResponse, ApiError>;
71
72#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
75struct SearchHistoryState {
76 query: String,
77 submitted_query: String,
78 response: Option<PostListResponse>,
79 failed: bool,
80 #[serde(default)]
81 order: SortOrder,
82}
83
84impl SearchHistoryState {
85 fn result(&self) -> Option<SearchResult> {
86 if self.failed {
87 Some(Err(ApiError::Internal("搜索暂时未能完成,请重试".into())))
88 } else {
89 self.response.clone().map(Ok)
90 }
91 }
92}
93
94fn ordered(posts: &[PostListItem], order: SortOrder) -> Vec<PostListItem> {
96 let mut posts = posts.to_vec();
97 if order == SortOrder::Latest {
98 posts.sort_by(|a, b| {
99 let at = |p: &PostListItem| p.published_at.unwrap_or(p.created_at);
100 at(b).cmp(&at(a)).then_with(|| b.id.cmp(&a.id))
101 });
102 }
103 posts
104}
105
106fn result_id(index: usize) -> String {
107 format!("search-result-{index}")
108}
109
110pub struct SearchRoute;
112
113#[lazy_route]
114impl LazyRoute for SearchRoute {
115 fn data() -> Self {
116 Self
117 }
118
119 fn view(_this: Self) -> AnyView {
120 view! { <SearchPage/> }.into_any()
121 }
122}
123
124#[component]
126fn SearchPage() -> impl IntoView {
127 let params = use_query_map();
128 let q = Memo::new(move |_| {
129 params
130 .read()
131 .get("q")
132 .unwrap_or_default()
133 .trim()
134 .to_string()
135 });
136 let entry_id = crate::bridges::navigation::entry_id();
137
138 let query = RwSignal::new(q.get_untracked());
140 let submitted_query = RwSignal::new(String::new());
141 let search_res = RwSignal::new(None::<SearchResult>);
142 let order = RwSignal::new(SortOrder::default());
143 let is_searching = RwSignal::new(false);
144 let request_id = RwSignal::new(0_u64);
145 let is_composing = RwSignal::new(false);
146 let input_ref = NodeRef::<html::Input>::new();
147 let restored = RwSignal::new(false);
149
150 Effect::new(move |_| {
151 if !restored.get() {
152 return;
153 }
154 let result = search_res.get();
155 let state = SearchHistoryState {
156 query: query.get(),
157 submitted_query: if is_searching.get() {
158 String::new()
159 } else {
160 submitted_query.get()
161 },
162 response: result
163 .as_ref()
164 .and_then(|result| result.as_ref().ok())
165 .cloned(),
166 failed: result.as_ref().is_some_and(|result| result.is_err()),
167 order: order.get(),
168 };
169 crate::bridges::navigation::write_state(&entry_id, "search", &state);
170 });
171
172 let on_search = move |value: String| {
173 let q = value.trim().to_string();
174 if is_composing.get_untracked()
175 || q.is_empty()
176 || (is_searching.get_untracked() && q == submitted_query.get_untracked())
177 {
178 return;
179 }
180 let id = request_id.get_untracked() + 1;
181 request_id.set(id);
182 submitted_query.set(q.clone());
183 is_searching.set(true);
184 search_res.set(None);
185 spawn_local(async move {
186 let res = search_posts(q).await;
187 if request_id.try_get_untracked() == Some(id) {
189 search_res.set(Some(res));
190 is_searching.set(false);
191 }
192 });
193 };
194
195 let focus_input = move || {
197 request_animation_frame(move || {
198 if let Some(input) = input_ref.get_untracked() {
199 let _ = input.focus();
200 }
201 });
202 };
203
204 let clear_search = move || {
205 request_id.update(|id| *id += 1);
206 query.set(String::new());
207 submitted_query.set(String::new());
208 search_res.set(None);
209 is_searching.set(false);
210 focus_input();
211 };
212
213 let schedule_search = move || {
214 request_id.update(|id| *id += 1);
216 is_searching.set(false);
217 let id = request_id.get_untracked();
218 let q = query.get_untracked().trim().to_string();
219 if q.is_empty() {
220 submitted_query.set(String::new());
221 search_res.set(None);
222 return;
223 }
224 if is_composing.get_untracked() {
225 return;
226 }
227 spawn_local(async move {
228 sleep_ms(SEARCH_DEBOUNCE_MS).await;
229 if request_id.try_get_untracked() == Some(id)
230 && !is_composing.get_untracked()
231 && !(q == submitted_query.get_untracked()
232 && search_res.with_untracked(|res| matches!(res, Some(Ok(_)))))
233 {
234 on_search(q);
235 }
236 });
237 };
238
239 Effect::new(move |previous: Option<()>| {
242 let q = q.get();
243 if previous.is_none() {
244 if let Some(saved) =
245 crate::bridges::navigation::read_state::<SearchHistoryState>("search")
246 {
247 if !saved.query.is_empty() {
248 query.set(saved.query.clone());
249 }
250 submitted_query.set(saved.submitted_query.clone());
251 search_res.set(saved.result());
252 order.set(saved.order);
253 }
254 restored.set(true);
255 }
256 if q.is_empty() || q == submitted_query.get_untracked() {
257 return;
258 }
259 query.set(q.clone());
260 on_search(q);
261 });
262
263 let status = move || {
264 let submitted = submitted_query.get();
265 if is_searching.get() {
266 format!("正在查找「{submitted}」…")
267 } else {
268 search_res.with(|res| match res {
269 Some(Ok(res)) if res.posts.len() >= RESULT_LIMIT => {
270 format!(
271 "「{submitted}」找到 {RESULT_LIMIT} 篇以上,列出最相关的 {RESULT_LIMIT} 篇"
272 )
273 }
274 Some(Ok(res)) => format!("「{submitted}」找到 {} 篇", res.posts.len()),
275 Some(Err(_)) => "搜索暂时未能完成,请重试".to_string(),
276 None => String::new(),
277 })
278 }
279 };
280
281 let content = move || {
282 let searching = is_searching.get();
283 let res = search_res.get();
284 match res {
285 None if !searching => view! {
286 <SuggestedTags on_pick=move |name: String| {
287 query.set(name.clone());
288 on_search(name);
289 }/>
290 }
291 .into_any(),
292 _ if searching => view! {
293 <DelayedSkeleton>
294 <SearchSkeleton/>
295 </DelayedSkeleton>
296 }
297 .into_any(),
298 Some(Ok(res)) => results_view(res, submitted_query.get_untracked(), order, focus_input),
299 _ => view! {
300 <LoadError
301 title="搜索暂时未能完成"
302 class="page-enter"
303 on_retry=move |_| on_search(query.get_untracked())
304 />
305 }
306 .into_any(),
307 }
308 };
309
310 let on_input = move |value: String| {
311 if value.is_empty() {
312 clear_search();
313 } else {
314 query.set(value);
315 schedule_search();
316 }
317 };
318
319 let on_keydown = move |event: KeyboardEvent| match event.key().as_str() {
320 "Escape" => {
321 event.prevent_default();
322 clear_search();
323 }
324 "ArrowDown"
325 if search_res.with_untracked(|res| {
326 res.as_ref()
327 .is_some_and(|res| res.as_ref().is_ok_and(|res| !res.posts.is_empty()))
328 }) =>
329 {
330 event.prevent_default();
331 focus_result(0);
332 }
333 _ => {}
334 };
335
336 view! {
337 <Title text="搜索 · Yggdrasil"/>
338 <div class="search-page">
339 <h1 class="search-title page-enter">"搜索"</h1>
340 <form
341 class="search-composer page-enter"
342 style="--enter-index: 1"
343 role="search"
344 action="/search"
345 method="get"
346 on:submit=move |event| {
347 event.prevent_default();
348 on_search(query.get_untracked());
349 }
350 >
351 <Composer>
352 <Icon name=IconName::Search size=18/>
353 <label class="sr-only" for="article-search">
354 "搜索文章"
355 </label>
356 <ComposerInput
357 id="article-search"
358 search=true
359 maxlength=200
360 placeholder="标题、摘要或标签…"
361 value=query
362 input_ref=input_ref
363 oninput=on_input
364 oncomposition=move |active: bool| {
365 is_composing.set(active);
366 schedule_search();
367 }
368 onkeydown=on_keydown
369 />
370 <Show when=move || !query.get().is_empty()>
371 <button
372 class="btn btn-xs btn-icon btn-ghost search-clear"
373 type="button"
374 aria-label="清空搜索"
375 on:click=move |_| clear_search()
376 >
377 <Icon name=IconName::X size=14/>
378 </button>
379 </Show>
380 <ComposerSend
381 label="搜索"
382 disabled=Signal::derive(move || query.get().trim().is_empty())
383 />
384 </Composer>
385 </form>
386
387 <p class="sr-only" role="status" aria-live="polite">
388 {status}
389 </p>
390 <section
391 class="search-content"
392 data-vt-list=move || (!is_searching.get()).then_some("true")
393 aria-label="搜索结果"
394 aria-busy=move || is_searching.get().to_string()
395 >
396 {content}
397 </section>
398 </div>
399 }
400}
401
402fn results_view(
404 res: PostListResponse,
405 submitted: String,
406 order: RwSignal<SortOrder>,
407 focus_input: impl Fn() + Copy + Send + Sync + 'static,
408) -> AnyView {
409 if res.posts.is_empty() {
410 return view! {
411 <div class="search-empty page-enter">
412 {Mascot::Panic.img("mascot search-empty-mascot")}
413 <p class="search-empty-title">{format!("没有找到「{submitted}」")}</p>
414 <p class="search-empty-description">"换个更短的关键词,或者从归档里按标签找找。"</p>
415 <a class="btn btn-secondary btn-sm" href=Route::Archives.to_string()>
416 "浏览归档"
417 </a>
418 </div>
419 }
420 .into_any();
421 }
422
423 let total = res.posts.len();
424 let posts = Arc::new(res.posts);
425 let list_query = submitted.clone();
426 view! {
427 <div class="search-toolbar">
428 <p class="search-count tabular-nums" aria-hidden="true">
429 {format!("「{submitted}」找到 ")} <strong>{total}</strong>
430 {if total >= RESULT_LIMIT { " 篇以上" } else { " 篇" }}
431 </p>
432 <SegmentedControl
433 items=vec![
434 SegmentedItem::new("relevance".to_string(), "相关度"),
435 SegmentedItem::new("latest".to_string(), "最新"),
436 ]
437 value=Signal::derive(move || order.get().value().to_string())
438 on_change=move |value: String| order.set(SortOrder::from_value(&value))
439 aria_label="排序"
440 class="search-sort"
441 />
442 </div>
443 <ol
444 class="search-results"
445 data-order=move || order.get().value()
446 on:keydown=move |event: KeyboardEvent| {
447 let Some(index) = focused_result() else { return };
448 match event.key().as_str() {
449 "ArrowDown" => {
450 event.prevent_default();
451 focus_result(index + 1);
452 }
453 "ArrowUp" => {
454 event.prevent_default();
455 if index == 0 {
456 focus_input();
457 } else {
458 focus_result(index - 1);
459 }
460 }
461 _ => {}
462 }
463 }
464 >
465 {move || {
466 ordered(&posts, order.get())
467 .into_iter()
468 .enumerate()
469 .map(|(index, post)| {
470 view! {
471 <li>
472 <SearchResultItem post index query=list_query.clone()/>
473 </li>
474 }
475 })
476 .collect_view()
477 }}
478 </ol>
479 }
480 .into_any()
481}
482
483fn focused_result() -> Option<usize> {
485 #[cfg(target_arch = "wasm32")]
486 {
487 let active = web_sys::window()?.document()?.active_element()?;
488 active.id().strip_prefix("search-result-")?.parse().ok()
489 }
490 #[cfg(not(target_arch = "wasm32"))]
491 None
492}
493
494fn focus_result(index: usize) {
495 #[cfg(target_arch = "wasm32")]
496 crate::components::palette::focus_by_id(&result_id(index));
497 #[cfg(not(target_arch = "wasm32"))]
498 let _ = index;
499}
500
501#[component]
506fn SuggestedTags(#[prop(into)] on_pick: Callback<String>) -> impl IntoView {
507 let tags = RwSignal::new(Vec::<Tag>::new());
508 Effect::new(move |_| {
509 spawn_local(async move {
510 if let Ok(data) = list_tags().await {
511 let _ = tags.try_set(ranked_tags(data.tags));
512 }
513 });
514 });
515
516 move || {
517 let tags = tags.get();
518 if tags.is_empty() {
519 return None;
520 }
521 Some(view! {
522 <div class="search-suggest page-enter">
523 <h2 class="search-suggest-title">"试试这些标签"</h2>
524 <div class="search-suggest-list">
525 {tags
526 .into_iter()
527 .take(SUGGESTED_TAGS)
528 .map(|tag| {
529 let name = tag.name.clone();
530 view! {
531 <button
532 type="button"
533 class="chip"
534 on:click=move |_| on_pick.run(name.clone())
535 >
536 {tag.name} <span class="chip-count">{tag.post_count}</span>
537 </button>
538 }
539 })
540 .collect_view()}
541 </div>
542 </div>
543 })
544 }
545}
546
547#[component]
549fn Marked(text: String, query: String) -> impl IntoView {
550 highlight(&text, &query)
551 .into_iter()
552 .map(|(part, hit)| {
553 if hit {
554 view! { <mark>{part}</mark> }.into_any()
555 } else {
556 view! { <span>{part}</span> }.into_any()
557 }
558 })
559 .collect_view()
560}
561
562#[component]
563fn SearchResultItem(post: PostListItem, index: usize, query: String) -> impl IntoView {
564 let date = crate::utils::time::ymd(&post.published_at.unwrap_or(post.created_at), '.');
565 let reading_time = post.reading_time.max(1);
566 let summary = post
567 .summary
568 .as_deref()
569 .filter(|text| !text.trim().is_empty())
570 .map(|text| snippet(text, &query, SNIPPET_RADIUS));
571 let link_id = post.id.to_string();
572 let title_id = post.id.to_string();
573 let tags = post
574 .tags
575 .iter()
576 .take(RESULT_TAGS)
577 .map(|tag| {
578 let class = if contains_ci(tag, &query) {
579 "search-result-tag is-hit"
580 } else {
581 "search-result-tag"
582 };
583 view! { <span class=class>{tag.clone()}</span> }
584 })
585 .collect_view();
586 let has_tags = !post.tags.is_empty();
587 view! {
588 <a
589 id=result_id(index)
590 class="search-result"
591 data-vt-post-link=link_id
592 href=Route::PostDetail { slug: post.slug.clone() }.to_string()
593 >
594 <span class="search-result-title" data-vt-post-id=title_id data-vt-role="title">
595 <Marked text=post.title.clone() query=query.clone()/>
596 </span>
597 {summary
598 .map(|summary| {
599 view! {
600 <span class="search-result-summary">
601 <Marked text=summary query=query.clone()/>
602 </span>
603 }
604 })}
605 <span class="search-result-meta">
606 <time class="tabular-nums">{date}</time>
607 <span class="tabular-nums">{format!("{reading_time} 分钟")}</span>
608 {has_tags.then(|| view! { <span class="search-result-tags">{tags}</span> })}
609 </span>
610 </a>
611 }
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617 use chrono::{TimeZone, Utc};
618
619 fn post(id: i32, day: Option<u32>, created_day: u32) -> PostListItem {
620 let at = |d| Utc.with_ymd_and_hms(2026, 7, d, 0, 0, 0).unwrap();
621 PostListItem {
622 id,
623 author_id: 1,
624 title: format!("文章 {id}"),
625 slug: format!("post-{id}"),
626 summary: None,
627 status: crate::models::post::PostStatus::Published,
628 published_at: day.map(at),
629 created_at: at(created_day),
630 updated_at: at(created_day),
631 deleted_at: None,
632 tags: Vec::new(),
633 cover_image: None,
634 reading_time: 1,
635 word_count: 0,
636 }
637 }
638
639 #[test]
640 fn latest_order_sorts_by_publication_and_keeps_relevance_untouched() {
641 let posts = vec![post(1, Some(2), 1), post(2, None, 9), post(3, Some(5), 1)];
642 let ids = |order| {
643 ordered(&posts, order)
644 .iter()
645 .map(|p| p.id)
646 .collect::<Vec<_>>()
647 };
648 assert_eq!(ids(SortOrder::Relevance), [1, 2, 3]);
649 assert_eq!(ids(SortOrder::Latest), [2, 3, 1]);
650 }
651
652 #[test]
653 fn sort_order_round_trips_through_its_value() {
654 for order in [SortOrder::Relevance, SortOrder::Latest] {
655 assert_eq!(SortOrder::from_value(order.value()), order);
656 }
657 assert_eq!(SortOrder::from_value("unknown"), SortOrder::Relevance);
658 }
659
660 #[test]
661 fn result_item_keeps_view_transition_markers_and_highlights_hits() {
662 let mut item = post(7, Some(3), 1);
663 item.tags = vec!["Rust".to_string(), "其他".to_string()];
664 item.summary = Some("这里提到了 rust 语言".to_string());
665 let html = Owner::new().with(|| {
666 view! { <SearchResultItem post=item index=2 query="rust".to_string()/> }.to_html()
667 });
668 let html = html.replace("<!>", "");
670 assert!(html.contains(r#"id="search-result-2""#));
671 assert!(html.contains(r#"data-vt-post-link="7""#));
672 assert!(html.contains(r#"data-vt-post-id="7""#));
673 assert!(html.contains(r#"data-vt-role="title""#));
674 assert!(html.contains(r#"href="/post/post-7""#));
675 assert!(html.contains("search-result-tag is-hit"));
676 assert!(html.contains("<mark>rust</mark>"));
677 }
678
679 #[test]
680 fn history_state_restores_settled_results_only() {
681 let failed = SearchHistoryState {
682 failed: true,
683 ..Default::default()
684 };
685 assert!(matches!(failed.result(), Some(Err(_))));
686 assert!(SearchHistoryState::default().result().is_none());
687 }
688}