feat: 实现快速访问公司搜索功能,包括防抖输入、后端API和改进的股票代码处理。
This commit is contained in:
parent
f471813b95
commit
5b501e3984
@ -321,3 +321,18 @@ async def get_recent_companies(
|
|||||||
db=db,
|
db=db,
|
||||||
limit=20
|
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:
|
if len(companies) >= limit:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
||||||
return companies
|
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,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} 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 = {
|
type RecentCompany = {
|
||||||
market: string
|
market: string
|
||||||
symbol: string
|
symbol: string
|
||||||
company_name: string
|
company_name: string
|
||||||
last_update: string
|
last_update?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function NavHeaderInner() {
|
function NavHeaderInner() {
|
||||||
@ -25,7 +29,13 @@ function NavHeaderInner() {
|
|||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
|
||||||
const [dataSource, setDataSource] = useState("Bloomberg")
|
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
|
// Sync state with URL params
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -35,10 +45,50 @@ function NavHeaderInner() {
|
|||||||
}
|
}
|
||||||
}, [searchParams])
|
}, [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) => {
|
const handleDataSourceChange = (newSource: string) => {
|
||||||
setDataSource(newSource)
|
setDataSource(newSource)
|
||||||
|
|
||||||
// If viewing a company, update URL to trigger reload with new source
|
|
||||||
const currentSymbol = searchParams.get('symbol')
|
const currentSymbol = searchParams.get('symbol')
|
||||||
if (currentSymbol) {
|
if (currentSymbol) {
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
@ -47,43 +97,73 @@ function NavHeaderInner() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch recent companies
|
const inferMarket = (symbol: string): string[] => {
|
||||||
useEffect(() => {
|
// 6 digits -> China A-Share
|
||||||
const fetchRecent = async () => {
|
if (/^\d{6}$/.test(symbol)) return ["CH"]
|
||||||
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 handleCompanyChange = (value: string) => {
|
// 4 digits -> Japan or HK
|
||||||
if (!value) return
|
if (/^\d{4}$/.test(symbol)) return ["JP", "HK"]
|
||||||
const [symbol, market] = value.split(':')
|
|
||||||
const company = companies.find(c => c.symbol === symbol && c.market === market)
|
|
||||||
|
|
||||||
// Push URL params to trigger page state update
|
// 5 digits or 1-3 digits -> HK
|
||||||
// We encode company name as well to display it nicely before full details load
|
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()
|
const params = new URLSearchParams()
|
||||||
params.set('symbol', symbol)
|
params.set('symbol', symbol)
|
||||||
params.set('market', market)
|
params.set('market', market)
|
||||||
params.set('source', dataSource)
|
params.set('source', dataSource)
|
||||||
if (company) params.set('name', company.company_name)
|
if (name) params.set('name', name)
|
||||||
params.set('t', Date.now().toString()) // Force update
|
params.set('t', Date.now().toString())
|
||||||
|
|
||||||
router.push(`/?${params.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 (
|
return (
|
||||||
<header className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 z-[60] relative">
|
<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="/">
|
<Link className="flex items-center gap-2 font-semibold min-w-[140px]" href="/">
|
||||||
<MonitorPlay className="h-6 w-6" />
|
<MonitorPlay className="h-6 w-6" />
|
||||||
<span className="">股票分析 AI</span>
|
<span className="">股票分析 AI</span>
|
||||||
@ -104,25 +184,75 @@ function NavHeaderInner() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select onValueChange={handleCompanyChange}>
|
{/* Custom Combobox for Quick Access */}
|
||||||
<SelectTrigger className="w-[120px] h-8 text-xs font-mono">
|
<div className="relative w-[180px]">
|
||||||
<SelectValue placeholder="代码..." />
|
<Input
|
||||||
</SelectTrigger>
|
className="h-8 text-xs font-mono"
|
||||||
<SelectContent className="z-[70]">
|
placeholder="代码/名称..."
|
||||||
{companies.map((c) => (
|
value={inputValue}
|
||||||
<SelectItem key={`${c.symbol}:${c.market}`} value={`${c.symbol}:${c.market}`}>
|
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="flex items-center gap-2 w-full">
|
||||||
<span className="font-bold w-[50px] text-left">{c.symbol}</span>
|
<span className="font-bold w-[50px] text-left">{c.symbol}</span>
|
||||||
<span className="truncate flex-1 text-left">{c.company_name}</span>
|
<span className="truncate flex-1 text-left">{c.company_name}</span>
|
||||||
|
|
||||||
</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>
|
||||||
|
|
||||||
<div className="w-[160px] ml-4">
|
<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 }[]>;
|
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() {
|
export async function getConfig() {
|
||||||
const res = await fetch(`${API_BASE}/config`);
|
const res = await fetch(`${API_BASE}/config`);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user