feat(search): 搜索框支持切换搜索引擎

- 采用效果五:点击右侧引擎名展开横向快捷切换栏
- 支持 Google / Bing / 百度 / 搜狗 / 360,选择结果存 localStorage
- 输入时仍按原逻辑过滤本地项目;回车用当前引擎打开新标签搜索
- Esc 关闭切换栏,点击外部自动收起
This commit is contained in:
2026-11-03 20:33:41 +08:00
parent a5d7859c6e
commit 038b7ecb42
2 changed files with 177 additions and 1 deletions
+77
View File
@@ -1436,3 +1436,80 @@ html.theme-transition .site-background {
animation: none;
}
}
/* ---------- 搜索框:搜索引擎切换(效果五) ---------- */
.engine-switch {
position: relative;
display: inline-flex;
align-items: center;
}
.engine-toggle {
display: inline-flex;
align-items: center;
gap: 3px;
height: 24px;
padding: 0 var(--su-space-2);
border: 1px solid var(--su-border);
border-radius: var(--su-radius-xs);
background-color: var(--su-bg-subtle);
color: var(--su-text-sub);
font: inherit;
font-size: 11px;
line-height: 1;
cursor: pointer;
transition:
color var(--su-duration-fast) var(--su-ease-in-out),
background-color var(--su-duration-fast) var(--su-ease-in-out),
border-color var(--su-duration-fast) var(--su-ease-in-out);
}
.engine-toggle:hover,
.engine-toggle[aria-expanded='true'] {
border-color: var(--su-border-strong);
background-color: var(--su-bg-hover);
color: var(--su-text-main);
}
.engine-toggle span {
font-size: 9px;
opacity: 0.6;
}
.engine-menu {
position: absolute;
top: calc(100% + 12px);
right: 0;
z-index: var(--su-z-float);
display: flex;
flex-direction: row;
gap: 2px;
padding: var(--su-space-1);
border: 1px solid var(--su-border);
border-radius: var(--su-radius-sm);
background-color: var(--su-bg-card);
box-shadow: var(--su-shadow-pop);
}
.engine-option {
padding: var(--su-space-1) var(--su-space-2);
border: 0;
border-radius: var(--su-radius-xs);
background: transparent;
color: var(--su-text-sub);
font: inherit;
font-size: var(--su-font-size-xs);
white-space: nowrap;
cursor: pointer;
}
.engine-option:hover {
background-color: var(--su-bg-subtle);
color: var(--su-text-main);
}
.engine-option.is-active {
background-color: var(--su-color-primary-soft);
color: var(--su-color-primary);
font-weight: 600;
}
+100 -1
View File
@@ -62,6 +62,27 @@ type Route =
| { kind: 'item'; key: string; itemId: string }
| { kind: 'api' }
const SEARCH_ENGINES = [
{ key: 'google', name: 'Google', search: 'https://www.google.com/search?q=' },
{ key: 'bing', name: 'Bing', search: 'https://www.bing.com/search?q=' },
{ key: 'baidu', name: '百度', search: 'https://www.baidu.com/s?wd=' },
{ key: 'sogou', name: '搜狗', search: 'https://www.sogou.com/web?query=' },
{ key: '360', name: '360', search: 'https://www.so.com/s?q=' },
] as const
type SearchEngine = (typeof SEARCH_ENGINES)[number]
const SEARCH_ENGINE_KEY = 'stash-search-engine'
function readSearchEngine(): SearchEngine {
try {
const saved = window.localStorage.getItem(SEARCH_ENGINE_KEY)
return SEARCH_ENGINES.find((engine) => engine.key === saved) ?? SEARCH_ENGINES[0]
} catch {
return SEARCH_ENGINES[0]
}
}
function parseRoute(pathname: string): Route {
const segments = pathname.split('/').filter(Boolean)
if (segments[0] === 'interface') return { kind: 'api' }
@@ -193,6 +214,9 @@ export default function AppNext() {
const [editingKey, setEditingKey] = useState<string | null>(null)
const [scrolled, setScrolled] = useState(false)
const searchRef = useRef<HTMLInputElement>(null)
const [searchEngine, setSearchEngine] = useState<SearchEngine>(readSearchEngine)
const [engineOpen, setEngineOpen] = useState(false)
const engineRef = useRef<HTMLDivElement>(null)
const load = () => {
void fetchManage().then((data) => {
@@ -342,6 +366,49 @@ export default function AppNext() {
writeThemeOverride(next)
}
useEffect(() => {
if (!engineOpen) return
const close = (event: PointerEvent) => {
if (engineRef.current !== null && !engineRef.current.contains(event.target as Node)) {
setEngineOpen(false)
}
}
document.addEventListener('pointerdown', close)
return () => document.removeEventListener('pointerdown', close)
}, [engineOpen])
// 回车用当前搜索引擎打开新标签页;输入时的本地过滤逻辑保持不变
useEffect(() => {
const input = searchRef.current
if (input === null) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setEngineOpen(false)
return
}
if (event.key !== 'Enter' || keyword.trim() === '') return
event.preventDefault()
window.open(
`${searchEngine.search}${encodeURIComponent(keyword.trim())}`,
'_blank',
'noopener,noreferrer',
)
}
input.addEventListener('keydown', handleKeyDown)
return () => input.removeEventListener('keydown', handleKeyDown)
}, [keyword, searchEngine])
const chooseEngine = (next: SearchEngine) => {
setSearchEngine(next)
setEngineOpen(false)
try {
window.localStorage.setItem(SEARCH_ENGINE_KEY, next.key)
} catch {
// 隐私模式下只影响本次选择,不阻断搜索
}
searchRef.current?.focus()
}
// 站点级设置:浏览器标签标题与 favicon(空值回落到内置)
useEffect(() => {
const title = site.site.title.trim()
@@ -731,9 +798,41 @@ export default function AppNext() {
onChange={setKeyword}
inputRef={searchRef}
placeholder="搜索本地项目…"
label="搜索本地项目"
label="搜索本地项目,回车使用当前搜索引擎"
variant="card"
hotkey="k"
suffix={
<div className="engine-switch" ref={engineRef}>
<button
type="button"
className="engine-toggle"
aria-haspopup="menu"
aria-expanded={engineOpen}
aria-label={`搜索引擎:${searchEngine.name},点击切换`}
title={`当前:${searchEngine.name};回车用该引擎搜索`}
onClick={() => setEngineOpen((open) => !open)}
>
{searchEngine.name}
<span aria-hidden="true">▾</span>
</button>
{engineOpen && (
<div className="engine-menu" role="menu" aria-label="选择搜索引擎">
{SEARCH_ENGINES.map((engine) => (
<button
key={engine.key}
type="button"
role="menuitemradio"
aria-checked={engine.key === searchEngine.key}
className={`engine-option${engine.key === searchEngine.key ? ' is-active' : ''}`}
onClick={() => chooseEngine(engine)}
>
{engine.name}
</button>
))}
</div>
)}
</div>
}
/>
</div>
}