diff --git a/web_dashboard/backend/tests/test_frontend_contract_view_audit.py b/web_dashboard/backend/tests/test_frontend_contract_view_audit.py new file mode 100644 index 00000000..3bd3daa7 --- /dev/null +++ b/web_dashboard/backend/tests/test_frontend_contract_view_audit.py @@ -0,0 +1,34 @@ +from pathlib import Path +import re + + +FRONTEND_SRC = Path(__file__).resolve().parents[2] / "frontend" / "src" +CONTRACT_VIEW = FRONTEND_SRC / "utils" / "contractView.js" +LEGACY_TOP_LEVEL_FIELDS = ("decision", "confidence", "quant_signal", "llm_signal") +DIRECT_FIELD_ACCESS = re.compile(r"(?:\?|)\.\s*(decision|confidence|quant_signal|llm_signal)\b") + + +def test_contract_view_reads_contract_result_before_compat_fields(): + source = CONTRACT_VIEW.read_text() + + assert "getResult(payload).decision ?? getCompat(payload).decision" in source + assert "getResult(payload).confidence ?? getCompat(payload).confidence" in source + assert "getResult(payload).signals?.quant?.rating ?? getCompat(payload).quant_signal" in source + assert "getResult(payload).signals?.llm?.rating ?? getCompat(payload).llm_signal" in source + + +def test_frontend_consumers_use_contract_view_helpers_for_signal_fields(): + offenders: list[str] = [] + + for path in sorted(FRONTEND_SRC.rglob("*.js")) + sorted(FRONTEND_SRC.rglob("*.jsx")): + if path == CONTRACT_VIEW: + continue + matches = { + match.group(1) + for match in DIRECT_FIELD_ACCESS.finditer(path.read_text()) + if match.group(1) in LEGACY_TOP_LEVEL_FIELDS + } + if matches: + offenders.append(f"{path.relative_to(FRONTEND_SRC)} -> {sorted(matches)}") + + assert offenders == [] diff --git a/web_dashboard/frontend/src/components/ContractCues.jsx b/web_dashboard/frontend/src/components/ContractCues.jsx new file mode 100644 index 00000000..bfa973e1 --- /dev/null +++ b/web_dashboard/frontend/src/components/ContractCues.jsx @@ -0,0 +1,51 @@ +import { + getDataQualitySummary, + getDegradationSummary, + isDegradedPayload, +} from '../utils/contractView' + +const cueStyle = { + display: 'inline-flex', + alignItems: 'center', + padding: '2px 8px', + borderRadius: 'var(--radius-pill)', + background: 'var(--hold-dim)', + color: 'var(--hold)', + fontSize: 11, + fontWeight: 600, + lineHeight: 1.4, +} + +function formatCode(code) { + return String(code).replace(/_/g, ' ') +} + +export default function ContractCues({ payload, style = null }) { + const dataQuality = getDataQualitySummary(payload) + const degradation = getDegradationSummary(payload) + const primaryReason = degradation?.reason_codes?.[0] || null + const dataQualityState = dataQuality?.state || null + const items = [] + + if (isDegradedPayload(payload)) { + items.push(primaryReason && primaryReason !== dataQualityState + ? `降级 · ${formatCode(primaryReason)}` + : '降级结果') + } + + if (dataQualityState) { + items.push(`数据 · ${formatCode(dataQualityState)}`) + } + + if (items.length === 0) return null + + return ( +
+ {items.map((item) => ( + + {item} + + ))} +
+ ) +} diff --git a/web_dashboard/frontend/src/pages/AnalysisMonitor.jsx b/web_dashboard/frontend/src/pages/AnalysisMonitor.jsx index 0487a28a..0850235f 100644 --- a/web_dashboard/frontend/src/pages/AnalysisMonitor.jsx +++ b/web_dashboard/frontend/src/pages/AnalysisMonitor.jsx @@ -2,11 +2,10 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { useSearchParams } from 'react-router-dom' import { Card, Progress, Badge, Empty, Button, Result, message } from 'antd' import DecisionBadge from '../components/DecisionBadge' +import ContractCues from '../components/ContractCues' import { StatusIcon } from '../components/StatusIcon' import { getConfidence, - getDataQualitySummary, - getDegradationSummary, getDecision, getDisplayDate, getErrorMessage, @@ -36,8 +35,6 @@ export default function AnalysisMonitor() { const quantSignal = getQuantSignal(task) const confidence = getConfidence(task) const displayDate = getDisplayDate(task) - const dataQuality = getDataQualitySummary(task) - const degradation = getDegradationSummary(task) const errorMessage = getErrorMessage(task) const fetchInitialState = useCallback(async () => { @@ -196,16 +193,7 @@ export default function AnalysisMonitor() { )} )} - {dataQuality?.state && ( -
- 数据质量: {dataQuality.state} -
- )} - {degradation?.degraded && degradation?.reason_codes?.length > 0 && ( -
- 降级原因: {degradation.reason_codes.join(', ')} -
- )} + {errorMessage && (
错误: {errorMessage} diff --git a/web_dashboard/frontend/src/pages/BatchManager.jsx b/web_dashboard/frontend/src/pages/BatchManager.jsx index 12d1b27d..fd87cd46 100644 --- a/web_dashboard/frontend/src/pages/BatchManager.jsx +++ b/web_dashboard/frontend/src/pages/BatchManager.jsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react' import { Table, Button, Progress, Result, Card, message, Popconfirm, Tooltip } from 'antd' import { DeleteOutlined, CopyOutlined, SyncOutlined } from '@ant-design/icons' +import ContractCues from '../components/ContractCues' import DecisionBadge from '../components/DecisionBadge' import { StatusIcon, StatusTag } from '../components/StatusIcon' import { getDecision, getErrorMessage } from '../utils/contractView' @@ -107,8 +108,13 @@ export default function BatchManager() { { title: '决策', key: 'decision', - width: 80, - render: (_, record) => , + width: 180, + render: (_, record) => ( +
+ + +
+ ), }, { title: '任务ID', diff --git a/web_dashboard/frontend/src/pages/PortfolioPanel.jsx b/web_dashboard/frontend/src/pages/PortfolioPanel.jsx index 08d49a9c..d522591c 100644 --- a/web_dashboard/frontend/src/pages/PortfolioPanel.jsx +++ b/web_dashboard/frontend/src/pages/PortfolioPanel.jsx @@ -8,6 +8,7 @@ import { DownloadOutlined, SyncOutlined, AccountBookOutlined, } from '@ant-design/icons' import { portfolioApi } from '../services/portfolioApi' +import ContractCues from '../components/ContractCues' import DecisionBadge from '../components/DecisionBadge' import { getDecision, getDisplayDate, isCompletedLikeStatus } from '../utils/contractView' @@ -378,8 +379,13 @@ function RecommendationsTab() { render: t => {t} }, { title: '名称', dataIndex: 'name', key: 'name', render: t => {t} }, { - title: '决策', key: 'decision', width: 80, - render: (_, record) => , + title: '决策', key: 'decision', width: 180, + render: (_, record) => ( +
+ + +
+ ), }, { title: '分析日期', key: 'analysis_date', width: 120, render: (_, record) => getDisplayDate(record) || '—' }, ]