diff --git a/apps/dsa-web/src/components/decision-signals/DecisionSignalDisplay.tsx b/apps/dsa-web/src/components/decision-signals/DecisionSignalDisplay.tsx index c4aa1c677..3d0fec2c0 100644 --- a/apps/dsa-web/src/components/decision-signals/DecisionSignalDisplay.tsx +++ b/apps/dsa-web/src/components/decision-signals/DecisionSignalDisplay.tsx @@ -1,4 +1,5 @@ import type React from 'react'; +import { PanelRightOpen } from 'lucide-react'; import { Badge, Card, JsonViewer } from '../common'; import { useUiLanguage } from '../../contexts/UiLanguageContext'; import type { UiLanguage, UiTextKey } from '../../i18n/uiText'; @@ -82,6 +83,12 @@ function formatPercent(value: number | null | undefined): string { return number === '-' ? number : `${number}%`; } +function formatConfidence(value: number | null | undefined): string { + if (value === null || value === undefined || Number.isNaN(value)) return '-'; + const normalized = Math.abs(value) <= 1 ? value * 100 : value; + return `${formatNumber(normalized)}%`; +} + function formatEntryRange(item: DecisionSignalItem): string { const hasLow = item.entryLow !== null && item.entryLow !== undefined; const hasHigh = item.entryHigh !== null && item.entryHigh !== undefined; @@ -138,6 +145,61 @@ function getFeedbackLabel(value: DecisionSignalFeedbackValue | null | undefined, return t(key); } +function hasDisplayValue(value: string): boolean { + return value !== '-'; +} + +type SignalMetricTone = 'default' | 'success' | 'warning' | 'danger'; + +const metricToneClass: Record = { + default: 'text-foreground', + success: 'text-success', + warning: 'text-warning', + danger: 'text-danger', +}; + +type SignalMetricProps = { + label: string; + value: string; + tone?: SignalMetricTone; +}; + +const SignalMetric: React.FC = ({ label, value, tone = 'default' }) => ( +
+

{label}

+

{value}

+
+); + +type SignalTextTone = 'default' | 'warning' | 'danger' | 'info'; + +const textToneClass: Record = { + default: 'border-border/55 bg-elevated/35 text-secondary-text', + warning: 'border-warning/25 bg-warning/10 text-warning', + danger: 'border-danger/25 bg-danger/10 text-danger', + info: 'border-cyan/25 bg-cyan/10 text-cyan', +}; + +type SignalTextBlockProps = { + label: string; + value?: string | null; + tone?: SignalTextTone; + clamp?: boolean; +}; + +const SignalTextBlock: React.FC = ({ label, value, tone = 'default', clamp = true }) => { + const normalized = value?.trim(); + if (!normalized) return null; + return ( +
+

{label}

+

+ {normalized} +

+
+ ); +}; + type DecisionSignalCardProps = { item: DecisionSignalItem; onSelect?: (item: DecisionSignalItem) => void; @@ -148,8 +210,14 @@ export const DecisionSignalCard: React.FC = ({ item, on const { language, t } = useUiLanguage(); const actionLabel = getActionLabel(item, t); const interactive = Boolean(onSelect); + const entryRange = formatEntryRange(item); + const pricePlanItems = [ + { label: t('decisionSignals.entryRange'), value: entryRange, tone: 'default' as const }, + { label: t('decisionSignals.stopLoss'), value: formatNumber(item.stopLoss), tone: 'danger' as const }, + { label: t('decisionSignals.targetPrice'), value: formatNumber(item.targetPrice), tone: 'success' as const }, + ].filter((entry) => hasDisplayValue(entry.value)); const className = cn( - 'block w-full rounded-2xl border bg-card/70 p-4 text-left', + 'block w-full rounded-2xl border bg-card/75 p-4 text-left', interactive ? 'transition-colors hover:border-cyan/40 hover:bg-hover/70' : '', selected ? 'border-cyan/50 bg-cyan/10' : 'border-border/70', ); @@ -171,15 +239,41 @@ export const DecisionSignalCard: React.FC = ({ item, on
{formatDateTime(item.createdAt, language)}
-
- {item.reason ?

{item.reason}

: null} - {item.riskSummary ?

{item.riskSummary}

: null} - {item.watchConditions ?

{item.watchConditions}

: null} + +
+ + +
+ + {pricePlanItems.length > 0 ? ( +
+
+ {pricePlanItems.map((entry) => ( +
+

{entry.label}

+

+ {entry.value} +

+
+ ))} +
+
+ ) : null} + +
+ + + + + +
+
- {t('decisionSignals.horizon')}: {getDecisionSignalHorizonLabel(item.horizon, t)} {t('decisionSignals.planQuality')}: {getDecisionSignalPlanQualityLabel(item.planQuality, t)} {t('decisionSignals.marketPhase')}: {getDecisionSignalMarketPhaseLabel(item.marketPhase, t)} + {t('decisionSignals.expiresAt')}: {formatDateTime(item.expiresAt, language)} + {item.sourceReportId ? {t('decisionSignals.sourceReport')}: #{item.sourceReportId} : null}
); @@ -195,9 +289,10 @@ export const DecisionSignalCard: React.FC = ({ item, on
@@ -265,7 +360,7 @@ export const DecisionSignalDetails: React.FC = ({
- + @@ -282,6 +377,16 @@ export const DecisionSignalDetails: React.FC = ({
+ +
+ + + + + +
+
+ {outcomesLoading ? (

{t('common.loading')}...

@@ -351,14 +456,6 @@ export const DecisionSignalDetails: React.FC = ({
- -
- - - -
-
- {evidenceData ? ( diff --git a/apps/dsa-web/src/components/decision-signals/__tests__/DecisionSignalDisplay.test.tsx b/apps/dsa-web/src/components/decision-signals/__tests__/DecisionSignalDisplay.test.tsx index e026edcd8..0506cf7b8 100644 --- a/apps/dsa-web/src/components/decision-signals/__tests__/DecisionSignalDisplay.test.tsx +++ b/apps/dsa-web/src/components/decision-signals/__tests__/DecisionSignalDisplay.test.tsx @@ -52,10 +52,14 @@ describe('DecisionSignalCard', () => { renderCard(onSelect); expect(screen.getByText('贵州茅台').closest('button')).toBeNull(); + expect(screen.getByText('72%')).toBeInTheDocument(); + expect(screen.getByText('1600 - 1620')).toBeInTheDocument(); + expect(screen.getByText('业绩窗口')).toBeInTheDocument(); + expect(screen.getByText('跌破 1550')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '查看 贵州茅台 AI 建议详情' })); expect(onSelect).toHaveBeenCalledWith(signal); - expect(screen.getByText('周期: 3 日')).toBeInTheDocument(); + expect(screen.getByText('3 日')).toBeInTheDocument(); expect(screen.getByText('计划质量: 完整')).toBeInTheDocument(); expect(screen.getByText('阶段: 盘中')).toBeInTheDocument(); expect(screen.queryByText('3d')).not.toBeInTheDocument(); @@ -69,6 +73,39 @@ describe('DecisionSignalCard', () => { expect(screen.getByText('贵州茅台')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: '查看 贵州茅台 AI 建议详情' })).not.toBeInTheDocument(); }); + + it('hides missing optional plan text for sparse legacy signals', () => { + window.localStorage.setItem('dsa.uiLanguage', 'zh'); + render( + + + , + ); + + expect(screen.getByText('评分')).toBeInTheDocument(); + expect(screen.getByText('置信度')).toBeInTheDocument(); + expect(screen.getByText('周期')).toBeInTheDocument(); + expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(3); + expect(screen.queryByText('入场区间')).not.toBeInTheDocument(); + expect(screen.queryByText('止损')).not.toBeInTheDocument(); + expect(screen.queryByText('目标价')).not.toBeInTheDocument(); + expect(screen.queryByText('催化')).not.toBeInTheDocument(); + expect(screen.queryByText('失效条件')).not.toBeInTheDocument(); + }); }); describe('DecisionSignalDetails', () => { @@ -159,6 +196,10 @@ describe('DecisionSignalDetails', () => { expect(screen.getAllByText('3 日').length).toBeGreaterThan(1); expect(screen.getByText('命中')).toBeInTheDocument(); expect(screen.getByText('5%')).toBeInTheDocument(); + expect(screen.getByText('催化')).toBeInTheDocument(); + expect(screen.getByText('业绩窗口')).toBeInTheDocument(); + expect(screen.getByText('失效条件')).toBeInTheDocument(); + expect(screen.getByText('跌破 1550')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '无用' })); expect(onFeedbackSubmit).toHaveBeenCalledWith('not_useful'); }); diff --git a/apps/dsa-web/src/components/report/ReportDecisionSignals.tsx b/apps/dsa-web/src/components/report/ReportDecisionSignals.tsx index 5a0be4642..679a6b8ae 100644 --- a/apps/dsa-web/src/components/report/ReportDecisionSignals.tsx +++ b/apps/dsa-web/src/components/report/ReportDecisionSignals.tsx @@ -3,26 +3,66 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Activity } from 'lucide-react'; import { decisionSignalsApi } from '../../api/decisionSignals'; import { getParsedApiError, type ParsedApiError } from '../../api/error'; -import { ApiErrorAlert, Card, EmptyState } from '../common'; -import { DecisionSignalCard } from '../decision-signals/DecisionSignalDisplay'; +import { ApiErrorAlert, Card, Drawer, EmptyState } from '../common'; +import { + DecisionSignalCard, + DecisionSignalDetails, +} from '../decision-signals/DecisionSignalDisplay'; import { useUiLanguage } from '../../contexts/UiLanguageContext'; import type { ReportType } from '../../types/analysis'; -import type { DecisionSignalItem } from '../../types/decisionSignals'; +import type { + DecisionSignalFeedbackItem, + DecisionSignalItem, + DecisionSignalOutcomeItem, +} from '../../types/decisionSignals'; interface ReportDecisionSignalsProps { recordId?: number; reportType?: ReportType; } +type DetailSidecarState = { + signalId: number | null; + outcomes: { + items: DecisionSignalOutcomeItem[]; + loading: boolean; + error: ParsedApiError | null; + }; + feedback: { + item: DecisionSignalFeedbackItem | null; + loading: boolean; + error: ParsedApiError | null; + }; +}; + +function createDetailSidecarState(signalId: number | null = null, loading = false): DetailSidecarState { + return { + signalId, + outcomes: { + items: [], + loading, + error: null, + }, + feedback: { + item: null, + loading, + error: null, + }, + }; +} + export const ReportDecisionSignals: React.FC = ({ recordId, reportType, }) => { const { t } = useUiLanguage(); const [items, setItems] = useState([]); + const [selected, setSelected] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [detailSidecar, setDetailSidecar] = useState(() => createDetailSidecarState()); const requestIdRef = useRef(0); + const detailRequestIdRef = useRef(0); const shouldRender = Boolean(recordId) && reportType !== 'market_review'; const loadSignals = useCallback(async () => { @@ -31,6 +71,9 @@ export const ReportDecisionSignals: React.FC = ({ requestIdRef.current = requestId; setLoading(true); setItems([]); + setSelected(null); + detailRequestIdRef.current += 1; + setDetailSidecar(createDetailSidecarState()); setError(null); try { const response = await decisionSignalsApi.list({ @@ -58,6 +101,9 @@ export const ReportDecisionSignals: React.FC = ({ requestIdRef.current += 1; setLoading(false); setItems([]); + setSelected(null); + detailRequestIdRef.current += 1; + setDetailSidecar(createDetailSidecarState()); setError(null); return; } @@ -67,44 +113,170 @@ export const ReportDecisionSignals: React.FC = ({ }; }, [loadSignals, shouldRender]); + useEffect(() => { + if (!selected) { + detailRequestIdRef.current += 1; + setDetailSidecar(createDetailSidecarState()); + return; + } + + const signalId = selected.id; + const requestId = detailRequestIdRef.current + 1; + detailRequestIdRef.current = requestId; + setDetailSidecar(createDetailSidecarState(signalId, true)); + + void decisionSignalsApi.getSignalOutcomes(signalId) + .then((response) => { + if (detailRequestIdRef.current !== requestId) return; + setDetailSidecar((current) => ( + current.signalId === signalId + ? { + ...current, + outcomes: { + items: response.items, + loading: false, + error: null, + }, + } + : current + )); + }) + .catch((err) => { + if (detailRequestIdRef.current !== requestId) return; + const parsed = getParsedApiError(err); + setDetailSidecar((current) => ( + current.signalId === signalId + ? { + ...current, + outcomes: { + items: [], + loading: false, + error: parsed, + }, + } + : current + )); + }); + + void decisionSignalsApi.getFeedback(signalId) + .then((response) => { + if (detailRequestIdRef.current !== requestId) return; + setDetailSidecar((current) => ( + current.signalId === signalId + ? { + ...current, + feedback: { + item: response, + loading: false, + error: null, + }, + } + : current + )); + }) + .catch((err) => { + if (detailRequestIdRef.current !== requestId) return; + const parsed = getParsedApiError(err); + setDetailSidecar((current) => ( + current.signalId === signalId + ? { + ...current, + feedback: { + item: null, + loading: false, + error: parsed, + }, + } + : current + )); + }); + }, [selected]); + + const handleSelectSignal = useCallback((item: DecisionSignalItem) => { + if (selected?.id === item.id) return; + detailRequestIdRef.current += 1; + setSelected(item); + setDetailSidecar(createDetailSidecarState(item.id, true)); + }, [selected?.id]); + + const handleCloseDetails = useCallback(() => { + detailRequestIdRef.current += 1; + setSelected(null); + setDetailSidecar(createDetailSidecarState()); + }, []); + if (!shouldRender) { return null; } + const sidecarMatches = selected !== null && detailSidecar.signalId === selected.id; + const detailOutcomes = sidecarMatches + ? detailSidecar.outcomes + : { items: [], loading: Boolean(selected), error: null }; + const detailFeedback = sidecarMatches + ? detailSidecar.feedback + : { item: null, loading: Boolean(selected), error: null }; + return ( - - {error ? ( - void loadSignals()} - /> - ) : null} - {loading && items.length === 0 ? ( -
-
-
-
- ) : null} - {!loading && !error && items.length === 0 ? ( - } - /> - ) : null} - {items.length > 0 ? ( -
- {items.map((item) => ( - - ))} -
- ) : null} - + <> + + {error ? ( + void loadSignals()} + /> + ) : null} + {loading && items.length === 0 ? ( +
+
+
+
+ ) : null} + {!loading && !error && items.length === 0 ? ( + } + /> + ) : null} + {items.length > 0 ? ( +
+ {items.map((item) => ( + + ))} +
+ ) : null} + + + + {selected ? ( + + ) : null} + + ); }; diff --git a/apps/dsa-web/src/components/report/__tests__/ReportDecisionSignals.test.tsx b/apps/dsa-web/src/components/report/__tests__/ReportDecisionSignals.test.tsx index f1c907de7..9965ca0a5 100644 --- a/apps/dsa-web/src/components/report/__tests__/ReportDecisionSignals.test.tsx +++ b/apps/dsa-web/src/components/report/__tests__/ReportDecisionSignals.test.tsx @@ -1,14 +1,20 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import type React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { decisionSignalsApi } from '../../../api/decisionSignals'; import { UiLanguageProvider } from '../../../contexts/UiLanguageContext'; -import type { DecisionSignalItem } from '../../../types/decisionSignals'; +import type { + DecisionSignalFeedbackItem, + DecisionSignalItem, + DecisionSignalOutcomeItem, +} from '../../../types/decisionSignals'; import { ReportDecisionSignals } from '../ReportDecisionSignals'; vi.mock('../../../api/decisionSignals', () => ({ decisionSignalsApi: { list: vi.fn(), + getSignalOutcomes: vi.fn(), + getFeedback: vi.fn(), }, })); @@ -45,6 +51,51 @@ const signal: DecisionSignalItem = { metadata: undefined, }; +const nextSignal: DecisionSignalItem = { + ...signal, + id: 22, + stockCode: 'AAPL', + stockName: 'Apple', + market: 'us', + reason: '第二条信号理由', + riskSummary: '等待财报确认', +}; + +const outcome: DecisionSignalOutcomeItem = { + id: 120, + signalId: 21, + horizon: '5d', + engineVersion: 'decision-signal-v1', + evalStatus: 'completed', + outcome: 'hit', + directionExpected: 'not_down', + directionCorrect: true, + holdingState: 'holding', +}; + +const nextOutcome: DecisionSignalOutcomeItem = { + ...outcome, + id: 121, + signalId: 22, + outcome: 'neutral', +}; + +const feedback: DecisionSignalFeedbackItem = { + signalId: 21, + feedbackValue: 'useful', + reasonCode: null, + note: null, + source: 'web', +}; + +const emptyFeedback: DecisionSignalFeedbackItem = { + signalId: 21, + feedbackValue: null, + reasonCode: null, + note: null, + source: null, +}; + function renderComponent(props: React.ComponentProps) { return render( @@ -71,6 +122,13 @@ beforeEach(() => { page: 1, pageSize: 20, }); + vi.mocked(decisionSignalsApi.getSignalOutcomes).mockResolvedValue({ + items: [outcome], + total: 1, + page: 1, + pageSize: 20, + }); + vi.mocked(decisionSignalsApi.getFeedback).mockResolvedValue(feedback); }); describe('ReportDecisionSignals', () => { @@ -79,12 +137,18 @@ describe('ReportDecisionSignals', () => { expect(await screen.findByText('腾讯控股')).toBeInTheDocument(); expect(screen.getByText('缺少成交量确认')).toBeInTheDocument(); - expect(screen.getByText('周期: 5 日')).toBeInTheDocument(); + expect(screen.getByText('5 日')).toBeInTheDocument(); expect(screen.getByText('计划质量: 部分')).toBeInTheDocument(); expect(screen.getByText('阶段: 盘后')).toBeInTheDocument(); expect(screen.queryByText('5d')).not.toBeInTheDocument(); expect(screen.queryByText('postmarket')).not.toBeInTheDocument(); expect(screen.queryByText('partial')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '查看 腾讯控股 AI 建议详情' })); + expect(within(await screen.findByRole('dialog')).getByText('结构等待确认')).toBeInTheDocument(); + expect(await within(screen.getByRole('dialog')).findByText('命中')).toBeInTheDocument(); + expect(within(screen.getByRole('dialog')).getByText('有用')).toBeInTheDocument(); + expect(decisionSignalsApi.getSignalOutcomes).toHaveBeenCalledWith(21); + expect(decisionSignalsApi.getFeedback).toHaveBeenCalledWith(21); expect(decisionSignalsApi.list).toHaveBeenCalledWith({ sourceReportId: 5, sourceType: 'analysis', @@ -93,6 +157,139 @@ describe('ReportDecisionSignals', () => { }); }); + it('does not show a previous signal sidecar while the next signal details are loading', async () => { + const nextOutcomes = deferredPromise<{ + items: DecisionSignalOutcomeItem[]; + total: number; + page: number; + pageSize: number; + }>(); + const nextFeedback = deferredPromise(); + vi.mocked(decisionSignalsApi.list).mockResolvedValueOnce({ + items: [signal, nextSignal], + total: 2, + page: 1, + pageSize: 20, + }); + vi.mocked(decisionSignalsApi.getSignalOutcomes).mockImplementation((signalId: number) => { + if (signalId === 21) { + return Promise.resolve({ + items: [outcome], + total: 1, + page: 1, + pageSize: 20, + }); + } + return nextOutcomes.promise; + }); + vi.mocked(decisionSignalsApi.getFeedback).mockImplementation((signalId: number) => { + if (signalId === 21) return Promise.resolve(feedback); + return nextFeedback.promise; + }); + + renderComponent({ recordId: 5, reportType: 'detailed' }); + + fireEvent.click(await screen.findByRole('button', { name: '查看 腾讯控股 AI 建议详情' })); + let dialog = await screen.findByRole('dialog'); + expect(await within(dialog).findByText('命中')).toBeInTheDocument(); + expect(within(dialog).getByText('有用')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: '查看 Apple AI 建议详情' })); + dialog = await screen.findByRole('dialog'); + + expect(within(dialog).getByText('第二条信号理由')).toBeInTheDocument(); + expect(within(dialog).queryByText('命中')).not.toBeInTheDocument(); + expect(within(dialog).queryByText('有用')).not.toBeInTheDocument(); + expect(within(dialog).getAllByText(/正在加载/).length).toBeGreaterThanOrEqual(2); + }); + + it('keeps loaded sidecar data when selecting the current signal again', async () => { + renderComponent({ recordId: 5, reportType: 'detailed' }); + + fireEvent.click(await screen.findByRole('button', { name: '查看 腾讯控股 AI 建议详情' })); + const dialog = await screen.findByRole('dialog'); + expect(await within(dialog).findByText('命中')).toBeInTheDocument(); + expect(within(dialog).getByText('有用')).toBeInTheDocument(); + expect(decisionSignalsApi.getSignalOutcomes).toHaveBeenCalledTimes(1); + expect(decisionSignalsApi.getFeedback).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole('button', { name: '查看 腾讯控股 AI 建议详情' })); + + expect(within(dialog).getByText('命中')).toBeInTheDocument(); + expect(within(dialog).getByText('有用')).toBeInTheDocument(); + expect(within(dialog).queryByText(/正在加载/)).not.toBeInTheDocument(); + expect(decisionSignalsApi.getSignalOutcomes).toHaveBeenCalledTimes(1); + expect(decisionSignalsApi.getFeedback).toHaveBeenCalledTimes(1); + }); + + it('ignores stale sidecar responses after selecting another report signal', async () => { + const firstOutcomes = deferredPromise<{ + items: DecisionSignalOutcomeItem[]; + total: number; + page: number; + pageSize: number; + }>(); + const firstFeedback = deferredPromise(); + vi.mocked(decisionSignalsApi.list).mockResolvedValueOnce({ + items: [signal, nextSignal], + total: 2, + page: 1, + pageSize: 20, + }); + vi.mocked(decisionSignalsApi.getSignalOutcomes).mockImplementation((signalId: number) => { + if (signalId === 21) return firstOutcomes.promise; + return Promise.resolve({ + items: [nextOutcome], + total: 1, + page: 1, + pageSize: 20, + }); + }); + vi.mocked(decisionSignalsApi.getFeedback).mockImplementation((signalId: number) => { + if (signalId === 21) return firstFeedback.promise; + return Promise.resolve({ + ...emptyFeedback, + signalId: 22, + }); + }); + + renderComponent({ recordId: 5, reportType: 'detailed' }); + + fireEvent.click(await screen.findByRole('button', { name: '查看 腾讯控股 AI 建议详情' })); + fireEvent.click(screen.getByRole('button', { name: '查看 Apple AI 建议详情' })); + const dialog = await screen.findByRole('dialog'); + expect(await within(dialog).findByText('第二条信号理由')).toBeInTheDocument(); + expect(await within(dialog).findByText('中性')).toBeInTheDocument(); + + await act(async () => { + firstOutcomes.resolve({ + items: [outcome], + total: 1, + page: 1, + pageSize: 20, + }); + firstFeedback.resolve(feedback); + await Promise.all([firstOutcomes.promise, firstFeedback.promise]); + }); + + expect(within(dialog).queryByText('命中')).not.toBeInTheDocument(); + expect(within(dialog).queryByText('有用')).not.toBeInTheDocument(); + expect(within(dialog).getByText('暂无反馈')).toBeInTheDocument(); + }); + + it('shows independent sidecar error and empty feedback states', async () => { + vi.mocked(decisionSignalsApi.getSignalOutcomes).mockRejectedValueOnce(new Error('outcomes down')); + vi.mocked(decisionSignalsApi.getFeedback).mockResolvedValueOnce(emptyFeedback); + + renderComponent({ recordId: 5, reportType: 'detailed' }); + + fireEvent.click(await screen.findByRole('button', { name: '查看 腾讯控股 AI 建议详情' })); + const dialog = await screen.findByRole('dialog'); + + expect(await within(dialog).findByText('outcomes down')).toBeInTheDocument(); + expect(within(dialog).getByText('暂无反馈')).toBeInTheDocument(); + }); + it('shows an empty state when the report has no extracted signals', async () => { vi.mocked(decisionSignalsApi.list).mockResolvedValueOnce({ items: [], diff --git a/apps/dsa-web/src/i18n/uiText.ts b/apps/dsa-web/src/i18n/uiText.ts index 9460bc6a9..063b5f2f5 100644 --- a/apps/dsa-web/src/i18n/uiText.ts +++ b/apps/dsa-web/src/i18n/uiText.ts @@ -206,6 +206,7 @@ const zh = { 'decisionSignals.confidence': '置信度', 'decisionSignals.confirmStatusTitle': '更新信号状态', 'decisionSignals.createdAt': '创建时间', + 'decisionSignals.catalystSummary': '催化', 'decisionSignals.dataQuality': '数据质量', 'decisionSignals.description': '按股票、阶段、来源和状态查询结构化 AI 建议,并保留风险与数据质量上下文。', 'decisionSignals.detailTitle': '信号详情', @@ -230,6 +231,7 @@ const zh = { 'decisionSignals.horizon.intraday': '盘中', 'decisionSignals.horizon.long': '长期', 'decisionSignals.horizon.swing': '波段', + 'decisionSignals.invalidation': '失效条件', 'decisionSignals.invalidate': '标记失效', 'decisionSignals.invalidateConfirm': '确认将这条信号标记为失效吗?失效后不会再作为当前 active 建议展示。', 'decisionSignals.invalidated': '已失效', @@ -875,6 +877,7 @@ const en: Record = { 'decisionSignals.confidence': 'Confidence', 'decisionSignals.confirmStatusTitle': 'Update signal status', 'decisionSignals.createdAt': 'Created', + 'decisionSignals.catalystSummary': 'Catalyst', 'decisionSignals.dataQuality': 'Data quality', 'decisionSignals.description': 'Query structured AI recommendations by stock, phase, source, and status while keeping risk and data-quality context visible.', 'decisionSignals.detailTitle': 'Signal details', @@ -899,6 +902,7 @@ const en: Record = { 'decisionSignals.horizon.intraday': 'Intraday', 'decisionSignals.horizon.long': 'Long term', 'decisionSignals.horizon.swing': 'Swing', + 'decisionSignals.invalidation': 'Invalidation', 'decisionSignals.invalidate': 'Mark invalid', 'decisionSignals.invalidateConfirm': 'Mark this signal invalid? Invalidated signals are no longer shown as current active recommendations.', 'decisionSignals.invalidated': 'Invalidated', diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 540cbcafd..d764a475f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -64,6 +64,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - [修复] AlphaSift 热点详情兼容 `leader_stocks` 与 `stocks` 双字段,避免旧合约或缓存只提供其中一个字段时概念股详情报缺失字段。 - [改进] 问股页移动端策略选择改为默认收起的按钮入口,展开后仍可多选策略并在发送后自动收起,减少对对话内容的遮挡。 - [修复] Refs #1718 Web 股票输入与自动补全补齐日韩 Yahoo 后缀代码校验和常用日韩股票索引,手输或搜索 `7203.T`、`005930.KS`、`035720.KQ` 可正常提交分析。 +- [改进] AI 建议 / 决策信号卡片强化评分、置信度、价格计划、催化、风险与失效条件展示,并支持从历史报告内查看信号详情。 ## [3.22.0] - 2026-06-13 diff --git a/docs/decision-signals.md b/docs/decision-signals.md index 2998b5b31..43810aaee 100644 --- a/docs/decision-signals.md +++ b/docs/decision-signals.md @@ -68,7 +68,7 @@ Web 入口位于 `/decision-signals`: - market filter 已包含 `cn/hk/us/jp/kr`,P7 只补齐 `jp/kr` 的本地化标签,不改变筛选范围。 - 详情抽屉展示动作、状态、评分、置信度、周期、计划质量、市场阶段、价格计划、风险、观察条件、证据、数据质量和 metadata。 - Web 只能把信号标记为 `closed`、`invalidated` 或 `archived`,不提供 terminal 状态恢复为 active。 -- 报告页只在普通个股历史报告有 `recordId` 时读取该报告绑定的 `source_type=analysis` 信号;大盘复盘和无记录 ID 的报告不触发查询。 +- 报告页只在普通个股历史报告有 `recordId` 时读取该报告绑定的 `source_type=analysis` 信号;大盘复盘和无记录 ID 的报告不触发查询。历史报告内的详情抽屉会按当前选中的信号懒加载只读后验结果和用户反馈,sidecar 加载失败只影响详情内对应区块,不阻断报告主体展示。 - 持仓页异步查询每个唯一持仓的 latest active 信号,单只查询失败只显示降级提示,不阻断组合快照或其他持仓信号。 所有用户可见枚举必须使用 i18n 标签;技术 ID、股票代码、API 字段名、env key、URL 示例可以保留英文。