mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
## 1. 拼音首字母搜索 (同花顺式) 后端 search_instruments 新增拼音匹配层, 输入 payh 可命中「平安银行」。 - 辅助函数 _name_pinyin_keys 用 lru_cache 缓存「名称→首字母串」, 命中后 近似 dict 查找, 全市场遍历 < 1ms - 多音字用 heteronym 笛卡尔积展开, 「重庆」同时匹配 cq/zq 两种读音; 并加载 A 股高频地名词典 (重庆/长安/长春/长沙/长城/长江) - 拼音分支仅在纯 ASCII 字母输入时触发, 中文/数字搜索零开销跳过, 完全向后兼容 - 搜索分层: ① code/symbol 前缀 → ② 拼音首字母前缀 → ③ 包含匹配 新增依赖: pypinyin>=0.50 (纯 Python, 无 C 扩展) 新增测试: tests/test_instrument_search.py (15 用例覆盖拼音/多音字/兼容/边界) ## 2. 搜索结果显示板块徽标 自选/财务搜索/监控规则/回测选标的 四个搜索入口的结果项, 现在会显示 创业板(橙「创」)/科创板(青「科」)/北交所(紫「北」)彩色徽标。 - 复用项目既有的 boardTag (@/components/stock-table/primitives), 与自选/策略/Dashboard 表格徽标样式完全统一 - 沪深主板不显示徽标 (信息量低, 保持简洁) ## 验证 - 后端全量测试 503 passed (含新增 15) - 前端 tsc --noEmit 零错误
141 lines
5.6 KiB
TypeScript
141 lines
5.6 KiB
TypeScript
import { useState, useEffect, useRef } from 'react'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import { motion, AnimatePresence } from 'framer-motion'
|
|
import { Search, Loader2 } from 'lucide-react'
|
|
import { api } from '@/lib/api'
|
|
import { QK } from '@/lib/queryKeys'
|
|
import { boardTag } from '@/components/stock-table/primitives'
|
|
|
|
interface Props {
|
|
onSelect: (symbol: string, name: string) => void
|
|
/** 搜索资产类型, 逗号分隔 (默认 'stock')。如 'stock,index' */
|
|
assetTypes?: string
|
|
}
|
|
|
|
/**
|
|
* 个股模糊搜索框 —— 财务页主入口。
|
|
* 复用 instrumentSearch 后端(代码 / 名称模糊匹配),单选即跳转该股财务详情。
|
|
* 模式对齐 Watchlist.StockSearchBox:useQuery + 外部点击关闭 + 键盘导航。
|
|
*/
|
|
export function StockFinancialSearch({ onSelect, assetTypes }: Props) {
|
|
const [query, setQuery] = useState('')
|
|
const [open, setOpen] = useState(false)
|
|
const [activeIdx, setActiveIdx] = useState(-1)
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
|
|
const search = useQuery({
|
|
queryKey: QK.instrumentSearch(query, assetTypes ?? 'stock'),
|
|
queryFn: () => api.instrumentSearch(query, 20, assetTypes),
|
|
enabled: query.trim().length > 0,
|
|
staleTime: 30_000,
|
|
})
|
|
|
|
const results = search.data?.results ?? []
|
|
|
|
// 外部点击关闭下拉
|
|
useEffect(() => {
|
|
function handleClick(e: MouseEvent) {
|
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
setOpen(false)
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleClick)
|
|
return () => document.removeEventListener('mousedown', handleClick)
|
|
}, [])
|
|
|
|
function handleSelect(r: { symbol: string; name: string }) {
|
|
onSelect(r.symbol, r.name)
|
|
setQuery('')
|
|
setOpen(false)
|
|
setActiveIdx(-1)
|
|
}
|
|
|
|
function handleKeyDown(e: React.KeyboardEvent) {
|
|
if (e.key === 'Escape') { setOpen(false); return }
|
|
if (!open || results.length === 0) return
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault()
|
|
setActiveIdx(i => Math.min(i + 1, results.length - 1))
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
setActiveIdx(i => Math.max(i - 1, -1))
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
if (activeIdx >= 0) handleSelect(results[activeIdx])
|
|
else if (results.length > 0) handleSelect(results[0])
|
|
}
|
|
}
|
|
|
|
const trimmed = query.trim()
|
|
|
|
return (
|
|
<div ref={containerRef} className="relative w-full max-w-xl mx-auto">
|
|
<div className="relative flex items-center">
|
|
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted pointer-events-none" />
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
placeholder="输入股票代码或名称,如 600000 / 浦发"
|
|
value={query}
|
|
onChange={(e) => { setQuery(e.target.value); setOpen(true); setActiveIdx(-1) }}
|
|
onFocus={() => { if (trimmed) setOpen(true) }}
|
|
onKeyDown={handleKeyDown}
|
|
// 较宽、更醒目 —— 作为财务页主入口
|
|
className="w-full h-11 pl-11 pr-10 rounded-card bg-surface border border-border text-sm text-foreground placeholder:text-muted focus:outline-none focus:border-accent/50 focus:bg-base transition-colors"
|
|
/>
|
|
{search.isFetching && (
|
|
<Loader2 className="absolute right-3.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted animate-spin" />
|
|
)}
|
|
</div>
|
|
|
|
<AnimatePresence>
|
|
{open && trimmed && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -4 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -4 }}
|
|
transition={{ duration: 0.12, ease: [0.16, 1, 0.3, 1] }}
|
|
className="absolute left-0 right-0 top-full mt-1.5 z-50 max-h-[360px] overflow-y-auto rounded-card border border-border bg-base shadow-xl"
|
|
>
|
|
{search.isLoading ? (
|
|
<div className="px-4 py-6 flex items-center justify-center gap-2 text-xs text-muted">
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
搜索中…
|
|
</div>
|
|
) : results.length === 0 ? (
|
|
<div className="px-4 py-6 text-center text-xs text-muted">
|
|
未找到匹配的股票
|
|
</div>
|
|
) : (
|
|
results.map((r, i) => (
|
|
<button
|
|
key={r.symbol}
|
|
type="button"
|
|
onClick={() => handleSelect(r)}
|
|
className={`w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors duration-100 ${
|
|
i === activeIdx ? 'bg-accent/10 text-accent' : 'hover:bg-elevated text-foreground'
|
|
}`}
|
|
>
|
|
<span className="font-mono shrink-0 text-xs w-[88px]">{r.symbol}</span>
|
|
<span className="truncate text-sm flex-1">{r.name}</span>
|
|
{(() => {
|
|
const b = boardTag(r.symbol)
|
|
return b && (
|
|
<span className={`shrink-0 px-1 py-0.5 rounded text-[10px] leading-none border ${b.color}`}>{b.label}</span>
|
|
)
|
|
})()}
|
|
{r.asset_type === 'index' && (
|
|
<span className="shrink-0 px-1 py-0.5 rounded text-[10px] leading-none bg-sky-500/10 text-sky-400">指数</span>
|
|
)}
|
|
{r.code && <span className="text-[10px] text-muted font-mono shrink-0">{r.code}</span>}
|
|
</button>
|
|
))
|
|
)}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
)
|
|
}
|