mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
feat: 指数(asset_type=index)前端接入 — 自选/监控规则/分析页
- 自选: 搜索含指数+徽标; 板块筛选豁免非股票行; 分时列指数降级 - 监控: RuleEditor 指数资产选项 (仅 signal/price, 隐藏涨停/分时信号); 规则列表指数徽标 - 分析: StockFinancialSearch assetTypes prop; StockAnalysis 搜索开放指数
This commit is contained in:
@@ -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>
|
||||
))
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user