feat(search): 按参考稿重做搜索框并支持后台配置

- 顶部悬浮引擎标签 + 下拉选项,使用 stash-ui 新增的 EngineIcon
- 站内搜索保持现有本地过滤与回车打开首个匹配项
- 外部引擎使用配置的 {query} 模板新标签页搜索
- settings 新增 search.default / search.engines,后台新增『搜索框』配置默认项、名称、地址与启停
- 默认默认搜索项为站内搜索,地址校验必须含 {query}
This commit is contained in:
2026-11-04 18:11:22 +08:00
parent 5ae2088826
commit aa6f341505
6 changed files with 447 additions and 127 deletions
+85 -78
View File
@@ -1,12 +1,13 @@
import {
Empty,
Icon,
EngineIcon,
Layout,
Menu,
Notice,
SearchField,
Space,
Toaster,
type EngineIconName,
type MenuItem,
} from '@stash/ui'
import { useEffect, useMemo, useRef, useState } from 'react'
@@ -27,6 +28,7 @@ import { ServicePanel } from './components/sidebar/ServicePanel'
import { useProjects } from './useProjects'
import { fetchSession, logout, type SessionState } from './auth'
import {
DEFAULT_SEARCH_ENGINES,
fetchSettings,
type SettingsDocument,
type SiteSettings,
@@ -63,27 +65,7 @@ type Route =
| { kind: 'item'; key: string; itemId: string }
| { kind: 'api' }
const SEARCH_ENGINES = [
{ key: 'local', name: '本地', search: '', icon: 'search_3_line' },
{ key: 'google', name: 'Google', search: 'https://www.google.com/search?q=', icon: 'google_fill' },
{ key: 'bing', name: 'Bing', search: 'https://www.bing.com/search?q=', icon: 'edge_fill' },
{ key: 'baidu', name: '百度', search: 'https://www.baidu.com/s?wd=', icon: 'compass_line' },
{ key: 'sogou', name: '搜狗', search: 'https://www.sogou.com/web?query=', icon: 'search_2_line' },
{ key: '360', name: '360', search: 'https://www.so.com/s?q=', icon: 'safety_certificate_line' },
] 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]
}
}
const INTERNAL_SEARCH = { id: 'internal', name: '站内搜索', url: '' } as const
function parseRoute(pathname: string): Route {
const segments = pathname.split('/').filter(Boolean)
@@ -234,7 +216,7 @@ 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 [searchEngineId, setSearchEngineId] = useState<string | null>(null)
const [engineOpen, setEngineOpen] = useState(false)
const engineRef = useRef<HTMLDivElement>(null)
@@ -321,6 +303,10 @@ export default function AppNext() {
show_nav: true,
},
background: { light: '', dark: '' },
search: {
default: 'internal',
engines: DEFAULT_SEARCH_ENGINES.map((engine) => ({ ...engine })),
},
profile: {
name: '本地管理员',
role: '管理员',
@@ -339,6 +325,17 @@ export default function AppNext() {
const keywordLower = keyword.trim().toLowerCase()
const searchEngines = [
INTERNAL_SEARCH,
...site.search.engines
.filter((engine) => engine.enabled)
.map((engine) => ({ id: engine.id, name: engine.name, url: engine.url })),
]
const searchEngine =
searchEngines.find((engine) => engine.id === searchEngineId) ??
searchEngines.find((engine) => engine.id === site.search.default) ??
INTERNAL_SEARCH
// 配色:本机偏好优先于站点默认,写在 <html data-scheme> 上,
// 深色由组件库的令牌块接管,页面与组件都不用改
const [themeOverride, setThemeOverride] = useState<ThemeChoice | null>(() =>
@@ -410,7 +407,7 @@ export default function AppNext() {
event.preventDefault()
setEngineOpen(false)
if (searchEngine.key === 'local') {
if (searchEngine.id === 'internal') {
const target = findLocalTarget(manage.groups, keywordLower)
if (target !== undefined) {
navigate(
@@ -422,24 +419,19 @@ export default function AppNext() {
return
}
window.open(
`${searchEngine.search}${encodeURIComponent(keyword.trim())}`,
'_blank',
'noopener,noreferrer',
const searchUrl = searchEngine.url.replaceAll(
'{query}',
encodeURIComponent(keyword.trim()),
)
window.open(searchUrl, '_blank', 'noopener,noreferrer')
}
input.addEventListener('keydown', handleKeyDown)
return () => input.removeEventListener('keydown', handleKeyDown)
}, [keyword, keywordLower, manage.groups, navigate, searchEngine])
const chooseEngine = (next: SearchEngine) => {
setSearchEngine(next)
const chooseEngine = (next: string) => {
setSearchEngineId(next)
setEngineOpen(false)
try {
window.localStorage.setItem(SEARCH_ENGINE_KEY, next.key)
} catch {
// 隐私模式下只影响本次选择,不阻断搜索
}
searchRef.current?.focus()
}
@@ -827,61 +819,76 @@ export default function AppNext() {
}
toolbar={
<div className="search-toolbar">
<SearchField
value={keyword}
onChange={setKeyword}
inputRef={searchRef}
placeholder={
searchEngine.key === 'local'
? '搜索本地项目…'
: `用 ${searchEngine.name} 搜索,回车打开…`
}
label={
searchEngine.key === 'local'
? '搜索本地项目,回车打开第一个匹配项'
: `用 ${searchEngine.name} 搜索,回车打开新标签页`
}
variant="card"
hotkey="k"
leading={
<div className="engine-switch" ref={engineRef}>
<div className="search-shell" ref={engineRef}>
<button
type="button"
className="engine-badge"
aria-haspopup="menu"
aria-expanded={engineOpen}
aria-label={`搜索引擎:${searchEngine.name},点击切换`}
title={
searchEngine.id === 'internal'
? '当前:站内搜索;回车打开第一个匹配项'
: `当前:${searchEngine.name};回车用该引擎搜索`
}
onClick={() => setEngineOpen((open) => !open)}
>
<EngineIcon name={searchEngine.id as EngineIconName} size={14} />
<span>{searchEngine.name}</span>
</button>
<SearchField
value={keyword}
onChange={setKeyword}
inputRef={searchRef}
maxWidth="100%"
placeholder={
searchEngine.id === 'internal'
? '搜索站内项目…'
: `使用 ${searchEngine.name} 搜索...`
}
label={
searchEngine.id === 'internal'
? '搜索站内项目,回车打开第一个匹配项'
: `使用 ${searchEngine.name} 搜索,回车打开新标签页`
}
variant="card"
hotkey="k"
/>
{engineOpen && (
<div className="engine-dropdown" role="menu" aria-label="选择搜索引擎">
<button
type="button"
className="engine-trigger"
aria-haspopup="menu"
aria-expanded={engineOpen}
aria-label={`搜索引擎:${searchEngine.name},点击切换`}
title={
searchEngine.key === 'local'
? '当前:本地搜索;回车打开第一个匹配项'
: `当前:${searchEngine.name};回车用该引擎搜索`
}
onClick={() => setEngineOpen((open) => !open)}
role="menuitemradio"
aria-checked={searchEngine.id === 'internal'}
className={`engine-option${searchEngine.id === 'internal' ? ' is-active' : ''}`}
onClick={() => chooseEngine('internal')}
>
<Icon name={searchEngine.icon} />
<EngineIcon name="internal" size={18} />
<span>站内搜索</span>
</button>
{engineOpen && (
<div className="engine-menu" role="menu" aria-label="选择搜索引擎">
{SEARCH_ENGINES.map((engine) => (
{searchEngines.length > 1 && (
<>
<div className="engine-divider" />
{searchEngines.slice(1).map((engine) => (
<button
key={engine.key}
key={engine.id}
type="button"
role="menuitemradio"
aria-checked={engine.key === searchEngine.key}
className={`engine-option${engine.key === searchEngine.key ? ' is-active' : ''}`}
onClick={() => chooseEngine(engine)}
aria-checked={engine.id === searchEngine.id}
className={`engine-option${engine.id === searchEngine.id ? ' is-active' : ''}`}
onClick={() => chooseEngine(engine.id)}
>
<span className="engine-option-icon">
<Icon name={engine.icon} />
</span>
{engine.name}
<EngineIcon name={engine.id as EngineIconName} size={18} />
<span>{engine.name}</span>
</button>
))}
</div>
</>
)}
</div>
}
/>
)}
</div>
</div>
}
sider={