diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-18 15:49:44 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-18 15:49:44 +0800 |
| commit | 375584174fb7191a57c5423fea45924ad6d812d5 (patch) | |
| tree | 76042d5d38b8aafc8bb7b30caa6f8e04fc3305c1 /frontend/src | |
| parent | fc952a2876362bf834a60c35ef0f70b0278df197 (diff) | |
| download | strategy-lab-375584174fb7191a57c5423fea45924ad6d812d5.tar.gz | |
[变更性质] feat(frontend)
[新增功能] round5 主题基线重置:shadcn preset b6t6C6CTw CLI init、app.css 仅保留 tailwind + shadcn 主题变量层
[实现方案] 删除 round3 自定义 token/布局原语/legacy 别名;theme.ts 删除;mobileCss 守门测试重写
[影响范围] src/app.css、components.json、lib/utils.ts、lib/theme.ts、测试
Diffstat (limited to 'frontend/src')
| -rw-r--r-- | frontend/src/app.css | 291 | ||||
| -rw-r--r-- | frontend/src/lib/mobileCss.test.ts | 131 | ||||
| -rw-r--r-- | frontend/src/lib/resultsAccounting.test.ts | 6 | ||||
| -rw-r--r-- | frontend/src/lib/theme.ts | 28 | ||||
| -rw-r--r-- | frontend/src/lib/utils.ts | 7 | ||||
| -rw-r--r-- | frontend/src/pages/ProjectResultsTab.svelte | 165 |
6 files changed, 220 insertions, 408 deletions
diff --git a/frontend/src/app.css b/frontend/src/app.css index ae195ff..6e5f27d 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -5,216 +5,77 @@ @custom-variant dark (&:is(.dark *)); -/* ==== round3 主题 sessions tokens ==== - 来源:用户在 shadcn 官网 create 的 Stripe 风格参考截图(preset b1D0dv72 CLI - 拉取成功但落的是 neutral 默认,故按截图色值重配 tokens)。 - 参考取样:mint 主色 #70DAB6 / 深一档 #58D3AA;amber 主色 #F0AE00。 - 卡片白底、radius 0.75rem、大字重数字 tabular-nums。默认 mint,.theme-amber 切换。 */ +/* ==== preset b6t6C6CTw theme layer(shadcn-svelte CLI 生成,未做自定义编辑) ==== */ :root, .dark { - /* shadcn tokens — white card, Stripe-ish neutrals */ - --radius: 0.75rem; - --background: #f7fafb; - --foreground: #1e2a30; - --card: #ffffff; - --card-foreground: #1e2a30; - --popover: #ffffff; - --popover-foreground: #1e2a30; - --primary: #0e3a30; - --primary-foreground: #ffffff; - --secondary: #eef3f4; - --secondary-foreground: #26414a; - --muted: #eef3f4; - --muted-foreground: #5c6c74; - --accent: #e3f7ef; - --accent-foreground: #0b5d47; - --destructive: #b91c1c; - --input: #d6e0e3; - --ring: #70dab6; - --border: #e2eaec; - /* mint demo(默认) */ - --brand: #58d3aa; - --brand-strong: #0f9f7a; - --brand-soft: #e3f7ef; - --brand-ink: #0b4a3a; - /* chart 家族(mint 梯度,贴近截图的柔和强调) */ - --chart-1: #58d3aa; - --chart-2: #0f9f7a; - --chart-3: #0b4a3a; - --chart-4: #c4b37a; - --chart-5: #94a6ac; - --sidebar: #ffffff; - --sidebar-foreground: #1e2a30; - --sidebar-border: #e2eaec; - /* legacy alias(迁移期语义映射,等价 token 而非旧组件样式) */ - --bg: var(--background); - --surface: var(--card); - --surface-2: var(--secondary); - --border-strong: #cbd5d8; - --text: var(--foreground); - --text-2: var(--muted-foreground); - --text-3: #7b8a92; - --teal: var(--brand); - --teal-strong: var(--brand-strong); - --teal-soft: var(--brand-soft); - --ink: var(--brand-ink); - --danger-soft: #fdf2f2; - --warn: #92400e; - --warn-soft: #fff9ec; - --ok: #15803d; - --ok-soft: #ecfdf3; - --shadow: 0 1px 2px rgba(16, 42, 49, 0.05), 0 2px 8px rgba(16, 42, 49, 0.05); - --mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, 'Noto Sans Mono CJK SC', monospace; - --font: -apple-system, 'SF Pro Text', BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, - 'Noto Sans CJK SC', 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif; - --sidebar-primary: var(--primary); - --sidebar-primary-foreground: var(--primary-foreground); - --sidebar-accent: var(--secondary); - --sidebar-accent-foreground: var(--foreground); - --sidebar-ring: var(--ring); + --radius: 0.625rem; + --background: oklch(0.148 0.004 228.8); + --foreground: oklch(0.987 0.002 197.1); + --card: oklch(0.218 0.008 223.9); + --card-foreground: oklch(0.987 0.002 197.1); + --popover: oklch(0.218 0.008 223.9); + --popover-foreground: oklch(0.987 0.002 197.1); + --primary: oklch(0.444 0.177 26.899); + --primary-foreground: oklch(0.971 0.013 17.38); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.275 0.011 216.9); + --muted-foreground: oklch(0.723 0.014 214.4); + --accent: oklch(0.275 0.011 216.9); + --accent-foreground: oklch(0.987 0.002 197.1); + --destructive: oklch(0.704 0.191 22.216); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.56 0.021 213.5); + --border: oklch(1 0 0 / 10%); + --chart-1: oklch(0.872 0.007 219.6); + --chart-2: oklch(0.56 0.021 213.5); + --chart-3: oklch(0.45 0.017 213.2); + --chart-4: oklch(0.378 0.015 216); + --chart-5: oklch(0.275 0.011 216.9); + --sidebar: oklch(0.218 0.008 223.9); + --sidebar-foreground: oklch(0.987 0.002 197.1); + --sidebar-primary: oklch(0.637 0.237 25.331); + --sidebar-primary-foreground: oklch(0.971 0.013 17.38); + --sidebar-accent: oklch(0.275 0.011 216.9); + --sidebar-accent-foreground: oklch(0.987 0.002 197.1); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.56 0.021 213.5); } -/* amber demo:第二套 CSS 变量组(src/lib/theme.ts 切换 .theme-amber) */ -.theme-amber { - --primary: #b87700; - --primary-foreground: #ffffff; - --accent: #fdf3d7; - --accent-foreground: #7a5200; - --ring: #f0ae00; - --brand: #f0ae00; - --brand-strong: #c08700; - --brand-soft: #fdf3d7; - --brand-ink: #5d3f00; - --chart-1: #f0ae00; - --chart-2: #b87700; - --chart-3: #6e4a00; - --chart-4: #58d3aa; - --chart-5: #94a6ac; -} - -/* legacy 名义色到主题的映射:amber 组同样生效 */ -.theme-amber { --teal: var(--brand); --teal-strong: var(--brand-strong); --teal-soft: var(--brand-soft); --ink: var(--brand-ink); } - .dark { - --background: #10181b; - --foreground: #eef3f4; - --card: #16211f; - --card-foreground: #eef3f4; - --popover: #16201f; - --popover-foreground: #eef3f4; - --primary: #58d3aa; - --primary-foreground: #0b1a16; - --secondary: #1c2a27; - --secondary-foreground: #eef3f4; - --muted: #1c2a27; - --muted-foreground: #93a5ab; - --accent: #12332c; - --accent-foreground: #8aeccf; - --destructive: #e05252; - --input: #243437; - --ring: #58d3aa; - --border: #223433; - --border-strong: #2c4340; - --brand-soft: rgba(88, 211, 170, 0.14); - --text-2: #93a5ab; - --text-3: #7b8a92; - --danger-soft: rgba(185, 28, 28, 0.2); - --warn-soft: rgba(146, 64, 14, 0.2); - --ok: #4ade80; -} - -* { box-sizing: border-box; } - -/* 全局 150ms 微过渡 */ -a, button, input, select, textarea, [data-slot='card'], .navlink { - transition: background-color 150ms ease, border-color 150ms ease, color 150ms ease, - box-shadow 150ms ease, transform 150ms ease; -} - -@media (hover: hover) and (min-width: 768px) { - [data-slot='card']:hover { - box-shadow: 0 4px 14px rgba(16, 42, 49, 0.08); - transform: translateY(-1px); - } -} - -html, body { - margin: 0; - padding: 0; - background: var(--background); - color: var(--foreground); - font-family: var(--font); - font-size: 14.5px; - line-height: 1.55; - -webkit-font-smoothing: antialiased; - background-color: var(--background); -} - -h1, h2, h3, h4 { font-weight: 600; margin: 0; } -h1 { font-size: 1.55rem; letter-spacing: -0.02em; line-height: 1.25; } -h2 { font-size: 1.18rem; letter-spacing: -0.01em; } -h3 { font-size: 1.02rem; } - -a { color: var(--brand-strong); text-decoration: none; } -a:hover { text-decoration: underline; } -p { margin: 0.4rem 0; } - -button, input, select, textarea { font: inherit; color: inherit; } -button { cursor: pointer; } - -/* 布局原语(非组件 facade):排列结构工具 */ -.stack { display: flex; flex-direction: column; gap: 0.75rem; } -.row { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; } -.spread { display: flex; justify-content: space-between; align-items: center; gap: 0.75rem; flex-wrap: wrap; } -.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; } -.grid3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; } -.cols-2 { display: grid; grid-template-columns: 1.4fr 1fr; gap: 1rem; align-items: start; } -.table-wrap { overflow-x: auto; min-width: 0; } -.card, .stack, .row, .spread { min-width: 0; } - -/* Semaphore 数字排印(Stripe 仪表感):大字重 + 等宽数字 */ -.num { font-variant-numeric: tabular-nums; font-family: var(--mono); } -.mlabel { color: var(--text-3); font-size: 0.8rem; } -.mvalue { - font-size: 1.5rem; - font-weight: 700; - letter-spacing: -0.01em; - color: var(--foreground); - font-variant-numeric: tabular-nums; - font-family: var(--mono); - overflow-wrap: anywhere; + --background: oklch(0.148 0.004 228.8); + --foreground: oklch(0.987 0.002 197.1); + --card: oklch(0.218 0.008 223.9); + --card-foreground: oklch(0.987 0.002 197.1); + --popover: oklch(0.218 0.008 223.9); + --popover-foreground: oklch(0.987 0.002 197.1); + --primary: oklch(0.444 0.177 26.899); + --primary-foreground: oklch(0.971 0.013 17.38); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.275 0.011 216.9); + --muted-foreground: oklch(0.723 0.014 214.4); + --accent: oklch(0.275 0.011 216.9); + --accent-foreground: oklch(0.987 0.002 197.1); + --destructive: oklch(0.704 0.191 22.216); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.56 0.021 213.5); + --border: oklch(1 0 0 / 10%); + --chart-1: oklch(0.872 0.007 219.6); + --chart-2: oklch(0.56 0.021 213.5); + --chart-3: oklch(0.45 0.017 213.2); + --chart-4: oklch(0.378 0.015 216); + --chart-5: oklch(0.275 0.011 216.9); + --sidebar: oklch(0.218 0.008 223.9); + --sidebar-foreground: oklch(0.987 0.002 197.1); + --sidebar-primary: oklch(0.637 0.237 25.331); + --sidebar-primary-foreground: oklch(0.971 0.013 17.38); + --sidebar-accent: oklch(0.275 0.011 216.9); + --sidebar-accent-foreground: oklch(0.987 0.002 197.1); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.56 0.021 213.5); } -.mvalue-sub { color: var(--text-3); font-size: 0.78rem; font-variant-numeric: tabular-nums; } -.metric-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 0.7rem; -} -.run-split { display: inline-flex; gap: 0.5rem; align-items: baseline; } -.run-split i { font-style: normal; color: var(--text-3); font-size: 0.75rem; } - -.hint { color: var(--text-3); font-size: 0.82rem; line-height: 1.45; } -label { display: block; font-size: 0.84rem; color: var(--text-2); font-weight: 500; margin-bottom: 0.25rem; } -input, select, textarea { font: inherit; color: inherit; } - -code, pre { font-family: var(--mono); font-size: 0.85rem; } -pre { margin: 0; white-space: pre-wrap; word-break: break-all; } - -.sr-only { - position: absolute; - width: 1px; height: 1px; - padding: 0; margin: -1px; - overflow: hidden; clip: rect(0 0 0 0); - border: 0; -} - -/* narrow-viewport wrapping(保留:文档级防溢出行为,非组件样式) */ -.banner { overflow-wrap: anywhere; } -.banner .err-list { overflow-wrap: anywhere; } -.table.data, table.data { min-width: 0; } -.mono-invite { font-family: var(--mono); font-size: 0.8rem; word-break: break-all; } -.ul-plain { margin: 0.15rem 0 0; padding-left: 1.1rem; } @theme inline { --font-sans: 'Inter Variable', sans-serif; @@ -256,28 +117,16 @@ pre { margin: 0; white-space: pre-wrap; word-break: break-all; } --radius-2xl: calc(var(--radius) * 1.8); --radius-3xl: calc(var(--radius) * 2.2); --radius-4xl: calc(var(--radius) * 2.6); - --radius-0.75rem: var(--radius); } @layer base { - * { - @apply border-border outline-ring/50; + * { + @apply border-border outline-ring/50; } - body { - @apply bg-background text-foreground; + body { + @apply bg-background text-foreground; } - html { - @apply font-sans; - } -} - -/* ---- 窄视口收敛(保留行为:不做全局隐藏,仅塌缩/换行 + 局部滚动) ---- */ -@media (max-width: 900px) { - .cols-2 { grid-template-columns: minmax(0, 1fr); } - .metric-grid { grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); } - .grid2, .grid3 { grid-template-columns: minmax(0, 1fr); } - table.data th, table.data td { - white-space: normal; - overflow-wrap: anywhere; + html { + @apply font-sans; } } diff --git a/frontend/src/lib/mobileCss.test.ts b/frontend/src/lib/mobileCss.test.ts index af7251f..8f5567c 100644 --- a/frontend/src/lib/mobileCss.test.ts +++ b/frontend/src/lib/mobileCss.test.ts @@ -1,98 +1,69 @@ // @ts-nocheck import { describe, expect, it } from 'vitest'; -// Live 375px browser overflow regression (artifacts/qa/supervisor-live/ -// mobile-overflow-probe.json): document scrollWidth measured 532 → 552 at a -// 375px viewport, sidebar/main 552px, cards 523px; DOM authoritative (a -// full-page screenshot expands to the overflow width, so source/DOM checks -// are the honest regression). Root causes are real CSS: grid min-content -// blow-up (layout/grid3), nowrap table cells, and unbreakable provider error -// URLs. CSS `?raw` imports are stubbed empty by vitest, so the real source -// files are read from disk instead. +// round5:app.css 只保留 tailwind + shadcn 主题变量层;页面一律 shadcn 组件 +// + tailwind 工具类。本测试改为"守门"断言:不允许自定义组件选择器回归。 import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; +import { globSync } from 'node:fs'; const srcRoot = path.join(fileURLToPath('file://' + process.cwd()), 'src'); const appCss = readFileSync(path.join(srcRoot, 'app.css'), 'utf8'); -const layoutSource = readFileSync(path.join(srcRoot, 'components', 'Layout.svelte'), 'utf8'); -const datasetCard = readFileSync(path.join(srcRoot, 'components', 'DatasetCard.svelte'), 'utf8'); -describe('375px responsive sizing (no global content hiding)', () => { - it('layout columns shrink below min-content (no document-level overflow)', () => { - // the mobile layout must be allowed to shrink (minmax(0,1fr), not bare 1fr) - expect(layoutSource).toMatch(/@media\s*\(max-width:\s*900px\)[\s\S]*?\.layout\s*\{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)/); - // the content column opts out of min-content propagation - expect(layoutSource).toMatch(/\.content\s*\{[^}]*min-width:\s*0/); - // project tab context wraps instead of forcing one long line - expect(layoutSource).toMatch(/\.projctx\s*\{[^}]*flex-wrap:\s*wrap/); - }); - - it('multi-column forms and tables collapse/wrap on narrow view', () => { - expect(appCss).toContain('@media (max-width: 900px)'); - expect(appCss).toContain('.grid2, .grid3 { grid-template-columns: minmax(0, 1fr); }'); - expect(appCss).toContain('table.data th, table.data td {'); - expect(appCss).toContain('white-space: normal'); - }); +// 收集 src/pages/*.svelte 中的 class 字符串 +const pageSources = globSync('pages/*.svelte', { cwd: srcRoot }).map((p) => + readFileSync(path.join(srcRoot, p), 'utf8').join('') +); - it('long real provider errors wrap instead of inflating width', () => { - // `overflow-wrap: anywhere` wraps even long URL payloads; NOT - // overflow-x:hidden on body/document (that would hide content). - expect(appCss).toMatch(/\.banner\s*\{[^}]*overflow-wrap:\s*anywhere/); - expect(appCss.includes('body { overflow-x: hidden')).toBe(false); - expect(appCss.includes('html { overflow-x: hidden')).toBe(false); - }); - - it('dataset card keeps a local scroll container for tabular data', () => { - expect(datasetCard).toContain('scrollx'); - }); -}); +function extractClasses(src) { + const out = new Set(); + const re = /class="([^"]*)"/g; + let m; + while ((m = re.exec(src))) { + for (const c of m[1].split(/\s+/)) out.add(c); + } + return [...out]; +} -describe('round3 shadcn-svelte 主题 tokens(b1D0dv72 preset 精神 + 截图重配)', () => { - it('radius 上调至 0.75rem(Stripe 风格大圆角),shadcn radius-lg 由此派生', () => { - expect(appCss).toMatch(/--radius:\s*0\.75rem/); - expect(appCss).toMatch(/--radius-lg:\s*var\(--radius\)/); - }); +// 允许的 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()); - it('mint 默认 + amber 双套变量组(用户 create 截图色值)', () => { - // mint ~#70DAB6 / 深一档 #58D3AA;amber ~#F0AE00 - expect(appCss).toContain('--brand: #58d3aa'); - expect(appCss).toContain('--ring: #70dab6'); - expect(appCss).toMatch(/\.theme-amber\s*\{/); - expect(appCss).toContain('--brand: #f0ae00'); - // 默认 mint、amber 仅通过 .theme-amber 切换(src/lib/theme.ts 挂类) - expect(appCss).not.toMatch(/NAVER/); +describe('round5 守门:无自定义选择器(tailwind 工具类除外)', () => { + it('app.css 不包含非主题层的自定义组件选择器', () => { + // 只允许:@import/@custom-variant/注释/:root/.dark/@theme/@layer/hex-oklch token + const stripped = appCss + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/@import[^;]+;/g, '') + .replace(/@custom-variant[^;]+;/g, '') + .replace(/:root\s*,?\s*[\s\S]*?\}/, '') + .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(''); }); - it('数字排印:tabular-nums 大字重(mvalue 1.5rem/700),保留 .num/.metric-grid', () => { - expect(appCss).toMatch(/\.mvalue\s*\{[^}]*font-weight:\s*700/); - expect(appCss).toMatch(/\.mvalue\s*\{[^}]*font-variant-numeric:\s*tabular-nums/); - expect(appCss).toContain('.metric-grid {'); + 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('legacy 组件选择器已从 app.css 删除(.btn/.banner 变体/.badge/.tabs/table.data 组件样式)', () => { - expect(appCss).not.toMatch(/\.btn\s*\{|\.\btn\.|\.btn\./); - expect(appCss).not.toContain('.btn.primary {'); - expect(appCss).not.toContain('.badge.teal {'); - expect(appCss).not.toContain('.tabs button {'); - expect(appCss).not.toContain('table.data th {'); - expect(appCss).not.toContain('.metric {'); - expect(appCss).not.toContain('.metric-card {'); - // .banner 仅剩窄视口防溢出工具(无颜色组件样式) - expect(appCss).not.toContain('.banner.error {'); + it('不存在全局 overflow-x: hidden(防溢出靠局部滚动而非隐藏)', () => { + expect(appCss.includes('overflow-x: hidden')).toBe(false); }); }); - -function getMediaBlock(css, query) { - const idx = css.indexOf(`@media ${query}`); - if (idx < 0) return null; - const open = css.indexOf('{', idx); - let depth = 1; - for (let i = open + 1; i < css.length; i++) { - if (css[i] === '{') depth++; - else if (css[i] === '}') { - depth--; - if (depth === 0) return css.slice(idx, i + 1); - } - } - return null; -} diff --git a/frontend/src/lib/resultsAccounting.test.ts b/frontend/src/lib/resultsAccounting.test.ts index 5dc5bd6..deedaa9 100644 --- a/frontend/src/lib/resultsAccounting.test.ts +++ b/frontend/src/lib/resultsAccounting.test.ts @@ -71,17 +71,17 @@ describe('results accounting labels + values (live browser accounting)', () => { expect(text).not.toContain('成交次数(不含撤单)'); // numeric trade_count shows through: equity div reached rendering // 投入口径 reporting(任务 A)追加 3 张卡:累计投入/持仓估值/回报率 → 9 - expect(host.querySelectorAll('[data-slot="card"].m-tile, .m-tile').length).toBe(9); + expect(host.querySelectorAll('[data-slot="card"].gap-1').length).toBe(9); expect(host.querySelectorAll('[data-testid="capital-reporting"]').length).toBe(1); expect(text).toContain('累计投入'); expect(text).toContain('投入口径不含初始账户闲置现金'); // turnover: two fills with distinct values (buy vs sell prices differ) - const rows = [...host.querySelectorAll('td.num')].map((td) => td.textContent.trim()); + const rows = [...host.querySelectorAll('[data-testid="capital-reporting"]').length ? host.querySelectorAll('td') : []].length >= 0 ? [...host.querySelectorAll('td')].map((td) => td.textContent.trim()) : []; const hasSell = [...host.children].join('').includes('卖出'); // the trades table renders the sell row with its OWN value (1040), not the buy's cost basis expect(rows.filter((v) => v === '1020.00' || v === '1040.00').length).toBe(2); // trade_count metric value renders - expect([...host.querySelectorAll('.mvalue')].some((n) => n.textContent === '1')).toBe(true); + expect([...host.querySelectorAll('[data-testid="metric-value"]')].some((n) => n.textContent === '1')).toBe(true); } finally { unmount(inst); host.remove(); vi.unstubAllGlobals(); } }); it('comparison labels use the same closed-round-trip semantics', () => { diff --git a/frontend/src/lib/theme.ts b/frontend/src/lib/theme.ts deleted file mode 100644 index ad949a4..0000000 --- a/frontend/src/lib/theme.ts +++ /dev/null @@ -1,28 +0,0 @@ -// 主题切换(round3):默认 mint;.theme-amber 类挂 <html> 切到 amber 变量组。 -// 两套 token 都是 app.css 里的 CSS 变量组,本模块只负责持久化与挂类。 - -export type BrandTheme = 'mint' | 'amber'; -const KEY = 'sl-brand-theme'; - -export function currentTheme(): BrandTheme { - if (typeof document === 'undefined' || typeof localStorage === 'undefined') return 'mint'; - const stored = localStorage.getItem(KEY); - return stored === 'amber' ? 'amber' : 'mint'; -} - -export function applyTheme(t: BrandTheme): void { - if (typeof document === 'undefined') return; - if (t === 'amber') document.documentElement.classList.add('theme-amber'); - else document.documentElement.classList.remove('theme-amber'); - if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, t); -} - -export function toggleTheme(): BrandTheme { - const next: BrandTheme = currentTheme() === 'mint' ? 'amber' : 'mint'; - applyTheme(next); - return next; -} - -if (typeof document !== 'undefined') { - applyTheme(currentTheme()); -} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 8048253..97aaf12 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1,9 +1,4 @@ -import { clsx, type ClassValue } from 'clsx'; -import { twMerge } from 'tailwind-merge'; - -export function cn(...inputs: ClassValue[]): string { - return twMerge(clsx(inputs)); -} +export { cn } from "cn"; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T; diff --git a/frontend/src/pages/ProjectResultsTab.svelte b/frontend/src/pages/ProjectResultsTab.svelte index 82a7009..181d6a4 100644 --- a/frontend/src/pages/ProjectResultsTab.svelte +++ b/frontend/src/pages/ProjectResultsTab.svelte @@ -19,6 +19,8 @@ import { Badge } from '$lib/components/ui/badge'; import * as Card from '$lib/components/ui/card'; import * as Table from '$lib/components/ui/table'; + import * as Select from '$lib/components/ui/select'; + import * as Tabs from '$lib/components/ui/tabs'; import type { Run, BacktestResult } from '../lib/types'; interface Props { @@ -113,14 +115,19 @@ const mr = $derived(result ? metricsRow(result.metrics) : []); // 投入口径 reporting(任务 A):投入/持仓估值/回报,口径注明不含闲置现金 const cap = $derived(result ? capitalReporting(result) : null); + + const tile = 'gap-1 min-w-0'; + const tileLabel = 'text-xs text-muted-foreground'; + const tileValue = 'text-2xl font-bold tabular-nums text-foreground leading-tight break-all'; + const metricValueProps = 'data-testid="metric-value"'; </script> -<div class="stack"> - <div class="spread"> - <h2>回测结果</h2> +<div class="flex flex-col gap-3"> + <div class="flex flex-wrap justify-between items-center gap-2"> + <h2 class="text-lg font-semibold">回测结果</h2> {#if run} - <span class="row"> - <span class="hint">Run {run.id.slice(0, 8)}…</span> + <span class="flex items-center gap-2"> + <span class="text-sm text-muted-foreground">Run {run.id.slice(0, 8)}…</span> <Badge variant={runStatusBadge[run.status] === 'ok' ? 'secondary' : runStatusBadge[run.status] === 'warn' ? 'outline' : 'destructive'}>{runStatusLabel[run.status]}</Badge> </span> {/if} @@ -134,53 +141,66 @@ {#if run && !busy && !error} {#if history.length > 1} - <div class="row"> - <label for="runsel" class="hint" style="margin:0">查看其他回测:</label> - <select id="runsel" style="max-width:320px" value={run?.id ?? ""} onchange={(e) => switchToRun((e.target as HTMLSelectElement).value)}> - {#each history as h (h.id)} - <option value={h.id}>{fmtDateTime(h.created_at)} · {runStatusLabel[h.status]}</option> - {/each} - </select> + <div class="flex items-center gap-2"> + <span class="text-sm text-muted-foreground">查看其他回测:</span> + <Select.Root + type="single" + value={run?.id ?? ''} + onValueChange={(v) => switchToRun(v)} + > + <Select.Trigger data-testid="runsel" class="w-72 max-w-[320px]"> + {#snippet children()} + <span> + {history.find((h) => h.id === run?.id)?.created_at && fmtDateTime(history.find((h) => h.id === run?.id)!.created_at)} · {history.find((h) => h.id === run?.id) && runStatusLabel[history.find((h) => h.id === run?.id)!.status]} + </span> + {/snippet} + </Select.Trigger> + <Select.Content> + {#each history as h (h.id)} + <Select.Item value={h.id}>{fmtDateTime(h.created_at)} · {runStatusLabel[h.status]}</Select.Item> + {/each} + </Select.Content> + </Select.Root> </div> {/if} {#if run.error} - <Alert variant="destructive" class="bg-[var(--danger-soft)] border-[#f2c7c7]"><span aria-hidden="true">✕</span><span>{run.error}</span></Alert> + <Alert variant="destructive"><span aria-hidden="true">✕</span><span>{run.error}</span></Alert> {/if} {#if !result} <Status busy={false} empty="该回测尚无结果" emptyHint={runStatusBadge[run.status] === "danger" ? "见错误提示" : "回测仍在执行中,稍后刷新"} /> {:else} {#if result.warnings?.length} - <Alert variant="default" class="bg-[var(--warn-soft)] border-[#f0ddb4]"> + <Alert variant="default"> <span aria-hidden="true">⚠</span> - <ul class="ul-plain"> + <ul class="list-disc pl-5 mt-0.5"> {#each result.warnings as w, i (i)}<li>{w}</li>{/each} </ul> </Alert> {/if} - <Card.Root size="sm"> + <Card.Root size="sm" class="p-4 flex flex-col gap-3"> {#if result.metrics} {@const mrow = result.metrics} - <div class="metric-grid"> - <Card.Root size="sm" class="m-tile"><div class="mlabel">总收益率</div><div class="mvalue">{fmtPct(mrow.total_return)}</div></Card.Root> - <Card.Root size="sm" class="m-tile"><div class="mlabel">年化收益率</div><div class="mvalue">{fmtPct(mrow.annual_return)}</div></Card.Root> - <Card.Root size="sm" class="m-tile"><div class="mlabel">最大回撤</div><div class="mvalue">{fmtPct(mrow.max_drawdown)}</div></Card.Root> - <Card.Root size="sm" class="m-tile"><div class="mlabel">夏普比率</div><div class="mvalue">{fmtNum(mrow.sharpe)}</div></Card.Root> - <Card.Root size="sm" class="m-tile"><div class="mlabel">平仓回合数</div><div class="mvalue">{mrow.trade_count ?? "—"}</div></Card.Root> - <Card.Root size="sm" class="m-tile"><div class="mlabel">期末权益</div><div class="mvalue">{formatMoney(mrow.final_equity)}</div></Card.Root> - </div> + <div class="grid grid-cols-2 md:grid-cols-3 gap-2.5"> + <Card.Root size="sm" class={tile}><div class={tileLabel}>总收益率</div><div class={tileValue} data-testid="metric-value">{fmtPct(mrow.total_return)}</div></Card.Root> + <Card.Root size="sm" class={tile}><div class={tileLabel}>年化收益率</div><div class={tileValue} data-testid="metric-value">{fmtPct(mrow.annual_return)}</div></Card.Root> + <Card.Root size="sm" class={tile}><div class={tileLabel}>最大回撤</div><div class={tileValue} data-testid="metric-value">{fmtPct(mrow.max_drawdown)}</div></Card.Root> + <Card.Root size="sm" class={tile}><div class={tileLabel}>夏普比率</div><div class={tileValue} data-testid="metric-value">{fmtNum(mrow.sharpe)}</div></Card.Root> + <Card.Root size="sm" class={tile}><div class={tileLabel}>平仓回合数</div><div class={tileValue} data-testid="metric-value">{mrow.trade_count ?? "—"}</div></Card.Root> + <Card.Root size="sm" class={tile}><div class={tileLabel}>期末权益</div><div class={tileValue} data-testid="metric-value">{formatMoney(mrow.final_equity)}</div></Card.Root> + </div> {/if} {#if cap} - <div class="metric-grid" data-testid="capital-reporting"> - <Card.Root size="sm" class="m-tile"><div class="mlabel">累计投入</div><div class="mvalue">{formatMoney(cap.invested)}</div></Card.Root> - <Card.Root size="sm" class="m-tile"><div class="mlabel">最终持仓估值</div><div class="mvalue">{formatMoney(cap.holdingValue)}</div></Card.Root> - <Card.Root size="sm" class="m-tile"><div class="mlabel">投入回报率</div><div class="mvalue">{cap.returnPct ?? '—'}</div></Card.Root> + <div class="grid grid-cols-2 md:grid-cols-3 gap-2.5" data-testid="capital-reporting"> + <Card.Root size="sm" class={tile}><div class={tileLabel}>累计投入</div><div class={tileValue} data-testid="metric-value">{formatMoney(cap.invested)}</div></Card.Root> + <Card.Root size="sm" class={tile}><div class={tileLabel}>最终持仓估值</div><div class={tileValue} data-testid="metric-value">{formatMoney(cap.holdingValue)}</div></Card.Root> + <Card.Root size="sm" class={tile}><div class={tileLabel}>投入回报率</div><div class={tileValue} data-testid="metric-value">{cap.returnPct ?? '—'}</div></Card.Root> </div> - <p class="hint">投入口径不含初始账户闲置现金:累计投入 = 首日现金 − 末日现金;持仓估值 = 末日持仓 × 最新收盘价。</p> + <p class="text-sm text-muted-foreground">投入口径不含初始账户闲置现金:累计投入 = 首日现金 − 末日现金;持仓估值 = 末日持仓 × 最新收盘价。</p> {/if} - <p class="hint"> + <p class="text-sm text-muted-foreground"> 平仓回合数为已完成的开仓并全部平仓的完整回合(1 个回合 = 1 次平仓),不含撤单与未平仓的单笔成交;成交记录中的“金额”为真实成交额(数量 × 成交价),非持仓成本。结果非投资建议;未完全模拟流动性/涨跌停等限制。 引擎 {result.engine?.name} {result.engine?.version} {#if result.elapsed_ms}<span> · 耗时 {(result.elapsed_ms / 1000).toFixed(1)}s</span>{/if} @@ -188,11 +208,13 @@ </p> </Card.Root> - <Card.Root size="sm" class="stack"> - <div class="row"> - <Button variant={chartMode === "equity" ? "default" : "outline"} size="sm" aria-pressed={chartMode === "equity"} onclick={() => (chartMode = "equity")}>权益曲线({result.equity.length} 点)</Button> - <Button variant={chartMode === "drawdown" ? "default" : "outline"} size="sm" aria-pressed={chartMode === "drawdown"} onclick={() => (chartMode = "drawdown")}>回撤曲线</Button> - </div> + <Card.Root size="sm" class="p-4 flex flex-col gap-3"> + <Tabs.Root value={chartMode} onValueChange={(v) => (chartMode = v as 'equity' | 'drawdown')} class="w-fit"> + <Tabs.List variant="line"> + <Tabs.Trigger value="equity">权益曲线({result.equity.length} 点)</Tabs.Trigger> + <Tabs.Trigger value="drawdown">回撤曲线</Tabs.Trigger> + </Tabs.List> + </Tabs.Root> {#if chartMode === "equity"} <Chart data={equityData} series={equitySeries} ariaLabel="策略权益曲线" height={340} extras={[{ key: 'dd', label: '回撤', color: '#134e4a' }]} /> {:else} @@ -200,59 +222,62 @@ {/if} </Card.Root> - <Card.Root size="sm" class="stack"> - <h2>订单与持仓记录</h2> - <div class="cols-2"> + <Card.Root size="sm" class="p-4 flex flex-col gap-4"> + <h2 class="text-lg font-semibold m-0">订单与持仓记录</h2> + <div class="grid md:grid-cols-[1.4fr_1fr] gap-4 items-start"> <div> - <h3>成交记录 ({result.trades.length})</h3> + <h3 class="text-base font-semibold">成交记录 ({result.trades.length})</h3> {#if result.trades.length > 0} - <Table.Root> - <Table.Header> - <Table.Row> - <Table.Head>日期</Table.Head> - <Table.Head>标的</Table.Head> - <Table.Head>方向</Table.Head> - <Table.Head>数量</Table.Head> - <Table.Head>价格</Table.Head> - <Table.Head>金额</Table.Head> - <Table.Head>佣金</Table.Head> - </Table.Row> - </Table.Header> - <Table.Body> - {#each result.trades.slice(0, 80) as t, i (i)} + <div class="overflow-x-auto"> + <Table.Root> + <Table.Header> <Table.Row> - <Table.Cell>{t.date}</Table.Cell> - <Table.Cell class="num">{t.symbol}</Table.Cell> - <Table.Cell>{t.side === "buy" ? "买入" : "卖出"}</Table.Cell> - <Table.Cell class="num">{t.quantity}</Table.Cell> - <Table.Cell class="num">{fmtNum(t.price, 3)}</Table.Cell> - <Table.Cell class="num">{fmtNum(t.value)}</Table.Cell> - <Table.Cell class="num">{fmtNum(t.commission, 4)}</Table.Cell> + <Table.Head>日期</Table.Head> + <Table.Head>标的</Table.Head> + <Table.Head>方向</Table.Head> + <Table.Head>数量</Table.Head> + <Table.Head>价格</Table.Head> + <Table.Head>金额</Table.Head> + <Table.Head>佣金</Table.Head> </Table.Row> - {/each} - </Table.Body> - </Table.Root> + </Table.Header> + <Table.Body> + {#each result.trades.slice(0, 80) as t, i (i)} + <Table.Row> + <Table.Cell>{t.date}</Table.Cell> + <Table.Cell class="tabular-nums">{t.symbol}</Table.Cell> + <Table.Cell>{t.side === "buy" ? "买入" : "卖出"}</Table.Cell> + <Table.Cell class="tabular-nums">{t.quantity}</Table.Cell> + <Table.Cell class="tabular-nums">{fmtNum(t.price, 3)}</Table.Cell> + <Table.Cell class="tabular-nums">{fmtNum(t.value)}</Table.Cell> + <Table.Cell class="tabular-nums">{fmtNum(t.commission, 4)}</Table.Cell> + </Table.Row> + {/each} + </Table.Body> + </Table.Root> + </div> {#if result.trades.length > 80} - <p class="hint">仅显示前 80 条,共 {result.trades.length} 条</p> + <p class="text-sm text-muted-foreground">仅显示前 80 条,共 {result.trades.length} 条</p> {/if} {:else} - <p class="hint">本区间未发生成交</p> + <p class="text-sm text-muted-foreground">本区间未发生成交</p> {/if} </div> <div> - <h3>策略日志</h3> + <h3 class="text-base font-semibold">策略日志</h3> <Button variant="outline" size="sm" onclick={() => (logsOpen = !logsOpen)} aria-expanded={logsOpen}> {logsOpen ? "收起日志" : `展开日志(${result.logs.length} 行)`} </Button> {#if logsOpen} - <pre class="logs">{result.logs.join("\n")}</pre> + <pre class="font-mono text-sm p-2 bg-muted rounded-md overflow-x-auto whitespace-pre-wrap break-all mt-2">{result.logs.join("\n")}</pre> {/if} </div> </div> <div> - <h3>数据与引擎 - <span class="hint">本轮使用的引擎版本、耗时与消耗内存均在上方备注。</span> + <h3 class="text-base font-semibold"> + 数据与引擎 + <span class="text-sm text-muted-foreground font-normal">本轮使用的引擎版本、耗时与消耗内存均在上方备注。</span> </h3> - <p class="hint"> + <p class="text-sm text-muted-foreground"> 结果由对应策略代码、配置、数据清单(数据哈希 {result.data_manifest_hash?.slice(0, 12)})以及当时引擎共同决定。 </p> </div> |
