From 8f51240956e02f6100561e979272d745d6ebf528 Mon Sep 17 00:00:00 2001 From: TianKai Ma Date: Fri, 31 Jul 2026 22:32:12 +0800 Subject: [PATCH 1/7] fix(shell): lazy-load GlobalSearchDialog to meet client budget Global search dialog chunks were eagerly bundled into the shell, pushing public route initial request counts over CI limits. Load the dialog on first open (Cmd+K) so catalog and home pages stay within budget. --- src/lib/components/shell/AppShell.svelte | 36 ++++++++++++++++-------- 1 file changed, 24 insertions(+), 12 deletions(-) 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} From fa58a9c6976fd44c4bec3c15e1ff2c5d2e05a0a8 Mon Sep 17 00:00:00 2001 From: TianKai Ma Date: Fri, 31 Jul 2026 22:49:07 +0800 Subject: [PATCH 2/7] fix(worker): import cache tag without pulling Prisma into bundle worker.js imported CATALOG_EDGE_CACHE_TAG from catalog-runtime-cache, which evaluated Prisma and broke the E2E worker with fileURLToPath. Move the constant to a leaf module. --- src/lib/catalog-edge-cache-tag.ts | 2 ++ src/lib/catalog-runtime-cache.ts | 3 ++- src/worker.js | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 src/lib/catalog-edge-cache-tag.ts 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/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, From 03afce413d9823683dbd09ff175602672d4d7eb2 Mon Sep 17 00:00:00 2001 From: TianKai Ma Date: Fri, 31 Jul 2026 23:01:02 +0800 Subject: [PATCH 3/7] test(e2e): fix selectors and cache headers after shell/search changes Scope catalog/admin search buttons away from the global search trigger, expect 24h CDN cache on catalog APIs, and tighten mobile topbar padding to avoid 280px horizontal overflow. --- src/lib/components/shell/AppTopbar.svelte | 2 +- tests/e2e/src/app/admin/users/test.ts | 7 +++++-- tests/e2e/src/app/api/courses/test.ts | 2 +- tests/e2e/src/app/courses/test.ts | 6 +++--- tests/e2e/src/app/teachers/test.ts | 6 +++--- 5 files changed, 13 insertions(+), 10 deletions(-) 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" > -
+
{ 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/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(); From 201e7627f0c52ff41c3060672ea65a57db21c82d Mon Sep 17 00:00:00 2001 From: TianKai Ma Date: Fri, 31 Jul 2026 23:14:38 +0800 Subject: [PATCH 4/7] test(e2e): focus links search directly after global Cmd+K takeover The shell now owns Ctrl/Cmd+K for global search, so the dashboard links page test should click its local searchbox instead of expecting the shortcut to focus it. --- tests/e2e/src/app/dashboard/links/test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 From 38366f8015e70c83ef937152ed94641df4f5d030 Mon Sep 17 00:00:00 2001 From: TianKai Ma Date: Fri, 31 Jul 2026 23:15:09 +0800 Subject: [PATCH 5/7] feat(search): add link search, /search page, and ranking fixes Extend global search with campus link results, a dedicated /search page, keyboard navigation, and teacher-before-section ranking. Match links across all locales and route /search through the dynamic SSR bypass. --- messages/en-us.json | 8 +- messages/zh-cn.json | 8 +- .../lib/dashboard-link-search.ts | 36 +++ .../search/lib/global-search-client.ts | 19 ++ .../search/lib/global-search-keyboard.ts | 106 ++++++++ .../server/global-search-catalog-queries.ts | 22 +- .../server/global-search-link-queries.ts | 37 +++ .../search/server/global-search-service.ts | 62 +++-- .../search/server/global-search-types.ts | 20 +- src/lib/api/routes/global-search.ts | 2 +- src/lib/cloudflare/public-ssr-gateway.ts | 1 + .../shell/GlobalSearchDialog.svelte | 219 ++++++++-------- .../shell/GlobalSearchResults.svelte | 130 ++++++++++ src/lib/metrics/analytics-engine.ts | 1 + src/routes/search/+page.server.ts | 8 + src/routes/search/+page.svelte | 244 ++++++++++++++++++ tests/e2e/src/app/global-search/test.ts | 12 +- tests/e2e/src/app/search/test.ts | 39 +++ tests/unit/global-search-keyboard.test.ts | 32 +++ tests/unit/global-search-link-queries.test.ts | 20 ++ tests/unit/global-search-service.test.ts | 70 ++++- tests/unit/global-search-types.test.ts | 13 + tests/unit/public-ssr-gateway.test.ts | 2 + 23 files changed, 957 insertions(+), 154 deletions(-) create mode 100644 src/features/search/lib/global-search-client.ts create mode 100644 src/features/search/lib/global-search-keyboard.ts create mode 100644 src/features/search/server/global-search-link-queries.ts create mode 100644 src/lib/components/shell/GlobalSearchResults.svelte create mode 100644 src/routes/search/+page.server.ts create mode 100644 src/routes/search/+page.svelte create mode 100644 tests/e2e/src/app/search/test.ts create mode 100644 tests/unit/global-search-keyboard.test.ts create mode 100644 tests/unit/global-search-link-queries.test.ts create mode 100644 tests/unit/global-search-types.test.ts 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..46a2239ae 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 { AppLocale } from "@/i18n/config"; +import type { Prisma } from "@/generated/prisma/client"; 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/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/GlobalSearchDialog.svelte b/src/lib/components/shell/GlobalSearchDialog.svelte index 0662b0312..3f97b03af 100644 --- a/src/lib/components/shell/GlobalSearchDialog.svelte +++ b/src/lib/components/shell/GlobalSearchDialog.svelte @@ -1,47 +1,30 @@ + +{#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..07c1fa5d4 --- /dev/null +++ b/src/routes/search/+page.svelte @@ -0,0 +1,244 @@ + + + + {data.copy.pageTitle} - Life@USTC + + +
+
+

+ {data.copy.pageTitle} +

+

{data.copy.pageDescription}

+
+ +
+ + +
+ +
+ +
+
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/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..99f374979 --- /dev/null +++ b/tests/unit/global-search-link-queries.test.ts @@ -0,0 +1,20 @@ +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) => { From 29af82d9c0b3c641e79cca03458d14c4de46e9c9 Mon Sep 17 00:00:00 2001 From: TianKai Ma Date: Fri, 31 Jul 2026 23:18:02 +0800 Subject: [PATCH 6/7] style: fix biome import order and formatting on search files --- .../search/server/global-search-catalog-queries.ts | 2 +- src/lib/components/shell/GlobalSearchDialog.svelte | 10 ++++++---- src/lib/components/shell/GlobalSearchResults.svelte | 2 +- src/routes/search/+page.svelte | 13 +++++++------ tests/unit/global-search-link-queries.test.ts | 4 +++- 5 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/features/search/server/global-search-catalog-queries.ts b/src/features/search/server/global-search-catalog-queries.ts index 46a2239ae..8c8892c47 100644 --- a/src/features/search/server/global-search-catalog-queries.ts +++ b/src/features/search/server/global-search-catalog-queries.ts @@ -1,8 +1,8 @@ import { buildCourseListWhere } from "@/features/catalog/server/course-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 { AppLocale } from "@/i18n/config"; 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"; diff --git a/src/lib/components/shell/GlobalSearchDialog.svelte b/src/lib/components/shell/GlobalSearchDialog.svelte index 3f97b03af..d8c8bee57 100644 --- a/src/lib/components/shell/GlobalSearchDialog.svelte +++ b/src/lib/components/shell/GlobalSearchDialog.svelte @@ -2,7 +2,6 @@ import SearchIcon from "@lucide/svelte/icons/search"; import XIcon from "@lucide/svelte/icons/x"; import { createEventDispatcher } from "svelte"; -import { goto } from "$app/navigation"; import { fetchGlobalSearch, GLOBAL_SEARCH_DEBOUNCE_MS, @@ -12,12 +11,15 @@ import { import { activeItemIdFromIndex, flattenSearchGroups, - globalSearchItemDomId, GLOBAL_SEARCH_LISTBOX_ID, + globalSearchItemDomId, handleSearchListboxKeydown, } from "@/features/search/lib/global-search-keyboard"; -import type { GlobalSearchResultGroup } from "@/features/search/server/global-search-types"; -import type { GlobalSearchResultItem } from "@/features/search/server/global-search-types"; +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"; diff --git a/src/lib/components/shell/GlobalSearchResults.svelte b/src/lib/components/shell/GlobalSearchResults.svelte index 905bce8f0..b3499fe22 100644 --- a/src/lib/components/shell/GlobalSearchResults.svelte +++ b/src/lib/components/shell/GlobalSearchResults.svelte @@ -8,8 +8,8 @@ import RouteIcon from "@lucide/svelte/icons/route"; import SearchXIcon from "@lucide/svelte/icons/search-x"; import UsersIcon from "@lucide/svelte/icons/users"; import { - GLOBAL_SEARCH_LISTBOX_ID, flattenSearchGroups, + GLOBAL_SEARCH_LISTBOX_ID, globalSearchItemDomId, } from "@/features/search/lib/global-search-keyboard"; import type { diff --git a/src/routes/search/+page.svelte b/src/routes/search/+page.svelte index 07c1fa5d4..9c5c4fa55 100644 --- a/src/routes/search/+page.svelte +++ b/src/routes/search/+page.svelte @@ -1,8 +1,5 @@