feat(search): 搜索框支持切换搜索引擎
- 采用效果五:点击右侧引擎名展开横向快捷切换栏 - 支持 Google / Bing / 百度 / 搜狗 / 360,选择结果存 localStorage - 输入时仍按原逻辑过滤本地项目;回车用当前引擎打开新标签搜索 - Esc 关闭切换栏,点击外部自动收起
This commit is contained in:
@@ -1436,3 +1436,80 @@ html.theme-transition .site-background {
|
|||||||
animation: none;
|
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
@@ -62,6 +62,27 @@ type Route =
|
|||||||
| { kind: 'item'; key: string; itemId: string }
|
| { kind: 'item'; key: string; itemId: string }
|
||||||
| { kind: 'api' }
|
| { 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 {
|
function parseRoute(pathname: string): Route {
|
||||||
const segments = pathname.split('/').filter(Boolean)
|
const segments = pathname.split('/').filter(Boolean)
|
||||||
if (segments[0] === 'interface') return { kind: 'api' }
|
if (segments[0] === 'interface') return { kind: 'api' }
|
||||||
@@ -193,6 +214,9 @@ export default function AppNext() {
|
|||||||
const [editingKey, setEditingKey] = useState<string | null>(null)
|
const [editingKey, setEditingKey] = useState<string | null>(null)
|
||||||
const [scrolled, setScrolled] = useState(false)
|
const [scrolled, setScrolled] = useState(false)
|
||||||
const searchRef = useRef<HTMLInputElement>(null)
|
const searchRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [searchEngine, setSearchEngine] = useState<SearchEngine>(readSearchEngine)
|
||||||
|
const [engineOpen, setEngineOpen] = useState(false)
|
||||||
|
const engineRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const load = () => {
|
const load = () => {
|
||||||
void fetchManage().then((data) => {
|
void fetchManage().then((data) => {
|
||||||
@@ -342,6 +366,49 @@ export default function AppNext() {
|
|||||||
writeThemeOverride(next)
|
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(空值回落到内置)
|
// 站点级设置:浏览器标签标题与 favicon(空值回落到内置)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const title = site.site.title.trim()
|
const title = site.site.title.trim()
|
||||||
@@ -731,9 +798,41 @@ export default function AppNext() {
|
|||||||
onChange={setKeyword}
|
onChange={setKeyword}
|
||||||
inputRef={searchRef}
|
inputRef={searchRef}
|
||||||
placeholder="搜索本地项目…"
|
placeholder="搜索本地项目…"
|
||||||
label="搜索本地项目"
|
label="搜索本地项目,回车使用当前搜索引擎"
|
||||||
variant="card"
|
variant="card"
|
||||||
hotkey="k"
|
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>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user