fix: stabilize watchlist details and workspace state (#2126)

This commit is contained in:
zhulinsen
2026-08-05 22:45:12 +08:00
committed by GitHub
parent 03dd26ac2e
commit ae19329d66
16 changed files with 1720 additions and 132 deletions

1
.gitignore vendored
View File

@@ -87,4 +87,5 @@ verify_*.py
static/
/apps/dsa-desktop/dist/
/apps/dsa-desktop/node_modules/
/node_modules/
docs/specs/

View File

@@ -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<HistoryListResponse> => {
getList: async (
params: GetHistoryListParams = {},
options: GetHistoryListOptions = {},
): Promise<HistoryListResponse> => {
const { stockCode, reportType, startDate, endDate, page = 1, limit = 20 } = params;
const queryParams: Record<string, string | number> = { page, limit };
@@ -35,6 +42,7 @@ export const historyApi = {
const response = await apiClient.get<Record<string, unknown>>('/api/v1/history', {
params: queryParams,
signal: options.signal,
});
const data = toCamelCase<{ total: number; page: number; limit: number; items: HistoryItem[] }>(response.data);

View File

@@ -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<TaskPanelProps> = ({
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<TaskPanelProps> = ({
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 (
<Card
variant="bordered"
padding="none"
className={`home-panel-card overflow-hidden ${className}`}
className={`home-panel-card shrink-0 overflow-hidden ${className}`}
>
<div className="border-b border-subtle px-3 py-3">
<div className="px-3 py-3">
<DashboardPanelHeader
className="mb-0"
title={title ?? t('taskPanel.title')}
@@ -201,31 +227,71 @@ export const TaskPanel: React.FC<TaskPanelProps> = ({
)}
headingClassName="items-center"
actions={(
<div className="flex items-center gap-2 text-xs text-muted-text">
{processingCount > 0 && (
<span className="flex items-center gap-1">
<StatusDot tone="info" pulse className="h-1.5 w-1.5" aria-label="进行中任务" />
{t('taskPanel.processingTasks', { count: processingCount })}
</span>
)}
{pendingCount > 0 ? (
<span className="flex items-center gap-1">
<StatusDot tone="neutral" className="h-1.5 w-1.5" aria-label="等待中任务" />
{t('taskPanel.pendingTasks', { count: pendingCount })}
</span>
) : null}
<div className="flex items-center gap-1.5">
<div className="hidden items-center gap-2 text-xs text-muted-text sm:flex">
{processingCount > 0 && (
<span className="flex items-center gap-1">
<StatusDot tone="info" pulse className="h-1.5 w-1.5" />
{t('taskPanel.processingTasks', { count: processingCount })}
</span>
)}
{pendingCount > 0 ? (
<span className="flex items-center gap-1">
<StatusDot tone="neutral" className="h-1.5 w-1.5" />
{t('taskPanel.pendingTasks', { count: pendingCount })}
</span>
) : null}
</div>
<Button
type="button"
variant="ghost"
size="xsm"
className="h-7 w-7 px-0"
aria-expanded={!isCollapsed}
aria-controls={contentId}
aria-label={isCollapsed ? t('taskPanel.expand') : t('taskPanel.collapse')}
onClick={handleCollapsedChange}
>
<ChevronDown
className={`h-4 w-4 transition-transform ${isCollapsed ? '-rotate-90' : ''}`}
aria-hidden="true"
/>
</Button>
</div>
)}
/>
</div>
<div className="max-h-64 overflow-y-auto p-2">
<div className="space-y-2">
{activeTasks.map((task) => (
<TaskItem key={task.taskId} task={task} onOpenRunFlow={onOpenRunFlow} />
))}
{isCollapsed ? (
<div
id={contentId}
className="border-t border-subtle px-3 py-2.5 text-xs text-muted-text"
data-testid="task-panel-collapsed-summary"
>
<div className="flex flex-wrap items-center gap-2">
{processingCount > 0 ? (
<span>{t('taskPanel.processingTasks', { count: processingCount })}</span>
) : null}
{pendingCount > 0 ? (
<span>{t('taskPanel.pendingTasks', { count: pendingCount })}</span>
) : null}
{cancelRequestedCount > 0 ? (
<span>{t('taskPanel.cancelRequestedTasks', { count: cancelRequestedCount })}</span>
) : null}
{processingCount > 0 ? (
<span>{t('taskPanel.averageProgress', { progress: averageProgress })}</span>
) : null}
</div>
</div>
</div>
) : (
<div id={contentId} className="max-h-64 overflow-y-auto border-t border-subtle p-2">
<div className="space-y-2">
{activeTasks.map((task) => (
<TaskItem key={task.taskId} task={task} onOpenRunFlow={onOpenRunFlow} />
))}
</div>
</div>
)}
</Card>
);
};

View File

@@ -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(
<TaskPanel
tasks={[
{
...baseTask,
progress: 40,
},
{
...baseTask,
taskId: 'task-2',
stockCode: 'AAPL',
stockName: 'Apple',
status: 'pending',
progress: 0,
},
]}
/>,
);
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(
<TaskPanel

View File

@@ -9,16 +9,18 @@ import {
Loader2,
Play,
Plus,
RefreshCw,
Star,
Trash2,
} from 'lucide-react';
import { Badge, Button, Input, ScrollArea, StatusDot } from '../common';
import { Badge, Button, InlineAlert, Input, ScrollArea, StatusDot } from '../common';
import { DashboardPanelHeader, DashboardStateBlock } from '../dashboard';
import { StockBar } from '../history';
import type { StockBarItem, TaskInfo } from '../../types/analysis';
import { getSentimentColor } from '../../types/analysis';
import { buildDecisionActionLabelMap, getDecisionActionLabel } from '../../utils/decisionAction';
import { formatDateTime } from '../../utils/format';
import { areStockCodesEquivalent } from '../../utils/stockCode';
import { truncateStockName } from '../../utils/stockName';
import { useUiLanguage } from '../../contexts/UiLanguageContext';
import type { UiTextKey, UiTextParams } from '../../i18n/uiText';
@@ -111,16 +113,44 @@ const ScoreBadge: React.FC<{ item?: StockBarItem }> = ({ item }) => {
const WatchlistRowItem: React.FC<{
row: HomeWatchlistRow;
onRemove: (code: string) => Promise<void>;
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 (
<div className="home-subpanel grid min-w-0 gap-2 px-3 py-2.5">
<div className="flex min-w-0 items-start justify-between gap-2">
<div
className={`home-subpanel group grid min-w-0 grid-cols-[minmax(0,1fr)_auto] gap-2 px-3 py-2.5 text-left transition-colors ${
selected
? 'border-primary/35 bg-primary/10'
: 'hover:border-subtle-hover hover:bg-base/65'
}`}
data-testid={`watchlist-row-${row.code}`}
>
<button
type="button"
aria-pressed={selected}
aria-label={canOpenDetail
? t('watchlist.openLatestDetailAria', { code: row.code })
: isLatestDetailLoading
? t('watchlist.latestDetailLoadingAria', { code: row.code })
: isLatestDetailUnavailable
? t('watchlist.latestDetailUnavailableAria', { code: row.code })
: t('watchlist.noLatestDetailAria', { code: row.code })}
className="grid min-w-0 cursor-pointer gap-2 rounded-lg text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan/30"
onClick={handleOpenDetail}
>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-semibold text-foreground">
@@ -145,32 +175,43 @@ const WatchlistRowItem: React.FC<{
</>
) : null}
</div>
<div className="flex min-w-0 items-center justify-between gap-2 text-[11px]">
<span className={`truncate ${canOpenDetail ? 'text-primary' : isLatestDetailLoading ? 'text-muted-text' : 'text-warning'}`}>
{canOpenDetail
? t('common.details')
: isLatestDetailLoading
? t('watchlist.latestDetailLoadingCta')
: isLatestDetailUnavailable
? t('watchlist.latestDetailUnavailableCta')
: t('watchlist.noLatestDetailCta')}
</span>
</div>
{row.activeTask ? (
<div className="flex min-w-0 items-center gap-2 text-[11px] text-muted-text">
<StatusDot
tone={row.activeTask.status === 'processing' ? 'info' : 'neutral'}
pulse={row.activeTask.status === 'processing'}
className="h-1.5 w-1.5"
/>
<span className="truncate">{t('watchlist.taskRunning', { status: taskLabel })}</span>
</div>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1.5">
<ScoreBadge item={item} />
<Button
type="button"
variant="ghost"
size="xsm"
className="h-7 w-7 px-0"
disabled={disabled}
aria-label={t('watchlist.removeAria', { code: row.code })}
onClick={() => void onRemove(row.code)}
>
<Trash2 className="h-3.5 w-3.5 text-danger" aria-hidden="true" />
</Button>
</div>
</button>
<div className="flex shrink-0 items-start gap-1.5">
<ScoreBadge item={item} />
<Button
type="button"
variant="ghost"
size="xsm"
className="h-7 w-7 px-0"
disabled={disabled}
aria-label={t('watchlist.removeAria', { code: row.code })}
onClick={() => void onRemove(row.code)}
>
<Trash2 className="h-3.5 w-3.5 text-danger" aria-hidden="true" />
</Button>
</div>
{row.activeTask ? (
<div className="flex min-w-0 items-center gap-2 text-[11px] text-muted-text">
<StatusDot
tone={row.activeTask.status === 'processing' ? 'info' : 'neutral'}
pulse={row.activeTask.status === 'processing'}
className="h-1.5 w-1.5"
/>
<span className="truncate">{t('watchlist.taskRunning', { status: taskLabel })}</span>
</div>
) : null}
</div>
);
};
@@ -225,6 +266,7 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
}) => {
const { t } = useUiLanguage();
const [draftCode, setDraftCode] = useState('');
const [workspaceNoticeCode, setWorkspaceNoticeCode] = useState<string | null>(null);
const pendingWatchlistCount = watchlistRows
.filter((row) => !row.analyzedToday && !row.isTodayStatusLoading && !row.isTodayStatusUnknown)
.length;
@@ -243,13 +285,42 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
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 = (
<div className="grid grid-cols-3 gap-1 rounded-xl border border-subtle bg-base/40 p-1">
{tabs.map((tab) => {
@@ -262,7 +333,10 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
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}
</button>
@@ -291,7 +365,7 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
return (
<aside className={`glass-card flex min-h-0 flex-1 flex-col overflow-hidden ${className}`}>
<div className="space-y-3 border-b border-subtle px-4 py-4">
<div className="space-y-2.5 border-b border-subtle px-3 py-3 sm:px-4">
{renderTabs}
{activeTab === 'watchlist' ? (
@@ -301,24 +375,40 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
title={t('watchlist.title')}
titleClassName="text-sm font-medium"
leading={<Star className="h-4 w-4 text-primary" aria-hidden="true" />}
actions={<span className="text-[11px] text-muted-text">{t('common.itemsCount', { count: watchlistRows.length })}</span>}
actions={(
<div className="flex items-center gap-1.5">
<span className="text-[11px] text-muted-text">{t('common.itemsCount', { count: watchlistRows.length })}</span>
<Button
type="button"
variant="ghost"
size="xsm"
className="h-7 w-7 px-0"
disabled={watchlistLoading}
onClick={() => {
setWorkspaceNoticeCode(null);
void onRefreshWatchlist();
}}
aria-label={t('watchlist.refreshAria')}
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
</div>
)}
/>
<div className="grid grid-cols-2 gap-2">
<div className="rounded-xl border border-subtle bg-base/35 px-3 py-2">
<p className="text-[11px] text-muted-text">{t('watchlist.todayCoverage')}</p>
<p className="mt-1 text-sm font-semibold text-foreground">{watchlistAnalyzedTodayCount}/{watchlistRows.length}</p>
</div>
<div className="rounded-xl border border-subtle bg-base/35 px-3 py-2">
<p className="text-[11px] text-muted-text">{t('watchlist.pendingToday')}</p>
<p className="mt-1 text-sm font-semibold text-foreground">{pendingWatchlistCount}</p>
</div>
<div className="flex flex-wrap gap-1.5">
<Badge variant="default" className="gap-1 shadow-none text-[11px]">
{t('watchlist.todayCoverage')} {watchlistAnalyzedTodayCount}/{watchlistRows.length}
</Badge>
<Badge variant="default" className="gap-1 shadow-none text-[11px]">
{t('watchlist.pendingToday')} {pendingWatchlistCount}
</Badge>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="home-action-ai"
className="whitespace-nowrap px-2 text-xs"
className="h-8 flex-1 whitespace-nowrap px-2 text-xs sm:flex-none"
disabled={watchlistRows.length === 0 || isBatchAnalyzing}
isLoading={isBatchAnalyzing}
loadingText={t('watchlist.submitting')}
@@ -331,7 +421,7 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
type="button"
size="sm"
variant="home-action-report"
className="whitespace-nowrap px-2 text-xs"
className="h-8 flex-1 whitespace-nowrap px-2 text-xs sm:flex-none"
disabled={pendingWatchlistCount === 0 || isTodayStatusUnavailable || isBatchAnalyzing}
onClick={() => void onAnalyzeWatchlist('pending')}
>
@@ -344,7 +434,7 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
value={draftCode}
onChange={(event) => setDraftCode(event.target.value)}
placeholder={t('watchlist.addPlaceholder')}
className="h-9 rounded-lg px-3 text-xs"
className="h-8 rounded-lg px-3 text-xs"
disabled={watchlistActioning}
aria-label={t('watchlist.addPlaceholder')}
/>
@@ -352,7 +442,7 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
type="submit"
size="sm"
variant="secondary"
className="h-9 w-9 px-0"
className="h-8 w-8 px-0"
disabled={!draftCode.trim() || watchlistActioning}
isLoading={watchlistActioning}
aria-label={t('watchlist.add')}
@@ -370,6 +460,13 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
{watchlistMessage}
</div>
) : null}
{visibleWorkspaceNotice ? (
<InlineAlert
variant="warning"
message={visibleWorkspaceNotice.message}
className="rounded-xl px-3 py-2 text-xs shadow-none"
/>
) : null}
</>
) : (
<>
@@ -380,23 +477,19 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
leading={<CalendarDays className="h-4 w-4 text-cyan" aria-hidden="true" />}
actions={<span className="text-[11px] text-muted-text">{t('common.itemsCount', { count: todayItems.length })}</span>}
/>
<div className="grid grid-cols-2 gap-2">
<div className="rounded-xl border border-subtle bg-base/35 px-3 py-2">
<p className="text-[11px] text-muted-text">{t('watchlist.watchlistCoverage')}</p>
<p className="mt-1 text-sm font-semibold text-foreground">{watchlistAnalyzedTodayCount}/{watchlistRows.length}</p>
</div>
<div className="rounded-xl border border-subtle bg-base/35 px-3 py-2">
<p className="text-[11px] text-muted-text">{t('watchlist.topScore')}</p>
<p className="mt-1 truncate text-sm font-semibold text-foreground">
{topTodayItem?.sentimentScore ?? '-'}
</p>
</div>
<div className="flex flex-wrap gap-1.5">
<Badge variant="default" className="gap-1 shadow-none text-[11px]">
{t('watchlist.watchlistCoverage')} {watchlistAnalyzedTodayCount}/{watchlistRows.length}
</Badge>
<Badge variant="default" className="gap-1 shadow-none text-[11px]">
{t('watchlist.topScore')} {topTodayItem?.sentimentScore ?? '-'}
</Badge>
</div>
</>
)}
</div>
<ScrollArea viewportClassName="p-4" className="min-h-0 flex-1">
<ScrollArea viewportClassName="px-3 py-3 sm:px-4" className="min-h-0 flex-1">
{activeTab === 'watchlist' ? (
watchlistLoading ? (
<DashboardStateBlock loading compact title={t('watchlist.loading')} />
@@ -407,7 +500,7 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
description={t('watchlist.emptyDescription')}
/>
) : (
<div className="space-y-2">
<div className="space-y-1.5">
<div className="flex items-center gap-2 text-[11px] text-muted-text">
<ArrowDownWideNarrow className="h-3.5 w-3.5" aria-hidden="true" />
{t('watchlist.listHint')}
@@ -416,8 +509,22 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
<WatchlistRowItem
key={row.code}
row={row}
onRemove={onRemoveFromWatchlist}
onRemove={async (code) => {
setWorkspaceNoticeCode(null);
await onRemoveFromWatchlist(code);
}}
onOpenDetail={handleWatchlistRowOpen}
disabled={watchlistActioning}
selected={
(typeof selectedRecordId === 'number' && selectedRecordId === row.latestItem?.id)
|| (
Boolean(selectedStockCode)
&& (
areStockCodesEquivalent(selectedStockCode ?? '', row.code)
|| areStockCodesEquivalent(selectedStockCode ?? '', row.latestItem?.stockCode ?? '')
)
)
}
/>
))}
</div>
@@ -448,21 +555,6 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
</div>
)}
</ScrollArea>
{activeTab === 'watchlist' ? (
<div className="border-t border-subtle px-4 py-3">
<Button
type="button"
size="sm"
variant="ghost"
className="w-full"
disabled={watchlistLoading}
onClick={() => void onRefreshWatchlist()}
>
{t('watchlist.refresh')}
</Button>
</div>
) : null}
</aside>
);
};

View File

@@ -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[]) => (
<UiLanguageProvider>
<HomeStockWorkspace
activeTab="watchlist"
onTabChange={vi.fn()}
watchlistRows={rows}
watchlistLoading={false}
watchlistActioning={false}
watchlistMessage={null}
onAddToWatchlist={vi.fn().mockResolvedValue(undefined)}
onRemoveFromWatchlist={onRemoveFromWatchlist}
onRefreshWatchlist={vi.fn().mockResolvedValue(undefined)}
onAnalyzeWatchlist={vi.fn().mockResolvedValue(undefined)}
isBatchAnalyzing={false}
batchStatus={null}
todayItems={[]}
isLoadingTodayItems={false}
todayLoadError={false}
watchlistAnalyzedTodayCount={rows.filter((row) => row.analyzedToday).length}
historyItems={[]}
isLoadingHistory={false}
selectedStockCode={selectedStockCode}
selectedRecordId={selectedRecordId}
onHistoryItemClick={onHistoryItemClick}
/>
</UiLanguageProvider>
);
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');
});
});

View File

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

View File

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

View File

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

View File

@@ -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<string>;
};
type WatchlistHistoryLookupResult = {
code: string;
item: HistoryItem | null;
failed: boolean;
};
async function lookupWatchlistHistory(
codes: string[],
isCanceled: () => boolean,
signal: AbortSignal,
): Promise<WatchlistHistoryLookupResult[]> {
const results: Array<WatchlistHistoryLookupResult | undefined> = 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<RunFlowDrawerState>({ open: false });
const [duplicateBannerVisible, setDuplicateBannerVisible] = useState(false);
const [sidebarWorkspaceTab, setSidebarWorkspaceTab] = useState<HomeWorkspaceTab>('history');
const [isTaskPanelCollapsed, setIsTaskPanelCollapsed] = useState<boolean>(() => (
readTaskPanelCollapsedPreference() ?? false
));
const [isBatchAnalyzingWatchlist, setIsBatchAnalyzingWatchlist] = useState(false);
const [batchAnalyzeStatus, setBatchAnalyzeStatus] = useState<BatchAnalyzeStatus>(null);
const [watchlistHistoryItemsByCode, setWatchlistHistoryItemsByCode] = useState<Map<string, StockBarItem>>(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<StockBarItem[]>([]);
const [isLoadingTodayAnalysisItems, setIsLoadingTodayAnalysisItems] = useState(false);
const [todayAnalysisLoadFailed, setTodayAnalysisLoadFailed] = useState(false);
const [todayAnalysisRefreshVersion, setTodayAnalysisRefreshVersion] = useState(0);
const [isStockBarInitialLoadSettled, setIsStockBarInitialLoadSettled] = useState(false);
const [completedTaskRefreshPendingCounts, setCompletedTaskRefreshPendingCounts] = useState<Map<string, number>>(
new Map(),
);
const duplicateBannerTimer = useRef<number | null>(null);
const marketReviewPollTimer = useRef<number | null>(null);
const stockBarLoadStartedRef = useRef(false);
const taskPanelPreferenceSettledRef = useRef(readTaskPanelCollapsedPreference() !== null);
const dashboardScrollRef = useRef<HTMLElement | null>(null);
const strategyMenuRef = useRef<HTMLDivElement | null>(null);
const strategyButtonRef = useRef<HTMLButtonElement | null>(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<string, string>();
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<HomeWatchlistRow[]>(() => (
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(
() => (
<div className="flex min-h-0 h-full flex-col gap-3 overflow-hidden">
<TaskPanel tasks={activeTasks} onOpenRunFlow={openTaskRunFlow} />
<div className="flex h-full min-h-0 flex-col gap-2 overflow-hidden">
<TaskPanel
tasks={activeTasks}
onOpenRunFlow={openTaskRunFlow}
collapsed={isTaskPanelCollapsed}
onCollapsedChange={handleTaskPanelCollapsedChange}
/>
<HomeStockWorkspace
activeTab={sidebarWorkspaceTab}
onTabChange={setSidebarWorkspaceTab}
@@ -1230,7 +1374,7 @@ const HomePage: React.FC = () => {
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,
],
);

View File

@@ -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(
<MemoryRouter>
<HomePage />
</MemoryRouter>,
);
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(
<MemoryRouter>
<HomePage />
</MemoryRouter>,
);
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(
<MemoryRouter>
<HomePage />
</MemoryRouter>,
);
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<Awaited<ReturnType<typeof historyApi.getStockBarList>>>((_, 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(
<MemoryRouter>
<HomePage />
</MemoryRouter>,
);
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<ReturnType<typeof historyApi.getStockBarList>>) => void;
const stockBarRefreshPromise = new Promise<Awaited<ReturnType<typeof historyApi.getStockBarList>>>((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(
<MemoryRouter>
<HomePage />
</MemoryRouter>,
);
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<ReturnType<typeof historyApi.getStockBarList>>) => void;
let resolveManualRefresh!: (response: Awaited<ReturnType<typeof historyApi.getStockBarList>>) => void;
const completionRefreshPromise = new Promise<Awaited<ReturnType<typeof historyApi.getStockBarList>>>((resolve) => {
resolveCompletionRefresh = resolve;
});
const manualRefreshPromise = new Promise<Awaited<ReturnType<typeof historyApi.getStockBarList>>>((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(
<MemoryRouter>
<HomePage />
</MemoryRouter>,
);
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<ReturnType<typeof historyApi.getList>>;
const pending = new Map<string, (response: HistoryListResponse) => 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<HistoryListResponse>((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(
<MemoryRouter>
<HomePage />
</MemoryRouter>,
);
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<ReturnType<typeof historyApi.getList>>) => 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<ReturnType<typeof historyApi.getList>>) => void;
const aaplHistoryPromise = new Promise<Awaited<ReturnType<typeof historyApi.getList>>>((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(
<MemoryRouter>
<HomePage />
</MemoryRouter>,
);
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<ReturnType<typeof historyApi.getList>>) => void;
const aaplHistoryPromise = new Promise<Awaited<ReturnType<typeof historyApi.getList>>>((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(
<MemoryRouter>
<HomePage />
</MemoryRouter>,
);
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<ReturnType<typeof historyApi.getList>>) => void;
const aaplHistoryPromise = new Promise<Awaited<ReturnType<typeof historyApi.getList>>>((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(
<MemoryRouter>
<HomePage />
</MemoryRouter>,
);
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(
<MemoryRouter>
<HomePage />
</MemoryRouter>,
);
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<ReturnType<typeof historyApi.getStockBarList>>) => void;
const stockBarPromise = new Promise<Awaited<ReturnType<typeof historyApi.getStockBarList>>>((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(
<MemoryRouter>
<HomePage />
</MemoryRouter>,
);
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(
<MemoryRouter>
<HomePage />
</MemoryRouter>,
);
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`);

View File

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

View File

@@ -1077,6 +1077,7 @@ export const useStockPoolStore = create<StockPoolState>((set, get) => ({
refreshStockBar: async () => {
const requestSeq = ++stockBarRequestSeq;
set({ isLoadingStockBar: true });
try {
const response = await historyApi.getStockBarList({
startDate: getRecentStartDate(90),

View File

@@ -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<digits>` 或继续走裸码分类导致误判为 US而是直接返回 `unsupported` 并携带可定位原因;保留 `000300.SH` / `sh000300.SH` 等已知 INDEX alias 的索引命中路径(关闭 PR #2122 review blocker OR-COR-607f1395 / OR-COR-26596201 / OR-COR-d6afd0d6

View File

@@ -1563,7 +1563,8 @@ FastAPI 提供 RESTful API 服务,支持配置管理和触发分析。
### 功能特性
- 📝 **配置管理** - 查看/修改自选股列表
- 🗂️ **首页三视图** - 首页新增「历史 / 自选 / 今日」工作区,默认进入历史视图;自选支持批量提交全部或仅提交“今日未分析”股票
- 🗂️ **首页三视图** - 首页提供「历史 / 自选 / 今日」工作区,默认进入历史视图;自选股行可用鼠标或键盘打开已确认的最新分析详情,提示始终跟随当前的查找中、查找失败或确认无详情状态;任何 stock-bar 请求及完成任务后的数据刷新都会在开始时进入待确认状态,重新确认或状态未知期间不会开放旧 stock-bar 或 fallback 报告;自选页刷新会同时重试列表和详情状态,逐股票详情补查使用固定并发上限,并在刷新或页面状态切换时取消已失效批次;支持批量提交全部或仅提交“今日未分析”股票
- 📌 **任务面板折叠** - 首页任务面板可折叠/展开,折叠后保留 pending/processing 摘要并把更多侧栏空间让给自选股列表;折叠状态在当前页面会话内保持
- 🧭 **界面语言切换** - 登录态与退出态均支持界面语言快速切换(`zh` / `en`),独立于 `REPORT_LANGUAGE`,用于静态 UI 文案与导航骨架
- 🚀 **快速分析** - 通过 API 接口触发个股分析;首页也提供“大盘复盘”按钮和单次市场选择器,可在 Docker/server 模式下按服务器默认或临时选择的单个/多个市场后台触发复盘
- 🎯 **策略选择** - 首页支持显式选择分析策略 skill不传 `skills` 时按系统默认策略运行,便于保持与历史行为兼容

View File

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