feat: 实现快速访问公司搜索功能,包括防抖输入、后端API和改进的股票代码处理。

This commit is contained in:
xucheng 2026-01-19 16:45:05 +08:00
parent f471813b95
commit 5b501e3984
5 changed files with 259 additions and 47 deletions

View File

@ -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
)

View File

@ -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
]

View File

@ -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<RecentCompany[]>([])
// Quick Access Search State
const [inputValue, setInputValue] = useState("")
const [isOpen, setIsOpen] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [results, setResults] = useState<RecentCompany[]>([])
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 (
<header className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 z-[60] relative">
<div className="flex h-14 items-center gap-4 px-6 lg:h-[60px] w-full px-8"> {/* Adjusted constraint */}
<div className="flex h-14 items-center gap-4 px-6 lg:h-[60px] w-full px-8">
<Link className="flex items-center gap-2 font-semibold min-w-[140px]" href="/">
<MonitorPlay className="h-6 w-6" />
<span className=""> AI</span>
@ -104,25 +184,75 @@ function NavHeaderInner() {
</SelectContent>
</Select>
<Select onValueChange={handleCompanyChange}>
<SelectTrigger className="w-[120px] h-8 text-xs font-mono">
<SelectValue placeholder="代码..." />
</SelectTrigger>
<SelectContent className="z-[70]">
{companies.map((c) => (
<SelectItem key={`${c.symbol}:${c.market}`} value={`${c.symbol}:${c.market}`}>
<span className="flex items-center gap-2 w-full">
<span className="font-bold w-[50px] text-left">{c.symbol}</span>
<span className="truncate flex-1 text-left">{c.company_name}</span>
{/* Custom Combobox for Quick Access */}
<div className="relative w-[180px]">
<Input
className="h-8 text-xs font-mono"
placeholder="代码/名称..."
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value)
setIsOpen(true)
}}
onFocus={() => setIsOpen(true)}
onBlur={() => setTimeout(() => setIsOpen(false), 200)} // Delay close to allow clicks
onKeyDown={handleKeyDown}
/>
{isLoading && (
<Loader2 className="absolute right-2 top-2 h-4 w-4 animate-spin text-muted-foreground" />
)}
</span>
</SelectItem>
))}
{companies.length === 0 && (
<div className="p-2 text-xs text-muted-foreground text-center"></div>
)}
</SelectContent>
</Select>
{isOpen && (
<div className="absolute top-full mt-1 left-0 w-[240px] bg-popover text-popover-foreground rounded-md border shadow-md z-[80] overflow-hidden max-h-[300px] overflow-y-auto">
<div className="p-1">
{results.map((c) => (
<button
key={`${c.symbol}:${c.market}`}
className="w-full flex items-center justify-between p-2 hover:bg-accent hover:text-accent-foreground rounded-sm text-xs text-left transition-colors"
onClick={() => handleSelect(c.symbol, c.market, c.company_name)}
>
<span className="flex items-center gap-2 w-full">
<span className="font-bold w-[50px] text-left">{c.symbol}</span>
<span className="truncate flex-1 text-left">{c.company_name}</span>
</span>
<span className="text-[10px] text-muted-foreground">{c.market}</span>
</button>
))}
{results.length === 0 && inputValue && !isLoading && (
<>
{(() => {
const manual = parseManualInput(inputValue)
if (manual) {
return (
<button
className="w-full p-2 hover:bg-accent hover:text-accent-foreground rounded-sm text-xs text-left text-blue-500 flex items-center gap-2"
onClick={() => handleSelect(manual.symbol, manual.market, manual.symbol)}
>
<span> <strong>{manual.symbol}</strong> ({manual.market}) - </span>
</button>
)
}
return inferMarket(inputValue).map(m => (
<button
key={m}
className="w-full p-2 hover:bg-accent hover:text-accent-foreground rounded-sm text-xs text-left text-blue-500 flex items-center gap-2"
onClick={() => handleSelect(inputValue, m, inputValue)}
>
<span> <strong>{inputValue}</strong> ({m})</span>
</button>
))
})()}
</>
)}
{results.length === 0 && !inputValue && (
<div className="p-2 text-xs text-muted-foreground text-center">访</div>
)}
</div>
</div>
)}
</div>
</div>
<div className="w-[160px] ml-4">

View File

@ -0,0 +1,15 @@
import { useEffect, useState } from 'react'
export function useDebounce<T>(value: T, delay?: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay || 500)
return () => {
clearTimeout(timer)
}
}, [value, delay])
return debouncedValue
}

View File

@ -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`);