fix: refresh report after reanalysis completes (#1809)

* fix: refresh report after reanalysis completes

* fix: preserve completed report selection

* fix(review-feedback-1809): 修复

* fix: type completed refresh test promise
This commit is contained in:
zhulinsen
2026-06-27 20:12:47 +08:00
committed by GitHub
parent ebf340a519
commit 5eec53362d
7 changed files with 482 additions and 11 deletions

View File

@@ -108,8 +108,9 @@ describe('useDashboardLifecycle', () => {
expect(removeTask).not.toHaveBeenCalled();
});
it('refreshes history and removes completed tasks after the grace window', () => {
it('refreshes completed task history and removes completed tasks after the grace window', () => {
const refreshHistory = vi.fn().mockResolvedValue(undefined);
const refreshHistoryForCompletedTask = vi.fn().mockResolvedValue(undefined);
const syncTaskUpdated = vi.fn();
const removeTask = vi.fn();
@@ -117,6 +118,7 @@ describe('useDashboardLifecycle', () => {
useDashboardLifecycle({
loadInitialHistory: vi.fn().mockResolvedValue(undefined),
refreshHistory,
refreshHistoryForCompletedTask,
refreshActiveTasks: vi.fn().mockResolvedValue(undefined),
syncTaskCreated: vi.fn(),
syncTaskUpdated,
@@ -134,7 +136,8 @@ describe('useDashboardLifecycle', () => {
});
expect(syncTaskUpdated).toHaveBeenCalledWith(completedTask);
expect(refreshHistory).toHaveBeenCalledWith(true);
expect(refreshHistoryForCompletedTask).toHaveBeenCalledWith(completedTask);
expect(refreshHistory).not.toHaveBeenCalledWith(true);
expect(defaultMocks.refreshMarketReviewHistory).toHaveBeenCalledWith(true);
act(() => {

View File

@@ -5,6 +5,7 @@ import { useTaskStream } from './useTaskStream';
type UseDashboardLifecycleOptions = {
loadInitialHistory: () => Promise<void>;
refreshHistory: (silent?: boolean) => Promise<void>;
refreshHistoryForCompletedTask?: (task: TaskInfo) => Promise<void>;
refreshActiveTasks: () => Promise<void>;
loadStockBar: () => Promise<void>;
refreshStockBar: () => Promise<void>;
@@ -20,6 +21,7 @@ type UseDashboardLifecycleOptions = {
export function useDashboardLifecycle({
loadInitialHistory,
refreshHistory,
refreshHistoryForCompletedTask,
refreshActiveTasks,
loadStockBar,
refreshStockBar,
@@ -102,7 +104,11 @@ export function useDashboardLifecycle({
},
onTaskCompleted: (task) => {
syncTaskUpdated(task);
void refreshHistory(true);
if (refreshHistoryForCompletedTask) {
void refreshHistoryForCompletedTask(task);
} else {
void refreshHistory(true);
}
void refreshStockBar();
void refreshMarketReviewHistory?.(true);
scheduleTaskRemoval(task.taskId, 2_000);

View File

@@ -44,6 +44,7 @@ export function useHomeDashboardState() {
clearError: state.clearError,
loadInitialHistory: state.loadInitialHistory,
refreshHistory: state.refreshHistory,
refreshHistoryForCompletedTask: state.refreshHistoryForCompletedTask,
loadMoreHistory: state.loadMoreHistory,
loadMarketReviewHistory: state.loadMarketReviewHistory,
refreshMarketReviewHistory: state.refreshMarketReviewHistory,

View File

@@ -113,6 +113,7 @@ const HomePage: React.FC = () => {
clearError,
loadInitialHistory,
refreshHistory,
refreshHistoryForCompletedTask,
loadMarketReviewHistory,
refreshMarketReviewHistory,
selectHistoryItem,
@@ -359,6 +360,7 @@ const HomePage: React.FC = () => {
useDashboardLifecycle({
loadInitialHistory,
refreshHistory,
refreshHistoryForCompletedTask,
loadMarketReviewHistory,
refreshMarketReviewHistory,
loadStockBar,

View File

@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { analysisApi, DuplicateTaskError } from '../../api/analysis';
import { historyApi } from '../../api/history';
import type { TaskInfo, TaskListResponse } from '../../types/analysis';
import type { AnalysisReport, HistoryListResponse, TaskInfo, TaskListResponse } from '../../types/analysis';
import { getRecentStartDate, getTodayInShanghai } from '../../utils/format';
import { useStockPoolStore } from '../stockPoolStore';
@@ -562,6 +562,339 @@ describe('stockPoolStore', () => {
expect(state.currentPage).toBe(1);
});
it('selects the newest report for the completed task stock during silent refresh', async () => {
const latestItem = {
...historyItem,
id: 2,
queryId: 'q-2',
createdAt: '2026-03-18T09:00:00Z',
};
const latestReport = {
...historyReport,
meta: {
...historyReport.meta,
id: 2,
queryId: 'q-2',
createdAt: '2026-03-18T09:00:00Z',
},
};
useStockPoolStore.setState({
historyItems: [historyItem],
selectedReport: historyReport,
});
vi.mocked(historyApi.getList).mockResolvedValue({
total: 2,
page: 1,
limit: 20,
items: [latestItem, historyItem],
});
vi.mocked(historyApi.getDetail).mockResolvedValue(latestReport);
await useStockPoolStore.getState().refreshHistoryForCompletedTask(createTask({
status: 'completed',
progress: 100,
}));
const state = useStockPoolStore.getState();
expect(historyApi.getDetail).toHaveBeenCalledWith(2);
expect(state.historyItems.map((item) => item.id)).toEqual([2, 1]);
expect(state.selectedReport?.meta.id).toBe(2);
});
it('selects the completed-task report after an overlapping refresh supersedes the original request', async () => {
const latestItem = {
...historyItem,
id: 2,
queryId: 'q-2',
createdAt: '2026-03-18T09:00:00Z',
};
const latestReport = {
...historyReport,
meta: {
...historyReport.meta,
id: 2,
queryId: 'q-2',
createdAt: '2026-03-18T09:00:00Z',
},
};
const completedRefresh = createDeferred<HistoryListResponse>();
const overlappingRefresh = createDeferred<HistoryListResponse>();
useStockPoolStore.setState({
historyItems: [historyItem],
selectedReport: historyReport,
});
vi.mocked(historyApi.getList)
.mockReturnValueOnce(completedRefresh.promise)
.mockReturnValueOnce(overlappingRefresh.promise);
vi.mocked(historyApi.getDetail).mockResolvedValue(latestReport);
const completedRefreshPromise = useStockPoolStore.getState().refreshHistoryForCompletedTask(createTask({
status: 'completed',
progress: 100,
}));
const overlappingRefreshPromise = useStockPoolStore.getState().refreshHistory(true);
overlappingRefresh.resolve({
total: 2,
page: 1,
limit: 20,
items: [latestItem, historyItem],
});
await overlappingRefreshPromise;
completedRefresh.resolve({
total: 2,
page: 1,
limit: 20,
items: [latestItem, historyItem],
});
await completedRefreshPromise;
const state = useStockPoolStore.getState();
expect(historyApi.getDetail).toHaveBeenCalledTimes(1);
expect(historyApi.getDetail).toHaveBeenCalledWith(2);
expect(state.historyItems.map((item) => item.id)).toEqual([2, 1]);
expect(state.selectedReport?.meta.id).toBe(2);
});
it('selects the newest completed-task report when stock codes use equivalent aliases', async () => {
const olderTencentItem = {
...historyItem,
id: 10,
queryId: 'q-10',
stockCode: 'HK00700',
stockName: '腾讯控股',
};
const olderTencentReport = {
...historyReport,
meta: {
...historyReport.meta,
id: 10,
queryId: 'q-10',
stockCode: 'HK00700',
stockName: '腾讯控股',
},
};
const latestTencentItem = {
...olderTencentItem,
id: 11,
queryId: 'q-11',
stockCode: '00700.HK',
createdAt: '2026-03-18T09:00:00Z',
};
const latestTencentReport = {
...olderTencentReport,
meta: {
...olderTencentReport.meta,
id: 11,
queryId: 'q-11',
stockCode: '00700.HK',
createdAt: '2026-03-18T09:00:00Z',
},
};
useStockPoolStore.setState({
historyItems: [olderTencentItem],
selectedReport: olderTencentReport,
});
vi.mocked(historyApi.getList).mockResolvedValue({
total: 2,
page: 1,
limit: 20,
items: [latestTencentItem, olderTencentItem],
});
vi.mocked(historyApi.getDetail).mockResolvedValue(latestTencentReport);
await useStockPoolStore.getState().refreshHistoryForCompletedTask(createTask({
stockCode: '00700.HK',
stockName: '腾讯控股',
status: 'completed',
progress: 100,
}));
const state = useStockPoolStore.getState();
expect(historyApi.getDetail).toHaveBeenCalledWith(11);
expect(state.historyItems.map((item) => item.id)).toEqual([11, 10]);
expect(state.selectedReport?.meta.id).toBe(11);
});
it('does not replace the selected report when another stock task completes', async () => {
const otherReport = {
...historyReport,
meta: {
...historyReport.meta,
id: 3,
queryId: 'q-3',
stockCode: 'AAPL',
stockName: 'Apple',
},
};
const latestItem = {
...historyItem,
id: 2,
queryId: 'q-2',
createdAt: '2026-03-18T09:00:00Z',
};
useStockPoolStore.setState({
historyItems: [historyItem],
selectedReport: otherReport,
});
vi.mocked(historyApi.getList).mockResolvedValue({
total: 2,
page: 1,
limit: 20,
items: [latestItem, historyItem],
});
await useStockPoolStore.getState().refreshHistoryForCompletedTask(createTask({
status: 'completed',
progress: 100,
}));
const state = useStockPoolStore.getState();
expect(historyApi.getDetail).not.toHaveBeenCalled();
expect(state.historyItems.map((item) => item.id)).toEqual([2, 1]);
expect(state.selectedReport?.meta.stockCode).toBe('AAPL');
});
it('does not auto-switch to completed-task latest when the selected report changed before the refresh response returns', async () => {
const latestItem = {
...historyItem,
id: 2,
queryId: 'q-2',
createdAt: '2026-03-18T09:00:00Z',
};
const latestCompletedReport = {
...historyReport,
meta: {
...historyReport.meta,
id: 2,
queryId: 'q-2',
createdAt: '2026-03-18T09:00:00Z',
},
};
const switchedReport = {
...historyReport,
meta: {
...historyReport.meta,
id: 3,
queryId: 'q-3',
},
};
const completedRefreshResponse = createDeferred<HistoryListResponse>();
useStockPoolStore.setState({
historyItems: [historyItem],
selectedReport: historyReport,
});
vi.mocked(historyApi.getList).mockReturnValue(completedRefreshResponse.promise);
vi.mocked(historyApi.getDetail).mockResolvedValue(latestCompletedReport);
const completedRefreshPromise = useStockPoolStore.getState().refreshHistoryForCompletedTask(createTask({
status: 'completed',
progress: 100,
}));
useStockPoolStore.setState({
selectedReport: switchedReport,
});
completedRefreshResponse.resolve({
total: 2,
page: 1,
limit: 20,
items: [latestItem, historyItem],
});
await completedRefreshPromise;
const state = useStockPoolStore.getState();
expect(state.historyItems.map((item) => item.id)).toEqual([2, 1]);
expect(state.selectedReport?.meta.id).toBe(3);
expect(historyApi.getDetail).not.toHaveBeenCalled();
});
it('does not auto-switch report when user selection is pending during completed-task refresh', async () => {
const latestItem = {
...historyItem,
id: 2,
queryId: 'q-2',
createdAt: '2026-03-18T09:00:00Z',
};
const completedRefreshResponse = createDeferred<HistoryListResponse>();
const userSelectionDetail = createDeferred<AnalysisReport>();
const userSelectionReport = {
...historyReport,
meta: {
...historyReport.meta,
id: 3,
queryId: 'q-3',
stockCode: 'AAPL',
stockName: 'Apple',
createdAt: '2026-03-18T07:00:00Z',
},
};
const latestCompletedReport = {
...historyReport,
meta: {
...historyReport.meta,
id: 2,
queryId: 'q-2',
createdAt: '2026-03-18T09:00:00Z',
},
};
useStockPoolStore.setState({
historyItems: [
historyItem,
{
...historyItem,
id: 3,
queryId: 'q-3',
stockCode: 'AAPL',
stockName: 'Apple',
createdAt: '2026-03-18T07:00:00Z',
},
],
selectedReport: historyReport,
});
vi.mocked(historyApi.getList).mockReturnValueOnce(completedRefreshResponse.promise);
vi.mocked(historyApi.getDetail)
.mockReturnValueOnce(userSelectionDetail.promise)
.mockResolvedValue(latestCompletedReport);
const completedRefreshPromise = useStockPoolStore.getState().refreshHistoryForCompletedTask(
createTask({
status: 'completed',
progress: 100,
}),
);
const manualSelectionPromise = useStockPoolStore.getState().selectHistoryItem(3);
completedRefreshResponse.resolve({
total: 2,
page: 1,
limit: 20,
items: [latestItem, historyItem],
});
await completedRefreshPromise;
const midState = useStockPoolStore.getState();
expect(midState.selectedReport?.meta.id).toBe(historyReport.meta.id);
expect(historyApi.getDetail).toHaveBeenCalledWith(3);
expect(historyApi.getDetail).toHaveBeenCalledTimes(1);
userSelectionDetail.resolve(userSelectionReport);
await manualSelectionPromise;
const state = useStockPoolStore.getState();
expect(state.selectedReport?.meta.id).toBe(3);
expect(state.selectedReport?.meta.stockCode).toBe('AAPL');
expect(state.historyItems.map((item) => item.id)).toEqual([2, 1, 3]);
});
it('ignores late history responses after dashboard reset', async () => {
const deferred = createDeferred<{
total: number;

View File

@@ -5,6 +5,7 @@ import { getParsedApiError } from '../api/error';
import { historyApi } from '../api/history';
import type { AnalysisReport, HistoryItem, HistoryListResponse, ReportLanguage, StockBarItem, StockHistoryFilters, StockHistoryRange, TaskInfo } from '../types/analysis';
import { getRecentStartDate, getTodayInShanghai } from '../utils/format';
import { normalizeStockCode } from '../utils/stockCode';
import { isObviouslyInvalidStockQuery, looksLikeStockCode, validateStockCode } from '../utils/validation';
const PAGE_SIZE = 20;
@@ -18,6 +19,7 @@ type FetchHistoryOptions = {
autoSelectFirst?: boolean;
reset?: boolean;
silent?: boolean;
selectLatestForStockCode?: string;
};
type SubmitAnalysisOptions = {
@@ -31,6 +33,11 @@ type SubmitAnalysisOptions = {
reportLanguage?: ReportLanguage;
};
type CompletedTaskSelectionIntent = {
manualSelectionSeq: number;
selectedReportId: number | undefined;
};
let reportRequestSeq = 0;
let analyzeRequestSeq = 0;
let historyRequestSeq = 0;
@@ -38,7 +45,10 @@ let marketReviewHistoryRequestSeq = 0;
let stockHistoryRequestSeq = 0;
let activeTaskRequestSeq = 0;
let activeTaskLocalRevision = 0;
let manualSelectionRequestSeq = 0;
let manualSelectionRequestId = 0;
const dismissedTaskIds = new Set<string>();
const pendingCompletedTaskSelectionKeys = new Map<string, CompletedTaskSelectionIntent>();
export interface StockPoolState {
query: string;
@@ -88,11 +98,12 @@ export interface StockPoolState {
loadMoreStockHistory: () => Promise<void>;
loadInitialHistory: () => Promise<void>;
refreshHistory: (silent?: boolean) => Promise<void>;
refreshHistoryForCompletedTask: (task: TaskInfo) => Promise<void>;
loadMoreHistory: () => Promise<void>;
loadMarketReviewHistory: () => Promise<void>;
refreshMarketReviewHistory: (silent?: boolean) => Promise<void>;
loadMoreMarketReviewHistory: () => Promise<void>;
selectHistoryItem: (recordId: number) => Promise<void>;
selectHistoryItem: (recordId: number, isUserInitiated?: boolean) => Promise<void>;
toggleHistorySelection: (recordId: number) => void;
toggleSelectAllVisible: () => void;
deleteSelectedHistory: () => Promise<void>;
@@ -238,6 +249,87 @@ function normalizeSelectedReport(report: AnalysisReport): AnalysisReport {
};
}
function normalizeStockCodeKey(stockCode: string | undefined): string {
const trimmed = (stockCode ?? '').trim();
return trimmed ? normalizeStockCode(trimmed).toUpperCase() : '';
}
function queueCompletedTaskSelection(
stockCode: string | undefined,
selectedReport: AnalysisReport | null,
): void {
const key = normalizeStockCodeKey(stockCode);
if (key) {
pendingCompletedTaskSelectionKeys.set(key, {
manualSelectionSeq: manualSelectionRequestSeq,
selectedReportId: selectedReport?.meta.id,
});
}
}
function consumeCompletedTaskSelection(items: HistoryItem[], selectedReport: AnalysisReport | null): HistoryItem | undefined {
if (pendingCompletedTaskSelectionKeys.size === 0) {
return undefined;
}
if (manualSelectionRequestId !== 0) {
pendingCompletedTaskSelectionKeys.clear();
return undefined;
}
if (selectedReport?.meta.reportType === 'market_review') {
pendingCompletedTaskSelectionKeys.clear();
return undefined;
}
if (selectedReport) {
const selectedStockCode = normalizeStockCodeKey(selectedReport.meta.stockCode);
const pendingSelectionIntent = selectedStockCode
? pendingCompletedTaskSelectionKeys.get(selectedStockCode)
: undefined;
if (!selectedStockCode || pendingSelectionIntent === undefined) {
pendingCompletedTaskSelectionKeys.clear();
return undefined;
}
if (pendingSelectionIntent.manualSelectionSeq !== manualSelectionRequestSeq) {
pendingCompletedTaskSelectionKeys.clear();
return undefined;
}
if (pendingSelectionIntent.selectedReportId !== selectedReport.meta.id) {
pendingCompletedTaskSelectionKeys.clear();
return undefined;
}
for (const key of Array.from(pendingCompletedTaskSelectionKeys.keys())) {
if (key !== selectedStockCode) {
pendingCompletedTaskSelectionKeys.delete(key);
}
}
const latestItem = items.find(
(item) =>
item.reportType !== 'market_review' &&
normalizeStockCodeKey(item.stockCode) === selectedStockCode,
);
if (latestItem) {
pendingCompletedTaskSelectionKeys.delete(selectedStockCode);
}
return latestItem;
}
const latestItem = items.find((item) => {
if (item.reportType === 'market_review') {
return false;
}
const stockCode = normalizeStockCodeKey(item.stockCode);
const pendingSelectionIntent = pendingCompletedTaskSelectionKeys.get(stockCode);
return stockCode.length > 0 && pendingSelectionIntent?.manualSelectionSeq === manualSelectionRequestSeq;
});
if (latestItem) {
pendingCompletedTaskSelectionKeys.clear();
}
return latestItem;
}
function isDateInHistoryRange(createdAt: string | undefined, range: StockHistoryRange): boolean {
if (range === 'all') {
return true;
@@ -353,9 +445,17 @@ async function fetchHistory(
set: (partial: Partial<StockPoolState>) => void,
options: FetchHistoryOptions = {},
): Promise<HistoryListResponse | null> {
const { autoSelectFirst = false, reset = true, silent = false } = options;
const {
autoSelectFirst = false,
reset = true,
silent = false,
selectLatestForStockCode,
} = options;
const currentState = get();
const page = reset ? 1 : currentState.currentPage + 1;
if (reset) {
queueCompletedTaskSelection(selectLatestForStockCode, currentState.selectedReport);
}
const requestId = ++historyRequestSeq;
if (!silent) {
@@ -400,8 +500,14 @@ async function fetchHistory(
selectedHistoryIds: get().selectedHistoryIds.filter((id) => visibleIds.has(id)),
});
if (autoSelectFirst && response.items.length > 0 && !get().selectedReport) {
await get().selectHistoryItem(response.items[0].id);
if (reset) {
const latestCompletedTaskItem = consumeCompletedTaskSelection(response.items, get().selectedReport);
const selectedReport = get().selectedReport;
if (latestCompletedTaskItem && latestCompletedTaskItem.id !== selectedReport?.meta.id) {
await get().selectHistoryItem(latestCompletedTaskItem.id, false);
} else if (autoSelectFirst && response.items.length > 0 && !selectedReport) {
await get().selectHistoryItem(response.items[0].id, false);
}
}
return response;
@@ -554,6 +660,14 @@ export const useStockPoolStore = create<StockPoolState>((set, get) => ({
await fetchHistory(get, set, { reset: true, silent });
},
refreshHistoryForCompletedTask: async (task) => {
await fetchHistory(get, set, {
reset: true,
silent: true,
selectLatestForStockCode: task.reportType === 'market_review' ? undefined : task.stockCode,
});
},
loadMoreHistory: async () => {
const state = get();
if (state.isLoadingMore || !state.hasMore) {
@@ -578,8 +692,12 @@ export const useStockPoolStore = create<StockPoolState>((set, get) => ({
await fetchMarketReviewHistory(get, set, { reset: false });
},
selectHistoryItem: async (recordId) => {
selectHistoryItem: async (recordId, isUserInitiated = true) => {
const requestId = ++reportRequestSeq;
if (isUserInitiated) {
manualSelectionRequestSeq += 1;
manualSelectionRequestId = requestId;
}
const shouldShowInitialLoading = !get().selectedReport;
if (shouldShowInitialLoading) {
@@ -617,6 +735,10 @@ export const useStockPoolStore = create<StockPoolState>((set, get) => ({
error: getParsedApiError(error),
isLoadingReport: false,
});
} finally {
if (isUserInitiated && manualSelectionRequestId === requestId) {
manualSelectionRequestId = 0;
}
}
},
@@ -666,7 +788,7 @@ export const useStockPoolStore = create<StockPoolState>((set, get) => ({
if (selectedWasDeleted) {
const nextItem = freshPage?.items?.[0];
if (nextItem) {
await get().selectHistoryItem(nextItem.id);
await get().selectHistoryItem(nextItem.id, false);
} else {
stockHistoryRequestSeq += 1;
resetStockHistoryState(set);
@@ -730,7 +852,7 @@ export const useStockPoolStore = create<StockPoolState>((set, get) => ({
if (selectedWasDeleted) {
const nextItem = freshPage?.items?.[0];
if (nextItem) {
await get().selectHistoryItem(nextItem.id);
await get().selectHistoryItem(nextItem.id, false);
} else {
set({ selectedReport: null });
}
@@ -914,9 +1036,12 @@ export const useStockPoolStore = create<StockPoolState>((set, get) => ({
stockHistoryRequestSeq += 1;
reportRequestSeq = 0;
analyzeRequestSeq = 0;
manualSelectionRequestSeq = 0;
manualSelectionRequestId = 0;
activeTaskRequestSeq += 1;
activeTaskLocalRevision += 1;
dismissedTaskIds.clear();
pendingCompletedTaskSelectionKeys.clear();
set({ ...initialState });
},

View File

@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [修复] 修复通知 Markdown 表格转换在空单元格后将后续内容错配到错误表头的问题。
- [修复] 将 Docker 可安装的 Longbridge SDK 版本固定为 0.2.75,避免 `longbridge>=0.2.77` 从包索引消失后导致 docker-build 失败。
- [修复] Web 首页重新分析完成后自动切换到同一股票最新生成的报告,避免仍停留在旧报告内容。
- [修复] 默认通知报告补充展示 `dashboard.phase_decision` 盘中决策护栏字段,避免与模板渲染路径展示不一致。
- [修复] 修复 Windows 环境下 Web/Desktop 静态 JS 资源可能被识别为 `text/plain` 导致前端黑屏的问题。