feat: 增强 AI 建议信号展示 (#1728)

* Improve decision signal display

* fix(review-feedback-1728): 补齐执行计划、风险边界和详情入口,符合用户可见能力增强目标

* fix: isolate report decision signal sidecar state

* fix: preserve report sidecar on repeated selection

---------

Co-authored-by: massif-01 <massif0601@gmail.com>
This commit is contained in:
mumu
2026-06-20 16:25:52 +08:00
committed by GitHub
parent a1cae3e614
commit 1b831730f6
7 changed files with 570 additions and 58 deletions

View File

@@ -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<SignalMetricTone, string> = {
default: 'text-foreground',
success: 'text-success',
warning: 'text-warning',
danger: 'text-danger',
};
type SignalMetricProps = {
label: string;
value: string;
tone?: SignalMetricTone;
};
const SignalMetric: React.FC<SignalMetricProps> = ({ label, value, tone = 'default' }) => (
<div className="min-w-0 rounded-xl border border-border/60 bg-elevated/45 px-3 py-2">
<p className="truncate text-[11px] text-muted-text">{label}</p>
<p className={cn('mt-1 truncate text-sm font-semibold tabular-nums', metricToneClass[tone])}>{value}</p>
</div>
);
type SignalTextTone = 'default' | 'warning' | 'danger' | 'info';
const textToneClass: Record<SignalTextTone, string> = {
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<SignalTextBlockProps> = ({ label, value, tone = 'default', clamp = true }) => {
const normalized = value?.trim();
if (!normalized) return null;
return (
<div className={cn('rounded-xl border px-3 py-2.5', textToneClass[tone])}>
<p className="text-[11px] font-medium text-current/80">{label}</p>
<p className={cn('mt-1 text-sm leading-5 text-current', clamp ? 'line-clamp-2' : 'whitespace-pre-wrap')}>
{normalized}
</p>
</div>
);
};
type DecisionSignalCardProps = {
item: DecisionSignalItem;
onSelect?: (item: DecisionSignalItem) => void;
@@ -148,8 +210,14 @@ export const DecisionSignalCard: React.FC<DecisionSignalCardProps> = ({ 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<DecisionSignalCardProps> = ({ item, on
<div className="mt-1">{formatDateTime(item.createdAt, language)}</div>
</div>
</div>
<div className="mt-3 grid gap-2 text-sm text-secondary-text">
{item.reason ? <p className="line-clamp-2">{item.reason}</p> : null}
{item.riskSummary ? <p className="line-clamp-2 text-warning">{item.riskSummary}</p> : null}
{item.watchConditions ? <p className="line-clamp-2">{item.watchConditions}</p> : null}
<div className="mt-4 grid grid-cols-3 gap-2">
<SignalMetric label={t('decisionSignals.score')} value={formatNumber(item.score)} />
<SignalMetric label={t('decisionSignals.confidence')} value={formatConfidence(item.confidence)} />
<SignalMetric label={t('decisionSignals.horizon')} value={getDecisionSignalHorizonLabel(item.horizon, t)} />
</div>
{pricePlanItems.length > 0 ? (
<div className="mt-3 rounded-xl border border-border/60 bg-elevated/35 px-3 py-2.5">
<div className="grid gap-2 sm:grid-cols-3">
{pricePlanItems.map((entry) => (
<div key={entry.label} className="min-w-0">
<p className="truncate text-[11px] text-muted-text">{entry.label}</p>
<p className={cn('mt-1 truncate text-sm font-semibold tabular-nums', metricToneClass[entry.tone])}>
{entry.value}
</p>
</div>
))}
</div>
</div>
) : null}
<div className="mt-3 grid gap-2">
<SignalTextBlock label={t('decisionSignals.reason')} value={item.reason} />
<SignalTextBlock label={t('decisionSignals.catalystSummary')} value={item.catalystSummary} tone="info" />
<SignalTextBlock label={t('decisionSignals.watchConditions')} value={item.watchConditions} />
<SignalTextBlock label={t('decisionSignals.riskSummary')} value={item.riskSummary} tone="warning" />
<SignalTextBlock label={t('decisionSignals.invalidation')} value={item.invalidation} tone="danger" />
</div>
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted-text">
<span>{t('decisionSignals.horizon')}: {getDecisionSignalHorizonLabel(item.horizon, t)}</span>
<span>{t('decisionSignals.planQuality')}: {getDecisionSignalPlanQualityLabel(item.planQuality, t)}</span>
<span>{t('decisionSignals.marketPhase')}: {getDecisionSignalMarketPhaseLabel(item.marketPhase, t)}</span>
<span>{t('decisionSignals.expiresAt')}: {formatDateTime(item.expiresAt, language)}</span>
{item.sourceReportId ? <span>{t('decisionSignals.sourceReport')}: #{item.sourceReportId}</span> : null}
</div>
</>
);
@@ -195,9 +289,10 @@ export const DecisionSignalCard: React.FC<DecisionSignalCardProps> = ({ item, on
<button
type="button"
onClick={() => onSelect?.(item)}
className="btn-secondary !px-3 !py-1.5 !text-xs"
className="btn-secondary inline-flex items-center gap-1.5 !px-3 !py-1.5 !text-xs"
aria-label={t('decisionSignals.viewDetailsFor', { stock: item.stockName || item.stockCode })}
>
<PanelRightOpen className="h-3.5 w-3.5" />
{t('common.details')}
</button>
</div>
@@ -265,7 +360,7 @@ export const DecisionSignalDetails: React.FC<DecisionSignalDetailsProps> = ({
<div className="grid gap-3 sm:grid-cols-2">
<DetailRow label={t('decisionSignals.score')} value={formatNumber(item.score)} />
<DetailRow label={t('decisionSignals.confidence')} value={formatNumber(item.confidence)} />
<DetailRow label={t('decisionSignals.confidence')} value={formatConfidence(item.confidence)} />
<DetailRow label={t('decisionSignals.horizon')} value={getDecisionSignalHorizonLabel(item.horizon, t)} />
<DetailRow label={t('decisionSignals.planQuality')} value={getDecisionSignalPlanQualityLabel(item.planQuality, t)} />
<DetailRow label={t('decisionSignals.marketPhase')} value={getDecisionSignalMarketPhaseLabel(item.marketPhase, t)} />
@@ -282,6 +377,16 @@ export const DecisionSignalDetails: React.FC<DecisionSignalDetailsProps> = ({
</div>
</Card>
<Card padding="sm" className="rounded-xl">
<div className="grid gap-3">
<SignalTextBlock label={t('decisionSignals.reason')} value={formatJsonish(item.reason)} clamp={false} />
<SignalTextBlock label={t('decisionSignals.catalystSummary')} value={formatJsonish(item.catalystSummary)} tone="info" clamp={false} />
<SignalTextBlock label={t('decisionSignals.watchConditions')} value={formatJsonish(item.watchConditions)} clamp={false} />
<SignalTextBlock label={t('decisionSignals.riskSummary')} value={formatJsonish(item.riskSummary)} tone="warning" clamp={false} />
<SignalTextBlock label={t('decisionSignals.invalidation')} value={formatJsonish(item.invalidation)} tone="danger" clamp={false} />
</div>
</Card>
<Card title={t('decisionSignals.outcomes')} padding="sm" className="rounded-xl">
{outcomesLoading ? (
<p className="text-sm text-secondary-text">{t('common.loading')}...</p>
@@ -351,14 +456,6 @@ export const DecisionSignalDetails: React.FC<DecisionSignalDetailsProps> = ({
</div>
</Card>
<Card padding="sm" className="rounded-xl">
<div className="grid gap-4">
<DetailRow label={t('decisionSignals.reason')} value={formatJsonish(item.reason)} />
<DetailRow label={t('decisionSignals.riskSummary')} value={formatJsonish(item.riskSummary)} />
<DetailRow label={t('decisionSignals.watchConditions')} value={formatJsonish(item.watchConditions)} />
</div>
</Card>
{evidenceData ? (
<Card title={t('decisionSignals.evidence')} padding="sm" className="rounded-xl">
<JsonViewer data={evidenceData} maxHeight="240px" />

View File

@@ -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(
<UiLanguageProvider>
<DecisionSignalCard
item={{
...signal,
score: null,
confidence: null,
horizon: null,
entryLow: null,
entryHigh: null,
stopLoss: null,
targetPrice: null,
invalidation: null,
watchConditions: null,
catalystSummary: null,
}}
/>
</UiLanguageProvider>,
);
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');
});

View File

@@ -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<ReportDecisionSignalsProps> = ({
recordId,
reportType,
}) => {
const { t } = useUiLanguage();
const [items, setItems] = useState<DecisionSignalItem[]>([]);
const [selected, setSelected] = useState<DecisionSignalItem | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<ParsedApiError | null>(null);
const [detailSidecar, setDetailSidecar] = useState<DetailSidecarState>(() => 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<ReportDecisionSignalsProps> = ({
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<ReportDecisionSignalsProps> = ({
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<ReportDecisionSignalsProps> = ({
};
}, [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 (
<Card
title={t('decisionSignals.reportSectionTitle')}
subtitle={t('decisionSignals.reportSectionDescription')}
padding="md"
>
{error ? (
<ApiErrorAlert
error={{ ...error, title: t('decisionSignals.reportErrorTitle') }}
actionLabel={t('common.retry')}
onAction={() => void loadSignals()}
/>
) : null}
{loading && items.length === 0 ? (
<div className="grid gap-3">
<div className="h-24 animate-pulse rounded-2xl border border-border/70 bg-card/60" />
<div className="h-24 animate-pulse rounded-2xl border border-border/70 bg-card/60" />
</div>
) : null}
{!loading && !error && items.length === 0 ? (
<EmptyState
className="border-none bg-transparent py-6 shadow-none"
title={t('decisionSignals.reportEmptyTitle')}
description={t('decisionSignals.reportEmptyDescription')}
icon={<Activity className="h-6 w-6" />}
/>
) : null}
{items.length > 0 ? (
<div className="grid gap-3 lg:grid-cols-2">
{items.map((item) => (
<DecisionSignalCard key={item.id} item={item} />
))}
</div>
) : null}
</Card>
<>
<Card
title={t('decisionSignals.reportSectionTitle')}
subtitle={t('decisionSignals.reportSectionDescription')}
padding="md"
>
{error ? (
<ApiErrorAlert
error={{ ...error, title: t('decisionSignals.reportErrorTitle') }}
actionLabel={t('common.retry')}
onAction={() => void loadSignals()}
/>
) : null}
{loading && items.length === 0 ? (
<div className="grid gap-3">
<div className="h-24 animate-pulse rounded-2xl border border-border/70 bg-card/60" />
<div className="h-24 animate-pulse rounded-2xl border border-border/70 bg-card/60" />
</div>
) : null}
{!loading && !error && items.length === 0 ? (
<EmptyState
className="border-none bg-transparent py-6 shadow-none"
title={t('decisionSignals.reportEmptyTitle')}
description={t('decisionSignals.reportEmptyDescription')}
icon={<Activity className="h-6 w-6" />}
/>
) : null}
{items.length > 0 ? (
<div className="grid gap-3 lg:grid-cols-2">
{items.map((item) => (
<DecisionSignalCard
key={item.id}
item={item}
onSelect={handleSelectSignal}
selected={selected?.id === item.id}
/>
))}
</div>
) : null}
</Card>
<Drawer
isOpen={Boolean(selected)}
onClose={handleCloseDetails}
title={t('decisionSignals.detailTitle')}
width="max-w-3xl"
>
{selected ? (
<DecisionSignalDetails
item={selected}
outcomes={detailOutcomes.items}
outcomesLoading={detailOutcomes.loading}
outcomesError={detailOutcomes.error?.message ?? null}
feedback={detailFeedback.item}
feedbackLoading={detailFeedback.loading}
feedbackError={detailFeedback.error?.message ?? null}
/>
) : null}
</Drawer>
</>
);
};

View File

@@ -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<typeof ReportDecisionSignals>) {
return render(
<UiLanguageProvider>
@@ -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<DecisionSignalFeedbackItem>();
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<DecisionSignalFeedbackItem>();
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: [],

View File

@@ -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<UiTextKey, string> = {
'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<UiTextKey, string> = {
'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',

View File

@@ -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

View File

@@ -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 示例可以保留英文。