From ae19329d6684c4ec4ad0b51e627c0c5204ccd594 Mon Sep 17 00:00:00 2001 From: zhulinsen <42829555+ZhuLinsen@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:45:12 +0800 Subject: [PATCH] fix: stabilize watchlist details and workspace state (#2126) --- .gitignore | 1 + apps/dsa-web/src/api/history.ts | 10 +- .../src/components/tasks/TaskPanel.tsx | 108 ++- .../tasks/__tests__/TaskPanel.test.tsx | 38 + .../watchlist/HomeStockWorkspace.tsx | 244 +++-- .../__tests__/HomeStockWorkspace.test.tsx | 304 ++++++ .../__tests__/useDashboardLifecycle.test.tsx | 3 + .../src/hooks/useDashboardLifecycle.ts | 3 + apps/dsa-web/src/i18n/uiText.ts | 30 + apps/dsa-web/src/pages/HomePage.tsx | 204 +++- .../src/pages/__tests__/HomePage.test.tsx | 897 +++++++++++++++++- .../stores/__tests__/stockPoolStore.test.ts | 1 + apps/dsa-web/src/stores/stockPoolStore.ts | 1 + docs/CHANGELOG.md | 2 + docs/full-guide.md | 3 +- docs/full-guide_EN.md | 3 +- 16 files changed, 1720 insertions(+), 132 deletions(-) create mode 100644 apps/dsa-web/src/components/watchlist/__tests__/HomeStockWorkspace.test.tsx diff --git a/.gitignore b/.gitignore index 17e28c663..2b26e35a3 100644 --- a/.gitignore +++ b/.gitignore @@ -87,4 +87,5 @@ verify_*.py static/ /apps/dsa-desktop/dist/ /apps/dsa-desktop/node_modules/ +/node_modules/ docs/specs/ diff --git a/apps/dsa-web/src/api/history.ts b/apps/dsa-web/src/api/history.ts index 6107b23e5..3ba772839 100644 --- a/apps/dsa-web/src/api/history.ts +++ b/apps/dsa-web/src/api/history.ts @@ -19,12 +19,19 @@ export interface GetHistoryListParams extends HistoryFilters { limit?: number; } +export interface GetHistoryListOptions { + signal?: AbortSignal; +} + export const historyApi = { /** * 获取历史分析列表 * @param params 筛选和分页参数 */ - getList: async (params: GetHistoryListParams = {}): Promise => { + getList: async ( + params: GetHistoryListParams = {}, + options: GetHistoryListOptions = {}, + ): Promise => { const { stockCode, reportType, startDate, endDate, page = 1, limit = 20 } = params; const queryParams: Record = { page, limit }; @@ -35,6 +42,7 @@ export const historyApi = { const response = await apiClient.get>('/api/v1/history', { params: queryParams, + signal: options.signal, }); const data = toCamelCase<{ total: number; page: number; limit: number; items: HistoryItem[] }>(response.data); diff --git a/apps/dsa-web/src/components/tasks/TaskPanel.tsx b/apps/dsa-web/src/components/tasks/TaskPanel.tsx index fbdbbb26c..1d50686fc 100644 --- a/apps/dsa-web/src/components/tasks/TaskPanel.tsx +++ b/apps/dsa-web/src/components/tasks/TaskPanel.tsx @@ -1,4 +1,5 @@ import type React from 'react'; +import { useId, useState } from 'react'; import { ChevronDown, RefreshCw, Workflow } from 'lucide-react'; import { Badge, Button, Card, StatusDot, Tooltip } from '../common'; import { DashboardPanelHeader } from '../dashboard'; @@ -158,6 +159,10 @@ interface TaskPanelProps { className?: string; /** 打开运行流面板 */ onOpenRunFlow?: (task: TaskInfo) => void; + /** 是否折叠 */ + collapsed?: boolean; + /** 折叠状态变化 */ + onCollapsedChange?: (collapsed: boolean) => void; } /** @@ -170,12 +175,17 @@ export const TaskPanel: React.FC = ({ title, className = '', onOpenRunFlow, + collapsed, + onCollapsedChange, }) => { const { t } = useUiLanguage(); + const contentId = useId(); + const [internalCollapsed, setInternalCollapsed] = useState(false); // 筛选活跃任务(pending / processing / cancel requested) const activeTasks = tasks.filter( (t) => t.status === 'pending' || t.status === 'processing' || t.status === 'cancel_requested' ); + const isCollapsed = collapsed ?? internalCollapsed; // 无任务或不可见时不渲染 if (!visible || activeTasks.length === 0) { @@ -184,14 +194,30 @@ export const TaskPanel: React.FC = ({ const pendingCount = activeTasks.filter((t) => t.status === 'pending').length; const processingCount = activeTasks.filter((t) => t.status === 'processing').length; + const cancelRequestedCount = activeTasks.filter((t) => t.status === 'cancel_requested').length; + const averageProgress = processingCount > 0 + ? Math.round( + activeTasks + .filter((t) => t.status === 'processing') + .reduce((total, task) => total + Math.max(0, Math.min(100, task.progress || 0)), 0) / processingCount, + ) + : 0; + + const handleCollapsedChange = () => { + const nextCollapsed = !isCollapsed; + if (collapsed === undefined) { + setInternalCollapsed(nextCollapsed); + } + onCollapsedChange?.(nextCollapsed); + }; return ( -
+
= ({ )} headingClassName="items-center" actions={( -
- {processingCount > 0 && ( - - - {t('taskPanel.processingTasks', { count: processingCount })} - - )} - {pendingCount > 0 ? ( - - - {t('taskPanel.pendingTasks', { count: pendingCount })} - - ) : null} +
+
+ {processingCount > 0 && ( + + + {t('taskPanel.processingTasks', { count: processingCount })} + + )} + {pendingCount > 0 ? ( + + + {t('taskPanel.pendingTasks', { count: pendingCount })} + + ) : null} +
+
)} />
-
-
- {activeTasks.map((task) => ( - - ))} + {isCollapsed ? ( +
+
+ {processingCount > 0 ? ( + {t('taskPanel.processingTasks', { count: processingCount })} + ) : null} + {pendingCount > 0 ? ( + {t('taskPanel.pendingTasks', { count: pendingCount })} + ) : null} + {cancelRequestedCount > 0 ? ( + {t('taskPanel.cancelRequestedTasks', { count: cancelRequestedCount })} + ) : null} + {processingCount > 0 ? ( + {t('taskPanel.averageProgress', { progress: averageProgress })} + ) : null} +
-
+ ) : ( +
+
+ {activeTasks.map((task) => ( + + ))} +
+
+ )} ); }; diff --git a/apps/dsa-web/src/components/tasks/__tests__/TaskPanel.test.tsx b/apps/dsa-web/src/components/tasks/__tests__/TaskPanel.test.tsx index a324bfe50..45f39ca62 100644 --- a/apps/dsa-web/src/components/tasks/__tests__/TaskPanel.test.tsx +++ b/apps/dsa-web/src/components/tasks/__tests__/TaskPanel.test.tsx @@ -72,6 +72,44 @@ describe('TaskPanel', () => { expect(container.querySelector('.home-subpanel')).toBeTruthy(); }); + it('collapses into a one-line summary and expands back with aria state', () => { + render( + , + ); + + const collapseButton = screen.getByRole('button', { name: '折叠任务面板' }); + expect(collapseButton).toHaveAttribute('aria-expanded', 'true'); + + fireEvent.click(collapseButton); + + const expandButton = screen.getByRole('button', { name: '展开任务面板' }); + expect(expandButton).toHaveAttribute('aria-expanded', 'false'); + expect(screen.getByTestId('task-panel-collapsed-summary')).toHaveTextContent('1 进行中'); + expect(screen.getByTestId('task-panel-collapsed-summary')).toHaveTextContent('1 等待中'); + expect(screen.getByTestId('task-panel-collapsed-summary')).toHaveTextContent('平均进度 40%'); + expect(screen.queryByTestId('task-panel-item')).not.toBeInTheDocument(); + + fireEvent.click(expandButton); + + expect(screen.getByRole('button', { name: '折叠任务面板' })).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getAllByTestId('task-panel-item')).toHaveLength(2); + }); + it('keeps narrow sidebar task metadata in rows instead of squeezing diagnostics vertically', () => { render( = ({ item }) => { const WatchlistRowItem: React.FC<{ row: HomeWatchlistRow; onRemove: (code: string) => Promise; + onOpenDetail: (row: HomeWatchlistRow) => void; disabled: boolean; -}> = ({ row, onRemove, disabled }) => { + selected: boolean; +}> = ({ row, onRemove, onOpenDetail, disabled, selected }) => { const { t } = useUiLanguage(); const taskLabel = getTaskStatusLabel(row.activeTask, t); - const item = row.latestItem; - const stockName = item?.stockName || row.code; + const isLatestDetailLoading = Boolean(row.isTodayStatusLoading); + const isLatestDetailUnavailable = !isLatestDetailLoading && Boolean(row.isTodayStatusUnknown); + const item = isLatestDetailLoading || isLatestDetailUnavailable ? undefined : row.latestItem; + const stockName = row.latestItem?.stockName || row.code; + const canOpenDetail = typeof item?.id === 'number'; + + const handleOpenDetail = () => { + onOpenDetail(row); + }; return ( -
-
+
+ -
+ +
+ +
- {row.activeTask ? ( -
- - {t('watchlist.taskRunning', { status: taskLabel })} -
- ) : null}
); }; @@ -225,6 +266,7 @@ export const HomeStockWorkspace: React.FC = ({ }) => { const { t } = useUiLanguage(); const [draftCode, setDraftCode] = useState(''); + const [workspaceNoticeCode, setWorkspaceNoticeCode] = useState(null); const pendingWatchlistCount = watchlistRows .filter((row) => !row.analyzedToday && !row.isTodayStatusLoading && !row.isTodayStatusUnknown) .length; @@ -243,13 +285,42 @@ export const HomeStockWorkspace: React.FC = ({ return 'border-success/30 bg-success/10 text-success'; }, [batchStatus]); + const visibleWorkspaceNotice = useMemo(() => { + if (!workspaceNoticeCode) return null; + const row = watchlistRows.find((item) => areStockCodesEquivalent(item.code, workspaceNoticeCode)); + if (!row) return null; + if (row.isTodayStatusLoading) { + return { message: t('watchlist.latestDetailLoading') }; + } + if (row.isTodayStatusUnknown) { + return { message: t('watchlist.latestDetailUnavailable') }; + } + if (row.latestItem) return null; + return { message: t('watchlist.noLatestDetail') }; + }, [t, watchlistRows, workspaceNoticeCode]); + const handleAddSubmit = (event: React.FormEvent) => { event.preventDefault(); const code = draftCode.trim(); if (!code) return; + setWorkspaceNoticeCode(null); void onAddToWatchlist(code).then(() => setDraftCode('')); }; + const handleWatchlistRowOpen = (row: HomeWatchlistRow) => { + if (row.isTodayStatusLoading || row.isTodayStatusUnknown) { + setWorkspaceNoticeCode(row.code); + return; + } + const recordId = row.latestItem?.id; + if (typeof recordId === 'number') { + setWorkspaceNoticeCode(null); + onHistoryItemClick(recordId); + return; + } + setWorkspaceNoticeCode(row.code); + }; + const renderTabs = (
{tabs.map((tab) => { @@ -262,7 +333,10 @@ export const HomeStockWorkspace: React.FC = ({ className={`h-8 rounded-lg px-2 text-xs font-medium transition-colors ${ selected ? 'bg-primary/15 text-primary shadow-inner' : 'text-secondary-text hover:bg-hover hover:text-foreground' }`} - onClick={() => onTabChange(tab.key)} + onClick={() => { + setWorkspaceNoticeCode(null); + onTabChange(tab.key); + }} > {tab.label} @@ -291,7 +365,7 @@ export const HomeStockWorkspace: React.FC = ({ return ( ); }; diff --git a/apps/dsa-web/src/components/watchlist/__tests__/HomeStockWorkspace.test.tsx b/apps/dsa-web/src/components/watchlist/__tests__/HomeStockWorkspace.test.tsx new file mode 100644 index 000000000..0185670f6 --- /dev/null +++ b/apps/dsa-web/src/components/watchlist/__tests__/HomeStockWorkspace.test.tsx @@ -0,0 +1,304 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { UiLanguageProvider } from '../../../contexts/UiLanguageContext'; +import { UI_LANGUAGE_STORAGE_KEY } from '../../../utils/uiLanguage'; +import { HomeStockWorkspace } from '../HomeStockWorkspace'; +import type { HomeWatchlistRow } from '../HomeStockWorkspace'; + +function renderWorkspace({ + watchlistRows, + selectedRecordId, + selectedStockCode, +}: { + watchlistRows: HomeWatchlistRow[]; + selectedRecordId?: number; + selectedStockCode?: string; +}) { + const onHistoryItemClick = vi.fn(); + const onRemoveFromWatchlist = vi.fn().mockResolvedValue(undefined); + window.localStorage.setItem(UI_LANGUAGE_STORAGE_KEY, 'zh'); + + const renderView = (rows: HomeWatchlistRow[]) => ( + + row.analyzedToday).length} + historyItems={[]} + isLoadingHistory={false} + selectedStockCode={selectedStockCode} + selectedRecordId={selectedRecordId} + onHistoryItemClick={onHistoryItemClick} + /> + + ); + const view = render(renderView(watchlistRows)); + + return { + onHistoryItemClick, + onRemoveFromWatchlist, + rerenderWatchlistRows: (rows: HomeWatchlistRow[]) => view.rerender(renderView(rows)), + }; +} + +describe('HomeStockWorkspace', () => { + it('opens the latest watchlist detail from a native button and keeps the row selected', () => { + const { onHistoryItemClick } = renderWorkspace({ + watchlistRows: [{ + code: '600519', + analyzedToday: true, + latestItem: { + id: 21, + stockCode: '600519', + stockName: '贵州茅台', + sentimentScore: 88, + operationAdvice: '买入', + analysisCount: 1, + lastAnalysisTime: '2026-03-19T09:00:00+08:00', + }, + }], + selectedRecordId: 21, + }); + + const row = screen.getByRole('button', { name: '打开 600519 最新分析详情' }); + fireEvent.click(row); + + expect(onHistoryItemClick).toHaveBeenCalledWith(21); + expect(row.tagName).toBe('BUTTON'); + expect(row).toHaveAttribute('aria-pressed', 'true'); + }); + + it('shows an explicit notice when a watchlist row has no detail yet', async () => { + const { onHistoryItemClick } = renderWorkspace({ + watchlistRows: [{ + code: 'AAPL', + analyzedToday: false, + }], + }); + + const row = screen.getByRole('button', { name: '暂无 AAPL 的分析详情,可先分析' }); + fireEvent.click(row); + + expect(await screen.findByRole('alert')).toHaveTextContent('暂无分析详情,可先分析。'); + expect(onHistoryItemClick).not.toHaveBeenCalled(); + }); + + it('shows loading feedback instead of no-detail copy while latest detail lookup is still pending', async () => { + const { onHistoryItemClick } = renderWorkspace({ + watchlistRows: [{ + code: 'AAPL', + analyzedToday: false, + isTodayStatusLoading: true, + }], + }); + + const row = screen.getByRole('button', { name: '正在查找 AAPL 的最新分析详情' }); + fireEvent.click(row); + + expect(await screen.findByRole('alert')).toHaveTextContent('正在查找最新分析详情,请稍候。'); + expect(onHistoryItemClick).not.toHaveBeenCalled(); + expect(screen.getByText('正在查找详情...')).toBeInTheDocument(); + }); + + it('shows retry feedback instead of no-detail copy when the latest detail lookup failed', async () => { + const { onHistoryItemClick } = renderWorkspace({ + watchlistRows: [{ + code: 'AAPL', + analyzedToday: false, + isTodayStatusUnknown: true, + }], + }); + + const row = screen.getByRole('button', { name: 'AAPL 的最新分析详情暂时无法确认,请稍后重试' }); + fireEvent.click(row); + + expect(await screen.findByRole('alert')).toHaveTextContent('最新分析详情暂时无法确认,请稍后重试。'); + expect(screen.queryByText('暂无分析详情,可先分析。')).not.toBeInTheDocument(); + expect(screen.getByText('详情暂不可用')).toBeInTheDocument(); + expect(onHistoryItemClick).not.toHaveBeenCalled(); + }); + + it('does not expose a cached detail while the current row status is unsettled', async () => { + const cachedItem = { + id: 24, + stockCode: 'AAPL', + stockName: 'Apple', + sentimentScore: 68, + operationAdvice: 'neutral', + analysisCount: 1, + lastAnalysisTime: '2026-03-18T09:20:00+08:00', + }; + const { onHistoryItemClick, rerenderWatchlistRows } = renderWorkspace({ + watchlistRows: [{ + code: 'AAPL', + analyzedToday: false, + latestItem: cachedItem, + isTodayStatusLoading: true, + }], + }); + + fireEvent.click(screen.getByRole('button', { name: '\u6b63\u5728\u67e5\u627e AAPL \u7684\u6700\u65b0\u5206\u6790\u8be6\u60c5' })); + expect(await screen.findByRole('alert')).toHaveTextContent('\u6b63\u5728\u67e5\u627e\u6700\u65b0\u5206\u6790\u8be6\u60c5\uff0c\u8bf7\u7a0d\u5019\u3002'); + expect(onHistoryItemClick).not.toHaveBeenCalled(); + + rerenderWatchlistRows([{ + code: 'AAPL', + analyzedToday: false, + latestItem: cachedItem, + isTodayStatusUnknown: true, + }]); + fireEvent.click(screen.getByRole('button', { name: 'AAPL \u7684\u6700\u65b0\u5206\u6790\u8be6\u60c5\u6682\u65f6\u65e0\u6cd5\u786e\u8ba4\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5' })); + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent('\u6700\u65b0\u5206\u6790\u8be6\u60c5\u6682\u65f6\u65e0\u6cd5\u786e\u8ba4\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002'); + }); + expect(onHistoryItemClick).not.toHaveBeenCalled(); + }); + + it('clears a loading notice when the same row detail lookup settles', async () => { + const { rerenderWatchlistRows } = renderWorkspace({ + watchlistRows: [{ + code: 'AAPL', + analyzedToday: false, + isTodayStatusLoading: true, + }], + }); + + const row = screen.getByTestId('watchlist-row-AAPL'); + fireEvent.click(row.querySelector('button[aria-pressed]') as HTMLButtonElement); + expect(await screen.findByRole('alert')).toBeInTheDocument(); + + rerenderWatchlistRows([{ + code: 'AAPL', + analyzedToday: false, + latestItem: { + id: 22, + stockCode: 'AAPL', + stockName: 'Apple', + sentimentScore: 72, + operationAdvice: 'neutral', + analysisCount: 1, + lastAnalysisTime: '2026-03-19T09:00:00+08:00', + }, + }]); + + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()); + expect(row.querySelector('button[aria-pressed]')).toBeInTheDocument(); + }); + + it('clears a no-detail notice when the matching row receives a detail', async () => { + const { rerenderWatchlistRows } = renderWorkspace({ + watchlistRows: [{ + code: 'AAPL', + analyzedToday: false, + }], + }); + + fireEvent.click(screen.getByRole('button', { name: '暂无 AAPL 的分析详情,可先分析' })); + expect(await screen.findByRole('alert')).toHaveTextContent('暂无分析详情,可先分析。'); + + rerenderWatchlistRows([{ + code: 'AAPL', + analyzedToday: true, + latestItem: { + id: 23, + stockCode: 'AAPL', + stockName: 'Apple', + sentimentScore: 80, + operationAdvice: 'buy', + analysisCount: 1, + lastAnalysisTime: '2026-03-19T10:00:00+08:00', + }, + }]); + + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()); + expect(screen.getByRole('button', { name: '打开 AAPL 最新分析详情' })).toBeInTheDocument(); + }); + + it('derives an opened notice from the latest row state instead of retaining stale copy', async () => { + const { rerenderWatchlistRows } = renderWorkspace({ + watchlistRows: [{ + code: 'AAPL', + analyzedToday: false, + }], + }); + + const row = screen.getByTestId('watchlist-row-AAPL'); + fireEvent.click(row.querySelector('button[aria-pressed]') as HTMLButtonElement); + expect(await screen.findByRole('alert')).toHaveTextContent('\u6682\u65e0\u5206\u6790\u8be6\u60c5\uff0c\u53ef\u5148\u5206\u6790\u3002'); + + rerenderWatchlistRows([{ + code: 'AAPL', + analyzedToday: false, + isTodayStatusLoading: true, + }]); + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent('\u6b63\u5728\u67e5\u627e\u6700\u65b0\u5206\u6790\u8be6\u60c5\uff0c\u8bf7\u7a0d\u5019\u3002'); + }); + + rerenderWatchlistRows([{ + code: 'AAPL', + analyzedToday: false, + isTodayStatusUnknown: true, + }]); + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent('\u6700\u65b0\u5206\u6790\u8be6\u60c5\u6682\u65f6\u65e0\u6cd5\u786e\u8ba4\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002'); + }); + }); + + it('does not bubble delete clicks into detail opening', async () => { + const { onHistoryItemClick, onRemoveFromWatchlist } = renderWorkspace({ + watchlistRows: [{ + code: '600519', + analyzedToday: true, + latestItem: { + id: 21, + stockCode: '600519', + stockName: '贵州茅台', + sentimentScore: 88, + operationAdvice: '买入', + analysisCount: 1, + lastAnalysisTime: '2026-03-19T09:00:00+08:00', + }, + }], + }); + + fireEvent.click(screen.getByRole('button', { name: '从自选股移除 600519' })); + + expect(onRemoveFromWatchlist).toHaveBeenCalledWith('600519'); + expect(onHistoryItemClick).not.toHaveBeenCalled(); + }); + + it('keeps the watchlist row selected for equivalent stock-code formats', () => { + renderWorkspace({ + watchlistRows: [{ + code: 'HK700', + analyzedToday: true, + latestItem: { + id: 88, + stockCode: '00700', + stockName: '腾讯控股', + sentimentScore: 91, + operationAdvice: '买入', + analysisCount: 1, + lastAnalysisTime: '2026-03-19T09:00:00+08:00', + }, + }], + selectedStockCode: '00700.HK', + }); + + expect(screen.getByRole('button', { name: '打开 HK700 最新分析详情' })).toHaveAttribute('aria-pressed', 'true'); + }); +}); diff --git a/apps/dsa-web/src/hooks/__tests__/useDashboardLifecycle.test.tsx b/apps/dsa-web/src/hooks/__tests__/useDashboardLifecycle.test.tsx index a9cd86de1..0f17e8ab5 100644 --- a/apps/dsa-web/src/hooks/__tests__/useDashboardLifecycle.test.tsx +++ b/apps/dsa-web/src/hooks/__tests__/useDashboardLifecycle.test.tsx @@ -117,6 +117,7 @@ describe('useDashboardLifecycle', () => { const refreshHistoryForCompletedTask = vi.fn().mockResolvedValue(undefined); const syncTaskUpdated = vi.fn(); const removeTask = vi.fn(); + const onCompletedTaskDataRefreshStarted = vi.fn(); const onCompletedTaskDataRefreshed = vi.fn(); renderHook(() => @@ -129,6 +130,7 @@ describe('useDashboardLifecycle', () => { syncTaskUpdated, syncTaskFailed: vi.fn(), removeTask, + onCompletedTaskDataRefreshStarted, onCompletedTaskDataRefreshed, ...defaultMocks, }), @@ -144,6 +146,7 @@ describe('useDashboardLifecycle', () => { }); expect(syncTaskUpdated).toHaveBeenCalledWith(completedTask); + expect(onCompletedTaskDataRefreshStarted).toHaveBeenCalledWith(completedTask); expect(refreshHistoryForCompletedTask).toHaveBeenCalledWith(completedTask); expect(refreshHistory).not.toHaveBeenCalledWith(true); expect(defaultMocks.refreshMarketReviewHistory).toHaveBeenCalledWith(true); diff --git a/apps/dsa-web/src/hooks/useDashboardLifecycle.ts b/apps/dsa-web/src/hooks/useDashboardLifecycle.ts index 7d07362a3..7fdef602f 100644 --- a/apps/dsa-web/src/hooks/useDashboardLifecycle.ts +++ b/apps/dsa-web/src/hooks/useDashboardLifecycle.ts @@ -16,6 +16,7 @@ type UseDashboardLifecycleOptions = { syncTaskFailed: (task: TaskInfo) => void; removeTask: (taskId: string) => void; onDashboardDataRefresh?: () => void; + onCompletedTaskDataRefreshStarted?: (task: TaskInfo) => void; onCompletedTaskDataRefreshed?: (task: TaskInfo) => void; enabled?: boolean; }; @@ -34,6 +35,7 @@ export function useDashboardLifecycle({ syncTaskFailed, removeTask, onDashboardDataRefresh, + onCompletedTaskDataRefreshStarted, onCompletedTaskDataRefreshed, enabled = true, }: UseDashboardLifecycleOptions): void { @@ -110,6 +112,7 @@ export function useDashboardLifecycle({ }, onTaskCompleted: (task) => { syncTaskUpdated(task); + onCompletedTaskDataRefreshStarted?.(task); const historyRefresh = refreshHistoryForCompletedTask ? refreshHistoryForCompletedTask(task) : refreshHistory(true); diff --git a/apps/dsa-web/src/i18n/uiText.ts b/apps/dsa-web/src/i18n/uiText.ts index a0e6b233c..a4189b316 100644 --- a/apps/dsa-web/src/i18n/uiText.ts +++ b/apps/dsa-web/src/i18n/uiText.ts @@ -448,12 +448,23 @@ const zh = { 'watchlist.listHint': '按自选顺序展示,今日状态实时标记', 'watchlist.loading': '加载自选股中...', 'watchlist.noPendingAnalyze': '今天没有待分析的自选股。', + 'watchlist.noLatestDetail': '暂无分析详情,可先分析。', + 'watchlist.noLatestDetailAria': '暂无 {code} 的分析详情,可先分析', + 'watchlist.noLatestDetailCta': '暂无详情,可先分析', + 'watchlist.latestDetailLoading': '正在查找最新分析详情,请稍候。', + 'watchlist.latestDetailLoadingAria': '正在查找 {code} 的最新分析详情', + 'watchlist.latestDetailLoadingCta': '正在查找详情...', + 'watchlist.latestDetailUnavailable': '最新分析详情暂时无法确认,请稍后重试。', + 'watchlist.latestDetailUnavailableAria': '{code} 的最新分析详情暂时无法确认,请稍后重试', + 'watchlist.latestDetailUnavailableCta': '详情暂不可用', 'watchlist.noStocksAnalyze': '请先添加自选股。', 'watchlist.notAnalyzedToday': '今日未分析', + 'watchlist.openLatestDetailAria': '打开 {code} 最新分析详情', 'watchlist.pendingToday': '今日待分析', 'watchlist.pendingStatusLoading': '正在确认自选股今日状态,请稍后再提交仅未分析。', 'watchlist.pendingStatusUnavailable': '自选股今日状态仍有未知项,请刷新后再提交仅未分析。', 'watchlist.refresh': '刷新自选股', + 'watchlist.refreshAria': '刷新自选股列表', 'watchlist.removeAria': '从自选股移除 {code}', 'watchlist.submitting': '提交中', 'watchlist.tabHistory': '历史', @@ -514,7 +525,11 @@ const zh = { 'taskPanel.processingTasks': '{count} 进行中', 'taskPanel.cancelRequested': '请求取消', 'taskPanel.cancelRequestedAria': '任务请求取消', + 'taskPanel.cancelRequestedTasks': '{count} 请求取消', 'taskPanel.cancelled': '已取消', + 'taskPanel.averageProgress': '平均进度 {progress}%', + 'taskPanel.collapse': '折叠任务面板', + 'taskPanel.expand': '展开任务面板', 'taskPanel.pendingAria': '任务等待中', 'taskPanel.openRunFlow': '查看运行流', 'taskPanel.openRunFlowAria': '查看 {stock} 运行流', @@ -1384,12 +1399,23 @@ const en: Record = { 'watchlist.listHint': 'Shown in watchlist order with today status', 'watchlist.loading': 'Loading watchlist...', 'watchlist.noPendingAnalyze': 'No watchlist stocks are pending today.', + 'watchlist.noLatestDetail': 'No analysis details yet. Run an analysis first.', + 'watchlist.noLatestDetailAria': 'No analysis details for {code} yet. Run an analysis first.', + 'watchlist.noLatestDetailCta': 'No details yet', + 'watchlist.latestDetailLoading': 'Looking up the latest analysis details. Please wait.', + 'watchlist.latestDetailLoadingAria': 'Looking up the latest analysis details for {code}', + 'watchlist.latestDetailLoadingCta': 'Looking up details...', + 'watchlist.latestDetailUnavailable': 'The latest analysis details could not be confirmed. Please try again later.', + 'watchlist.latestDetailUnavailableAria': 'The latest analysis details for {code} could not be confirmed. Please try again later.', + 'watchlist.latestDetailUnavailableCta': 'Details unavailable', 'watchlist.noStocksAnalyze': 'Add watchlist stocks first.', 'watchlist.notAnalyzedToday': 'Not analyzed today', + 'watchlist.openLatestDetailAria': 'Open the latest analysis details for {code}', 'watchlist.pendingToday': 'Pending today', 'watchlist.pendingStatusLoading': 'Checking watchlist status. Submit pending stocks after it finishes.', 'watchlist.pendingStatusUnavailable': 'Some watchlist today statuses are unknown. Refresh before submitting pending stocks.', 'watchlist.refresh': 'Refresh watchlist', + 'watchlist.refreshAria': 'Refresh watchlist items', 'watchlist.removeAria': 'Remove {code} from watchlist', 'watchlist.submitting': 'Submitting', 'watchlist.tabHistory': 'History', @@ -1450,7 +1476,11 @@ const en: Record = { 'taskPanel.processingTasks': '{count} processing', 'taskPanel.cancelRequested': 'Cancel requested', 'taskPanel.cancelRequestedAria': 'Task cancel requested', + 'taskPanel.cancelRequestedTasks': '{count} cancel requested', 'taskPanel.cancelled': 'Cancelled', + 'taskPanel.averageProgress': 'Avg {progress}%', + 'taskPanel.collapse': 'Collapse task panel', + 'taskPanel.expand': 'Expand task panel', 'taskPanel.pendingAria': 'Task pending', 'taskPanel.openRunFlow': 'View run flow', 'taskPanel.openRunFlowAria': 'View {stock} run flow', diff --git a/apps/dsa-web/src/pages/HomePage.tsx b/apps/dsa-web/src/pages/HomePage.tsx index 41ff5cc03..a01ed4c06 100644 --- a/apps/dsa-web/src/pages/HomePage.tsx +++ b/apps/dsa-web/src/pages/HomePage.tsx @@ -61,6 +61,8 @@ type StockAnalysisNavigationState = { const DUPLICATE_BANNER_AUTO_DISMISS_MS = 5000; const BATCH_ANALYSIS_CHUNK_SIZE = 50; const TODAY_ANALYSIS_PAGE_SIZE = 100; +const WATCHLIST_HISTORY_LOOKUP_CONCURRENCY = 4; +const TASK_PANEL_COLLAPSED_STORAGE_KEY = 'dsa.home.taskPanelCollapsed'; const SERVER_LOCAL_DATE_TIME_PATTERN = /^\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/; type BatchAnalyzeStatus = { @@ -74,6 +76,45 @@ type WatchlistHistoryLookupState = { failedKeys: Set; }; +type WatchlistHistoryLookupResult = { + code: string; + item: HistoryItem | null; + failed: boolean; +}; + +async function lookupWatchlistHistory( + codes: string[], + isCanceled: () => boolean, + signal: AbortSignal, +): Promise { + const results: Array = new Array(codes.length); + let nextIndex = 0; + + const runWorker = async () => { + while (!isCanceled()) { + const index = nextIndex; + if (index >= codes.length) { + return; + } + nextIndex += 1; + const code = codes[index]; + try { + const response = await historyApi.getList( + { stockCode: code, limit: 1 }, + { signal }, + ); + results[index] = { code, item: response.items[0] ?? null, failed: false }; + } catch { + results[index] = { code, item: null, failed: true }; + } + } + }; + + const workerCount = Math.min(WATCHLIST_HISTORY_LOOKUP_CONCURRENCY, codes.length); + await Promise.all(Array.from({ length: workerCount }, () => runWorker())); + return results.filter((entry): entry is WatchlistHistoryLookupResult => entry !== undefined); +} + function getShanghaiDateKey(value?: string | null): string { if (!value) return ''; const trimmed = value.trim(); @@ -114,6 +155,31 @@ function chunkStockCodes(codes: string[]): string[][] { return chunks; } +function readTaskPanelCollapsedPreference(): boolean | null { + if (typeof window === 'undefined') { + return null; + } + try { + const rawValue = window.sessionStorage.getItem(TASK_PANEL_COLLAPSED_STORAGE_KEY); + if (rawValue === 'true') return true; + if (rawValue === 'false') return false; + return null; + } catch { + return null; + } +} + +function writeTaskPanelCollapsedPreference(collapsed: boolean): void { + if (typeof window === 'undefined') { + return; + } + try { + window.sessionStorage.setItem(TASK_PANEL_COLLAPSED_STORAGE_KEY, String(collapsed)); + } catch { + // Session storage is best-effort; keep the in-memory toggle state working. + } +} + function countBatchAccepted(result: AnalyzeAsyncResponse): { accepted: number; duplicates: number } { if ('accepted' in result) { return { @@ -195,6 +261,9 @@ const HomePage: React.FC = () => { const [runFlowDrawer, setRunFlowDrawer] = useState({ open: false }); const [duplicateBannerVisible, setDuplicateBannerVisible] = useState(false); const [sidebarWorkspaceTab, setSidebarWorkspaceTab] = useState('history'); + const [isTaskPanelCollapsed, setIsTaskPanelCollapsed] = useState(() => ( + readTaskPanelCollapsedPreference() ?? false + )); const [isBatchAnalyzingWatchlist, setIsBatchAnalyzingWatchlist] = useState(false); const [batchAnalyzeStatus, setBatchAnalyzeStatus] = useState(null); const [watchlistHistoryItemsByCode, setWatchlistHistoryItemsByCode] = useState>(new Map()); @@ -203,14 +272,19 @@ const HomePage: React.FC = () => { settledKeys: new Set(), failedKeys: new Set(), }); + const [watchlistHistoryRetryVersion, setWatchlistHistoryRetryVersion] = useState(0); const [todayHistoryItems, setTodayHistoryItems] = useState([]); const [isLoadingTodayAnalysisItems, setIsLoadingTodayAnalysisItems] = useState(false); const [todayAnalysisLoadFailed, setTodayAnalysisLoadFailed] = useState(false); const [todayAnalysisRefreshVersion, setTodayAnalysisRefreshVersion] = useState(0); const [isStockBarInitialLoadSettled, setIsStockBarInitialLoadSettled] = useState(false); + const [completedTaskRefreshPendingCounts, setCompletedTaskRefreshPendingCounts] = useState>( + new Map(), + ); const duplicateBannerTimer = useRef(null); const marketReviewPollTimer = useRef(null); const stockBarLoadStartedRef = useRef(false); + const taskPanelPreferenceSettledRef = useRef(readTaskPanelCollapsedPreference() !== null); const dashboardScrollRef = useRef(null); const strategyMenuRef = useRef(null); const strategyButtonRef = useRef(null); @@ -318,6 +392,22 @@ const HomePage: React.FC = () => { return clearDuplicateBannerTimer; }, [clearDuplicateBannerTimer, duplicateError]); + useEffect(() => { + if (taskPanelPreferenceSettledRef.current || activeTasks.length === 0) { + return; + } + const nextCollapsed = activeTasks.length > 1; + setIsTaskPanelCollapsed(nextCollapsed); + writeTaskPanelCollapsedPreference(nextCollapsed); + taskPanelPreferenceSettledRef.current = true; + }, [activeTasks.length]); + + const handleTaskPanelCollapsedChange = useCallback((collapsed: boolean) => { + setIsTaskPanelCollapsed(collapsed); + taskPanelPreferenceSettledRef.current = true; + writeTaskPanelCollapsedPreference(collapsed); + }, []); + useEffect(() => { document.title = t('home.pageTitle'); }, [t]); @@ -509,10 +599,42 @@ const HomePage: React.FC = () => { return requiredNeedsAction.slice(0, 3).join(uiLanguage === 'en' ? ', ' : '、'); }, [setupStatus, uiLanguage]); - const handleCompletedTaskDataRefreshed = useCallback((task: TaskInfo) => { - if (task.reportType !== 'market_review') { - setTodayAnalysisRefreshVersion((version) => version + 1); + const handleCompletedTaskDataRefreshStarted = useCallback((task: TaskInfo) => { + if (task.reportType === 'market_review') { + return; } + const key = getStockCodeKey(task.stockCode); + if (!key) { + return; + } + setCompletedTaskRefreshPendingCounts((current) => { + const next = new Map(current); + next.set(key, (next.get(key) ?? 0) + 1); + return next; + }); + }, []); + + const handleCompletedTaskDataRefreshed = useCallback((task: TaskInfo) => { + if (task.reportType === 'market_review') { + return; + } + const key = getStockCodeKey(task.stockCode); + if (key) { + setCompletedTaskRefreshPendingCounts((current) => { + const pendingCount = current.get(key) ?? 0; + if (pendingCount === 0) { + return current; + } + const next = new Map(current); + if (pendingCount === 1) { + next.delete(key); + } else { + next.set(key, pendingCount - 1); + } + return next; + }); + } + setTodayAnalysisRefreshVersion((version) => version + 1); }, []); const handleDashboardDataRefresh = useCallback(() => { @@ -533,6 +655,7 @@ const HomePage: React.FC = () => { refreshActiveTasks, removeTask, onDashboardDataRefresh: handleDashboardDataRefresh, + onCompletedTaskDataRefreshStarted: handleCompletedTaskDataRefreshStarted, onCompletedTaskDataRefreshed: handleCompletedTaskDataRefreshed, }); @@ -547,6 +670,7 @@ const HomePage: React.FC = () => { }, [isLoadingStockBar, stockBarItems.length]); const watchlistState = useWatchlist(); + const refreshWatchlist = watchlistState.refresh; const watchlistCodesByNormalized = useMemo(() => { const codesByNormalized = new Map(); for (const code of watchlistState.watchlistCodes) { @@ -607,18 +731,14 @@ const HomePage: React.FC = () => { } let isCanceled = false; + const abortController = new AbortController(); setWatchlistHistoryLookupState({ signature: currentSignature, settledKeys: new Set(), failedKeys: new Set() }); void (async () => { try { - const results = await Promise.all( - missingCodes.map(async (code) => { - try { - const response = await historyApi.getList({ stockCode: code, limit: 1 }); - return { code, item: response.items[0] ?? null, failed: false }; - } catch { - return { code, item: null, failed: true }; - } - }), + const results = await lookupWatchlistHistory( + missingCodes, + () => isCanceled, + abortController.signal, ); if (isCanceled) { @@ -660,8 +780,9 @@ const HomePage: React.FC = () => { return () => { isCanceled = true; + abortController.abort(); }; - }, [canLookupWatchlistHistory, watchlistMissingHistoryEntries, watchlistMissingHistorySignature]); + }, [canLookupWatchlistHistory, watchlistHistoryRetryVersion, watchlistMissingHistoryEntries, watchlistMissingHistorySignature]); const clearMarketReviewState = useCallback(() => { stopMarketReviewPolling(); @@ -677,6 +798,14 @@ const HomePage: React.FC = () => { setSidebarOpen(false); }, [clearMarketReviewState, selectHistoryItem]); + const handleRefreshWatchlist = useCallback(async () => { + await Promise.all([ + refreshWatchlist(), + refreshStockBar(), + ]); + setWatchlistHistoryRetryVersion((version) => version + 1); + }, [refreshStockBar, refreshWatchlist]); + const [isDeletingStock, setIsDeletingStock] = useState(false); const handleDeleteStock = useCallback(async (stockCode: string) => { if (isDeletingStock) return; @@ -983,22 +1112,12 @@ const HomePage: React.FC = () => { const watchlistRows = useMemo(() => ( watchlistState.watchlistCodes.map((code) => { const key = getStockCodeKey(code); - const latestItem = key + const latestItemCandidate = key ? stockBarItemByCode.get(key) ?? watchlistHistoryItemsByCode.get(key) : undefined; const isMissingFromStockBar = Boolean(key && !stockBarItemByCode.has(key)); - const isTodayStatusUnknown = Boolean( - stockBarRefreshFailed - || ( - isMissingFromStockBar - && canLookupWatchlistHistory - && watchlistHistoryLookupState.signature === watchlistMissingHistorySignature - && watchlistHistoryLookupState.failedKeys.has(key) - ), - ); - const isTodayStatusLoading = Boolean( + const hasPendingHistoryLookup = Boolean( isMissingFromStockBar - && !isTodayStatusUnknown && ( !canLookupWatchlistHistory || @@ -1006,6 +1125,24 @@ const HomePage: React.FC = () => { || !watchlistHistoryLookupState.settledKeys.has(key) ), ); + const hasFailedHistoryLookup = Boolean( + isMissingFromStockBar + && canLookupWatchlistHistory + && watchlistHistoryLookupState.signature === watchlistMissingHistorySignature + && watchlistHistoryLookupState.failedKeys.has(key) + ); + const isTodayStatusLoading = Boolean( + isLoadingStockBar + || hasPendingHistoryLookup + || (key && completedTaskRefreshPendingCounts.has(key)) + ); + const isTodayStatusUnknown = Boolean( + hasFailedHistoryLookup + || (stockBarRefreshFailed && !hasPendingHistoryLookup) + ); + const latestItem = isTodayStatusLoading || isTodayStatusUnknown + ? undefined + : latestItemCandidate; return { code, latestItem, @@ -1018,6 +1155,8 @@ const HomePage: React.FC = () => { ), [ activeTaskByCode, canLookupWatchlistHistory, + completedTaskRefreshPendingCounts, + isLoadingStockBar, stockBarRefreshFailed, stockBarItemByCode, todayDateKey, @@ -1219,8 +1358,13 @@ const HomePage: React.FC = () => { const sidebarContent = useMemo( () => ( -
- +
+ { watchlistMessage={watchlistState.actionMessage} onAddToWatchlist={watchlistState.addToWatchlist} onRemoveFromWatchlist={watchlistState.removeFromWatchlist} - onRefreshWatchlist={watchlistState.refresh} + onRefreshWatchlist={handleRefreshWatchlist} onAnalyzeWatchlist={handleAnalyzeWatchlist} isBatchAnalyzing={isBatchAnalyzingWatchlist} batchStatus={batchAnalyzeStatus} @@ -1255,10 +1399,13 @@ const HomePage: React.FC = () => { handleAnalyzeWatchlist, handleDeleteStock, handleHistoryItemClick, + handleRefreshWatchlist, + handleTaskPanelCollapsedChange, isBatchAnalyzingWatchlist, isDeletingStock, isLoadingStockBar, isLoadingTodayAnalysisItems, + isTaskPanelCollapsed, todayAnalysisLoadFailed, mergedStockBarItems, openTaskRunFlow, @@ -1272,7 +1419,6 @@ const HomePage: React.FC = () => { watchlistState.addToWatchlist, watchlistState.isActioning, watchlistState.isLoading, - watchlistState.refresh, watchlistState.removeFromWatchlist, ], ); diff --git a/apps/dsa-web/src/pages/__tests__/HomePage.test.tsx b/apps/dsa-web/src/pages/__tests__/HomePage.test.tsx index 3be2d5176..3dab23e66 100644 --- a/apps/dsa-web/src/pages/__tests__/HomePage.test.tsx +++ b/apps/dsa-web/src/pages/__tests__/HomePage.test.tsx @@ -55,6 +55,8 @@ vi.mock('../../api/systemConfig', () => ({ getConfig: vi.fn(), getSetupStatus: vi.fn(), getWatchlist: vi.fn().mockResolvedValue([]), + addToWatchlist: vi.fn().mockResolvedValue([]), + removeFromWatchlist: vi.fn().mockResolvedValue([]), }, })); @@ -209,6 +211,7 @@ describe('HomePage', () => { vi.clearAllMocks(); navigateMock.mockReset(); window.localStorage.clear(); + window.sessionStorage.clear(); window.localStorage.setItem(UI_LANGUAGE_STORAGE_KEY, 'zh'); useStockPoolStore.getState().resetDashboardState(); vi.mocked(analysisApi.getTasks).mockResolvedValue({ @@ -479,7 +482,135 @@ describe('HomePage', () => { expect(analysisApi.analyzeAsync).not.toHaveBeenCalled(); }); + it('opens the latest report when clicking a watchlist row and marks it selected', async () => { + vi.mocked(systemConfigApi.getWatchlist).mockResolvedValue(['600519']); + vi.mocked(historyApi.getStockBarList).mockResolvedValue({ + total: 1, + items: [{ + id: 21, + stockCode: '600519', + stockName: '贵州茅台', + reportType: 'detailed', + sentimentScore: 88, + operationAdvice: '买入', + analysisCount: 1, + lastAnalysisTime: '2026-03-19T09:00:00+08:00', + }], + }); + vi.mocked(historyApi.getList).mockResolvedValue({ + total: 0, + page: 1, + limit: 20, + items: [], + }); + vi.mocked(historyApi.getDetail).mockResolvedValue({ + ...historyReport, + meta: { + ...historyReport.meta, + id: 21, + }, + summary: { + ...historyReport.summary, + analysisSummary: '自选股详情已打开', + }, + }); + + render( + + + , + ); + + fireEvent.click(await screen.findByRole('button', { name: '自选' })); + + const rowButton = await screen.findByRole('button', { name: '打开 600519 最新分析详情' }); + fireEvent.click(rowButton); + + await waitFor(() => { + expect(historyApi.getDetail).toHaveBeenCalledWith(21); + }); + expect(await screen.findByText('自选股详情已打开')).toBeInTheDocument(); + expect(rowButton).toHaveAttribute('aria-pressed', 'true'); + }); + + it('does not open details when removing a watchlist row', async () => { + vi.mocked(systemConfigApi.getWatchlist).mockResolvedValue(['600519']); + vi.mocked(historyApi.getStockBarList).mockResolvedValue({ + total: 1, + items: [{ + id: 21, + stockCode: '600519', + stockName: '贵州茅台', + reportType: 'detailed', + sentimentScore: 88, + operationAdvice: '买入', + analysisCount: 1, + lastAnalysisTime: '2026-03-19T09:00:00+08:00', + }], + }); + vi.mocked(historyApi.getList).mockResolvedValue({ + total: 0, + page: 1, + limit: 20, + items: [], + }); + vi.mocked(systemConfigApi.removeFromWatchlist).mockResolvedValue([]); + + render( + + + , + ); + + fireEvent.click(await screen.findByRole('button', { name: '自选' })); + fireEvent.click(await screen.findByRole('button', { name: '从自选股移除 600519' })); + + await waitFor(() => { + expect(systemConfigApi.removeFromWatchlist).toHaveBeenCalledWith('600519'); + }); + expect(historyApi.getDetail).not.toHaveBeenCalled(); + }); + + it('shows explicit feedback when a watchlist row has no report details yet', async () => { + vi.mocked(systemConfigApi.getWatchlist).mockResolvedValue(['AAPL']); + vi.mocked(historyApi.getStockBarList).mockResolvedValue({ + total: 0, + items: [], + }); + vi.mocked(historyApi.getList).mockImplementation((params: { stockCode?: string; limit?: number } = {}) => { + if (params.stockCode === 'AAPL' && params.limit === 1) { + return Promise.resolve({ + total: 0, + page: 1, + limit: 1, + items: [], + }); + } + + return Promise.resolve({ + total: 0, + page: 1, + limit: params.limit ?? 20, + items: [], + }); + }); + + render( + + + , + ); + + fireEvent.click(await screen.findByRole('button', { name: '自选' })); + const rowButton = await screen.findByRole('button', { name: '暂无 AAPL 的分析详情,可先分析' }); + fireEvent.click(rowButton); + + expect(await screen.findByRole('alert')).toHaveTextContent('暂无分析详情,可先分析。'); + expect(historyApi.getDetail).not.toHaveBeenCalled(); + }); + it('blocks pending watchlist submission when the stock-bar refresh after completion fails', async () => { + const todayInShanghai = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai' }).format(new Date()); vi.mocked(systemConfigApi.getWatchlist).mockResolvedValue(['600519']); vi.mocked(historyApi.getStockBarList) .mockResolvedValueOnce({ @@ -495,7 +626,20 @@ describe('HomePage', () => { lastAnalysisTime: '2026-01-01T09:00:00+08:00', }], }) - .mockRejectedValueOnce(new Error('temporary stock-bar failure')); + .mockRejectedValueOnce(new Error('temporary stock-bar failure')) + .mockResolvedValueOnce({ + total: 1, + items: [{ + id: 13, + stockCode: '600519', + stockName: '贵州茅台', + reportType: 'detailed', + sentimentScore: 80, + operationAdvice: '观察', + analysisCount: 2, + lastAnalysisTime: `${todayInShanghai}T10:00:00+08:00`, + }], + }); vi.mocked(historyApi.getList).mockResolvedValue({ total: 0, page: 1, @@ -531,6 +675,243 @@ describe('HomePage', () => { expect(analyzePendingButton).toBeDisabled(); fireEvent.click(analyzePendingButton); expect(analysisApi.analyzeAsync).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: '刷新自选股列表' })); + + expect(await screen.findByLabelText('今日已分析')).toBeInTheDocument(); + expect(historyApi.getStockBarList).toHaveBeenCalledTimes(3); + }); + + it('keeps a stale fallback row unknown when the completion stock-bar refresh fails', async () => { + let rejectCompletionStockBar!: (reason?: unknown) => void; + const completionStockBarPromise = new Promise>>((_, reject) => { + rejectCompletionStockBar = reject; + }); + vi.mocked(systemConfigApi.getWatchlist).mockResolvedValue(['AAPL']); + vi.mocked(historyApi.getStockBarList) + .mockResolvedValueOnce({ + total: 1, + items: [{ + id: 11, + stockCode: '600519', + stockName: '贵州茅台', + reportType: 'detailed', + sentimentScore: 72, + operationAdvice: '观察', + analysisCount: 1, + lastAnalysisTime: '2026-01-01T09:00:00+08:00', + }], + }) + .mockReturnValueOnce(completionStockBarPromise); + vi.mocked(historyApi.getList).mockImplementation((params: { stockCode?: string; limit?: number } = {}) => { + if (params.stockCode === 'AAPL') { + return Promise.resolve({ + total: 1, + page: 1, + limit: 1, + items: [{ + id: 12, + queryId: 'q-aapl-old', + stockCode: 'AAPL', + stockName: 'Apple', + reportType: 'detailed' as const, + sentimentScore: 68, + operationAdvice: '中性', + createdAt: '2026-01-01T09:20:00+08:00', + }], + }); + } + return Promise.resolve({ + total: 0, + page: 1, + limit: params.limit ?? 20, + items: [], + }); + }); + + render( + + + , + ); + + fireEvent.click(await screen.findByRole('button', { name: '自选' })); + expect(await screen.findByLabelText('今日未分析')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '仅未分析' })).toBeEnabled(); + + const taskStreamOptions = vi.mocked(useTaskStream).mock.calls.at(-1)?.[0]; + act(() => { + taskStreamOptions?.onTaskCompleted?.({ + taskId: 'task-aapl', + stockCode: 'AAPL', + stockName: 'Apple', + status: 'completed', + progress: 100, + reportType: 'detailed', + createdAt: '2026-03-18T08:00:00Z', + }); + }); + + expect(await screen.findByLabelText('确认今日状态中')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '打开 AAPL 最新分析详情' })).not.toBeInTheDocument(); + + await act(async () => { + rejectCompletionStockBar(new Error('temporary stock-bar failure')); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(await screen.findByLabelText('今日状态未知')).toBeInTheDocument(); + const unavailableDetailButton = screen.getByRole('button', { + name: 'AAPL 的最新分析详情暂时无法确认,请稍后重试', + }); + expect(screen.queryByRole('button', { name: '打开 AAPL 最新分析详情' })).not.toBeInTheDocument(); + fireEvent.click(unavailableDetailButton); + expect(await screen.findByRole('alert')).toHaveTextContent('最新分析详情暂时无法确认,请稍后重试。'); + expect(historyApi.getDetail).not.toHaveBeenCalled(); + const analyzePendingButton = screen.getByRole('button', { name: '仅未分析' }); + expect(analyzePendingButton).toBeDisabled(); + fireEvent.click(analyzePendingButton); + expect(analysisApi.analyzeAsync).not.toHaveBeenCalled(); + }); + + it('blocks a stale stock-bar detail while a manual refresh is pending', async () => { + let resolveStockBarRefresh!: (response: Awaited>) => void; + const stockBarRefreshPromise = new Promise>>((resolve) => { + resolveStockBarRefresh = resolve; + }); + const staleStockBarItem = { + id: 11, + stockCode: '600519', + stockName: '贵州茅台', + reportType: 'detailed' as const, + sentimentScore: 72, + operationAdvice: '观察', + analysisCount: 1, + lastAnalysisTime: '2026-01-01T09:00:00+08:00', + }; + vi.mocked(systemConfigApi.getWatchlist).mockResolvedValue(['600519']); + vi.mocked(historyApi.getStockBarList) + .mockResolvedValueOnce({ total: 1, items: [staleStockBarItem] }) + .mockReturnValueOnce(stockBarRefreshPromise); + vi.mocked(historyApi.getList).mockResolvedValue({ + total: 0, + page: 1, + limit: 20, + items: [], + }); + + render( + + + , + ); + + fireEvent.click(await screen.findByRole('button', { name: '自选' })); + expect(await screen.findByRole('button', { name: '打开 600519 最新分析详情' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: '刷新自选股列表' })); + + expect(await screen.findByLabelText('确认今日状态中')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '打开 600519 最新分析详情' })).not.toBeInTheDocument(); + expect(historyApi.getDetail).not.toHaveBeenCalled(); + + await act(async () => { + resolveStockBarRefresh({ + total: 1, + items: [{ ...staleStockBarItem, id: 13 }], + }); + await stockBarRefreshPromise; + }); + + expect(await screen.findByRole('button', { name: '打开 600519 最新分析详情' })).toBeInTheDocument(); + }); + + it('stays unsettled when a completion refresh is superseded by a manual refresh', async () => { + let resolveCompletionRefresh!: (response: Awaited>) => void; + let resolveManualRefresh!: (response: Awaited>) => void; + const completionRefreshPromise = new Promise>>((resolve) => { + resolveCompletionRefresh = resolve; + }); + const manualRefreshPromise = new Promise>>((resolve) => { + resolveManualRefresh = resolve; + }); + const oldFallbackItem = { + id: 12, + queryId: 'q-aapl-old', + stockCode: 'AAPL', + stockName: 'Apple', + reportType: 'detailed' as const, + sentimentScore: 68, + operationAdvice: '中性', + createdAt: '2026-01-01T09:20:00+08:00', + }; + vi.mocked(systemConfigApi.getWatchlist).mockResolvedValue(['AAPL']); + vi.mocked(historyApi.getStockBarList) + .mockResolvedValueOnce({ total: 0, items: [] }) + .mockReturnValueOnce(completionRefreshPromise) + .mockReturnValueOnce(manualRefreshPromise); + vi.mocked(historyApi.getList).mockImplementation((params: { stockCode?: string; limit?: number } = {}) => ( + Promise.resolve({ + total: params.stockCode === 'AAPL' ? 1 : 0, + page: 1, + limit: params.limit ?? 20, + items: params.stockCode === 'AAPL' ? [oldFallbackItem] : [], + }) + )); + + render( + + + , + ); + + fireEvent.click(await screen.findByRole('button', { name: '自选' })); + expect(await screen.findByRole('button', { name: '打开 AAPL 最新分析详情' })).toBeInTheDocument(); + + const taskStreamOptions = vi.mocked(useTaskStream).mock.calls.at(-1)?.[0]; + act(() => { + taskStreamOptions?.onTaskCompleted?.({ + taskId: 'task-aapl-overlap', + stockCode: 'AAPL', + stockName: 'Apple', + status: 'completed', + progress: 100, + reportType: 'detailed', + createdAt: '2026-03-18T08:00:00Z', + }); + }); + expect(await screen.findByLabelText('确认今日状态中')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: '刷新自选股列表' })); + + await act(async () => { + resolveCompletionRefresh({ total: 0, items: [] }); + await completionRefreshPromise; + await Promise.resolve(); + }); + + expect(screen.getByLabelText('确认今日状态中')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '打开 AAPL 最新分析详情' })).not.toBeInTheDocument(); + + await act(async () => { + resolveManualRefresh({ + total: 1, + items: [{ + id: 13, + stockCode: 'AAPL', + stockName: 'Apple', + reportType: 'detailed', + sentimentScore: 80, + operationAdvice: '观察', + analysisCount: 2, + lastAnalysisTime: '2026-03-18T10:00:00+08:00', + }], + }); + await manualRefreshPromise; + }); + + expect(await screen.findByRole('button', { name: '打开 AAPL 最新分析详情' })).toBeInTheDocument(); }); it('falls back to watchlist history lookup when watchlist code is outside stock-bar window', async () => { @@ -619,6 +1000,79 @@ describe('HomePage', () => { expect(await screen.findByRole('button', { name: /Apple/ })).toBeInTheDocument(); }); + it('limits concurrent fallback history lookups for large watchlists', async () => { + const codes = Array.from({ length: 10 }, (_, index) => `T${String(index + 1).padStart(3, '0')}`); + type HistoryListResponse = Awaited>; + const pending = new Map void>(); + let inFlight = 0; + let maxInFlight = 0; + + vi.mocked(systemConfigApi.getWatchlist).mockResolvedValue(codes); + vi.mocked(historyApi.getStockBarList).mockResolvedValue({ total: 0, items: [] }); + vi.mocked(historyApi.getList).mockImplementation(( + params: { stockCode?: string; limit?: number } = {}, + options: { signal?: AbortSignal } = {}, + ) => { + if (!params.stockCode || !codes.includes(params.stockCode)) { + return Promise.resolve({ + total: 0, + page: 1, + limit: params.limit ?? 20, + items: [], + }); + } + + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + return new Promise((resolve) => { + const stockCode = params.stockCode!; + const settle = (response: HistoryListResponse) => { + if (!pending.delete(stockCode)) { + return; + } + inFlight -= 1; + resolve(response); + }; + pending.set(stockCode, settle); + options.signal?.addEventListener('abort', () => { + settle({ total: 0, page: 1, limit: 1, items: [] }); + }, { once: true }); + }); + }); + + render( + + + , + ); + + await waitFor(() => expect(pending.size).toBe(4)); + expect(maxInFlight).toBe(4); + expect(vi.mocked(historyApi.getList).mock.calls + .filter(([params]) => Boolean(params?.stockCode && codes.includes(params.stockCode))) + .every(([, options]) => options?.signal instanceof AbortSignal)).toBe(true); + + for (let index = 0; index < codes.length; index += 1) { + await waitFor(() => expect(pending.size).toBeGreaterThan(0)); + const nextPending = pending.entries().next().value as [string, (response: HistoryListResponse) => void]; + await act(async () => { + nextPending[1]({ + total: 0, + page: 1, + limit: 1, + items: [], + }); + }); + } + + await waitFor(() => { + const stockCodeCalls = vi.mocked(historyApi.getList).mock.calls + .filter(([params]) => Boolean(params?.stockCode && codes.includes(params.stockCode))); + expect(stockCodeCalls).toHaveLength(codes.length); + }); + expect(maxInFlight).toBe(4); + }); + it('keeps pending watchlist submission disabled while fallback history lookup is unresolved', async () => { const todayInShanghai = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai' }).format(new Date()); let resolveAaplHistory!: (response: Awaited>) => void; @@ -662,7 +1116,10 @@ describe('HomePage', () => { fireEvent.click(await screen.findByRole('button', { name: '自选' })); await waitFor(() => { - expect(historyApi.getList).toHaveBeenCalledWith({ stockCode: 'AAPL', limit: 1 }); + expect(historyApi.getList).toHaveBeenCalledWith( + { stockCode: 'AAPL', limit: 1 }, + { signal: expect.any(AbortSignal) }, + ); }); expect(await screen.findByLabelText('确认今日状态中')).toBeInTheDocument(); @@ -692,6 +1149,301 @@ describe('HomePage', () => { expect(await screen.findByLabelText('今日已分析')).toBeInTheDocument(); }); + it('does not show no-detail feedback while watchlist fallback history lookup is still pending', async () => { + const todayInShanghai = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai' }).format(new Date()); + let resolveAaplHistory!: (response: Awaited>) => void; + const aaplHistoryPromise = new Promise>>((resolve) => { + resolveAaplHistory = resolve; + }); + + vi.mocked(systemConfigApi.getWatchlist).mockResolvedValue(['AAPL']); + vi.mocked(historyApi.getStockBarList).mockResolvedValue({ + total: 1, + items: [{ + id: 11, + stockCode: '600519', + stockName: '贵州茅台', + reportType: 'detailed', + sentimentScore: 72, + operationAdvice: '观察', + analysisCount: 2, + lastAnalysisTime: `${todayInShanghai}T22:00:00`, + }], + }); + vi.mocked(historyApi.getList).mockImplementation((params: { stockCode?: string; limit?: number } = {}) => { + if (params.stockCode === 'AAPL') { + return aaplHistoryPromise; + } + + return Promise.resolve({ + total: 0, + page: 1, + limit: params.limit ?? 20, + items: [], + }); + }); + vi.mocked(historyApi.getDetail).mockResolvedValue({ + meta: { + id: 12, + queryId: 'q-aapl', + stockCode: 'AAPL', + stockName: 'Apple', + reportType: 'detailed', + reportLanguage: 'zh', + createdAt: `${todayInShanghai}T09:20:00`, + }, + summary: { + analysisSummary: 'Apple 分析摘要', + operationAdvice: '继续观察', + trendPrediction: '短线震荡', + sentimentScore: 68, + }, + }); + + render( + + + , + ); + + fireEvent.click(await screen.findByRole('button', { name: '自选' })); + await waitFor(() => { + expect(historyApi.getList).toHaveBeenCalledWith( + { stockCode: 'AAPL', limit: 1 }, + { signal: expect.any(AbortSignal) }, + ); + }); + + const loadingRow = await screen.findByRole('button', { name: '正在查找 AAPL 的最新分析详情' }); + fireEvent.click(loadingRow); + + expect(await screen.findByRole('alert')).toHaveTextContent('正在查找最新分析详情,请稍候。'); + expect(screen.queryByText('暂无分析详情,可先分析。')).not.toBeInTheDocument(); + expect(historyApi.getDetail).not.toHaveBeenCalled(); + + await act(async () => { + resolveAaplHistory({ + total: 1, + page: 1, + limit: 1, + items: [{ + id: 12, + queryId: 'q-aapl', + stockCode: 'AAPL', + stockName: 'Apple', + reportType: 'detailed', + sentimentScore: 68, + operationAdvice: '中性', + createdAt: `${todayInShanghai}T09:20:00`, + }], + }); + await aaplHistoryPromise; + }); + + const readyRow = await screen.findByRole('button', { name: '打开 AAPL 最新分析详情' }); + fireEvent.click(readyRow); + await waitFor(() => { + expect(historyApi.getDetail).toHaveBeenCalledWith(12); + }); + }); + + it('keeps watchlist fallback rows loading while stock-bar refresh has failed but history lookup is still pending', async () => { + let resolveAaplHistory!: (response: Awaited>) => void; + const aaplHistoryPromise = new Promise>>((resolve) => { + resolveAaplHistory = resolve; + }); + + vi.mocked(systemConfigApi.getWatchlist).mockResolvedValue(['AAPL']); + vi.mocked(historyApi.getStockBarList).mockRejectedValue(new Error('stock-bar unavailable')); + vi.mocked(historyApi.getList).mockImplementation((params: { stockCode?: string; limit?: number } = {}) => { + if (params.stockCode === 'AAPL') { + return aaplHistoryPromise; + } + + return Promise.resolve({ + total: 0, + page: 1, + limit: params.limit ?? 20, + items: [], + }); + }); + + render( + + + , + ); + + fireEvent.click(await screen.findByRole('button', { name: '自选' })); + + await waitFor(() => { + expect(historyApi.getList).toHaveBeenCalledWith( + { stockCode: 'AAPL', limit: 1 }, + { signal: expect.any(AbortSignal) }, + ); + }); + + const loadingRow = await screen.findByRole('button', { name: '正在查找 AAPL 的最新分析详情' }); + expect(screen.getByLabelText('确认今日状态中')).toBeInTheDocument(); + fireEvent.click(loadingRow); + + expect(await screen.findByRole('alert')).toHaveTextContent('正在查找最新分析详情,请稍候。'); + expect(screen.queryByText('暂无分析详情,可先分析。')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: '仅未分析' })).toBeDisabled(); + + await act(async () => { + resolveAaplHistory({ + total: 0, + page: 1, + limit: 1, + items: [], + }); + await aaplHistoryPromise; + }); + + expect(await screen.findByLabelText('今日状态未知')).toBeInTheDocument(); + }); + + it('does not show no-detail feedback while a failed stock-bar refresh still has a pending fallback detail lookup', async () => { + const todayInShanghai = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai' }).format(new Date()); + let resolveAaplHistory!: (response: Awaited>) => void; + const aaplHistoryPromise = new Promise>>((resolve) => { + resolveAaplHistory = resolve; + }); + + vi.mocked(systemConfigApi.getWatchlist).mockResolvedValue(['AAPL']); + vi.mocked(historyApi.getStockBarList).mockRejectedValue(new Error('stock-bar unavailable')); + vi.mocked(historyApi.getList).mockImplementation((params: { stockCode?: string; limit?: number } = {}) => { + if (params.stockCode === 'AAPL') { + return aaplHistoryPromise; + } + + return Promise.resolve({ + total: 0, + page: 1, + limit: params.limit ?? 20, + items: [], + }); + }); + vi.mocked(historyApi.getDetail).mockResolvedValue({ + meta: { + id: 12, + queryId: 'q-aapl', + stockCode: 'AAPL', + stockName: 'Apple', + reportType: 'detailed', + reportLanguage: 'zh', + createdAt: `${todayInShanghai}T09:20:00`, + }, + summary: { + analysisSummary: 'Apple 分析摘要', + operationAdvice: '继续观察', + trendPrediction: '短线震荡', + sentimentScore: 68, + }, + }); + + render( + + + , + ); + + fireEvent.click(await screen.findByRole('button', { name: '自选' })); + await waitFor(() => { + expect(historyApi.getList).toHaveBeenCalledWith( + { stockCode: 'AAPL', limit: 1 }, + { signal: expect.any(AbortSignal) }, + ); + }); + + const loadingRow = await screen.findByRole('button', { name: '正在查找 AAPL 的最新分析详情' }); + fireEvent.click(loadingRow); + + expect(await screen.findByRole('alert')).toHaveTextContent('正在查找最新分析详情,请稍候。'); + expect(screen.queryByText('暂无分析详情,可先分析。')).not.toBeInTheDocument(); + + await act(async () => { + resolveAaplHistory({ + total: 1, + page: 1, + limit: 1, + items: [{ + id: 12, + queryId: 'q-aapl', + stockCode: 'AAPL', + stockName: 'Apple', + reportType: 'detailed', + sentimentScore: 68, + operationAdvice: '中性', + createdAt: `${todayInShanghai}T09:20:00`, + }], + }); + await aaplHistoryPromise; + }); + + expect(await screen.findByRole('button', { + name: 'AAPL 的最新分析详情暂时无法确认,请稍后重试', + })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '打开 AAPL 最新分析详情' })).not.toBeInTheDocument(); + expect(historyApi.getDetail).not.toHaveBeenCalled(); + expect(screen.getByLabelText('今日状态未知')).toBeInTheDocument(); + }); + + it('retries a failed per-code lookup but keeps detail blocked while upstream state is unknown', async () => { + let aaplLookupCount = 0; + vi.mocked(systemConfigApi.getWatchlist) + .mockResolvedValueOnce(['AAPL']) + .mockRejectedValue(new Error('watchlist unavailable')); + vi.mocked(historyApi.getStockBarList).mockRejectedValue(new Error('stock-bar unavailable')); + vi.mocked(historyApi.getList).mockImplementation((params: { stockCode?: string; limit?: number } = {}) => { + if (params.stockCode === 'AAPL') { + aaplLookupCount += 1; + if (aaplLookupCount === 1) { + return Promise.reject(new Error('detail unavailable')); + } + return Promise.resolve({ + total: 1, + page: 1, + limit: 1, + items: [{ + id: 12, + queryId: 'q-aapl-recovered', + stockCode: 'AAPL', + stockName: 'Apple', + reportType: 'detailed' as const, + sentimentScore: 68, + operationAdvice: 'neutral', + createdAt: '2026-03-18T09:20:00+08:00', + }], + }); + } + return Promise.resolve({ + total: 0, + page: 1, + limit: params.limit ?? 20, + items: [], + }); + }); + + render( + + + , + ); + + fireEvent.click(await screen.findByRole('button', { name: '自选' })); + expect(await screen.findByRole('button', { name: 'AAPL 的最新分析详情暂时无法确认,请稍后重试' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: '刷新自选股列表' })); + + await waitFor(() => expect(aaplLookupCount).toBe(2)); + expect(await screen.findByRole('button', { + name: 'AAPL 的最新分析详情暂时无法确认,请稍后重试', + })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '打开 AAPL 最新分析详情' })).not.toBeInTheDocument(); + }); + it('waits for stock-bar load before launching watchlist fallback lookups', async () => { let resolveStockBar!: (response: Awaited>) => void; const stockBarPromise = new Promise>>((resolve) => { @@ -729,7 +1481,10 @@ describe('HomePage', () => { }); await waitFor(() => { - expect(historyApi.getList).toHaveBeenCalledWith({ stockCode: 'AAPL', limit: 1 }); + expect(historyApi.getList).toHaveBeenCalledWith( + { stockCode: 'AAPL', limit: 1 }, + { signal: expect.any(AbortSignal) }, + ); }); }); @@ -873,6 +1628,142 @@ describe('HomePage', () => { ).toBeTruthy(); }); + it('keeps the task panel collapsed after task stream updates', async () => { + window.sessionStorage.setItem('dsa.home.taskPanelCollapsed', 'false'); + vi.mocked(historyApi.getList).mockResolvedValue({ + total: 0, + page: 1, + limit: 20, + items: [], + }); + vi.mocked(analysisApi.getTasks).mockResolvedValue({ + total: 2, + pending: 1, + processing: 1, + tasks: [ + { + taskId: 'task-1', + traceId: 'trace-1', + stockCode: '600519', + stockName: '贵州茅台', + status: 'processing', + progress: 35, + message: '分析中', + reportType: 'detailed', + createdAt: '2026-06-08T08:00:00Z', + }, + { + taskId: 'task-2', + stockCode: 'AAPL', + stockName: 'Apple', + status: 'pending', + progress: 0, + message: '等待中', + reportType: 'detailed', + createdAt: '2026-06-08T08:01:00Z', + }, + ], + }); + + render( + + + , + ); + + const collapseButton = await screen.findByRole('button', { name: '折叠任务面板' }); + fireEvent.click(collapseButton); + + expect(await screen.findByTestId('task-panel-collapsed-summary')).toHaveTextContent('1 进行中'); + expect(screen.queryByTestId('task-panel-item')).not.toBeInTheDocument(); + + const taskStreamOptions = vi.mocked(useTaskStream).mock.calls.at(-1)?.[0]; + act(() => { + taskStreamOptions?.onTaskProgress?.({ + taskId: 'task-1', + traceId: 'trace-1', + stockCode: '600519', + stockName: '贵州茅台', + status: 'processing', + progress: 72, + message: '分析进度更新', + reportType: 'detailed', + createdAt: '2026-06-08T08:00:00Z', + }); + }); + + expect(await screen.findByRole('button', { name: '展开任务面板' })).toHaveAttribute('aria-expanded', 'false'); + expect(screen.getByTestId('task-panel-collapsed-summary')).toHaveTextContent('1 进行中'); + expect(screen.getByTestId('task-panel-collapsed-summary')).toHaveTextContent('1 等待中'); + expect(screen.queryByTestId('task-panel-item')).not.toBeInTheDocument(); + }); + + it('keeps the task panel usable when sessionStorage access is blocked', async () => { + const sessionGetItemSpy = vi.spyOn(window.sessionStorage, 'getItem').mockImplementation((key: string) => { + if (key === 'dsa.home.taskPanelCollapsed') { + throw new DOMException('Access denied', 'SecurityError'); + } + return null; + }); + const sessionSetItemSpy = vi.spyOn(window.sessionStorage, 'setItem').mockImplementation((key: string, value: string) => { + void value; + if (key === 'dsa.home.taskPanelCollapsed') { + throw new DOMException('Access denied', 'SecurityError'); + } + }); + try { + vi.mocked(historyApi.getList).mockResolvedValue({ + total: 0, + page: 1, + limit: 20, + items: [], + }); + vi.mocked(analysisApi.getTasks).mockResolvedValue({ + total: 2, + pending: 1, + processing: 1, + tasks: [ + { + taskId: 'task-1', + traceId: 'trace-1', + stockCode: '600519', + stockName: '贵州茅台', + status: 'processing', + progress: 35, + message: '分析中', + reportType: 'detailed', + createdAt: '2026-06-08T08:00:00Z', + }, + { + taskId: 'task-2', + stockCode: 'AAPL', + stockName: 'Apple', + status: 'pending', + progress: 0, + message: '等待中', + reportType: 'detailed', + createdAt: '2026-06-08T08:01:00Z', + }, + ], + }); + + render( + + + , + ); + + expect(await screen.findByTestId('task-panel-collapsed-summary')).toHaveTextContent('1 进行中'); + + fireEvent.click(screen.getByRole('button', { name: '展开任务面板' })); + + expect(await screen.findByRole('button', { name: '折叠任务面板' })).toHaveAttribute('aria-expanded', 'true'); + } finally { + sessionGetItemSpy.mockRestore(); + sessionSetItemSpy.mockRestore(); + } + }); + it('keeps Shanghai-day records that fall on the previous server date', async () => { const todayInShanghai = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai' }).format(new Date()); const rangeStart = new Date(`${todayInShanghai}T12:00:00Z`); diff --git a/apps/dsa-web/src/stores/__tests__/stockPoolStore.test.ts b/apps/dsa-web/src/stores/__tests__/stockPoolStore.test.ts index cae06ca2b..3944fdde6 100644 --- a/apps/dsa-web/src/stores/__tests__/stockPoolStore.test.ts +++ b/apps/dsa-web/src/stores/__tests__/stockPoolStore.test.ts @@ -1219,6 +1219,7 @@ describe('stockPoolStore', () => { expect(useStockPoolStore.getState().isLoadingStockBar).toBe(true); const refreshPromise = useStockPoolStore.getState().refreshStockBar(); + expect(useStockPoolStore.getState().isLoadingStockBar).toBe(true); refreshStockBarRequest.resolve({ total: 1, items: [stockBarItem], diff --git a/apps/dsa-web/src/stores/stockPoolStore.ts b/apps/dsa-web/src/stores/stockPoolStore.ts index 0cf73f824..74342ee7a 100644 --- a/apps/dsa-web/src/stores/stockPoolStore.ts +++ b/apps/dsa-web/src/stores/stockPoolStore.ts @@ -1077,6 +1077,7 @@ export const useStockPoolStore = create((set, get) => ({ refreshStockBar: async () => { const requestSeq = ++stockBarRequestSeq; + set({ isLoadingStockBar: true }); try { const response = await historyApi.getStockBarList({ startDate: getRecentStartDate(90), diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e65340903..002c301cf 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -36,6 +36,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - [修复] 统一等价股票代码的本地日线候选与同源窗口解析;冲突沪深交易所代码不再降级匹配裸码,回测仅接受快照或交易日历确认的起点,并在同一起点中优先完整的单一代码窗口。 - [新功能] 新增按 individual SkillAgent 自身 signal、版本化 engine 与本地已存同源日线窗口计算并持久化 `skill_opinion_outcomes` 的核心服务。 - [修复] #1970 关闭认证属于高风险操作,即使携带有效 session cookie 也强制要求再次输入当前管理员密码二次确认;后端 `auth_update_settings` 的 disable 分支统一走 currentPassword 校验,命中 rate limit 时与 enable 路径一致返回 429,前端 `AuthSettingsCard` 在关闭认证时如有缺失当前密码将阻止提交并给出内联提示。 +- [改进] 优化首页侧栏任务面板与自选股工作区:支持折叠任务摘要、自选股直接打开最新详情,并压缩头部操作以释放窄侧栏列表空间。 +- [修复] 收敛自选股行交互与今日状态语义:详情与移除操作使用独立可访问按钮,详情提示从当前行状态实时派生并区分查找中、查找失败和确认无详情,任何 stock-bar 请求及完成任务后的数据刷新都会在开始时进入待确认状态,旧 stock-bar 或 fallback 报告在重新确认或未知期间不会作为最新详情开放;刷新失败时不再把旧历史记录误标为今日分析,自选股刷新会显式重试列表、stock-bar 与逐股票详情查询,逐股票 fallback 使用固定 worker 并发上限并取消已失效的查询批次。 - [新功能] 新增按 individual SkillAgent 自身 signal、版本化 engine 与本地已存同源日线窗口计算并持久化 `skill_opinion_outcomes` 的核心服务;本阶段不提供管理员 API、表现统计、样本充足度或权重调整。 - [新功能] STOCK_LIST 解析新增 `parse_analysis_target()` 单条目解析契约,支持 sh/sz/bj/hk/us 前缀校验、裸码默认归股票、未命中前缀降级为股票三段语义;保留现有 `split_stock_list()`/`serialize_stock_list()` 行为不变,并对外暴露 `IndexRegistry`、`AnalysisTarget`、`ParseStatus`、`default_index_registry()` 以便上层注入自定义指数白名单(关联 issue #2063 Phase 1) - [修复] `parse_analysis_target()` 在显式交易所后缀输入被规范化层拒绝时(如 `600519.BJ`、`600000.HK`、`1234567.SH`、`abc.SH`),不再静默改写为 `sh` 或继续走裸码分类导致误判为 US,而是直接返回 `unsupported` 并携带可定位原因;保留 `000300.SH` / `sh000300.SH` 等已知 INDEX alias 的索引命中路径(关闭 PR #2122 review blocker OR-COR-607f1395 / OR-COR-26596201 / OR-COR-d6afd0d6) diff --git a/docs/full-guide.md b/docs/full-guide.md index 6ba226984..29a84d4eb 100644 --- a/docs/full-guide.md +++ b/docs/full-guide.md @@ -1563,7 +1563,8 @@ FastAPI 提供 RESTful API 服务,支持配置管理和触发分析。 ### 功能特性 - 📝 **配置管理** - 查看/修改自选股列表 -- 🗂️ **首页三视图** - 首页新增「历史 / 自选 / 今日」工作区,默认进入历史视图;自选页支持批量提交全部或仅提交“今日未分析”股票 +- 🗂️ **首页三视图** - 首页提供「历史 / 自选 / 今日」工作区,默认进入历史视图;自选股行可用鼠标或键盘打开已确认的最新分析详情,提示始终跟随当前的查找中、查找失败或确认无详情状态;任何 stock-bar 请求及完成任务后的数据刷新都会在开始时进入待确认状态,重新确认或状态未知期间不会开放旧 stock-bar 或 fallback 报告;自选页刷新会同时重试列表和详情状态,逐股票详情补查使用固定并发上限,并在刷新或页面状态切换时取消已失效批次;支持批量提交全部或仅提交“今日未分析”股票 +- 📌 **任务面板折叠** - 首页任务面板可折叠/展开,折叠后保留 pending/processing 摘要并把更多侧栏空间让给自选股列表;折叠状态在当前页面会话内保持 - 🧭 **界面语言切换** - 登录态与退出态均支持界面语言快速切换(`zh` / `en`),独立于 `REPORT_LANGUAGE`,用于静态 UI 文案与导航骨架 - 🚀 **快速分析** - 通过 API 接口触发个股分析;首页也提供“大盘复盘”按钮和单次市场选择器,可在 Docker/server 模式下按服务器默认或临时选择的单个/多个市场后台触发复盘 - 🎯 **策略选择** - 首页支持显式选择分析策略 skill;不传 `skills` 时按系统默认策略运行,便于保持与历史行为兼容 diff --git a/docs/full-guide_EN.md b/docs/full-guide_EN.md index 49098942a..42d7241d0 100644 --- a/docs/full-guide_EN.md +++ b/docs/full-guide_EN.md @@ -1402,7 +1402,8 @@ FastAPI provides RESTful API service for configuration management and triggering ### Features - **Configuration Management** - View/modify watchlist -- **Home workspace tri-view** - Home now has History / Watchlist / Today tabs, with History as the default view; Watchlist supports batch submission for all stocks or only those not analyzed today +- **Home workspace tri-view** - Home has History / Watchlist / Today tabs, with History as the default view; a watchlist row opens its confirmed latest report with mouse or keyboard, and its notice always follows the current lookup-in-progress, lookup-failed, or confirmed-no-detail state; every stock-bar request and the data refresh after task completion enter the unsettled state immediately, so stale stock-bar and fallback reports stay unavailable while status is being reconfirmed or is unknown; Refresh retries both the watchlist and detail status, with per-stock fallback lookups using a fixed concurrency bound and obsolete batches cancelled on refresh or page-state changes; Watchlist supports batch submission for all stocks or only those not analyzed today +- **Collapsible task panel** - The Home task panel can be collapsed or expanded; the collapsed state keeps pending/processing summaries visible, gives more sidebar space to the watchlist, and persists for the current page session - **UI Language Switch** - Toggle UI language (`zh`/`en`) on login page, shell/navigation, settings page, and shared controls; this switch is independent of `REPORT_LANGUAGE`. - **Quick Analysis** - Trigger stock analysis via API; the Home page also provides a one-run market selector next to Market Review, so Docker/server mode can use the server default or a temporary single/multi-market scope - **Strategy selection** - The Home page supports explicitly selecting analysis strategy skills; when `skills` is omitted, analysis uses the server default strategy so legacy clients keep existing behavior