search-syntax-pro/src/floating-panel/App.tsx
Zain 2e5f50251d refactor(搜索组件): 优化搜索参数处理逻辑
将搜索参数更新与查询生成逻辑分离
使用useEffect自动触发查询生成
统一处理搜索参数变更的回调函数
2025-11-25 11:59:03 +08:00

309 行
9.2 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react'
import { SearchForm } from '@/components/SearchForm'
import { QueryPreview } from '@/components/QueryPreview'
import { LogoIcon } from '@/components/Logo'
import { ThemeProvider } from '@/hooks/useTheme'
import { TranslationProvider, useTranslation } from '@/i18n'
import { SearchAdapterFactory } from '@/services/adapters'
import { detectCurrentEngine } from '@/config/search-engine-selectors'
import { extractAndDecodeQuery } from '@/utils/url-utils'
import type { SearchParams, ValidationResult, FloatingPanelMessageEnvelope } from '@/types'
import './floating-panel.css'
// Inner component that uses the translation hook
function FloatingPanelContent() {
const { t } = useTranslation()
const [searchParams, setSearchParams] = useState<SearchParams>({
keyword: '',
engine: detectCurrentEngine(),
// 旧字段(向后兼容)
site: '',
fileType: '',
exactMatch: '',
fromUser: '',
toUser: '',
// 新多值字段
exactMatches: [],
sites: [],
fileTypes: [],
fromUsers: [],
toUsers: [],
subreddits: [],
languages: [],
tags: [],
// 其他字段
inTitle: '',
inUrl: '',
excludeWords: [],
orKeywords: [],
inText: '',
numberRange: undefined,
wildcardQuery: '',
allInTitle: '',
relatedSite: '',
cacheSite: '',
dateRange: undefined
})
const [generatedQuery, setGeneratedQuery] = useState('')
const [validation, setValidation] = useState<ValidationResult | null>(null)
// 浮动面板默认展开高级选项(用户打开面板就是为了构建高级搜索语法)
const [showAdvanced, setShowAdvanced] = useState(true)
const handleSearchParamsChange = useCallback((params: SearchParams) => {
setSearchParams(params)
}, [])
// 🔥 监听来自 content script 的消息
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const data = event.data as FloatingPanelMessageEnvelope
if (data && data.source === 'ssp-content') {
const message = data.message
// 处理填充关键词和引擎信息
if (message.type === 'FLOATING_PANEL_FILL_INPUT') {
const payload = (message as any).payload
setSearchParams(prev => ({
...prev,
keyword: payload.keyword || '',
// 使用父页面传递的引擎信息(修复 iframe 无法检测引擎的问题)
engine: payload.engine || prev.engine
}))
}
}
}
window.addEventListener('message', handleMessage)
// 通知 content script iframe 已准备好
const readyMessage: FloatingPanelMessageEnvelope = {
source: 'ssp-iframe',
message: { type: 'FLOATING_PANEL_READY' },
timestamp: Date.now()
}
window.parent.postMessage(readyMessage, '*')
return () => {
window.removeEventListener('message', handleMessage)
}
}, [])
// 生成搜索查询
const generateQuery = useCallback(async (params: SearchParams) => {
try {
const adapter = SearchAdapterFactory.getAdapter(params.engine)
// 验证搜索参数
const validationResult = await Promise.resolve(
adapter.validateParams?.(params) || {
isValid: true,
errors: [],
warnings: []
}
)
setValidation(validationResult)
// 构建查询
const query = adapter.buildQuery(params)
// 安全提取和解码查询字符串,避免在搜索框中显示 %20, %3A 等编码
const decodedQuery = extractAndDecodeQuery(query)
setGeneratedQuery(decodedQuery)
} catch (error) {
console.error('[FloatingPanel] Failed to generate query:', error)
setValidation({
isValid: false,
errors: [t('popup.adapterLoadError')],
warnings: []
})
}
}, [t])
useEffect(() => {
void generateQuery(searchParams)
}, [searchParams, generateQuery])
// 通用的应用逻辑,接受autoSearch参数
const applyQuery = useCallback((autoSearch: boolean) => {
if (!validation?.isValid) {
console.warn('[FloatingPanel] Cannot apply invalid query')
return
}
// 发送消息到 content script
const applyMessage: FloatingPanelMessageEnvelope = {
source: 'ssp-iframe',
message: {
type: 'FLOATING_PANEL_APPLY_SYNTAX',
payload: {
query: generatedQuery,
autoSearch, // 使用参数而非硬编码
searchUrl: '' // content script 会处理
}
},
timestamp: Date.now()
}
window.parent.postMessage(applyMessage, '*')
}, [generatedQuery, validation])
// "应用到搜索框"按钮: 仅填充,不执行搜索
const handleApplyToSearchBox = useCallback(() => {
applyQuery(false)
}, [applyQuery])
// "执行搜索"按钮: 填充并执行搜索
const handleExecuteSearch = useCallback(() => {
applyQuery(true)
}, [applyQuery])
// 关闭面板
const handleClose = useCallback(() => {
const closeMessage: FloatingPanelMessageEnvelope = {
source: 'ssp-iframe',
message: { type: 'FLOATING_PANEL_CLOSE' },
timestamp: Date.now()
}
window.parent.postMessage(closeMessage, '*')
}, [])
// ESC 键关闭
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleClose()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleClose])
return (
<div className="floating-panel-container">
{/* 头部 */}
<header className="floating-panel-header">
<div className="header-left">
<LogoIcon size={24} color="white" />
<h1 className="header-title">{t('popup.headerTitle')}</h1>
<span className="engine-badge">{searchParams.engine.toUpperCase()}</span>
</div>
<button
onClick={handleClose}
className="close-button"
title={t('common.close', undefined, 'Close (ESC)')}
>
✕
</button>
</header>
{/* 主体内容 */}
<main className="floating-panel-content">
{/* 搜索表单 - 针对 800px 优化,隐藏引擎选择器(当前页面已确定搜索引擎) */}
<SearchForm
searchParams={searchParams}
onSearchParamsChange={handleSearchParamsChange}
showAdvanced={showAdvanced}
onToggleAdvanced={setShowAdvanced}
hideEngineSelector={true}
/>
{/* 验证结果 */}
{validation && (validation.errors.length > 0 || validation.warnings.length > 0) && (
<div className="validation-section">
{validation.errors.length > 0 && (
<div className="alert alert-error">
<ul className="text-sm">
{validation.errors.map((error, index) => (
<li key={index}>• {error}</li>
))}
</ul>
</div>
)}
{validation.warnings.length > 0 && (
<div className="alert alert-warning">
<ul className="text-sm">
{validation.warnings.map((warning, index) => (
<li key={index}>• {warning}</li>
))}
</ul>
</div>
)}
</div>
)}
{/* 查询预览 */}
{generatedQuery && (
<div className="preview-section">
<QueryPreview
query={generatedQuery}
onSearch={handleExecuteSearch}
disabled={!validation?.isValid}
/>
</div>
)}
</main>
{/* 底部操作栏 */}
<footer className="floating-panel-footer">
<div className="footer-info">
<span className="text-xs text-gray-500 dark:text-gray-400">
{t('popup.footer.currentEngine', { engine: searchParams.engine.toUpperCase() })}
</span>
</div>
<div className="footer-actions">
<button
onClick={handleClose}
className="btn btn-ghost"
>
{t('common.cancel', undefined, 'Cancel')}
</button>
<button
onClick={handleApplyToSearchBox}
disabled={!validation?.isValid || !generatedQuery}
className="btn btn-primary"
>
{t('common.apply', undefined, 'Apply to Search Box')}
</button>
</div>
</footer>
</div>
)
}
// Outer component that provides the language context
function FloatingPanelApp() {
const [language, setLanguage] = useState<'zh-CN' | 'en-US'>('zh-CN')
// 🔥 初始化:从 storage 获取用户语言偏好
useEffect(() => {
const loadSettings = async () => {
try {
const result = await chrome.storage.local.get('user_settings')
const userLanguage = result.user_settings?.language || 'zh-CN'
setLanguage(userLanguage)
} catch (error) {
console.error('[FloatingPanel] Failed to load settings:', error)
}
}
loadSettings()
}, [])
return (
<ThemeProvider>
<TranslationProvider language={language}>
<FloatingPanelContent />
</TranslationProvider>
</ThemeProvider>
)
}
export default FloatingPanelApp