feat(ts): 实现 Polaris 可交互首页
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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">
|
||||
|
||||
</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()
|
||||
}
|
||||
Reference in New Issue
Block a user