fix(web): 选股结果持久化恢复 - 新增历史记录入口并支持刷新恢复 (#2154)

* fix(web): 选股历史结果持久化恢复(方案A)

- 选股页新增历史记录区块:加载时展示最近 10 次选股运行,点击条目
  通过 history API 加载并展示该次完整结果
- 任务完成后将 runId 持久化到 sessionStorage(不再清空),刷新后
  优先从 history API 按 run_id 恢复结果,避免依赖内存态 task 队列
- task 恢复失败(服务重启/任务过期)时回退到原有 task 轮询逻辑
- 失败/未知状态任务仍清理持久化记录

* docs: 记录选股结果持久化恢复修复到 CHANGELOG

* fix(web): 闭合选股恢复状态清理的两个正确性 blocker

- 不可恢复任务分支补 clearPersistedScreenTask(),避免刷新后反复恢复同一失效任务
- clearScreeningResults() 同步清理持久化的 runId/taskId,切换筛选条件后刷新不再恢复旧结果
- 新增两条回归测试覆盖上述场景

* fix(web): 选股历史条目展示筛选条件,切换筛选条件不清除恢复状态

- 撤销场景2错误修复:clearScreeningResults() 不再清除持久化 runId/taskId,
  切换筛选条件后历史与刷新恢复能力完整保留
- 历史条目展示筛选条件:策略中文名(formatHistoryStrategyName)、
  市场标签(formatHistoryMarketLabel)、返回数量(该次 run 实际候选数 candidateCount)
- 重写对应回归测试 + 新增历史条件展示测试

* fix(web): 历史记录区块移到选股结果下方,符合先看结果再看历史的浏览顺序

* fix(web): 历史 run 恢复时同步策略与市场上下文;changelog 条目移入 Unreleased

- handleHistoryRunSelect 与挂载时 runId 恢复分支现在会从 history detail
  同步 strategy/market 到页面 state,结果区文案和后续深度分析的
  analysisSkills 使用该历史 run 对应的策略,而非当前表单选择
  (关闭 Codex P2 + OR-COR-6c1e5d27)
- CHANGELOG 选股持久化恢复条目从 [3.30.0] 文档小节移至 [Unreleased] 段
- 新增回归测试:点击历史条目后结果区展示同步后的策略/市场上下文

* fix(web): 忽略过期历史详情响应,修复快速切换历史条目的竞态

- handleHistoryRunSelect 用 historyRunRequestIdRef 追踪最新请求,
  响应返回时校验仍是最新请求才应用结果/错误/loading 状态,
  避免 run A 晚到覆盖 run B 的结果(关闭 Codex P2)
- 新增回归测试:先点 run-a 再点 run-b,run-a 迟到返回不覆盖
  run-b 的结果与策略上下文

* fix(web): 历史恢复与手动选择互斥,自动恢复接入竞态保护

- handleHistoryRunSelect 选择历史记录时取消运行中的选股任务轮询(setActiveTaskId(null)),避免任务完成后把当前任务候选回写到历史上下文
- 页面初始自动恢复 effect 接入 historyRunRequestIdRef 竞态保护:若自动恢复返回前用户已手动选择历史记录,则放弃自动恢复响应,避免较晚返回的旧 run 覆盖用户最新选择
- 自动恢复失败回退 task 轮询时也校验用户未手动选择历史记录
- 补充两条回归测试:晚到自动恢复不覆盖手动选择、选择历史时取消进行中的任务

* fix(web): 修复历史恢复链路三处竞态(OpenReview 2026-08-24 复审反馈)

- OR-COR-9b1f8c4e: 过期自动恢复请求的 finally 不再无条件清共享
  loading;setLoading(false) 复用 request-id 守卫,setRestoreResolved(true)
  保持无条件执行,避免提前放开"运行选股"入口
- OR-COR-2c71d8af: handleSubmit 递增 historyRunRequestIdRef 作废飞行中
  的历史/恢复响应并解除 restoreResolved 门闩,新任务轮询立即启动,
  迟到响应不再覆盖新任务状态
- 策略列表竞态: 新增 historyContextStrategyRef,历史选择/自动恢复写入
  的策略跳过 loadStrategies 的归一化回退,迟到的 /strategies 响应不再把
  历史自定义策略改写回默认策略

验证: StockScreeningPage 测试 35 passed(新增 3 条回归)、screening API
测试 13 passed、tsc -b && vite build 通过

* fix(web): 手动选中历史记录后同步持久化恢复指针(OR-COR-4d1a7e90)

handleHistoryRunSelect 成功加载历史详情时调用 persistScreenTask 记录当前
选中的 runId/strategy/market,刷新后自动恢复用户刚查看的历史 run,
而不是停留在更早的 task/run。历史详情不携带 taskId,以 runId 作为占位:
正常路径刷新走 getRun(runId) 恢复、不会触发轮询;若该 run 恢复失败,
占位轮询会命中不可恢复错误并清理过期指针。useCallback 依赖同步补全,
确保持久化使用当前表单值。

验证: StockScreeningPage 测试 36 passed(新增 1 条回归:选中历史后刷新
仍恢复该 run)、screening API 测试 13 passed、tsc -b && vite build 通过

---------

Co-authored-by: ariesy <ariesy.bleiben@gmail.com>
This commit is contained in:
Kun Zhu
2026-08-30 17:42:38 +08:00
committed by GitHub
parent 03e11fe35c
commit 77cc9ec30f
3 changed files with 1046 additions and 5 deletions

View File

@@ -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<string | null>(null);
const hotspotDetailRequestIdRef = useRef(0);
const hotspotDetailsByTopicRef = useRef<Record<string, ScreeningHotspotDetail>>({});
const historyRunRequestIdRef = useRef(0);
const [hotspotDetail, setHotspotDetail] = useState<ScreeningHotspotDetail | null>(null);
const [loadingHotspotDetail, setLoadingHotspotDetail] = useState(false);
const [searchingHotspotNews, setSearchingHotspotNews] = useState(false);
@@ -828,6 +859,14 @@ const StockScreeningPage: React.FC = () => {
const [expandedCode, setExpandedCode] = useState<string | null>(null);
const [loading, setLoading] = useState(Boolean(restoredTask?.taskId));
const [enabling, setEnabling] = useState(false);
const [historyRuns, setHistoryRuns] = useState<ScreeningRunSummary[]>([]);
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<typeof window.setTimeout> | 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 = () => {
)}
</section>
) : null}
<section className="rounded-2xl border border-border/80 bg-card/95 p-4 shadow-soft-card">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<Clock3 className="h-4 w-4 text-cyan" />
</div>
<button
type="button"
className="text-xs font-medium text-cyan transition-colors hover:text-foreground"
onClick={() => void loadHistory()}
disabled={historyLoading}
>
{historyLoading ? '加载中...' : '刷新'}
</button>
</div>
{historyError ? (
<p className="mb-3 text-xs text-danger">{historyError}</p>
) : null}
{historyRuns.length === 0 ? (
<p className="py-3 text-center text-xs text-secondary-text">
{historyLoading ? '正在加载历史记录...' : '暂无历史选股记录'}
</p>
) : (
<div className="divide-y divide-border/70">
{historyRuns.map((run) => (
<button
key={run.runId}
type="button"
className="flex w-full items-center justify-between gap-3 py-2.5 text-left transition-colors hover:bg-hover/50"
onClick={() => void handleHistoryRunSelect(run.runId)}
>
<span className="min-w-0">
<span className="block truncate text-sm font-semibold text-foreground">
{formatHistoryStrategyName(run.strategy, strategies)}
<span className="ml-2 text-xs font-normal text-secondary-text">
{formatHistoryMarketLabel(run.market)}
</span>
</span>
<span className="mt-0.5 block text-xs text-secondary-text">
{run.candidateCount ?? 0}
{run.snapshotCount != null ? ` · 快照 ${run.snapshotCount}` : ''}
{run.llmRanked ? ' · 智能重排' : ''}
</span>
</span>
<span className="shrink-0 text-xs text-secondary-text">
{formatRunCreatedAt(run.createdAt)}
</span>
</button>
))}
</div>
)}
</section>
</AppPage>
);
};

