feat: 实现快速访问公司搜索功能,包括防抖输入、后端API和改进的股票代码处理。
This commit is contained in:
parent
f471813b95
commit
5b501e3984
@ -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
|
||||
)
|
||||
|
||||
@ -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
|
||||
]
|
||||
|
||||
@ -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}`}>
|
||||
{/* 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" />
|
||||
)}
|
||||
|
||||
{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>
|
||||
</SelectItem>
|
||||
<span className="text-[10px] text-muted-foreground">{c.market}</span>
|
||||
</button>
|
||||
))}
|
||||
{companies.length === 0 && (
|
||||
<div className="p-2 text-xs text-muted-foreground text-center">无记录</div>
|
||||
|
||||
{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>
|
||||
))
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{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">
|
||||
|
||||
15
frontend/src/hooks/use-debounce.ts
Normal file
15
frontend/src/hooks/use-debounce.ts
Normal 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
|
||||
}
|
||||
@ -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`);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user