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:
wshy
2026-07-14 18:49:23 +08:00
committed by GitHub
co-authored by shy3130
parent 002fb9387e
commit 70a4aad010
7 changed files with 390 additions and 9 deletions
+15 -1
View File
@@ -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}
+2
View File
@@ -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")
+57
View File
@@ -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(),
}
+38
View File
@@ -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 副本。
+33
View File
@@ -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
View File
@@ -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}` : ''}`)
},
+226 -7
View File
@@ -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>
)
}