diff --git a/backend/app/api/alerts.py b/backend/app/api/alerts.py index 87c1922..af8a5a9 100644 --- a/backend/app/api/alerts.py +++ b/backend/app/api/alerts.py @@ -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} diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 51d83fe..ab0a252 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -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") diff --git a/backend/app/services/preferences.py b/backend/app/services/preferences.py index bdfa222..80ce20e 100644 --- a/backend/app/services/preferences.py +++ b/backend/app/services/preferences.py @@ -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(), } diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py index e3c1b12..1b2aad7 100644 --- a/backend/app/services/quote_service.py +++ b/backend/app/services/quote_service.py @@ -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 副本。 diff --git a/frontend/src/components/AlertToast.tsx b/frontend/src/components/AlertToast.tsx index d37997d..3714207 100644 --- a/frontend/src/components/AlertToast.tsx +++ b/frontend/src/components/AlertToast.tsx @@ -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 = { export function AlertToastContainer() { const [items, setItems] = useState([]) 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 && {ev.message}} )} + + {/* 行业/概念标签 (后端 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)[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 ( +
+ {tags.map((t, i) => ( + {t.text} + ))} +
+ ) + })()} ) })} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7bfa885..4f992a1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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 @@ -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}` : ''}`) }, diff --git a/frontend/src/pages/Monitor.tsx b/frontend/src/pages/Monitor.tsx index 8221719..999ebfc 100644 --- a/frontend/src/pages/Monitor.tsx +++ b/frontend/src/pages/Monitor.tsx @@ -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 = { signal: '个股信号', price: '价格/涨跌', market: '市场异动', strategy: '策略监控', @@ -53,6 +54,44 @@ function renderMessage(source: string, message: string) { ) } +/** + * 从事件行中取出 ext 字段标签 (行业/概念), 按 item 配置裁剪 (maxTags/hiddenIndices)。 + */ +function getExtTags(ev: Record, 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 + 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 ( +
+ {industryTags.map((t, i) => ( + {t} + ))} + {conceptTags.map((t, i) => ( + {t} + ))} +
+ ) +} + 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() { ))} - {/* 数量 + 清空 */} + {/* 数量 + 清空 + 字段配置 */}
+ {total} {total > 0 && (
- +
@@ -187,6 +249,12 @@ export function Monitor() { onConfirm={() => clearRulesMut.mutate()} pending={clearRulesMut.isPending} /> + + setExtConfigOpen(false)} + /> ) } @@ -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 confirmClear: boolean setConfirmClear: (v: boolean) => void total: number enterTs: number + monitorExtFields: { concept: MonitorExtFieldItem | null; industry: MonitorExtFieldItem | null } }) { const qc = useQueryClient() const [confirmTs, setConfirmTs] = useState(null) @@ -410,6 +479,7 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs )} )} +
@@ -733,3 +803,152 @@ function ConfirmDialog({ open, title, message, confirmText, danger, pending, onC ) } + +/** 监控中心 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(fields.concept) + const [industry, setIndustry] = useState(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 ( + + {open && ( + + e.stopPropagation()} + > +
+ +

个股通知标签配置

+
+

选择在触发记录和推送通知中显示的行业/概念字段,留空则不显示。

+
+ + +
+
+ + +
+
+
+ )} +
+ ) +} + +/** 单个 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 ( +
+ +
+ + {field && ( + + )} +
+ {field && ( +
+ 显示前N个 + 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" + /> + 留空=全部 +
+ )} + {field && maxTags > 0 && ( +
+ 隐藏位置 +
+ {Array.from({ length: maxTags }, (_, i) => ( + + ))} +
+ 点数字划掉=隐藏该位置 +
+ )} +
+ ) +}