diff --git a/apps/dsa-web/src/pages/StockScreeningPage.tsx b/apps/dsa-web/src/pages/StockScreeningPage.tsx index a6a2bbd7d..2101e1655 100644 --- a/apps/dsa-web/src/pages/StockScreeningPage.tsx +++ b/apps/dsa-web/src/pages/StockScreeningPage.tsx @@ -33,6 +33,7 @@ import { type ScreeningHotspotDetail, type ScreeningHotspot, type ScreeningHotspotsResponse, + type ScreeningRunSummary, type ScreeningScreenResponse, type ScreeningScreenTaskStatus, type ScreeningStrategy, @@ -64,11 +65,39 @@ const formatStrategyCategory = (value?: string) => { type PersistedScreenTask = { taskId: string; + runId?: string; market: string; strategy: string; maxResults: number; }; +const formatRunCreatedAt = (value: string | null | undefined) => { + if (!value) { + return '时间未知'; + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return value; + } + return parsed.toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); +}; + +// 历史条目里展示筛选条件:策略 ID → 中文名(找不到时回退到原始 ID) +const formatHistoryStrategyName = (strategyId: string, strategies: ScreeningStrategy[]): string => { + const matched = strategies.find((item) => item.id === strategyId); + return matched?.name || matched?.title || strategyId || '未知策略'; +}; + +// 历史条目里展示筛选条件:市场 ID → 中文标签 +const formatHistoryMarketLabel = (marketId: string | null | undefined): string => + MARKETS.find((item) => item.id === marketId)?.label || marketId || 'cn'; + const readPersistedScreenTask = (): PersistedScreenTask | null => { if (typeof window === 'undefined') { return null; @@ -85,6 +114,7 @@ const readPersistedScreenTask = (): PersistedScreenTask | null => { const restoredMaxResults = Number(parsed.maxResults); return { taskId: parsed.taskId, + runId: typeof parsed.runId === 'string' && parsed.runId.trim() ? parsed.runId : undefined, market: typeof parsed.market === 'string' && parsed.market.trim() ? parsed.market : 'cn', strategy: typeof parsed.strategy === 'string' && parsed.strategy.trim() ? parsed.strategy : 'dual_low', maxResults: Number.isFinite(restoredMaxResults) ? Math.min(100, Math.max(1, restoredMaxResults)) : 3, @@ -818,6 +848,7 @@ const StockScreeningPage: React.FC = () => { const selectedHotspotTopicRef = useRef(null); const hotspotDetailRequestIdRef = useRef(0); const hotspotDetailsByTopicRef = useRef>({}); + const historyRunRequestIdRef = useRef(0); const [hotspotDetail, setHotspotDetail] = useState(null); const [loadingHotspotDetail, setLoadingHotspotDetail] = useState(false); const [searchingHotspotNews, setSearchingHotspotNews] = useState(false); @@ -828,6 +859,14 @@ const StockScreeningPage: React.FC = () => { const [expandedCode, setExpandedCode] = useState(null); const [loading, setLoading] = useState(Boolean(restoredTask?.taskId)); const [enabling, setEnabling] = useState(false); + const [historyRuns, setHistoryRuns] = useState([]); + const [historyLoading, setHistoryLoading] = useState(false); + const [historyError, setHistoryError] = useState(''); + const [restoreResolved, setRestoreResolved] = useState(() => !restoredTask?.runId); + // 标记当前 strategy 是否来自历史 run(刷新自动恢复或手动历史选择)的上下文同步。 + // 为 true 时 loadStrategies 跳过“不在列表则回退第一项”的归一化, + // 防止迟到的 /strategies 响应把历史上下文改写回默认策略。 + const historyContextStrategyRef = useRef(false); const [loadingStrategies, setLoadingStrategies] = useState(false); const [error, setError] = useState(''); const [strategyLoadError, setStrategyLoadError] = useState(''); @@ -863,6 +902,74 @@ const StockScreeningPage: React.FC = () => { setExpandedCode(nextCandidates[0]?.code ?? null); }, []); + const loadHistory = useCallback(async () => { + setHistoryLoading(true); + setHistoryError(''); + try { + const result = await screeningApi.getHistory({ limit: 10 }); + setHistoryRuns(result.runs || []); + } catch (err) { + setHistoryError(toApiErrorMessage(err, '历史记录加载失败')); + } finally { + setHistoryLoading(false); + } + }, []); + + const handleHistoryRunSelect = useCallback(async (runId: string) => { + // 竞态防护:快速切换历史条目时,只应用最新一次请求的响应 + const requestId = historyRunRequestIdRef.current + 1; + historyRunRequestIdRef.current = requestId; + const isCurrentRequest = () => historyRunRequestIdRef.current === requestId; + // 与运行中的选股任务互斥:手动选择历史记录后,暂停/取消后台任务轮询, + // 避免任务完成后把当前任务的候选结果回写到历史上下文中。 + setActiveTaskId(null); + setHistoryError(''); + setLoading(true); + try { + const detail = await screeningApi.getRun(runId); + if (!isCurrentRequest()) { + return; + } + if (detail?.result) { + applyScreenResult(detail.result); + historyContextStrategyRef.current = true; + // 同步持久化恢复指针:刷新后应恢复用户刚选中的历史 run, + // 而不是停留在更早的 task/run。历史详情不携带 taskId,以 runId + // 作为占位——正常路径刷新走 getRun(runId) 恢复、不会触发轮询; + // 若该 run 恢复失败,占位轮询会命中不可恢复错误并清理过期指针。 + persistScreenTask({ + taskId: runId, + runId, + market: detail.market || market, + strategy: detail.strategy || strategy, + maxResults, + }); + // 同步历史 run 的策略与市场上下文,确保结果区文案和后续深度分析 + // 使用该历史 run 对应的 strategy/market,而不是当前表单的选择 + if (detail.strategy) { + setStrategy(detail.strategy); + } + if (detail.market) { + setMarket(detail.market); + } + setError(''); + setTaskProgress(100); + setTaskMessage('已加载历史选股结果'); + } else { + setError('历史记录中未找到该次运行的结果。'); + } + } catch (err) { + if (!isCurrentRequest()) { + return; + } + setError(toApiErrorMessage(err, '历史结果加载失败')); + } finally { + if (isCurrentRequest()) { + setLoading(false); + } + } + }, [applyScreenResult, market, maxResults, strategy]); + const clearScreeningResults = () => { setCandidates([]); setScreenMeta(null); @@ -942,7 +1049,9 @@ const StockScreeningPage: React.FC = () => { const result = await screeningApi.getStrategies(); const loadedStrategies = result.strategies || []; setStrategies(loadedStrategies); - if (loadedStrategies.length > 0) { + // 历史 run 的自定义/下线策略不在当前列表属预期行为,不做“回退第一项”的归一化, + // 否则迟到的策略列表会把刚恢复好的上下文改写回默认策略。 + if (loadedStrategies.length > 0 && !historyContextStrategyRef.current) { setStrategy((currentStrategy) => loadedStrategies.some((item) => item.id === currentStrategy) ? currentStrategy : loadedStrategies[0].id, ); @@ -1083,6 +1192,7 @@ const StockScreeningPage: React.FC = () => { if (status.enabled && status.available) { void loadStrategies(); void loadHotspots(false); + void loadHistory(); } }) .catch(() => { @@ -1096,8 +1206,67 @@ const StockScreeningPage: React.FC = () => { }; }, [loadHotspots, loadStrategies]); + // 刷新后优先从 history API 按 run_id 恢复结果;恢复失败再回退到 task 轮询 useEffect(() => { - if (!activeTaskId) { + const runId = restoredTask?.runId; + if (!runId) { + setRestoreResolved(true); + return undefined; + } + let active = true; + setLoading(true); + // 记录自动恢复的请求基准:若在自动恢复返回前用户手动点开了历史记录 + // (historyRunRequestIdRef 被 handleHistoryRunSelect 递增),则放弃本次自动恢复响应, + // 避免较晚返回的自动恢复把页面切回旧 run,覆盖用户最新一次的历史选择。 + const restoreRequestBase = historyRunRequestIdRef.current; + screeningApi + .getRun(runId) + .then((detail) => { + if (!active || historyRunRequestIdRef.current !== restoreRequestBase) { + return; + } + if (detail?.result) { + applyScreenResult(detail.result); + historyContextStrategyRef.current = true; + // 同步恢复该历史 run 的策略与市场上下文,避免结果区展示和 + // 深度分析沿用当前表单策略(与 handleHistoryRunSelect 一致) + if (detail.strategy) { + setStrategy(detail.strategy); + } + if (detail.market) { + setMarket(detail.market); + } + setError(''); + setTaskProgress(100); + setTaskMessage('已从历史记录恢复上次选股结果'); + setActiveTaskId(null); + } + }) + .catch(() => { + // 历史记录恢复失败(run 不存在或服务重启),回退到 task 轮询; + // 若用户已手动选择了历史记录,则不再回退,保持用户的选择。 + if (active && historyRunRequestIdRef.current === restoreRequestBase) { + setActiveTaskId(restoredTask?.taskId ?? null); + } + }) + .finally(() => { + // 无论结果是否过期都要解除自动恢复门闩,否则新任务的轮询会被阻塞到旧请求超时。 + if (active) { + setRestoreResolved(true); + } + // 过期的自动恢复不得触碰共享 loading:用户手动选择的历史详情请求可能仍在飞行, + // 提前清掉会重新放开“运行选股”入口,随后迟到的历史响应会覆盖新任务状态。 + if (active && historyRunRequestIdRef.current === restoreRequestBase) { + setLoading(false); + } + }); + return () => { + active = false; + }; + }, [applyScreenResult, restoredTask]); + + useEffect(() => { + if (!activeTaskId || !restoreResolved) { return undefined; } @@ -1106,7 +1275,6 @@ const StockScreeningPage: React.FC = () => { let timer: ReturnType | undefined; function finishTask() { - clearPersistedScreenTask(); setActiveTaskId(null); setLoading(false); } @@ -1120,6 +1288,17 @@ const StockScreeningPage: React.FC = () => { if (task.result) { applyScreenResult(task.result); setError(''); + // 持久化 runId:刷新后优先从 history API 恢复结果,而非依赖内存 task + const completedRunId = task.result.runId || screenMeta?.runId; + if (completedRunId) { + persistScreenTask({ + taskId: pollingTaskId, + runId: completedRunId, + market, + strategy, + maxResults, + }); + } } else { setError('选股任务已完成,但服务端未返回候选结果。'); setCandidates([]); @@ -1134,6 +1313,7 @@ const StockScreeningPage: React.FC = () => { setScreenMeta(null); setExpandedCode(null); setError(formatScreenTaskFailure(task.error || task.message)); + clearPersistedScreenTask(); finishTask(); return; } @@ -1145,6 +1325,7 @@ const StockScreeningPage: React.FC = () => { } setError(`选股任务返回未知状态:${task.status || 'unknown'}`); + clearPersistedScreenTask(); finishTask(); } @@ -1164,6 +1345,7 @@ const StockScreeningPage: React.FC = () => { setError(formatParsedApiError(parsedError) || '选股任务不可恢复,请重新提交。'); setCandidates([]); setScreenMeta(null); + clearPersistedScreenTask(); finishTask(); return; } @@ -1181,7 +1363,7 @@ const StockScreeningPage: React.FC = () => { window.clearTimeout(timer); } }; - }, [activeTaskId, applyScreenResult]); + }, [activeTaskId, applyScreenResult, restoreResolved]); const handleEnable = async () => { setEnabling(true); @@ -1228,6 +1410,11 @@ const StockScreeningPage: React.FC = () => { }; const handleSubmit = async () => { + // 新任务提交即代表用户放弃当前历史/恢复上下文: + // 递增请求代号作废飞行中的历史详情与自动恢复响应; + // 解除自动恢复门闩,保证刚提交的任务轮询立即可启动。 + historyRunRequestIdRef.current += 1; + setRestoreResolved(true); setLoading(true); setError(''); setScreenMeta(null); @@ -1880,6 +2067,59 @@ const StockScreeningPage: React.FC = () => { )} ) : null} + +
+
+
+ + 历史记录 +
+ +
+ {historyError ? ( +

{historyError}

+ ) : null} + {historyRuns.length === 0 ? ( +

+ {historyLoading ? '正在加载历史记录...' : '暂无历史选股记录'} +

+ ) : ( +
+ {historyRuns.map((run) => ( + + ))} +
+ )} +
); }; diff --git a/apps/dsa-web/src/pages/__tests__/StockScreeningPage.test.tsx b/apps/dsa-web/src/pages/__tests__/StockScreeningPage.test.tsx index 9a5c8083c..a3fe50c38 100644 --- a/apps/dsa-web/src/pages/__tests__/StockScreeningPage.test.tsx +++ b/apps/dsa-web/src/pages/__tests__/StockScreeningPage.test.tsx @@ -5,6 +5,8 @@ import StockScreeningPage from '../StockScreeningPage'; const { enableScreening, + getHistory, + getRun, getScreeningStatus, getHotspotDetail, getHotspots, @@ -42,6 +44,8 @@ const { }); return { enableScreening: vi.fn(), + getHistory: vi.fn(), + getRun: vi.fn(), getScreeningStatus: vi.fn(), getHotspotDetail: vi.fn(), getHotspots: vi.fn(), @@ -70,6 +74,8 @@ vi.mock('../../api/screening', () => ({ getStatus: () => getScreeningStatus(), getHotspotDetail: (payload: unknown) => getHotspotDetail(payload), getHotspots: (payload: unknown) => getHotspots(payload), + getHistory: (payload: unknown) => getHistory(payload), + getRun: (runId: string) => getRun(runId), getStrategies: () => getStrategies(), getScreenTask: (taskId: string) => getScreenTask(taskId), screen: (payload: unknown) => screenStocks(payload), @@ -107,6 +113,8 @@ function createDeferred() { describe('StockScreeningPage', () => { beforeEach(() => { enableScreening.mockReset(); + getHistory.mockReset(); + getRun.mockReset(); getScreeningStatus.mockReset(); getHotspotDetail.mockReset(); getHotspots.mockReset(); @@ -143,6 +151,15 @@ describe('StockScreeningPage', () => { stockCount: 1, }); getHotspots.mockResolvedValue({ enabled: true, provider: 'akshare', hotspots: [], hotspotCount: 0 }); + getHistory.mockResolvedValue({ runs: [] }); + getRun.mockRejectedValue(Object.assign(new Error('run not found'), { + parsedError: { + title: '选股任务不可恢复', + message: '服务端没有找到这次选股任务,可能后端已重启或任务记录已清理,请重新运行选股。', + rawMessage: 'screening_screen_task_not_found', + category: 'http_error', + }, + })); window.sessionStorage.clear(); }); @@ -1156,7 +1173,8 @@ describe('StockScreeningPage', () => { expect(await screen.findByText('恢复后的候选')).toBeInTheDocument(); expect(screen.getByText('选股完成')).toBeInTheDocument(); - expect(window.sessionStorage.getItem('dsa.screening.activeScreenTask.v1')).toBeNull(); + // 方案A:任务完成后保留 runId(而非清空),刷新后可从 history API 恢复 + expect(window.sessionStorage.getItem('dsa.screening.activeScreenTask.v1')).toContain('screen-task-1'); }); it('keeps a restored screening task recoverable when status polling times out', async () => { @@ -1183,6 +1201,463 @@ describe('StockScreeningPage', () => { expect(window.sessionStorage.getItem('dsa.screening.activeScreenTask.v1')).toContain('screen-task-1'); }); + it('clears the persisted recovery state when a restored task becomes unrecoverable', async () => { + getScreeningStatus.mockResolvedValue({ + enabled: true, + available: true, + }); + window.sessionStorage.setItem('dsa.screening.activeScreenTask.v1', JSON.stringify({ + taskId: 'screen-task-1', + market: 'cn', + strategy: 'dual_low', + maxResults: 3, + })); + getScreenTask.mockRejectedValueOnce(Object.assign(new Error('选股任务不可恢复'), { + parsedError: { + title: '选股任务不可恢复', + message: '服务端没有找到这次选股任务,可能后端已重启或任务记录已清理,请重新运行选股。', + rawMessage: 'screening_screen_task_not_found', + category: 'http_error', + }, + })); + + render(); + + await waitFor(() => expect(getScreenTask).toHaveBeenCalledTimes(1)); + expect(screen.getByText(/选股任务不可恢复/)).toBeInTheDocument(); + expect(screen.queryByText('选股运行中')).not.toBeInTheDocument(); + // 不可恢复分支必须清理持久化恢复状态,避免刷新后反复恢复同一条失效任务 + expect(window.sessionStorage.getItem('dsa.screening.activeScreenTask.v1')).toBeNull(); + }); + + it('keeps the persisted recovery state and history visible after filters change', async () => { + getScreeningStatus.mockResolvedValue({ + enabled: true, + available: true, + }); + screenStocks.mockResolvedValueOnce({ + enabled: true, + candidates: [ + { + rank: 1, + code: '000001', + name: '旧筛选条件下的候选', + score: 88.5, + reason: 'old filter result', + raw: {}, + }, + ], + candidateCount: 1, + runId: 'run-1', + }); + + render(); + + expect(await screen.findByText('选股已开启')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /运行选股/ })); + + expect(await screen.findByText('旧筛选条件下的候选')).toBeInTheDocument(); + expect(window.sessionStorage.getItem('dsa.screening.activeScreenTask.v1')).toContain('run-1'); + + // 修改筛选条件(返回数量 3 -> 5):当前结果视图清空,但持久化恢复状态必须保留 + fireEvent.change(screen.getByRole('spinbutton', { name: /返回数量/ }), { target: { value: '5' } }); + expect(screen.queryByText('旧筛选条件下的候选')).not.toBeInTheDocument(); + expect(window.sessionStorage.getItem('dsa.screening.activeScreenTask.v1')).toContain('run-1'); + + // 历史记录区块仍然可见(筛选条件切换不影响历史可获取性) + expect(screen.getByText('历史记录')).toBeInTheDocument(); + }); + + it('shows the screening conditions on each history entry', async () => { + getScreeningStatus.mockResolvedValue({ + enabled: true, + available: true, + }); + getHistory.mockResolvedValue({ + enabled: true, + runs: [ + { + runId: 'run-1', + strategy: 'dual_low', + market: 'cn', + candidateCount: 3, + snapshotCount: 50, + llmRanked: true, + createdAt: '2026-08-05T10:00:00Z', + }, + ], + runCount: 1, + }); + + render(); + + // 历史条目展示筛选条件:策略中文名 + 市场标签 + 返回数量(该次 run 实际候选数) + expect(await screen.findByText(/返回 3 只/)).toBeInTheDocument(); + expect(screen.getAllByText('Dual Low').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('A 股').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText(/快照 50/)).toBeInTheDocument(); + expect(screen.getByText(/智能重排/)).toBeInTheDocument(); + }); + + it('syncs strategy and market context when opening a history run', async () => { + getScreeningStatus.mockResolvedValue({ + enabled: true, + available: true, + }); + getHistory.mockResolvedValue({ + enabled: true, + runs: [ + { + runId: 'run-1', + strategy: 'capital_heat', + market: 'cn', + candidateCount: 3, + snapshotCount: 50, + llmRanked: true, + createdAt: '2026-08-05T10:00:00Z', + }, + ], + runCount: 1, + }); + getRun.mockResolvedValue({ + runId: 'run-1', + strategy: 'capital_heat', + market: 'cn', + candidateCount: 3, + enabled: true, + result: { + enabled: true, + candidates: [ + { + rank: 1, + code: '00700', + name: '腾讯控股', + score: 88.5, + reason: '热度因子领先', + amount: 1042000000, + factorScores: { heat: 92 }, + raw: {}, + }, + ], + candidateCount: 1, + snapshotCount: 50, + afterFilterCount: 10, + llmRanked: true, + }, + }); + + render(); + + // 点击历史记录中的 run 条目(策略 capital_heat,与当前表单默认 dual_low 不同) + fireEvent.click(await screen.findByText('capital_heat')); + + // 结果区上下文同步为该历史 run 的策略与市场 + expect(await screen.findByText(/自定义策略 \(capital_heat\) · A 股/)).toBeInTheDocument(); + expect(getRun).toHaveBeenCalledWith('run-1'); + }); + + it('ignores stale history-detail responses when switching runs quickly', async () => { + getScreeningStatus.mockResolvedValue({ + enabled: true, + available: true, + }); + getHistory.mockResolvedValue({ + enabled: true, + runs: [ + { + runId: 'run-a', + strategy: 'capital_heat', + market: 'cn', + candidateCount: 1, + createdAt: '2026-08-05T10:00:00Z', + }, + { + runId: 'run-b', + strategy: 'dual_low', + market: 'cn', + candidateCount: 1, + createdAt: '2026-08-06T10:00:00Z', + }, + ], + runCount: 2, + }); + const runADetail = createDeferred(); + const runBDetail = createDeferred(); + getRun.mockImplementation((runId: string) => { + if (runId === 'run-a') { + return runADetail.promise; + } + if (runId === 'run-b') { + return runBDetail.promise; + } + return Promise.reject(new Error(`unexpected runId: ${runId}`)); + }); + + render(); + + // 先点 run-a(capital_heat,请求挂起),再点 run-b(dual_low) + fireEvent.click(await screen.findByText('capital_heat')); + fireEvent.click(screen.getByRole('button', { name: /Dual Low/ })); + await waitFor(() => expect(getRun).toHaveBeenLastCalledWith('run-b')); + + // run-b 先返回:结果区展示 dual_low 上下文 + await act(async () => { + runBDetail.resolve({ + runId: 'run-b', + strategy: 'dual_low', + market: 'cn', + candidateCount: 1, + enabled: true, + result: { + enabled: true, + candidates: [ + { + rank: 1, + code: '000001', + name: '平安银行', + score: 88.5, + reason: '双低策略', + amount: 1042000000, + factorScores: { value: 92 }, + raw: {}, + }, + ], + candidateCount: 1, + snapshotCount: 50, + afterFilterCount: 10, + llmRanked: true, + }, + }); + }); + expect(await screen.findByText(/Dual Low · A 股/)).toBeInTheDocument(); + + // run-a 迟到返回:不得覆盖 run-b 的结果与上下文 + await act(async () => { + runADetail.resolve({ + runId: 'run-a', + strategy: 'capital_heat', + market: 'cn', + candidateCount: 1, + enabled: true, + result: { + enabled: true, + candidates: [ + { + rank: 1, + code: '00700', + name: '腾讯控股', + score: 90, + reason: '热度因子领先', + amount: 1042000000, + factorScores: { heat: 92 }, + raw: {}, + }, + ], + candidateCount: 1, + snapshotCount: 50, + afterFilterCount: 10, + llmRanked: true, + }, + }); + }); + expect(screen.getByText(/Dual Low · A 股/)).toBeInTheDocument(); + expect(screen.queryByText(/自定义策略 \(capital_heat\)/)).not.toBeInTheDocument(); + expect(screen.queryByText('腾讯控股')).not.toBeInTheDocument(); + }); + + it('ignores late auto-restore responses after the user manually picks a history run', async () => { + getScreeningStatus.mockResolvedValue({ + enabled: true, + available: true, + }); + // 持久化 run-a:挂载时会触发自动恢复的 getRun('run-a') + window.sessionStorage.setItem('dsa.screening.activeScreenTask.v1', JSON.stringify({ + taskId: 'task-a', + runId: 'run-a', + strategy: 'capital_heat', + market: 'cn', + maxResults: 3, + })); + getHistory.mockResolvedValue({ + enabled: true, + runs: [ + { + runId: 'run-b', + strategy: 'dual_low', + market: 'cn', + candidateCount: 1, + createdAt: '2026-08-05T10:00:00Z', + }, + ], + runCount: 1, + }); + + const runA = createDeferred(); + getRun.mockImplementation((runId: string) => { + if (runId === 'run-a') { + return runA.promise; + } + return Promise.resolve({ + runId: 'run-b', + strategy: 'dual_low', + market: 'cn', + candidateCount: 1, + enabled: true, + result: { + enabled: true, + candidates: [ + { + rank: 1, + code: '600000', + name: '浦发银行', + score: 88, + reason: '低估值', + amount: 1042000000, + factorScores: { value: 87 }, + raw: {}, + }, + ], + candidateCount: 1, + snapshotCount: 50, + afterFilterCount: 10, + llmRanked: true, + }, + }); + }); + + render(); + + // 自动恢复 run-a 还在挂起时,用户手动点开历史里的 run-b(策略 dual_low → 显示 Dual Low) + fireEvent.click(await screen.findByText(/返回 1 只/)); + expect(await screen.findByText(/Dual Low · A 股/)).toBeInTheDocument(); + expect(screen.getByText('浦发银行')).toBeInTheDocument(); + + // run-a 较晚返回:不得覆盖用户手动选择的 run-b + await act(async () => { + runA.resolve({ + runId: 'run-a', + strategy: 'capital_heat', + market: 'cn', + candidateCount: 1, + enabled: true, + result: { + enabled: true, + candidates: [ + { + rank: 1, + code: '00700', + name: '腾讯控股', + score: 90, + reason: '热度因子领先', + amount: 1042000000, + factorScores: { heat: 92 }, + raw: {}, + }, + ], + candidateCount: 1, + snapshotCount: 50, + afterFilterCount: 10, + llmRanked: true, + }, + }); + }); + expect(screen.getByText(/Dual Low · A 股/)).toBeInTheDocument(); + expect(screen.getByText('浦发银行')).toBeInTheDocument(); + expect(screen.queryByText(/自定义策略 \(capital_heat\)/)).not.toBeInTheDocument(); + expect(screen.queryByText('腾讯控股')).not.toBeInTheDocument(); + }); + + it('cancels an in-flight screening task when a history run is selected', async () => { + getScreeningStatus.mockResolvedValue({ + enabled: true, + available: true, + }); + getHistory.mockResolvedValue({ + enabled: true, + runs: [ + { + runId: 'run-hist', + strategy: 'capital_heat', + market: 'cn', + candidateCount: 1, + createdAt: '2026-08-05T10:00:00Z', + }, + ], + runCount: 1, + }); + // 提交一个未完成的任务(pending):进入后台轮询 + getScreenTask.mockResolvedValueOnce({ + taskId: 'screen-task-1', + traceId: 'screen-task-1', + status: 'running', + progress: 40, + message: 'Screening 正在分析...', + result: null, + }); + screenStocks.mockResolvedValueOnce({ + enabled: true, + candidates: [ + { + rank: 1, + code: '600000', + name: '任务候选', + score: 80, + reason: '任务原因', + amount: 100, + factorScores: { value: 80 }, + raw: {}, + }, + ], + candidateCount: 1, + snapshotCount: 50, + afterFilterCount: 10, + llmRanked: true, + }); + getRun.mockResolvedValueOnce({ + runId: 'run-hist', + strategy: 'capital_heat', + market: 'cn', + candidateCount: 1, + enabled: true, + result: { + enabled: true, + candidates: [ + { + rank: 1, + code: '00700', + name: '历史候选', + score: 90, + reason: '历史原因', + amount: 1042000000, + factorScores: { heat: 92 }, + raw: {}, + }, + ], + candidateCount: 1, + snapshotCount: 50, + afterFilterCount: 10, + llmRanked: true, + }, + }); + + render(); + + // 等待选股开启后,提交选股任务进入轮询 + expect(await screen.findByText('选股已开启')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /运行选股/ })); + await waitFor(() => expect(startScreenTask).toHaveBeenCalled()); + await waitFor(() => expect(getScreenTask).toHaveBeenCalled()); + + // 任务仍在轮询时,点击历史记录 run(策略 capital_heat) + fireEvent.click(await screen.findByText('capital_heat')); + expect(await screen.findByText(/自定义策略 \(capital_heat\)/)).toBeInTheDocument(); + expect(screen.getByText('历史候选')).toBeInTheDocument(); + + // 后台任务随后完成,也不得把任务候选回写到历史上下文中 + expect(screen.queryByText('任务候选')).not.toBeInTheDocument(); + expect(screen.getByText(/自定义策略 \(capital_heat\)/)).toBeInTheDocument(); + }); + it('surfaces Screening LLM fallback instead of showing empty LLM fields as normal', async () => { getScreeningStatus.mockResolvedValueOnce({ enabled: true, @@ -1347,4 +1822,329 @@ describe('StockScreeningPage', () => { expect(screen.getByText('数据补充提示')).toBeInTheDocument(); expect(screen.getByText('stock_news_unavailable')).toBeInTheDocument(); }); + it('keeps the shared loading held when a stale auto-restore finishes while a manual history request is in flight', async () => { + // 回归 OR-COR-9b1f8c4e:过期的自动恢复请求不得在 finally 中无条件清掉共享 loading, + // 否则手动历史详情仍在飞行时“运行选股”会被提前放开。 + getScreeningStatus.mockResolvedValue({ + enabled: true, + available: true, + }); + window.sessionStorage.setItem('dsa.screening.activeScreenTask.v1', JSON.stringify({ + taskId: 'task-a', + runId: 'run-a', + strategy: 'capital_heat', + market: 'cn', + maxResults: 3, + })); + let resolveRunA: (value: unknown) => void = () => {}; + let resolveRunB: (value: unknown) => void = () => {}; + getRun.mockImplementation((runId: string) => { + if (runId === 'run-a') { + return new Promise((resolve) => { + resolveRunA = resolve; + }); + } + if (runId === 'run-b') { + return new Promise((resolve) => { + resolveRunB = resolve; + }); + } + return Promise.reject(new Error('run not found')); + }); + getHistory.mockResolvedValue({ + enabled: true, + runs: [ + { + runId: 'run-b', + strategy: 'dual_low', + market: 'cn', + candidateCount: 1, + createdAt: '2026-08-05T10:00:00Z', + }, + ], + runCount: 1, + }); + render(); + expect(await screen.findByText('选股已开启')).toBeInTheDocument(); + // 自动恢复 run-a 仍在飞行时,用户点开历史里的 run-b + fireEvent.click(await screen.findByRole('button', { name: /Dual Low/ })); + // 先让过期的自动恢复 run-a 结束(其响应已被 request-id 判定为过期) + resolveRunA({ + runId: 'run-a', + strategy: 'capital_heat', + market: 'cn', + candidateCount: 1, + enabled: true, + result: { + enabled: true, + candidates: [ + { + rank: 1, + code: '600519', + name: '贵州茅台', + score: 90, + reason: '热度', + raw: {}, + }, + ], + candidateCount: 1, + snapshotCount: 50, + afterFilterCount: 10, + llmRanked: true, + }, + }); + await act(async () => {}); + // 过期 finally 不清共享 loading:表单仍处于禁用态(按钮在 loading 时渲染为 spinner, + // 故用市场下拉框断言),页面也未切到任何历史结果 + expect(screen.getByLabelText('市场')).toBeDisabled(); + expect(screen.queryByText(/Dual Low · A 股/)).not.toBeInTheDocument(); + expect(screen.queryByText(/自定义策略 \(capital_heat\)/)).not.toBeInTheDocument(); + // 随后 run-b 正常返回:结果恢复,loading 由本次请求自己收口 + resolveRunB({ + runId: 'run-b', + strategy: 'dual_low', + market: 'cn', + candidateCount: 1, + enabled: true, + result: { + enabled: true, + candidates: [ + { + rank: 1, + code: '600000', + name: '浦发银行', + score: 88, + reason: '低估值', + raw: {}, + }, + ], + candidateCount: 1, + snapshotCount: 50, + afterFilterCount: 10, + llmRanked: true, + }, + }); + expect(await screen.findByText(/Dual Low · A 股/)).toBeInTheDocument(); + await waitFor(() => expect(screen.getByLabelText('市场')).toBeEnabled()); + }); + it('polls a newly submitted task immediately while a stale auto-restore request is still pending', async () => { + // 回归 OR-COR-2c71d8af:handleSubmit 必须解除自动恢复门闩并作废飞行中的恢复请求, + // 否则新任务的轮询会被阻塞到旧请求超时,页面假死在提交进度。 + getScreeningStatus.mockResolvedValue({ + enabled: true, + available: true, + }); + window.sessionStorage.setItem('dsa.screening.activeScreenTask.v1', JSON.stringify({ + taskId: 'task-a', + runId: 'run-a', + strategy: 'capital_heat', + market: 'cn', + maxResults: 3, + })); + // 自动恢复 run-a 永远挂起(模拟卡死的旧请求) + getRun.mockImplementation((runId: string) => { + if (runId === 'run-a') { + return new Promise(() => {}); + } + return Promise.resolve({ + runId: 'run-b', + strategy: 'dual_low', + market: 'cn', + candidateCount: 1, + enabled: true, + result: { + enabled: true, + candidates: [ + { + rank: 1, + code: '600000', + name: '历史候选', + score: 80, + reason: '历史', + raw: {}, + }, + ], + candidateCount: 1, + snapshotCount: 50, + afterFilterCount: 10, + llmRanked: true, + }, + }); + }); + getHistory.mockResolvedValue({ + enabled: true, + runs: [ + { + runId: 'run-b', + strategy: 'dual_low', + market: 'cn', + candidateCount: 1, + createdAt: '2026-08-05T10:00:00Z', + }, + ], + runCount: 1, + }); + screenStocks.mockResolvedValue({ + enabled: true, + candidates: [ + { + rank: 1, + code: '000001', + name: '新任务候选', + score: 88, + reason: '新任务', + raw: {}, + }, + ], + candidateCount: 1, + }); + render(); + expect(await screen.findByText('选股已开启')).toBeInTheDocument(); + // 自动恢复挂起时,用户先打开历史 run-b(正常返回并由该请求自身收口 loading) + fireEvent.click(await screen.findByRole('button', { name: /Dual Low/ })); + expect(await screen.findByText(/Dual Low · A 股/)).toBeInTheDocument(); + // 随即发起新任务:轮询必须立即启动,不被仍挂起的自动恢复门闩阻塞 + fireEvent.click(screen.getByRole('button', { name: /运行选股/ })); + await waitFor(() => expect(getScreenTask).toHaveBeenCalledTimes(1)); + expect(await screen.findByText(/新任务候选/)).toBeInTheDocument(); + }); + it('does not rewrite a restored custom strategy when the strategy list arrives late', async () => { + // 回归:迟到的 /strategies 响应不得把历史/恢复上下文中的自定义策略改写回默认策略。 + let resolveStrategies: (value: unknown) => void = () => {}; + getStrategies.mockImplementation( + () => new Promise((resolve) => { + resolveStrategies = resolve; + }), + ); + getScreeningStatus.mockResolvedValue({ + enabled: true, + available: true, + }); + window.sessionStorage.setItem('dsa.screening.activeScreenTask.v1', JSON.stringify({ + taskId: 'task-a', + runId: 'run-a', + strategy: 'capital_heat', + market: 'cn', + maxResults: 3, + })); + getRun.mockResolvedValue({ + runId: 'run-a', + strategy: 'capital_heat', + market: 'cn', + candidateCount: 1, + enabled: true, + result: { + enabled: true, + candidates: [ + { + rank: 1, + code: '600519', + name: '贵州茅台', + score: 90, + reason: '热度', + raw: {}, + }, + ], + candidateCount: 1, + snapshotCount: 50, + afterFilterCount: 10, + llmRanked: true, + }, + }); + getHistory.mockResolvedValue({ enabled: true, runs: [], runCount: 0 }); + render(); + expect(await screen.findByText('选股已开启')).toBeInTheDocument(); + // 自动恢复先应用历史上下文(此时策略列表请求仍挂起) + expect(await screen.findByText(/自定义策略 \(capital_heat\) · A 股/)).toBeInTheDocument(); + // 迟到的策略列表不包含该历史策略 + resolveStrategies({ + enabled: true, + strategies: [ + { id: 'dual_low', name: '双低', description: 'desc', category: '价值' }, + ], + strategyCount: 1, + }); + await act(async () => {}); + // 归一化被跳过:表单下拉切到自定义项、输入框保留原始 ID、结果区标题不变 + expect(screen.getByLabelText('策略')).toHaveValue('__custom_strategy__'); + expect(screen.getByLabelText('自定义策略 ID')).toHaveValue('capital_heat'); + expect(screen.getByText(/自定义策略 \(capital_heat\) · A 股/)).toBeInTheDocument(); + }); + it('persists the selected history run so a refresh restores that run instead of the stale task', async () => { + // 回归 OR-COR-4d1a7e90:手动打开历史记录后必须同步持久化恢复指针, + // 刷新后应恢复用户刚选中的历史 run,而不是停留在更早的 task。 + getScreeningStatus.mockResolvedValue({ + enabled: true, + available: true, + }); + window.sessionStorage.setItem('dsa.screening.activeScreenTask.v1', JSON.stringify({ + taskId: 'task-a', + strategy: 'dual_low', + market: 'cn', + maxResults: 3, + })); + getHistory.mockResolvedValue({ + enabled: true, + runs: [ + { + runId: 'run-b', + strategy: 'capital_heat', + market: 'cn', + candidateCount: 1, + createdAt: '2026-08-05T10:00:00Z', + }, + ], + runCount: 1, + }); + getRun.mockResolvedValue({ + runId: 'run-b', + strategy: 'capital_heat', + market: 'cn', + candidateCount: 1, + enabled: true, + result: { + enabled: true, + candidates: [ + { + rank: 1, + code: '600519', + name: '贵州茅台', + score: 90, + reason: '热度', + raw: {}, + }, + ], + candidateCount: 1, + snapshotCount: 50, + afterFilterCount: 10, + llmRanked: true, + }, + }); + getScreenTask.mockResolvedValue({ + taskId: 'task-a', + traceId: 'task-a', + status: 'processing', + progress: 10, + message: '正在执行 Screening 选股', + result: null, + }); + const first = render(); + expect(await screen.findByText('选股已开启')).toBeInTheDocument(); + // 手动选中历史记录 run-b(策略 capital_heat) + fireEvent.click(await screen.findByText('capital_heat')); + expect(await screen.findByText(/自定义策略 \(capital_heat\) · A 股/)).toBeInTheDocument(); + // 持久化指针已切换到刚选中的历史 run + const stored = JSON.parse( + window.sessionStorage.getItem('dsa.screening.activeScreenTask.v1') || '{}', + ); + expect(stored.runId).toBe('run-b'); + expect(stored.taskId).toBe('run-b'); + // 刷新(重新挂载)后按新指针恢复 run-b,而不是旧 task-a + first.unmount(); + render(); + await waitFor(() => expect(getRun).toHaveBeenLastCalledWith('run-b')); + expect(await screen.findByText(/自定义策略 \(capital_heat\) · A 股/)).toBeInTheDocument(); + // 正常恢复成功时不应对占位 taskId 触发轮询回退 + expect(getScreenTask).not.toHaveBeenCalledWith('run-b'); + }); }); diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 480f4174c..e313ab921 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -60,6 +60,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - [改进] AIHubMix 注册与引流链接统一使用 inferera.com,改善中国大陆网络直连体验。 - [修复] 单股推送模式在未配置通知渠道时仍会落盘本地个股报告;CLI 启动分析若因空股票列表、个股结果全失败或本地报告保存失败而未生成报告,会显式返回失败并记录原因。 - [修复] 合并推送模式下即使个股汇总报告落盘失败,仍会先发送已有的合并通知;仅启用大盘复盘但最终未生成任何复盘内容时,分析任务会显式返回失败。 +- [修复] 选股结果持久化恢复:选股页新增历史记录区块,任务完成后保留 run_id,刷新页面可从历史 API 恢复上次选股结果(此前刷新后结果丢失)。 - [修复] Web/API runtime scheduler 使用跨平台独立进程执行分析,并在默认 45 分钟硬超时或服务停止后清理进程树;停止返回后不再派发新的自动任务,避免一次卡死阻断后续调度。 - [修复] SearXNG 公共实例发现的默认值由启用改为关闭:公共实例普遍存在限流、下线或不返回 JSON 的情况,默认开启会让未配置搜索 key 的用户每次分析多耗 30~60 秒且新闻面最终为空。运行时默认值、配置模板、中英文档与工作流诊断同步调整;显式设为 true 的用户行为不变。 - [改进] 新闻检索未执行或零命中时,报告中如实标注结论未纳入新闻面证据:零命中与「未配置搜索渠道」使用各自独立的文案,覆盖日报 / dashboard / brief / 个股 / 企业微信与模板渲染的详细与摘要分支、历史报告与分享导出、报告详情 API 与 Web 报告详情页,并按 `zh` / `en` / `ko` 分别本地化。此前该情况下消息面章节直接消失,读者无从区分「确实没有新闻」与「检索静默失败」。披露以本次分析实际收到的消息面证据为准,涵盖实时检索、社交情绪与本地已落库的资讯池三路来源;搜索命中数仅用于在确无证据时说明原因(未配置渠道 / 检索零命中),避免把已用到本地或社交证据的分析误报成「未纳入新闻面证据」。Agent 模式的命中数取自 Agent 实际消费的搜索工具结果,而非分析结束后为持久化情报而补打的查询。