feat(ts): 实现 Polaris 可交互首页

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-02 23:53:28 +08:00
co-authored by Claude Opus 4.8
parent 95c3da0f71
commit c3d2a40c1f
71 changed files with 15613 additions and 46 deletions
@@ -0,0 +1,60 @@
import { act, render, screen } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { ClockGreeting } from "./clock-greeting"
describe("ClockGreeting", () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 7, 20, 21, 56, 30))
})
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
})
it("挂载后显示当前问候、时间和日期", () => {
render(<ClockGreeting />)
expect(screen.getByRole("timer")).toHaveTextContent("晚上好,21:56")
expect(screen.getByText("2026 年 8 月 20 日 星期四")).toBeInTheDocument()
})
it("在下一分钟边界刷新", () => {
render(<ClockGreeting />)
act(() => {
vi.advanceTimersByTime(30_000)
})
expect(screen.getByRole("timer")).toHaveTextContent("21:57")
})
it("延迟执行后重新对齐下一个真实分钟边界", () => {
const setTimeoutSpy = vi.spyOn(window, "setTimeout")
vi.setSystemTime(new Date(2026, 7, 20, 9, 6, 30))
render(<ClockGreeting />)
const delayedTick = setTimeoutSpy.mock.calls[0]?.[0]
expect(typeof delayedTick).toBe("function")
if (typeof delayedTick !== "function") throw new Error("Missing clock tick")
vi.setSystemTime(new Date(2026, 7, 20, 9, 7, 12))
act(() => delayedTick())
expect(setTimeoutSpy).toHaveBeenLastCalledWith(expect.any(Function), 48_000)
})
it("跨午夜同步更新日期与问候语", () => {
vi.setSystemTime(new Date(2026, 7, 20, 23, 59, 30))
render(<ClockGreeting />)
act(() => {
vi.advanceTimersByTime(30_000)
})
expect(screen.getByRole("timer")).toHaveTextContent("夜深了,00:00")
expect(screen.getByText("2026 年 8 月 21 日 星期五")).toBeInTheDocument()
})
})
@@ -0,0 +1,34 @@
"use client"
import { useClock } from "@/features/home/hooks/use-clock"
import {
formatDate,
formatTime,
getGreeting,
} from "@/features/home/lib/date-time"
export function ClockGreeting() {
const now = useClock()
if (!now) {
return (
<section className="clock-greeting" aria-label="当前时间">
<p className="clock-greeting__time" role="timer">
正在同步时间…
</p>
<p className="clock-greeting__date" aria-hidden="true">
&nbsp;
</p>
</section>
)
}
return (
<section className="clock-greeting" aria-label="当前时间">
<p className="clock-greeting__time" role="timer" aria-live="off">
{getGreeting(now.getHours())},{formatTime(now)}
</p>
<p className="clock-greeting__date">{formatDate(now)}</p>
</section>
)
}
@@ -0,0 +1,34 @@
import { ClockGreeting } from "@/features/home/components/clock-greeting"
import { QuickLinks } from "@/features/home/components/quick-links"
import { SearchBox } from "@/features/home/components/search-box"
import type { Category, NavLink } from "@/types/navigation"
import type { AppSettings } from "@/types/settings"
type HomeContentProps = {
categories: Category[]
links: NavLink[]
pinnedLinks: NavLink[]
settings: AppSettings
}
export function HomeContent({
categories,
links,
pinnedLinks,
settings,
}: HomeContentProps) {
return (
<main className="home-content">
<div className="home-content__inner">
<ClockGreeting />
<SearchBox
links={links}
categories={categories}
defaultProvider={settings.search.defaultProvider}
enabledProviders={settings.search.enabledProviders}
/>
<QuickLinks links={pinnedLinks} />
</div>
</main>
)
}
@@ -0,0 +1,53 @@
import type { MutableRefObject } from "react"
import { SiteIcon } from "@/components/shared/site-icon"
import type { LocalSearchResult } from "@/types/search"
type LocalSearchResultsProps = {
listboxId: string
results: LocalSearchResult[]
activeIndex: number
optionRefs: MutableRefObject<Array<HTMLAnchorElement | null>>
onActiveIndexChange: (index: number) => void
}
export function LocalSearchResults({
listboxId,
results,
activeIndex,
optionRefs,
onActiveIndexChange,
}: LocalSearchResultsProps) {
return (
<div className="local-search-results" role="listbox" id={listboxId}>
{results.length === 0 ? (
<p className="local-search-results__empty">没有匹配的网站</p>
) : (
results.map(({ link, categoryTitle }, index) => (
<a
key={link.id}
ref={(element) => {
optionRefs.current[index] = element
}}
id={`${listboxId}-option-${index}`}
className="local-search-results__option"
role="option"
aria-selected={index === activeIndex}
href={link.url}
target="_blank"
rel="noopener noreferrer"
onMouseMove={() => onActiveIndexChange(index)}
>
<SiteIcon link={link} size={32} />
<span className="local-search-results__copy">
<span className="local-search-results__title">{link.title}</span>
<span className="local-search-results__category">
{categoryTitle}
</span>
</span>
</a>
))
)}
</div>
)
}
@@ -0,0 +1,55 @@
import { render, screen } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import type { NavLink } from "@/types/navigation"
import { QuickLinks } from "./quick-links"
const links: NavLink[] = [
{
id: "github",
title: "GitHub",
description: "代码托管",
url: "https://github.com",
icon: "si:github",
categoryId: "dev",
pinned: true,
pinnedSort: 10,
keywords: [],
sort: 10,
},
{
id: "chatgpt",
title: "ChatGPT",
description: "AI 助手",
url: "https://chatgpt.com",
icon: "si:openai",
categoryId: "ai",
pinned: true,
pinnedSort: 20,
keywords: [],
sort: 10,
},
]
describe("QuickLinks", () => {
it("按输入顺序渲染安全的新标签页链接", () => {
render(<QuickLinks links={links} />)
const renderedLinks = screen.getAllByRole("link")
expect(renderedLinks.map((link) => link.textContent)).toEqual([
"GitHub",
"ChatGPT",
])
for (const link of renderedLinks) {
expect(link).toHaveAttribute("target", "_blank")
expect(link).toHaveAttribute("rel", "noopener noreferrer")
}
})
it("只展示图标和名称,不展示描述", () => {
render(<QuickLinks links={links} />)
expect(screen.queryByText("代码托管")).not.toBeInTheDocument()
expect(screen.queryByText("AI 助手")).not.toBeInTheDocument()
})
})
@@ -0,0 +1,26 @@
import { SiteIcon } from "@/components/shared/site-icon"
import type { NavLink } from "@/types/navigation"
export function QuickLinks({ links }: { links: NavLink[] }) {
return (
<section className="quick-links" aria-labelledby="quick-links-title">
<h2 id="quick-links-title" className="quick-links__title">
常用链接
</h2>
<div className="quick-links__grid">
{links.map((link) => (
<a
key={link.id}
className="quick-link-card"
href={link.url}
target="_blank"
rel="noopener noreferrer"
>
<SiteIcon link={link} size={38} />
<span>{link.title}</span>
</a>
))}
</div>
</section>
)
}
@@ -0,0 +1,172 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import type { ComponentProps } from "react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import type { Category, NavLink } from "@/types/navigation"
import { SearchBox } from "./search-box"
const categories: Category[] = [
{ id: "dev", title: "开发工具", description: "", icon: "code", sort: 10 },
]
const links: NavLink[] = [
{
id: "github",
title: "GitHub",
description: "代码托管平台",
url: "https://github.com",
icon: "si:github",
categoryId: "dev",
pinned: true,
pinnedSort: 10,
keywords: ["git", "仓库"],
sort: 10,
},
{
id: "gitlab",
title: "GitLab",
description: "DevOps 平台",
url: "https://gitlab.com",
icon: "si:gitlab",
categoryId: "dev",
pinned: false,
keywords: ["git", "ci"],
sort: 20,
},
]
function renderSearchBox(
overrides: Partial<ComponentProps<typeof SearchBox>> = {},
) {
const onExternalNavigate = vi.fn()
const renderResult = render(
<SearchBox
links={links}
categories={categories}
defaultProvider="google"
enabledProviders={["google", "bing", "github", "local"]}
onExternalNavigate={onExternalNavigate}
{...overrides}
/>,
)
return { onExternalNavigate, ...renderResult }
}
describe("SearchBox", () => {
beforeEach(() => window.localStorage.clear())
afterEach(() => vi.restoreAllMocks())
it("挂载后自动聚焦,并响应 Ctrl+K 与 Meta+K", async () => {
renderSearchBox()
const input = screen.getByRole("combobox", {
name: "搜索网站、书签或关键词",
})
await waitFor(() => expect(input).toHaveFocus())
input.blur()
fireEvent.keyDown(window, { key: "k", ctrlKey: true })
expect(input).toHaveFocus()
input.blur()
fireEvent.keyDown(window, { key: "k", metaKey: true })
expect(input).toHaveFocus()
})
it("Google 模式回车只执行当前页外部导航", async () => {
const user = userEvent.setup()
const { onExternalNavigate } = renderSearchBox()
const input = screen.getByRole("combobox", {
name: "搜索网站、书签或关键词",
})
await user.type(input, "Next.js 中文{Enter}")
const url = new URL(onExternalNavigate.mock.calls[0][0])
expect(url.hostname).toBe("www.google.com")
expect(url.searchParams.get("q")).toBe("Next.js 中文")
expect(screen.queryByRole("listbox")).not.toBeInTheDocument()
})
it("空白查询不导航", async () => {
const user = userEvent.setup()
const { onExternalNavigate } = renderSearchBox()
await user.type(screen.getByRole("combobox"), " {Enter}")
expect(onExternalNavigate).not.toHaveBeenCalled()
})
it("切换 Local 后实时显示结果并持久化 Provider", async () => {
const user = userEvent.setup()
renderSearchBox()
await user.click(
screen.getByRole("button", { name: "选择搜索方式,当前为 Google" }),
)
await user.click(screen.getByRole("menuitemradio", { name: "本地" }))
await user.type(screen.getByRole("combobox"), "git")
expect(screen.getByRole("listbox")).toBeInTheDocument()
expect(screen.getAllByRole("option")).toHaveLength(2)
expect(window.localStorage.getItem("polaris.search.provider")).toBe("local")
})
it("Local 模式支持上下选择、Enter 与 Esc", async () => {
const user = userEvent.setup()
renderSearchBox({ defaultProvider: "local" })
const input = screen.getByRole("combobox")
await user.type(input, "git")
const options = screen.getAllByRole("option")
expect(options[0]).toHaveAttribute("aria-selected", "true")
expect(options[0]).toHaveAttribute("target", "_blank")
expect(options[0]).toHaveAttribute("rel", "noopener noreferrer")
await user.keyboard("{ArrowUp}")
expect(options[1]).toHaveAttribute("aria-selected", "true")
await user.keyboard("{ArrowDown}")
expect(options[0]).toHaveAttribute("aria-selected", "true")
fireEvent.mouseMove(options[1])
expect(options[1]).toHaveAttribute("aria-selected", "true")
const clickSpy = vi
.spyOn(HTMLAnchorElement.prototype, "click")
.mockImplementation(() => {})
await user.keyboard("{Enter}")
expect(clickSpy).toHaveBeenCalledOnce()
await user.keyboard("{Escape}")
expect(input).toHaveValue("")
expect(screen.queryByRole("listbox")).not.toBeInTheDocument()
})
it("Local 无结果显示空状态", async () => {
const user = userEvent.setup()
renderSearchBox({ defaultProvider: "local" })
await user.type(screen.getByRole("combobox"), "不存在的网站")
expect(screen.getByText("没有匹配的网站")).toBeInTheDocument()
})
it("恢复合法 Provider,非法或未启用值回退默认 Provider", async () => {
window.localStorage.setItem("polaris.search.provider", "local")
const { unmount } = renderSearchBox()
await waitFor(() =>
expect(
screen.getByRole("button", { name: /当前为 本地/ }),
).toBeInTheDocument(),
)
unmount()
window.localStorage.setItem("polaris.search.provider", "invalid")
const secondRender = renderSearchBox()
await waitFor(() =>
expect(
screen.getByRole("button", { name: /当前为 Google/ }),
).toBeInTheDocument(),
)
secondRender.unmount()
window.localStorage.setItem("polaris.search.provider", "local")
renderSearchBox({ enabledProviders: ["google", "bing"] })
await waitFor(() =>
expect(
screen.getByRole("button", { name: /当前为 Google/ }),
).toBeInTheDocument(),
)
})
})
@@ -0,0 +1,217 @@
"use client"
import { Search } from "lucide-react"
import {
useEffect,
useId,
useMemo,
useRef,
useState,
useSyncExternalStore,
type FormEvent,
type KeyboardEvent,
} from "react"
import { LocalSearchResults } from "@/features/home/components/local-search-results"
import { SearchProviderMenu } from "@/features/home/components/search-provider-menu"
import { searchLocalLinks } from "@/features/home/lib/local-search"
import {
parseStoredProvider,
SEARCH_PROVIDER_STORAGE_KEY,
} from "@/features/home/lib/provider-preference"
import { buildSearchUrl } from "@/features/home/lib/search-providers"
import type { Category, NavLink } from "@/types/navigation"
import type { SearchProvider } from "@/types/search"
type SearchBoxProps = {
links: NavLink[]
categories: Category[]
defaultProvider: SearchProvider
enabledProviders: SearchProvider[]
onExternalNavigate?: (url: string) => void
}
function subscribeHydration() {
return () => undefined
}
function getClientHydrationSnapshot() {
return true
}
function getServerHydrationSnapshot() {
return false
}
function readStoredProvider(
defaultProvider: SearchProvider,
enabledProviders: SearchProvider[],
): SearchProvider {
try {
const storedProvider = parseStoredProvider(
window.localStorage.getItem(SEARCH_PROVIDER_STORAGE_KEY),
defaultProvider,
)
return enabledProviders.includes(storedProvider)
? storedProvider
: defaultProvider
} catch {
return defaultProvider
}
}
export function SearchBox({
links,
categories,
defaultProvider,
enabledProviders,
onExternalNavigate,
}: SearchBoxProps) {
const inputRef = useRef<HTMLInputElement>(null)
const optionRefs = useRef<Array<HTMLAnchorElement | null>>([])
const listboxId = useId()
const [query, setQuery] = useState("")
const [selectedProvider, setSelectedProvider] =
useState<SearchProvider | null>(null)
const [activeIndex, setActiveIndex] = useState(0)
const hydrated = useSyncExternalStore(
subscribeHydration,
getClientHydrationSnapshot,
getServerHydrationSnapshot,
)
// Server 与 hydration 首帧保持默认 Provider;hydration 后再容错读取浏览器偏好。
const restoredProvider = hydrated
? readStoredProvider(defaultProvider, enabledProviders)
: defaultProvider
const provider = selectedProvider ?? restoredProvider
const results = useMemo(
() =>
provider === "local" ? searchLocalLinks(query, links, categories, 8) : [],
[categories, links, provider, query],
)
const panelOpen = provider === "local" && query.trim().length > 0
const resolvedActiveIndex =
results.length > 0 ? Math.min(activeIndex, results.length - 1) : 0
const activeOptionId =
panelOpen && results.length > 0
? `${listboxId}-option-${resolvedActiveIndex}`
: undefined
const shortcutHint =
hydrated && navigator.userAgent.includes("Mac") ? "⌘K" : "Ctrl K"
useEffect(() => {
inputRef.current?.focus()
const focusSearch = (event: globalThis.KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault()
inputRef.current?.focus()
}
}
window.addEventListener("keydown", focusSearch)
return () => window.removeEventListener("keydown", focusSearch)
}, [])
const changeProvider = (nextProvider: SearchProvider) => {
setSelectedProvider(nextProvider)
setActiveIndex(0)
try {
window.localStorage.setItem(SEARCH_PROVIDER_STORAGE_KEY, nextProvider)
} catch {
// Provider 已保留在当前组件状态,存储受限时不影响本次交互。
}
}
const openActiveLocalResult = () => {
if (results.length === 0) return
// 复用真实 anchor 的导航语义,键盘 Enter 不另造 click-only 跳转路径。
optionRefs.current[resolvedActiveIndex]?.click()
}
const submitSearch = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
// Local 只打开站内结果;外部 Provider 只构建搜索 URL,两条路径严格分离。
if (provider === "local") {
openActiveLocalResult()
return
}
const url = buildSearchUrl(provider, query)
if (!url) return
if (onExternalNavigate) onExternalNavigate(url)
else window.location.assign(url)
}
const handleInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Escape") {
event.preventDefault()
setQuery("")
setActiveIndex(0)
return
}
if (provider !== "local") return
if (event.key === "Enter") {
event.preventDefault()
openActiveLocalResult()
return
}
if (results.length === 0) return
if (event.key === "ArrowDown") {
event.preventDefault()
setActiveIndex((index) => (index + 1) % results.length)
} else if (event.key === "ArrowUp") {
event.preventDefault()
setActiveIndex((index) => (index - 1 + results.length) % results.length)
}
}
return (
<div className="search-box">
<form className="search-box__form" role="search" onSubmit={submitSearch}>
<SearchProviderMenu
provider={provider}
enabledProviders={enabledProviders}
onProviderChange={changeProvider}
/>
<Search className="search-box__icon" aria-hidden="true" />
<input
ref={inputRef}
className="search-box__input"
role="combobox"
aria-label="搜索网站、书签或关键词"
aria-autocomplete="list"
aria-expanded={panelOpen}
aria-controls={panelOpen ? listboxId : undefined}
aria-activedescendant={activeOptionId}
placeholder="搜索网站、书签或关键词..."
value={query}
onChange={(event) => {
setQuery(event.target.value)
setActiveIndex(0)
}}
onKeyDown={handleInputKeyDown}
/>
<kbd className="search-box__shortcut" aria-hidden="true">
{shortcutHint}
</kbd>
</form>
{panelOpen ? (
<LocalSearchResults
listboxId={listboxId}
results={results}
activeIndex={resolvedActiveIndex}
optionRefs={optionRefs}
onActiveIndexChange={setActiveIndex}
/>
) : null}
</div>
)
}
@@ -0,0 +1,85 @@
"use client"
import {
Database,
GitFork,
Globe2,
Search,
type LucideIcon,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { isSearchProvider } from "@/features/home/lib/search-providers"
import type { SearchProvider } from "@/types/search"
const PROVIDER_LABELS: Record<SearchProvider, string> = {
google: "Google",
bing: "Bing",
github: "GitHub",
local: "本地",
}
const PROVIDER_ICONS: Record<SearchProvider, LucideIcon> = {
google: Globe2,
bing: Search,
github: GitFork,
local: Database,
}
type SearchProviderMenuProps = {
provider: SearchProvider
enabledProviders: SearchProvider[]
onProviderChange: (provider: SearchProvider) => void
}
export function SearchProviderMenu({
provider,
enabledProviders,
onProviderChange,
}: SearchProviderMenuProps) {
const CurrentIcon = PROVIDER_ICONS[provider]
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
className="search-provider-menu__trigger"
aria-label={`选择搜索方式,当前为 ${PROVIDER_LABELS[provider]}`}
>
<CurrentIcon aria-hidden="true" />
<span>{PROVIDER_LABELS[provider]}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
value={provider}
onValueChange={(value) => {
if (isSearchProvider(value)) onProviderChange(value)
}}
>
{enabledProviders.map((enabledProvider) => {
const Icon = PROVIDER_ICONS[enabledProvider]
return (
<DropdownMenuRadioItem
key={enabledProvider}
value={enabledProvider}
>
<Icon aria-hidden="true" />
{PROVIDER_LABELS[enabledProvider]}
</DropdownMenuRadioItem>
)
})}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,61 @@
"use client"
import { useSyncExternalStore } from "react"
import { getDelayUntilNextMinute } from "@/features/home/lib/date-time"
type Listener = () => void
const listeners = new Set<Listener>()
let currentTime: Date | null = null
let timeoutId: number | undefined
function emitCurrentTime(): Date {
const nextTime = new Date()
currentTime = nextTime
listeners.forEach((listener) => listener())
return nextTime
}
function scheduleNextTick() {
const observedTime = emitCurrentTime()
// 每次 tick 都重新对齐;标签页休眠或主线程阻塞后不会把延迟永久带入后续分钟。
timeoutId = window.setTimeout(
scheduleNextTick,
getDelayUntilNextMinute(observedTime),
)
}
function startClock() {
scheduleNextTick()
}
function stopClock() {
if (timeoutId !== undefined) window.clearTimeout(timeoutId)
timeoutId = undefined
currentTime = null
}
function subscribe(listener: Listener) {
listeners.add(listener)
if (listeners.size === 1) startClock()
return () => {
listeners.delete(listener)
if (listeners.size === 0) stopClock()
}
}
function getSnapshot() {
return currentTime
}
function getServerSnapshot() {
return null
}
export function useClock(): Date | null {
// 服务端与 hydration 首帧固定为空;订阅建立后再读取浏览器时间,避免首屏不一致。
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest"
import {
formatDate,
formatTime,
getDelayUntilNextMinute,
getGreeting,
} from "./date-time"
describe("首页日期与问候", () => {
it.each([
[0, "夜深了"],
[4, "夜深了"],
[5, "早上好"],
[11, "早上好"],
[12, "中午好"],
[13, "中午好"],
[14, "下午好"],
[17, "下午好"],
[18, "晚上好"],
[23, "晚上好"],
])("%i 时显示 %s", (hour, expected) => {
expect(getGreeting(hour)).toBe(expected)
})
it("拒绝无效小时", () => {
expect(() => getGreeting(-1)).toThrow("hour must be between 0 and 23")
expect(() => getGreeting(24)).toThrow("hour must be between 0 and 23")
})
it("格式化中文日期与两位时间", () => {
const date = new Date(2026, 7, 20, 9, 6, 30)
expect(formatTime(date)).toBe("09:06")
expect(formatDate(date)).toBe("2026 年 8 月 20 日 星期四")
})
it("计算到下一分钟边界的延迟", () => {
expect(getDelayUntilNextMinute(new Date(2026, 7, 20, 9, 6, 30, 250))).toBe(
29_750,
)
})
})
@@ -0,0 +1,34 @@
const WEEKDAYS = [
"星期日",
"星期一",
"星期二",
"星期三",
"星期四",
"星期五",
"星期六",
] as const
export function getGreeting(hour: number): string {
if (!Number.isInteger(hour) || hour < 0 || hour > 23) {
throw new RangeError("hour must be between 0 and 23")
}
if (hour < 5) return "夜深了"
if (hour < 12) return "早上好"
if (hour < 14) return "中午好"
if (hour < 18) return "下午好"
return "晚上好"
}
export function formatTime(date: Date): string {
const hours = String(date.getHours()).padStart(2, "0")
const minutes = String(date.getMinutes()).padStart(2, "0")
return `${hours}:${minutes}`
}
export function formatDate(date: Date): string {
return `${date.getFullYear()} 年 ${date.getMonth() + 1} 月 ${date.getDate()} 日 ${WEEKDAYS[date.getDay()]}`
}
export function getDelayUntilNextMinute(date: Date): number {
return 60_000 - (date.getSeconds() * 1_000 + date.getMilliseconds())
}
@@ -0,0 +1,112 @@
import { describe, expect, it } from "vitest"
import type { Category, NavLink } from "@/types/navigation"
import { searchLocalLinks } from "./local-search"
const categories: Category[] = [
{ id: "dev", title: "开发工具", description: "", icon: "code", sort: 10 },
{ id: "ai", title: "AI 工具", description: "", icon: "sparkles", sort: 20 },
]
const links: NavLink[] = [
{
id: "github",
title: "GitHub",
description: "代码托管平台",
url: "https://github.com",
icon: "si:github",
categoryId: "dev",
pinned: true,
pinnedSort: 10,
keywords: ["git", "仓库"],
sort: 20,
},
{
id: "gitlab",
title: "GitLab",
description: "DevOps 平台",
url: "https://gitlab.com",
icon: "si:gitlab",
categoryId: "dev",
pinned: false,
keywords: ["git", "ci"],
sort: 10,
},
{
id: "chatgpt",
title: "ChatGPT",
description: "智能对话与写作",
url: "https://chatgpt.com",
icon: "si:openai",
categoryId: "ai",
pinned: true,
pinnedSort: 20,
keywords: ["人工智能", "写作"],
sort: 10,
},
]
describe("Local 搜索", () => {
it("标题完全匹配优先于标题包含和关键词", () => {
expect(searchLocalLinks("github", links, categories)[0].link.id).toBe(
"github",
)
})
it("匹配关键词、描述、分类标题和分类 ID", () => {
expect(searchLocalLinks("人工智能", links, categories)[0].link.id).toBe(
"chatgpt",
)
expect(searchLocalLinks("DevOps", links, categories)[0].link.id).toBe(
"gitlab",
)
expect(
searchLocalLinks("开发工具", links, categories).map(
({ link }) => link.id,
),
).toEqual(["gitlab", "github"])
expect(searchLocalLinks("ai", links, categories)[0].link.id).toBe("chatgpt")
})
it("同分时按 sort 再按标题稳定排序", () => {
expect(
searchLocalLinks("git", links, categories).map(({ link }) => link.id),
).toEqual(["gitlab", "github"])
const titleTies: NavLink[] = [
{
...links[0],
id: "beta",
title: "贝塔",
keywords: ["工具"],
sort: 10,
},
{
...links[0],
id: "alpha",
title: "阿尔法",
keywords: ["工具"],
sort: 10,
},
]
expect(
searchLocalLinks("工具", titleTies, categories).map(
({ link }) => link.id,
),
).toEqual(["alpha", "beta"])
})
it("规范化大小写、全角字符和首尾空白并限制结果数量", () => {
expect(
searchLocalLinks(" GitHub ", links, categories, 1).map(
({ link }) => link.id,
),
).toEqual(["github"])
})
it("空查询返回空数组", () => {
expect(searchLocalLinks(" ", links, categories)).toEqual([])
})
})
@@ -0,0 +1,74 @@
import type { Category, NavLink } from "@/types/navigation"
import type { LocalSearchResult } from "@/types/search"
function normalize(value: string): string {
return value.normalize("NFKC").trim().toLocaleLowerCase("zh-CN")
}
function textScore(
value: string,
query: string,
exact: number,
prefix: number,
contains: number,
): number {
if (value === query) return exact
if (value.startsWith(query)) return prefix
if (value.includes(query)) return contains
return 0
}
function scoreLink(
link: NavLink,
category: Category | undefined,
query: string,
): number {
const title = normalize(link.title)
const keywordScore = Math.max(
0,
...link.keywords.map((keyword) =>
textScore(normalize(keyword), query, 300, 260, 240),
),
)
// 各区间不重叠,保证“标题 > 关键词 > 描述 > 分类”的规则不会被字段数量反转。
return Math.max(
textScore(title, query, 500, 400, 350),
keywordScore,
normalize(link.description).includes(query) ? 180 : 0,
category ? textScore(normalize(category.title), query, 140, 130, 120) : 0,
category ? textScore(normalize(category.id), query, 100, 90, 80) : 0,
)
}
export function searchLocalLinks(
query: string,
links: NavLink[],
categories: Category[],
limit = 8,
): LocalSearchResult[] {
const normalizedQuery = normalize(query)
if (!normalizedQuery || limit <= 0) return []
const categoryById = new Map(
categories.map((category) => [category.id, category]),
)
return links
.map((link) => {
const category = categoryById.get(link.categoryId)
return {
link,
categoryTitle: category?.title ?? "未分类",
score: scoreLink(link, category, normalizedQuery),
}
})
.filter(({ score }) => score > 0)
.sort(
(a, b) =>
b.score - a.score ||
a.link.sort - b.link.sort ||
a.link.title.localeCompare(b.link.title, "zh-CN"),
)
.slice(0, limit)
}
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest"
import { parseStoredProvider } from "./provider-preference"
describe("Provider 持久化值", () => {
it("返回合法存储值", () => {
expect(parseStoredProvider("local", "google")).toBe("local")
})
it("非法或空值回退默认值", () => {
expect(parseStoredProvider("duckduckgo", "google")).toBe("google")
expect(parseStoredProvider(null, "bing")).toBe("bing")
})
})
@@ -0,0 +1,11 @@
import { isSearchProvider } from "@/features/home/lib/search-providers"
import type { SearchProvider } from "@/types/search"
export const SEARCH_PROVIDER_STORAGE_KEY = "polaris.search.provider"
export function parseStoredProvider(
value: string | null,
fallback: SearchProvider,
): SearchProvider {
return isSearchProvider(value) ? value : fallback
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest"
import { buildSearchUrl, isSearchProvider } from "./search-providers"
describe("外部搜索 Provider", () => {
it.each([
["google", "https://www.google.com/search"],
["bing", "https://www.bing.com/search"],
["github", "https://github.com/search"],
] as const)("为 %s 构建正确 URL", (provider, expectedBase) => {
const result = buildSearchUrl(provider, "Next.js 中文")
const url = new URL(result!)
expect(`${url.origin}${url.pathname}`).toBe(expectedBase)
expect(url.searchParams.get("q")).toBe("Next.js 中文")
})
it("对查询参数分隔符和百分号执行 URL 编码", () => {
const query = "a&next=1 %= #中文"
const url = new URL(buildSearchUrl("google", query)!)
expect(url.searchParams.get("q")).toBe(query)
expect([...url.searchParams.keys()]).toEqual(["q"])
})
it("空白查询不生成 URL", () => {
expect(buildSearchUrl("google", " ")).toBeNull()
})
it("识别合法 Provider", () => {
expect(isSearchProvider("local")).toBe(true)
expect(isSearchProvider("duckduckgo")).toBe(false)
expect(isSearchProvider(null)).toBe(false)
})
})
@@ -0,0 +1,30 @@
import {
SEARCH_PROVIDERS,
type ExternalSearchProvider,
type SearchProvider,
} from "@/types/search"
const SEARCH_ENDPOINTS: Record<ExternalSearchProvider, string> = {
google: "https://www.google.com/search",
bing: "https://www.bing.com/search",
github: "https://github.com/search",
}
export function isSearchProvider(value: unknown): value is SearchProvider {
return (
typeof value === "string" &&
SEARCH_PROVIDERS.includes(value as SearchProvider)
)
}
export function buildSearchUrl(
provider: ExternalSearchProvider,
query: string,
): string | null {
const normalizedQuery = query.trim()
if (!normalizedQuery) return null
const url = new URL(SEARCH_ENDPOINTS[provider])
url.searchParams.set("q", normalizedQuery)
return url.toString()
}