mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
fix: 按需生成分享图并恢复基础设置中的选股开关
This commit is contained in:
@@ -41,7 +41,7 @@ TUSHARE_TOKEN=
|
||||
# TICKFLOW_BATCH_DAILY_ENABLED=true # 有权限时通过 TickFlow 批量预取日 K
|
||||
# TICKFLOW_BATCH_SIZE=100 # TickFlow 单次批量请求的最大标的数
|
||||
|
||||
# 选股(参考 AlphaSift 实现,默认关闭;通常由 Web“开启选股”按钮维护)
|
||||
# 选股(参考 AlphaSift 实现,默认关闭;通常由 Web“基础设置”中的选股开关维护)
|
||||
SCREENING_ENABLED=false
|
||||
# 全市场快照源优先级;未配置时 DSA 会按是否配置 TUSHARE_TOKEN 自动注入:
|
||||
# - 有 TUSHARE_TOKEN:tushare,sina,efinance,akshare_em,em_datacenter
|
||||
|
||||
@@ -37,7 +37,7 @@ vi.mock('../../theme/ThemeToggle', () => ({
|
||||
|
||||
describe('SidebarNav', () => {
|
||||
it('hides the screening navigation item while Screening is disabled', () => {
|
||||
mockGetScreeningStatus.mockResolvedValueOnce({ enabled: false, available: false });
|
||||
mockGetScreeningStatus.mockResolvedValueOnce({ enabled: false, available: true });
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
@@ -48,8 +48,8 @@ describe('SidebarNav', () => {
|
||||
expect(screen.queryByRole('link', { name: '选股' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the screening navigation item when Screening is enabled', async () => {
|
||||
mockGetScreeningStatus.mockResolvedValueOnce({ enabled: true, available: false });
|
||||
it('shows screening directly after chat when Screening is enabled', async () => {
|
||||
mockGetScreeningStatus.mockResolvedValueOnce({ enabled: true, available: true });
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
@@ -58,26 +58,14 @@ describe('SidebarNav', () => {
|
||||
);
|
||||
|
||||
expect(await screen.findByRole('link', { name: '选股' })).toHaveAttribute('href', '/screening');
|
||||
});
|
||||
|
||||
it('places screening directly after chat when Screening is enabled', async () => {
|
||||
mockGetScreeningStatus.mockResolvedValueOnce({ enabled: true, available: false });
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<SidebarNav />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await screen.findByRole('link', { name: '选股' });
|
||||
const hrefs = screen.getAllByRole('link').map((link) => link.getAttribute('href'));
|
||||
expect(hrefs.slice(0, 5)).toEqual(['/', '/chat', '/screening', '/portfolio', '/decision-signals']);
|
||||
});
|
||||
|
||||
it('refreshes the screening navigation item after any config save event', async () => {
|
||||
it('refreshes the controlled screening entry after config changes', async () => {
|
||||
mockGetScreeningStatus
|
||||
.mockResolvedValueOnce({ enabled: false, available: false })
|
||||
.mockResolvedValueOnce({ enabled: true, available: false });
|
||||
.mockResolvedValueOnce({ enabled: false, available: true })
|
||||
.mockResolvedValueOnce({ enabled: true, available: true });
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
@@ -86,7 +74,7 @@ describe('SidebarNav', () => {
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('link', { name: '选股' })).not.toBeInTheDocument();
|
||||
window.dispatchEvent(new Event('dsa-system-config-changed'));
|
||||
window.dispatchEvent(new Event('screening-config-changed'));
|
||||
|
||||
expect(await screen.findByRole('link', { name: '选股' })).toHaveAttribute('href', '/screening');
|
||||
await waitFor(() => expect(mockGetScreeningStatus.mock.calls.length).toBeGreaterThanOrEqual(2));
|
||||
|
||||
@@ -47,12 +47,12 @@ export const ShareImageButton: React.FC<ShareImageButtonProps> = ({
|
||||
state: ShareState;
|
||||
}>(() => ({
|
||||
recordId: activeRecordId,
|
||||
state: activeRecordId === undefined ? 'idle' : 'loading',
|
||||
state: 'idle',
|
||||
}));
|
||||
const resetTimerRef = useRef<number | null>(null);
|
||||
const loadTokenRef = useRef(0);
|
||||
const cachedImageRef = useRef<{ recordId: number; blob: Blob } | null>(null);
|
||||
const state = stateSnapshot.recordId === activeRecordId ? stateSnapshot.state : 'loading';
|
||||
const state = stateSnapshot.recordId === activeRecordId ? stateSnapshot.state : 'idle';
|
||||
const setState = useCallback((nextState: ShareState) => {
|
||||
setStateSnapshot({ recordId: activeRecordId, state: nextState });
|
||||
}, [activeRecordId]);
|
||||
@@ -63,11 +63,6 @@ export const ShareImageButton: React.FC<ShareImageButtonProps> = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
loadTokenRef.current += 1;
|
||||
clearResetTimer();
|
||||
}, [clearResetTimer]);
|
||||
|
||||
const scheduleReset = useCallback(() => {
|
||||
clearResetTimer();
|
||||
const scheduledRecordId = activeRecordId;
|
||||
@@ -80,57 +75,41 @@ export const ShareImageButton: React.FC<ShareImageButtonProps> = ({
|
||||
}, 2200);
|
||||
}, [activeRecordId, clearResetTimer]);
|
||||
|
||||
const prepareShareImage = useCallback(() => {
|
||||
if (activeRecordId === undefined) return;
|
||||
|
||||
const loadToken = loadTokenRef.current + 1;
|
||||
loadTokenRef.current = loadToken;
|
||||
cachedImageRef.current = null;
|
||||
setState('loading');
|
||||
|
||||
void historyApi.getShareImage(activeRecordId).then((blob) => {
|
||||
if (loadTokenRef.current !== loadToken) return;
|
||||
cachedImageRef.current = { recordId: activeRecordId, blob };
|
||||
setState('idle');
|
||||
}).catch((error: unknown) => {
|
||||
if (loadTokenRef.current !== loadToken) return;
|
||||
console.error('Generate share image failed:', error);
|
||||
setState('error');
|
||||
});
|
||||
}, [activeRecordId, setState]);
|
||||
|
||||
useEffect(() => {
|
||||
clearResetTimer();
|
||||
if (activeRecordId === undefined) return undefined;
|
||||
|
||||
const loadToken = loadTokenRef.current + 1;
|
||||
loadTokenRef.current = loadToken;
|
||||
loadTokenRef.current += 1;
|
||||
cachedImageRef.current = null;
|
||||
void historyApi.getShareImage(activeRecordId).then((blob) => {
|
||||
if (loadTokenRef.current !== loadToken) return;
|
||||
cachedImageRef.current = { recordId: activeRecordId, blob };
|
||||
setState('idle');
|
||||
}).catch((error: unknown) => {
|
||||
if (loadTokenRef.current !== loadToken) return;
|
||||
console.error('Generate share image failed:', error);
|
||||
setState('error');
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearResetTimer();
|
||||
loadTokenRef.current += 1;
|
||||
};
|
||||
}, [activeRecordId, clearResetTimer, setState]);
|
||||
}, [activeRecordId, clearResetTimer]);
|
||||
|
||||
const handleShare = useCallback(async () => {
|
||||
if (activeRecordId === undefined || state === 'loading') return;
|
||||
const cachedImage = cachedImageRef.current;
|
||||
if (!cachedImage || cachedImage.recordId !== activeRecordId) {
|
||||
prepareShareImage();
|
||||
return;
|
||||
clearResetTimer();
|
||||
|
||||
let blob = cachedImageRef.current?.recordId === activeRecordId
|
||||
? cachedImageRef.current.blob
|
||||
: null;
|
||||
|
||||
if (!blob) {
|
||||
const loadToken = loadTokenRef.current + 1;
|
||||
loadTokenRef.current = loadToken;
|
||||
setState('loading');
|
||||
try {
|
||||
blob = await historyApi.getShareImage(activeRecordId);
|
||||
} catch (error) {
|
||||
if (loadTokenRef.current !== loadToken) return;
|
||||
console.error('Generate share image failed:', error);
|
||||
setState('error');
|
||||
return;
|
||||
}
|
||||
if (loadTokenRef.current !== loadToken) return;
|
||||
cachedImageRef.current = { recordId: activeRecordId, blob };
|
||||
}
|
||||
|
||||
const blob = cachedImage.blob;
|
||||
const filename = `${safeFilenamePart(reportTitle)}-${activeRecordId}.png`;
|
||||
const file = new File([blob], filename, { type: 'image/png' });
|
||||
const canShareFile = typeof navigator.share === 'function'
|
||||
@@ -141,8 +120,6 @@ export const ShareImageButton: React.FC<ShareImageButtonProps> = ({
|
||||
try {
|
||||
if (canShareFile) {
|
||||
try {
|
||||
// The image is preloaded before the click, so navigator.share() is
|
||||
// invoked while this user-activation event is still active.
|
||||
await navigator.share({
|
||||
files: [file],
|
||||
title: reportTitle,
|
||||
@@ -165,7 +142,7 @@ export const ShareImageButton: React.FC<ShareImageButtonProps> = ({
|
||||
console.error('Generate share image failed:', error);
|
||||
setState('error');
|
||||
}
|
||||
}, [activeRecordId, prepareShareImage, reportTitle, scheduleReset, setState, state]);
|
||||
}, [activeRecordId, clearResetTimer, reportTitle, scheduleReset, setState, state]);
|
||||
|
||||
if (activeRecordId === undefined) return null;
|
||||
|
||||
|
||||
@@ -38,13 +38,14 @@ describe('ShareImageButton', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockedGetShareImage).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '分享' }));
|
||||
await waitFor(() => expect(mockedGetShareImage).toHaveBeenCalledWith(17));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '分享' }));
|
||||
await waitFor(() => expect(HTMLAnchorElement.prototype.click).toHaveBeenCalled());
|
||||
expect(screen.getByRole('button', { name: '已生成' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('preloads the PNG and invokes native sharing synchronously from the click', async () => {
|
||||
it('generates the PNG after the click and then invokes native sharing', async () => {
|
||||
const nativeShare = vi.fn().mockResolvedValue(undefined);
|
||||
let resolveImage: ((blob: Blob) => void) | undefined;
|
||||
mockedGetShareImage.mockReturnValue(new Promise((resolve) => {
|
||||
@@ -61,6 +62,12 @@ describe('ShareImageButton', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '分享' })).toBeEnabled();
|
||||
expect(mockedGetShareImage).not.toHaveBeenCalled();
|
||||
expect(nativeShare).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '分享' }));
|
||||
expect(mockedGetShareImage).toHaveBeenCalledWith(18);
|
||||
expect(screen.getByRole('button', { name: '生成中...' })).toBeDisabled();
|
||||
expect(nativeShare).not.toHaveBeenCalled();
|
||||
|
||||
@@ -68,7 +75,6 @@ describe('ShareImageButton', () => {
|
||||
resolveImage?.(new Blob(['png'], { type: 'image/png' }));
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '分享' }));
|
||||
expect(nativeShare).toHaveBeenCalledTimes(1);
|
||||
const sharePayload = nativeShare.mock.calls[0][0];
|
||||
expect(sharePayload.title).toBe('A股市场复盘');
|
||||
@@ -89,6 +95,7 @@ describe('ShareImageButton', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockedGetShareImage).not.toHaveBeenCalled();
|
||||
fireEvent.click(await screen.findByRole('button', { name: '分享' }));
|
||||
|
||||
await waitFor(() => expect(nativeShare).toHaveBeenCalledTimes(1));
|
||||
@@ -107,7 +114,10 @@ describe('ShareImageButton', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockedGetShareImage).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '分享' }));
|
||||
expect(await screen.findByRole('button', { name: '重试' })).toBeInTheDocument();
|
||||
expect(mockedGetShareImage).toHaveBeenCalledWith(19);
|
||||
});
|
||||
|
||||
it('does not render or prefetch share images during desktop runtime', () => {
|
||||
@@ -150,11 +160,7 @@ describe('ShareImageButton', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mockedGetShareImage).toHaveBeenCalledWith(21);
|
||||
expect(mockedGetShareImage).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('button', { name: '分享' })).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
@@ -162,6 +168,7 @@ describe('ShareImageButton', () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mockedGetShareImage).toHaveBeenCalledWith(21);
|
||||
expect(nativeShare).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByRole('button', { name: '已生成' })).toBeInTheDocument();
|
||||
|
||||
@@ -173,6 +180,9 @@ describe('ShareImageButton', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '分享' })).toBeEnabled();
|
||||
expect(mockedGetShareImage).not.toHaveBeenCalledWith(22);
|
||||
fireEvent.click(screen.getByRole('button', { name: '分享' }));
|
||||
expect(screen.getByRole('button', { name: '生成中...' })).toBeDisabled();
|
||||
await act(async () => {
|
||||
resolveSecondImage?.(new Blob(['b'], { type: 'image/png' }));
|
||||
@@ -180,7 +190,7 @@ describe('ShareImageButton', () => {
|
||||
});
|
||||
|
||||
expect(mockedGetShareImage).toHaveBeenCalledWith(22);
|
||||
expect(screen.getByRole('button', { name: '分享' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '已生成' })).toBeInTheDocument();
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -637,7 +637,7 @@ const zh = {
|
||||
'settings.activePanelTitle': '当前分类配置项',
|
||||
'settings.agentSettings': 'Agent 设置',
|
||||
'settings.screening': '选股',
|
||||
'settings.screeningDescription': '启用实验性质的选股功能,实现参考 AlphaSift。',
|
||||
'settings.screeningDescription': '控制 Web 选股入口及内置选股功能。',
|
||||
'settings.screeningDisabled': '选股未开启',
|
||||
'settings.screeningEnabled': '选股已开启',
|
||||
'settings.screeningRisk': '实验功能与风险提示:选股结果仅用于研究和辅助判断,不构成投资建议;市场有风险,交易决策和损益由使用者自行承担。',
|
||||
@@ -1574,7 +1574,7 @@ const en: Record<UiTextKey, string> = {
|
||||
'settings.activePanelTitle': 'Current category fields',
|
||||
'settings.agentSettings': 'Agent settings',
|
||||
'settings.screening': 'Screening',
|
||||
'settings.screeningDescription': 'Enable experimental screening, implemented with reference to AlphaSift.',
|
||||
'settings.screeningDescription': 'Control the Web navigation entry and built-in stock screening feature.',
|
||||
'settings.screeningDisabled': 'Screening disabled',
|
||||
'settings.screeningEnabled': 'Screening enabled',
|
||||
'settings.screeningRisk': 'Experimental risk notice: screening results are for research and decision support only and are not investment advice. Markets involve risk; trading decisions and results are your responsibility.',
|
||||
|
||||
@@ -324,7 +324,7 @@ const settingsHelpZhCN: SettingsHelpMap = {
|
||||
impact: ['影响 Web 自动补全和后端股票名称解析使用的股票简称新鲜度。'],
|
||||
notes: ['远程下载失败时会继续使用已有缓存或随应用打包的内置索引。'],
|
||||
},
|
||||
'settings.data_source.SCREENING_ENABLED': {
|
||||
'settings.base.SCREENING_ENABLED': {
|
||||
title: '选股',
|
||||
summary: '控制是否启用选股页;实现参考 AlphaSift。',
|
||||
usage: '默认关闭。设为 true 后显示选股入口并启用选股策略。',
|
||||
@@ -1522,7 +1522,7 @@ const settingsHelpEnUS: SettingsHelpMap = {
|
||||
impact: ['Affects stock-name freshness for Web autocomplete and backend stock-name resolution.'],
|
||||
notes: ['When remote download fails, the app keeps using an existing cache or the bundled index.'],
|
||||
},
|
||||
'settings.data_source.SCREENING_ENABLED': {
|
||||
'settings.base.SCREENING_ENABLED': {
|
||||
title: 'Screening',
|
||||
summary: 'Controls the Screening page, implemented with reference to AlphaSift.',
|
||||
usage: 'Disabled by default. Set it to true to show Screening and enable screening strategies.',
|
||||
|
||||
@@ -1045,10 +1045,10 @@ const SettingsPage: React.FC = () => {
|
||||
const rawActiveItems = itemsByCategory[activeCategory] || [];
|
||||
const rawActiveItemMap = new Map(rawActiveItems.map((item) => [item.key, String(item.value ?? '')]));
|
||||
const firstSetupStockCode = parseSetupStockList(getConfigItem(itemsByCategory.base || [], 'STOCK_LIST')?.value)[0] || '';
|
||||
const screeningItem = (itemsByCategory.data_source || []).find((item) => item.key === 'SCREENING_ENABLED');
|
||||
const screeningItem = (itemsByCategory.base || []).find((item) => item.key === 'SCREENING_ENABLED');
|
||||
const screeningEnabled = String(screeningItem?.value ?? '').trim().toLowerCase() === 'true';
|
||||
const shouldShowFirstRunSetup = activeCategory === 'base';
|
||||
const shouldShowScreeningSettings = activeCategory === 'data_source' && Boolean(screeningItem);
|
||||
const shouldShowScreeningSettings = activeCategory === 'base' && Boolean(screeningItem);
|
||||
const hasConfiguredChannels = Boolean((rawActiveItemMap.get('LLM_CHANNELS') || '').trim());
|
||||
const hasLitellmConfig = Boolean((rawActiveItemMap.get('LITELLM_CONFIG') || '').trim());
|
||||
const hasRuntimeSchedulerMismatch =
|
||||
@@ -1103,12 +1103,14 @@ const SettingsPage: React.FC = () => {
|
||||
'ADMIN_AUTH_ENABLED',
|
||||
...SCHEDULER_SETTING_KEYS,
|
||||
]);
|
||||
const DATA_SOURCE_HIDDEN_KEYS = new Set([
|
||||
const BASE_HIDDEN_KEYS = new Set([
|
||||
'SCREENING_ENABLED',
|
||||
]);
|
||||
const AGENT_HIDDEN_KEYS = new Set(['AGENT_GENERATION_BACKEND']);
|
||||
const activeItems =
|
||||
activeCategory === 'ai_model'
|
||||
activeCategory === 'base'
|
||||
? rawActiveItems.filter((item) => !BASE_HIDDEN_KEYS.has(item.key))
|
||||
: activeCategory === 'ai_model'
|
||||
? rawActiveItems.filter((item) => {
|
||||
if (hasConfiguredChannels && LLM_CHANNEL_KEY_RE.test(item.key)) {
|
||||
return false;
|
||||
@@ -1120,8 +1122,6 @@ const SettingsPage: React.FC = () => {
|
||||
})
|
||||
: activeCategory === 'system'
|
||||
? rawActiveItems.filter((item) => !SYSTEM_HIDDEN_KEYS.has(item.key))
|
||||
: activeCategory === 'data_source'
|
||||
? rawActiveItems.filter((item) => !DATA_SOURCE_HIDDEN_KEYS.has(item.key))
|
||||
: activeCategory === 'agent'
|
||||
? rawActiveItems.filter((item) => !AGENT_HIDDEN_KEYS.has(item.key))
|
||||
: rawActiveItems;
|
||||
|
||||
@@ -1392,10 +1392,11 @@ describe('SettingsPage', () => {
|
||||
it('runs Screening enable flow from the settings card', async () => {
|
||||
const configState = buildSystemConfigState();
|
||||
useSystemConfigMock.mockReturnValue(buildSystemConfigState({
|
||||
activeCategory: 'data_source',
|
||||
activeCategory: 'base',
|
||||
itemsByCategory: {
|
||||
...configState.itemsByCategory,
|
||||
data_source: [
|
||||
base: [
|
||||
...configState.itemsByCategory.base,
|
||||
{
|
||||
key: 'SCREENING_ENABLED',
|
||||
value: 'false',
|
||||
@@ -1403,7 +1404,7 @@ describe('SettingsPage', () => {
|
||||
isMasked: false,
|
||||
schema: {
|
||||
key: 'SCREENING_ENABLED',
|
||||
category: 'data_source',
|
||||
category: 'base',
|
||||
dataType: 'boolean',
|
||||
uiControl: 'switch',
|
||||
isSensitive: false,
|
||||
@@ -1430,10 +1431,11 @@ describe('SettingsPage', () => {
|
||||
it('maps SCREENING_ENABLED to the built-in screening card instead of a generic field', () => {
|
||||
const configState = buildSystemConfigState();
|
||||
useSystemConfigMock.mockReturnValue(buildSystemConfigState({
|
||||
activeCategory: 'data_source',
|
||||
activeCategory: 'base',
|
||||
itemsByCategory: {
|
||||
...configState.itemsByCategory,
|
||||
data_source: [
|
||||
base: [
|
||||
...configState.itemsByCategory.base,
|
||||
{
|
||||
key: 'SCREENING_ENABLED',
|
||||
value: 'false',
|
||||
@@ -1441,7 +1443,7 @@ describe('SettingsPage', () => {
|
||||
isMasked: false,
|
||||
schema: {
|
||||
key: 'SCREENING_ENABLED',
|
||||
category: 'data_source',
|
||||
category: 'base',
|
||||
dataType: 'boolean',
|
||||
uiControl: 'switch',
|
||||
isSensitive: false,
|
||||
@@ -1462,9 +1464,10 @@ describe('SettingsPage', () => {
|
||||
expect(screen.queryByTestId('settings-field-SCREENING_ENABLED')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('scopes setup and Screening helper cards to their related categories', async () => {
|
||||
it('shows the Screening control only on the base settings page', async () => {
|
||||
const configState = buildSystemConfigState();
|
||||
const dataSourceItems = [
|
||||
const baseItems = [
|
||||
...configState.itemsByCategory.base,
|
||||
{
|
||||
key: 'SCREENING_ENABLED',
|
||||
value: 'false',
|
||||
@@ -1472,7 +1475,7 @@ describe('SettingsPage', () => {
|
||||
isMasked: false,
|
||||
schema: {
|
||||
key: 'SCREENING_ENABLED',
|
||||
category: 'data_source',
|
||||
category: 'base',
|
||||
dataType: 'boolean',
|
||||
uiControl: 'switch',
|
||||
isSensitive: false,
|
||||
@@ -1489,20 +1492,20 @@ describe('SettingsPage', () => {
|
||||
activeCategory: 'base',
|
||||
itemsByCategory: {
|
||||
...configState.itemsByCategory,
|
||||
data_source: dataSourceItems,
|
||||
base: baseItems,
|
||||
},
|
||||
}));
|
||||
|
||||
const { rerender } = render(<SettingsPage />);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: '首次启动配置检查' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('heading', { name: '选股' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: '选股' })).toBeInTheDocument();
|
||||
|
||||
useSystemConfigMock.mockReturnValue(buildSystemConfigState({
|
||||
activeCategory: 'ai_model',
|
||||
itemsByCategory: {
|
||||
...configState.itemsByCategory,
|
||||
data_source: dataSourceItems,
|
||||
base: baseItems,
|
||||
},
|
||||
}));
|
||||
rerender(<SettingsPage />);
|
||||
@@ -1514,12 +1517,12 @@ describe('SettingsPage', () => {
|
||||
activeCategory: 'data_source',
|
||||
itemsByCategory: {
|
||||
...configState.itemsByCategory,
|
||||
data_source: dataSourceItems,
|
||||
base: baseItems,
|
||||
},
|
||||
}));
|
||||
rerender(<SettingsPage />);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: '选股' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('heading', { name: '选股' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('heading', { name: '首次启动配置检查' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -2135,10 +2138,11 @@ describe('SettingsPage', () => {
|
||||
const configState = buildSystemConfigState();
|
||||
screeningEnable.mockRejectedValueOnce(new Error('config update failed'));
|
||||
useSystemConfigMock.mockReturnValue(buildSystemConfigState({
|
||||
activeCategory: 'data_source',
|
||||
activeCategory: 'base',
|
||||
itemsByCategory: {
|
||||
...configState.itemsByCategory,
|
||||
data_source: [
|
||||
base: [
|
||||
...configState.itemsByCategory.base,
|
||||
{
|
||||
key: 'SCREENING_ENABLED',
|
||||
value: 'false',
|
||||
@@ -2146,7 +2150,7 @@ describe('SettingsPage', () => {
|
||||
isMasked: false,
|
||||
schema: {
|
||||
key: 'SCREENING_ENABLED',
|
||||
category: 'data_source',
|
||||
category: 'base',
|
||||
dataType: 'boolean',
|
||||
uiControl: 'switch',
|
||||
isSensitive: false,
|
||||
|
||||
@@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
> For user-friendly release highlights, see the [GitHub Releases](https://github.com/ZhuLinsen/daily_stock_analysis/releases) page.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- [修复] Web 分享图改为用户点击“分享”后才按需生成,不再在报告加载时自动请求
|
||||
- [修复] 将 `SCREENING_ENABLED` 及 Web 选股功能开关归入“基础设置”,选股导航入口继续由该开关控制
|
||||
- [修复] `scripts/ci_gate.sh` 的 `offline_test_suite` 给 `pytest -m "not network"` 加 `--timeout=120 -o timeout_method=thread` 与 `-o faulthandler_timeout=300`:单个测试(含其 teardown)超过 2 分钟直接 fail,单个测试(含其 teardown)超过 5 分钟时 dump 全部线程栈到 stderr。配合 `.github/requirements-ci.txt` 新增 `pytest-timeout>=2.3.0` 依赖。issue #2131 报告过 backend-gate 在 AlphaSift hotspot 用例附近间歇性无 traceback 卡住直到被 GitHub Actions 取消,此次修复让任何未来 CI hang 都会留下可定位的失败信息或 post-mortem 栈,而不是静默消亡。同步修正 `.github/workflows/docker-publish.yml` 的 `Install backend gate dependencies` 与 `setup-python cache-dependency-path` 对齐 `ci.yml` 的 backend-gate 依赖安装方式,避免发布流程跑同一个 `./scripts/ci_gate.sh` 时因缺少 `pytest-timeout` 而直接 fail。
|
||||
|
||||
- [修复] 选股策略栏稳定展示完整中文策略列表,并保留自定义策略 ID 入口
|
||||
|
||||
@@ -22,6 +22,8 @@ DSA 将选股能力作为主项目的一部分维护。实现参考 [AlphaSift](
|
||||
SCREENING_ENABLED=false
|
||||
```
|
||||
|
||||
Web“基础设置”页展示“选股”开关。开启后左侧显示“选股”入口并允许执行策略、热点和选股任务;关闭后入口隐藏,选股 API 继续拒绝业务请求。
|
||||
|
||||
常用可选项:
|
||||
|
||||
```dotenv
|
||||
|
||||
@@ -35,7 +35,7 @@ SHARE_IMAGE_XIAOHONGSHU_QR_PATH=assets/my-xiaohongshu-qr.png
|
||||
|
||||
## Web 一键分享
|
||||
|
||||
浏览器版历史个股报告、市场复盘和完整报告抽屉右上角都会显示“分享”按钮。页面加载报告时会先调用 `GET /api/v1/history/{record_id}/share-image` 预生成并缓存 PNG,准备完成后按钮才可点击,因此 `navigator.share()` 能在点击的瞬时用户激活窗口内同步发起。支持文件分享的浏览器会打开系统分享面板;其他浏览器会直接下载 PNG。如果浏览器已声明支持文件分享、但系统分享面板实际打开失败,除用户主动取消外也会自动回退下载已经生成的 PNG。
|
||||
浏览器版历史个股报告、市场复盘和完整报告抽屉右上角都会显示“分享”按钮。页面加载报告时不会生成图片;只有用户点击“分享”后,页面才调用 `GET /api/v1/history/{record_id}/share-image` 按需生成或读取缓存 PNG。支持文件分享的浏览器会尝试打开系统分享面板;其他浏览器会直接下载 PNG。如果系统分享面板打开失败,除用户主动取消外也会自动回退下载已经生成的 PNG。
|
||||
|
||||
Electron 桌面运行时默认不展示该按钮。当前 Windows/macOS 打包版不会随包分发 `wkhtmltoimage`、`markdown-to-file` 或 Playwright/Chromium renderer,避免桌面用户在页面加载时就命中 `share_image_unavailable` 失败态。
|
||||
|
||||
|
||||
@@ -877,7 +877,7 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
||||
"SCREENING_ENABLED": {
|
||||
"title": "Built-in Stock Screening",
|
||||
"description": "Enable DSA's built-in stock screening tab. The implementation is based on AlphaSift and maintained as part of DSA. Disabled by default.",
|
||||
"category": "data_source",
|
||||
"category": "base",
|
||||
"data_type": "boolean",
|
||||
"ui_control": "switch",
|
||||
"is_sensitive": False,
|
||||
@@ -887,7 +887,7 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
||||
"options": [],
|
||||
"validation": {},
|
||||
"display_order": 17,
|
||||
"help_key": "settings.data_source.SCREENING_ENABLED",
|
||||
"help_key": "settings.base.SCREENING_ENABLED",
|
||||
"examples": [
|
||||
"SCREENING_ENABLED=false",
|
||||
"SCREENING_ENABLED=true",
|
||||
|
||||
@@ -154,10 +154,22 @@ class TestScreeningFieldsRegistered(unittest.TestCase):
|
||||
def test_builtin_screening_toggle_is_registered(self):
|
||||
field = get_field_definition("SCREENING_ENABLED")
|
||||
|
||||
self.assertEqual(field["category"], "base")
|
||||
self.assertFalse(field["is_sensitive"])
|
||||
self.assertEqual(field["ui_control"], "switch")
|
||||
self.assertEqual(field["help_key"], "settings.base.SCREENING_ENABLED")
|
||||
self.assertIn("built-in", field["description"].lower())
|
||||
|
||||
def test_builtin_screening_toggle_is_grouped_under_base_settings(self):
|
||||
schema = build_schema_response()
|
||||
categories = {
|
||||
category["category"]: {field["key"] for field in category["fields"]}
|
||||
for category in schema["categories"]
|
||||
}
|
||||
|
||||
self.assertIn("SCREENING_ENABLED", categories["base"])
|
||||
self.assertNotIn("SCREENING_ENABLED", categories["data_source"])
|
||||
|
||||
|
||||
class TestLLMUsageHMACFieldsRegistered(unittest.TestCase):
|
||||
def test_secret_is_sensitive_password_field(self):
|
||||
|
||||
Reference in New Issue
Block a user