View File

@@ -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<T>() {
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(<StockScreeningPage />);
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(<StockScreeningPage />);
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(<StockScreeningPage />);
// 历史条目展示筛选条件:策略中文名 + 市场标签 + 返回数量(该次 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(<StockScreeningPage />);
// 点击历史记录中的 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<unknown>();
const runBDetail = createDeferred<unknown>();
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(<StockScreeningPage />);
// 先点 run-acapital_heat请求挂起再点 run-bdual_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(<StockScreeningPage />);
// 自动恢复 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(<StockScreeningPage />);
// 等待选股开启后,提交选股任务进入轮询
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(<StockScreeningPage />);
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-2c71d8afhandleSubmit 必须解除自动恢复门闩并作废飞行中的恢复请求,
// 否则新任务的轮询会被阻塞到旧请求超时,页面假死在提交进度。
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(<StockScreeningPage />);
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(<StockScreeningPage />);
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(<StockScreeningPage />);
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(<StockScreeningPage />);
await waitFor(() => expect(getRun).toHaveBeenLastCalledWith('run-b'));
expect(await screen.findByText(/自定义策略 \(capital_heat\) · A 股/)).toBeInTheDocument();
// 正常恢复成功时不应对占位 taskId 触发轮询回退
expect(getScreenTask).not.toHaveBeenCalledWith('run-b');
});
});

View File

@@ -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 实际消费的搜索工具结果,而非分析结束后为持久化情报而补打的查询。