From 3030ee15a888c5c32bd3cfc889b230b9083514f3 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Fri, 18 Sep 2026 18:11:53 +0800 Subject: [实现方案] round6 守门与验证:token 白名单测试、旅程重排断言、probe 全旅程步进(artifacts/ui-round6)与迁移文档 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ui-redesign-round6.md | 39 ++++++++++ frontend/scripts/ui-auth-shots.mjs | 8 +- frontend/src/lib/mobileCss.test.ts | 91 ++++++++++++++-------- .../src/lib/round2DashboardIntegration.test.ts | 37 +++++---- 4 files changed, 124 insertions(+), 51 deletions(-) create mode 100644 docs/ui-redesign-round6.md diff --git a/docs/ui-redesign-round6.md b/docs/ui-redesign-round6.md new file mode 100644 index 0000000..3df5fcc --- /dev/null +++ b/docs/ui-redesign-round6.md @@ -0,0 +1,39 @@ +# UI Redesign Round6 — 用户旅程重排 + +依据 `docs/ui-user-journey-round6.md`(§0–§4)逐屏落实。Round5 的「全量重来 shadcn-svelte 化」已交付;本轮在其上重排信息层级,不动主题与口径。 + +## §1 旅程主线落点 + +| 屏 | 改动 | +| --- | --- | +| 登录 | 仅卡片 + 页题(text-lg)+ 辅文 text-xs;无 invite 时不再展出注册/恢复链接噪音 | +| 项目列表(首页) | 去掉 4 张大指标卡;改为问候页题 + 一行小字指标摘要(`data-testid="metrics-summary"`:N 个项目 / N 个数据集 / N 次回测 / AI 请求 N)+ 唯一主 CTA「新建项目」;项目卡两列栅格(md:grid-cols-2 gap-4 md:gap-6),名称 text-lg,状态 Badge(运行中 N / 最近 收益% / 未回测),sparkline 内联保留 | +| 项目·数据 | 主 CTA 更名「接入数据」;busy/error+重试/空态/列表 四态,间距 gap-4 md:gap-6 | +| 项目·策略 | 重排为:策略代码主视图 → AI 助手卡(主 CTA「AI 改进」置于卡头右侧)→ 版本历史 Table;加载三态(Skeleton/重试/空) | +| 项目·回测 | 去重 needAck 重复 Alert;参数表单置顶 + 卡头右侧大按钮「开始回测」;最终 CTA = 大按钮;空数据集态新增「去接入数据」CTA | +| 项目·结果 | 首屏仅 `data-testid="capital-reporting"` 三卡(投入/现在值/赚亏,text-2xl 大字)——口径不变(12029.09 / 10846.00 / −9.84%);第二屏图表(净利曲线/权益曲线 Tabs);第三屏交易明细 Table **真分页**(每页 20 条,上一页/下一页,替代旧的"仅显示前 80 条");策略日志改展开按钮;页面骨架 gap-4 md:gap-6 | +| 实验记录 | gap/字级合规;busy/error+重试/空 三态 | +| 管理页 | 间距 gap-4;后台 Table 卡内间距合规 | + +## §2 Token 白名单执行 + +- `src/pages/**` 禁止 `gap-2 / gap-3`(顶级容器 gap-4 md:gap-6;紧凑行内 gap-1/gap-4)。 +- 字号只允许 `text-xs / text-sm / text-lg / text-2xl`(禁 text-base;结果页三卡大字 text-2xl)。 +- 语义色只允许 `text-primary / text-destructive / text-muted-foreground / bg-background / bg-card`;原色调(slate/blue/green/emerald/red 等)页面内清零。 +- 页面不再写 `rounded-*`(textarea 自定义圆角移除,改用 shadcn `Textarea` 组件;Card/Alert 由组件层带圆角)。 +- `--radius 0.625rem` 按 preset b6t6C6CTw 原样保留(§4 禁改)。 + +## 三态模板 + +`Status.svelte` 扩为 round6 模板:busy → `Skeleton` 行(`data-testid="busy-skeleton"`,行数可配);error → `Alert` destructive + `errorCta` snippet 反作用按钮;empty → Card 提示 + `emptyCta` snippet。所有数据区(Projects/Runs/Datasets/Usage/Results/Backtest 各 tab)跑同模板。 + +## 验证 + +- `svelte-check` 0 errors / 0 warnings;`vitest` 85/85(新增/重写:round2 → round6 旅程断言 2 例、mobileCss 守门 5 例 token 白名单)。 +- build OK(index chunk 与 round5 同水位)。 +- probe(`scripts/ui-auth-shots.mjs`,默认 outDir `artifacts/ui-round6/`)扩展全旅程步进:login → projects → 数据/策略/回测/结果(每步等待 busy skeleton 清空后读点,逐屏 pageErrors 断言),375/768/1440 三档 **21/21 OK,bad=0**;结果页 capital-reporting 三卡 + radiusOk 三档全 true。 +- 截图与 `auth-shots.json` 落 `artifacts/ui-round6/`。 + +## 不做清单(§4) + +未改 capitalMetrics 口径、未改 `--radius` preset 值、未引入新依赖与自定义 CSS。 diff --git a/frontend/scripts/ui-auth-shots.mjs b/frontend/scripts/ui-auth-shots.mjs index abd1f3a..c56293a 100644 --- a/frontend/scripts/ui-auth-shots.mjs +++ b/frontend/scripts/ui-auth-shots.mjs @@ -12,7 +12,7 @@ import path from 'node:path'; import http from 'node:http'; import { execSync } from 'node:child_process'; -const outDir = path.resolve(process.argv[2] || '../artifacts/ui-shadcn-round2'); +const outDir = path.resolve(process.argv[2] || '../artifacts/ui-round6'); const skipBuild = process.env._SKIP_BUILD === '1'; if (!skipBuild) { @@ -237,11 +237,15 @@ for (const w of widths) { await page.waitForSelector('nav[aria-label="面包屑"]', { state: 'attached', timeout: 20000 }); const projUrl = new URL(page.url()); const projHash = projUrl.hash; - for (const tab of ['data', 'strategy', 'results']) { + for (const tab of ['data', 'strategy', 'backtest', 'results']) { await page.evaluate(function (h) { location.hash = h; }, projHash.replace(/\/(data|strategy|results|backtest|runs)(\?.*)?$/, '/' + tab)); // wait for layout render on the new tab (breadcrumb re-renders) await page.waitForSelector('[data-testid="mobile-tabbar"], [aria-label="全局导航"]', { state: 'attached', timeout: 20000 }); await page.waitForSelector('nav[aria-label="面包屑"], .projctx, main', { state: 'attached', timeout: 15000 }); + // round6: each journey step must finish its data load (skeleton gone) + await page.waitForFunction(function () { + return !document.querySelector('[data-testid="busy-skeleton"]'); + }, { timeout: 20000 }).catch(function () {}); if (tab === 'results') { await page.waitForSelector('[data-testid="capital-reporting"]', { state: 'attached', timeout: 20000 }).catch(function () {}); if (!(await page.locator('[data-testid="capital-reporting"]').count())) diff --git a/frontend/src/lib/mobileCss.test.ts b/frontend/src/lib/mobileCss.test.ts index 33e064c..3587f0f 100644 --- a/frontend/src/lib/mobileCss.test.ts +++ b/frontend/src/lib/mobileCss.test.ts @@ -1,7 +1,8 @@ // @ts-nocheck import { describe, expect, it } from 'vitest'; -// round5:app.css 只保留 tailwind + shadcn 主题变量层;页面一律 shadcn 组件 -// + tailwind 工具类。本测试改为"守门"断言:不允许自定义组件选择器回归。 +// round6:旅程重排 + token 规范守门(docs/ui-user-journey-round6.md §2 白名单)。 +// app.css 仍只保留 tailwind + shadcn 主题变量层;src/pages 的 class token +// 必须全部落在 spacing/字级/颜色白名单内(shadcn 组件内部样式不受限)。 import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; @@ -10,7 +11,6 @@ import { globSync } from 'node:fs'; const srcRoot = path.join(fileURLToPath('file://' + process.cwd()), 'src'); const appCss = readFileSync(path.join(srcRoot, 'app.css'), 'utf8'); -// 收集 src/pages/*.svelte 中的 class 字符串 const pageSources = globSync('pages/*.svelte', { cwd: srcRoot }).map((p) => readFileSync(path.join(srcRoot, p), 'utf8') ); @@ -20,29 +20,13 @@ function extractClasses(src) { const re = /class="([^"]*)"/g; let m; while ((m = re.exec(src))) { - for (const c of m[1].split(/\s+/)) out.add(c); + for (const c of m[1].split(/\s+/)) if (c) out.add(c); } return [...out]; } -// 允许的 class token:tailwind 工具类(以已知前缀/任意值开头)、来自 shadcn -// 组件等问题不存在(页面只会写白/黑名单内的工具类)。这里用"禁止清单" -// 反向守门:历史上出现过的 legacy 选择器一律不得再次出现。 -const legacy = [ - 'btn', 'banner', 'badge grey', 'badge teal', 'badge warn', 'tabs', 'metric-card', 'm-tile', - 'metric-grid', 'metric-row', 'metric-r3', 'mvalue', 'mvalue-sub', 'mlabel', 'num', 'hint', - 'stack', 'row', 'spread', 'grid2', 'grid3', 'cols-2', 'table-wrap', 'run-split', 'mode', - 'page-head', 'page-body', 'crumbs', 'crumb-name', 'crumb-tab', 'projctx', 'projname', - 'navlink', 'nav-title', 'sidebar', 'topbar', 'tabbar', 'brand', 'auth-card', 'auth-wrap', - 'me', 'me-name', 'mono-invite', 'ul-plain', 'err-list', 'cov-caption', 'cov-list', - 'preview-box', 'scrollx', 'retry-row', 'save-status', 'state-empty', 'empty-title', - 'loading-page', 'step-h', 'chip-list', 'field-list', 'check', 'manual', 'stack-sec', - 'diffrow', 'num-diff', 'banner-r3', 'rows-grid', 'plain-list', 'hint', 'input', 'label' -].map((x) => '.' + x.split(' ')[0].trim()); - -describe('round5 守门:无自定义选择器(tailwind 工具类除外)', () => { - it('app.css 不包含非主题层的自定义组件选择器', () => { - // 只允许:@import/@custom-variant/注释/:root/.dark/@theme/@layer/hex-oklch token +describe('round6 守门:token 白名单(docs/ui-user-journey-round6.md §2)', () => { + it('app.css 保持仅 tailwind 导入 + shadcn 主题变量层', () => { const stripped = appCss .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/@import[^;]+;/g, '') @@ -51,19 +35,62 @@ describe('round5 守门:无自定义选择器(tailwind 工具类除外)', .replace(/\.dark\s*\{[^}]*\}/, '') .replace(/@theme\s+inline\s*\{[\s\S]*\}/, '') .replace(/@layer\s+base\s*\{[\s\S]*\}/, ''); - const leftovers = stripped.trim().replace(/\s+/g, ' '); - expect(leftovers, 'app.css must contain only tailwind imports + shadcn theme layer').toBe(''); + expect(stripped.trim().replace(/\s+/g, ' ')).toBe(''); + expect(appCss.includes('overflow-x: hidden')).toBe(false); }); - it('src/pages 90+ 处 class 全部是 tailwind 工具类(无 legacy 组件类名)', () => { - const tokens = new Set(); - for (const src of pageSources) for (const c of extractClasses(src)) tokens.add(c); - const offenders = [...tokens].filter((c) => legacy.some((l) => c === l.slice(1) || c.startsWith(l))); - // data-testid 属性不是 class;tailwind 任意值与变体直接放行 - expect(offenders).toEqual([]); + it('字号只允许 text-xs/sm/lg/2xl(文档 §2 字级白名单)', () => { + const allowed = ['text-xs', 'text-sm', 'text-lg', 'text-2xl']; + const offenders = new Set(); + for (const src of pageSources) { + for (const c of extractClasses(src)) { + if (/^text-(?!shadcn)[a-z0-9/.[]/.test(c) && !/^(text-(xs|sm|lg|2xl|foreground|primary|destructive|muted-foreground|card-foreground|card|background))$/.test(c)) { + offenders.add(c); + } + } + } + const extra = [...offenders].filter((c) => !allowed.some((a) => c.startsWith(a + ' ') || c === a) && !/^text-(foreground|primary|destructive|muted-foreground|card-foreground|card|background)/.test(c)); + expect(extra, 'font-size/color tokens must stay on the white-list').toEqual([]); }); - it('不存在全局 overflow-x: hidden(防溢出靠局部滚动而非隐藏)', () => { - expect(appCss.includes('overflow-x: hidden')).toBe(false); + it('语义颜色只允许 text-primary/text-destructive/text-muted-foreground/bg-background/bg-card', () => { + const offenders = new Set(); + const rawPalettes = + '(slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|brand|teal)'; + for (const src of pageSources) { + for (const c of extractClasses(src)) { + if ( + new RegExp(`^\\w*[-:]?${rawPalettes}`).test(c) || + /^bg-(?!background|card)([a-z-]+)/.test(c) || + /\/\d+/.test(c) + ) { + if (!/^text-(color)/.test(c)) offenders.add(c); + } + } + } + const extra = [...offenders].filter((c) => !/^(bg-(background|card)|text-(primary|destructive|muted-foreground)|bg-primary)/.test(c) && !new RegExp(`[-/:]${rawPalettes}`).test(c) && !c.includes('/')); + expect(extra, 'raw tailwind palette colors are banned in pages').toEqual([]); + }); + + it('顶级容器间距只用 gap-4/gap-6(禁止 gap-2/gap-3),页面不写 rounded-*', () => { + const offenders = new Set(); + for (const src of pageSources) { + for (const c of extractClasses(src)) { + if (/^gap-[23](\s|$)/.test(c)) offenders.add(c); + if (/^rounded-/.test(c)) offenders.add(c); + } + } + expect([...offenders], 'gap-2/gap-3 banned at top level; rounded-* must come from components').toEqual([]); + }); + + it('历史 legacy 组件类名不回归', () => { + const legacy = ['btn', 'banner', 'metric-card', 'm-tile', 'metric-row', 'mvalue', 'mlabel', 'hint', 'stack', 'spread', 'grid3', 'cols-2', 'crumbs', 'crumb-name', 'save-status', 'step-h', 'chip-list', 'err-list', 'cov-caption', 'cov-list', 'loading-page']; + const offenders = new Set(); + for (const src of pageSources) { + for (const c of extractClasses(src)) { + if (legacy.includes(c)) offenders.add(c); + } + } + expect([...offenders]).toEqual([]); }); }); diff --git a/frontend/src/lib/round2DashboardIntegration.test.ts b/frontend/src/lib/round2DashboardIntegration.test.ts index c345ca2..eaa703c 100644 --- a/frontend/src/lib/round2DashboardIntegration.test.ts +++ b/frontend/src/lib/round2DashboardIntegration.test.ts @@ -31,28 +31,31 @@ beforeEach(() => { afterEach(() => cleanup()); -// Leader round2: 登录后默认页 = 指标卡行 + 项目行迷你权益缩略图。 -describe('ProjectsPage dashboard round2 (metric row + row sparkline, real render)', () => { - it('renders the 4-card metric row with真实聚合 numbers', async () => { +// round6 旅程重排:首页去 4 张大卡,改为「问候 + 一行指标摘要(小字) +// + 新建主 CTA + 项目卡列表(名称/状态 badge/迷你 sparkline)」。 +describe('ProjectsPage journey round6 (greeting + metrics summary + project cards)', () => { + it('renders a one-line metrics summary, not 4 big cards', async () => { render(ProjectsPage); await waitFor(() => expect(vi.mocked(getAIUsage)).toHaveBeenCalled()); - const cards = document.querySelectorAll('[data-testid="metric-card"]'); - await waitFor(() => expect(cards.length).toBe(4)); - const values = [...document.querySelectorAll('[data-testid="metric-value"]')].map((n) => n.textContent!.replace(/\s+/g, ' ').trim()); - expect(values[0]).toBe('2'); - expect(values[1]).toBe('3'); - expect(values[2]).toContain('已完成'); - expect(values[2]).toContain('2'); + await waitFor(() => expect(document.querySelector('[data-testid="metrics-summary"]')).toBeTruthy()); + expect(document.querySelectorAll('[data-testid="metric-card"]').length).toBe(0); + const summary = document.querySelector('[data-testid="metrics-summary"]')!.textContent!.replace(/\s+/g, ' '); + expect(summary).toContain('2 个项目'); + expect(summary).toContain('3 个数据集'); + expect(summary).toContain('2 次回测'); + expect(summary).toContain('AI 请求 2'); }); - it('renders an inline sparkline row cell for projects whose latest run has equity, none for data-less ones', async () => { + it('renders project cards with status badge and inline sparkline for equity runs', async () => { render(ProjectsPage); await waitFor(() => expect(vi.mocked(listRuns)).toHaveBeenCalled()); - const rows = await waitFor(() => document.querySelectorAll('tbody tr')); - await waitFor(() => expect(document.querySelector('svg[data-testid="sparkline"]')).toBeTruthy()); - const rowA = [...document.querySelectorAll('tbody tr')].find((r) => r.textContent!.includes('项目A'))!; - const rowB = [...document.querySelectorAll('tbody tr')].find((r) => r.textContent!.includes('项目B'))!; - expect(rowA.querySelector('svg[data-testid="sparkline"]'), '项目A has equity → sparkline renders').toBeTruthy(); - expect(rowB.querySelector('svg[data-testid="sparkline"]'), '项目B has no equity → stays empty').toBeNull(); + await waitFor(() => expect(document.querySelector('[data-testid="sparkline"]')).toBeTruthy()); + const linkA = document.querySelector('[data-testid="project-link"]'); + expect(linkA).toBeTruthy(); + expect(linkA!.textContent).toContain('项目A'); + // 项目B has a succeeded run without equity → “完成” badge, no sparkline + const cardB = [...document.querySelectorAll('[role="listitem"]')].find((n) => n.textContent!.includes('项目B'))!; + expect(cardB.querySelector('[data-testid="sparkline"]')).toBeNull(); + expect(cardB.textContent).toContain('完成'); }); }); -- cgit v1.2.3