From 45ecb4e9dfe225bc982e9af9315590bcb0783df7 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Fri, 18 Sep 2026 08:26:57 +0800 Subject: feat(frontend): 结果/实验/策略/数据页迁移 shadcn 与新 Chart API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [变更性质] 四个业务标签页与数据集卡片迁移 shadcn 组件,图表消费切换到 layerchart 长格式 API,功能与数据语义不变。 [新增功能] 结果页权益曲线(策略权益/现金/基准,基准可缺省)与回撤曲线(desc 面积)改由 Chart.svelte data/series 渲染;实验记录页多回测归一对比 曲线改为快速长格式行构造。空数据行允许 undefined 列(基准缺口), Chart data 类型放宽 [key:string]: string|number|undefined。 [实现方案] RunsPage:查看入口改 shadcn Button(href 语义保留 hash 路由), 对比图数据由 compareOption(echarts option)化为 compare。 ProjectResultsTab:图表开关、日志折叠用 Button;equityData/ drawdownData $derived 计算峰值回撤。ProjectStrategyTab:快照输入、 冲突/AI/版本操作迁移 Button/Input。ProjectDataTab 与 DatasetCard: shadcn 组件 + 保留 banner error/warn/info、retry-row 等 legacy class, "使用相同请求重新获取"重试入口与 onRecreated 逻辑不变,配套 datasetCardRetry/datasetCardWarnings/projectDataTabRecreated 回归 测试。resultsAccounting.test.ts:layerchart 在 jsdom 挂载约 3 秒, suite 全量下易撞默认 5s 超时,首测提高至 20s。 [影响范围] npm run check 0 error、58 tests 全绿、npm run build 通过;真浏览器 375/768/1440 图表页零溢出(artifacts/ui-shadcn 截图与探针 JSON)。 --- frontend/src/components/DatasetCard.svelte | 169 +++++++++++++++++------ frontend/src/lib/datasetCardRetry.test.ts | 113 +++++++++++++++ frontend/src/lib/datasetCardWarnings.test.ts | 91 ++++++++++++ frontend/src/lib/projectDataTabRecreated.test.ts | 19 +++ frontend/src/lib/resultsAccounting.test.ts | 2 +- frontend/src/pages/ProjectDataTab.svelte | 9 +- frontend/src/pages/ProjectResultsTab.svelte | 108 +++++---------- frontend/src/pages/ProjectStrategyTab.svelte | 35 ++--- frontend/src/pages/RunsPage.svelte | 59 ++++---- 9 files changed, 444 insertions(+), 161 deletions(-) create mode 100644 frontend/src/lib/datasetCardRetry.test.ts create mode 100644 frontend/src/lib/datasetCardWarnings.test.ts create mode 100644 frontend/src/lib/projectDataTabRecreated.test.ts (limited to 'frontend/src') diff --git a/frontend/src/components/DatasetCard.svelte b/frontend/src/components/DatasetCard.svelte index 7b38874..2d49e65 100644 --- a/frontend/src/components/DatasetCard.svelte +++ b/frontend/src/components/DatasetCard.svelte @@ -1,15 +1,24 @@ -
+ {#if ds}
-
+
{ds.name ?? '未命名数据集'} {#if ds.manifest_hash}manifest {ds.manifest_hash.slice(0, 10)}…{/if} - {#if ds.cache_hit}缓存命中 · 复用现有物化对象{/if} + {#if ds.cache_hit}缓存命中 · 复用现有物化对象{/if}
- + {datasetStatusLabel[ds.status]} - + +
+
+ + + + 请求标的 + {(ds.request.instruments ?? []).map(instrumentLabel).join(';')} + + + 范围 + {ds.request.start_date} ~ {ds.request.end_date} · {ds.request.frequency} · {adjustmentLabel[ds.request.adjustment] ?? ds.request.adjustment} + + + 字段 + {(ds.request.fields ?? []).join(', ')} + + + 创建时间 + {fmtDate(ds.created_at)} + + +
- - - - - - - -
请求标的{(ds.request.instruments ?? []).map(instrumentLabel).join(';')}
范围{ds.request.start_date} ~ {ds.request.end_date} · {ds.request.frequency} · {adjustmentLabel[ds.request.adjustment] ?? ds.request.adjustment}
字段{(ds.request.fields ?? []).join(', ')}
创建时间{fmtDate(ds.created_at)}
{#if ds.status === 'failed'} - {/if} - {#if ds.status !== 'ready'} -

{ds.status === 'pending' || ds.status === 'running' ? '获取中,完成后可预览覆盖情况' : ''}

+
+ + {#if recreatedId} + + 已用原请求创建新数据集(编号 {recreatedId.slice(0, 8)}…),新数据集出现在下方列表并自动跟踪进度;本条失败记录原样保留。 + + {/if} + {#if recreateErr}{recreateErr}{/if} +
{/if} {#if ds.status === 'ready'}
- +
{#if showPreview}
@@ -111,30 +186,40 @@
    {#each cov.warnings as w, i (i)}
  • {w}
  • {/each}
- {#if (preview.coverage ?? []).some((c) => c.warnings)} -
    - {#each preview.coverage.filter((c) => c.warnings) as c (c.instrument)} -
  • {c.instrument}:{(c.warnings ?? []).join(';')}
  • - {/each} -
- {/if}
- {:else if cov.common.start} + {/if} + {#if (preview.coverage ?? []).some((c) => (c.warnings ?? []).length > 0)} + + {/if} + {#if cov.common.start && cov.warnings.length === 0} {/if} - - - {#each preview.columns as c (c)}{/each} - - - {#each preview.rows as row, i (i)} - {#each preview.columns as c (c)}{/each} - {/each} - -
{c}
{row[c]}
+
+ + + + {#each preview.columns as c (c)}{c}{/each} + + + + {#each preview.rows as row, i (i)} + + {#each preview.columns as c (c)}{row[c]}{/each} + + {/each} + + +
{#if preview.truncated}

预览仅展示部分行

{/if} @@ -145,13 +230,15 @@ {:else} {/if} -
+ diff --git a/frontend/src/lib/datasetCardRetry.test.ts b/frontend/src/lib/datasetCardRetry.test.ts new file mode 100644 index 0000000..df9dc31 --- /dev/null +++ b/frontend/src/lib/datasetCardRetry.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/svelte'; + +vi.mock('../lib/client', () => { + const real = vi.importActual('../lib/client'); + return { + ...real, + getDataset: vi.fn(), + getDatasetPreview: vi.fn(), + createDataset: vi.fn() + }; +}); + +import DatasetCard from '../components/DatasetCard.svelte'; +import { getDataset, createDataset } from '../lib/client'; +import type { Dataset } from '../lib/types'; + +const failedDs: Dataset = { + id: 'ds-failed-1', + name: '国泰自由现金流-20251231-20260917', + request: { + name: '国泰自由现金流-20251231-20260917', + instruments: [ + { symbol: '159399', market: 'SZ', asset_type: 'etf', name: '国泰自由现金流', currency: null } + ], + start_date: '2025-12-31', + end_date: '2026-09-17', + frequency: 'daily', + adjustment: 'none', + fields: ['open', 'high', 'low', 'close', 'volume'] + }, + status: 'failed', + error: '159399: eastmoney failed (RemoteDisconnected); tencent fallback failed (tencent source has no listed-ETF daily adapter)', + created_at: '2026-09-17T01:00:00Z' +}; + +beforeEach(() => { + vi.mocked(getDataset).mockResolvedValue(failedDs); + vi.mocked(createDataset).mockReset(); +}); + +afterEach(() => cleanup()); + +describe('failed dataset retry affordance (159399 regression: user could never re-request)', () => { + it('failed dataset offers recreate-with-same-request button and original error stays visible', async () => { + render(DatasetCard, { props: { dsId: 'ds-failed-1' } }); + await screen.findByText(/eastmoney failed/); + const btn = await screen.findByRole('button', { name: /使用相同请求重新获取/ }); + expect(btn).toBeTruthy(); + }); + + it('recreate preserves ds.name (canonical request has no name) via the normal create API and keeps the failed record untouched', async () => { + vi.mocked(createDataset).mockResolvedValue({ + ...failedDs, + id: 'ds-new-2', + status: 'pending', + error: null + }); + render(DatasetCard, { props: { dsId: 'ds-failed-1' } }); + const btn = await screen.findByRole('button', { name: /使用相同请求重新获取/ }); + await fireEvent.click(btn); + await waitFor(() => { + expect(createDataset).toHaveBeenCalledTimes(1); + }); + expect(vi.mocked(createDataset).mock.calls[0][0]).toStrictEqual({ + ...failedDs.request, + name: failedDs.name, + instruments: [{ ...failedDs.request.instruments[0], currency: null }] + }); + // new dataset becomes visible/trackable: hint points to the list below + await screen.findByText(/新数据集出现在下方列表并自动跟踪进度/); + }); + + it('recreate button is disabled after a successful recreate (no duplicate submissions), error clears button state instead', async () => { + vi.mocked(createDataset).mockResolvedValue({ + ...failedDs, + id: 'ds-new-3', + status: 'pending', + error: null + }); + render(DatasetCard, { props: { dsId: 'ds-failed-1' } }); + const btn = await screen.findByRole('button', { name: /使用相同请求重新获取/ }); + expect((btn as HTMLButtonElement).disabled).toBe(false); + await fireEvent.click(btn); + await screen.findByText(/已用原请求创建新数据集/); + const done = screen.getByRole('button', { name: /使用相同请求重新获取/ }); + expect((done as HTMLButtonElement).disabled).toBe(true); + }); + + it('onRecreated callback fires with the new dataset so parents can refresh', async () => { + vi.mocked(createDataset).mockResolvedValue({ + ...failedDs, + id: 'ds-new-4', + status: 'pending', + error: null + }); + const onRecreated = vi.fn(); + render(DatasetCard, { props: { dsId: 'ds-failed-1', onRecreated } }); + const btn = await screen.findByRole('button', { name: /使用相同请求重新获取/ }); + await fireEvent.click(btn); + await screen.findByText(/已用原请求创建新数据集/); + expect(onRecreated).toHaveBeenCalledTimes(1); + expect(onRecreated.mock.calls[0][0].id).toBe('ds-new-4'); + }); + + it('recreate failure surfaces the error message instead of a silent no-op', async () => { + vi.mocked(createDataset).mockRejectedValue(new Error('HTTP400: validation')); + render(DatasetCard, { props: { dsId: 'ds-failed-1' } }); + const btn = await screen.findByRole('button', { name: /使用相同请求重新获取/ }); + await fireEvent.click(btn); + await screen.findByText(/重新获取失败/); + }); +}); diff --git a/frontend/src/lib/datasetCardWarnings.test.ts b/frontend/src/lib/datasetCardWarnings.test.ts new file mode 100644 index 0000000..8e79e84 --- /dev/null +++ b/frontend/src/lib/datasetCardWarnings.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/svelte'; + +vi.mock('../lib/client', () => { + const real = vi.importActual('../lib/client'); + return { + ...real, + getDataset: vi.fn(), + getDatasetPreview: vi.fn(), + createDataset: vi.fn() + }; +}); + +import DatasetCard from '../components/DatasetCard.svelte'; +import { getDataset, getDatasetPreview } from '../lib/client'; +import type { Dataset, DatasetPreview } from '../lib/types'; + +const readyDs: Dataset = { + id: 'ds-ready-1', + name: '国泰自由现金流-20251231-20260917', + request: { + instruments: [ + { symbol: '159399', market: 'SZ', asset_type: 'etf', name: '国泰自由现金流ETF', currency: null } + ], + start_date: '2025-12-31', + end_date: '2026-09-17', + frequency: 'daily', + adjustment: 'none', + fields: ['open', 'high', 'low', 'close', 'volume'] + }, + status: 'ready', + created_at: '2026-09-17T01:00:00Z' +}; + +const preview: DatasetPreview = { + columns: ['date', 'open', 'high', 'low', 'close', 'volume'], + rows: [ + { date: '2026-09-16', open: 1.001, high: 1.001, low: 0.982, close: 0.992, volume: 144526400 } + ], + coverage: [ + { + instrument: '159399', + market: 'SZ', + asset_type: 'etf', + requested_start: '2025-12-31', + requested_end: '2026-09-17', + actual_start: '2025-12-31', + actual_end: '2026-09-16', + row_count: 173, + warnings: [ + 'sina volume unit is 股 (shares)', + 'provider_fallback: eastmoney attempt failed; served by sina', + 'actual coverage 2025-12-31..2026-09-16 differs from request; gaps kept' + ] + } + ], + warnings: [] +}; + +beforeEach(() => { + vi.mocked(getDataset).mockResolvedValue(readyDs); + vi.mocked(getDatasetPreview).mockReset(); +}); + +afterEach(() => cleanup()); + +describe('per-instrument provenance warnings must be visible even without the range warning banner (159399 units/provider_fallback disclosure)', () => { + it('renders 夲-instrument sina volume-unit and provider_fallback warnings even when the common range banner shows 覆盖平稳', async () => { + vi.mocked(getDatasetPreview).mockResolvedValue(preview); + render(DatasetCard, { props: { dsId: 'ds-ready-1' } }); + const btn = await screen.findByRole('button', { name: /查看数据覆盖预览/ }); + await fireEvent.click(btn); + await waitFor(() => { + expect(screen.getByText(/provider_fallback/)).toBeTruthy(); + expect(screen.getByText(/volume unit is 股/)).toBeTruthy(); + expect(screen.getByText(/differs from request/)).toBeTruthy(); + }); + }); + + it('no warnings: shows the plain 覆盖平稳 banner and no warning list', async () => { + vi.mocked(getDatasetPreview).mockResolvedValue({ + ...preview, + coverage: [{ ...preview.coverage[0], warnings: [] }] + }); + render(DatasetCard, { props: { dsId: 'ds-ready-1' } }); + const btn = await screen.findByRole('button', { name: /查看数据覆盖预览/ }); + await fireEvent.click(btn); + await screen.findByText(/覆盖平稳/); + expect(screen.queryByText(/provider_fallback/)).toBeNull(); + }); +}); diff --git a/frontend/src/lib/projectDataTabRecreated.test.ts b/frontend/src/lib/projectDataTabRecreated.test.ts new file mode 100644 index 0000000..1a922c2 --- /dev/null +++ b/frontend/src/lib/projectDataTabRecreated.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +// leader review regression (2026-09-17): the recreated dataset previously +// stayed invisible — the parent (ProjectDataTab) never reloaded, the user +// remained on the failed card. The wiring must exist AND the callback must be +// the parent's own load (so pending→ready/failed tracking resumes). +import dataTabSource from '../pages/ProjectDataTab.svelte?raw'; +import wizardSource from '../components/DatasetWizard.svelte?raw'; + +describe('ProjectDataTab reloads when a failed dataset is re-requested', () => { + it('DatasetCard receives onRecreated bound to the parent reload', () => { + const src: string = dataTabSource as unknown as string; + expect(src).toMatch(/onRecreated=\{\(\) => void load\(\)\}/); + }); + + it('dataset card posts the create through the same client API the wizard uses', () => { + const src: string = wizardSource as unknown as string; + expect(src).toMatch(/createDataset/); + }); +}); diff --git a/frontend/src/lib/resultsAccounting.test.ts b/frontend/src/lib/resultsAccounting.test.ts index 226e1d0..cccb7ed 100644 --- a/frontend/src/lib/resultsAccounting.test.ts +++ b/frontend/src/lib/resultsAccounting.test.ts @@ -53,7 +53,7 @@ const fetchMockOnce = () => (url) => { }; describe('results accounting labels + values (live browser accounting)', () => { - it('renders actual result DOM: 平仓回合数 label, trade turnover values, metrics grid classes', async () => { + it('renders actual result DOM: 平仓回合数 label, trade turnover values, metrics grid classes', { timeout: 20000 }, async () => { // jsdom lacks ResizeObserver; Chart.svelte needs it only for responsive // canvas resize, which jsdom never triggers. Narrow environment shim. vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); diff --git a/frontend/src/pages/ProjectDataTab.svelte b/frontend/src/pages/ProjectDataTab.svelte index e3b1923..284aec7 100644 --- a/frontend/src/pages/ProjectDataTab.svelte +++ b/frontend/src/pages/ProjectDataTab.svelte @@ -5,6 +5,7 @@ import { listDatasets } from '../lib/client'; import { extractStatusMessage } from '../lib/state'; import type { Dataset } from '../lib/types'; + import { Button } from '$lib/components/ui/button'; interface Props { projectId?: string; @@ -17,7 +18,7 @@ let error: string | null = $state(null); // svelte-ignore state_referenced_locally let wizardOpen = $state(showWizard); - + let refreshTimer: ReturnType | null = null; @@ -61,9 +62,9 @@
{#if wizardOpen} void load()} /> - + {:else} - + {/if}

我的数据集

@@ -79,7 +80,7 @@ {#if !busy && !error && items.length > 0} {#each items as ds (ds.id)} - + void load()} /> {/each} {/if}
diff --git a/frontend/src/pages/ProjectResultsTab.svelte b/frontend/src/pages/ProjectResultsTab.svelte index 4149213..5ff03c0 100644 --- a/frontend/src/pages/ProjectResultsTab.svelte +++ b/frontend/src/pages/ProjectResultsTab.svelte @@ -1,8 +1,8 @@