mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 02:43:35 +08:00
fix: stabilize watchlist details and workspace state (#2126)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -87,4 +87,5 @@ verify_*.py
|
||||
static/
|
||||
/apps/dsa-desktop/dist/
|
||||
/apps/dsa-desktop/node_modules/
|
||||
/node_modules/
|
||||
docs/specs/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1563,7 +1563,8 @@ FastAPI 提供 RESTful API 服务,支持配置管理和触发分析。
|
||||
### 功能特性
|
||||
|
||||
- 📝 **配置管理** - 查看/修改自选股列表
|
||||
- 🗂️ **首页三视图** - 首页新增「历史 / 自选 / 今日」工作区,默认进入历史视图;自选页支持批量提交全部或仅提交“今日未分析”股票
|
||||
- 🗂️ **首页三视图** - 首页提供「历史 / 自选 / 今日」工作区,默认进入历史视图;自选股行可用鼠标或键盘打开已确认的最新分析详情,提示始终跟随当前的查找中、查找失败或确认无详情状态;任何 stock-bar 请求及完成任务后的数据刷新都会在开始时进入待确认状态,重新确认或状态未知期间不会开放旧 stock-bar 或 fallback 报告;自选页刷新会同时重试列表和详情状态,逐股票详情补查使用固定并发上限,并在刷新或页面状态切换时取消已失效批次;支持批量提交全部或仅提交“今日未分析”股票
|
||||
- 📌 **任务面板折叠** - 首页任务面板可折叠/展开,折叠后保留 pending/processing 摘要并把更多侧栏空间让给自选股列表;折叠状态在当前页面会话内保持
|
||||
- 🧭 **界面语言切换** - 登录态与退出态均支持界面语言快速切换(`zh` / `en`),独立于 `REPORT_LANGUAGE`,用于静态 UI 文案与导航骨架
|
||||
- 🚀 **快速分析** - 通过 API 接口触发个股分析;首页也提供“大盘复盘”按钮和单次市场选择器,可在 Docker/server 模式下按服务器默认或临时选择的单个/多个市场后台触发复盘
|
||||
- 🎯 **策略选择** - 首页支持显式选择分析策略 skill;不传 `skills` 时按系统默认策略运行,便于保持与历史行为兼容
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user