feat: 指数(asset_type=index)前端接入 — 自选/监控规则/分析页

- 自选: 搜索含指数+徽标; 板块筛选豁免非股票行; 分时列指数降级
- 监控: RuleEditor 指数资产选项 (仅 signal/price, 隐藏涨停/分时信号); 规则列表指数徽标
- 分析: StockFinancialSearch assetTypes prop; StockAnalysis 搜索开放指数
This commit is contained in:
intfoo
2026-07-26 18:35:54 +08:00
parent ed4355e0ea
commit 4d069cc10f
5 changed files with 72 additions and 19 deletions
@@ -7,6 +7,8 @@ import { QK } from '@/lib/queryKeys'
interface Props {
onSelect: (symbol: string, name: string) => void
/** 搜索资产类型, 逗号分隔 (默认 'stock')。如 'stock,index' */
assetTypes?: string
}
/**
@@ -14,7 +16,7 @@ interface Props {
* 复用 instrumentSearch 后端(代码 / 名称模糊匹配),单选即跳转该股财务详情。
* 模式对齐 Watchlist.StockSearchBox:useQuery + 外部点击关闭 + 键盘导航。
*/
export function StockFinancialSearch({ onSelect }: Props) {
export function StockFinancialSearch({ onSelect, assetTypes }: Props) {
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
const [activeIdx, setActiveIdx] = useState(-1)
@@ -22,8 +24,8 @@ export function StockFinancialSearch({ onSelect }: Props) {
const inputRef = useRef<HTMLInputElement>(null)
const search = useQuery({
queryKey: QK.instrumentSearch(query),
queryFn: () => api.instrumentSearch(query),
queryKey: QK.instrumentSearch(query, assetTypes ?? 'stock'),
queryFn: () => api.instrumentSearch(query, 20, assetTypes),
enabled: query.trim().length > 0,
staleTime: 30_000,
})
@@ -116,6 +118,9 @@ export function StockFinancialSearch({ onSelect }: Props) {
>
<span className="font-mono shrink-0 text-xs w-[88px]">{r.symbol}</span>
<span className="truncate text-sm flex-1">{r.name}</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>
))
+31 -10
View File
@@ -82,8 +82,8 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
const [symbolQuery, setSymbolQuery] = useState('')
const [strategyQuery, setStrategyQuery] = useState('')
const [strategyCategory, setStrategyCategory] = useState<'all' | 'builtin' | 'custom' | 'ai'>('all')
// ETF 规则时标的搜索一并搜出 ETF
const symbolAssetTypes = assetType === 'etf' ? 'stock,etf' : 'stock'
// 标的搜索资产类型: ETF 一并搜股票; 指数只搜指数; 否则只搜股票
const symbolAssetTypes = assetType === 'etf' ? 'stock,etf' : assetType === 'index' ? 'index' : 'stock'
const symbolSearch = useQuery({
queryKey: QK.instrumentSearch(symbolQuery, symbolAssetTypes),
queryFn: () => api.instrumentSearch(symbolQuery, 20, symbolAssetTypes),
@@ -158,6 +158,20 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
...SIGNAL_OPTIONS.map(key => ({ key, label: cnSignal(key) })),
...(options.data?.builtin_signals ?? []).filter(option => MONITOR_INTRADAY_SIGNAL_OPTIONS.includes(option.key)),
]
// 指数: 隐藏涨跌停/连板类 (指数无这些列) 与分时信号 (无本地分钟K, 会静默不触发)
const INDEX_HIDDEN_SIGNALS = (key: string) =>
key.includes('limit') || MONITOR_INTRADAY_SIGNAL_OPTIONS.includes(key)
const pickerSignals = assetType === 'index'
? monitorBuiltinSignals.filter(o => !INDEX_HIDDEN_SIGNALS(o.key))
: monitorBuiltinSignals
// 指数: 监控类型仅 signal/price (无涨跌停/策略/封单语义)
const visibleTypes = (options.data?.types ?? []).filter(
t => assetType !== 'index' || t.key === 'signal' || t.key === 'price',
)
// 指数: 作用范围仅 symbols (无全市场/板块语义)
const visibleScopes = (options.data?.scopes ?? []).filter(
s => assetType !== 'index' || s.key === 'symbols',
)
const thresholdConds = draft.conditions.filter(c => c.op !== 'truth')
const strategyPresets = strategies.data?.presets ?? []
const selectedStrategy = strategyPresets.find(strategy => strategy.id === draft.strategy_id)
@@ -212,7 +226,7 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
signals={selectedSignals}
onChange={onSignalPickerChange}
kind="entry"
builtinSignals={monitorBuiltinSignals}
builtinSignals={pickerSignals}
disabledSignals={intradaySupport?.available === false ? MONITOR_INTRADAY_SIGNAL_OPTIONS : []}
disabledSignalHint={intradaySupport?.reason}
/>
@@ -287,26 +301,33 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
</button>
</div>
{/* 资产类型: 股票 / ETF (个股极简模式不显示) */}
{/* 资产类型: 股票 / ETF / 指数 (个股极简模式不显示) */}
{!simple && (
<div className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<div className="inline-flex h-9 rounded-btn border border-border overflow-hidden">
{(['stock', 'etf'] as const).map(t => (
{(['stock', 'etf', 'index'] as const).map(t => (
<button
key={t}
type="button"
aria-pressed={assetType === t}
onClick={() => {
if (assetType === t) return
setDraft(d => ({ ...d, asset_type: t, strategy_id: null, symbols: [] }))
setDraft(d => ({
...d,
asset_type: t,
strategy_id: null,
symbols: [],
type: t === 'index' && d.type !== 'signal' && d.type !== 'price' ? 'signal' : d.type,
scope: t === 'index' ? 'symbols' : d.scope,
}))
setStrategyQuery('')
setStrategyCategory('all')
}}
className={`h-full px-4 text-xs font-medium transition-colors cursor-pointer
${assetType === t ? 'bg-accent/10 text-accent' : 'text-muted hover:text-foreground'}`}
>
{t === 'stock' ? '股票' : 'ETF'}
{t === 'stock' ? '股票' : t === 'etf' ? 'ETF' : '指数'}
</button>
))}
</div>
@@ -317,7 +338,7 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
<div className="space-y-1.5">
<span className="text-[11px] text-muted"></span>
<div className="grid grid-cols-2 gap-1.5 sm:grid-cols-4">
{(options.data?.types ?? []).map(t => {
{visibleTypes.map(t => {
const Icon = TYPE_ICONS[t.key as keyof typeof TYPE_ICONS] ?? Activity
const active = draft.type === t.key
return (
@@ -354,7 +375,7 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
<span className="text-[11px] text-muted"></span>
<div className="flex items-center gap-2">
<select value={draft.scope} onChange={e => setDraft(d => ({ ...d, scope: e.target.value as MonitorRule['scope'] }))} className="h-9 w-32 rounded-btn border border-border bg-base px-3 text-xs text-foreground">
{(options.data?.scopes ?? []).map(s => <option key={s.key} value={s.key} disabled={hasIntradaySignal && s.key !== 'symbols'}>{s.label}</option>)}
{visibleScopes.map(s => <option key={s.key} value={s.key} disabled={hasIntradaySignal && s.key !== 'symbols'}>{s.label}</option>)}
</select>
{draft.scope === 'symbols' && (
<div className="flex-1 flex flex-wrap items-center gap-1.5">
@@ -417,7 +438,7 @@ export function RuleEditor({ rule, preset, simple, onClose, onSaved }: Props) {
signals={selectedSignals}
onChange={onSignalPickerChange}
kind="entry"
builtinSignals={monitorBuiltinSignals}
builtinSignals={pickerSignals}
disabledSignals={intradaySupport?.available === false ? MONITOR_INTRADAY_SIGNAL_OPTIONS : []}
disabledSignalHint={intradaySupport?.reason}
/>
+3
View File
@@ -662,6 +662,9 @@ function RulesList({ rulesQuery, onEdit }: {
<span className={cn('shrink-0 rounded px-1.5 py-0.5 text-[9px] font-semibold', SOURCE_BADGE_STYLE[r.type] ?? 'bg-elevated text-muted')}>
{TYPE_LABEL[r.type]}
</span>
{r.asset_type === 'index' && (
<span className="shrink-0 rounded px-1.5 py-0.5 text-[9px] font-semibold bg-sky-500/10 text-sky-400"></span>
)}
{/* 个股类型: 直接显示可点击的代码+名称; 其他类型显示规则名 */}
{r.scope === 'symbols' && r.symbols.length > 0 ? (
<button
+1 -1
View File
@@ -93,7 +93,7 @@ export function StockAnalysis() {
{/* 搜索栏 */}
<div className="flex items-center gap-3">
<div className="w-72">
<StockFinancialSearch onSelect={onSelect} />
<StockFinancialSearch onSelect={onSelect} assetTypes="stock,index" />
</div>
{symbol && (
<>
+29 -5
View File
@@ -219,8 +219,8 @@ function StockSearchBox({
const [activeIdx, setActiveIdx] = useState(-1)
const search = useQuery({
queryKey: QK.instrumentSearch(query, 'stock,etf'),
queryFn: () => api.instrumentSearch(query, 20, 'stock,etf'),
queryKey: QK.instrumentSearch(query, 'stock,etf,index'),
queryFn: () => api.instrumentSearch(query, 20, 'stock,etf,index'),
enabled: query.trim().length > 0,
staleTime: 30_000,
})
@@ -304,6 +304,9 @@ function StockSearchBox({
{r.asset_type === 'etf' && (
<span className="shrink-0 px-1 py-0.5 rounded text-[10px] leading-none bg-accent/10 text-accent">ETF</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>
)}
</button>
<button
type="button"
@@ -671,6 +674,13 @@ export function Watchlist() {
const symbols = enriched.data?.rows?.map((r: any) => r.symbol) ?? []
const symbolsKey = symbols.join(',')
// 指数无本地分钟K数据, 分时批量请求剔除指数 symbol (省请求, 避免逐只 404)
const minuteSymbols = useMemo(
() => symbols.filter((s: string) => (enriched.data?.rows ?? []).find((r: any) => r.symbol === s)?.asset_type !== 'index'),
[symbols, enriched.data],
)
const minuteSymbolsKey = minuteSymbols.join(',')
// 实时行情状态 (提前到此处: 分时轮询判断需要 realtimeRunning)
const quoteStatus = useQuoteStatus()
const realtimeRunning = quoteStatus.data?.running ?? false
@@ -692,9 +702,9 @@ export function Watchlist() {
const intradayRefreshEnabled = prefsData?.minute_intraday_refresh ?? false
const intradayRefreshInterval = prefsData?.minute_intraday_refresh_interval ?? 6
const minuteBatch = useQuery({
queryKey: QK.minuteBatch(symbolsKey),
queryFn: () => api.klineMinuteBatch(symbols),
enabled: intradayVisible && symbols.length > 0,
queryKey: QK.minuteBatch(minuteSymbolsKey),
queryFn: () => api.klineMinuteBatch(minuteSymbols),
enabled: intradayVisible && minuteSymbols.length > 0,
staleTime: 10_000,
refetchInterval: (intradayRefreshEnabled && realtimeRunning) ? intradayRefreshInterval * 1000 : false,
})
@@ -846,6 +856,8 @@ export function Watchlist() {
let result = rows
if (boardFilter.size > 0 && boardFilter.size < BOARDS.length) {
result = result.filter(r => {
// 非股票 (指数/ETF) 无板块语义, 不受板块筛选影响 (顺带修复 ETF 行被误过滤)
if (r.asset_type && r.asset_type !== 'stock') return true
const board = getBoardType(r.symbol)
return board != null && boardFilter.has(board)
})
@@ -1333,6 +1345,18 @@ export function Watchlist() {
}
// 分时列
if (key === 'intraday') {
// 指数无本地分钟K数据, 分时列降级为占位符
if (r.asset_type === 'index') {
const iw = intradayChartVisible ? intradayResolved.width : 40
const ih = intradayChartVisible ? intradayResolved.height : 40
return (
<td className="pl-3 pr-2 py-1.5 border-l border-border/30" style={{ width: iw + 4, minWidth: iw + 4, maxWidth: iw + 4, height: ih }}>
<div className="flex items-center justify-center">
<span className="text-[10px] text-muted"></span>
</div>
</td>
)
}
const rows: MinuteKlineRow[] = minuteData[r.symbol] ?? []
// 眼睛关闭(收起)时用小尺寸 (和日k收起态一致 40x40); 开启时用配置值
const iw = intradayChartVisible ? intradayResolved.width : 40