From 038b7ecb42c1eeb793385c70f6521ce6f125250c Mon Sep 17 00:00:00 2001 From: CNWei Date: Tue, 3 Nov 2026 20:33:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(search):=20=E6=90=9C=E7=B4=A2=E6=A1=86?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=88=87=E6=8D=A2=E6=90=9C=E7=B4=A2=E5=BC=95?= =?UTF-8?q?=E6=93=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 采用效果五:点击右侧引擎名展开横向快捷切换栏 - 支持 Google / Bing / 百度 / 搜狗 / 360,选择结果存 localStorage - 输入时仍按原逻辑过滤本地项目;回车用当前引擎打开新标签搜索 - Esc 关闭切换栏,点击外部自动收起 --- stash-web/src/App.css | 77 +++++++++++++++++++++++++++++ stash-web/src/AppNext.tsx | 101 +++++++++++++++++++++++++++++++++++++- 2 files changed, 177 insertions(+), 1 deletion(-) diff --git a/stash-web/src/App.css b/stash-web/src/App.css index c5ed1ff..861a7f3 100644 --- a/stash-web/src/App.css +++ b/stash-web/src/App.css @@ -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; +} diff --git a/stash-web/src/AppNext.tsx b/stash-web/src/AppNext.tsx index 74d27c5..23c62a9 100644 --- a/stash-web/src/AppNext.tsx +++ b/stash-web/src/AppNext.tsx @@ -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(null) const [scrolled, setScrolled] = useState(false) const searchRef = useRef(null) + const [searchEngine, setSearchEngine] = useState(readSearchEngine) + const [engineOpen, setEngineOpen] = useState(false) + const engineRef = useRef(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={ +
+ + {engineOpen && ( +
+ {SEARCH_ENGINES.map((engine) => ( + + ))} +
+ )} +
+ } /> }