summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--frontend/components.json2
-rw-r--r--frontend/src/app.css291
-rw-r--r--frontend/src/lib/mobileCss.test.ts131
-rw-r--r--frontend/src/lib/resultsAccounting.test.ts6
-rw-r--r--frontend/src/lib/theme.ts28
-rw-r--r--frontend/src/lib/utils.ts7
-rw-r--r--frontend/src/pages/ProjectResultsTab.svelte165
7 files changed, 221 insertions, 409 deletions
diff --git a/frontend/components.json b/frontend/components.json
index f385b1d..b8678aa 100644
--- a/frontend/components.json
+++ b/frontend/components.json
@@ -2,7 +2,7 @@
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/app.css",
- "baseColor": "neutral"
+ "baseColor": "mist"
},
"aliases": {
"components": "$lib/components",
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>