mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
feat(monitor): 个股通知带行业/概念 ext 标签 + 全局配置 (#118)
监控中心顶部加全局配置(齿轮), 选择要在个股通知里展示的 ext 字段
(默认概念 ext_gn_ths + 行业 ext_hy_ths)。开启后触发记录卡片和推送
toast 统一显示行业(蓝)/概念(橙)标签。
架构: 源头富化 — 后端 SSE 推送前 (_enrich_alerts_ext) 用
_load_ext_value_maps (带 mtime 缓存) 按 symbol 富化; GET /api/alerts
读取时同样富化。前端零额外请求。
配置支持细粒度裁剪:
- 字段下拉按扩展表分组 (optgroup)
- 显示前N个 (maxTags), 留空=全部
- 隐藏指定位置 (hiddenIndices), 点数字划掉
数据结构 {field, maxTags, hiddenIndices} 向后兼容旧字符串格式。
后端:
- preferences.py: get_monitor_ext_fields + _normalize_ext_field
- settings.py: RealtimeMonitorConfigIn + get_preferences 加字段
- quote_service.py: _enrich_alerts_ext SSE 推送前富化
- alerts.py: list_alerts 加 ext_columns 参数
前端:
- api.ts: MonitorExtFieldItem 类型 + alertsList extColumns 参数
- Monitor.tsx: 齿轮入口 + 配置弹窗 + 卡片标签行
- AlertToast.tsx: toast 底行加标签
Co-authored-by: shy3130 <shy3130@users.noreply.github.com>
This commit is contained in:
@@ -23,11 +23,25 @@ def list_alerts(
|
||||
limit: int = 5000,
|
||||
source: str | None = None,
|
||||
type: str | None = None,
|
||||
ext_columns: str | None = None,
|
||||
):
|
||||
"""查询触发记录 (时间倒序)。"""
|
||||
"""查询触发记录 (时间倒序)。
|
||||
|
||||
ext_columns: 逗号分隔的 "configId.fieldName", 传入后按 symbol 富化行业/概念等 ext 字段,
|
||||
每条记录附带 {configId}__{fieldName} 键 (与 watchlist/screener 一致)。
|
||||
"""
|
||||
events = alert_store.list_recent(
|
||||
_data_dir(request), days=days, limit=limit, source=source, type=type,
|
||||
)
|
||||
if ext_columns and events:
|
||||
try:
|
||||
from app.api.screener import _load_ext_value_maps, _rows_with_ext
|
||||
repo = request.app.state.repo
|
||||
value_maps = _load_ext_value_maps(repo, ext_columns)
|
||||
if value_maps:
|
||||
events = _rows_with_ext(events, value_maps)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
total = alert_store.count(_data_dir(request))
|
||||
return {"alerts": events, "total": total}
|
||||
|
||||
|
||||
@@ -430,6 +430,7 @@ def get_preferences() -> dict:
|
||||
"sidebar_index_symbols": preferences.get_sidebar_index_symbols(),
|
||||
"minute_intraday_refresh": preferences.get_minute_intraday_refresh(),
|
||||
"minute_intraday_refresh_interval": preferences.get_minute_intraday_refresh_interval(),
|
||||
"monitor_ext_fields": preferences.get_monitor_ext_fields(),
|
||||
"nav_order": preferences.get_nav_order(),
|
||||
"nav_hidden": preferences.get_nav_hidden(),
|
||||
"screener_auto_run": preferences.get_screener_auto_run(),
|
||||
@@ -756,6 +757,7 @@ class RealtimeMonitorConfigIn(BaseModel):
|
||||
screener_auto_run: bool | None = None
|
||||
minute_intraday_refresh: bool | None = None
|
||||
minute_intraday_refresh_interval: int | None = None
|
||||
monitor_ext_fields: dict | None = None
|
||||
|
||||
|
||||
@router.put("/preferences/realtime-monitor")
|
||||
|
||||
@@ -119,6 +119,56 @@ def get_minute_intraday_refresh_interval() -> int:
|
||||
int(load().get("minute_intraday_refresh_interval", 6))))
|
||||
|
||||
|
||||
# 监控中心个股通知 ext 字段默认配置 (与 ext_presets 内置预设对齐)
|
||||
_MONITOR_EXT_FIELDS_DEFAULT = {
|
||||
"concept": "ext_gn_ths.所属概念",
|
||||
"industry": "ext_hy_ths.所属同话顺行业",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_ext_field(raw) -> dict | None:
|
||||
"""规范化单个 ext 字段配置, 兼容旧字符串格式 ("id.field") 和新对象格式。
|
||||
|
||||
新格式: {"field": "id.field", "maxTags": N, "hiddenIndices": [...]}
|
||||
maxTags=0 或缺省=不限制; hiddenIndices 指定要隐藏的位置 (0-based)。
|
||||
"""
|
||||
if raw is None:
|
||||
return None
|
||||
# 旧格式: 纯字符串 "configId.fieldName"
|
||||
if isinstance(raw, str):
|
||||
return {"field": raw}
|
||||
if isinstance(raw, dict):
|
||||
field = raw.get("field")
|
||||
if not field:
|
||||
return None
|
||||
return {
|
||||
"field": field,
|
||||
"maxTags": int(raw["maxTags"]) if raw.get("maxTags") else 0,
|
||||
"hiddenIndices": [int(i) for i in raw["hiddenIndices"]] if raw.get("hiddenIndices") else [],
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def get_monitor_ext_fields() -> dict:
|
||||
"""监控中心个股通知要展示的 ext 字段 (concept/industry)。
|
||||
|
||||
返回 {"concept": {"field", "maxTags", "hiddenIndices"} | None, ...}。
|
||||
后端只需读 .field 构建 ext_columns; maxTags/hiddenIndices 供前端渲染裁剪。
|
||||
兼容旧字符串格式 ("id.field") 自动升级。
|
||||
"""
|
||||
data = load()
|
||||
raw = data.get("monitor_ext_fields")
|
||||
if raw is None:
|
||||
return {
|
||||
"concept": {"field": _MONITOR_EXT_FIELDS_DEFAULT["concept"]},
|
||||
"industry": {"field": _MONITOR_EXT_FIELDS_DEFAULT["industry"]},
|
||||
}
|
||||
return {
|
||||
"concept": _normalize_ext_field(raw.get("concept")),
|
||||
"industry": _normalize_ext_field(raw.get("industry")),
|
||||
}
|
||||
|
||||
|
||||
def get_minute_sync_days() -> int:
|
||||
return max(1, min(30, load().get("minute_sync_days", 5)))
|
||||
|
||||
@@ -645,6 +695,12 @@ def set_realtime_monitor_config(cfg: dict) -> dict:
|
||||
updates["minute_intraday_refresh_interval"] = max(
|
||||
_INTRADAY_REFRESH_INTERVAL_MIN,
|
||||
min(_INTRADAY_REFRESH_INTERVAL_MAX, int(cfg["minute_intraday_refresh_interval"])))
|
||||
if "monitor_ext_fields" in cfg:
|
||||
raw = cfg["monitor_ext_fields"] or {}
|
||||
updates["monitor_ext_fields"] = {
|
||||
"concept": _normalize_ext_field(raw.get("concept")),
|
||||
"industry": _normalize_ext_field(raw.get("industry")),
|
||||
}
|
||||
if updates:
|
||||
save(updates)
|
||||
return get_realtime_monitor_config()
|
||||
@@ -660,6 +716,7 @@ def get_realtime_monitor_config() -> dict:
|
||||
"screener_auto_run": get_screener_auto_run(),
|
||||
"minute_intraday_refresh": get_minute_intraday_refresh(),
|
||||
"minute_intraday_refresh_interval": get_minute_intraday_refresh_interval(),
|
||||
"monitor_ext_fields": get_monitor_ext_fields(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1058,6 +1058,8 @@ class QuoteService:
|
||||
|
||||
# 广播到所有 SSE 订阅者 (背压保护在订阅者队列内做)
|
||||
if all_alerts:
|
||||
# 按 symbol 富化行业/概念 ext 字段, 使 toast + 触发记录统一展示板块标签。
|
||||
self._enrich_alerts_ext(all_alerts)
|
||||
self._broadcast_alerts(all_alerts)
|
||||
logger.info("监控评估完成: %d 条通知", len(all_alerts))
|
||||
|
||||
@@ -1073,6 +1075,42 @@ class QuoteService:
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("监控评估失败: %s", e)
|
||||
|
||||
def _enrich_alerts_ext(self, alerts: list[dict]) -> None:
|
||||
"""就地给告警事件按 symbol 追加行业/概念 ext 字段。
|
||||
|
||||
读 preferences.get_monitor_ext_fields() 取字段配置, 用 screener._load_ext_value_maps
|
||||
(带 parquet mtime 缓存) 富化。富化失败静默降级 (告警照常推送, 只是没标签)。
|
||||
每条事件新增 {configId}__{fieldName} 键 (与 watchlist/screener 输出约定一致)。
|
||||
"""
|
||||
if not alerts or not self._app_state or self._repo is None:
|
||||
return
|
||||
try:
|
||||
from app.services import preferences
|
||||
fields = preferences.get_monitor_ext_fields()
|
||||
# 新结构 {field, maxTags, hiddenIndices}, 后端只需 .field
|
||||
parts = []
|
||||
for key in ("concept", "industry"):
|
||||
item = fields.get(key)
|
||||
if isinstance(item, dict) and item.get("field"):
|
||||
parts.append(item["field"])
|
||||
elif isinstance(item, str) and item:
|
||||
parts.append(item) # 兼容旧格式
|
||||
if not parts:
|
||||
return
|
||||
ext_columns = ",".join(parts)
|
||||
from app.api.screener import _load_ext_value_maps
|
||||
value_maps = _load_ext_value_maps(self._repo, ext_columns)
|
||||
if not value_maps:
|
||||
return
|
||||
for ev in alerts:
|
||||
sym = ev.get("symbol")
|
||||
if not sym:
|
||||
continue
|
||||
for out_col, vmap in value_maps.items():
|
||||
ev[out_col] = vmap.get(str(sym))
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("告警 ext 富化失败 (不影响推送): %s", e)
|
||||
|
||||
def _inject_sealed_vol(self, enriched_today: pl.DataFrame, enriched_date) -> pl.DataFrame:
|
||||
"""从 depth_service 取封单量, 作为临时列 _sealed_vol 注入 enriched 副本。
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { fmtPct, fmtPrice } from '@/lib/format'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { playNotificationSound } from '@/lib/notificationSound'
|
||||
import { speakAlerts } from '@/lib/voiceBroadcast'
|
||||
import { usePreferences } from '@/lib/useSharedQueries'
|
||||
|
||||
/** 通知渠道分发 — 所有副作用渠道在此汇合, 新增渠道只改这里 */
|
||||
function dispatchSideEffects(alerts: AlertEvent[]) {
|
||||
@@ -93,6 +94,11 @@ const SOURCE_BADGE: Record<string, { label: string; cls: string }> = {
|
||||
export function AlertToastContainer() {
|
||||
const [items, setItems] = useState<Item[]>([])
|
||||
const navigate = useNavigate()
|
||||
const { data: prefs } = usePreferences()
|
||||
const extFields = prefs?.monitor_ext_fields ?? {
|
||||
concept: { field: 'ext_gn_ths.所属概念' },
|
||||
industry: { field: 'ext_hy_ths.所属同花顺行业' },
|
||||
}
|
||||
|
||||
const sub = useCallback(() => {
|
||||
_listeners.add(setItems)
|
||||
@@ -188,6 +194,33 @@ export function AlertToastContainer() {
|
||||
{ev.message && <span className="text-[11px] text-foreground/70 truncate flex-1">{ev.message}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 行业/概念标签 (后端 SSE 推送时已富化, 字段配置来自监控中心全局设置) */}
|
||||
{(() => {
|
||||
const tags: { text: string; cls: string }[] = []
|
||||
for (const [isIndustry, item] of [[true, extFields.industry], [false, extFields.concept]] as const) {
|
||||
if (!item?.field) continue
|
||||
const key = item.field.replace('.', '__')
|
||||
const v = (ev as Record<string, unknown>)[key]
|
||||
if (v == null) continue
|
||||
let parts = String(v).split(/[、,,;;\-]/).map(s => s.trim()).filter(Boolean)
|
||||
const mt = item.maxTags ?? 0
|
||||
if (mt > 0) parts = parts.slice(0, mt)
|
||||
const hi = item.hiddenIndices
|
||||
if (hi?.length) parts = parts.filter((_, i) => !hi.includes(i))
|
||||
for (const t of parts) {
|
||||
tags.push({ text: t, cls: isIndustry ? 'bg-sky-500/10 text-sky-400' : 'bg-orange-500/10 text-orange-400' })
|
||||
}
|
||||
}
|
||||
if (!tags.length) return null
|
||||
return (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1 pl-0.5">
|
||||
{tags.map((t, i) => (
|
||||
<span key={i} className={cn('rounded px-1 py-px text-[9px] leading-tight', t.cls)}>{t.text}</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
|
||||
+19
-1
@@ -507,6 +507,8 @@ export interface AlertEvent {
|
||||
strategy_id?: string
|
||||
conditions?: MonitorCondition[]
|
||||
logic?: 'and' | 'or'
|
||||
/** ext 富化字段 (行业/概念等), 键为 "{configId}__{fieldName}" */
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 生成监控规则 id (时间戳 + 随机后缀), 用户无需手动填写。 */
|
||||
@@ -847,6 +849,17 @@ export interface Preferences {
|
||||
screener_auto_run: boolean
|
||||
minute_intraday_refresh: boolean
|
||||
minute_intraday_refresh_interval: number
|
||||
monitor_ext_fields: { concept: MonitorExtFieldItem | null; industry: MonitorExtFieldItem | null }
|
||||
}
|
||||
|
||||
/** 监控中心 ext 字段单项配置 (行业/概念标签的来源 + 显示裁剪) */
|
||||
export interface MonitorExtFieldItem {
|
||||
/** "configId.fieldName" */
|
||||
field: string
|
||||
/** 显示前N个标签, 0=不限制 */
|
||||
maxTags?: number
|
||||
/** 隐藏的位置 (0-based), 如 [0] 表示隐藏第一个 */
|
||||
hiddenIndices?: number[]
|
||||
}
|
||||
export interface StrategyAlertEvent {
|
||||
source: 'strategy' | 'depth'
|
||||
@@ -858,6 +871,8 @@ export interface StrategyAlertEvent {
|
||||
price?: number | null
|
||||
change_pct?: number | null
|
||||
signals?: string[]
|
||||
/** ext 富化字段 (行业/概念等), 键为 "{configId}__{fieldName}" */
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// ===== API surface =====
|
||||
@@ -1026,6 +1041,7 @@ export const api = {
|
||||
screener_auto_run?: boolean
|
||||
minute_intraday_refresh?: boolean
|
||||
minute_intraday_refresh_interval?: number
|
||||
monitor_ext_fields?: { concept: MonitorExtFieldItem | null; industry: MonitorExtFieldItem | null }
|
||||
}) =>
|
||||
request<{
|
||||
sse_refresh_pages: Record<string, boolean>
|
||||
@@ -1035,6 +1051,7 @@ export const api = {
|
||||
screener_auto_run: boolean
|
||||
minute_intraday_refresh: boolean
|
||||
minute_intraday_refresh_interval: number
|
||||
monitor_ext_fields: { concept: MonitorExtFieldItem | null; industry: MonitorExtFieldItem | null }
|
||||
}>('/api/settings/preferences/realtime-monitor', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(cfg),
|
||||
@@ -1947,12 +1964,13 @@ export const api = {
|
||||
request<{ ok: boolean; generated: number }>('/api/monitor-rules/seed', { method: 'POST' }),
|
||||
|
||||
// ===== Alerts (触发记录) =====
|
||||
alertsList: (params?: { days?: number; limit?: number; source?: string; type?: string }) => {
|
||||
alertsList: (params?: { days?: number; limit?: number; source?: string; type?: string; extColumns?: string }) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (params?.days) qs.set('days', String(params.days))
|
||||
if (params?.limit) qs.set('limit', String(params.limit))
|
||||
if (params?.source) qs.set('source', params.source)
|
||||
if (params?.type) qs.set('type', params.type)
|
||||
if (params?.extColumns) qs.set('ext_columns', params.extColumns)
|
||||
const s = qs.toString()
|
||||
return request<{ alerts: AlertEvent[]; total: number }>(`/api/alerts${s ? `?${s}` : ''}`)
|
||||
},
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { RadioTower, Plus, Trash2, Settings2, Zap, Bell, ListChecks, BellRing, TrendingUp, TrendingDown, Flame } from 'lucide-react'
|
||||
import { RadioTower, Plus, Trash2, Settings2, Zap, Bell, ListChecks, BellRing, TrendingUp, TrendingDown, Flame, Tags } from 'lucide-react'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import { Skeleton } from '@/components/data/Skeleton'
|
||||
import { api, type MonitorRule, type AlertEvent, type MonitorCondition } from '@/lib/api'
|
||||
import { api, type MonitorRule, type AlertEvent, type MonitorCondition, type MonitorExtFieldItem } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { fmtPrice, fmtPct } from '@/lib/format'
|
||||
import { cn } from '@/lib/cn'
|
||||
@@ -14,6 +14,7 @@ import { boardTag } from '@/components/stock-table/primitives'
|
||||
import { markSeen, resetBadge, leaveMonitorPage } from '@/lib/monitorBadge'
|
||||
import { RuleEditor } from '@/components/monitor/RuleEditor'
|
||||
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
|
||||
import { usePreferences } from '@/lib/useSharedQueries'
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
signal: '个股信号', price: '价格/涨跌', market: '市场异动', strategy: '策略监控',
|
||||
@@ -53,6 +54,44 @@ function renderMessage(source: string, message: string) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从事件行中取出 ext 字段标签 (行业/概念), 按 item 配置裁剪 (maxTags/hiddenIndices)。
|
||||
*/
|
||||
function getExtTags(ev: Record<string, unknown>, item: MonitorExtFieldItem | null): string[] {
|
||||
if (!item?.field) return []
|
||||
const key = item.field.replace('.', '__')
|
||||
const v = ev[key]
|
||||
if (v == null) return []
|
||||
const str = String(v)
|
||||
if (!str) return []
|
||||
let tags = str.split(/[、,,;;\-]/).map(s => s.trim()).filter(Boolean)
|
||||
const maxTags = item.maxTags ?? 0
|
||||
if (maxTags > 0) tags = tags.slice(0, maxTags)
|
||||
const hidden = item.hiddenIndices
|
||||
if (hidden?.length) tags = tags.filter((_, i) => !hidden.includes(i))
|
||||
return tags
|
||||
}
|
||||
|
||||
/** 个股通知的 ext 标签行 (行业/概念), 无数据返回 null */
|
||||
function AlertExtTags({ ev, fields }: {
|
||||
ev: Record<string, unknown>
|
||||
fields: { concept: MonitorExtFieldItem | null; industry: MonitorExtFieldItem | null }
|
||||
}) {
|
||||
const conceptTags = getExtTags(ev, fields.concept)
|
||||
const industryTags = getExtTags(ev, fields.industry)
|
||||
if (conceptTags.length === 0 && industryTags.length === 0) return null
|
||||
return (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1 pl-0.5">
|
||||
{industryTags.map((t, i) => (
|
||||
<span key={`i${i}`} className="rounded bg-sky-500/10 px-1 py-px text-[9px] text-sky-400 leading-tight">{t}</span>
|
||||
))}
|
||||
{conceptTags.map((t, i) => (
|
||||
<span key={`c${i}`} className="rounded bg-orange-500/10 px-1 py-px text-[9px] text-orange-400 leading-tight">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Monitor() {
|
||||
const qc = useQueryClient()
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
@@ -62,9 +101,22 @@ export function Monitor() {
|
||||
const [filter, setFilter] = useState<'all' | 'strategy' | 'signal' | 'price' | 'market'>('all')
|
||||
const [confirmClear, setConfirmClear] = useState(false)
|
||||
const [confirmClearRules, setConfirmClearRules] = useState(false)
|
||||
|
||||
// 全局 ext 字段配置 (监控中心个股通知带行业/概念标签)
|
||||
const { data: prefs } = usePreferences()
|
||||
const monitorExtFields = prefs?.monitor_ext_fields ?? {
|
||||
concept: { field: 'ext_gn_ths.所属概念' },
|
||||
industry: { field: 'ext_hy_ths.所属同花顺行业' },
|
||||
}
|
||||
const [extConfigOpen, setExtConfigOpen] = useState(false)
|
||||
const extColumnsParam = useMemo(() => {
|
||||
const parts = [monitorExtFields.concept?.field, monitorExtFields.industry?.field].filter(Boolean) as string[]
|
||||
return parts.length > 0 ? parts.join(',') : undefined
|
||||
}, [monitorExtFields])
|
||||
|
||||
const alertsQuery = useQuery({
|
||||
queryKey: QK.alerts(filter === 'all' ? undefined : filter),
|
||||
queryFn: () => api.alertsList({ days: 7, limit: 500, source: filter === 'all' ? undefined : filter }),
|
||||
queryKey: [...QK.alerts(filter === 'all' ? undefined : filter), extColumnsParam ?? ''],
|
||||
queryFn: () => api.alertsList({ days: 7, limit: 500, source: filter === 'all' ? undefined : filter, extColumns: extColumnsParam }),
|
||||
refetchInterval: 10000,
|
||||
refetchIntervalInBackground: true,
|
||||
})
|
||||
@@ -120,8 +172,18 @@ export function Monitor() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* 数量 + 清空 */}
|
||||
{/* 数量 + 清空 + 字段配置 */}
|
||||
<div className="ml-auto flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => setExtConfigOpen(true)}
|
||||
title="配置行业/概念标签"
|
||||
className={cn(
|
||||
'inline-flex h-6 w-6 items-center justify-center rounded-lg border transition-all cursor-pointer',
|
||||
extConfigOpen ? 'border-accent/40 text-accent' : 'border-border/60 bg-surface text-muted hover:border-accent/40 hover:text-accent',
|
||||
)}
|
||||
>
|
||||
<Tags className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<span className="rounded-md bg-elevated/50 px-1.5 py-0.5 text-[10px] font-medium text-muted">{total}</span>
|
||||
{total > 0 && (
|
||||
<button
|
||||
@@ -134,7 +196,7 @@ export function Monitor() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-3.5">
|
||||
<AlertsList alertsQuery={alertsQuery} confirmClear={confirmClear} setConfirmClear={setConfirmClear} total={total} enterTs={enterTsRef.current} />
|
||||
<AlertsList alertsQuery={alertsQuery} confirmClear={confirmClear} setConfirmClear={setConfirmClear} total={total} enterTs={enterTsRef.current} monitorExtFields={monitorExtFields} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -187,6 +249,12 @@ export function Monitor() {
|
||||
onConfirm={() => clearRulesMut.mutate()}
|
||||
pending={clearRulesMut.isPending}
|
||||
/>
|
||||
|
||||
<MonitorExtConfigDialog
|
||||
open={extConfigOpen}
|
||||
fields={monitorExtFields}
|
||||
onClose={() => setExtConfigOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -201,12 +269,13 @@ function SectionHeader({ icon: Icon, title }: { icon: any; title: string }) {
|
||||
}
|
||||
|
||||
// ── 触发记录列表 ──────────────────────────────────────
|
||||
function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs }: {
|
||||
function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs, monitorExtFields }: {
|
||||
alertsQuery: ReturnType<typeof useQuery>
|
||||
confirmClear: boolean
|
||||
setConfirmClear: (v: boolean) => void
|
||||
total: number
|
||||
enterTs: number
|
||||
monitorExtFields: { concept: MonitorExtFieldItem | null; industry: MonitorExtFieldItem | null }
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const [confirmTs, setConfirmTs] = useState<number | null>(null)
|
||||
@@ -410,6 +479,7 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<AlertExtTags ev={ev} fields={monitorExtFields} />
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<span className="text-[10px] text-muted/60 font-mono">
|
||||
@@ -733,3 +803,152 @@ function ConfirmDialog({ open, title, message, confirmText, danger, pending, onC
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
/** 监控中心 ext 字段配置弹窗: 选概念/行业字段, 保存到 preferences.monitor_ext_fields */
|
||||
function MonitorExtConfigDialog({ open, fields, onClose }: {
|
||||
open: boolean
|
||||
fields: { concept: MonitorExtFieldItem | null; industry: MonitorExtFieldItem | null }
|
||||
onClose: () => void
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const [concept, setConcept] = useState<MonitorExtFieldItem | null>(fields.concept)
|
||||
const [industry, setIndustry] = useState<MonitorExtFieldItem | null>(fields.industry)
|
||||
useEffect(() => { setConcept(fields.concept); setIndustry(fields.industry) }, [fields.concept, fields.industry])
|
||||
|
||||
const schema = useQuery({
|
||||
queryKey: QK.extDataSchemaAll,
|
||||
queryFn: api.extDataSchemaAll,
|
||||
enabled: open,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
// 下拉选项: 按扩展表分组 → [{ group: 表名, options: [{value, label}] }]
|
||||
const groups = useMemo(() => {
|
||||
return (schema.data?.items ?? []).map(tbl => ({
|
||||
group: tbl.label || tbl.id,
|
||||
options: tbl.columns.map(col => ({
|
||||
value: `${tbl.id}.${col.name}`,
|
||||
label: col.label || col.name,
|
||||
})),
|
||||
}))
|
||||
}, [schema.data])
|
||||
|
||||
const handleSave = async () => {
|
||||
await api.updateRealtimeMonitorConfig({ monitor_ext_fields: { concept, industry } })
|
||||
qc.invalidateQueries({ queryKey: QK.preferences })
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="w-full max-w-md rounded-2xl border border-border bg-surface p-5 shadow-2xl max-h-[85vh] overflow-y-auto"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Tags className="h-4 w-4 text-accent" />
|
||||
<h3 className="text-sm font-medium text-foreground">个股通知标签配置</h3>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted mb-4">选择在触发记录和推送通知中显示的行业/概念字段,留空则不显示。</p>
|
||||
<div className="space-y-4">
|
||||
<ExtFieldSection label="行业字段" value={industry} onChange={setIndustry} groups={groups} loading={schema.isLoading} />
|
||||
<ExtFieldSection label="概念字段" value={concept} onChange={setConcept} groups={groups} loading={schema.isLoading} />
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button onClick={onClose} className="px-3 py-1.5 rounded-btn text-xs text-secondary hover:text-foreground transition-colors cursor-pointer">取消</button>
|
||||
<button onClick={handleSave} className="px-3 py-1.5 rounded-btn text-xs font-medium bg-accent text-base cursor-pointer">保存</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
/** 单个 ext 字段配置区: 字段下拉 + 显示前N个 + 隐藏指定位置 */
|
||||
function ExtFieldSection({ label, value, onChange, groups, loading }: {
|
||||
label: string
|
||||
value: MonitorExtFieldItem | null
|
||||
onChange: (v: MonitorExtFieldItem | null) => void
|
||||
groups: { group: string; options: { value: string; label: string }[] }[]
|
||||
loading: boolean
|
||||
}) {
|
||||
const field = value?.field ?? ''
|
||||
const maxTags = value?.maxTags ?? 0
|
||||
const hidden = value?.hiddenIndices ?? []
|
||||
|
||||
// 选/换字段时, 保留已有 maxTags/hiddenIndices 配置
|
||||
const pickField = (f: string | null) => {
|
||||
onChange(f ? { field: f, maxTags: value?.maxTags, hiddenIndices: value?.hiddenIndices } : null)
|
||||
}
|
||||
const setMaxTags = (n: number) => {
|
||||
onChange({ field, maxTags: n, hiddenIndices: n > 0 ? hidden.filter(i => i < n) : undefined })
|
||||
}
|
||||
const toggleHidden = (i: number) => {
|
||||
const next = hidden.includes(i) ? hidden.filter(x => x !== i) : [...hidden, i]
|
||||
onChange({ field, maxTags, hiddenIndices: next.length ? next : undefined })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-secondary block">{label}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={field}
|
||||
onChange={e => pickField(e.target.value || null)}
|
||||
disabled={loading}
|
||||
className="flex-1 min-w-0 h-8 bg-elevated border border-border rounded text-xs text-foreground px-2 focus:outline-none focus:border-accent/50"
|
||||
>
|
||||
<option value="">不显示</option>
|
||||
{groups.map(g => (
|
||||
<optgroup key={g.group} label={g.group}>
|
||||
{g.options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
{field && (
|
||||
<button onClick={() => onChange(null)} title="清除" className="shrink-0 p-1 rounded text-muted hover:text-danger transition-colors cursor-pointer">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{field && (
|
||||
<div className="flex items-center gap-2 pl-0.5">
|
||||
<span className="text-[10px] text-muted shrink-0">显示前N个</span>
|
||||
<input
|
||||
type="number" min={0} max={20}
|
||||
value={maxTags || ''}
|
||||
onChange={e => setMaxTags(e.target.value ? Number(e.target.value) : 0)}
|
||||
placeholder="不限"
|
||||
className="w-14 h-6 bg-elevated border border-border rounded text-[11px] text-foreground px-1.5 focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
<span className="text-[10px] text-muted/60">留空=全部</span>
|
||||
</div>
|
||||
)}
|
||||
{field && maxTags > 0 && (
|
||||
<div className="flex items-center gap-2 pl-0.5">
|
||||
<span className="text-[10px] text-muted shrink-0">隐藏位置</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Array.from({ length: maxTags }, (_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => toggleHidden(i)}
|
||||
className={`w-5 h-5 rounded text-[10px] font-medium transition-colors cursor-pointer ${
|
||||
hidden.includes(i) ? 'bg-elevated text-muted line-through' : 'bg-accent/15 text-accent'
|
||||
}`}
|
||||
>{i + 1}</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted/60">点数字划掉=隐藏该位置</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user