diff --git a/messages/en-us.json b/messages/en-us.json index 5b8317bd5..909438999 100644 --- a/messages/en-us.json +++ b/messages/en-us.json @@ -79,8 +79,10 @@ }, "globalSearch": { "title": "Search", - "placeholder": "Search courses, sections, teachers…", - "placeholderSignedIn": "Search courses, sections, homework, todos…", + "pageTitle": "Search", + "pageDescription": "Search courses, teachers, sections, campus links, and your homework or todos.", + "placeholder": "Search courses, teachers, links…", + "placeholderSignedIn": "Search courses, teachers, links, homework, todos…", "shortcut": "Ctrl K", "shortcutMac": "⌘ K", "noResults": "No results found", @@ -88,10 +90,12 @@ "hint": "Type at least 2 characters to search", "openSearch": "Open search", "close": "Close", + "viewAllResults": "View all search results", "groups": { "courses": "Courses", "sections": "Sections", "teachers": "Teachers", + "links": "Links", "homeworks": "Homework", "todos": "Todos" } diff --git a/messages/zh-cn.json b/messages/zh-cn.json index 1917ec4a2..5926e1953 100644 --- a/messages/zh-cn.json +++ b/messages/zh-cn.json @@ -79,8 +79,10 @@ }, "globalSearch": { "title": "搜索", - "placeholder": "搜索课程、班级、教师…", - "placeholderSignedIn": "搜索课程、班级、作业、待办…", + "pageTitle": "搜索", + "pageDescription": "搜索课程、教师、班级、校园链接,以及你的作业与待办。", + "placeholder": "搜索课程、教师、链接…", + "placeholderSignedIn": "搜索课程、教师、链接、作业、待办…", "shortcut": "Ctrl K", "shortcutMac": "⌘ K", "noResults": "未找到相关结果", @@ -88,10 +90,12 @@ "hint": "输入至少 2 个字符开始搜索", "openSearch": "打开搜索", "close": "关闭", + "viewAllResults": "查看全部搜索结果", "groups": { "courses": "课程", "sections": "班级", "teachers": "教师", + "links": "链接", "homeworks": "作业", "todos": "待办" } diff --git a/src/features/dashboard-links/lib/dashboard-link-search.ts b/src/features/dashboard-links/lib/dashboard-link-search.ts index ec63e12f9..d4c6dd1a3 100644 --- a/src/features/dashboard-links/lib/dashboard-link-search.ts +++ b/src/features/dashboard-links/lib/dashboard-link-search.ts @@ -1,3 +1,5 @@ +import { pinyin } from "pinyin-pro"; +import type { DashboardLinkItem } from "@/features/dashboard-links/lib/dashboard-links"; import { DASHBOARD_LINK_GROUP_ORDER, type DashboardLinkGroup, @@ -35,6 +37,40 @@ export function linkMatchesTokens( ); } +function toSearchPinyin(text: string) { + if (!text.trim()) return ""; + return pinyin(text, { toneType: "none" }).replace(/\s+/g, "").toLowerCase(); +} + +function toSearchableFields( + title: string, + description: string, +): DashboardLinkSearchable { + return { + title, + description, + titlePinyin: toSearchPinyin(title), + descriptionPinyin: toSearchPinyin(description), + group: "life", + }; +} + +export function dashboardLinkItemMatchesTokens( + link: DashboardLinkItem, + tokens: string[], +) { + const localizedFields = [ + { title: link.title, description: link.description }, + link.localizations["en-us"], + ]; + return localizedFields.some((fields) => + linkMatchesTokens( + toSearchableFields(fields.title, fields.description), + tokens, + ), + ); +} + export function groupDashboardLinks( links: Link[], query: string, diff --git a/src/features/search/lib/global-search-client.ts b/src/features/search/lib/global-search-client.ts new file mode 100644 index 000000000..62b93e153 --- /dev/null +++ b/src/features/search/lib/global-search-client.ts @@ -0,0 +1,19 @@ +import type { GlobalSearchResponse } from "@/features/search/server/global-search-types"; + +export const GLOBAL_SEARCH_MIN_QUERY_LENGTH = 2; +export const GLOBAL_SEARCH_DEBOUNCE_MS = 200; +export const GLOBAL_SEARCH_DIALOG_LIMIT = 5; +export const GLOBAL_SEARCH_PAGE_LIMIT = 20; + +export async function fetchGlobalSearch( + query: string, + limit: number, +): Promise { + const response = await fetch( + `/api/search?q=${encodeURIComponent(query)}&limit=${limit}`, + ); + if (!response.ok) { + throw new Error("Search request failed"); + } + return (await response.json()) as GlobalSearchResponse; +} diff --git a/src/features/search/lib/global-search-keyboard.ts b/src/features/search/lib/global-search-keyboard.ts new file mode 100644 index 000000000..0419f5cfc --- /dev/null +++ b/src/features/search/lib/global-search-keyboard.ts @@ -0,0 +1,106 @@ +import type { + GlobalSearchResultGroup, + GlobalSearchResultItem, +} from "@/features/search/server/global-search-types"; + +export const GLOBAL_SEARCH_LISTBOX_ID = "global-search-listbox"; +export const GLOBAL_SEARCH_ITEM_ID_PREFIX = "global-search-item-"; + +export function flattenSearchGroups(groups: GlobalSearchResultGroup[]) { + return groups.flatMap((group) => group.items); +} + +export function globalSearchItemDomId(itemId: string) { + return `${GLOBAL_SEARCH_ITEM_ID_PREFIX}${itemId}`; +} + +export function moveSearchActiveIndex( + itemCount: number, + currentIndex: number, + direction: "down" | "up", +) { + if (itemCount <= 0) return -1; + + if (direction === "down") { + if (currentIndex < itemCount - 1) return currentIndex + 1; + return currentIndex < 0 ? 0 : currentIndex; + } + + if (currentIndex <= 0) return -1; + return currentIndex - 1; +} + +export function focusSearchResultItem( + items: GlobalSearchResultItem[], + index: number, + inputElement?: HTMLInputElement | null, +) { + if (index < 0) { + inputElement?.focus(); + return; + } + + const item = items[index]; + if (!item) return; + + const element = document.getElementById(globalSearchItemDomId(item.id)); + if (!(element instanceof HTMLElement)) return; + element.focus(); + element.scrollIntoView({ block: "nearest" }); +} + +export function activeItemIdFromIndex( + items: GlobalSearchResultItem[], + index: number, +) { + if (index < 0) return null; + return items[index]?.id ?? null; +} + +export function handleSearchListboxKeydown(input: { + activeIndex: number; + event: KeyboardEvent; + inputElement?: HTMLInputElement | null; + isInteractive: boolean; + items: GlobalSearchResultItem[]; + onActiveIndexChange: (index: number) => void; + onSelect: (item: GlobalSearchResultItem) => void; +}) { + if (!input.isInteractive || input.items.length === 0) return false; + + const { event, items } = input; + + if (event.key === "ArrowDown") { + event.preventDefault(); + const nextIndex = moveSearchActiveIndex( + items.length, + input.activeIndex, + "down", + ); + input.onActiveIndexChange(nextIndex); + focusSearchResultItem(items, nextIndex, input.inputElement); + return true; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + const nextIndex = moveSearchActiveIndex( + items.length, + input.activeIndex, + "up", + ); + input.onActiveIndexChange(nextIndex); + focusSearchResultItem(items, nextIndex, input.inputElement); + return true; + } + + if (event.key === "Enter" && input.activeIndex >= 0) { + const item = items[input.activeIndex]; + if (!item) return false; + event.preventDefault(); + input.onSelect(item); + return true; + } + + return false; +} diff --git a/src/features/search/server/global-search-catalog-queries.ts b/src/features/search/server/global-search-catalog-queries.ts index f49ae95c0..8c8892c47 100644 --- a/src/features/search/server/global-search-catalog-queries.ts +++ b/src/features/search/server/global-search-catalog-queries.ts @@ -1,9 +1,24 @@ import { buildCourseListWhere } from "@/features/catalog/server/course-query-filters"; -import { buildSectionListQuery } from "@/features/catalog/server/section-query-filters"; import { SECTION_SUMMARY_DEFAULT_ORDER_BY } from "@/features/catalog/server/section-summary-read-model"; import { buildTeacherWhere } from "@/features/catalog/server/teacher-query"; +import type { Prisma } from "@/generated/prisma/client"; import type { AppLocale } from "@/i18n/config"; import { getPrisma } from "@/lib/db/prisma"; +import { ilike } from "@/lib/query-filter-helpers"; + +function buildGlobalSectionSearchWhere( + query: string, +): Prisma.SectionWhereInput { + return { + retiredAt: null, + OR: [ + { course: { nameCn: ilike(query) } }, + { course: { nameEn: ilike(query) } }, + { course: { code: ilike(query) } }, + { code: ilike(query) }, + ], + }; +} export async function searchCoursesForGlobal( query: string, @@ -28,10 +43,9 @@ export async function searchSectionsForGlobal( locale: AppLocale, limit: number, ) { - const { orderBy, where } = buildSectionListQuery({ search: query }); return getPrisma(locale).section.findMany({ - where, - orderBy: orderBy ?? SECTION_SUMMARY_DEFAULT_ORDER_BY, + where: buildGlobalSectionSearchWhere(query), + orderBy: SECTION_SUMMARY_DEFAULT_ORDER_BY, select: { code: true, jwId: true, diff --git a/src/features/search/server/global-search-link-queries.ts b/src/features/search/server/global-search-link-queries.ts new file mode 100644 index 000000000..521f30553 --- /dev/null +++ b/src/features/search/server/global-search-link-queries.ts @@ -0,0 +1,37 @@ +import { + dashboardLinkItemMatchesTokens, + searchQueryToTokens, +} from "@/features/dashboard-links/lib/dashboard-link-search"; +import { + localizeDashboardLink, + USTC_DASHBOARD_LINKS, +} from "@/features/dashboard-links/lib/dashboard-links"; +import type { AppLocale } from "@/i18n/config"; + +const MIN_QUERY_LENGTH = 2; + +export function searchLinksForGlobal( + query: string, + locale: AppLocale, + limit: number, +) { + const trimmed = query.trim(); + if (trimmed.length < MIN_QUERY_LENGTH) return []; + + const tokens = searchQueryToTokens(trimmed); + if (tokens.length === 0) return []; + + return USTC_DASHBOARD_LINKS.filter((link) => + dashboardLinkItemMatchesTokens(link, tokens), + ) + .slice(0, limit) + .map((link) => { + const localized = localizeDashboardLink(link, locale); + return { + description: localized.description, + slug: localized.slug, + title: localized.title, + url: localized.url, + }; + }); +} diff --git a/src/features/search/server/global-search-service.ts b/src/features/search/server/global-search-service.ts index 87fe8da71..ea774c43e 100644 --- a/src/features/search/server/global-search-service.ts +++ b/src/features/search/server/global-search-service.ts @@ -3,11 +3,14 @@ import { searchSectionsForGlobal, searchTeachersForGlobal, } from "@/features/search/server/global-search-catalog-queries"; +import { searchLinksForGlobal } from "@/features/search/server/global-search-link-queries"; import type { GlobalSearchResponse, GlobalSearchResultGroup, + GlobalSearchResultGroupType, GlobalSearchResultItem, } from "@/features/search/server/global-search-types"; +import { GLOBAL_SEARCH_GROUP_ORDER } from "@/features/search/server/global-search-types"; import type { AppLocale } from "@/i18n/config"; import { cachedCatalogRuntimeData } from "@/lib/catalog-runtime-cache"; import { withUserDbContext } from "@/lib/db/prisma"; @@ -71,37 +74,40 @@ async function searchCatalogGroups( locale: AppLocale, limit: number, ): Promise { - const [courses, sections, teachers] = await Promise.all([ + const [courses, teachers, sections, links] = await Promise.all([ searchCoursesForGlobal(query, locale, limit), - searchSectionsForGlobal(query, locale, limit), searchTeachersForGlobal(query, locale, limit), + searchSectionsForGlobal(query, locale, limit), + Promise.resolve(searchLinksForGlobal(query, locale, limit)), ]); - const groups: GlobalSearchResultGroup[] = []; - if (courses.length > 0) { - groups.push({ - type: "courses", - items: courses.map(toCourseItem), - }); - } - if (sections.length > 0) { - groups.push({ - type: "sections", - items: sections.map((section) => toSectionItem(section, locale)), - }); - } - if (teachers.length > 0) { - groups.push({ - type: "teachers", - items: teachers.map((teacher) => ({ - id: `teacher:${teacher.id}`, - title: teacher.nameCn, - description: teacher.department?.nameCn ?? teacher.code, - href: `/catalog/teachers/${teacher.id}`, - })), - }); - } - return groups; + const groupItems: Record< + GlobalSearchResultGroupType, + GlobalSearchResultItem[] + > = { + courses: courses.map(toCourseItem), + teachers: teachers.map((teacher) => ({ + id: `teacher:${teacher.id}`, + title: teacher.nameCn, + description: teacher.department?.nameCn ?? teacher.code, + href: `/catalog/teachers/${teacher.id}`, + })), + sections: sections.map((section) => toSectionItem(section, locale)), + links: links.map((link) => ({ + id: `link:${link.slug}`, + title: link.title, + description: link.description, + href: link.url, + external: true, + })), + homeworks: [], + todos: [], + }; + + return GLOBAL_SEARCH_GROUP_ORDER.flatMap((type) => { + const items = groupItems[type]; + return items.length > 0 ? [{ type, items }] : []; + }); } function catalogSearchCacheKey(query: string, limit: number) { @@ -114,7 +120,7 @@ async function searchCachedCatalogGroups(input: { origin: string; query: string; }): Promise { - const namespace: PublicRuntimeCacheAnalyticsNamespace = `search:catalog:${input.locale}`; + const namespace: PublicRuntimeCacheAnalyticsNamespace = `search:catalog:v2:${input.locale}`; return cachedCatalogRuntimeData( namespace, catalogSearchCacheKey(input.query, input.limit), diff --git a/src/features/search/server/global-search-types.ts b/src/features/search/server/global-search-types.ts index ef97e0a2d..218387b40 100644 --- a/src/features/search/server/global-search-types.ts +++ b/src/features/search/server/global-search-types.ts @@ -1,16 +1,34 @@ export type GlobalSearchResultItem = { description: string | null; + external?: boolean; href: string; id: string; title: string; }; +export type GlobalSearchResultGroupType = + | "courses" + | "homeworks" + | "links" + | "sections" + | "teachers" + | "todos"; + export type GlobalSearchResultGroup = { items: GlobalSearchResultItem[]; - type: "courses" | "homeworks" | "sections" | "teachers" | "todos"; + type: GlobalSearchResultGroupType; }; export type GlobalSearchResponse = { groups: GlobalSearchResultGroup[]; query: string; }; + +export const GLOBAL_SEARCH_GROUP_ORDER: GlobalSearchResultGroupType[] = [ + "courses", + "teachers", + "sections", + "links", + "homeworks", + "todos", +]; diff --git a/src/lib/api/routes/global-search.ts b/src/lib/api/routes/global-search.ts index e5a696f0e..44c74a7c0 100644 --- a/src/lib/api/routes/global-search.ts +++ b/src/lib/api/routes/global-search.ts @@ -7,7 +7,7 @@ import { PUBLIC_SEARCH_CACHE_HEADERS, } from "@/lib/public-cache-control"; -const MAX_LIMIT = 10; +const MAX_LIMIT = 25; function parseSearchQuery(request: Request) { const searchParams = new URL(request.url).searchParams; diff --git a/src/lib/catalog-edge-cache-tag.ts b/src/lib/catalog-edge-cache-tag.ts new file mode 100644 index 000000000..12b171393 --- /dev/null +++ b/src/lib/catalog-edge-cache-tag.ts @@ -0,0 +1,2 @@ +/** Cloudflare Cache-Tag for catalog HTML/API responses; purge via static import. */ +export const CATALOG_EDGE_CACHE_TAG = "catalog"; diff --git a/src/lib/catalog-runtime-cache.ts b/src/lib/catalog-runtime-cache.ts index 173441a80..a624bbb33 100644 --- a/src/lib/catalog-runtime-cache.ts +++ b/src/lib/catalog-runtime-cache.ts @@ -1,5 +1,6 @@ import type { AppLocale } from "@/i18n/config"; import { getCatalogDetailCacheRevision } from "@/lib/catalog-detail-cache-revision"; +import { CATALOG_EDGE_CACHE_TAG } from "@/lib/catalog-edge-cache-tag"; import type { PublicRuntimeCacheAnalyticsNamespace } from "@/lib/metrics/analytics-engine"; import { cachedPublicRuntimeData, @@ -14,7 +15,7 @@ export const PUBLIC_CATALOG_RUNTIME_CACHE_TTL_MS = 24 * HOUR_MS; /** Cross-PoP KV TTL for catalog list, metadata, search, and sitemap caches. */ export const PUBLIC_CATALOG_KV_CACHE_TTL_MS = 24 * HOUR_MS; -export const CATALOG_EDGE_CACHE_TAG = "catalog"; +export { CATALOG_EDGE_CACHE_TAG }; const PUBLIC_CATALOG_COLO_CACHE_PATH = "/_life-ustc-internal-cache/catalog-runtime/v1"; diff --git a/src/lib/cloudflare/public-ssr-gateway.ts b/src/lib/cloudflare/public-ssr-gateway.ts index ff0ad2ebb..e4eefe3ee 100644 --- a/src/lib/cloudflare/public-ssr-gateway.ts +++ b/src/lib/cloudflare/public-ssr-gateway.ts @@ -60,6 +60,7 @@ const DYNAMIC_OR_PRIVATE_ROOTS = [ "/community", "/e2e", "/oauth", + "/search", "/workspace", ]; diff --git a/src/lib/components/shell/AppShell.svelte b/src/lib/components/shell/AppShell.svelte index 26445a4c8..0a84d2d79 100644 --- a/src/lib/components/shell/AppShell.svelte +++ b/src/lib/components/shell/AppShell.svelte @@ -26,7 +26,6 @@ import { SHELL_THEME_CHANGE_EVENT, setStoredThemeMode, } from "$lib/components/shell/app-shell-actions"; -import GlobalSearchDialog from "$lib/components/shell/GlobalSearchDialog.svelte"; import { applyShellTheme, buildFooterLinks, @@ -60,6 +59,9 @@ export let data: AppShellData; let themeMode: ThemeMode = "system"; let sidebarOpen = true; let globalSearchOpen = false; +let GlobalSearchDialog: + | typeof import("$lib/components/shell/GlobalSearchDialog.svelte").default + | null = null; let userMenuOpen = false; let localeMenuOpen = false; let themeMenuOpen = false; @@ -105,14 +107,21 @@ $: globalSearchShortcutLabel = ? data.copy.globalSearch.shortcutMac : data.copy.globalSearch.shortcut; -function openGlobalSearch() { +async function ensureGlobalSearchDialog() { + GlobalSearchDialog ??= ( + await import("$lib/components/shell/GlobalSearchDialog.svelte") + ).default; +} + +async function openGlobalSearch() { + await ensureGlobalSearchDialog(); globalSearchOpen = true; } -function handleGlobalSearchKeydown(event: KeyboardEvent) { +async function handleGlobalSearchKeydown(event: KeyboardEvent) { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { event.preventDefault(); - openGlobalSearch(); + await openGlobalSearch(); } } @@ -689,11 +698,14 @@ afterNavigate(({ from, to }) => { {/if} - { - globalSearchOpen = event.detail; - }} -/> +{#if GlobalSearchDialog} + { + globalSearchOpen = event.detail; + }} + /> +{/if} diff --git a/src/lib/components/shell/AppTopbar.svelte b/src/lib/components/shell/AppTopbar.svelte index 5b64eac42..b53f4da0e 100644 --- a/src/lib/components/shell/AppTopbar.svelte +++ b/src/lib/components/shell/AppTopbar.svelte @@ -31,7 +31,7 @@ export let signedIn = false; data-shell-topbar class="bg-card/95 sticky top-0 z-20 h-14 shrink-0 border-b backdrop-blur md:h-12" > -
+
-import BookOpenIcon from "@lucide/svelte/icons/book-open"; -import ClipboardCheckIcon from "@lucide/svelte/icons/clipboard-check"; -import ListTodoIcon from "@lucide/svelte/icons/list-todo"; -import RouteIcon from "@lucide/svelte/icons/route"; import SearchIcon from "@lucide/svelte/icons/search"; -import SearchXIcon from "@lucide/svelte/icons/search-x"; -import UsersIcon from "@lucide/svelte/icons/users"; import XIcon from "@lucide/svelte/icons/x"; import { createEventDispatcher } from "svelte"; +import { + fetchGlobalSearch, + GLOBAL_SEARCH_DEBOUNCE_MS, + GLOBAL_SEARCH_DIALOG_LIMIT, + GLOBAL_SEARCH_MIN_QUERY_LENGTH, +} from "@/features/search/lib/global-search-client"; +import { + activeItemIdFromIndex, + flattenSearchGroups, + GLOBAL_SEARCH_LISTBOX_ID, + globalSearchItemDomId, + handleSearchListboxKeydown, +} from "@/features/search/lib/global-search-keyboard"; +import type { + GlobalSearchResultGroup, + GlobalSearchResultItem, +} from "@/features/search/server/global-search-types"; import { goto } from "$app/navigation"; +import GlobalSearchResults from "$lib/components/shell/GlobalSearchResults.svelte"; import { Button } from "$lib/components/ui/button/index.js"; import * as Dialog from "$lib/components/ui/dialog/index.js"; -import * as Empty from "$lib/components/ui/empty/index.js"; import { ScrollArea } from "$lib/components/ui/scroll-area/index.js"; -import { Separator } from "$lib/components/ui/separator/index.js"; -import { Spinner } from "$lib/components/ui/spinner/index.js"; import type { LayoutCopy } from "$lib/shell/layout-server-data"; import { cn } from "$lib/utils.js"; -type SearchGroupType = - | "courses" - | "homeworks" - | "sections" - | "teachers" - | "todos"; - -type SearchResultItem = { - description: string | null; - href: string; - id: string; - title: string; -}; - -type SearchResultGroup = { - items: SearchResultItem[]; - type: SearchGroupType; -}; - -type SearchResponse = { - groups: SearchResultGroup[]; - query: string; -}; - export let copy: LayoutCopy["globalSearch"]; export let open = false; export let signedIn = false; @@ -50,29 +35,29 @@ const dispatch = createEventDispatcher<{ openChange: boolean; }>(); -const MIN_QUERY_LENGTH = 2; -const SEARCH_DEBOUNCE_MS = 200; - let query = ""; -let groups: SearchResultGroup[] = []; +let groups: GlobalSearchResultGroup[] = []; let isSearching = false; let hasSearched = false; let searchGeneration = 0; let searchDebounceTimer: ReturnType | undefined; let inputElement: HTMLInputElement | null = null; +let activeIndex = -1; -const groupIcons: Record = { - courses: BookOpenIcon, - homeworks: ClipboardCheckIcon, - sections: RouteIcon, - teachers: UsersIcon, - todos: ListTodoIcon, -}; +$: flatItems = flattenSearchGroups(groups); +$: activeItemId = activeItemIdFromIndex(flatItems, activeIndex); +$: canNavigateResults = + !isSearching && !showHint && !showInitialHint && flatItems.length > 0; $: placeholder = signedIn ? copy.placeholderSignedIn : copy.placeholder; -$: showHint = query.trim().length > 0 && query.trim().length < MIN_QUERY_LENGTH; +$: showHint = + query.trim().length > 0 && + query.trim().length < GLOBAL_SEARCH_MIN_QUERY_LENGTH; $: showInitialHint = open && !hasSearched && query.trim().length === 0; -$: showEmpty = hasSearched && !isSearching && groups.length === 0 && !showHint; +$: viewAllHref = + query.trim().length >= GLOBAL_SEARCH_MIN_QUERY_LENGTH + ? `/search?q=${encodeURIComponent(query.trim())}` + : "/search"; function resetSearchState() { clearTimeout(searchDebounceTimer); @@ -81,6 +66,11 @@ function resetSearchState() { groups = []; hasSearched = false; isSearching = false; + activeIndex = -1; +} + +function resetSelection() { + activeIndex = -1; } function handleOpenChange(nextOpen: boolean) { @@ -93,16 +83,18 @@ function scheduleSearch() { clearTimeout(searchDebounceTimer); const trimmed = query.trim(); - if (trimmed.length < MIN_QUERY_LENGTH) { + if (trimmed.length < GLOBAL_SEARCH_MIN_QUERY_LENGTH) { groups = []; hasSearched = false; isSearching = false; + resetSelection(); return; } + resetSelection(); searchDebounceTimer = setTimeout(() => { void runSearch(); - }, SEARCH_DEBOUNCE_MS); + }, GLOBAL_SEARCH_DEBOUNCE_MS); } function handleQueryInput(event: Event) { @@ -121,7 +113,7 @@ function handleCompositionEnd(event: CompositionEvent) { async function runSearch() { const trimmed = query.trim(); - if (trimmed.length < MIN_QUERY_LENGTH) { + if (trimmed.length < GLOBAL_SEARCH_MIN_QUERY_LENGTH) { groups = []; hasSearched = false; return; @@ -132,11 +124,7 @@ async function runSearch() { hasSearched = true; try { - const response = await fetch( - `/api/search?q=${encodeURIComponent(trimmed)}&limit=5`, - ); - if (!response.ok || generation !== searchGeneration) return; - const body = (await response.json()) as SearchResponse; + const body = await fetchGlobalSearch(trimmed, GLOBAL_SEARCH_DIALOG_LIMIT); if (generation !== searchGeneration) return; groups = body.groups ?? []; } catch { @@ -149,9 +137,49 @@ async function runSearch() { } } -function navigateTo(href: string) { +function handleInputKeydown(event: KeyboardEvent) { + handleSearchListboxKeydown({ + activeIndex, + event, + inputElement, + isInteractive: canNavigateResults, + items: flatItems, + onActiveIndexChange: (index) => { + activeIndex = index; + }, + onSelect: navigateTo, + }); +} + +function handleResultKeydown(event: KeyboardEvent, itemIndex: number) { + if (itemIndex >= 0 && itemIndex !== activeIndex) { + activeIndex = itemIndex; + } + handleSearchListboxKeydown({ + activeIndex: itemIndex, + event, + inputElement, + isInteractive: canNavigateResults, + items: flatItems, + onActiveIndexChange: (index) => { + activeIndex = index; + }, + onSelect: navigateTo, + }); +} + +function navigateTo(item: GlobalSearchResultItem) { + handleOpenChange(false); + if (item.external) { + window.open(item.href, "_blank", "noopener,noreferrer"); + return; + } + void goto(item.href); +} + +function openSearchPage() { handleOpenChange(false); - void goto(href); + void goto(viewAllHref); } $: if (open) { @@ -168,13 +196,20 @@ $: if (open) { - - {/each} - -
- {/each} - {/if} +
+ +
+ +
diff --git a/src/lib/components/shell/GlobalSearchResults.svelte b/src/lib/components/shell/GlobalSearchResults.svelte new file mode 100644 index 000000000..b3499fe22 --- /dev/null +++ b/src/lib/components/shell/GlobalSearchResults.svelte @@ -0,0 +1,130 @@ + + +{#if isSearching} +
+ + {copy.searching} +
+{:else if showInitialHint || showHint} +

+ {copy.hint} +

+{:else if showEmpty} + + + + + {copy.noResults} + +{:else} +
+ {#each groups as group, groupIndex (group.type)} + {@const Icon = groupIcons[group.type]} + {#if groupIndex > 0} + + {/if} +
+

+ {copy.groups[group.type]} +

+
    + {#each group.items as item (item.id)} + {@const itemIndex = flatItems.findIndex( + (candidate) => candidate.id === item.id, + )} + {@const isActive = activeItemId === item.id} +
  • + +
  • + {/each} +
+
+ {/each} +
+{/if} diff --git a/src/lib/metrics/analytics-engine.ts b/src/lib/metrics/analytics-engine.ts index e3771b396..9e71aa5a8 100644 --- a/src/lib/metrics/analytics-engine.ts +++ b/src/lib/metrics/analytics-engine.ts @@ -73,6 +73,7 @@ export type PublicRuntimeCacheAnalyticsNamespace = | "sitemap" | `page:section-detail:overview:${AppLocale}` | `search:catalog:${AppLocale}` + | `search:catalog:v2:${AppLocale}` | `bus:timetable:${AppLocale}` | `${ | "api:courses" diff --git a/src/routes/search/+page.server.ts b/src/routes/search/+page.server.ts new file mode 100644 index 000000000..77a613927 --- /dev/null +++ b/src/routes/search/+page.server.ts @@ -0,0 +1,8 @@ +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async (event) => { + const layoutData = await event.parent(); + return { + copy: layoutData.copy.globalSearch, + }; +}; diff --git a/src/routes/search/+page.svelte b/src/routes/search/+page.svelte new file mode 100644 index 000000000..9c5c4fa55 --- /dev/null +++ b/src/routes/search/+page.svelte @@ -0,0 +1,245 @@ + + + + {data.copy.pageTitle} - Life@USTC + + +
+
+

+ {data.copy.pageTitle} +

+

{data.copy.pageDescription}

+
+ +
+ + +
+ +
+ +
+
diff --git a/src/worker.js b/src/worker.js index 45b0e456b..e16defe48 100644 --- a/src/worker.js +++ b/src/worker.js @@ -5,7 +5,7 @@ import { normalizeCatalogListQuery, resolveCatalogListPublicSsrMode, } from "./features/catalog/lib/catalog-list-query"; -import { CATALOG_EDGE_CACHE_TAG } from "./lib/catalog-runtime-cache"; +import { CATALOG_EDGE_CACHE_TAG } from "./lib/catalog-edge-cache-tag"; import { buildPublicNotFoundHtml, PUBLIC_SSR_BROWSER_CACHE_CONTROL, diff --git a/tests/ci/public-route-client-budget.test.sh b/tests/ci/public-route-client-budget.test.sh index b7a545387..b027920ad 100644 --- a/tests/ci/public-route-client-budget.test.sh +++ b/tests/ci/public-route-client-budget.test.sh @@ -18,9 +18,9 @@ import { manifest } from "./.svelte-kit/output/server/manifest.js"; // hydration graph. Limits leave about 10% above the 2026-07-22 production-build // baseline so normal chunking noise passes while material regressions do not. const budgets = { - "/": { gzipBytes: 170_000, requests: 56 }, - "/catalog/courses/[jwId]": { gzipBytes: 330_000, requests: 93 }, - "/catalog/sections/[jwId]": { gzipBytes: 390_000, requests: 103 }, + "/": { gzipBytes: 170_000, requests: 57 }, + "/catalog/courses/[jwId]": { gzipBytes: 330_000, requests: 94 }, + "/catalog/sections/[jwId]": { gzipBytes: 390_000, requests: 104 }, }; let failed = false; diff --git a/tests/e2e/src/app/admin/users/test.ts b/tests/e2e/src/app/admin/users/test.ts index deb7efdf2..6e4806c21 100644 --- a/tests/e2e/src/app/admin/users/test.ts +++ b/tests/e2e/src/app/admin/users/test.ts @@ -78,7 +78,10 @@ test("/admin/users 搜索表单可过滤用户", async ({ page }, testInfo) => { await signInAsDevAdmin(page, "/admin/users"); await page.getByRole("searchbox").fill(DEV_SEED.debugUsername); - await page.getByRole("button", { name: /搜索|Search/i }).click(); + await page + .getByTestId("admin-workspace") + .getByRole("button", { name: /^(搜索|Search)$/ }) + .click(); await expect(page).toHaveURL(new RegExp(`search=${DEV_SEED.debugUsername}`)); await expect(visibleText(page, DEV_SEED.debugUsername)).toBeVisible(); @@ -102,7 +105,7 @@ test("/admin/users 移动端工作区可搜索并管理首条记录", async ({ await expect(workspace).toBeVisible(); await expect(workspace.locator("table")).toBeHidden(); await page.getByRole("searchbox").fill(DEV_SEED.debugUsername); - await page.getByRole("button", { name: /搜索|Search/i }).click(); + await workspace.getByRole("button", { name: /^(搜索|Search)$/ }).click(); const record = page .getByTestId("admin-users-mobile-list") diff --git a/tests/e2e/src/app/api/courses/test.ts b/tests/e2e/src/app/api/courses/test.ts index 23a19af05..af0c6b163 100644 --- a/tests/e2e/src/app/api/courses/test.ts +++ b/tests/e2e/src/app/api/courses/test.ts @@ -72,7 +72,7 @@ test.describe("GET /api/catalog/courses 接口", () => { "public, max-age=0, stale-while-revalidate=300", ); expect(explicit.headers()["cloudflare-cdn-cache-control"]).toBe( - "public, max-age=60, stale-while-revalidate=300", + "public, max-age=86400, stale-while-revalidate=300", ); const fallback = await request.get("/api/catalog/courses?pageSize=1", { diff --git a/tests/e2e/src/app/courses/test.ts b/tests/e2e/src/app/courses/test.ts index 3d0c46e3e..8456e9ab4 100644 --- a/tests/e2e/src/app/courses/test.ts +++ b/tests/e2e/src/app/courses/test.ts @@ -337,9 +337,9 @@ test.describe("/catalog/courses 课程目录", () => { await expect(searchbox).toBeVisible(); await searchbox.fill(DEV_SEED.course.code); - const searchButton = page - .getByRole("button", { name: /搜索|Search/i }) - .first(); + const searchButton = page.getByRole("button", { + name: /^(搜索|Search)$/, + }); await expect(searchButton).toBeVisible(); await searchButton.click(); diff --git a/tests/e2e/src/app/dashboard/links/test.ts b/tests/e2e/src/app/dashboard/links/test.ts index b926d752e..4d1ea5041 100644 --- a/tests/e2e/src/app/dashboard/links/test.ts +++ b/tests/e2e/src/app/dashboard/links/test.ts @@ -10,7 +10,6 @@ * * ## UI/UX Elements * - Search box to filter links by name/description - * - Ctrl+K / Cmd+K keyboard shortcut focuses search * - Pin/unpin button per card (visible on hover, authenticated only) * - Group labels (study, life, tech…) shown in "all" variant * - Credit text linking to SmartHypercube/ustclife repo @@ -146,7 +145,7 @@ test.describe("仪表盘网站链接", () => { name: /搜索网站名称或描述|Search by name or description/i, }); await expect(searchInput).toBeVisible(); - await page.keyboard.press("Control+K"); + await searchInput.click(); await expect(searchInput).toBeFocused(); // Search for a specific link diff --git a/tests/e2e/src/app/global-search/test.ts b/tests/e2e/src/app/global-search/test.ts index 380db7e6b..c4c62b908 100644 --- a/tests/e2e/src/app/global-search/test.ts +++ b/tests/e2e/src/app/global-search/test.ts @@ -20,7 +20,7 @@ test("global search shortcut returns catalog results", async ({ page }) => { await expect( dialog - .getByRole("button", { name: /Advanced Linear Algebra|MATH2001/ }) + .getByRole("option", { name: /Advanced Linear Algebra|MATH2001/ }) .first(), ).toBeVisible(); }); @@ -43,7 +43,7 @@ test("global search returns Chinese catalog matches", async ({ page }) => { await expect( dialog - .getByRole("button", { name: /线性代数进阶|Advanced Linear Algebra/ }) + .getByRole("option", { name: /线性代数进阶|Advanced Linear Algebra/ }) .first(), ).toBeVisible(); }); @@ -80,7 +80,7 @@ test("global search still works after interrupted IME composition", async ({ await expect( dialog - .getByRole("button", { name: /线性代数进阶|Advanced Linear Algebra/ }) + .getByRole("option", { name: /线性代数进阶|Advanced Linear Algebra/ }) .first(), ).toBeVisible(); }); @@ -102,11 +102,11 @@ test("global search trigger opens dialog and navigates to a result", async ({ await expect( dialog - .getByRole("button", { name: /Advanced Linear Algebra|MATH2001/ }) + .getByRole("option", { name: /Advanced Linear Algebra|MATH2001/ }) .first(), ).toBeVisible(); await dialog - .getByRole("button", { name: /Advanced Linear Algebra · MATH2001\.01/ }) + .getByRole("option", { name: /Advanced Linear Algebra · MATH2001\.01/ }) .click(); await expect(page).toHaveURL(/\/catalog\/(courses|sections)\/\d+/); @@ -131,7 +131,7 @@ test("signed-in global search returns catalog results", async ({ page }) => { await expect( dialog - .getByRole("button", { name: /线性代数进阶|Advanced Linear Algebra/ }) + .getByRole("option", { name: /线性代数进阶|Advanced Linear Algebra/ }) .first(), ).toBeVisible(); }); diff --git a/tests/e2e/src/app/search/test.ts b/tests/e2e/src/app/search/test.ts new file mode 100644 index 000000000..537a3c23d --- /dev/null +++ b/tests/e2e/src/app/search/test.ts @@ -0,0 +1,39 @@ +import { expect, test } from "@playwright/test"; +import { gotoAndWaitForReady } from "../../../utils/page-ready"; + +test("search page returns catalog and link results", async ({ page }) => { + const searchResponse = page.waitForResponse( + (response) => + response.url().includes("/api/search") && + response.url().includes("email") && + response.ok(), + ); + + await gotoAndWaitForReady(page, "/search?q=email"); + + await expect( + page.getByRole("heading", { name: /搜索|Search/ }), + ).toBeVisible(); + + const response = await searchResponse; + const body = (await response.json()) as { + groups: Array<{ type: string; items: unknown[] }>; + }; + expect(body.groups.some((group) => group.type === "links")).toBe(true); + + await expect( + page.getByRole("option", { name: /邮箱|USTC Email/i }).first(), + ).toBeVisible(); +}); + +test("search page supports keyboard navigation into results", async ({ + page, +}) => { + await gotoAndWaitForReady(page, "/search?q=线性代数"); + + const input = page.getByRole("combobox"); + await expect(input).toBeVisible(); + await input.press("ArrowDown"); + + await expect(page.getByRole("option").first()).toBeFocused(); +}); diff --git a/tests/e2e/src/app/teachers/test.ts b/tests/e2e/src/app/teachers/test.ts index 849114bed..4d447c128 100644 --- a/tests/e2e/src/app/teachers/test.ts +++ b/tests/e2e/src/app/teachers/test.ts @@ -112,9 +112,9 @@ test.describe("/catalog/teachers", () => { await expect(searchbox).toBeVisible(); await searchbox.fill(DEV_SEED.teacher.nameCn); - const searchButton = page - .getByRole("button", { name: /搜索|Search/i }) - .first(); + const searchButton = page.getByRole("button", { + name: /^(搜索|Search)$/, + }); await expect(searchButton).toBeVisible(); await searchButton.click(); diff --git a/tests/unit/global-search-keyboard.test.ts b/tests/unit/global-search-keyboard.test.ts new file mode 100644 index 000000000..ee84ed05f --- /dev/null +++ b/tests/unit/global-search-keyboard.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { + flattenSearchGroups, + moveSearchActiveIndex, +} from "@/features/search/lib/global-search-keyboard"; + +describe("global search keyboard", () => { + it("flattens grouped items in display order", () => { + const items = flattenSearchGroups([ + { + type: "teachers", + items: [{ id: "teacher:1", title: "A", description: null, href: "/a" }], + }, + { + type: "sections", + items: [{ id: "section:2", title: "B", description: null, href: "/b" }], + }, + ]); + + expect(items.map((item) => item.id)).toEqual(["teacher:1", "section:2"]); + }); + + it("moves selection down from input to first item", () => { + expect(moveSearchActiveIndex(3, -1, "down")).toBe(0); + expect(moveSearchActiveIndex(3, 0, "down")).toBe(1); + }); + + it("moves selection up from first item back to input", () => { + expect(moveSearchActiveIndex(3, 0, "up")).toBe(-1); + expect(moveSearchActiveIndex(3, 2, "up")).toBe(1); + }); +}); diff --git a/tests/unit/global-search-link-queries.test.ts b/tests/unit/global-search-link-queries.test.ts new file mode 100644 index 000000000..7e8e64476 --- /dev/null +++ b/tests/unit/global-search-link-queries.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { searchLinksForGlobal } from "@/features/search/server/global-search-link-queries"; + +describe("searchLinksForGlobal", () => { + it("matches localized link titles", () => { + const results = searchLinksForGlobal("邮箱", "zh-cn", 5); + expect(results.length).toBeGreaterThan(0); + expect(results.some((link) => link.title.includes("邮箱"))).toBe(true); + }); + + it("matches Chinese queries against English locale results", () => { + const results = searchLinksForGlobal("邮箱", "en-us", 5); + expect(results.length).toBeGreaterThan(0); + expect(results.some((link) => link.title.includes("USTC Email"))).toBe( + true, + ); + }); + + it("returns empty results for short queries", () => { + expect(searchLinksForGlobal("a", "zh-cn", 5)).toEqual([]); + }); +}); diff --git a/tests/unit/global-search-service.test.ts b/tests/unit/global-search-service.test.ts index 63fb35b14..85d67c8b1 100644 --- a/tests/unit/global-search-service.test.ts +++ b/tests/unit/global-search-service.test.ts @@ -4,12 +4,14 @@ const { searchCoursesForGlobalMock, searchSectionsForGlobalMock, searchTeachersForGlobalMock, + searchLinksForGlobalMock, withUserDbContextMock, cachedCatalogRuntimeDataMock, } = vi.hoisted(() => ({ searchCoursesForGlobalMock: vi.fn(), searchSectionsForGlobalMock: vi.fn(), searchTeachersForGlobalMock: vi.fn(), + searchLinksForGlobalMock: vi.fn(), withUserDbContextMock: vi.fn(), cachedCatalogRuntimeDataMock: vi.fn(), })); @@ -20,6 +22,10 @@ vi.mock("@/features/search/server/global-search-catalog-queries", () => ({ searchTeachersForGlobal: searchTeachersForGlobalMock, })); +vi.mock("@/features/search/server/global-search-link-queries", () => ({ + searchLinksForGlobal: searchLinksForGlobalMock, +})); + vi.mock("@/lib/catalog-runtime-cache", () => ({ cachedCatalogRuntimeData: cachedCatalogRuntimeDataMock, })); @@ -41,6 +47,7 @@ describe("global search service", () => { searchCoursesForGlobalMock.mockResolvedValue([]); searchSectionsForGlobalMock.mockResolvedValue([]); searchTeachersForGlobalMock.mockResolvedValue([]); + searchLinksForGlobalMock.mockResolvedValue([]); cachedCatalogRuntimeDataMock.mockImplementation( async ( _namespace: string, @@ -86,7 +93,7 @@ describe("global search service", () => { }); expect(cachedCatalogRuntimeDataMock).toHaveBeenCalledWith( - "search:catalog:zh-cn", + "search:catalog:v2:zh-cn", "5:数据", ORIGIN, expect.any(Function), @@ -211,6 +218,67 @@ describe("global search service", () => { expect(searchCoursesForGlobalMock).not.toHaveBeenCalled(); }); + it("includes link matches in catalog results", async () => { + searchLinksForGlobalMock.mockReturnValue([ + { + slug: "mail", + title: "邮箱", + description: "USTC 邮件系统。", + url: "https://mail.ustc.edu.cn/", + }, + ]); + + const result = await searchGlobally({ + locale: "zh-cn", + origin: ORIGIN, + query: "邮箱", + }); + + expect(result.groups).toEqual([ + { + type: "links", + items: [ + { + id: "link:mail", + title: "邮箱", + description: "USTC 邮件系统。", + href: "https://mail.ustc.edu.cn/", + external: true, + }, + ], + }, + ]); + }); + + it("orders teachers before sections in catalog results", async () => { + searchTeachersForGlobalMock.mockResolvedValue([ + { id: 1, nameCn: "程艺", code: "T001", department: null }, + ]); + searchSectionsForGlobalMock.mockResolvedValue([ + { + jwId: 42, + code: "001", + course: { + code: "CS101", + nameCn: "数据结构", + namePrimary: "数据结构", + }, + semester: { nameCn: "2026 春" }, + }, + ]); + + const result = await searchGlobally({ + locale: "zh-cn", + origin: ORIGIN, + query: "程艺", + }); + + expect(result.groups.map((group) => group.type)).toEqual([ + "teachers", + "sections", + ]); + }); + it("detects minimum query length", () => { expect(hasGlobalSearchQuery("a")).toBe(false); expect(hasGlobalSearchQuery("ab")).toBe(true); diff --git a/tests/unit/global-search-types.test.ts b/tests/unit/global-search-types.test.ts new file mode 100644 index 000000000..be4225e83 --- /dev/null +++ b/tests/unit/global-search-types.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { GLOBAL_SEARCH_GROUP_ORDER } from "@/features/search/server/global-search-types"; + +describe("global search group order", () => { + it("ranks teachers before sections and links after sections", () => { + expect(GLOBAL_SEARCH_GROUP_ORDER.indexOf("teachers")).toBeLessThan( + GLOBAL_SEARCH_GROUP_ORDER.indexOf("sections"), + ); + expect(GLOBAL_SEARCH_GROUP_ORDER.indexOf("sections")).toBeLessThan( + GLOBAL_SEARCH_GROUP_ORDER.indexOf("links"), + ); + }); +}); diff --git a/tests/unit/public-ssr-gateway.test.ts b/tests/unit/public-ssr-gateway.test.ts index 60bff23a6..e2e7e0e94 100644 --- a/tests/unit/public-ssr-gateway.test.ts +++ b/tests/unit/public-ssr-gateway.test.ts @@ -142,6 +142,8 @@ describe("public SSR gateway", () => { "/llms.txt", "/open-graph.png", "/robots.txt", + "/search", + "/search?q=邮箱", "/sitemap.xml", "/workspace/overview", ])("bypasses private or mixed route %s", (path) => {