Skip to main content

yggdrasil/pages/
search.rs

1//! 全文搜索结果页 `/search?q=`:顶部 Composer,结果按相关度或时间排列,命中词高亮。
2//!
3//! 输入停顿后自动检索,回车立即提交;编辑与清空会立即作废旧请求。输入框里按 ↓
4//! 进入结果,结果间 ↑↓ 移动,↑ 越过第一条回到输入框。未输入时给出常用标签。
5//!
6//! 搜索请求只在浏览器发起(Effect / 事件处理里 `spawn_local`),页面没有 SSR 数据,
7//! 因此路由使用默认 `SsrMode`。服务端与水合首帧都按 `?q=` 渲染输入框、不渲染结果;
8//! 历史记录里保存的状态在挂载后的 Effect 里恢复,保证两端首帧 DOM 一致。
9
10use 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;
36/// 服务端最多返回的条数。
37const RESULT_LIMIT: usize = 50;
38/// 未输入时推荐的标签数。
39const SUGGESTED_TAGS: usize = 8;
40/// 摘要片段在命中词两侧保留的字数。
41const SNIPPET_RADIUS: usize = 40;
42/// 每条结果最多列出的标签数。
43const 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
69/// 搜索结果:成功的响应,或失败。
70type SearchResult = Result<PostListResponse, ApiError>;
71
72/// Only settled responses are persisted; request IDs and pending work belong to
73/// the mounted page and must never be revived by browser Back.
74#[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
94/// 服务端已按相关度排序;「最新」在浏览器里按发布时间重排,同时间按 id 稳定。
95fn 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
110/// 搜索路由,对应 `/search`(查询参数 `q`)。没有 SSR 数据,请求全部在浏览器发起。
111pub 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/// 搜索页。`q` 来自 `/search?q=`:历史记录里没有保存的查询时用它作为初始查询并立即搜索。
125#[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    // 首帧与服务端一致:输入框只带 `?q=`,其余状态在挂载后恢复。
139    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    // 历史状态恢复完成前不写回,避免用首帧默认值覆盖掉要恢复的记录。
148    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            // 页面可能已离开;序号不符说明有更新的请求或清空,旧响应作废。
188            if request_id.try_get_untracked() == Some(id) {
189                search_res.set(Some(res));
190                is_searching.set(false);
191            }
192        });
193    };
194
195    // NodeRef 先于元素挂入文档被填充,等一帧再聚焦。
196    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        // 同一序号同时约束防抖计时与网络响应,A → B → A 也不会提前提交。
215        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    // 挂载后先恢复历史状态,再处理 `?q=`:带 `?q=` 进入或在本页再次带入新查询时立即搜索;
240    // 与已提交的查询相同则不重复请求。恢复与首次判断必须在同一个 Effect 里按序完成。
241    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
402/// 搜索完成后的内容:无结果提示,或工具栏(计数 + 排序)与结果列表。
403fn 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
483/// 当前获得焦点的结果序号(仅 wasm;服务端渲染时没有焦点)。
484fn 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/// 未输入时的「试试这些标签」。标签接口失败时不显示。
502///
503/// 只在浏览器挂载后请求一次;用 Effect + 镜像信号而不是 `Resource`,避免外层
504/// `Suspense` 把整页计入挂起(SSR 与水合首帧都不渲染)。
505#[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/// 命中词用 `<mark>` 标出的一段文字。
548#[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        // erase_components 下 SSR 在动态文本后输出 `<!>` 水合标记;断言只看内容与属性。
669        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}