From 5b501e398489f5937411a5b14b3bd8b985fca93d Mon Sep 17 00:00:00 2001 From: xucheng Date: Mon, 19 Jan 2026 16:45:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=BF=AB=E9=80=9F?= =?UTF-8?q?=E8=AE=BF=E9=97=AE=E5=85=AC=E5=8F=B8=E6=90=9C=E7=B4=A2=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=8C=E5=8C=85=E6=8B=AC=E9=98=B2=E6=8A=96=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E3=80=81=E5=90=8E=E7=AB=AFAPI=E5=92=8C=E6=94=B9?= =?UTF-8?q?=E8=BF=9B=E7=9A=84=E8=82=A1=E7=A5=A8=E4=BB=A3=E7=A0=81=E5=A4=84?= =?UTF-8?q?=E7=90=86=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/data_routes.py | 15 ++ backend/app/services/data_fetcher_service.py | 46 ++++ frontend/src/components/nav-header.tsx | 224 +++++++++++++++---- frontend/src/hooks/use-debounce.ts | 15 ++ frontend/src/lib/api.ts | 6 + 5 files changed, 259 insertions(+), 47 deletions(-) create mode 100644 frontend/src/hooks/use-debounce.ts diff --git a/backend/app/api/data_routes.py b/backend/app/api/data_routes.py index 406dc13..1c55693 100644 --- a/backend/app/api/data_routes.py +++ b/backend/app/api/data_routes.py @@ -321,3 +321,18 @@ async def get_recent_companies( db=db, limit=20 ) + + +@router.get("/search_local", response_model=List[dict]) +async def search_local_companies( + q: str, + db: AsyncSession = Depends(get_db) +): + """ + 搜索本地数据库中的公司 + """ + return await data_fetcher_service.search_companies( + query=q, + db=db, + limit=10 + ) diff --git a/backend/app/services/data_fetcher_service.py b/backend/app/services/data_fetcher_service.py index 07f4bd7..a11ac3a 100644 --- a/backend/app/services/data_fetcher_service.py +++ b/backend/app/services/data_fetcher_service.py @@ -534,4 +534,50 @@ async def get_recent_companies( if len(companies) >= limit: break + return companies + +async def search_companies( + query: str, + db: AsyncSession, + limit: int = 10 +) -> List[Dict]: + """ + 搜索本地公司库 + + Args: + query: 搜索关键词 (代码或名称) + db: 数据库会话 + limit: 返回数量 + + Returns: + 公司列表 + """ + if not query: + return [] + + # 构建模糊查询 + search_term = f"%{query}%" + + # 查找 symbol 或 company_name 匹配的公司 + stmt = ( + select(Company) + .where( + (Company.symbol.ilike(search_term)) | + (Company.company_name.ilike(search_term)) + ) + .limit(limit) + ) + + result = await db.execute(stmt) + companies = result.scalars().all() + + return [ + { + "market": c.market, + "symbol": c.symbol, + "company_name": c.company_name, + "id": c.id + } + for c in companies + ] diff --git a/frontend/src/components/nav-header.tsx b/frontend/src/components/nav-header.tsx index 1dfea2b..1beff06 100644 --- a/frontend/src/components/nav-header.tsx +++ b/frontend/src/components/nav-header.tsx @@ -12,12 +12,16 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" +import { Input } from "@/components/ui/input" +import { searchLocalCompanies } from "@/lib/api" +import { useDebounce } from "@/hooks/use-debounce" +import { Loader2 } from "lucide-react" type RecentCompany = { market: string symbol: string company_name: string - last_update: string + last_update?: string } function NavHeaderInner() { @@ -25,7 +29,13 @@ function NavHeaderInner() { const searchParams = useSearchParams() const [dataSource, setDataSource] = useState("Bloomberg") - const [companies, setCompanies] = useState([]) + + // Quick Access Search State + const [inputValue, setInputValue] = useState("") + const [isOpen, setIsOpen] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [results, setResults] = useState([]) + const debouncedValue = useDebounce(inputValue, 300) // Sync state with URL params useEffect(() => { @@ -35,10 +45,50 @@ function NavHeaderInner() { } }, [searchParams]) + // Load initial recent companies + useEffect(() => { + const fetchRecent = async () => { + // Only fetch if input is empty to show history + if (!inputValue) { + try { + const res = await fetch(`/api/data/recent?data_source=${dataSource}`) + if (res.ok) { + const data = await res.json() + setResults(Array.isArray(data) ? data : []) + } + } catch (err) { + console.error("Failed to fetch recent companies:", err) + } + } + } + fetchRecent() + }, [dataSource, inputValue]) + + // Handle Search + useEffect(() => { + const doSearch = async () => { + if (!debouncedValue) return + + setIsLoading(true) + try { + const data = await searchLocalCompanies(debouncedValue) + setResults(data) + } catch (error) { + console.error("Search error:", error) + setResults([]) + } finally { + setIsLoading(false) + } + } + + if (debouncedValue) { + doSearch() + } + }, [debouncedValue]) + + const handleDataSourceChange = (newSource: string) => { setDataSource(newSource) - - // If viewing a company, update URL to trigger reload with new source const currentSymbol = searchParams.get('symbol') if (currentSymbol) { const params = new URLSearchParams(searchParams.toString()) @@ -47,43 +97,73 @@ function NavHeaderInner() { } } - // Fetch recent companies - useEffect(() => { - const fetchRecent = async () => { - try { - // Use relative path since we are in same domain mainly - const res = await fetch(`/api/data/recent?data_source=${dataSource}`) - if (res.ok) { - const data = await res.json() - setCompanies(Array.isArray(data) ? data : []) - } - } catch (err) { - console.error("Failed to fetch recent companies:", err) - } - } - fetchRecent() - }, [dataSource]) + const inferMarket = (symbol: string): string[] => { + // 6 digits -> China A-Share + if (/^\d{6}$/.test(symbol)) return ["CH"] - const handleCompanyChange = (value: string) => { - if (!value) return - const [symbol, market] = value.split(':') - const company = companies.find(c => c.symbol === symbol && c.market === market) + // 4 digits -> Japan or HK + if (/^\d{4}$/.test(symbol)) return ["JP", "HK"] - // Push URL params to trigger page state update - // We encode company name as well to display it nicely before full details load + // 5 digits or 1-3 digits -> HK + if (/^\d{5}$/.test(symbol) || /^\d{1,3}$/.test(symbol)) return ["HK"] + + // Letters -> US or Vietnam + if (/^[A-Za-z]+$/.test(symbol)) return ["US", "VN"] + + // Default fallbacks if pattern matches nothing specific but has digits + if (/^\d+$/.test(symbol)) return ["CH"] + + return ["CH"] // Ultimate default + } + + const handleSelect = (symbol: string, market: string, name?: string) => { const params = new URLSearchParams() params.set('symbol', symbol) params.set('market', market) params.set('source', dataSource) - if (company) params.set('name', company.company_name) - params.set('t', Date.now().toString()) // Force update + if (name) params.set('name', name) + params.set('t', Date.now().toString()) router.push(`/?${params.toString()}`) + setIsOpen(false) + setInputValue("") // Clear input after selection + } + + const parseManualInput = (input: string): { symbol: string, market: string } | null => { + const match = input.trim().match(/^([^\s]+)\s+([a-zA-Z]+)$/) + if (match) { + return { symbol: match[1], market: match[2].toUpperCase() } + } + return null + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && inputValue) { + // Check if there is an exact match + const exactMatch = results.find(r => r.symbol === inputValue) + if (exactMatch) { + handleSelect(exactMatch.symbol, exactMatch.market, exactMatch.company_name) + return + } + + // Check for manual input first + const manual = parseManualInput(inputValue) + if (manual) { + handleSelect(manual.symbol, manual.market, manual.symbol) + return + } + + // If no exact match and no manual input, use the first inferred market + const markets = inferMarket(inputValue) + if (markets.length > 0) { + handleSelect(inputValue, markets[0], inputValue) + } + } } return (
-
{/* Adjusted constraint */} +
股票分析 AI @@ -104,25 +184,75 @@ function NavHeaderInner() { - { + setInputValue(e.target.value) + setIsOpen(true) + }} + onFocus={() => setIsOpen(true)} + onBlur={() => setTimeout(() => setIsOpen(false), 200)} // Delay close to allow clicks + onKeyDown={handleKeyDown} + /> + {isLoading && ( + + )} - - - ))} - {companies.length === 0 && ( -
无记录
- )} - - + {isOpen && ( +
+
+ {results.map((c) => ( + + ))} + + {results.length === 0 && inputValue && !isLoading && ( + <> + {(() => { + const manual = parseManualInput(inputValue) + if (manual) { + return ( + + ) + } + return inferMarket(inputValue).map(m => ( + + )) + })()} + + )} + + {results.length === 0 && !inputValue && ( +
最近无访问记录
+ )} +
+
+ )} +
diff --git a/frontend/src/hooks/use-debounce.ts b/frontend/src/hooks/use-debounce.ts new file mode 100644 index 0000000..8b9933e --- /dev/null +++ b/frontend/src/hooks/use-debounce.ts @@ -0,0 +1,15 @@ +import { useEffect, useState } from 'react' + +export function useDebounce(value: T, delay?: number): T { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay || 500) + + return () => { + clearTimeout(timer) + } + }, [value, delay]) + + return debouncedValue +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 3351128..9552964 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -29,6 +29,12 @@ export async function searchStock(query: string, model?: string) { return res.json() as Promise<{ market: string; symbol: string; company_name: string }[]>; } +export async function searchLocalCompanies(query: string) { + const res = await fetch(`${API_BASE}/data/search_local?q=${query}`); + if (!res.ok) throw new Error("Local search failed"); + return res.json() as Promise<{ market: string; symbol: string; company_name: string }[]>; +} + // ========== 配置相关 ========== export async function getConfig() { const res = await fetch(`${API_BASE}/config`);