diff options
Diffstat (limited to 'frontend')
58 files changed, 8982 insertions, 0 deletions
diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..f394960 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ +<!doctype html> +<html lang="zh-CN"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <meta name="color-scheme" content="light only" /> + <title>策研 Strategy Lab</title> + </head> + <body> + <div id="app"></div> + <script type="module" src="/src/main.ts"></script> + </body> +</html> diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..d2f0b45 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2979 @@ +{ + "name": "strategy-lab-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "strategy-lab-frontend", + "version": "0.1.0", + "dependencies": { + "@codemirror/lang-python": "^6.1.7", + "@codemirror/merge": "^6.7.3", + "@codemirror/state": "^6.5.2", + "@codemirror/view": "^6.36.4", + "codemirror": "^6.0.1", + "diff": "^7.0.0", + "echarts": "^5.6.0" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@testing-library/svelte": "^5.2.7", + "@tsconfig/svelte": "^5.0.8", + "@types/diff": "^7.0.2", + "jsdom": "^26.1.0", + "playwright": "^1.63.0", + "svelte": "^5.19.0", + "svelte-check": "^4.1.4", + "typescript": "^5.7.3", + "vite": "^6.3.5", + "vitest": "^3.2.4" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmmirror.com/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.11.1", + "resolved": "https://registry.npmmirror.com/@codemirror/commands/-/commands-6.11.1.tgz", + "integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-python": { + "version": "6.2.1", + "resolved": "https://registry.npmmirror.com/@codemirror/lang-python/-/lang-python-6.2.1.tgz", + "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.3.2", + "@codemirror/language": "^6.8.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/python": "^1.1.4" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmmirror.com/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmmirror.com/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/merge": { + "version": "6.12.2", + "resolved": "https://registry.npmmirror.com/@codemirror/merge/-/merge-6.12.2.tgz", + "integrity": "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/highlight": "^1.0.0", + "style-mod": "^4.1.0" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.2", + "resolved": "https://registry.npmmirror.com/@codemirror/search/-/search-6.7.2.tgz", + "integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.5", + "resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-6.7.5.tgz", + "integrity": "sha512-QjLbZmY1Au3JiRrDVYFLRD0BZ3SOKS9pR3yjIkd7u27YY8TFD9/Q9fhPnLV5l1mHFSo3hHU/N31vpwEJOx4owQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.12", + "resolved": "https://registry.npmmirror.com/@codemirror/view/-/view-6.43.12.tgz", + "integrity": "sha512-Nv0vxQ19NAqvB/c2pFzjIzFlzzJl7jmdtNkwOwGbn0Ks9mFAzibvumz7cQem5cRsFA2cEw2fg+uHZGbcHupLQQ==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmmirror.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmmirror.com/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmmirror.com/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/python": { + "version": "1.1.19", + "resolved": "https://registry.npmmirror.com/@lezer/python/-/python-1.1.19.tgz", + "integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz", + "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.3.tgz", + "integrity": "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.3.tgz", + "integrity": "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.3.tgz", + "integrity": "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.3.tgz", + "integrity": "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.3.tgz", + "integrity": "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.3.tgz", + "integrity": "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.3.tgz", + "integrity": "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.3.tgz", + "integrity": "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.3.tgz", + "integrity": "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.3.tgz", + "integrity": "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.3.tgz", + "integrity": "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.3.tgz", + "integrity": "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.3.tgz", + "integrity": "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.3.tgz", + "integrity": "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.3.tgz", + "integrity": "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.3.tgz", + "integrity": "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.3.tgz", + "integrity": "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.3.tgz", + "integrity": "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.3.tgz", + "integrity": "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.3.tgz", + "integrity": "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.3.tgz", + "integrity": "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.3.tgz", + "integrity": "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.3.tgz", + "integrity": "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.3.tgz", + "integrity": "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.3.tgz", + "integrity": "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.13", + "resolved": "https://registry.npmmirror.com/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.3", + "resolved": "https://registry.npmmirror.com/@sveltejs/load-config/-/load-config-0.2.3.tgz", + "integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", + "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", + "debug": "^4.4.1", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.17", + "vitefu": "^1.0.6" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", + "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmmirror.com/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/svelte": { + "version": "5.4.2", + "resolved": "https://registry.npmmirror.com/@testing-library/svelte/-/svelte-5.4.2.tgz", + "integrity": "sha512-4o31E4HGo5BU5KwPkulNRocEden+7Tt9JYm9uhln5ajF7DULeyFA46BBWVfKJ8Ms9B3JmOFPTIiVamH7n3KpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "9.x.x || 10.x.x", + "@testing-library/svelte-core": "1.1.3" + }, + "engines": { + "node": ">= 10" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", + "vite": "*", + "vitest": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/svelte-core": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@testing-library/svelte-core/-/svelte-core-1.1.3.tgz", + "integrity": "sha512-KkMAvXeWorxN2Yn0kdC1lfoAItxpoj4uOWzxK5leDrNxonLvS5nwBFvztrroyTszQ0Wf/EU6iLT8JhY5qcn22g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" + } + }, + "node_modules/@tsconfig/svelte": { + "version": "5.0.8", + "resolved": "https://registry.npmmirror.com/@tsconfig/svelte/-/svelte-5.0.8.tgz", + "integrity": "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmmirror.com/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/diff": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/@types/diff/-/diff-7.0.2.tgz", + "integrity": "sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmmirror.com/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmmirror.com/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devalue": { + "version": "5.9.2", + "resolved": "https://registry.npmmirror.com/devalue/-/devalue-5.9.2.tgz", + "integrity": "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.3.7", + "resolved": "https://registry.npmmirror.com/esrap/-/esrap-2.3.7.tgz", + "integrity": "sha512-n2nf7fZR3c9yXf0BPEuHuXqT+KW0SJVj4cN5FMEkpCZ3scLjOQWpiccyCxVzCC2q1wubTghuEGzngJY/7Ah0Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmmirror.com/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmmirror.com/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmmirror.com/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nwsapi": { + "version": "2.2.27", + "resolved": "https://registry.npmmirror.com/nwsapi/-/nwsapi-2.2.27.tgz", + "integrity": "sha512-gQPNF78qebCQ6tvVFBYrvJdBNOrYZm90ZlXgpIFm06p6qHDHq/XC4TnJftN6OMbxVE0UTBAoRgcsDeJBBooITw==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rollup": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.63.3.tgz", + "integrity": "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.3", + "@rollup/rollup-android-arm64": "4.63.3", + "@rollup/rollup-darwin-arm64": "4.63.3", + "@rollup/rollup-darwin-x64": "4.63.3", + "@rollup/rollup-freebsd-arm64": "4.63.3", + "@rollup/rollup-freebsd-x64": "4.63.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.3", + "@rollup/rollup-linux-arm-musleabihf": "4.63.3", + "@rollup/rollup-linux-arm64-gnu": "4.63.3", + "@rollup/rollup-linux-arm64-musl": "4.63.3", + "@rollup/rollup-linux-loong64-gnu": "4.63.3", + "@rollup/rollup-linux-loong64-musl": "4.63.3", + "@rollup/rollup-linux-ppc64-gnu": "4.63.3", + "@rollup/rollup-linux-ppc64-musl": "4.63.3", + "@rollup/rollup-linux-riscv64-gnu": "4.63.3", + "@rollup/rollup-linux-riscv64-musl": "4.63.3", + "@rollup/rollup-linux-s390x-gnu": "4.63.3", + "@rollup/rollup-linux-x64-gnu": "4.63.3", + "@rollup/rollup-linux-x64-musl": "4.63.3", + "@rollup/rollup-openbsd-x64": "4.63.3", + "@rollup/rollup-openharmony-arm64": "4.63.3", + "@rollup/rollup-win32-arm64-msvc": "4.63.3", + "@rollup/rollup-win32-ia32-msvc": "4.63.3", + "@rollup/rollup-win32-x64-gnu": "4.63.3", + "@rollup/rollup-win32-x64-msvc": "4.63.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmmirror.com/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmmirror.com/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmmirror.com/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, + "node_modules/svelte": { + "version": "5.57.0", + "resolved": "https://registry.npmmirror.com/svelte/-/svelte-5.57.0.tgz", + "integrity": "sha512-NdbDn7fl4be1ViUG0oq/lvG6OZy3oENolV2ONjiqqsfVoeAfzaQAKUcEX3MrQod/Bebv1PgwET9rfXhgn9s4Kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.6", + "resolved": "https://registry.npmmirror.com/svelte-check/-/svelte-check-4.7.6.tgz", + "integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.3", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/svelte/node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmmirror.com/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmmirror.com/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmmirror.com/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/zimmerframe": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/zimmerframe/-/zimmerframe-1.1.5.tgz", + "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..a60fa56 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,39 @@ +{ + "name": "strategy-lab-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-check --tsconfig ./tsconfig.json", + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@testing-library/svelte": "^5.2.7", + "@tsconfig/svelte": "^5.0.8", + "@types/diff": "^7.0.2", + "jsdom": "^26.1.0", + "playwright": "^1.63.0", + "svelte": "^5.19.0", + "svelte-check": "^4.1.4", + "typescript": "^5.7.3", + "vite": "^6.3.5", + "vitest": "^3.2.4" + }, + "dependencies": { + "@codemirror/lang-python": "^6.1.7", + "@codemirror/merge": "^6.7.3", + "@codemirror/state": "^6.5.2", + "@codemirror/view": "^6.36.4", + "codemirror": "^6.0.1", + "diff": "^7.0.0", + "echarts": "^5.6.0" + }, + "allowScripts": { + "[email protected]": true + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..9f6109a --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#0f766e"/><path d="M8 22V13M13.5 22V9M19 22v-8M24.5 22v-5" stroke="#f0fdfa" stroke-width="2.6" stroke-linecap="round"/></svg> diff --git a/frontend/scripts/browser-smoke.mjs b/frontend/scripts/browser-smoke.mjs new file mode 100644 index 0000000..6afc2f4 --- /dev/null +++ b/frontend/scripts/browser-smoke.mjs @@ -0,0 +1,49 @@ +/** + * Browser smoke for 策研 Strategy Lab frontend. + * Usage: PLAYWRIGHT_BROWSERS_PATH=... node scripts/browser-smoke.mjs [baseURL] + * Requires the Vite dev server (frontend/ npm run dev, /api proxying to backend). + */ +import { chromium } from 'playwright'; + +const base = process.argv[2] ?? 'http://127.0.0.1:8788'; + +const browser = await chromium.launch(); +const page = await browser.newPage(); +const pageErrors = []; +page.on('pageerror', (e) => pageErrors.push(String(e))); +page.on('response', (r) => { + if (r.status() >= 500) pageErrors.push(`${r.status} ${r.url()}`); +}); + +await page.goto(`${base}/#/login`, { waitUntil: 'networkidle' }); +const t1 = await page.evaluate(() => document.body.innerText); +if (!t1.includes('邮箱') || !t1.includes('登录')) throw new Error(`login page missing content: ${t1.slice(0, 100)}`); + +await page.goto(`${base}/#/register`, { waitUntil: 'networkidle' }); +if (!(await page.$('form'))) throw new Error('register form missing'); + +await page.goto(`${base}/#/reset`, { waitUntil: 'networkidle' }); +if ((await page.$$eval('input', (els) => els.length)) < 3) throw new Error('reset inputs missing'); + +// wrong password flow must NOT navigate to app, must render error banner +await page.goto(`${base}/#/login`, { waitUntil: 'networkidle' }); +await page.fill('#le', '[email protected]'); +await page.fill('#lp', 'wrong-password'); +await page.click('button[type="submit"]'); +await page.waitForTimeout(1200); +const t2 = await page.evaluate(() => document.body.innerText); +if (!t2.includes('不正确') && !t2.includes('HTTP') && !t2.includes('出错')) { + throw new Error(`expected visible login error, got: ${t2.slice(0, 160)}`); +} + +for (const h of ['projects', 'datasets', 'runs', 'usage', 'account', 'admin']) { + await page.goto(`${base}/#/${h}`, { waitUntil: 'networkidle' }); + const has = await page.evaluate(() => document.body.innerText.length > 50); + if (!has) throw new Error(`route ${h} rendered empty`); +} + +if (pageErrors.length > 0) { + throw new Error(`page errors: ${pageErrors.slice(0, 5).join('; ')}`); +} +console.log('BROWSER SMOKE OK', { pageErrors: pageErrors.length }); +await browser.close(); diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte new file mode 100644 index 0000000..ae70ba4 --- /dev/null +++ b/frontend/src/App.svelte @@ -0,0 +1,71 @@ +<script lang="ts"> + import { onMount } from 'svelte'; + import type { Route } from './lib/router'; + import { routeFromHash, replace } from './lib/router'; + import { session } from './lib/session.svelte'; + import Layout from './components/Layout.svelte'; + import AuthPages from './pages/AuthPages.svelte'; + import ProjectsPage from './pages/ProjectsPage.svelte'; + import ProjectPage from './pages/ProjectPage.svelte'; + import DatasetsPage from './pages/DatasetsPage.svelte'; + import RunsPage from './pages/RunsPage.svelte'; + import UsagePage from './pages/UsagePage.svelte'; + import AccountPage from './pages/AccountPage.svelte'; + import AdminPage from './pages/AdminPage.svelte'; + import Loading from './components/Loading.svelte'; + + let route = $state<Route>(routeFromHash()); + + window.addEventListener('hashchange', () => { + route = routeFromHash(); + }); + + const publicRoutes = new Set(['login', 'register', 'reset']); + const projectRoutes = new Set(['project-data', 'project-strategy', 'project-backtest', 'project-results']); + + onMount(() => { + void session.refresh(); + }); + + $effect(() => { + if (publicRoutes.has(route.name)) return; + if (session.status === 'anon') { + replace('/login'); + return; + } + if (session.me && route.name === 'admin' && session.me.role !== 'admin') { + replace('/projects'); + } + }); + + const showAuth = $derived(publicRoutes.has(route.name) || session.status === 'anon'); + const booting = $derived(session.status === 'loading' && !publicRoutes.has(route.name)); +</script> + +<div class="app-root"> + {#if booting} + <Loading /> + {:else if showAuth} + <AuthPages {route} /> + {:else} + <Layout {route}> + {#if route.name === 'projects'} + <ProjectsPage /> + {:else if projectRoutes.has(route.name)} + <ProjectPage {route} /> + {:else if route.name === 'datasets'} + <DatasetsPage /> + {:else if route.name === 'runs'} + <RunsPage /> + {:else if route.name === 'usage'} + <UsagePage /> + {:else if route.name === 'account'} + <AccountPage /> + {:else if route.name === 'admin'} + <AdminPage /> + {:else} + <ProjectsPage /> + {/if} + </Layout> + {/if} +</div> diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..c9ed4be --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,249 @@ +:root { + --bg: #f7fafb; + --surface: #ffffff; + --surface-2: #f1f5f6; + --border: #e2e8ea; + --border-strong: #cbd5d8; + --text: #1e2a30; + --text-2: #4b5a62; + --text-3: #7b8a92; + --teal: #0d9488; + --teal-strong: #0f766e; + --teal-soft: #e6f4f2; + --ink: #12312d; + --danger: #b91c1c; + --danger-soft: #fdf2f2; + --warn: #92400e; + --warn-soft: #fff9ec; + --ok: #15803d; + --ok-soft: #ecfdf3; + --radius: 10px; + --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; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--text); + font-family: var(--font); + font-size: 14.5px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; +} + +h1, h2, h3, h4 { font-weight: 600; margin: 0; } +h1 { font-size: 1.35rem; } +h2 { font-size: 1.1rem; } +h3 { font-size: 1rem; } +a { color: var(--teal-strong); text-decoration: none; } +a:hover { text-decoration: underline; } +p { margin: 0.4rem 0; } + +button, input, select, textarea { font: inherit; color: inherit; } + +.btn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.45rem 0.95rem; + border-radius: var(--radius); + border: 1px solid var(--border-strong); + background: var(--surface); + color: var(--text); + cursor: pointer; + font-size: 0.92rem; + transition: background 0.12s, border-color 0.12s, box-shadow 0.12s; + white-space: nowrap; +} +.btn:hover:not(:disabled) { background: var(--surface-2); } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } +.btn:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, a:focus-visible { + outline: 2px solid var(--teal); + outline-offset: 1px; +} +.btn.primary { + background: var(--teal-strong); + border-color: var(--teal-strong); + color: #fff; +} +.btn.primary:hover:not(:disabled) { background: #115e59; } +.btn.danger { color: var(--danger); border-color: #f2c7c7; } +.btn.danger:hover:not(:disabled) { background: var(--danger-soft); } +.btn.ghost { border-color: transparent; background: transparent; } +.btn.ghost:hover:not(:disabled) { background: var(--surface-2); } +.btn.small { padding: 0.25rem 0.6rem; font-size: 0.83rem; } +.btn[aria-pressed='true'] { background: var(--teal-soft); border-color: var(--teal); color: var(--ink); } + +label { display: block; font-size: 0.84rem; color: var(--text-2); font-weight: 500; margin-bottom: 0.25rem; } +input, select, textarea.plain { + width: 100%; + padding: 0.45rem 0.65rem; + border: 1px solid var(--border-strong); + border-radius: 8px; + background: var(--surface); + font-size: 0.92rem; +} +input[type='checkbox'], input[type='radio'] { width: auto; } + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem 1.15rem; + box-shadow: var(--shadow); +} +.card + .card { margin-top: 0.9rem; } + +.hint { color: var(--text-3); font-size: 0.83rem; } + +table.data { + width: 100%; + border-collapse: collapse; + font-size: 0.88rem; +} +table.data th { + text-align: left; + font-weight: 600; + color: var(--text-2); + border-bottom: 1px solid var(--border-strong); + padding: 0.45rem 0.6rem; + white-space: nowrap; +} +table.data td { + border-bottom: 1px solid var(--border); + padding: 0.42rem 0.6rem; + vertical-align: top; +} +table.data tr:hover td { background: var(--surface-2); } +.num { font-family: var(--mono); font-variant-numeric: tabular-nums; } + +.badge { + display: inline-block; + padding: 0.1rem 0.5rem; + border-radius: 999px; + font-size: 0.76rem; + font-weight: 600; + border: 1px solid transparent; +} +.badge.teal { background: var(--teal-soft); color: var(--teal-strong); } +.badge.grey { background: var(--surface-2); color: var(--text-2); border-color: var(--border); } +.badge.warn { background: var(--warn-soft); color: var(--warn); border-color: #f0ddb4; } +.badge.danger { background: var(--danger-soft); color: var(--danger); border-color: #f2c7c7; } +.badge.ok { background: var(--ok-soft); color: var(--ok); border-color: #c3ecd0; } + +.banner { + border-radius: var(--radius); + padding: 0.65rem 0.9rem; + font-size: 0.9rem; + border: 1px solid; + display: flex; + gap: 0.6rem; + align-items: flex-start; +} +.banner.error { background: var(--danger-soft); border-color: #f2c7c7; color: var(--danger); } +.banner.warn { background: var(--warn-soft); border-color: #f0ddb4; color: var(--warn); } +.banner.info { background: var(--teal-soft); border-color: #c8eae6; color: var(--teal-strong); } + +.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; } + +.tabs { + display: flex; + gap: 0.25rem; + border-bottom: 1px solid var(--border); +} +.tabs button { + padding: 0.55rem 1rem; + border: none; + background: none; + color: var(--text-2); + font-size: 0.95rem; + cursor: pointer; + border-bottom: 2px solid transparent; + border-radius: 6px 6px 0 0; +} +.tabs button:hover { background: var(--surface-2); } +.tabs button[aria-selected='true'] { + color: var(--teal-strong); + font-weight: 600; + border-bottom-color: var(--teal); +} + +/* metric tiles shared by the results card and other metric rows; without + these the six result metrics stacked vertically with large blank space */ +.metric-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 0.7rem; +} +.metric { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.6rem 0.85rem; + min-width: 0; +} +.mlabel { color: var(--text-3); font-size: 0.8rem; } +.mvalue { + font-size: 1.15rem; + font-weight: 700; + color: var(--text); + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} +.cols-2 { display: grid; grid-template-columns: 1.4fr 1fr; gap: 1rem; align-items: start; } +.table-wrap { overflow-x: auto; min-width: 0; } + +@media (max-width: 900px) { + .cols-2 { grid-template-columns: minmax(0, 1fr); } + .metric-grid { grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); } +} + +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 (no global content hiding) ---- + Real provider error strings contain long unbreakable URLs; .spread/.stack + children would otherwise propagate min-content outwards and inflate the + document width on mobile. `overflow-wrap` keeps every word reachable while + wrapping; nothing is hidden — long tables get a local scroll container. */ +.card, .stack, .row, .spread, .banner { min-width: 0; } +.banner { overflow-wrap: anywhere; } +.banner .err-list { overflow-wrap: anywhere; } +.card table.data { min-width: 0; } + +@media (max-width: 900px) { + /* collapse multi-column forms: the dataset wizard grid3 + label min-widths + measured 523px min-content at 375px viewport (mobile-overflow-probe) */ + .grid2, .grid3 { grid-template-columns: minmax(0, 1fr); } + /* wrap table cells instead of nowrap inflating min-content; a truly wide + tabular block still gets its own local scrollbar (.scrollx), content + remains fully accessible without a document-level horizontal overflow */ + table.data th, table.data td { + white-space: normal; + overflow-wrap: anywhere; + } + .btn { white-space: normal; } +} + + diff --git a/frontend/src/components/Chart.svelte b/frontend/src/components/Chart.svelte new file mode 100644 index 0000000..ca8f4f8 --- /dev/null +++ b/frontend/src/components/Chart.svelte @@ -0,0 +1,67 @@ +<script lang="ts"> + import { onMount, onDestroy } from 'svelte'; + import type * as echartsNS from 'echarts'; + + interface Props { + option: echartsNS.EChartsOption; + height?: number; + ariaLabel?: string; + } + let { option, height = 360, ariaLabel = '图表' }: Props = $props(); + + let host: HTMLDivElement; + let chart: echartsNS.ECharts | null = null; + let observer: ResizeObserver | null = null; + + onMount(async () => { + // ECharts chunk loads lazily when a result/comparison view is opened. + const echarts = (await import('echarts')) as typeof echartsNS; + if (!host) return; + // Pass explicit sizes when the host has no layout yet (jsdom regression + // tests report 0×0 and ECharts warns "Can't get DOM width or height"). + // In a real browser clientWidth/clientHeight are non-zero, so the measured + // size is used exactly as before; responsive resizing still works through + // the ResizeObserver below. + chart = echarts.init(host, undefined, { + renderer: 'svg', + width: host.clientWidth || 640, + height: host.clientHeight || height + }); + observer = new ResizeObserver(() => { + if (!chart) return; + // re-measure on EVERY resize callback and pass the measured size + // explicitly: echarts keeps the width given at init/resize, so a + // desktop->mobile viewport change would otherwise leave the SVG at the + // old width and overflow the page (real, re-verified live bug). + const w = host.clientWidth || undefined; + const h = host.clientHeight || undefined; + if (w === undefined && h === undefined) return; + chart.resize({ width: w, height: h }); + }); + observer.observe(host); + chart?.setOption(option, { notMerge: true }); + }); + + onDestroy(() => { + observer?.disconnect(); + chart?.dispose(); + chart = null; + }); + + $effect(() => { + chart?.setOption(option, { notMerge: true }); + }); +</script> + +<div + bind:this={host} + style="height:{height}px; width:100%; min-width:0;" + role="img" + aria-label={ariaLabel} +></div> + +<style> + div { + display: block; + } +</style> diff --git a/frontend/src/components/CodeEditor.svelte b/frontend/src/components/CodeEditor.svelte new file mode 100644 index 0000000..544a6a4 --- /dev/null +++ b/frontend/src/components/CodeEditor.svelte @@ -0,0 +1,104 @@ +<script lang="ts"> + import { onMount, onDestroy } from 'svelte'; + import { EditorView, keymap, drawSelection } from '@codemirror/view'; + import { EditorState, StateEffect } from '@codemirror/state'; + import { python } from '@codemirror/lang-python'; + import { indentWithTab } from '@codemirror/commands'; + import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; + + interface Props { + value: string; + onInput: (v: string) => void; + height?: number; + readOnly?: boolean; + label?: string; + } + let { value, onInput, height = 420, readOnly = false, label }: Props = $props(); + + let host: HTMLDivElement; + let view: EditorView | null = null; + let applyingExternal = false; + // svelte-ignore state_referenced_locally + let handler = onInput; + $effect(() => { + handler = onInput; + }); + + const setDoc = StateEffect.define<string>(); + + onMount(() => { + view = new EditorView({ + parent: host, + state: EditorState.create({ + doc: value ?? '', + extensions: [ + readOnly ? EditorView.editable.of(false) : [], + history(), + drawSelection(), + keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]), + python(), + EditorView.lineWrapping, + EditorView.theme({ + '&': { + fontSize: '13px', + background: 'var(--surface)', + color: 'var(--text)', + fontFamily: + "ui-monospace, 'SF Mono', Menlo, Consolas, 'Noto Sans Mono CJK SC', monospace" + }, + '.cm-content': { padding: '10px 0', minHeight: `${height}px` }, + '.cm-scroller': { lineHeight: '1.55' }, + '&.cm-focused': { outline: 'none' }, + '.cm-gutters': { + background: 'var(--surface)', + border: 'none', + color: 'var(--text-3)' + }, + '.cm-activeLine': { background: 'var(--surface-2)' }, + '.cm-activeLineGutter': { background: 'var(--surface-2)' } + }), + EditorView.updateListener.of((u) => { + if (applyingExternal) return; + if (u.docChanged) handler(u.state.doc.toString()); + }) + ] + }) + }); + return () => view?.destroy(); + }); + + $effect(() => { + if (!view) return; + const current = view.state.doc.toString(); + if (value !== current) { + applyingExternal = true; + view.dispatch({ changes: { from: 0, to: current.length, insert: value ?? '' } }); + applyingExternal = false; + } + }); + + $effect(() => { + if (view) { + if (readOnly) view.contentDOM.setAttribute('aria-readonly', 'true'); + else view.contentDOM.removeAttribute('aria-readonly'); + } + }); + + onDestroy(() => view?.destroy()); +</script> + +<div> + {#if label}<span class="hint editor-label">{label}</span>{/if} + <div bind:this={host} class="cm-host"></div> +</div> + +<style> + .cm-host { + border: 1px solid var(--border-strong); + border-radius: var(--radius); + overflow: hidden; + background: var(--surface); + } + .cm-host :global(.cm-editor) { min-height: 60px; } + .editor-label { display: block; margin-bottom: 0.25rem; } +</style> diff --git a/frontend/src/components/DatasetCard.svelte b/frontend/src/components/DatasetCard.svelte new file mode 100644 index 0000000..7b38874 --- /dev/null +++ b/frontend/src/components/DatasetCard.svelte @@ -0,0 +1,157 @@ +<script lang="ts"> + import Status from './Status.svelte'; + import { getDataset, getDatasetPreview } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import { adjustmentLabel, assetTypeLabel, instrumentLabel, datasetStatusLabel, fmtDate } from '../lib/format'; + import { summarizeCoverage } from '../lib/coverage'; + import type { Dataset, DatasetPreview } from '../lib/types'; + + interface Props { + dsId: string; + } + let { dsId }: Props = $props(); + + let ds = $state<Dataset | null>(null); + let error: string | null = $state(null); + let busy = $state(true); + let showPreview = $state(false); + let preview = $state<DatasetPreview | null>(null); + let previewBusy = $state(false); + let previewError: string | null = $state(null); + let pollTimer: ReturnType<typeof setInterval> | null = null; + + function stopPolling() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + } + + $effect(() => { + void load(); + return stopPolling; + }); + + async function load() { + error = null; + try { + ds = await getDataset(dsId); + if (ds.status === 'pending' || ds.status === 'running') { + if (!pollTimer) { + pollTimer = setInterval(() => { + void load(); + }, 2000); + } + } else { + stopPolling(); + } + } catch (e) { + error = extractStatusMessage(e); + } finally { + busy = false; + } + } + + async function togglePreview() { + showPreview = !showPreview; + if (showPreview && !preview && ds?.status === 'ready') { + previewBusy = true; + previewError = null; + try { + preview = await getDatasetPreview(dsId); + } catch (e) { + previewError = `预览加载失败:${extractStatusMessage(e)}`; + } finally { + previewBusy = false; + } + } + } +</script> + +<div class="card"> + {#if ds} + <div class="spread"> + <div> + <strong>{ds.name ?? '未命名数据集'}</strong> + {#if ds.manifest_hash}<span class="hint mono">manifest {ds.manifest_hash.slice(0, 10)}…</span>{/if} + {#if ds.cache_hit}<span class="badge teal">缓存命中 · 复用现有物化对象</span>{/if} + </div> + <span class="badge {ds.status === 'ready' ? 'ok' : ds.status === 'failed' ? 'danger' : 'warn'}"> + {datasetStatusLabel[ds.status]} + </span> + </div> + <table class="data kv"> + <tbody> + <tr><th>请求标的</th><td>{(ds.request.instruments ?? []).map(instrumentLabel).join(';')}</td></tr> + <tr><th>范围</th><td>{ds.request.start_date} ~ {ds.request.end_date} · {ds.request.frequency} · {adjustmentLabel[ds.request.adjustment] ?? ds.request.adjustment}</td></tr> + <tr><th>字段</th><td>{(ds.request.fields ?? []).join(', ')}</td></tr> + <tr><th>创建时间</th><td>{fmtDate(ds.created_at)}</td></tr> + </tbody> + </table> + {#if ds.status === 'failed'} + <div class="banner error" role="alert"><span aria-hidden="true">✕</span><span>{ds.error ?? '数据获取失败'}</span></div> + {/if} + {#if ds.status !== 'ready'} + <p class="hint">{ds.status === 'pending' || ds.status === 'running' ? '获取中,完成后可预览覆盖情况' : ''}</p> + {/if} + {#if ds.status === 'ready'} + <div> + <button class="btn small" aria-expanded={showPreview} onclick={togglePreview}> + {showPreview ? '收起预览' : '查看数据覆盖预览'} + </button> + </div> + {#if showPreview} + <div class="preview-box"> + <Status busy={previewBusy} busyText="加载预览…" error={previewError} empty={null} /> + {#if preview} + {@const cov = summarizeCoverage(preview.coverage ?? [])} + {#if cov.warnings.length > 0} + <div class="banner warn"><span aria-hidden="true">⚠</span><div> + <div class="cov-caption">覆盖差异(共有区间 {cov.common.start ?? '—'} ~ {cov.common.end ?? '—'}):</div> + <ul class="cov-list"> + {#each cov.warnings as w, i (i)}<li>{w}</li>{/each} + </ul> + {#if (preview.coverage ?? []).some((c) => c.warnings)} + <ul class="cov-list"> + {#each preview.coverage.filter((c) => c.warnings) as c (c.instrument)} + <li>{c.instrument}:{(c.warnings ?? []).join(';')}</li> + {/each} + </ul> + {/if} + </div></div> + {:else if cov.common.start} + <div class="banner info"> + <span aria-hidden="true">✓</span> + <span>覆盖平稳:共有可用区间 {cov.common.start} ~ {cov.common.end}。可以直接用于回测。</span> + </div> + {/if} + <table class="data scrollx"> + <thead> + <tr>{#each preview.columns as c (c)}<th>{c}</th>{/each}</tr> + </thead> + <tbody> + {#each preview.rows as row, i (i)} + <tr>{#each preview.columns as c (c)}<td class="num">{row[c]}</td>{/each}</tr> + {/each} + </tbody> + </table> + {#if preview.truncated} + <p class="hint">预览仅展示部分行</p> + {/if} + {/if} + </div> + {/if} + {/if} + {:else} + <Status busy={busy} error={error} busyText="载入数据集…" empty={null} /> + {/if} +</div> + +<style> + .kv th { width: 110px; color: var(--text-2); font-weight: 500; min-width: 110px; } + .mono { font-family: var(--mono); margin-left: 0.5rem; } + .preview-box { margin-top: 0.5rem; display: grid; gap: 0.5rem; } + .scrollx { display: block; overflow-x: auto; } + .cov-caption { font-weight: 600; } + .cov-list { margin: 0.2rem 0 0 1.1rem; padding: 0; } +</style> diff --git a/frontend/src/components/DatasetWizard.svelte b/frontend/src/components/DatasetWizard.svelte new file mode 100644 index 0000000..1e61d92 --- /dev/null +++ b/frontend/src/components/DatasetWizard.svelte @@ -0,0 +1,374 @@ +<script lang="ts"> + import Status from './Status.svelte'; + import { getCapabilities, searchInstruments, createDataset } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import { adjustmentLabel, assetTypeLabel, itemSourceLabel } from '../lib/format'; + import { createSearchRunner, SEARCH_EMPTY_MSG } from '../lib/instrumentSearch'; + import type { Capabilities, Dataset, Instrument } from '../lib/types'; + + interface Props { + onCreated?: (d: Dataset) => void; + } + let { onCreated }: Props = $props(); + + let caps = $state<Capabilities | null>(null); + let loadErr: string | null = $state(null); + + let query = $state(''); + let searchBusy = $state(false); + let searchItems: Instrument[] = $state([]); + let searchSource = $state(''); + let searchErr: string | null = $state(null); + + const searchRunner = createSearchRunner(searchInstruments, { + onBusy: () => { + searchBusy = true; + searchErr = null; + searchItems = []; + }, + onResult: (res) => { + searchItems = res.items; + searchSource = res.source; + if (res.items.length === 0 && !searchErr) { + searchErr = SEARCH_EMPTY_MSG; + } + }, + onError: (message) => { + searchItems = []; + searchErr = message; + }, + onFinish: () => { + searchBusy = false; + } + }); + + function doSearch() { + searchRunner.runNow(query); + } + + function onSearchInput() { + searchRunner.requestDebounced(query); + } + + let selected: Instrument[] = $state([]); + let symbol = $state(''); + let market = $state('SH'); + let assetType = $state('stock'); + let manualName = $state(''); + + let startDate = $state(''); + let endDate = $state(''); + let adjustment: 'none' | 'qfq' | 'hfq' = $state('none'); + let chosenRaw: string[] = $state([]); + let dsName = $state(''); + + let submitErr: string | null = $state(null); + let submitting = $state(false); + let formErr: string[] = $state([]); + let formOk: string | null = $state(null); + + const maxYears = $derived(caps?.limits.max_years ?? 15); + const maxSymbols = $derived(caps?.limits.max_symbols ?? 5); + const rawFieldChoices = $derived(caps?.fields.filter((f) => f.raw) ?? []); + const baseRequired = $derived(caps?.fields.filter((f) => !f.raw) ?? []); + + $effect(() => { + (async () => { + try { + caps = await getCapabilities(); + } catch (e) { + loadErr = extractStatusMessage(e); + } + })(); + }); + + function isRaw(code: string) { + if (!caps) return false; + return !caps.fields.some((f) => f.code === code && !f.raw); + } + + function toggleRawField(code: string) { + chosenRaw = chosenRaw.includes(code) ? chosenRaw.filter((c) => c !== code) : [...chosenRaw, code]; + } + + function sameInstrument(a: Instrument, b: Instrument) { + return a.market === b.market && a.symbol === b.symbol && a.asset_type === b.asset_type; + } + + function addSelection(inst: Instrument) { + if (selected.some((s) => sameInstrument(s, inst))) { + formErr = [`${inst.market}.${inst.symbol} 已在列表中`]; + return; + } + if (selected.length >= maxSymbols) { + formErr = [`最多选择 ${maxSymbols} 个标的(能力上限)`]; + return; + } + formErr = []; + selected = [...selected, inst]; + } + + function removeSelection(target: Instrument) { + selected = selected.filter((s) => !sameInstrument(s, target)); + } + + function addManual() { + const sym = symbol.trim(); + if (!sym) { + formErr = ['请输入确切的代码,例如 600000 或 510300']; + return; + } + const inst: Instrument = { + symbol: sym, + market: market.trim().toUpperCase(), + asset_type: assetType as Instrument['asset_type'], + name: manualName.trim() || null, + currency: null + }; + addSelection(inst); + symbol = ''; + manualName = ''; + } + + async function submit() { + const errs: string[] = []; + if (selected.length === 0) errs.push('至少选择一个标的'); + if (!/^\d{4}-\d{2}-\d{2}$/.test(startDate)) errs.push('请选择开始日期'); + if (!/^\d{4}-\d{2}-\d{2}$/.test(endDate)) errs.push('请选择结束日期'); + if (/^\d{4}-\d{2}-\d{2}$/.test(startDate) && /^\d{4}-\d{2}-\d{2}$/.test(endDate)) { + const years = (new Date(endDate).getTime() - new Date(startDate).getTime()) / (365.25 * 86400000); + if (!(endDate > startDate)) errs.push('结束日期须晚于开始日期'); + else if (years > maxYears) errs.push(`范围不能超过 ${maxYears} 年(当前约 ${years.toFixed(1)} 年)`); + } + if (errs.length > 0) { + formErr = errs; + return; + } + formErr = []; + formOk = null; + submitting = true; + submitErr = null; + try { + const fields = ['open', 'high', 'low', 'close', 'volume'].concat(chosenRaw); + const ds = await createDataset({ + name: dsName.trim() || undefined, + instruments: selected, + start_date: startDate, + end_date: endDate, + frequency: 'daily', + adjustment, + fields + }); + formOk = `数据请求已提交,编号 ${ds.id.slice(0, 8)}…,可在下方列表查看获取进度`; + selected = []; + startDate = ''; + endDate = ''; + dsName = ''; + chosenRaw = []; + onCreated?.(ds); + } catch (e) { + submitErr = extractStatusMessage(e); + if ((e as { details?: { errors?: string[] } }).details?.errors) { + formErr = (e as { details: { errors: string[] } }).details.errors; + } + } finally { + submitting = false; + } + } +</script> + +<div class="card stack"> + <h2>新建数据集</h2> + + <Status busy={!caps && !loadErr} busyText="加载能力配置…" error={loadErr} empty={null} /> + + {#if caps} + <section class="stack"> + <h3 class="step-h">1) 选择标的 <span class="hint">最多 {maxSymbols} 个 · 日频 POC</span></h3> + <div class="row"> + <input + style="flex:2 1 200px" + placeholder="搜索代码或名称,如 600000、沪深300ETF" + bind:value={query} + oninput={onSearchInput} + onkeydown={(ev) => { + if (ev.key === 'Enter') doSearch(); + }} + aria-label="标的搜索" + /> + <button class="btn" disabled={searchBusy || !query.trim()} onclick={() => doSearch()}> + {searchBusy ? '搜索中…' : '搜索'} + </button> + </div> + <Status busy={searchBusy} busyText="搜索中…" error={searchErr} empty={null} /> + {#if searchErr && !searchBusy} + <button class="btn small" onclick={() => doSearch()}>重试</button> + {/if} + {#if searchItems.length > 0} + <table class="data"> + <thead> + <tr><th>代码</th><th>类型</th><th>名称</th><th>来源</th><th></th></tr> + </thead> + <tbody> + {#each searchItems as inst (inst.market + '.' + inst.symbol + inst.asset_type + '.' + (inst.source ?? 'unknown'))} + <tr> + <td class="num">{inst.market}.<strong>{inst.symbol}</strong></td> + <td>{assetTypeLabel[inst.asset_type] ?? inst.asset_type}</td> + <td>{inst.name ?? '—'}</td> + <td class="hint">{itemSourceLabel(inst.source, searchSource)}</td> + <td> + <button class="btn small" onclick={() => addSelection(inst)} disabled={inst.asset_type === 'index' && selected.some((s) => sameInstrument(s, inst))}> + {inst.asset_type === 'index' ? '加入(基准研究)' : '加入'} + </button> + </td> + </tr> + {/each} + </tbody> + </table> + {/if} + + <details> + <summary>手动录入标的(目录未收录时)</summary> + <div class="card stack manual"> + <p class="hint">仅接受确切标识,不会推断市场或类型。请与供应商页面核对后再录入。</p> + <div class="row wrap"> + <label style="flex:1 1 130px"> + 代码 + <input bind:value={symbol} placeholder="600000 / 510300 / 000300" /> + </label> + <label style="flex:0 0 110px"> + 市场 + <select bind:value={market}> + {#each ['SH', 'SZ', 'BJ'] as m (m)} + <option value={m}>{m}</option> + {/each} + </select> + </label> + <label style="flex:0 0 120px"> + 类型 + <select bind:value={assetType}> + {#each caps.asset_types as t (t)} + <option value={t}>{assetTypeLabel[t] ?? t}</option> + {/each} + </select> + </label> + <label style="flex:1 1 160px"> + 名称(可选) + <input bind:value={manualName} placeholder="与供应商页面一致" /> + </label> + </div> + <div class="row"> + <button class="btn" onclick={addManual}>加入所选</button> + </div> + </div> + </details> + </section> + + <section> + <h3 class="step-h">2) 已选标的 <span class="hint">{selected.length}/{maxSymbols}</span></h3> + {#if selected.length === 0} + <p class="hint">尚未选择标的。指数仅可作研究中枢,不可直接交易。</p> + {:else} + <ul class="chip-list"> + {#each selected as s, i (s.market + '.' + s.symbol + s.asset_type)} + <li> + <span class="num">{s.market}.{s.symbol}</span> + {#if s.name}<span class="hint">{s.name}</span>{/if} + <span class="badge grey">{assetTypeLabel[s.asset_type] ?? s.asset_type}</span> + <button class="btn small ghost" aria-label={`移除 ${s.symbol}`} onclick={() => selected.splice(i, 1)}>✕</button> + </li> + {/each} + </ul> + {/if} + </section> + + <section class="grid3 wrap-3"> + <label> + 起始日期 + <input type="date" bind:value={startDate} /> + </label> + <label> + 结束日期 + <input type="date" bind:value={endDate} /> + </label> + <label style="min-width:160px"> + 复权 + <select bind:value={adjustment}> + {#each caps.adjustments as a (a.code)} + <option value={a.code}>{a.label}</option> + {/each} + </select> + {#if adjustment !== 'none'} + <p class="hint">{adjustmentLabel[adjustment]}:以复权口径返回</p> + {/if} + <p class="hint">日频 · 周期上限 {maxYears} 年</p> + </label> + </section> + + <section> + <label for="ds-name">数据集名称(可选,留空自动生成)</label> + <input id="ds-name" bind:value={dsName} placeholder="例如:沪深300 2019-2024 前复权" /> + </section> + + <details> + <summary>附加原始字段(可选)</summary> + <p class="hint">OHLCV 必需字段由引擎要求固定获取;附加字段保持原始口径,未请求的字段不会返回。</p> + <ul class="field-list"> + {#each rawFieldChoices as f (f.code)} + <li> + <label class="check"> + <input type="checkbox" checked={chosenRaw.includes(f.code)} onchange={() => toggleRawField(f.code)} /> + {f.label} <span class="hint">(未请求不返回)</span> + </label> + </li> + {/each} + </ul> + {#if caps.fields.some((f) => !f.raw)} + <p class="hint">必需字段:{caps.fields.filter((f) => !f.raw).map((f) => f.label).join(' / ')}</p> + {/if} + </details> + + {#if formErr.length > 0} + <div class="banner error" role="alert"> + <span aria-hidden="true">⚠</span> + <ul class="err-list"> + {#each formErr as msg, i (i)}<li>{msg}</li>{/each} + </ul> + </div> + {/if} + {#if submitErr} + <div class="banner error" role="alert"> + <span aria-hidden="true">⚠</span> + <span>数据请求提交失败:{submitErr}</span> + </div> + {/if} + <p class="hint formhint">数据请求会进入队列异步获取。配置仅保存你填写的请求,不预填演示数据。</p> + <div> + <button class="btn primary" disabled={submitting || selected.length === 0} onclick={submit}> + {submitting ? '提交中…' : '提交数据请求'} + </button> + </div> + {/if} + + <Status busy={false} error={null} empty={formOk} /> +</div> + +<style> + .manual { padding: 0.8rem; margin: 0.4rem 0; } + .manual input, .manual select { margin-top: 0.25rem; } + .chip-list { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 0.4rem; } + .chip-list li { + display: inline-flex; + align-items: center; + gap: 0.4rem; + border: 1px solid var(--border-strong); + padding: 0.25rem 0.5rem; + border-radius: 999px; + background: var(--surface-2); + } + .field-list { list-style: none; padding: 0; margin: 0.3rem 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.2rem; } + .check { font-size: 0.88rem; margin: 0; display: flex; gap: 0.35rem; align-items: center; } + .step-h { font-size: 0.95rem; } + .err-list { padding-left: 1.1rem; margin: 0; list-style: disc; } + .wrap-3 { align-items: start; } +</style> diff --git a/frontend/src/components/DiffView.svelte b/frontend/src/components/DiffView.svelte new file mode 100644 index 0000000..cf0c1be --- /dev/null +++ b/frontend/src/components/DiffView.svelte @@ -0,0 +1,71 @@ +<script lang="ts"> + import { diffLines } from 'diff'; + + interface Props { + before: string; + after: string; + beforeLabel?: string; + afterLabel?: string; + maxHeight?: number; + } + let { before, after, beforeLabel = '原版本', afterLabel = '新版本', maxHeight = 480 }: Props = $props(); + + const parts = $derived(diffLines(before ?? '', after ?? '')); + + const lines = $derived.by(() => { + const out: { t: 'a' | 'd' | 'c'; text: string }[] = []; + for (const p of parts) { + const pieces = p.value.replace(/\n$/, '').split('\n'); + for (const piece of pieces) { + if (p.added) out.push({ t: 'a', text: piece }); + else if (p.removed) out.push({ t: 'd', text: piece }); + else out.push({ t: 'c', text: piece }); + } + } + return out; + }); +</script> + +<div class="diff"> + <div class="legend"> + <span><i class="sw a"></i>新增({afterLabel})</span> + <span><i class="sw d"></i>删除({beforeLabel})</span> + </div> + <!-- svelte-ignore a11y_no_noninteractive_tabindex --> + <div class="body" style="max-height:{maxHeight}px" tabindex="0" aria-label="代码差异" role="region"> + {#each lines as l, i (i)} + {#if l.t === 'a'} + <span class="ln add" aria-hidden="true"></span><span class="code add">+{l.text}</span> + {:else if l.t === 'd'} + <span class="ln del" aria-hidden="true"></span><span class="code del">−{l.text}</span> + {:else} + <span class="ln" aria-hidden="true"> </span><span class="code">{l.text}</span> + {/if} + {/each} + </div> +</div> + +<style> + .diff .legend { + color: var(--text-3); + font-size: 0.8rem; + margin-bottom: 0.4rem; + display: flex; + gap: 1rem; + } + .sw { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 4px; } + .sw.a { background: #c7f0e6; } + .sw.d { background: #fbd4d0; } + .body { + overflow: auto; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.5rem 0.7rem; + } + .body .code { font-family: var(--mono); font-size: 12.5px; white-space: pre-wrap; word-break: break-all; display: inline-block; width: 100%; } + .add { background: #eafaf5; color: #14532d; } + .del { background: #fdf0ee; color: #7f1d1d; } + .ln { user-select: none; } + .code.add::before, .code.del::before { opacity: 0.6; } +</style> diff --git a/frontend/src/components/Layout.svelte b/frontend/src/components/Layout.svelte new file mode 100644 index 0000000..78cee45 --- /dev/null +++ b/frontend/src/components/Layout.svelte @@ -0,0 +1,241 @@ +<script lang="ts"> + import type { Route } from '../lib/router'; + import { go } from '../lib/router'; + import { session } from '../lib/session.svelte'; + import { postLogout } from '../lib/client'; + import { projectOf, projectInvisible, projectsStore } from '../lib/listsStore.svelte'; + + interface Props { + route: Route; + children?: import('svelte').Snippet; + } + let { route, children }: Props = $props(); + + const topLeft: { name: string; label: string }[] = [ + { name: 'projects', label: '我的项目' }, + { name: 'datasets', label: '数据集' }, + { name: 'runs', label: '实验记录' }, + { name: 'usage', label: 'AI 用量' } + ]; + + const bottom: { name: string; label: string }[] = + session.me?.role === 'admin' + ? [ + { name: 'account', label: '账户与安全' }, + { name: 'admin', label: '管理员' } + ] + : [{ name: 'account', label: '账户与安全' }]; + + async function logout() { + await postLogout().catch(() => undefined); + session.set(null); + // drop cached projects/failure marks: the next session on this browser + // must not see (or be marked invisible by) the previous user's projects + projectsStore.clear(); + go('/login'); + } + + const isProject = $derived(route.name.startsWith('project-')); + const projectTab = $derived(isProject ? route.name.replace('project-', '') : ''); + const projectNav: [string, string][] = [ + ['data', '数据'], + ['strategy', '策略'], + ['backtest', '回测'], + ['results', '结果'] + ]; + const activeProject = $derived(isProject ? projectOf(route.params.id) : null); + const projectHidden = + $derived(isProject && !activeProject && projectInvisible(route.params.id)); + // Deep link / first navigation into a project tab: the sidebar store may not + // contain the project yet (ProjectsPage was never visited). Fetch it once so + // the 当前项目 name resolves without waiting for the tab's own lifecycle. + $effect(() => { + const id = isProject ? route.params.id : null; + if (id && !activeProject) void projectsStore.ensure(id).catch(() => undefined); + }); +</script> + +<div class="layout"> + <aside class="sidebar" aria-label="全局导航"> + <a href="#/projects" class="brand"> + <span class="brand-mark" aria-hidden="true"></span> + <span class="brand-text">策研 <span class="brand-sub">Strategy Lab</span></span> + </a> + + {#if isProject} + <div class="projctx"> + <div class="nav-title">当前项目</div> + <div class="projname">{activeProject?.name ?? (projectHidden ? '不可见项目' : '加载中…')}</div> + {#each projectNav as [t, label] (t)} + <a + class="navlink" + href={`#/projects/${route.params.id}/${t}`} + aria-current={projectTab === t ? 'page' : undefined} + >{label}</a> + {/each} + <div class="divider" aria-hidden="true"></div> + </div> + {/if} + + <div class="nav-title">工作台</div> + {#each topLeft as item (item.name)} + <a class="navlink" href={`#/${item.name}`} aria-current={route.name === item.name ? 'page' : undefined}> + {item.label} + </a> + {/each} + + <div class="push"></div> + <div class="nav-title">账户</div> + {#each bottom as item (item.name)} + <a class="navlink" href={`#/${item.name}`} aria-current={route.name === item.name ? 'page' : undefined}> + {item.label} + </a> + {/each} + {#if session.me} + <div class="me"> + <span class="me-name">{session.me.name}{session.me.role === 'admin' ? ' · 管理员' : ''}</span> + <button class="btn small ghost" onclick={() => logout()}>退出</button> + </div> + {/if} + </aside> + + <main class="content" aria-label="主内容"> + {@render children?.()} + </main> +</div> + +<style> + .layout { + display: grid; + grid-template-columns: 240px 1fr; + min-height: 100vh; + } + .sidebar { + background: var(--surface); + border-right: 1px solid var(--border); + padding: 1rem 0.9rem; + display: flex; + flex-direction: column; + gap: 0.65rem; + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; + } + .brand { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.3rem 0.4rem 0.6rem; + border-bottom: 1px solid var(--border); + margin-bottom: 0.4rem; + color: var(--text); + } + .brand:hover { text-decoration: none; } + .brand-mark { + width: 26px; + height: 26px; + border-radius: 7px; + background: var(--teal-strong); + flex-shrink: 0; + position: relative; + overflow: hidden; + } + .brand-mark::after { + content: ''; + position: absolute; + inset: 5px; + background: linear-gradient( + to top, + transparent 4px, + #f0fdfa 4px, + #f0fdfa 8px, + transparent 8px, + transparent 13px, + #f0fdfa 13px, + #f0fdfa 19px, + transparent 19px + ) left/ 4px 100% no-repeat; + opacity: 0.85; + } + .brand-text { font-weight: 700; font-size: 1.02rem; letter-spacing: 0.02em; } + .brand-sub { color: var(--text-3); font-weight: 500; font-size: 0.72rem; display: block; } + .nav-title { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-3); + padding: 0.4rem 0.5rem 0.1rem; + font-weight: 700; + } + .navlink { + display: block; + padding: 0.4rem 0.6rem; + border-radius: 8px; + color: var(--text-2); + font-size: 0.92rem; + } + .navlink:hover { background: var(--surface-2); text-decoration: none; } + .navlink[aria-current='page'] { + background: var(--teal-soft); + color: var(--teal-strong); + font-weight: 600; + } + .projctx .navlink { font-size: 0.88rem; } + .projname { + font-weight: 600; + padding: 0 0.6rem 0.25rem; + font-size: 0.94rem; + } + .divider { + height: 1px; + background: var(--border); + margin: 0.7rem 0.3rem; + } + .push { flex: 1; min-height: 1rem; } + .me { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.4rem; + padding: 0.5rem; + background: var(--surface-2); + border-radius: 8px; + font-size: 0.86rem; + } + .me-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-2); + } + .content { + padding: 1.6rem 2.2rem 3rem; + max-width: 1200px; + width: 100%; + /* grid/flex children default to min-content sizing; without min-width:0 a + wide table cell propagates min-content into the column and inflates the + whole document (375px probe measured 552px). */ + min-width: 0; + } + @media (max-width: 900px) { + .layout { grid-template-columns: minmax(0, 1fr); } + .sidebar { + position: static; + height: auto; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + border-bottom: 1px solid var(--border); + border-right: none; + gap: 0.3rem; + min-width: 0; + } + .nav-title, .divider, .push { display: none; } + .navlink { padding: 0.35rem 0.7rem; } + /* the project tab row must wrap, not force one long line */ + .projctx { display: flex; flex-wrap: wrap; gap: 0.3rem; min-width: 0; } + .projname { max-width: 100%; overflow-wrap: anywhere; } + .content { padding: 1.1rem 1rem 3rem; max-width: 100%; } + } +</style> diff --git a/frontend/src/components/Loading.svelte b/frontend/src/components/Loading.svelte new file mode 100644 index 0000000..6de2ea2 --- /dev/null +++ b/frontend/src/components/Loading.svelte @@ -0,0 +1,24 @@ +<div class="loading-page" role="status" aria-live="polite"> + <span class="spinner" aria-hidden="true"></span> + <p>加载中…</p> +</div> + +<style> + .loading-page { + min-height: 100vh; + display: grid; + place-items: center; + color: var(--text-3); + } + .spinner { + width: 22px; + height: 22px; + border: 2.5px solid var(--teal); + border-top-color: transparent; + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + @keyframes spin { + to { transform: rotate(360deg); } + } +</style> diff --git a/frontend/src/components/Modal.svelte b/frontend/src/components/Modal.svelte new file mode 100644 index 0000000..c9f334c --- /dev/null +++ b/frontend/src/components/Modal.svelte @@ -0,0 +1,63 @@ +<script lang="ts"> + import type { Snippet } from 'svelte'; + + interface Props { + title: string; + onClose: () => void; + wide?: boolean; + children?: Snippet; + } + let { title, onClose, wide = false, children }: Props = $props(); + + function onBackdrop(e: MouseEvent) { + if (e.target === e.currentTarget) onClose(); + } + + function onKeydown(e: KeyboardEvent) { + if (e.key === 'Escape') onClose(); + } + + let container: HTMLDivElement; + $effect(() => { + container?.focus(); + }); +</script> + +<div + class="overlay" + onclick={onBackdrop} + role="presentation" + onkeydown={onKeydown} + tabindex="-1" + bind:this={container} +> + <div class="dialog card" role="dialog" aria-modal="true" aria-label={title}> + <div class="spread"> + <h2>{title}</h2> + <button class="btn ghost" onclick={onClose} aria-label="关闭">✕</button> + </div> + <div class="content"> + {@render children?.()} + </div> + </div> +</div> + +<style> + .overlay { + position: fixed; + inset: 0; + background: rgba(15, 30, 35, 0.35); + display: grid; + place-items: center; + z-index: 50; + padding: 1rem; + } + .dialog { + width: min(760px, 100%); + max-height: 88vh; + overflow: auto; + padding: 1.1rem 1.2rem; + } + + .content { margin-top: 0.6rem; } +</style> diff --git a/frontend/src/components/Page.svelte b/frontend/src/components/Page.svelte new file mode 100644 index 0000000..a9549bb --- /dev/null +++ b/frontend/src/components/Page.svelte @@ -0,0 +1,37 @@ +<script lang="ts"> + import type { Snippet } from 'svelte'; + + interface Props { + title: string; + subtitle?: string; + actions?: Snippet; + children?: Snippet; + wide?: boolean; + } + let { title, subtitle, actions, children, wide }: Props = $props(); +</script> + +<header class="page-head"> + <div> + <h1>{title}</h1> + {#if subtitle}<p class="hint">{subtitle}</p>{/if} + </div> + {#if actions}{@render actions?.()}{/if} +</header> +<div class="page-body" class:wide> + {@render children?.()} +</div> + +<style> + .page-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1.1rem; + flex-wrap: wrap; + } + .page-head .hint { margin-top: 0.2rem; } + .page-body { max-width: 780px; } + .page-body.wide { max-width: none; } +</style> diff --git a/frontend/src/components/Status.svelte b/frontend/src/components/Status.svelte new file mode 100644 index 0000000..019c224 --- /dev/null +++ b/frontend/src/components/Status.svelte @@ -0,0 +1,66 @@ +<script lang="ts"> + interface Props { + error?: string | null; + busy?: boolean; + busyText?: string; + empty?: string | boolean | null; + emptyHint?: string; + warnings?: string[]; + } + let { error, busy, busyText = '加载中…', empty, emptyHint, warnings }: Props = $props(); +</script> + +{#if busy} + <div class="status" role="status"> + <span class="spinner" aria-hidden="true"></span> + {busyText} + </div> +{:else if error} + <div class="banner error" role="alert"> + <span aria-hidden="true">⚠</span> + <span>{error}</span> + </div> +{:else if empty !== null && empty !== undefined && empty !== false} + <div class="state empty"> + <p class="empty-title">{empty}</p> + {#if emptyHint}<p class="hint">{emptyHint}</p>{/if} + </div> +{/if} +{#if warnings && warnings.length > 0} + <div class="stack"> + {#each warnings as w, i (i)} + <div class="banner warn"><span aria-hidden="true">△</span><span>{w}</span></div> + {/each} + </div> +{/if} + +<style> + .status { + color: var(--text-3); + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.9rem 0; + font-size: 0.9rem; + } + .spinner { + width: 15px; + height: 15px; + border: 2px solid var(--teal); + border-top-color: transparent; + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + @keyframes spin { + to { transform: rotate(360deg); } + } + .empty { + text-align: center; + padding: 2.2rem 1rem; + border: 1px dashed var(--border-strong); + border-radius: var(--radius); + color: var(--text-2); + background: var(--surface); + } + .empty-title { font-weight: 600; margin-bottom: 0.2rem; } +</style> diff --git a/frontend/src/lib/__tests__/state.test.ts b/frontend/src/lib/__tests__/state.test.ts new file mode 100644 index 0000000..245c38c --- /dev/null +++ b/frontend/src/lib/__tests__/state.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { extractStatusMessage } from '../state'; + +afterEach(() => vi.unstubAllGlobals()); + +describe('extractStatusMessage', () => { + it('prefers ApiError.code-mapped Chinese text', () => { + const msg = extractStatusMessage({ + status: 409, + code: 'stale_generation', + message: '草稿已被其他修改更新' + }); + expect(msg).toContain('409'); + expect(msg).toContain('草稿已被其他修改更新'); + }); + + it('handles unknown errors without leaking internals', () => { + const msg = extractStatusMessage({ message: 'boom' }); + expect(msg).toContain('出错'); + }); +}); diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts new file mode 100644 index 0000000..b66d9ca --- /dev/null +++ b/frontend/src/lib/api.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { apiFetch, ApiError } from './api'; +import type { ApiErrorBody } from './api'; + +afterEach(() => vi.unstubAllGlobals()); + +describe('apiFetch', () => { + it('parses JSON success responses', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('{"ok":1}', { status: 200, headers: { 'content-type': 'application/json' } })) + ); + const res = await apiFetch<void, { ok: number }>('/api/health'); + expect(res).toEqual({ ok: 1 }); + }); + + it('returns undefined for empty body', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 204 })) + ); + const res = await apiFetch<void, undefined>('/api/auth/logout', { method: 'POST' }); + expect(res).toBeUndefined(); + }); + + it('raises ApiError with structured error body', async () => { + const body: ApiErrorBody = { + error: { code: 'stale_generation', message: '草稿已被其他修改更新', details: { expected: 'g1' } } + }; + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify(body), { status: 409, headers: { 'content-type': 'application/json' } })) + ); + const err = await apiFetch<void>('/api/never').catch((e) => e); + expect(err).toBeInstanceOf(ApiError); + expect(err.status).toBe(409); + expect(err.code).toBe('stale_generation'); + expect(err.message).toBe('草稿已被其他修改更新'); + expect(err.details).toEqual({ expected: 'g1' }); + }); + + it('raises ApiError for non-JSON error body', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('oops', { status: 500 })) + ); + const err = await apiFetch<void>('/api/never').catch((e) => e); + expect(err).toBeInstanceOf(ApiError); + expect(err.status).toBe(500); + expect(err.code).toBe('server_error'); + }); + + it('raises network ApiError', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new TypeError('failed'); + }) + ); + const err = await apiFetch<void>('/api/never').catch((e) => e); + expect(err).toBeInstanceOf(ApiError); + expect(err.status).toBe(0); + expect(err.code).toBe('network'); + }); + + it('sends JSON content type for body requests and does not for bodyless', async () => { + const f = vi.fn(async () => new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } })); + vi.stubGlobal('fetch', f); + await apiFetch('/api/projects', { method: 'POST', body: { name: 'x' } }); + expect(f).toHaveBeenCalledWith( + '/api/projects', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ 'content-type': 'application/json' }), + credentials: 'same-origin' + }) + ); + await apiFetch('/api/projects'); + expect(f).toHaveBeenLastCalledWith( + '/api/projects', + expect.not.objectContaining({ headers: expect.objectContaining({ 'content-type': 'application/json' }) }) + ); + }); + + it('rejects a 200 without JSON body per API contract', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('<html>oops</html>', { status: 200, headers: { 'content-type': 'text/html' } })) + ); + const err = await apiFetch<void>('/api/projects').catch((e) => e); + expect(err).toBeInstanceOf(ApiError); + expect(err.code).toBe('bad_response'); + }); +}); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..923a6e8 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,70 @@ +export interface ApiErrorShape { + code: string; + message: string; + details?: unknown; +} + +export interface ApiErrorBody { + error: ApiErrorShape; +} + +export class ApiError extends Error { + status: number; + code: string; + details?: unknown; + + constructor(status: number, code: string, message: string, details?: unknown) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.code = code; + this.details = details; + } +} + +interface Options { + method?: string; + body?: unknown; + signal?: AbortSignal; +} + +export async function apiFetch<T = unknown, R = T>(url: string, options: Options = {}): Promise<R> { + const headers: Record<string, string> = { accept: 'application/json' }; + let bodyStr: string | undefined; + if (options.body !== undefined) { + headers['content-type'] = 'application/json'; + bodyStr = JSON.stringify(options.body); + } + let res: Response; + try { + res = await fetch(url, { + method: options.method ?? 'GET', + headers, + body: bodyStr, + credentials: 'same-origin', + signal: options.signal + }); + } catch (e) { + if (e instanceof DOMException && e.name === 'AbortError') throw e; + throw new ApiError(0, 'network', '网络请求失败,请确认服务已启动后重试'); + } + if (res.status === 204) { + return undefined as R; + } + const contentType = res.headers.get('content-type') ?? ''; + if (!contentType.includes('application/json')) { + if (!res.ok) { + throw new ApiError(res.status, 'server_error', `服务返回错误(HTTP ${res.status})`); + } + throw new ApiError(res.status, 'bad_response', '服务返回了非预期的非 JSON 响应(接口约定为 JSON)'); + } + const parsed: unknown = await res.json().catch(() => null); + if (!res.ok) { + const errBody = parsed as ApiErrorBody | null; + if (errBody?.error) { + throw new ApiError(res.status, errBody.error.code, errBody.error.message, errBody.error.details); + } + throw new ApiError(res.status, 'server_error', `服务返回错误(HTTP ${res.status})`); + } + return parsed as R; +} diff --git a/frontend/src/lib/chartResize.test.ts b/frontend/src/lib/chartResize.test.ts new file mode 100644 index 0000000..95beaad --- /dev/null +++ b/frontend/src/lib/chartResize.test.ts @@ -0,0 +1,131 @@ +// @ts-nocheck +import { describe, expect, it, beforeEach, vi } from 'vitest'; +import { mount, unmount } from 'svelte'; +import Chart from '../components/Chart.svelte'; + +// Bounded chart-overflow regression (parent live QA: viewport 375, +// document.scrollWidth 1133 after desktop->mobile resize): +// +// echarts.init previously received an explicit width, and ECharts KEEPS the +// explicitly passed size on chart.resize(); a desktop->mobile viewport change +// therefore left the rendered SVG at the desktop width, overflowing the page. +// The ResizeObserver handler must now re-measure the host and pass the +// measured size into chart.resize() so the SVG actually shrinks with the +// viewport. The chart output (SVG renderer) itself must stay fully real — +// this test drives a genuine echarts SVG instance in jsdom (only zrender's +// canvas measureText is shimmed) and asserts the SVG element's width follows +// the host size on a simulated resize (fresh 375 deep links were already +// correct; this covers the persistent resize path). + +interface CapturedRO { + callback: () => void; + el: Element; +} +let captured: CapturedRO[] = []; + +function makeResizeObserverCtor() { + return class FakeResizeObserver { + cb: () => void; + el: Element | null = null; + constructor(cb: ResizeObserverCallback) { + this.cb = cb; + captured.push({ callback: () => cb([], this as unknown as ResizeObserver), el: null as never }); + } + observe(el: Element) { + const capture = captured[captured.length - 1]; + if (capture) capture.el = el; + } + unobserve() {} + disconnect() {} + }; +} + +const baseOption = { + grid: { top: 24, left: 60, right: 24, bottom: 54 }, + xAxis: { type: 'category', data: ['a', 'b'] }, + yAxis: { type: 'value' }, + series: [{ name: 'x', type: 'line' as const, data: [1, 2], showSymbol: false }] +}; + +function hostSvgWidth(host: HTMLElement): number | null { + const svg = host.querySelector('svg'); + if (!svg) return null; + const attr = svg.getAttribute('width'); + if (attr) return parseFloat(attr); + const style = svg.getAttribute('style') ?? ''; + const m = /(?:^|;)\s*width:\s*(\d+(?:\.\d+)?)px/.exec(style); + return m ? parseFloat(m[1]) : null; +} + +describe('Chart resize follows host width (desktop→mobile overflow root cause)', () => { + beforeEach(() => { + captured = []; + vi.stubGlobal('ResizeObserver', makeResizeObserverCtor()); + }); + + it('init uses measured/fallback size, then resizes the SVG when the host shrinks', async () => { + const host = document.createElement('div'); + host.style.width = '900px'; + document.body.appendChild(host); + const originalW = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth'); + Object.defineProperty(host, 'clientWidth', { configurable: true, get() { return 900; } }); + try { + const inst = mount(Chart, { + target: host, + props: { option: baseOption, height: 340, ariaLabel: '曲线' } + }); + // let the lazy echarts import mount + await new Promise((r) => setTimeout(r, 120)); + const ro = captured[captured.length - 1]; + expect(ro).toBeTruthy(); + expect(ro.el).toBe(host.firstElementChild); // observer watches the bind host + const inner = ro.el as HTMLElement; + + const svg = inner.querySelector('svg'); + expect(svg, 'echarts SVG renderer must be real in tests').toBeTruthy(); + // initial size: measured/fallback (target has no clientWidth -> 640) + expect(parseFloat(svg!.getAttribute('width')!)).toBe(640); + + // seed the chart at a "desktop" width, then shrink the host (viewport + // resize) and fire the observer: svg must follow, not stay sticky + const setW = (v: number) => Object.defineProperty(inner, 'clientWidth', { configurable: true, get() { return v; } }); + setW(900); + ro.callback(); + await new Promise((r) => setTimeout(r, 40)); + expect(parseFloat(inner.querySelector('svg')!.getAttribute('width')!)).toBe(900); + + // now the actual overflow scenario: shrink to 375-device width + setW(340); + ro.callback(); + await new Promise((r) => setTimeout(r, 40)); + + const svgAfter = inner.querySelector('svg')!; + const wAfter = svgAfter!.getAttribute('width'); + expect(wAfter, 'svg must shrink on resize, not keep desktop width').toBeTruthy(); + expect(parseFloat(wAfter!)).toBe(340); + + unmount(inst); + host.remove(); + vi.unstubAllGlobals(); + } finally { + Object.defineProperty(host, 'clientWidth', { configurable: true, get: () => 0 }); + } + }); + + it('does not pass undefined sizes to resize when host has no layout (avoid degenerate resize)', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const inst = mount(Chart, { + target: host, + props: { option: baseOption, height: 340, ariaLabel: '曲线' } + }); + await new Promise((r) => setTimeout(r, 120)); + const ro = captured[captured.length - 1]; + expect(ro?.el).toBeTruthy(); + // host with zero layout: callback must be a no-op (no degenerate 0-width resize) + expect(() => ro.callback()).not.toThrow(); + unmount(inst); + host.remove(); + vi.unstubAllGlobals(); + }); +}); diff --git a/frontend/src/lib/client.ts b/frontend/src/lib/client.ts new file mode 100644 index 0000000..4937902 --- /dev/null +++ b/frontend/src/lib/client.ts @@ -0,0 +1,118 @@ +// Central typed binding layer for the 策研 Strategy Lab HTTP contract. +import { apiFetch } from './api'; +import type { + AIAssist, + AIUsage, + AdminUserUpdate, + BacktestResult, + Capabilities, + Dataset, + DatasetPreview, + DatasetRequest, + InstrumentSearchResponse, + Invitation, + Project, + Run, + SessionInfo, + User, + Version +} from './types'; + +export const getHealth = () => + apiFetch<{ status: string; version: string; worker_available: boolean; ai_configured: boolean }>('/api/health'); + +// ---- auth ---- +export const postLogin = (email: string, password: string) => + apiFetch<{ user: User }>('/api/auth/login', { method: 'POST', body: { email, password } }); +export const postLogout = () => apiFetch<void>('/api/auth/logout', { method: 'POST', body: {} }); +export const getMe = () => apiFetch<{ user: User }>('/api/auth/me'); +export const postRegister = (invite_token: string, name: string, email: string, password: string) => + apiFetch<{ user: User }>('/api/auth/register', { method: 'POST', body: { invite_token, name, email, password } }); +export const patchProfile = (name: string) => apiFetch<{ user: User }>('/api/auth/profile', { method: 'PATCH', body: { name } }); +export const postPassword = (current_password: string, new_password: string) => + apiFetch<void>('/api/auth/password', { method: 'POST', body: { current_password, new_password } }); +export const getSessions = () => apiFetch<{ items: SessionInfo[] }>('/api/auth/sessions'); +export const deleteSession = (id: string) => apiFetch<void>(`/api/auth/sessions/${id}`, { method: 'DELETE', body: {} }); +export const postResetPassword = (token: string, new_password: string) => + apiFetch<{ user?: User }>('/api/auth/reset-password', { method: 'POST', body: { token, new_password } }); + +// ---- capabilities ---- +export const getCapabilities = () => apiFetch<Capabilities>('/api/capabilities'); + +// ---- instruments ---- +export const searchInstruments = (q: string, signal?: AbortSignal) => + apiFetch<InstrumentSearchResponse>(`/api/instruments?q=${encodeURIComponent(q)}`, { signal }); + +// ---- projects ---- +export const listProjects = () => apiFetch<{ items: Project[] }>('/api/projects'); +export const createProject = (name: string, description?: string) => + apiFetch<Project>('/api/projects', { method: 'POST', body: { name, description } }); +export const getProject = (id: string) => apiFetch<Project>(`/api/projects/${id}`); +export const patchProject = (id: string, body: { name?: string; description?: string }) => + apiFetch<Project>(`/api/projects/${id}`, { method: 'PATCH', body }); +export const putDraft = (id: string, code: string, expected_generation: number) => + apiFetch<Project>(`/api/projects/${id}/draft`, { method: 'PUT', body: { code, expected_generation } }); +export const listVersions = (id: string) => apiFetch<{ items: Version[] }>(`/api/projects/${id}/versions`); +export const postVersion = (id: string, message: string) => + apiFetch<Version>(`/api/projects/${id}/versions`, { method: 'POST', body: { message } }); +export const getVersionCode = (id: string, versionId: string) => apiFetch<{ code: string }>(`/api/projects/${id}/versions/${versionId}`); +export const postRestore = (id: string, version_id: string, expected_generation: number) => + apiFetch<Project>(`/api/projects/${id}/restore`, { method: 'POST', body: { version_id, expected_generation } }); + +// ---- datasets ---- +export const listDatasets = () => apiFetch<{ items: Dataset[] }>('/api/datasets'); +export const createDataset = (body: DatasetRequest) => apiFetch<Dataset>('/api/datasets', { method: 'POST', body }); +export const getDataset = (id: string) => apiFetch<Dataset>(`/api/datasets/${id}`); +export const getDatasetPreview = (id: string) => apiFetch<DatasetPreview>(`/api/datasets/${id}/preview`); + +// ---- runs ---- +export const listRuns = (project_id?: string) => + apiFetch<{ items: Run[] }>(project_id ? `/api/runs?project_id=${encodeURIComponent(project_id)}` : '/api/runs'); +export interface RunCreateBody { + project_id: string; + dataset_id: string; + capital: number; + commission: number; + slippage: number; + benchmark_symbol?: string | null; + parameters?: Record<string, unknown>; + acknowledge_warnings?: boolean; +} +export const createRun = (body: RunCreateBody) => apiFetch<Run>('/api/runs', { method: 'POST', body }); +export const getRun = (id: string) => apiFetch<Run>(`/api/runs/${id}`); +export const cancelRun = (id: string) => apiFetch<Run>(`/api/runs/${id}/cancel`, { method: 'POST', body: {} }); +export const rerunRun = (id: string, use_original_data = true) => + apiFetch<Run>(`/api/runs/${id}/rerun`, { method: 'POST', body: { use_original_data } }); + +// ---- AI ---- +export const postAIAssist = (project_id: string, instruction: string, expected_generation: number) => + apiFetch<AIAssist>('/api/ai/assist', { method: 'POST', body: { project_id, instruction, expected_generation } }); +export const postAIAccept = (aiId: string, expected_generation: number) => + apiFetch<Project>(`/api/ai/${aiId}/accept`, { method: 'POST', body: { expected_generation } }); +export const getAIUsage = () => apiFetch<AIUsage>('/api/ai/usage'); + +// ---- admin ---- +export const listAdminUsers = () => apiFetch<{ items: User[] }>('/api/admin/users'); +export const patchAdminUser = (id: string, body: AdminUserUpdate) => + apiFetch<User>(`/api/admin/users/${id}`, { method: 'PATCH', body }); +export const postAdminUserPassword = (id: string) => + apiFetch<{ reset_token: string; expires_at: string }>(`/api/admin/users/${id}/reset-password`, { method: 'POST', body: {} }); +export const createInvitation = (body: { email?: string; role?: string; expires_hours?: number }) => + apiFetch<{ token: string; expires_at: string }>('/api/admin/invitations', { method: 'POST', body }); +export const listInvitations = () => apiFetch<{ items: Invitation[] }>('/api/admin/invitations'); +export const deleteInvitation = (id: string) => apiFetch<void>(`/api/admin/invitations/${id}`, { method: 'DELETE', body: {} }); +export const getAdminAudit = () => + apiFetch<{ items: { actor?: string | null; action: string; target?: string | null; status: string; time: string; detail?: string | null }[] }>( + '/api/admin/audit' + ); + +// helpers +export async function poll<T>(fn: () => Promise<T>, until: (v: T) => boolean, intervalMs = 1500): Promise<T> { + for (;;) { + const v = await fn(); + if (until(v)) return v; + await new Promise((r) => setTimeout(r, intervalMs)); + } +} + +export type { BacktestResult }; diff --git a/frontend/src/lib/coverage.test.ts b/frontend/src/lib/coverage.test.ts new file mode 100644 index 0000000..cf59d54 --- /dev/null +++ b/frontend/src/lib/coverage.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { summarizeCoverage, type CoverageEntry } from './coverage'; + +describe('summarizeCoverage', () => { + const entry = (o: Partial<CoverageEntry>): CoverageEntry => ({ + instrument: '600000', + market: 'SH', + asset_type: 'stock', + requested_start: '', + requested_end: '', + actual_start: '', + actual_end: '', + row_count: 1, + ...o + }); + + it('lists unaligned starts without invented dates', () => { + const rows = [ + entry({ instrument: 'A', actual_start: '2018-01-02', actual_end: '2024-12-31', requested_start: '2015-01-01' }), + entry({ instrument: 'B', actual_start: '2020-01-02', actual_end: '2024-12-31', requested_start: '2015-01-01' }) + ]; + const s = summarizeCoverage(rows); + expect(s.common.start).toBe('2020-01-02'); + expect(s.common.end).toBe('2024-12-31'); + expect(s.warnings.includes('B 起始数据较其他标的更晚(2020-01-02)')).toBe(true); + }); + + it('reports missing rows', () => { + const rows = [entry({ instrument: 'C', row_count: 0, actual_start: '', actual_end: '' })]; + const s = summarizeCoverage(rows); + expect(s.warnings.includes('C 没有返回任何数据行')).toBe(true); + expect(s.common.start).toBeNull(); + }); + + it('reports different end dates', () => { + const rows = [ + entry({ instrument: 'A', actual_start: '2020-01-02', actual_end: '2024-12-31' }), + entry({ instrument: 'B', actual_start: '2020-01-02', actual_end: '2024-11-29' }) + ]; + const s = summarizeCoverage(rows); + expect(s.warnings.some((w) => w.includes('B 较早结束'))).toBe(true); + }); +}); diff --git a/frontend/src/lib/coverage.ts b/frontend/src/lib/coverage.ts new file mode 100644 index 0000000..c9a8447 --- /dev/null +++ b/frontend/src/lib/coverage.ts @@ -0,0 +1,52 @@ +export interface CoverageEntry { + instrument: string; + market?: string; + asset_type?: string; + requested_start?: string | null; + requested_end?: string | null; + actual_start?: string | null; + actual_end?: string | null; + row_count?: number; + warnings?: string[]; +} + +export interface CoverageSummary { + common: { start: string | null; end: string | null }; + warnings: string[]; +} + +export function summarizeCoverage(entries: CoverageEntry[]): CoverageSummary { + const warnings: string[] = []; + const withData = entries.filter((e) => (e.row_count ?? 0) > 0 && e.actual_start && e.actual_end); + for (const e of entries) { + if ((e.row_count ?? 0) === 0) { + warnings.push(`${e.instrument} 没有返回任何数据行`); + } else if (!e.actual_start || !e.actual_end) { + warnings.push(`${e.instrument} 数据区间不完整`); + } + } + let common: { start: string | null; end: string | null } = { start: null, end: null }; + if (withData.length > 0) { + const starts = withData.map((e) => e.actual_start as string).sort(); + const startsAsc = [...starts]; + const earliest = startsAsc[0]; + const lateStart = starts[starts.length - 1]; + if (lateStart > earliest) { + const owner = withData.find((e) => e.actual_start === lateStart); + if (owner) { + warnings.push(`${owner.instrument} 起始数据较其他标的更晚(${lateStart})`); + } + } + const ends = withData.map((e) => e.actual_end as string); + const commonEnd = ends.sort()[0]; + const earlyOwners = withData.filter((e) => e.actual_end === commonEnd); + const otherEnds = withData.filter((e) => (e.actual_end as string) > commonEnd); + if (otherEnds.length > 0) { + for (const e of earlyOwners) { + warnings.push(`${e.instrument} 较早结束(${commonEnd}),造成共有区间缺失`); + } + } + common = { start: lateStart, end: commonEnd }; + } + return { common, warnings }; +} diff --git a/frontend/src/lib/draftGuard.test.ts b/frontend/src/lib/draftGuard.test.ts new file mode 100644 index 0000000..a18de48 --- /dev/null +++ b/frontend/src/lib/draftGuard.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { unifiedDiffStats, ConflictResolver } from './draftGuard'; + +describe('unifiedDiffStats', () => { + it('counts added and removed lines', () => { + const s = unifiedDiffStats('a\nb\n', 'a\nc\nd\n'); + expect(s.added).toBe(2); + expect(s.removed).toBe(1); + }); + + it('is zero for identical inputs', () => { + const s = unifiedDiffStats('same', 'same'); + expect(s.added).toBe(0); + expect(s.removed).toBe(0); + }); +}); + +describe('ConflictResolver', () => { + const local = { generation: 1, code: 'x = 1\n' }; + + it('flags stale response: server generation ahead of local base', () => { + const r = new ConflictResolver(local); + const verdict = r.onServerState({ generation: 2, code: 'x = 2\n' }, local.generation); + expect(verdict.stale).toBe(true); + }); + + it('not stale when server generation equals local base generation', () => { + const r = new ConflictResolver(local); + const verdict = r.onServerState({ generation: 1, code: 'x = 1\n' }, local.generation); + expect(verdict.stale).toBe(false); + }); + + it('force-overwrite plan uses server generation to retry save', () => { + const r = new ConflictResolver(local); + const plan = r.retryPlan({ generation: 2, code: 'x = 2\n' } as const); + expect(plan).toEqual({ generation: 2, code: 'x = 1\n' }); + }); +}); diff --git a/frontend/src/lib/draftGuard.ts b/frontend/src/lib/draftGuard.ts new file mode 100644 index 0000000..d6a5222 --- /dev/null +++ b/frontend/src/lib/draftGuard.ts @@ -0,0 +1,46 @@ +import { diffLines, diffWordsWithSpace } from 'diff'; + +export interface DiffStats { + added: number; + removed: number; +} + +export function unifiedDiffStats(before: string, after: string): DiffStats { + const parts = diffLines(before, after); + let added = 0; + let removed = 0; + for (const p of parts) { + if (p.added) added += p.value.replace(/\n$/, '').split('\n').length; + else if (p.removed) removed += p.value.replace(/\n$/, '').split('\n').length; + } + return { added, removed }; +} + +export interface ServerDraft { + generation: number; + code: string; +} + +export interface ConflictVerdict { + stale: boolean; + serverEqualsLocal: boolean; + localDirty: boolean; +} + +export class ConflictResolver { + constructor(private local: { generation: number; code: string }) {} + + onServerState(server: ServerDraft, lastKnownGeneration: number): ConflictVerdict { + const stale = server.generation !== lastKnownGeneration && lastKnownGeneration !== null; + const serverEqualsLocal = server.generation === this.local.generation && server.code === this.local.code; + return { stale, serverEqualsLocal, localDirty: true }; + } + + retryPlan(server: ServerDraft): { generation: number; code: string } { + return { generation: server.generation, code: this.local.code }; + } +} + +export function wordDiff(before: string, after: string) { + return diffWordsWithSpace(before, after); +} diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts new file mode 100644 index 0000000..13e9d1e --- /dev/null +++ b/frontend/src/lib/format.ts @@ -0,0 +1,118 @@ +import type { + BacktestMetrics, + Dataset, + Instrument, + Run +} from './types'; + +export function uuidLike(id: string): boolean { + return /^[0-9a-f-]+$/i.test(id); +} + +export function fmtDateTime(iso?: string | null): string { + if (!iso) return '—'; + const d = new Date(iso); + if (isNaN(d.getTime())) return iso; + return d.toLocaleString('zh-CN', { hour12: false }); +} + +export function fmtDate(iso?: string | null): string { + if (!iso) return '—'; + return iso.replace('T', ' ').replace(/Z$|\+08:00$/i, ''); +} + +export function fmtPct(v: number | null | undefined, digits = 2): string { + if (v === null || v === undefined || Number.isNaN(v)) return '—'; + return `${(v * 100).toFixed(digits)}%`; +} + +export function fmtNum(v: number | null | undefined, digits = 2): string { + if (v === null || v === undefined || Number.isNaN(v)) return '—'; + if (Math.abs(v) >= 1e8) return `${(v / 1e8).toFixed(2)} 亿`; + if (Math.abs(v) >= 1e4) return `${(v / 1e4).toFixed(2)} 万`; + let r = v.toFixed(digits); + if (r === '0.00' && v !== 0) r = '0'; + return r; +} + +export function itemSourceLabel(itemSource?: string | null, fallback = ''): string { + const s = itemSource || fallback; + if (!s) return '—'; + if (s === 'catalog') return '目录'; + if (s === 'provider_suggest') return '供应商'; + if (s === 'eastmoney') return '东财'; + if (s === 'tencent') return '腾讯'; + if (s === 'sina') return '新浪'; + return s; +} + +export const runStatusLabel: Record<Run['status'], string> = { + queued: '排队中', + running: '执行中', + succeeded: '已完成', + failed: '失败', + cancelled: '已取消' +}; + +export const runStatusBadge: Record<Run['status'], 'grey' | 'warn' | 'ok' | 'danger' | 'teal'> = { + queued: 'grey', + running: 'warn', + succeeded: 'ok', + failed: 'danger', + cancelled: 'grey' +}; + +export const datasetStatusLabel: Record<Dataset['status'], string> = { + pending: '等待中', + running: '获取数据', + ready: '就绪', + failed: '失败' +}; + +export const adjustmentLabel: Record<string, string> = { + none: '不复权', + qfq: '前复权', + hfq: '后复权' +}; + +export const assetTypeLabel: Record<string, string> = { + stock: '股票', + etf: 'ETF', + index: '指数' +}; + +export function instrumentLabel(i: Instrument): string { + const name = i.name ? ` ${i.name}` : ''; + return `${i.market}.${i.symbol}${name}`; +} + +export function formatMoney(v: number | null | undefined): string { + if (v === null || v === undefined || Number.isNaN(v)) return '—'; + return `¥ ${fmtNum(v)}`; +} + +export const versionSourceLabel: Record<string, string> = { + manual: '手动快照', + run: '回测快照', + ai: 'AI 采用', + restore: '恢复产生' +}; + +export function metricsLabels(): string[] { + // trade_count semantics (worker/backtest.py): COUNT of closed round trips — + // an open that is fully closed afterwards counts as 1; fills/orders and + // cancellations do NOT count. Consistent label for the result card AND the + // run comparison tables. + return ['总收益率', '年化收益率', '最大回撤', '夏普比率', '平仓回合数', '期末权益']; +} + +export function metricsRow(m: BacktestMetrics): string[] { + return [ + fmtPct(m.total_return), + fmtPct(m.annual_return), + fmtPct(m.max_drawdown), + m.sharpe === null || m.sharpe === undefined ? '—' : m.sharpe.toFixed(2), + m.trade_count === null || m.trade_count === undefined ? '—' : String(m.trade_count), + m.final_equity === null || m.final_equity === undefined ? '—' : fmtNum(m.final_equity) + ]; +} diff --git a/frontend/src/lib/instrumentSearch.test.ts b/frontend/src/lib/instrumentSearch.test.ts new file mode 100644 index 0000000..15636be --- /dev/null +++ b/frontend/src/lib/instrumentSearch.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createSearchRunner, + SEARCH_TIMEOUT_MSG, + SEARCH_UNAVAILABLE_MSG, + type SearchFetcher, + type SearchHandlers +} from './instrumentSearch'; +import type { Instrument, InstrumentSearchResponse } from './types'; + +function inst(symbol: string): Instrument { + return { symbol, market: 'SH', asset_type: 'stock', name: symbol, currency: 'CNY', source: 'eastmoney' }; +} + +function ok(items: Instrument[]): InstrumentSearchResponse { + return { items, source: 'provider_suggest', status: 'ok' }; +} + +function trackedHandlers(): SearchHandlers & { events: string[] } { + const events: string[] = []; + return { + events, + onBusy: () => events.push('busy'), + onResult: (res) => events.push(`result:${res.items.length}:${res.status}`), + onError: (m) => events.push(`error:${m}`), + onFinish: () => events.push('finish') + }; +} + +const flush = () => new Promise<void>((r) => setTimeout(r, 0)); + +describe('instrument search coordinator', () => { + it('successful provider response shows items and clears the spinner', async () => { + const h = trackedHandlers(); + const fetch: SearchFetcher = vi.fn(async () => ok([inst('600000')])); + const runner = createSearchRunner(fetch, h); + runner.runNow('600000'); + await flush(); + expect(h.events).toEqual(['busy', 'result:1:ok', 'finish']); + }); + + it('unavailable status shows an explicit Chinese message and clears items', async () => { + const h = trackedHandlers(); + const fetch: SearchFetcher = async () => ({ items: [], source: 'none', status: 'unavailable: providers down' }); + const runner = createSearchRunner(fetch, h); + runner.runNow('600000'); + await flush(); + expect(h.events).toContain(`error:${SEARCH_UNAVAILABLE_MSG}`); + expect(h.events[h.events.length - 1]).toBe('finish'); + }); + + it('provider rejection surfaces a visible failure and the spinner is cleared in finally', async () => { + const h = trackedHandlers(); + const fetch: SearchFetcher = async () => { throw { status: 502, message: 'bad gateway' }; }; + const runner = createSearchRunner(fetch, h); + runner.runNow('600000'); + await flush(); + expect(h.events.some((e) => e.startsWith('error:'))).toBe(true); + expect(h.events[h.events.length - 1]).toBe('finish'); + }); + + it('a hard timer aborts the hung request and shows the timeout wording', async () => { + const h = trackedHandlers(); + const fetch: SearchFetcher = vi.fn(async (_q: string, signal?: AbortSignal) => + new Promise<InstrumentSearchResponse>((_, reject) => { + signal?.addEventListener('abort', () => reject(new DOMException('x', 'AbortError'))); + })); + const runner = createSearchRunner(fetch, h, { timeoutMs: 30 }); + runner.runNow('600000'); + await flush(); + await new Promise((r) => setTimeout(r, 60)); + expect(h.events).toContain(`error:${SEARCH_TIMEOUT_MSG}`); + expect(h.events[h.events.length - 1]).toBe('finish'); // spinner never hangs + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('stale prior query: its late result never repaints and never hangs the spinner', async () => { + const h = trackedHandlers(); + let releaseA!: (v: InstrumentSearchResponse) => void; + let calls = 0; + const fetch: SearchFetcher = vi.fn(async () => { + calls += 1; + if (calls === 1) { + return new Promise<InstrumentSearchResponse>((resolve) => { releaseA = resolve; }); + } + return ok([inst('600000')]); + }); + const runner = createSearchRunner(fetch, h); + runner.runNow('old'); + await flush(); + const busyCount = h.events.filter((e) => e === 'busy').length; + runner.runNow('new'); + await flush(); + // first request aborted; the slow promise is released AFTER the swap and + // resolves into a stale seq → no UI effect, no extra finish + releaseA(ok([inst('000001')])); + await flush(); + const busyAfter = h.events.filter((e) => e === 'busy').length; + expect(busyAfter).toBe(2); + expect(h.events[h.events.length - 1]).toBe('finish'); + expect(h.events[busyCount - 1]).toBe('busy'); + expect(h.events.join('|')).not.toContain('result:1:ok|finish|result'); + expect(h.events.filter((e) => e.startsWith('result')).length).toBe(1); + }); + + it('dispose invalidates in-flight callbacks entirely', async () => { + const h = trackedHandlers(); + let pendings = 0; + const fetch: SearchFetcher = async () => { + pendings += 1; + return new Promise<InstrumentSearchResponse>(() => { /* pending */ }); + }; + const runner = createSearchRunner(fetch, h); + runner.runNow('600000'); + await flush(); + const eventsAtDispose = h.events.length; + runner.dispose(); + await flush(); + expect(h.events.length).toBe(eventsAtDispose); // nothing further fired + }); +}); diff --git a/frontend/src/lib/instrumentSearch.ts b/frontend/src/lib/instrumentSearch.ts new file mode 100644 index 0000000..8a83a47 --- /dev/null +++ b/frontend/src/lib/instrumentSearch.ts @@ -0,0 +1,120 @@ +// Production search bug fix /instrument-search coordinator. +// +// Root cause of the hung spinner: the API previously did a full catalog fetch +// server-side. Client-side we now keep the round trip bounded end to end: +// debounce + abort of prior stale queries + a hard AbortController timer, and +// explicit handling of the honest failure statuses the backend now returns. +// Pure dependency injection makes stale-query cleanup unit testable. +import { apiFetch } from './api'; +import { extractStatusMessage } from './state'; +import type { InstrumentSearchResponse } from './types'; + +export const SEARCH_TIMEOUT_MS = 12_000; +export const SEARCH_DEBOUNCE_MS = 350; + +export const SEARCH_UNAVAILABLE_MSG = '搜索数据源暂时不可用,请稍后重试'; +export const SEARCH_TIMEOUT_MSG = '搜索超时,请重试'; +export const SEARCH_EMPTY_MSG = '未找到匹配标的,可使用下方手动录入并核对身份'; + +export type SearchFetcher = ( + q: string, + signal?: AbortSignal +) => Promise<InstrumentSearchResponse>; + +export function fetchSearch(q: string, signal?: AbortSignal): Promise<InstrumentSearchResponse> { + return apiFetch<InstrumentSearchResponse>(`/api/instruments?q=${encodeURIComponent(q)}`, { signal }); +} + +export interface SearchHandlers { + onBusy(): void; + onResult(res: InstrumentSearchResponse): void; + onError(message: string): void; + onFinish(): void; +} + +export interface SearchRunner { + runNow(q: string): void; + requestDebounced(q: string): void; + dispose(): void; +} + +export interface SearchRunnerOptions { + timeoutMs?: number; + debounceMs?: number; +} + +export function createSearchRunner( + doFetch: SearchFetcher = fetchSearch, + handlers: SearchHandlers, + options: SearchRunnerOptions = {}, +): SearchRunner { + const timeoutMs = options.timeoutMs ?? SEARCH_TIMEOUT_MS; + const debounceMs = options.debounceMs ?? SEARCH_DEBOUNCE_MS; + let seq = 0; + let controller: AbortController | null = null; + let timer: ReturnType<typeof setTimeout> | null = null; + + function clearTimer() { + if (timer) { + clearTimeout(timer); + timer = null; + } + } + + async function run(q: string) { + const mySeq = ++seq; + // abort the previous in-flight request first: its callbacks all become + // stale (mySeq !== seq) and must not touch the UI + controller?.abort(); + controller = new AbortController(); + const ctl = controller; + const timeout = setTimeout(() => ctl.abort(), timeoutMs); + timer = timeout; + handlers.onBusy(); + try { + const res = await doFetch(q, ctl.signal); + if (mySeq !== seq) return; + if (res.status === 'ok') { + handlers.onResult(res); + } else { + handlers.onResult({ items: [], source: 'none', status: res.status }); + handlers.onError(SEARCH_UNAVAILABLE_MSG); + } + } catch (e) { + if (mySeq !== seq) return; + const signalAborted = ctl.signal.aborted; + const abortError = (e as { name?: string }).name === 'AbortError'; + handlers.onError( + signalAborted || abortError ? SEARCH_TIMEOUT_MSG : extractStatusMessage(e), + ); + } finally { + if (timer === timeout) timer = null; + clearTimeout(timeout); + // stale requests never touch busy state (their handler may already have + // been superseded); only the still-current query may reset the spinner + if (mySeq === seq) handlers.onFinish(); + } + } + + return { + runNow: (q: string) => { + clearTimer(); + if (!q.trim()) return; + void run(q.trim()); + }, + requestDebounced: (q: string) => { + clearTimer(); + if (!q.trim()) return; + timer = setTimeout(() => { + timer = null; + void run(q.trim()); + }, debounceMs); + }, + dispose: () => { + clearTimer(); + seq += 1; // invalidate any in-flight callbacks + controller?.abort(); + controller = null; + }, + }; +} diff --git a/frontend/src/lib/layout.test.ts b/frontend/src/lib/layout.test.ts new file mode 100644 index 0000000..99d3c47 --- /dev/null +++ b/frontend/src/lib/layout.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +// `?raw` gives us the exact Svelte source for a real regression check +// (ProjectPage used to wrap a second <Layout>, doubling the sidebar). +// Type support comes from vite/client's `*?raw` module declaration. +import projectPageSource from '../pages/ProjectPage.svelte?raw'; + +describe('single layout shell', () => { + it('ProjectPage does not nest its own Layout', () => { + const src: string = projectPageSource as unknown as string; + expect(src).not.toMatch(/import\s+Layout/); + expect(src).not.toMatch(/<Layout/); + // page props still passed through on each tab + expect(src).toMatch(/ProjectDataTab/); + expect(src).toMatch(/ProjectStrategyTab/); + expect(src).toMatch(/ProjectBacktestTab/); + expect(src).toMatch(/ProjectResultsTab/); + }); +}); diff --git a/frontend/src/lib/listsStore.svelte.ts b/frontend/src/lib/listsStore.svelte.ts new file mode 100644 index 0000000..102543f --- /dev/null +++ b/frontend/src/lib/listsStore.svelte.ts @@ -0,0 +1,92 @@ +import type { Project } from './types'; +import { getProject, listProjects } from './client'; +import { ApiError } from './api'; + +let projects: Project[] = $state([]); + +// ids that failed to fetch because the project is not visible to this session +// (404 not found / 403 not owner). Remembered so the sidebar can show an honest +// 不可见项目 state instead of an endless 加载中… retry loop. Cleared on logout / +// session switch so cross-user stale names or failure marks never leak. +// $state map so derived views (sidebar) re-react when a mark is added/cleared. +let invisibleIds: Record<string, boolean> = $state({}); +let inflight = new Map<string, Promise<Project | null>>(); + +export interface ProjectsStore { + get all(): Project[]; + set(items: Project[]): void; + /** fetch + cache a project the sidebar/derived views cannot see yet (deep link) */ + ensure(id: string): Promise<Project | null>; + bump(p: Project): void; + /** reactive lookup: reads only the $state projects array */ + get(id: string): Project | undefined; + load(): Promise<Project[]>; + /** drop all cached projects + failure marks (must be called on logout so a + * future session on the same browser cannot see the previous user's names) */ + clear(): void; +} + +function upsert(p: Project) { + const idx = projects.findIndex((x) => x.id === p.id); + if (idx >= 0) projects[idx] = p; + else projects = [...projects, p]; +} + +export const projectsStore: ProjectsStore = { + get all() { + return projects; + }, + set(items: Project[]) { + projects = items; + }, + get(id: string) { + return projects.find((p) => p.id === id); + }, + bump(p: Project) { + upsert(p); + }, + async ensure(id: string) { + const known = projects.find((p) => p.id === id); + if (known) return known; + if (invisibleIds[id]) return null; + const pending = inflight.get(id); + if (pending) return pending; + const fetchPromise = (async () => { + try { + const p = await getProject(id); + upsert(p); + return p; + } catch (e) { + if (e instanceof ApiError && (e.status === 403 || e.status === 404)) { + invisibleIds = { ...invisibleIds, [id]: true }; + return null; + } + throw e; + } finally { + inflight.delete(id); + } + })(); + inflight.set(id, fetchPromise); + return fetchPromise; + }, + async load() { + const { items } = await listProjects(); + projects = items; + return items; + }, + clear() { + projects = []; + invisibleIds = {}; + inflight.clear(); + } +}; + +/** reactive derived read for components */ +export function projectOf(id: string): Project | undefined { + return projectsStore.get(id); +} + +/** true when a previous ensure() proved the id is not visible for this session */ +export function projectInvisible(id: string): boolean { + return !!invisibleIds[id]; +} diff --git a/frontend/src/lib/mobileCss.test.ts b/frontend/src/lib/mobileCss.test.ts new file mode 100644 index 0000000..c4d1efb --- /dev/null +++ b/frontend/src/lib/mobileCss.test.ts @@ -0,0 +1,63 @@ +// @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. +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +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'); + }); + + 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 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 new file mode 100644 index 0000000..226e1d0 --- /dev/null +++ b/frontend/src/lib/resultsAccounting.test.ts @@ -0,0 +1,99 @@ +// @ts-nocheck +import { describe, expect, it, vi } from 'vitest'; +import { mount, unmount } from 'svelte'; +import { metricsLabels, metricsRow } from '../lib/format'; +import resultsSource from '../pages/ProjectResultsTab.svelte?raw'; + +// Bounded accounting regression (parent live findings): +// 1) trade_count semantics: worker/backtest.py counts CLOSED ROUND TRIPS while +// the results card called it 成交次数 (fills). Label + definition must be the +// honest closed-round-trip semantics consistently, including comparisons +// (metricsLabels/metricsRow feed the run comparison tables too). +// 2) Fill value now = actual turnover quantity*price (worker regression has its +// own RED/GREEN accounting test over the real engine). +// 3) metric tiles had no CSS classes at all -> six stacked metrics with large +// blank space; global stylesheet must contain real layout classes. + +const PROJECT_ID = '7630e296-0b2f-44c3-85f1-fe695e7a7e4b'; +const RUN_ID = '569b23d0-1111-4222-8333-4444444444442'; + +function resultBody() { + return { + id: RUN_ID, project_id: PROJECT_ID, version_id: 'v', dataset_id: 'd', + status: 'succeeded', + config: { capital: 100000, commission: 0.0003, slippage: 0.001 }, + created_at: '2026-01-01T00:00:00Z', + result: { + status: 'succeeded', + engine: { name: 'backtrader', version: '1.9' }, + metrics: { total_return: 0.0123, annual_return: 0.05, max_drawdown: -0.033, sharpe: 0.9, trade_count: 1, final_equity: 101234.5 }, + equity: [{ date: '2024-01-02', equity: 100000, cash: 100000 }, { date: '2024-01-03', equity: 101234.5, cash: 98967.9 }], + orders: [], trades: [ + // the accounting shape the parent verified live: different buy/sell + // prices -> different turnover (abs(quantity*price)), NOT cost basis + { date: '2024-01-02', symbol: 'SH#600000', side: 'buy', quantity: 100, price: 10.2, commission: 0.30606, value: 1020.0 }, + { date: '2024-01-03', symbol: 'SH#600000', side: 'sell', quantity: 100, price: 10.4, commission: 0.32, value: 1040.0 }, + ], closed_trades: [], + logs: ['Engine is required', 'Final writing'], warnings: [], + elapsed_ms: 1234, peak_rss_kb: 8192, data_manifest_hash: 'deadbeefdeadbeef1234', + }, + }; +} + +function runBody() { + return { id: RUN_ID, project_id: PROJECT_ID, version_id: 'v', dataset_id: 'd', status: 'succeeded', config: {}, created_at: '2026-01-01T00:00:00Z' }; +} + +const fetchMockOnce = () => (url) => { + if (/\/api\/runs\/[0-9a-f-]+$/.test(String(url))) return Promise.resolve(new Response(JSON.stringify(resultBody()), { status: 200, headers: { 'content-type': 'application/json' } })); + if (/\/api\/runs\?|\/api\/runs$/.test(String(url))) return Promise.resolve(new Response(JSON.stringify({ items: [runBody()] }), { status: 200, headers: { 'content-type': 'application/json' } })); + if (/\/api\/projects\/[0-9a-f-]+$/.test(String(url))) + return Promise.resolve(new Response(JSON.stringify({ id: PROJECT_ID, name: '端到端验收项目', draft_code: 'x=1', draft_generation: 2 }), { status: 200, headers: { 'content-type': 'application/json' } })); + return Promise.resolve(new Response('{"items":[]}', { status: 200, headers: { 'content-type': 'application/json' } })); +}; + +describe('results accounting labels + values (live browser accounting)', () => { + it('renders actual result DOM: 平仓回合数 label, trade turnover values, metrics grid classes', 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() {} }); + vi.stubGlobal('fetch', fetchMockOnce()); + const ProjectResultsTab = (await import('../pages/ProjectResultsTab.svelte')).default; + const host = document.createElement('div'); + document.body.appendChild(host); + const inst = mount(ProjectResultsTab, { target: host, props: { projectId: PROJECT_ID, runId: RUN_ID } }); + await new Promise((r) => setTimeout(r, 80)); + try { + const text = host.textContent; + // closed round trip semantics (not fill count) + expect(text).toContain('平仓回合数'); + expect(text).toContain('已完成的开仓并全部平仓的完整回合'); + expect(text).not.toContain('成交次数(不含撤单)'); + // numeric trade_count shows through: equity div reached rendering + expect(host.querySelectorAll('.metric').length).toBe(6); + // turnover: two fills with distinct values (buy vs sell prices differ) + const rows = [...host.querySelectorAll('td.num')].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); + } finally { unmount(inst); host.remove(); vi.unstubAllGlobals(); } }); + + it('comparison labels use the same closed-round-trip semantics', () => { + const labels = metricsLabels(); + expect(labels).toContain('平仓回合数'); + const m = metricsRow({ total_return: 0.1, annual_return: 0.2, max_drawdown: -0.1, sharpe: 1, trade_count: 1, final_equity: 110000 }); + const label = labels[m.findIndex((_, i) => i === labels.indexOf('平仓回合数'))]; + // the value for trade_count in the comparison row is the numeric string + expect(m[labels.indexOf('平仓回合数')]).toBe('1'); + expect(labels).not.toContain('交易次数'); // old misleading '交易次数' gone + }); + + it('stylesheet defines the shared metric tile layout (no stacking blank space)', () => { + const template: string = resultsSource as unknown as string; + // keep the editor honest: no leftover 成交次数 wording in the metrics card + expect(template).toContain('平仓回合数'); + expect(template).not.toContain('成交次数'); + }); +}); diff --git a/frontend/src/lib/router.test.ts b/frontend/src/lib/router.test.ts new file mode 100644 index 0000000..944e342 --- /dev/null +++ b/frontend/src/lib/router.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { routeFromHash, projectPath } from './router'; + +function setHash(h: string) { + window.location.hash = h; +} + +describe('router', () => { + it('routes auth pages', () => { + setHash('#/login'); + expect(routeFromHash().name).toBe('login'); + setHash('#/register'); + expect(routeFromHash().name).toBe('register'); + setHash('#/reset'); + expect(routeFromHash().name).toBe('reset'); + }); + + it('routes protected areas', () => { + setHash('#/projects'); + expect(routeFromHash().name).toBe('projects'); + setHash('#/datasets'); + expect(routeFromHash().name).toBe('datasets'); + setHash('#/runs'); + expect(routeFromHash().name).toBe('runs'); + setHash('#/usage'); + expect(routeFromHash().name).toBe('usage'); + setHash('#/account'); + expect(routeFromHash().name).toBe('account'); + setHash('#/admin'); + expect(routeFromHash().name).toBe('admin'); + }); + + it('routes project tabs', () => { + setHash('#/projects/abc-1/strategy'); + const r = routeFromHash(); + expect(r.name).toBe('project-strategy'); + expect(r.params.id).toBe('abc-1'); + setHash('#/projects/abc-1'); + expect(routeFromHash().name).toBe('project-data'); + setHash('#/projects/abc-1/backtest'); + expect(routeFromHash().name).toBe('project-backtest'); + setHash('#/projects/abc-1/results?run=r9'); + const rr = routeFromHash(); + expect(rr.name).toBe('project-results'); + expect(rr.query.get('run')).toBe('r9'); + }); + + it('projects deep path builder', () => { + expect(projectPath('id1', 'strategy')).toBe('/projects/id1/strategy'); + }); +}); diff --git a/frontend/src/lib/router.ts b/frontend/src/lib/router.ts new file mode 100644 index 0000000..3c2631a --- /dev/null +++ b/frontend/src/lib/router.ts @@ -0,0 +1,43 @@ +export interface Route { + name: string; + params: Record<string, string>; + query: URLSearchParams; +} + +export function routeFromHash(): Route { + const raw = window.location.hash.replace(/^#/, '') || '/login'; + const [pathPart, queryPart] = raw.split('?'); + const segments = pathPart.split('/').filter(Boolean); + const query = new URLSearchParams(queryPart ?? ''); + if (segments[0] === 'login' || segments.length === 0) return { name: 'login', params: {}, query }; + if (segments[0] === 'register') return { name: 'register', params: {}, query }; + if (segments[0] === 'reset') return { name: 'reset', params: {}, query }; + if (segments[0] === 'account') return { name: 'account', params: {}, query }; + if (segments[0] === 'admin') return { name: 'admin', params: {}, query }; + if (segments[0] === 'datasets') return { name: 'datasets', params: {}, query }; + if (segments[0] === 'runs') return { name: 'runs', params: {}, query }; + if (segments[0] === 'usage') return { name: 'usage', params: {}, query }; + if (segments[0] === 'projects') { + if (!segments[1]) return { name: 'projects', params: {}, query }; + const id = segments[1]; + const tab = segments[2] ?? 'data'; + if (['data', 'strategy', 'backtest', 'results'].includes(tab)) { + return { name: `project-${tab}`, params: { id }, query }; + } + return { name: 'project-data', params: { id }, query }; + } + return { name: 'login', params: {}, query }; +} + +export function go(path: string) { + if (!path.startsWith('#')) path = `#${path.startsWith('/') ? path : `/${path}`}`; + window.location.hash = path; +} + +export function replace(path: string) { + go(path); +} + +export function projectPath(id: string, tab: 'data' | 'strategy' | 'backtest' | 'results') { + return `/projects/${id}/${tab}`; +} diff --git a/frontend/src/lib/session.svelte.ts b/frontend/src/lib/session.svelte.ts new file mode 100644 index 0000000..47b452f --- /dev/null +++ b/frontend/src/lib/session.svelte.ts @@ -0,0 +1,45 @@ +import type { User } from './types'; +import { getMe } from './client'; + +class SessionStore { + me = $state<User | null>(null); + status = $state<'loading' | 'anon' | 'authed'>('loading'); + + async refresh(): Promise<User | null> { + try { + const { user } = await getMe(); + if (!user || typeof user.id !== 'string' || user.id.length === 0) { + this.clear(); + return null; + } + this.me = user; + this.status = 'authed'; + return user; + } catch (e) { + const status = (e as { status?: number }).status ?? 0; + if (status === 0) { + // network/protocol error: keep previous state so UI can show retry instead + if (this.status === 'loading') this.clear(); + return null; + } + this.clear(); + return null; + } + } + + clear() { + this.me = null; + this.status = 'anon'; + } + + set(u: User | null) { + if (u && typeof u.id === 'string' && u.id.length > 0) { + this.me = u; + this.status = 'authed'; + } else { + this.clear(); + } + } +} + +export const session: SessionStore = new SessionStore(); diff --git a/frontend/src/lib/sidebarCurrentProject.test.ts b/frontend/src/lib/sidebarCurrentProject.test.ts new file mode 100644 index 0000000..5dc9b9d --- /dev/null +++ b/frontend/src/lib/sidebarCurrentProject.test.ts @@ -0,0 +1,186 @@ +// @ts-nocheck +import { describe, expect, it, beforeEach, vi } from 'vitest'; +import { mount, unmount, flushSync } from 'svelte'; +import Layout from '../components/Layout.svelte'; +import { session } from '../lib/session.svelte'; +import { projectsStore, projectInvisible, projectOf } from '../lib/listsStore.svelte'; +import { ApiError } from '../lib/api'; +import type { Route } from '../lib/router'; + +// Bounded sidebar regression (parent live finding): on a deep link or first +// navigation into a project tab the sidebar showed an endless 加载中… because +// projectOf() only read the (empty) list store and nothing fetched the missing +// project. Fix: Layout triggers projectsStore.ensure() for unknown ids; the +// name then resolves reactively, updates on in-tab changes (bump), and a +// non-visible project (404/403) shows an honest 不可见项目 state instead of a +// retry loop. Failure marks + cache are cleared on logout so no cross-user +// stale name (or invisible mark from another user's project) can leak. + +const PID = 'f10c5d43-7a2e-4b1c-9d6f-3311aabbcc01'; +const PID_OTHER = 'f10c5d43-7a2e-4b1c-9d6f-3311aabbcc02'; +const route = (name: string, id?: string): Route => + id + ? { name, params: { id }, query: new URLSearchParams() } + : { name: 'projects', params: {}, query: new URLSearchParams() }; + +const USER = { id: 'u1', name: '测试用户', role: 'user', session_id: 's1' }; + +function projectBody(name = '回测验收项目') { + return { + id: PID, + name, + owner_id: 'u1', + draft_code: 'x=1', + draft_generation: 2, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z' + }; +} + +async function mountLayout(r: Route) { + const el = document.createElement('div'); + document.body.appendChild(el); + const inst = mount(Layout, { target: el, props: { route: r } }); + flushSync(); + await new Promise((res) => setTimeout(res, 50)); + flushSync(); + return { el, inst }; +} + +describe('sidebar current project (deep link / navigation / updates)', () => { + beforeEach(() => { + session.set(USER as never); + projectsStore.clear(); + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + }); + + it('deep link into a project tab resolves 当前项目 name without another page loading first', async () => { + const calls: string[] = []; + vi.stubGlobal('fetch', (url) => { + calls.push(String(url)); + if (String(url).includes(`/api/projects/${PID}`)) + return Promise.resolve(new Response(JSON.stringify(projectBody()), { status: 200, headers: { 'content-type': 'application/json' } })); + return Promise.resolve(new Response('{"items":[]}', { status: 200, headers: { 'content-type': 'application/json' } })); + }); + const { el, inst } = await mountLayout(route('project-strategy', PID)); + try { + expect(el.textContent).toContain('回测验收项目'); + expect(el.textContent).not.toContain('加载中'); + expect(el.textContent).not.toContain('不可见项目'); + // exactly ONE direct project fetch (deduped by ensure(); no retry loops) + const projectCalls = calls.filter((u) => u.includes(`/api/projects/${PID}`)); + expect(projectCalls.length).toBe(1); + // project is now visible through the shared reactive store + expect(projectOf(PID)?.name).toBe('回测验收项目'); + } finally { + unmount(inst); + el.remove(); + vi.unstubAllGlobals(); + } + }); + + it('reacts to in-tab updates (bump) without extra fetching, then navigates between tabs', async () => { + const calls: string[] = []; + vi.stubGlobal('fetch', (url) => { + calls.push(String(url)); + if (String(url).includes(`/api/projects/${PID}`)) + return Promise.resolve(new Response(JSON.stringify(projectBody()), { status: 200, headers: { 'content-type': 'application/json' } })); + return Promise.resolve(new Response('{"items":[]}', { status: 200, headers: { 'content-type': 'application/json' } })); + }); + const { el, inst } = await mountLayout(route('project-data', PID)); + try { + expect(el.textContent).toContain('回测验收项目'); + + // one shared ensure cache: opening another tab of the SAME project does + // not refetch and stays showing the name + const { inst: inst2, el: el2 } = await mountLayout(route('project-backtest', PID)); + try { + expect(el2.textContent).toContain('回测验收项目'); + expect(calls.filter((u) => u.includes(`/api/projects/${PID}`)).length).toBe(1); + } finally { + unmount(inst2); + el2.remove(); + } + + // in-tab save/restore/AI bumps the store -> sidebar updates in place + projectsStore.bump({ ...projectBody('改名后的验收项目') } as never); + flushSync(); + expect(el.textContent).toContain('改名后的验收项目'); + } finally { + unmount(inst); + el.remove(); + vi.unstubAllGlobals(); + } + }); + + it('shows 不可见项目 (not an endless 加载中) for another user\'s/deleted project and clears marks on logout', async () => { + const calls: string[] = []; + vi.stubGlobal('fetch', (url) => { + calls.push(String(url)); + return Promise.resolve(new Response(JSON.stringify({ error: { code: 'not_found', message: 'no' } }), { status: 404, headers: { 'content-type': 'application/json' } })); + }); + const { el, inst } = await mountLayout(route('project-results', PID_OTHER)); + try { + expect(el.textContent).toContain('不可见项目'); + expect(el.textContent).not.toContain('回测验收项目'); + // retry guard: layout remount doesn't hammer a known-invisible project + await mountLayout(route('project-results', PID_OTHER)).then(({ inst: i2, el: e2 }) => { + unmount(i2); + e2.remove(); + }); + expect(calls.filter((u) => u.includes(`/api/projects/${PID_OTHER}`)).length).toBe(1); + expect(projectInvisible(PID_OTHER)).toBe(true); + } finally { + unmount(inst); + el.remove(); + vi.unstubAllGlobals(); + } + + // logout must clear the failure marks + cache: the NEXT session must not + // inherit invisible marks or cached project names (cross-user leakage) + projectsStore.clear(); + expect(projectInvisible(PID_OTHER)).toBe(false); + expect(projectOf(PID)).toBeUndefined(); + }); + + it('transient network errors are not remembered as invisible (honest retry)', async () => { + let fail = true; + // phase 1: the fetch to the project id rejects with a network-layer error + vi.stubGlobal('fetch', (url) => { + if (String(url).includes(`/api/projects/${PID}`)) { + if (fail) throw new TypeError('network down'); + return new Promise<Response>(() => {}); + } + return Promise.resolve(new Response('{"items":[]}', { status: 200, headers: { 'content-type': 'application/json' } })); + }); + const { el, inst } = await mountLayout(route('project-data', PID)); + try { + // network error: not marked invisible; sidebar keeps the honest 加载中… + expect(el.textContent).toContain('加载中'); + expect(projectInvisible(PID)).toBe(false); + } finally { + unmount(inst); + el.remove(); + vi.unstubAllGlobals(); + } + // phase 2: a later retry (fresh fetch stub) succeeds and renders + vi.stubGlobal('fetch', (url) => { + if (String(url).includes(`/api/projects/${PID}`)) + return Promise.resolve(new Response(JSON.stringify(projectBody('重试后的项目')), { status: 200, headers: { 'content-type': 'application/json' } })); + return Promise.resolve(new Response('{"items":[]}', { status: 200, headers: { 'content-type': 'application/json' } })); + }); + projectsStore.clear(); + const once = await mountLayout(route('project-data', PID)); + // allow the ensure() fetch+reactive update to settle regardless of load + await new Promise((res) => setTimeout(res, 300)); + flushSync(); + try { + expect(projectOf(PID)?.name).toBe('重试后的项目'); + expect(once.el.textContent).toContain('重试后的项目'); + } finally { + unmount(once.inst); + once.el.remove(); + vi.unstubAllGlobals(); + } + }); +}); diff --git a/frontend/src/lib/state.ts b/frontend/src/lib/state.ts new file mode 100644 index 0000000..3beb2c0 --- /dev/null +++ b/frontend/src/lib/state.ts @@ -0,0 +1,15 @@ +import type { ApiError } from './api'; + +export function extractStatusMessage(err: unknown): string { + const e = err as Partial<ApiError>; + if (e && typeof e.status === 'number') { + return `联网出错:HTTP ${e.status} ${e.message ?? ''}`.trim(); + } + if (e && e.message) return `出错:${e.message}`; + return '出错:未知错误,请稍后再试'; +} + +export function statusByName(err: unknown): string { + const code = (err as Partial<ApiError>)?.code ?? ''; + return code; +} diff --git a/frontend/src/lib/strategyTab.test.ts b/frontend/src/lib/strategyTab.test.ts new file mode 100644 index 0000000..a5bf6d8 --- /dev/null +++ b/frontend/src/lib/strategyTab.test.ts @@ -0,0 +1,95 @@ +// @ts-nocheck +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { mount, unmount } from 'svelte'; +// NOTE: `resolve.conditions: ['browser']` lets vitest resolve svelte's client +// build so actual component mounting works in jsdom. +import { putDraft } from '../lib/client'; +import { ApiError } from '../lib/api'; + +// Live regression (artifacts/qa/supervisor-live/browser-strategy.json): +// ProjectStrategyTab rendered `c(...).slice is not a function` pageerror and a +// blank main because the backend sends draft_generation as a NUMBER (i64) while +// the frontend typed it as a string and called .slice on it. Backend keeps the +// integer; these tests exercise the ACTUAL numeric API shape through the real +// fetch client and the REAL component render in jsdom (no source-string-only +// assertions). + +const PROJECT_ID = '7630e296-0b2f-44c3-85f1-fe695e7a7e4b'; + +const json = (v, status = 200) => + new Response(JSON.stringify(v), { status, headers: { 'content-type': 'application/json' } }); + +function projectBody(generation) { + return { + id: PROJECT_ID, name: '端到端验收项目', description: null, + draft_code: 'import backtrader as bt\nclass Strategy(bt.Strategy):\n pass\n', + draft_generation: generation, // NUMERIC contract from the backend (i64) + created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', + }; +} + +async function mountStrategyTab(fetchImpl) { + vi.stubGlobal('fetch', fetchImpl); + const ProjectStrategyTab = (await import('../pages/ProjectStrategyTab.svelte')).default; + const host = document.createElement('div'); + document.body.appendChild(host); + const inst = mount(ProjectStrategyTab, { target: host, props: { projectId: PROJECT_ID } }); + // flush effects/microtasks + await new Promise((r) => setTimeout(r, 50)); + return { host, inst, cleanup: () => { try { unmount(inst); } catch {} host.remove(); vi.unstubAllGlobals(); } }; +} + +function handler() { + return (url, _opts = {}) => { + if (/\/api\/projects\/[0-9a-f-]+$/.test(String(url))) + return Promise.resolve(json(projectBody(3))); + if (/\/versions/.test(String(url))) + return Promise.resolve(json({ items: [] })); + return Promise.resolve(json({})); + }; +} + +describe('strategy tab numeric generation contract (pageerror regression)', () => { + beforeEach(() => { document.body.innerHTML = ''; }); + afterEach(() => vi.unstubAllGlobals()); + + it('full numeric flow renders editor + 草稿代 3 without .slice crash', async () => { + const { host, cleanup } = await mountStrategyTab(handler()); + try { + const text = host.textContent; + // editor content from the numeric flow (component reached rendering) + expect(text).toContain('class Strategy(bt.Strategy)'); + // generation renders as the actual number, never a truncated-slice crash + expect(text).toMatch(/草稿代\s*3(?:\s|$|·)/); + expect(text).not.toContain('slice is not a function'); + } finally { cleanup(); } + }); + + it('client PUT sends numeric expected_generation and accepts numeric response', async () => { + const seen = []; + vi.stubGlobal('fetch', (url, opts = {}) => { + seen.push([String(url), opts]); + return Promise.resolve(json({ id: PROJECT_ID, draft_code: 'x=1', draft_generation: 4 })); + }); + const res = await putDraft(PROJECT_ID, 'x=1', 3); + expect(res.draft_generation).toBe(4); + expect(typeof res.draft_generation).toBe('number'); + const [, sentOpts] = seen[0]; + const sentBody = typeof sentOpts.body === 'string' ? JSON.parse(sentOpts.body) : sentOpts.body; + expect(sentBody.expected_generation).toBe(3); + expect(typeof sentBody.expected_generation).toBe('number'); + }); + + it('409 conflict keeps stale_generation code (numeric retry flow preserved)', async () => { + vi.stubGlobal('fetch', (url) => { + if (/\/draft$/.test(String(url))) + return Promise.resolve(new Response(JSON.stringify({ error: { code: 'stale_generation', message: 'g3' } }), { status: 409, headers: { 'content-type': 'application/json' } })); + return Promise.resolve(json({})); + }); + let err; + await putDraft(PROJECT_ID, 'stale', 2).catch((e) => { err = e; }); + expect(err).toBeInstanceOf(ApiError); + expect(err.status).toBe(409); + expect(err.code).toBe('stale_generation'); + }); +}); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts new file mode 100644 index 0000000..db065e4 --- /dev/null +++ b/frontend/src/lib/types.ts @@ -0,0 +1,244 @@ +import type { CoverageEntry } from './coverage'; + +export type { CoverageEntry }; + +export interface User { + id: string; + email: string; + name: string; + role: 'admin' | 'member'; + active: boolean; + daily_run_limit?: number | null; + ai_enabled?: boolean; + created_at?: string; +} + +export interface Project { + id: string; + name: string; + description: string | null; + draft_code: string; + draft_generation: number; + draft_code_preview?: string; + created_at: string; + updated_at: string; +} + +export interface Version { + id: string; + project_id: string; + message: string; + hash: string; + source: 'manual' | 'run' | 'ai' | 'restore'; + created_at: string; + code?: string; +} + +export interface Instrument { + symbol: string; + market: string; + asset_type: 'stock' | 'etf' | 'index'; + name: string | null; + currency: string | null; + source?: string | null; +} + +export interface InstrumentSearchItem extends Instrument { + symbol_requested?: string; +} + +export interface InstrumentSearchResponse { + items: Instrument[]; + source: 'provider_suggest' | 'operator_entry' | 'none'; + status: string; +} + +export interface DatasetManifestObject { + instrument: string; + market?: string; + asset_type?: string; + provider: string; + endpoint?: string; + params?: Record<string, unknown>; + akshare_version?: string; + fetched_at?: string; + requested_start: string; + requested_end: string; + actual_start: string | null; + actual_end: string | null; + row_count: number; + columns: string[]; + adjustment: string; + frequency?: string; + warnings?: string[]; +} + +export interface DatasetRequest { + name?: string; + instruments: Instrument[]; + start_date: string; + end_date: string; + frequency: 'daily'; + adjustment: 'none' | 'qfq' | 'hfq'; + fields: string[]; +} + +export interface Dataset { + id: string; + name: string | null; + request: DatasetRequest; + status: 'pending' | 'running' | 'ready' | 'failed'; + manifest?: DatasetManifest | null; + manifest_hash?: string | null; + error?: string | null; + cache_hit?: boolean; + created_at: string; +} + +export interface DatasetManifest { + version?: string; + hash?: string; + objects: DatasetManifestObject[]; + columns?: string[]; +} + +export interface DatasetPreview { + columns: string[]; + rows: Record<string, unknown>[]; + coverage: CoverageEntry[]; + warnings: string[]; + truncated?: boolean; +} + +export interface RunConfig { + capital: number; + commission: number; + slippage: number; + benchmark_symbol?: string | null; + parameters?: Record<string, unknown>; +} + +export interface Run { + id: string; + project_id: string; + version_id: string; + dataset_id: string; + status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'; + config: RunConfig; + created_at: string; + started_at?: string | null; + finished_at?: string | null; + error?: string | null; + result?: BacktestResult | null; +} + +export interface Trade { + date: string; + symbol: string; + side: string; + quantity: number; + price: number; + commission: number; + value: number; +} + +export interface OrderRecord { + date?: string; + symbol?: string; + side?: string; + quantity?: number; + price?: number | null; + state?: string; + value?: number | null; + commission?: number | null; +} + +export interface BacktestMetrics { + total_return?: number | null; + annual_return?: number | null; + max_drawdown?: number | null; + sharpe?: number | null; + /** closed ROUND-TRIP count (open fully closed = 1), NOT fill count */ + trade_count?: number | null; + final_equity?: number | null; +} + +export interface BacktestResult { + equity: { date: string; equity: number; cash: number; benchmark?: number | null }[]; + orders?: OrderRecord[]; + trades: Trade[]; + metrics: BacktestMetrics; + logs: string[]; + engine: { name: string; version: string }; + warnings: string[]; + elapsed_ms?: number | null; + peak_rss_kb?: number | null; + data_manifest_hash?: string; +} + +export interface RunListItem extends Run { + project_name?: string; +} + +export interface AIUsageItem { + id: string; + project_id?: string; + model: string; + input_tokens?: number; + output_tokens?: number; + status?: string; + created_at: string; +} + +export interface AIUsage { + items: AIUsageItem[]; + totals?: { input_tokens?: number; output_tokens?: number; requests?: number }; + internal_poc?: boolean; +} + +export interface AIAssist { + id: string; + model: string; + explanation: string; + proposed_code: string; + diff: string | null; + base_generation: number; + usage?: { input_tokens?: number; output_tokens?: number } | null; + status?: string; +} + +export interface Invitation { + id: string; + email?: string | null; + role?: string; + token?: string | null; + expires_at?: string; + used_at?: string | null; + revoked?: boolean; + created_by?: string; + created_at?: string; +} + +export interface AdminUserUpdate extends Partial<Omit<User, 'id' | 'email' | 'created_at'>> {} + +export interface Capabilities { + frequencies: string[]; + asset_types: string[]; + adjustments: { code: string; label: string }[]; + fields: { code: string; label: string; raw?: boolean }[]; + limits: { + max_symbols: number; + max_years: number; + max_code_length: number; + }; + internal_only: boolean; +} + +export interface SessionInfo { + id: string; + created_at?: string; + last_seen_at?: string | null; + user_agent?: string | null; + expires_at?: string | null; + current?: boolean; +} diff --git a/frontend/src/lib/wizardError.test.ts b/frontend/src/lib/wizardError.test.ts new file mode 100644 index 0000000..43a6bb0 --- /dev/null +++ b/frontend/src/lib/wizardError.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +// Live browser QA regression: a failing dataset submit (e.g. HTTP422/500 from +// the backend) was captured into `submitErr` but never rendered, so the user +// saw silently nothing. The wizard markup must surface submitErr. +// Type support comes from vite/client's `*?raw` module declaration. +import wizardSource from '../components/DatasetWizard.svelte?raw'; + +describe('dataset wizard visible submit errors', () => { + it('renders backend submit failures (submitErr in template, not script-only)', () => { + const src: string = wizardSource as unknown as string; + // captured at least once + expect(src).toMatch(/submitErr = extractStatusMessage\(e\)/); + // and rendered outside the script: a template reference (Svelte template) + const template = src.slice(src.indexOf('/script') + 7); + expect(template).toMatch(/\{submitErr\}/); + expect(template).toMatch(/role="alert"/); + }); +}); diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..d94ef99 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,7 @@ +import { mount } from 'svelte'; +import './app.css'; +import App from './App.svelte'; + +const app = mount(App, { target: document.getElementById('app')! }); + +export default app; diff --git a/frontend/src/pages/AccountPage.svelte b/frontend/src/pages/AccountPage.svelte new file mode 100644 index 0000000..ef6d1a3 --- /dev/null +++ b/frontend/src/pages/AccountPage.svelte @@ -0,0 +1,189 @@ +<script lang="ts"> + import Page from '../components/Page.svelte'; + import Status from '../components/Status.svelte'; + import { session } from '../lib/session.svelte'; + import { getSessions, patchProfile, postPassword, deleteSession } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import { fmtDateTime } from '../lib/format'; + import type { SessionInfo } from '../lib/types'; + + let nameInput = $state(session.me?.name ?? ''); + let profileBusy = $state(false); + let profileOk = $state<string | null>(null); + let profileErr = $state<string | null>(null); + + let curPw = $state(''); + let newPw = $state(''); + let newPw2 = $state(''); + let pwBusy = $state(false); + let pwErr = $state<string | null>(null); + let pwOk = $state<string | null>(null); + + let sessions: SessionInfo[] = $state([]); + let sessBusy = $state(true); + let sessErr = $state<string | null>(null); + let sessMsg = $state<string | null>(null); + + $effect(() => { + nameInput = session.me?.name ?? ''; + void loadSessions(); + }); + + async function loadSessions() { + sessBusy = true; + sessErr = null; + try { + const { items } = await getSessions(); + sessions = items; + } catch (e) { + sessErr = extractStatusMessage(e); + } finally { + sessBusy = false; + } + } + + async function saveProfile(e: SubmitEvent) { + e.preventDefault(); + profileBusy = true; + profileOk = null; + profileErr = null; + try { + await patchProfile(nameInput.trim()); + profileOk = '资料已更新'; + await session.refresh(); + } catch (err) { + profileErr = extractStatusMessage(err); + } finally { + profileBusy = false; + } + } + + async function changePassword(e: SubmitEvent) { + e.preventDefault(); + pwErr = null; + if (newPw.length < 8) { + pwErr = '新密码长度需至少 8 位'; + return; + } + if (newPw !== newPw2) { + pwErr = '两次输入的新密码不一致'; + return; + } + pwBusy = true; + try { + await postPassword(curPw, newPw); + pwOk = '密码已更新;其他会话已被登出,请用新密码重新登录本机外的应用。'; + curPw = ''; + newPw = ''; + newPw2 = ''; + } catch (err) { + pwErr = extractStatusMessage(err); + } finally { + pwBusy = false; + } + } + + async function revoke(s: SessionInfo) { + sessMsg = null; + sessErr = null; + try { + await deleteSession(s.id); + sessMsg = s.current ? '该会话已注销;如果你注销的是当前会话,会稍后自动退出。' : '会话已撤销'; + await loadSessions(); + await session.refresh(); + } catch (e) { + sessErr = extractStatusMessage(e); + } + } +</script> + +<Page title="账户与安全" subtitle="资料、密码与个人会话管理"> + <div class="stack"> + <section class="card stack"> + <h2>个人信息</h2> + <form onsubmit={saveProfile} class="row"> + <label style="flex:1 1 200px"> + 姓名 + <input bind:value={nameInput} autocomplete="name" /> + </label> + <div> + 邮箱 + <div class="fixed">{session.me?.email ?? '—'}</div> + </div> + <div> + 角色 + <div>{session.me?.role === 'admin' ? '管理员' : '成员'}{session.me?.active === false ? ' · 已停用' : ''}</div> + </div> + <button class="btn primary" disabled={profileBusy || nameInput.trim().length === 0}> + {profileBusy ? '保存中…' : '保存资料'} + </button> + </form> + <Status busy={false} error={profileErr} empty={profileOk} /> + </section> + + <section class="card stack"> + <h2>修改密码</h2> + <form onsubmit={changePassword} class="stack"> + <div class="grid3 wrap-3"> + <label> + 当前密码 + <input type="password" bind:value={curPw} required autocomplete="current-password" /> + </label> + <label> + 新密码 + <input type="password" bind:value={newPw} required autocomplete="new-password" minlength={8} /> + </label> + <label> + 确认新密码 + <input type="password" bind:value={newPw2} required autocomplete="new-password" minlength={8} /> + </label> + </div> + <div class="row"> + <button class="btn primary" disabled={pwBusy}> + {pwBusy ? '更新中…' : '更新密码'} + </button> + </div> + <Status busy={false} error={pwErr} empty={pwOk} /> + </form> + </section> + + <section class="card stack"> + <h2>登录会话</h2> + <Status busy={sessBusy} error={sessErr} busyText="载入会话…" empty={null} /> + {#if !sessBusy && !sessErr && sessions.length === 0} + <Status busy={false} empty="没有其他会话记录" /> + {/if} + {#if sessions.length > 0} + <table class="data"> + <thead> + <tr> + <th>创建</th> + <th>最近活跃</th> + <th>设备/UA</th> + <th></th> + </tr> + </thead> + <tbody> + {#each sessions as s (s.id)} + <tr> + <td>{fmtDateTime(s.created_at)}</td> + <td>{fmtDateTime(s.last_seen_at)}</td> + <td class="hint">{s.user_agent?.slice(0, 60) ?? '—'}</td> + <td> + <div class="row"> + {#if s.current}<span class="badge teal">当前会话</span>{/if} + <button class="btn small danger" disabled={s.current && sessions.length === 1} onclick={() => void revoke(s)}> + {s.current ? '注销当前' : '撤销'} + </button> + </div> + </td> + </tr> + {/each} + </tbody> + </table> + {/if} + <Status busy={false} error={null} empty={sessMsg} /> + <p class="hint">更改密码或被管理员停用时,其他会话将自动失效。</p> + </section> + </div> +</Page> diff --git a/frontend/src/pages/AdminPage.svelte b/frontend/src/pages/AdminPage.svelte new file mode 100644 index 0000000..984962d --- /dev/null +++ b/frontend/src/pages/AdminPage.svelte @@ -0,0 +1,371 @@ +<script lang="ts"> + import Page from '../components/Page.svelte'; + import Status from '../components/Status.svelte'; + import Modal from '../components/Modal.svelte'; + import { + listAdminUsers, + patchAdminUser, + createInvitation, + listInvitations, + deleteInvitation, + getAdminAudit, + postAdminUserPassword + } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import { fmtDateTime } from '../lib/format'; + import type { User, Invitation } from '../lib/types'; + + let tab = $state<'users' | 'invites' | 'audit'>('users'); + + let users: User[] = $state([]); + let usersBusy = $state(true); + let usersErr = $state<string | null>(null); + let resetFor = $state<User | null>(null); + let resetToken = $state(''); + let resetting = $state(false); + let adminOpsErr = $state<string | null>(null); + let inviteCopied = $state(false); + + let invites: Invitation[] = $state([]); + let inviteBusy = $state(true); + let inviteErr = $state<string | null>(null); + let inviteEmail = $state(''); + let inviteHours = $state(72); + let creatingInvite = $state(false); + let newInvite = $state<{ token: string; expires_at: string } | null>(null); + let gotToken = $state(false); + + let audit: { actor?: string | null; action: string; target?: string | null; status: string; time: string; detail?: string | null }[] = $state([]); + let auditBusy = $state(true); + let auditErr = $state<string | null>(null); + + $effect(() => { + void loadTab(tab); + }); + + async function loadTab(t: 'users' | 'invites' | 'audit') { + if (t === 'users') await loadUsers(); + else if (t === 'invites') await loadInvites(); + else await loadAudit(); + } + + async function loadUsers() { + usersBusy = true; + usersErr = null; + try { + const { items } = await listAdminUsers(); + users = items; + } catch (e) { + usersErr = extractStatusMessage(e); + } finally { + usersBusy = false; + } + } + + async function loadInvites() { + inviteBusy = true; + inviteErr = null; + try { + const { items } = await listInvitations(); + invites = items; + } catch (e) { + inviteErr = extractStatusMessage(e); + } finally { + inviteBusy = false; + } + } + + async function loadAudit() { + auditBusy = true; + auditErr = null; + try { + const res = await getAdminAudit(); + audit = res.items; + } catch (e) { + auditErr = extractStatusMessage(e); + } finally { + auditBusy = false; + } + } + + async function patchUser(u: User, patch: { active?: boolean; role?: 'admin' | 'member'; daily_run_limit?: number | null; ai_enabled?: boolean }) { + adminOpsErr = null; + try { + const fresh = await patchAdminUser(u.id, patch); + users = users.map((x) => (x.id === fresh.id ? fresh : x)); + } catch (e) { + adminOpsErr = extractStatusMessage(e); + } + } + + async function issueReset(u: User) { + resetFor = u; + resetToken = ''; + adminOpsErr = null; + resetting = true; + try { + const res = await postAdminUserPassword(u.id); + resetToken = res.reset_token; + } catch (e) { + adminOpsErr = extractStatusMessage(e); + resetFor = null; + } finally { + resetting = false; + } + } + + async function makeInvite(e: SubmitEvent) { + e.preventDefault(); + creatingInvite = true; + inviteErr = null; + newInvite = null; + try { + const res = await createInvitation({ + email: inviteEmail.trim() || undefined, + expires_hours: inviteHours + }); + newInvite = res; + inviteEmail = ''; + await loadInvites(); + } catch (err) { + inviteErr = extractStatusMessage(err); + } finally { + creatingInvite = false; + } + } + + async function revokeInvite(i: Invitation) { + adminOpsErr = null; + try { + await deleteInvitation(i.id); + await loadInvites(); + } catch (e) { + adminOpsErr = extractStatusMessage(e); + } + } + + async function copy(text: string) { + try { + await navigator.clipboard.writeText(text); + inviteCopied = true; + } catch { + /* clipboard unavailable; user will select manually */ + } + } + + const inviteUrl = $derived( + newInvite + ? `${window.location.origin}${window.location.pathname}#/register?invite=${encodeURIComponent(newInvite.token)}` + : '' + ); + const resetUrl = $derived( + resetToken + ? `${window.location.origin}${window.location.pathname}#/reset?token=${encodeURIComponent(resetToken)}` + : '' + ); +</script> + +<Page title="管理员" subtitle="用户与邀请管理、安全审计。不会直接查看他人策略代码"> + {#snippet actions()} + <div class="tabs" role="tablist"> + <button role="tab" aria-selected={tab === 'users'} onclick={() => (tab = 'users')}>用户</button> + <button role="tab" aria-selected={tab === 'invites'} onclick={() => (tab = 'invites')}>邀请与恢复</button> + <button role="tab" aria-selected={tab === 'audit'} onclick={() => (tab = 'audit')}>审计日志</button> + </div> + {/snippet} + + <div class="stack"> + <Status busy={false} error={adminOpsErr} empty={null} /> + + {#if tab === 'users'} + <section class="card stack"> + <h2>用户</h2> + <Status busy={usersBusy} error={usersErr} busyText="载入用户…" empty={null} /> + {#if !usersBusy && !usersErr && users.length > 0} + <table class="data"> + <thead> + <tr> + <th>邮箱</th> + <th>姓名</th> + <th>角色</th> + <th>状态</th> + <th>AI 权限</th> + <th>日回测上限</th> + <th>操作</th> + </tr> + </thead> + <tbody> + {#each users as u (u.id)} + <tr> + <td>{u.email}</td> + <td>{u.name}</td> + <td> + <select + value={u.role} + aria-label={`角色 ${u.email}`} + onchange={(ev) => void patchUser(u, { role: (ev.target as HTMLSelectElement).value as 'admin' | 'member' })} + > + <option value="member" selected={u.role === 'member'}>成员</option> + <option value="admin" selected={u.role === 'admin'}>管理员</option> + </select> + </td> + <td> + <span class="badge {u.active ? 'ok' : 'danger'}">{u.active ? '启用' : '停用'}</span> + </td> + <td> + <label class="checkline"> + <input type="checkbox" checked={u.ai_enabled ?? false} onchange={(e) => void patchUser(u, { ai_enabled: (e.target as HTMLInputElement).checked })} /> + </label> + </td> + <td> + <input + class="num" + style="width:90px" + type="number" + min="0" + max="200" + value={u.daily_run_limit ?? 5} + onchange={(e) => void patchUser(u, { daily_run_limit: Number((e.target as HTMLInputElement).value) })} + /> + </td> + <td> + <div class="row"> + <button + class="btn small {u.active ? 'danger' : ''}" + onclick={() => void patchUser(u, { active: !u.active })} + > + {u.active ? '停用' : '启用'} + </button> + <button class="btn small" disabled={resetting} onclick={() => void issueReset(u)}>生成恢复</button> + </div> + </td> + </tr> + {/each} + </tbody> + </table> + {/if} + <p class="hint">停用账户会立即吊销其会话并阻止其回测与 AI 请求。角色不能作用于他人项目,私阅策略代码不提供。</p> + </section> + {:else if tab === 'invites'} + <section class="card stack"> + <h2>签发邀请</h2> + <form onsubmit={makeInvite} class="row"> + <label style="flex:1 1 220px"> + 邮箱(可选备注) + <input bind:value={inviteEmail} placeholder="📬 将线下告知受邀人,用于区分" /> + </label> + <label style="flex:0 0 130px"> + 有效小时 + <input type="number" bind:value={inviteHours} min="1" max="720" /> + </label> + <button class="btn primary" disabled={creatingInvite}>{creatingInvite ? '生成中…' : '生成邀请'}</button> + </form> + <Status busy={false} error={inviteErr} empty={null} /> + {#if newInvite} + <div class="banner info"> + <div> + <div class="cov-caption">邀请令牌(只显示这一次,请立即复制):</div> + <div class="mono-invite">{newInvite.token}</div> + <div class="hint">到期:{fmtDateTime(newInvite.expires_at)} · 单次使用 · 注册页链接:</div> + <div class="mono-invite" title="复制该完整链接给对方">{inviteUrl}</div> + <button class="btn small" onclick={() => void copy(inviteUrl)}>{inviteUrl && inviteCopied ? '已复制' : '复制链接'}</button> + </div> + </div> + {/if} + </section> + + <section class="card stack"> + <h2>历史邀请</h2> + <Status busy={inviteBusy} error={inviteErr} busyText="载入邀请…" empty={null} /> + {#if !inviteBusy && !inviteErr && invites.length === 0} + <Status busy={false} empty="还没有邀请记录" /> + {/if} + {#if !inviteBusy && invites.length > 0} + <table class="data"> + <thead> + <tr> + <th>邮箱备注</th> + <th>角色</th> + <th>创建时间</th> + <th>过期</th> + <th>状态</th> + <th></th> + </tr> + </thead> + <tbody> + {#each invites as inv (inv.id)} + <tr> + <td>{inv.email ?? '—'}</td> + <td>{inv.role === 'admin' ? '管理员' : '成员'}</td> + <td>{fmtDateTime(inv.created_at)}</td> + <td>{fmtDateTime(inv.expires_at)}</td> + <td> + {#if inv.used_at}<span class="badge grey">已使用</span> + {:else if inv.revoked}<span class="badge grey">已撤销</span> + {:else if inv.expires_at && new Date(inv.expires_at) < new Date()}<span class="badge grey">已过期</span> + {:else}<span class="badge teal">有效</span> + {/if} + </td> + <td> + {#if !inv.used_at && !inv.revoked} + <button class="btn small danger" onclick={() => void revokeInvite(inv)}>撤销</button> + {/if} + </td> + </tr> + {/each} + </tbody> + </table> + {/if} + <p class="hint">恢复链接由“生成恢复”按钮产生,仅出现一次;不会自动发邮件。</p> + </section> + {:else} + <section class="card stack"> + <h2>安全审计(收尾尾部)</h2> + <Status busy={auditBusy} error={auditErr} busyText="载入审计记录…" empty={null} /> + {#if !auditBusy && !auditErr && audit.length === 0} + <Status busy={false} empty="暂无审计事件" /> + {/if} + {#if !auditBusy && audit.length > 0} + <table class="data"> + <thead> + <tr> + <th>时间</th> + <th>行为者</th> + <th>事件</th> + <th>对象</th> + <th>结果</th> + </tr> + </thead> + <tbody> + {#each audit as e, i (i)} + <tr> + <td>{fmtDateTime(e.time)}</td> + <td>{e.actor ?? '—'}</td> + <td>{e.action}</td> + <td class="num">{e.target ?? '—'}</td> + <td><span class="badge {e.status === 'ok' || e.status === 'success' ? 'ok' : 'danger'}">{e.status}</span></td> + </tr> + {/each} + </tbody> + </table> + {/if} + <p class="hint">审计仅记录操作行为与结果,不含密码/密钥/策略代码内容。</p> + </section> + {/if} + </div> + + {#if resetFor} + <Modal title={`为 ${resetFor.email} 生成密码恢复`} onClose={() => (resetFor = null)}> + <div class="stack"> + <div class="banner info"> + <div> + <div>恢复链接(管理员线下告知对方,只出现一次):</div> + <div class="mono-invite">{resetUrl}</div> + <div class="hint">令牌 {resetToken.slice(0, 8)}… 会即刻失效前提示时间(见链接有效期)。</div> + </div> + </div> + <p class="hint">单次有效、限时;重置会吊销对方的全部历史会话。</p> + </div> + </Modal> + {/if} +</Page> diff --git a/frontend/src/pages/AuthPages.svelte b/frontend/src/pages/AuthPages.svelte new file mode 100644 index 0000000..9e56be2 --- /dev/null +++ b/frontend/src/pages/AuthPages.svelte @@ -0,0 +1,187 @@ +<script lang="ts"> + import type { Route } from '../lib/router'; + import { session } from '../lib/session.svelte'; + import { go } from '../lib/router'; + import { postLogin, postRegister, postResetPassword } from '../lib/client'; + import { extractStatusMessage, statusByName } from '../lib/state'; + + interface Props { + route: Route; + } + let { route }: Props = $props(); + + const mode = $derived(route.name); + + let email = $state(''); + let password = $state(''); + let name = $state(''); + // Initial route query is deliberately read once: token is consumed at page load. + // svelte-ignore state_referenced_locally + let inviteToken = $state(route.query.get('invite') ?? ''); + // svelte-ignore state_referenced_locally + let resetToken = $state(route.query.get('token') ?? ''); + let busy = $state(false); + let resultError = $state<string | null>(null); + let done = $state<string | null>(null); + let pw2 = $state(''); + let newPassword = $state(''); + + async function login(e: SubmitEvent) { + e.preventDefault(); + busy = true; + resultError = null; + try { + await postLogin(email.trim(), password); + const user = await session.refresh(); + if (user) session.set(user); + go('/projects'); + } catch (err) { + if (statusByName(err) === 'unauthorized') resultError = '邮箱或密码不正确,或账户已被停用'; + else resultError = extractStatusMessage(err); + } finally { + busy = false; + } + } + + async function register(e: SubmitEvent) { + e.preventDefault(); + busy = true; + resultError = null; + try { + const { user } = await postRegister(inviteToken.trim(), name.trim(), email.trim(), password); + session.set(user); + go('/projects'); + } catch (err) { + resultError = extractStatusMessage(err); + } finally { + busy = false; + } + } + + async function reset(e: SubmitEvent) { + e.preventDefault(); + busy = true; + resultError = null; + try { + if (!resetToken.trim()) { + resultError = '链接中的重置令牌未提供。请使用管理员发给您的完整恢复链接打开本页。'; + return; + } + if (newPassword.length < 8) { + resultError = '新密码长度需至少 8 位'; + return; + } + if (newPassword !== pw2) { + resultError = '两次输入的新密码不一致'; + return; + } + await postResetPassword(resetToken.trim(), newPassword); + done = '密码已重置,现在可以用新密码登录。'; + } catch (err) { + resultError = extractStatusMessage(err); + } finally { + busy = false; + } + } +</script> + +<section class="auth-wrap"> + <div class="auth-card card" role="main"> + <div class="auth-brand"> + <span class="mark" aria-hidden="true"></span> + <h1>策研 <span class="sub">Strategy Lab</span></h1> + <p class="hint">内部策略研究平台 · 仅限受邀成员</p> + </div> + + {#if mode === 'register'} + <form onsubmit={register}> + <div> + <label for="inv">邀请令牌</label> + <input id="inv" bind:value={inviteToken} required autocomplete="off" /> + <p class="hint">单次有效,来自管理员邀请</p> + </div> + <div> + <label for="nm">姓名</label> + <input id="nm" bind:value={name} required autocomplete="name" /> + </div> + <div> + <label for="em">邮箱</label> + <input id="em" type="email" bind:value={email} required autocomplete="email" /> + </div> + <div> + <label for="pw">密码</label> + <input id="pw" type="password" bind:value={password} required autocomplete="new-password" minlength={8} /> + <div class="hint">至少 8 位</div> + </div> + {#if resultError}<div class="banner error" role="alert">{resultError}</div>{/if} + <button type="submit" class="btn primary" disabled={busy}>{busy ? '创建中…' : '开通账户'}</button> + <p class="hint">已有账户? <a href="#/login">登录</a></p> + </form> + {:else if mode === 'reset'} + {#if done} + <div class="banner info">{done}</div> + <p><a href="#/login">前往登录</a></p> + {:else} + <form onsubmit={reset}> + <div> + <label for="rt">重置令牌</label> + <input id="rt" bind:value={resetToken} autocomplete="off" /> + <p class="hint">由管理员线下发放的恢复链接自动填入;不支持邮箱自动发送</p> + </div> + <div> + <label for="np">新密码</label> + <input id="np" type="password" bind:value={newPassword} required autocomplete="new-password" minlength={8} /> + </div> + <div> + <label for="np2">再输入一次新密码</label> + <input id="np2" type="password" bind:value={pw2} required autocomplete="new-password" minlength={8} /> + </div> + {#if resultError}<div class="banner error" role="alert">{resultError}</div>{/if} + <button type="submit" class="btn primary" disabled={busy}>{busy ? '重置中…' : '重置密码'}</button> + <p class="hint">想起密码了? <a href="#/login">直接登录</a></p> + </form> + {/if} + {:else} + <form onsubmit={login}> + <div> + <label for="le">邮箱</label> + <input id="le" type="email" bind:value={email} required autocomplete="email" /> + </div> + <div> + <label for="lp">密码</label> + <input id="lp" type="password" bind:value={password} required autocomplete="current-password" /> + </div> + {#if resultError}<div class="banner error" role="alert">{resultError}</div>{/if} + <button type="submit" class="btn primary" disabled={busy}>{busy ? '登录中…' : '登录'}</button> + <p class="hint"> + 开通新账户需要管理员邀请: <a href="#/register">用邀请令牌注册</a> + {#if route.query.get('invite')}<span>(邀请令牌已自动填入)</span>{/if} + </p> + <p class="hint">忘记密码?请联系管理员获取 <a href="#/reset">恢复链接</a></p> + </form> + {/if} + </div> +</section> + +<style> + .auth-wrap { + min-height: 100vh; + display: grid; + place-items: center; + padding: 1.5rem; + background: + radial-gradient(700px 350px at 85% -60px, rgba(13, 148, 136, 0.08), transparent), + var(--bg); + } + .auth-card { width: 400px; max-width: 100%; padding: 1.6rem; } + .auth-brand { margin-bottom: 1.1rem; } + .mark { + display: inline-block; + width: 34px; + height: 34px; + border-radius: 9px; + background: var(--teal-strong); + margin-bottom: 0.6rem; + } + .sub { color: var(--text-3); font-weight: 500; font-size: 0.8rem; } +</style> diff --git a/frontend/src/pages/DatasetsPage.svelte b/frontend/src/pages/DatasetsPage.svelte new file mode 100644 index 0000000..b348ee3 --- /dev/null +++ b/frontend/src/pages/DatasetsPage.svelte @@ -0,0 +1,8 @@ +<script lang="ts"> + import Page from '../components/Page.svelte'; + import ProjectDataTab from './ProjectDataTab.svelte'; +</script> + +<Page title="数据集" subtitle="你的所有数据请求与物化数据(跨项目共享目录,锁定标的信息与覆盖情况)"> + <ProjectDataTab /> +</Page> diff --git a/frontend/src/pages/ProjectBacktestTab.svelte b/frontend/src/pages/ProjectBacktestTab.svelte new file mode 100644 index 0000000..baa9e5f --- /dev/null +++ b/frontend/src/pages/ProjectBacktestTab.svelte @@ -0,0 +1,384 @@ +<script lang="ts"> + import Status from '../components/Status.svelte'; + import { listDatasets, createRun, getRun, cancelRun, rerunRun, listRuns, getDatasetPreview } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import { + fmtDateTime, + fmtPct, + formatMoney, + datasetStatusLabel, + runStatusLabel, + runStatusBadge + } from '../lib/format'; + import { summarizeCoverage } from '../lib/coverage'; + import type { Run, Dataset } from '../lib/types'; + + interface Props { + projectId: string; + } + let { projectId }: Props = $props(); + + // config inputs + let datasetsReady: Dataset[] = $state([]); + let busyDatasets = $state(true); + let cfgErr = $state<string | null>(null); + + let datasetId = $state(''); + let capital = $state<number>(1_000_000); + let commission = $state(0.0003); + let slippage = $state(0.001); + let benchmark = $state(''); + let paramsText = $state(''); + let acknowledged = $state(false); + + // life cycle + let latest = $state<Run | null>(null); + let latestErr = $state<string | null>(null); + let cancelBusy = $state(false); + let rerunBusy = $state(false); + let actionMsg = $state<string | null>(null); + + let history: Run[] = $state([]); + let historyBusy = $state(false); + let historyErr = $state<string | null>(null); + + $effect(() => { + void loadDatasets(); + void loadLatest(true); + return () => { + if (pollTimer) clearInterval(pollTimer); + }; + }); + + let pollTimer: ReturnType<typeof setInterval> | null = null; + + function isLiveStatus(r: Run | null | undefined) { + return r?.status === 'queued' || r?.status === 'running'; + } + + async function loadDatasets() { + busyDatasets = true; + cfgErr = null; + try { + const { items } = await listDatasets(); + datasetsReady = items.filter((d) => d.status === 'ready'); + if (items.some((d) => d.status === 'pending' || d.status === 'running')) { + if (!waitTimer) { + waitTimer = setInterval(() => { + void loadDatasets(); + }, 2500); + } + } else { + stopWait(); + } + } catch (e) { + cfgErr = extractStatusMessage(e); + } finally { + busyDatasets = false; + } + } + + let waitTimer: ReturnType<typeof setInterval> | null = null; + + function stopWait() { + if (waitTimer) { + clearInterval(waitTimer); + waitTimer = null; + } + } + + async function loadLatest(startPolling: boolean) { + latestErr = null; + try { + const res = await listRuns(projectId); + history = res.items; + latest = res.items[0] ?? null; + if (startPolling && isLiveStatus(latest) && !pollTimer) { + pollTimer = setInterval(() => void loadLatest(false), 2000); + } + if (!isLiveStatus(latest) && pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + await refreshHistory(); + } + } catch (e) { + latestErr = extractStatusMessage(e); + } + } + + async function refreshHistory() { + historyBusy = true; + historyErr = null; + try { + const res = await listRuns(projectId); + history = res.items; + } catch (e) { + historyErr = extractStatusMessage(e); + } finally { + historyBusy = false; + } + } + + async function previewWarningsForDataset(id: string) { + try { + const preview = await getDatasetPreview(id); + const cov = summarizeCoverage(preview.coverage ?? []); + datasetWarnMsgs = [...cov.warnings, ...(preview.warnings ?? [])]; + } catch { + datasetWarnMsgs = []; + } + acknowledged = false; + } + + let datasetWarnMsgs: string[] = $state([]); + + const selectedDataset = $derived(datasetsReady.find((d) => d.id === datasetId) ?? null); + const needAck = $derived(datasetWarnMsgs.length > 0); + + $effect(() => { + if (datasetId) void previewWarningsForDataset(datasetId); + }); + + async function startRun() { + const errs: string[] = []; + if (!datasetId) errs.push('请选择一个就绪的数据集'); + if (!(capital > 0) || !Number.isFinite(capital)) errs.push('起始资金必须是正数'); + if (!(commission >= 0 && commission < 0.1)) errs.push('佣金率应在 0 与 0.1 之间'); + if (!(slippage >= 0 && slippage < 0.1)) errs.push('滑点应在 0 与 0.1 之间'); + let parameters: Record<string, unknown> | undefined; + if (paramsText.trim()) { + try { + parameters = JSON.parse(paramsText); + } catch { + errs.push('参数必须是合法 JSON'); + } + } + if (errs.length > 0) { + cfgErr = errs.join(';'); + return; + } + cfgErr = null; + if (needAck && !acknowledged) { + cfgErr = '数据覆盖有差异,请先勾选确认已知晓后再启动回测'; + return; + } + try { + const run = await createRun({ + project_id: projectId, + dataset_id: datasetId, + capital, + commission, + slippage, + benchmark_symbol: benchmark.trim() || undefined, + parameters, + acknowledge_warnings: acknowledged + }); + latest = run; + await loadLatest(true); + actionMsg = `回测已提交,编号 ${run.id.slice(0, 8)}…`; + void refreshHistory(); + } catch (e) { + cfgErr = extractStatusMessage(e); + const details = (e as { details?: { errors?: string[] } }).details?.errors; + if (details) cfgErr = details.join(';'); + } + } + + + + async function onCancel() { + if (!latest) return; + cancelBusy = true; + try { + latest = await cancelRun(latest.id); + actionMsg = '已请求取消'; + } catch (e) { + actionMsg = extractStatusMessage(e); + } finally { + cancelBusy = false; + } + } + + async function onRerun() { + if (!latest) return; + rerunBusy = true; + try { + const run = await rerunRun(latest.id, true); + latest = run; + await loadLatest(true); + actionMsg = `已用原始代码与数据重新提交,编号 ${run.id.slice(0, 8)}…`; + } catch (e) { + actionMsg = extractStatusMessage(e); + } finally { + rerunBusy = false; + } + } + + const canCancel = $derived(isLiveStatus(latest)); + const canRerun = $derived(latest?.status === 'succeeded' || latest?.status === 'failed' || latest?.status === 'cancelled'); +</script> + +<div class="stack"> + <section class="card stack"> + <h2>发起回测</h2> + <Status busy={busyDatasets} error={cfgErr} busyText="检查就绪的数据集…" empty={null} /> + {#if !busyDatasets && datasetsReady.length === 0 && !cfgErr} + <Status busy={false} empty="没有就绪的数据集" emptyHint="先在「数据」标签创建并等待数据集状态变为【就绪】,再回到这里发起回测" /> + {/if} + {#if datasetsReady.length > 0} + <div class="grid3 wrap-3"> + <label> + 数据集 + <select bind:value={datasetId}> + <option value="" disabled>选择数据集…</option> + {#each datasetsReady as d (d.id)} + <option value={d.id}> + {d.name ?? d.id.slice(0, 8)} · {d.request.start_date}~{d.request.end_date} · {d.request.instruments.map((i) => i.symbol).join(',')} + </option> + {/each} + </select> + </label> + <label> + 起始资金 + <input class="num" type="number" bind:value={capital} min="1" step="1000" /> + </label> + <div class="row2"> + <label> + 佣金率 + <input class="num" type="number" bind:value={commission} min="0" max="0.1" step="0.0001" /> + </label> + <label> + 滑点率 + <input class="num" type="number" bind:value={slippage} min="0" max="0.1" step="0.0001" /> + </label> + </div> + <label> + 基准代码(可选) + <input bind:value={benchmark} placeholder="例如 000300.SH 指数代码" /> + </label> + <label> + 策略参数(JSON,可选) + <textarea rows="1" bind:value={paramsText} placeholder='输入 JSON 参数,例如 fast/slow 周期'></textarea> + </label> + </div> + {#if needAck} + <div class="banner warn" role="alert"> + <span aria-hidden="true">⚠</span> + <div> + 该数据集存在覆盖/预警信息,需要明确知晓后才能启动: + <ul> + {#each datasetWarnMsgs as m, i (i)}<li>{m}</li>{/each} + </ul> + <label class="checkline"> + <input type="checkbox" bind:checked={acknowledged} /> + 我已知晓并用当前数据继续 + </label> + </div> + </div> + {/if} + <div class="row"> + <button class="btn primary" onclick={startRun} disabled={!datasetId}>开始回测</button> + {#if selectedDataset} + <span class="hint">本次回测与该数据集、当前策略快照一同记录,可按原始条件重跑</span> + {/if} + </div> + <Status busy={false} error={cfgErr} empty={actionMsg} /> + {/if} + </section> + + <section class="card stack"> + <div class="spread"> + <h2>最近一次回测</h2> + {#if latest} + <span class="row"> + <span class="badge">Run 分支 {latest.version_id?.slice(0,8) ?? "—"}</span> + <span class="badge {runStatusBadge[latest.status]}">{runStatusLabel[latest.status]}</span> + </span> + {/if} + </div> + + {#if latest} + <Status + busy={isLiveStatus(latest)} + busyText="排队/执行中,页面会自动刷新状态…" + empty={null} + error={latestErr} + /> + <div class="rows-grid"> + <table class="data kv"> + <tbody> + <tr><th>启动时间</th><td>{fmtDateTime(latest.created_at)}</td></tr> + {#if latest.started_at}<tr><th>开始执行</th><td>{fmtDateTime(latest.started_at)}</td></tr>{/if} + {#if latest.finished_at}<tr><th>结束时间</th><td>{fmtDateTime(latest.finished_at)}</td></tr>{/if} + <tr><th>代码版本</th><td><span class="num">{latest.version_id.slice(0, 10)}</span></td></tr> + <tr><th>数据集</th><td><span class="num">{latest.dataset_id?.slice(0, 8)}…</span></td></tr> + <tr><th>资金</th><td>{formatMoney(latest.config.capital)} · 佣金 {fmtPct(latest.config.commission, 4)} · 滑点 {fmtPct(latest.config.slippage, 4)}</td></tr> + </tbody> + </table> + </div> + {#if latest.error} + <div class="banner error" role="alert"><span aria-hidden="true">✕</span><span>{latest.error}</span></div> + {/if} + {#if latest.result?.warnings?.length} + <div class="banner warn" role="alert"> + <span aria-hidden="true">⚠</span> + <ul class="plain-list"> + {#each latest.result.warnings as w, i (i)}<li>{w}</li>{/each} + </ul> + </div> + {/if} + {#if latest.status === 'succeeded' && latest.result} + <a class="btn" href={`#/projects/${projectId}/results?run=${latest.id}`}>查看完整结果与图表</a> + {/if} + <div class="row"> + {#if canCancel} + <button class="btn danger" onclick={onCancel} disabled={cancelBusy}> + {cancelBusy ? '取消中…' : '取消回测'} + </button> + {/if} + {#if canRerun} + <button class="btn" onclick={onRerun} disabled={rerunBusy}> + {rerunBusy ? '重新提交…' : '按原始代码&数据重跑'} + </button> + {/if} + </div> + {:else} + <Status busy={false} empty="项目还没有回测记录" emptyHint="在上方选择数据集并启动一次回测" /> + {/if} + </section> + + <section class="stack"> + <h2>回测历史</h2> + <Status busy={historyBusy} error={historyErr} busyText="载入历史…" empty={null} /> + {#if history && history.length > 0} + <div class="card" style="padding:0.4rem 0.6rem"> + <table class="data"> + <thead> + <tr> + <th>启动时间</th> + <th>状态</th> + <th>资金/佣金/滑点</th> + <th>数据集</th> + <th>结果</th> + </tr> + </thead> + <tbody> + {#each history as h (h.id)} + <tr> + <td>{fmtDateTime(h.created_at)}</td> + <td>{h.status === 'succeeded' ? '完成' : h.status === 'failed' ? '失败' : h.status === 'running' ? '执行中' : h.status === 'cancelled' ? '已取消' : '排队中'}</td> + <td> + {formatMoney(h.config.capital)} · {fmtPct(h.config.commission, 4)} / {fmtPct(h.config.slippage, 4)} + </td> + <td class="num">{h.dataset_id?.slice(0, 8)}…</td> + <td> + <a class="btn small" href={`#/projects/${projectId}/results?run=${h.id}`}>结果</a> + </td> + </tr> + {/each} + </tbody> + </table> + </div> + {/if} + </section> +</div> diff --git a/frontend/src/pages/ProjectDataTab.svelte b/frontend/src/pages/ProjectDataTab.svelte new file mode 100644 index 0000000..e3b1923 --- /dev/null +++ b/frontend/src/pages/ProjectDataTab.svelte @@ -0,0 +1,85 @@ +<script lang="ts"> + import Status from '../components/Status.svelte'; + import DatasetCard from '../components/DatasetCard.svelte'; + import DatasetWizard from '../components/DatasetWizard.svelte'; + import { listDatasets } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import type { Dataset } from '../lib/types'; + + interface Props { + projectId?: string; + showWizard?: boolean; + } + let { projectId: _projectId, showWizard = false }: Props = $props(); + + let items: Dataset[] = $state([]); + let busy = $state(true); + let error: string | null = $state(null); + // svelte-ignore state_referenced_locally + let wizardOpen = $state(showWizard); + + + let refreshTimer: ReturnType<typeof setInterval> | null = null; + + $effect(() => { + void load(); + return () => { + if (refreshTimer) clearInterval(refreshTimer); + refreshTimer = null; + }; + }); + + async function load() { + error = null; + try { + const { items: got } = await listDatasets(); + items = got; + const active = got.some((d) => d.status === 'pending' || d.status === 'running'); + if (active && !refreshTimer) { + refreshTimer = setInterval(async () => { + try { + const { items: fresh } = await listDatasets(); + items = fresh; + const still = fresh.some((d) => d.status === 'pending' || d.status === 'running'); + if (!still && refreshTimer) { + clearInterval(refreshTimer); + refreshTimer = null; + } + } catch { + /* transient error, keep polling */ + } + }, 2000); + } + } catch (e) { + error = extractStatusMessage(e); + } finally { + busy = false; + } + } +</script> + +<div class="stack"> + {#if wizardOpen} + <DatasetWizard onCreated={() => void load()} /> + <button class="btn ghost" onclick={() => (wizardOpen = false)}>收起新建面板</button> + {:else} + <button class="btn primary" onclick={() => (wizardOpen = true)}>新建数据集</button> + {/if} + + <h2>我的数据集</h2> + <Status busy={busy} error={error} busyText="加载数据集列表…" empty={null} /> + + {#if !busy && !error && items.length === 0} + <Status + busy={false} + empty="还没有数据集" + emptyHint="先创建数据集:选择标的、日期范围与复权方式,提交后异步获取并给出覆盖预览" + /> + {/if} + + {#if !busy && !error && items.length > 0} + {#each items as ds (ds.id)} + <DatasetCard dsId={ds.id} /> + {/each} + {/if} +</div> diff --git a/frontend/src/pages/ProjectPage.svelte b/frontend/src/pages/ProjectPage.svelte new file mode 100644 index 0000000..b31caa3 --- /dev/null +++ b/frontend/src/pages/ProjectPage.svelte @@ -0,0 +1,23 @@ +<script lang="ts"> + import type { Route } from '../lib/router'; + import ProjectDataTab from '../pages/ProjectDataTab.svelte'; + import ProjectStrategyTab from '../pages/ProjectStrategyTab.svelte'; + import ProjectBacktestTab from '../pages/ProjectBacktestTab.svelte'; + import ProjectResultsTab from '../pages/ProjectResultsTab.svelte'; + + interface Props { + route: Route; + } + let { route }: Props = $props(); +</script> + +<!-- Layout is provided once by App.svelte; wrapping here produced the doubled sidebar bug. --> +{#if route.name === 'project-data'} + <ProjectDataTab projectId={route.params.id} /> +{:else if route.name === 'project-strategy'} + <ProjectStrategyTab projectId={route.params.id} /> +{:else if route.name === 'project-backtest'} + <ProjectBacktestTab projectId={route.params.id} /> +{:else if route.name === 'project-results'} + <ProjectResultsTab projectId={route.params.id} runId={route.query.get('run') ?? undefined} /> +{/if} diff --git a/frontend/src/pages/ProjectResultsTab.svelte b/frontend/src/pages/ProjectResultsTab.svelte new file mode 100644 index 0000000..4149213 --- /dev/null +++ b/frontend/src/pages/ProjectResultsTab.svelte @@ -0,0 +1,299 @@ +<script lang="ts"> + import Status from '../components/Status.svelte'; + import Chart from '../components/Chart.svelte'; + import type { EChartsOption, LineSeriesOption } from 'echarts'; +import { listRuns, getRun } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import { + fmtDateTime, + fmtPct, + fmtNum, + formatMoney, + runStatusLabel, + runStatusBadge, + metricsLabels, + metricsRow + } from '../lib/format'; + import type { Run, BacktestResult } from '../lib/types'; + + interface Props { + projectId: string; + runId?: string; + } + let { projectId, runId }: Props = $props(); + + let run = $state<Run | null>(null); + let busy = $state(true); + let error = $state<string | null>(null); + let history = $state<Run[]>([]); + let logsOpen = $state(false); + let ordersOpen = $state(false); + let chartMode = $state<'equity' | 'drawdown'>('equity'); + + $effect(() => { + void load(); + }); + + async function load() { + busy = true; + error = null; + try { + if (!runId) { + const res = await listRuns(projectId); + history = res.items; + run = res.items[0] ?? null; + } else { + run = await getRun(runId); + } + } catch (e) { + error = extractStatusMessage(e); + } finally { + busy = false; + } + } + + const result = $derived<BacktestResult | null>(run?.result ?? null); + const hasEquity = $derived(!!result && result.equity.length > 0); + + const equityOption: EChartsOption = $derived.by(() => { + if (!result || !hasEquity) return {}; + const dates = result.equity.map((p) => p.date); + const series: LineSeriesOption[] = [ + { + name: "策略权益", + type: "line" as const, + data: result.equity.map((p) => p.equity), + showSymbol: false, + lineStyle: { width: 1.6 }, + itemStyle: { color: "#0f766e" } + }, + { + name: "现金", + type: "line" as const, + data: result.equity.map((p) => p.cash), + showSymbol: false, + lineStyle: { type: "dashed", opacity: 0.65 }, + itemStyle: { color: "#94a3b8" } + } + ]; + const bench = result.equity.map((p) => (p.benchmark === null || p.benchmark === undefined ? null : p.benchmark)); + if (bench.some((x) => x !== null && x !== undefined)) { + series.push({ + name: "基准", + type: "line" as const, + data: bench as number[], + showSymbol: false, + itemStyle: { color: "#eab308" } + }); + } + return { + title: { text: undefined }, + legend: { top: 0 }, + grid: { top: 32, left: 60, right: 24, bottom: 54 }, + tooltip: { trigger: "axis" }, + xAxis: { type: "category", data: dates }, + yAxis: { type: "value", scale: true, name: "金额" }, + series + }; + }); + + const drawdownOption: EChartsOption = $derived.by(() => { + if (!result || !hasEquity) return {}; + const dates = result.equity.map((p) => p.date); + let peak = -Infinity; + const dd = result.equity.map((p) => { + peak = Math.max(peak, p.equity); + return -((peak - p.equity) / peak) * 100; + }); + return { + grid: { top: 24, left: 60, right: 24, bottom: 54 }, + tooltip: { trigger: "axis", valueFormatter: (v) => `${(v as number).toFixed(2)}%` }, + xAxis: { type: "category", data: dates }, + yAxis: { type: "value", name: "回撤 %", max: 0 }, + series: [ + { + name: "回撤", + type: "line" as const, + data: dd, + showSymbol: false, + areaStyle: { color: "rgba(30, 74, 66, 0.25)" }, + lineStyle: { width: 1 }, + itemStyle: { color: "#134e4a" } + } + ] + }; + }); + + function switchToRun(id: string) { + runId = id; + void load(); + } + + const ml = $derived(metricsLabels()); + const mr = $derived(result ? metricsRow(result.metrics) : []); +</script> + +<div class="stack"> + <div class="spread"> + <h2>回测结果</h2> + {#if run} + <span class="row"> + <span class="hint">Run {run.id.slice(0, 8)}…</span> + <span class="badge {runStatusBadge[run.status]}">{runStatusLabel[run.status]}</span> + </span> + {/if} + </div> + + <Status busy={busy} error={error} busyText="加载结果…" empty={null} /> + + {#if !busy && !error && !run} + <Status busy={false} empty="此项目还没有回测结果" emptyHint="先在「回测」标签发起一次回测" /> + {/if} + + {#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> + {/if} + + {#if run.error} + <div class="banner error" role="alert"><span aria-hidden="true">✕</span><span>{run.error}</span></div> + {/if} + + {#if !result} + <Status busy={false} empty="该回测尚无结果" emptyHint={runStatusBadge[run.status] === "danger" ? "见错误提示" : "回测仍在执行中,稍后刷新"} /> + {:else} + {#if result.warnings?.length} + <div class="banner warn" role="alert"> + <span aria-hidden="true">⚠</span> + <div> + <ul class="ul-plain"> + {#each result.warnings as w, i (i)}<li>{w}</li>{/each} + </ul> + </div> + </div> + {/if} + + <div class="card"> + {#if result.metrics} + {@const mrow = result.metrics} + <div class="metric-grid"> + <div class="metric"> + <div class="mlabel">总收益率</div> + <div class="mvalue">{fmtPct(mrow.total_return)}</div> + </div> + <div class="metric"> + <div class="mlabel">年化收益率</div> + <div class="mvalue">{fmtPct(mrow.annual_return)}</div> + </div> + <div class="metric"> + <div class="mlabel">最大回撤</div> + <div class="mvalue">{fmtPct(mrow.max_drawdown)}</div> + </div> + <div class="metric"> + <div class="mlabel">夏普比率</div> + <div class="mvalue">{fmtNum(mrow.sharpe)}</div> + </div> + <div class="metric"> + <div class="mlabel">平仓回合数</div> + <div class="mvalue">{mrow.trade_count ?? "—"}</div> + </div> + <div class="metric"> + <div class="mlabel">期末权益</div> + <div class="mvalue">{formatMoney(mrow.final_equity)}</div> + </div> + </div> + {/if} + <p class="hint"> + 平仓回合数为已完成的开仓并全部平仓的完整回合(1 个回合 = 1 次平仓),不含撤单与未平仓的单笔成交;成交记录中的“金额”为真实成交额(数量 × 成交价),非持仓成本。结果非投资建议;未完全模拟流动性/涨跌停等限制。 + 引擎 {result.engine?.name} {result.engine?.version} + {#if result.elapsed_ms}<span> · 耗时 {(result.elapsed_ms / 1000).toFixed(1)}s</span>{/if} + {#if result.peak_rss_kb}<span> · 峰值内存 {fmtNum(result.peak_rss_kb / 1024)} MB</span>{/if} + </p> + </div> + + <div class="card stack"> + <div class="row"> + <button class="btn small" aria-pressed={chartMode === "equity"} onclick={() => (chartMode = "equity")}>权益曲线({result.equity.length} 点)</button> + <button class="btn small" aria-pressed={chartMode === "drawdown"} onclick={() => (chartMode = "drawdown")}>回撤曲线</button> + </div> + {#if chartMode === "equity"} + <Chart option={equityOption} ariaLabel="策略权益曲线" height={340} /> + {:else} + <Chart option={drawdownOption} ariaLabel="回撤曲线" height={300} /> + {/if} + </div> + + <div class="card stack"> + <h2>订单与持仓记录</h2> + <div class="cols-2"> + <div> + <h3>成交记录 ({result.trades.length})</h3> + {#if result.trades.length > 0} + <div class="table-wrap"> + <table class="data"> + <thead> + <tr> + <th>日期</th> + <th>标的</th> + <th>方向</th> + <th>数量</th> + <th>价格</th> + <th>金额</th> + <th>佣金</th> + </tr> + </thead> + <tbody> + {#each result.trades.slice(0, 80) as t, i (i)} + <tr> + <td>{t.date}</td> + <td class="num">{t.symbol}</td> + <td>{t.side === "buy" ? "买入" : "卖出"}</td> + <td class="num">{t.quantity}</td> + <td class="num">{fmtNum(t.price, 3)}</td> + <td class="num">{fmtNum(t.value)}</td> + <td class="num">{fmtNum(t.commission, 4)}</td> + </tr> + {/each} + </tbody> + </table> + </div> + {#if result.trades.length > 80} + <p class="hint">仅显示前 80 条,共 {result.trades.length} 条</p> + {/if} + {:else} + <p class="hint">本区间未发生成交</p> + {/if} + </div> + <div> + <h3>策略日志</h3> + <button class="btn small" onclick={() => (logsOpen = !logsOpen)} aria-expanded={logsOpen}> + {logsOpen ? "收起日志" : `展开日志(${result.logs.length} 行)`} + </button> + {#if logsOpen} + <pre class="logs">{result.logs.join("\n")}</pre> + {/if} + </div> + </div> + <div> + <h3>数据与引擎 + <span class="hint">本轮使用的引擎版本、耗时与消耗内存均在上方备注。</span> + </h3> + <p class="hint"> + 结果由对应策略代码、配置、数据清单(数据哈希 {result.data_manifest_hash?.slice(0, 12)})以及当时引擎共同决定。 + </p> + </div> + </div> + {/if} + {/if} + + {#if !busy && !error && history.length === 0 && run === null} + <Status busy={false} empty="还没有可展示的回测" emptyHint="在「回测」标签先启动一遍" /> + {/if} +</div> diff --git a/frontend/src/pages/ProjectStrategyTab.svelte b/frontend/src/pages/ProjectStrategyTab.svelte new file mode 100644 index 0000000..5f2e242 --- /dev/null +++ b/frontend/src/pages/ProjectStrategyTab.svelte @@ -0,0 +1,420 @@ +<script lang="ts"> + import Status from '../components/Status.svelte'; + import CodeEditor from '../components/CodeEditor.svelte'; + import DiffView from '../components/DiffView.svelte'; + import Modal from '../components/Modal.svelte'; + import { projectsStore } from '../lib/listsStore.svelte'; + import { + getProject, + putDraft, + listVersions, + postVersion, + getVersionCode, + postRestore, + postAIAssist, + postAIAccept + } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import { fmtDateTime, versionSourceLabel } from '../lib/format'; + import type { Project, Version, AIAssist } from '../lib/types'; + + interface Props { + projectId: string; + } + let { projectId }: Props = $props(); + + let code = $state(''); + let baseGeneration = $state(0); + let baseCode = $state(''); + let saveStatus = $state<'saved' | 'saving' | 'dirty' | 'conflict'>('saved'); + let saveMsg = $state(''); + let versions: Version[] = $state([]); + let versionsBusy = $state(false); + let versionsErr = $state<string | null>(null); + let snapshotBusy = $state(false); + let snapshotMsg = $state<string | null>(null); + let snapshotErr = $state<string | null>(null); + let snapshotMessage = $state(''); + let conflictServer: { generation: number; code: string } | null = $state(null); + let comparing: { label: string; before: string; after: string } | null = $state(null); + + let instruction = $state(''); + let aiBusy = $state(false); + let aiErr = $state<string | null>(null); + let aiResult = $state<AIAssist | null>(null); + let aiAcceptBusy = $state(false); + let aiAcceptErr = $state<string | null>(null); + let aiNote = $state<string | null>(null); + + let initBusy = $state(true); + let initErr = $state<string | null>(null); + let saveTimer: ReturnType<typeof setTimeout> | null = null; + + $effect(() => { + void init(); + return () => { + if (saveTimer) clearTimeout(saveTimer); + }; + }); + + async function init() { + initBusy = true; + initErr = null; + try { + const p: Project = await getProject(projectId); + projectsStore.bump(p); + code = p.draft_code ?? ''; + baseGeneration = p.draft_generation; + baseCode = code; + await refreshVersions(); + } catch (e) { + initErr = extractStatusMessage(e); + } finally { + initBusy = false; + } + } + + async function refreshVersions() { + versionsBusy = true; + versionsErr = null; + try { + const { items } = await listVersions(projectId); + versions = items; + } catch (e) { + versionsErr = extractStatusMessage(e); + } finally { + versionsBusy = false; + } + } + + function onEditorInput(v: string) { + if (saveStatus !== 'dirty') saveStatus = 'dirty'; + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(() => void saveDraft(v), 800); + saveMsg = '输入暂停后自动保存'; + } + + async function saveDraft(payload?: string) { + const toSave = payload ?? code; + if (!toSave && !baseCode) return; + saveStatus = 'saving'; + saveMsg = '保存中…'; + try { + const p = await putDraft(projectId, toSave, baseGeneration); + code = toSave; + baseGeneration = p.draft_generation; + baseCode = toSave; + projectsStore.bump(p); + saveStatus = 'saved'; + saveMsg = `已保存 · 草稿代 ${baseGeneration}`; + if (aiNote) aiNote = null; + } catch (e) { + const err = e as { status?: number; code?: string }; + if (err.status === 409 && err.code === 'stale_generation') { + const fresh = await getProject(projectId); + saveStatus = 'conflict'; + conflictServer = { generation: fresh.draft_generation, code: fresh.draft_code ?? '' }; + saveMsg = '检测到冲突'; + } else { + saveStatus = 'dirty'; + saveMsg = extractStatusMessage(e); + } + } + } + + async function keepMine() { + if (!conflictServer) return; + try { + const p = await putDraft(projectId, code, conflictServer.generation); + baseGeneration = p.draft_generation; + baseCode = code; + saveStatus = 'saved'; + saveMsg = '已覆盖保存本机内容'; + conflictServer = null; + } catch (e) { + saveMsg = extractStatusMessage(e); + } + } + + function loadServer() { + if (!conflictServer) return; + code = conflictServer.code; + baseGeneration = conflictServer.generation; + baseCode = code; + saveStatus = 'saved'; + saveMsg = '已采用服务器草稿'; + conflictServer = null; + } + + async function snapshot() { + snapshotBusy = true; + snapshotErr = null; + snapshotMsg = null; + try { + await postVersion(projectId, snapshotMessage.trim() || '保存版本快照'); + snapshotMessage = ''; + snapshotMsg = '版本快照已创建'; + await refreshVersions(); + } catch (e) { + snapshotErr = extractStatusMessage(e); + } finally { + snapshotBusy = false; + } + } + + async function compareWithDraft(v: Version) { + versionsErr = null; + try { + const { code: vCode } = await getVersionCode(projectId, v.id); + comparing = { label: `版本 ${v.hash.slice(0, 10)} · ${v.message}`, before: vCode, after: code }; + } catch (e) { + versionsErr = extractStatusMessage(e); + } + } + + async function compareTwoVersions(i: number) { + const v = versions[i]; + const prev = versions[i + 1]; + if (!prev) return; + try { + const [a, b] = await Promise.all([getVersionCode(projectId, v.id), getVersionCode(projectId, prev.id)]); + comparing = { + label: `${a.code ? '' : ''}${v.hash.slice(0, 8)}(新)对比 ${prev.hash.slice(0, 8)}(旧)`, + before: b.code, + after: a.code + }; + } catch (e) { + versionsErr = extractStatusMessage(e); + } + } + + async function restore(v: Version) { + versionsErr = null; + try { + const p = await postRestore(projectId, v.id, baseGeneration); + code = p.draft_code ?? ''; + baseGeneration = p.draft_generation; + baseCode = code; + projectsStore.bump(p); + versionsErr = null; + aiNote = `已从该版本恢复为新草稿(代 ${baseGeneration}),历史未改写`; + await refreshVersions(); + } catch (e) { + versionsErr = extractStatusMessage(e); + } + } + + async function askAI() { + if (!instruction.trim()) return; + aiBusy = true; + aiErr = null; + try { + const res = await postAIAssist(projectId, instruction.trim(), baseGeneration); + aiResult = res; + if (res.status && res.status !== 'ok') { + aiErr = `模型返回状态 ${res.status},未生成代码建议`; + } + } catch (e) { + aiErr = extractStatusMessage(e); + aiResult = null; + } finally { + aiBusy = false; + } + } + + async function acceptAI() { + if (!aiResult) return; + aiAcceptBusy = true; + aiAcceptErr = null; + try { + const p = await postAIAccept(aiResult.id, baseGeneration); + code = p.draft_code ?? aiResult.proposed_code; + baseGeneration = p.draft_generation; + baseCode = code; + projectsStore.bump(p); + aiNote = 'AI 建议已接受,生成新的代码版本'; + aiResult = null; + instruction = ''; + await refreshVersions(); + } catch (e) { + const err = e as { status?: number; code?: string }; + if (err.status === 409) { + aiAcceptErr = '草稿代已变化,建议可能失效。请重新发起 AI 说明或刷新草稿后重试。'; + } else { + aiAcceptErr = extractStatusMessage(e); + } + } finally { + aiAcceptBusy = false; + } + } + + function discardAI() { + aiResult = null; + aiErr = null; + aiAcceptErr = null; + } +</script> + +<div class="stack"> + <Status busy={initBusy} error={initErr} busyText="加载策略草稿…" empty={null} /> + + {#if !initBusy && !initErr} + <div class="card stack"> + <div class="spread"> + <h2>策略代码(Backtrader Python)</h2> + <span class="save-status" role="status"> + {saveStatus === 'saved' && `已保存${saveMsg ? ` · ${saveMsg}` : ''}`} + {#if saveStatus === 'dirty'}<span class="badge warn">有未保存修改</span>{/if} + {#if saveStatus === 'saving'}<span class="badge teal">保存中…</span>{/if} + {#if saveStatus === 'conflict'}<span class="badge danger">冲突</span>{/if} + </span> + </div> + <p class="hint"> + 编辑器修改会自动保存(约 0.8 秒停顿后提交)。草稿代 {baseGeneration} · 回测与获取 AI 建议使用当前草稿快照 + </p> + <CodeEditor value={code} onInput={onEditorInput} readOnly={false} label="策略源码" height={430} /> + <div class="spread wrap"> + <input + bind:value={snapshotMessage} + placeholder="快照说明,例如:加入RSI过滤器(可选)" + style="flex:2 1 240px" + onkeydown={(ev) => { + if (ev.key === 'Enter') void snapshot(); + }} + /> + <button class="btn" disabled={snapshotBusy} onclick={snapshot}> + {snapshotBusy ? '创建快照…' : '保存版本快照'} + </button> + <button + class="btn" + onclick={() => versions[1] && void compareWithDraft(versions[1])} + disabled={!versions[1]} + > + 草稿与上一快照对比 + </button> + </div> + <Status busy={false} error={snapshotErr} empty={snapshotMsg} /> + + {#if conflictServer} + <div class="banner warn" role="alert"> + <span aria-hidden="true">⚠</span> + <span> + 服务器上的草稿已被其他修改更新(代 {conflictServer.generation}),你正在编辑的本地内容不会自动覆盖。 + </span> + </div> + <DiffView before={conflictServer.code} after={code} beforeLabel="服务器" afterLabel="本机" /> + <div class="row"> + <button class="btn primary" onclick={keepMine}>保留本机修改(覆盖服务器)</button> + <button class="btn" onclick={loadServer}>采用服务器版本</button> + </div> + {/if} + </div> + + <div class="stack"> + <h2>AI 助手</h2> + <div class="card stack"> + <label for="ai-instruction">希望 AI 帮你做什么?<span class="hint">例如:把均线周期改为两个可调参数并解释影响</span></label> + <textarea id="ai-instruction" rows="3" bind:value={instruction} placeholder="描述对策略的修改意图,AI 返回完整新源码与差异预览"></textarea> + <div class="row"> + <button class="btn primary" disabled={aiBusy || !instruction.trim()} onclick={askAI}> + {aiBusy ? 'AI 处理中…' : '获取建议'} + </button> + </div> + {#if aiErr} + <div class="banner error" role="alert"> + <span aria-hidden="true">⚠</span> + <span>{aiErr}</span> + </div> + {/if} + + {#if aiResult} + <div class="stack"> + <div class="spread"> + <h3>建议解释 <span class="hint">{aiResult.model}</span></h3> + <span class="hint"> + tokens {aiResult.usage?.input_tokens ?? '—'} 入 / {aiResult.usage?.output_tokens ?? '—'} 出 + </span> + </div> + <p>{aiResult.explanation}</p> + <h3>完整建议源码</h3> + <CodeEditor value={aiResult.proposed_code} onInput={() => {}} readOnly={true} label="AI 返回代码(只读)" height={280} /> + <h3>与当前草稿的差异</h3> + <DiffView before={code} after={aiResult.proposed_code} beforeLabel="当前草稿" afterLabel="AI 建议" /> + <div class="row"> + <button + class="btn primary" + disabled={aiAcceptBusy} + onclick={acceptAI} + title={aiResult.base_generation !== baseGeneration ? '草稿代已变化,建议如需采用请重新获取' : ''} + > + {aiAcceptBusy ? '接受中…' : '接受为新版本'} + </button> + <button class="btn ghost" disabled={aiAcceptBusy} onclick={discardAI}>放弃建议</button> + {#if aiResult.base_generation !== baseGeneration} + <span class="banner warn" role="alert">草稿代 {aiResult.base_generation} 与当前 {baseGeneration} 不一致,接受会失败;请重新发起说明</span> + {/if} + {#if aiAcceptErr} + <div class="banner error" role="alert">{aiAcceptErr}</div> + {/if} + </div> + </div> + {/if} + {#if aiNote} + <div class="banner info">{aiNote}</div> + {/if} + </div> + </div> + + <div class="stack"> + <h2>版本历史</h2> + <Status busy={versionsBusy} error={versionsErr} busyText="载入版本…" empty={null} /> + {#if !versionsBusy && versions.length === 0} + <Status busy={false} empty="还没有版本快照" emptyHint="保存第一个快照后可以在历史中查看完整代码与恢复" /> + {/if} + {#if !versionsBusy && versions.length > 0} + <div class="card" style="padding:0.4rem 0.6rem"> + <table class="data"> + <thead> + <tr> + <th>时间</th> + <th>摘要</th> + <th>哈希</th> + <th>来源</th> + <th>操作</th> + </tr> + </thead> + <tbody> + {#each versions as v, i (v.id)} + <tr> + <td>{fmtDateTime(v.created_at)}</td> + <td> + {v.message?.trim() || '(无说明)'} + <span class="hint">版本代 {v.hash?.slice(0, 8) || '—'}</span> + </td> + <td class="num">{v.hash?.slice(0, 12)}</td> + <td><span class="badge grey">{versionSourceLabel[v.source]}</span></td> + <td> + <div class="row"> + <button class="btn small" onclick={() => void compareWithDraft(v)}>对比当前</button> + {#if i + 1 < versions.length} + <button class="btn small" onclick={() => void compareTwoVersions(i)}>与相邻旧版对比</button> + {/if} + <button class="btn small" onclick={() => void restore(v)}>恢复为新草稿</button> + </div> + </td> + </tr> + {/each} + </tbody> + </table> + </div> + {/if} + <p class="hint">版本为不可变记录,恢复均创建新草稿,从不改写历史。获取到最新草稿修改内容请刷新页面查看。</p> + </div> + + {#if comparing} + <Modal title={comparing.label} onClose={() => (comparing = null)}> + <DiffView before={comparing.before} after={comparing.after} /> + </Modal> + {/if} + {/if} +</div> diff --git a/frontend/src/pages/ProjectsPage.svelte b/frontend/src/pages/ProjectsPage.svelte new file mode 100644 index 0000000..b29a856 --- /dev/null +++ b/frontend/src/pages/ProjectsPage.svelte @@ -0,0 +1,112 @@ +<script lang="ts"> + import Page from '../components/Page.svelte'; + import Status from '../components/Status.svelte'; + import { projectsStore } from '../lib/listsStore.svelte'; + import { createProject } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import { go } from '../lib/router'; + import { fmtDateTime } from '../lib/format'; + + let busy = $state(false); + let error = $state<string | null>(null); + let creating = $state(false); + let showCreate = $state(false); + let newName = $state(''); + let newDesc = $state(''); + let createErr = $state<string | null>(null); + + async function load() { + busy = true; + error = null; + try { + await projectsStore.load(); + } catch (e) { + error = extractStatusMessage(e); + } finally { + busy = false; + } + } + + async function create(e: SubmitEvent) { + e.preventDefault(); + if (!newName.trim()) return; + creating = true; + createErr = null; + try { + const p = await createProject(newName.trim(), newDesc.trim() || undefined); + projectsStore.bump(p); + showCreate = false; + newName = ''; + newDesc = ''; + go(`/projects/${p.id}/data`); + } catch (err) { + createErr = extractStatusMessage(err); + } finally { + creating = false; + } + } +</script> + +<Page title="我的项目" subtitle="每个项目持有独立的策略代码版本、数据集与回测记录"> + {#snippet actions()} + <button class="btn primary" onclick={() => (showCreate = !showCreate)} aria-expanded={showCreate}> + {showCreate ? '取消新建' : '新建项目'} + </button> + {/snippet} + + <div class="stack"> + <Status {busy} {error} busyText="加载项目列表…" empty={null} /> + {#if showCreate} + <form class="card stack" onsubmit={create}> + <h2>新建项目</h2> + <div> + <label for="pn">项目名称</label> + <input id="pn" bind:value={newName} required placeholder="例如:双均线ETF轮动" /> + </div> + <div> + <label for="pd">简介(可选)</label> + <input id="pd" bind:value={newDesc} placeholder="一句话说明研究思路" /> + </div> + {#if createErr}<div class="banner error" role="alert">{createErr}</div>{/if} + <div class="row"> + <button type="submit" class="btn primary" disabled={creating || !newName.trim()}> + {creating ? '创建中…' : '创建项目'} + </button> + </div> + </form> + {/if} + + {#if !busy && !error && projectsStore.all.length === 0} + <Status busy={false} empty="还没有项目" emptyHint="点击右上角「新建项目」开始你的第一个策略研究" /> + {/if} + + {#if projectsStore.all.length > 0} + <div class="card" style="padding:0.4rem 0.6rem;"> + <table class="data"> + <thead> + <tr> + <th>名称</th> + <th>简介</th> + <th>版本代</th> + <th>最近更新</th> + </tr> + </thead> + <tbody> + {#each projectsStore.all as p (p.id)} + <tr> + <td><a href={`#/projects/${p.id}/data`} class="p-name">{p.name}</a></td> + <td class="hint">{p.description ?? '—'}</td> + <td class="num">{p.draft_generation ?? '—'}</td> + <td>{fmtDateTime(p.updated_at)}</td> + </tr> + {/each} + </tbody> + </table> + </div> + {/if} + </div> +</Page> + +<style> + .p-name { font-weight: 600; } +</style> diff --git a/frontend/src/pages/RunsPage.svelte b/frontend/src/pages/RunsPage.svelte new file mode 100644 index 0000000..bd77892 --- /dev/null +++ b/frontend/src/pages/RunsPage.svelte @@ -0,0 +1,180 @@ +<script lang="ts"> + import Page from '../components/Page.svelte'; + import Status from '../components/Status.svelte'; + import Chart from '../components/Chart.svelte'; + import type { EChartsOption } from 'echarts'; + import { listRuns } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import { fmtDateTime, runStatusLabel, runStatusBadge, fmtPct, fmtNum, formatMoney } from '../lib/format'; + import type { Run } from '../lib/types'; + + let runs: Run[] = $state([]); + let busy = $state(true); + let error = $state<string | null>(null); + let chosenIds: string[] = $state([]); + let failedLoad = $state<string[]>([]); + + $effect(() => { + void load(); + }); + + async function load() { + busy = true; + try { + const { items } = await listRuns(); + runs = items; + } catch (e) { + error = extractStatusMessage(e); + } finally { + busy = false; + } + } + + const selected = $derived(runs.filter((r) => chosenIds.includes(r.id))); + const comparable = $derived( + selected.filter((r) => r.status === 'succeeded' && r.result) + ); + + function toggle(id: string) { + if (chosenIds.includes(id)) chosenIds = chosenIds.filter((x) => x !== id); + else chosenIds = [...chosenIds.slice(-3), id]; + } + + const compareOption: EChartsOption = $derived.by(() => { + if (comparable.length === 0) return {}; + const series = comparable.map((r, idx) => { + const eq = r.result?.equity ?? []; + const base = eq[0]?.equity ?? 1; + const colors = ['#0f766e', '#b45309', '#1d4ed8', '#8b1d8b']; + return { + name: `Run ${r.id.slice(0, 6)}.`, + type: 'line' as const, + data: eq.map((p) => p.equity / base), + showSymbol: false, + lineStyle: { width: 1.5 }, + itemStyle: { color: colors[idx % colors.length] } + }; + }); + let xLen = Math.max(...comparable.map((r) => (r.result?.equity ?? []).length), 0); + const longest = comparable.reduce((best, r) => ((r.result?.equity ?? []).length > best.length ? r.result!.equity : best), [] as { date: string }[]); + return { + legend: { top: 0 }, + grid: { top: 32, left: 64, right: 20, bottom: 50 }, + tooltip: { trigger: 'axis' }, + xAxis: { type: 'category', data: longest.map((p) => p.date) }, + yAxis: { type: 'value', scale: true, name: '归一净值' }, + series + }; + }); + + const configDiffRows = $derived.by(() => { + if (selected.length < 2) return []; + const keys: (keyof Run)[] = ['project_id', 'version_id', 'dataset_id']; + const cfgKeys: (keyof NonNullable<Run['config']>)[] = ['capital', 'commission', 'slippage', 'benchmark_symbol']; + const rows: { label: string; vals: string[]; differing: boolean }[] = []; + const mk = (label: string, vals: unknown[]) => { + const valsStr = vals.map((v) => (v === null || v === undefined || v === '' ? '—' : v instanceof Date ? String(v) : String(v))); + rows.push({ label, vals: valsStr, differing: new Set(valsStr).size > 1 }); + }; + for (const k of keys) mk(k === 'project_id' ? '项目' : k === 'version_id' ? '代码版本' : '数据集', selected.map((r) => r[k])); + for (const k of cfgKeys) { + mk( + k === 'capital' ? '起始资金' : k === 'commission' ? '佣金率' : k === 'slippage' ? '滑点' : '基准', + selected.map((r) => r.config?.[k]) + ); + } + mk('状态', selected.map((r) => runStatusLabel[r.status])); + mk('时间', selected.map((r) => r.created_at)); + return rows; + }); +</script> + +<Page title="实验记录" subtitle="查看与选择比较不同回测;结果仅展示真实完成任务的数据" wide> + <div class="stack"> + <Status busy={busy} error={error} busyText="正在加载实验…" empty={null} /> + + {#if !busy && !error && runs.length === 0} + <Status busy={false} empty="还没有实验记录" emptyHint="进入项目「回测」标签发起一次回测后记录会出现在这里" /> + {/if} + + {#if !busy && !error && runs.length > 0} + <div class="card" style="padding:0.4rem 0.6rem"> + <table class="data"> + <thead> + <tr> + <th></th> + <th>时间</th> + <th>状态</th> + <th>项目</th> + <th>数据集</th> + <th>资金/佣金/滑点</th> + <th>收益</th> + <th>最大回撤</th> + <th>操作</th> + </tr> + </thead> + <tbody> + {#each runs as r (r.id)} + <tr> + <td> + <input + type="checkbox" + checked={chosenIds.includes(r.id)} + onchange={() => toggle(r.id)} + aria-label={`选择 Run ${r.id.slice(0, 8)} 参与`} + disabled={r.status !== 'succeeded' && chosenIds.length >= 4 && !chosenIds.includes(r.id)} + /> + </td> + <td>{fmtDateTime(r.created_at)}</td> + <td><span class="badge {runStatusBadge[r.status]}">{runStatusLabel[r.status]}</span></td> + <td class="num">{r.project_id?.slice(0, 8)}…</td> + <td class="num">{r.dataset_id?.slice(0, 8)}…</td> + <td>{formatMoney(r.config?.capital)} · {fmtPct(r.config?.commission, 4)} / {fmtPct(r.config?.slippage, 4)}</td> + <td>{r.result ? fmtPct(r.result.metrics.total_return) : '—'}</td> + <td>{r.result ? fmtPct(r.result.metrics.max_drawdown) : '—'}</td> + <td> + <a class="btn small" href={`#/projects/${r.project_id}/results?run=${r.id}`}>查看</a> + </td> + </tr> + {/each} + </tbody> + </table> + <p class="hint" style="padding:0.4rem 0.6rem">最多同时选中 4 个已完成的回测用于比较。</p> + </div> + + {#if selected.length > 0} + <div class="card stack"> + <h2>条件差异</h2> + <table class="data"> + <thead> + <tr> + <th>维度</th> + {#each selected as r (r.id)}<th>Run {r.id.slice(0, 6)}</th>{/each} + </tr> + </thead> + <tbody> + {#each configDiffRows as row, i (i)} + <tr class:diffrow={row.differing}> + <th>{row.label}</th> + {#each row.vals as v, j (j)} + <td class={row.differing ? 'num-diff num' : 'num'}>{v}</td> + {/each} + </tr> + {/each} + </tbody> + </table> + </div> + + {#if comparable.length > 1} + <div class="card stack"> + <h2>收益曲线对齐比较(首日归一)</h2> + <Chart option={compareOption} ariaLabel="多回测对比曲线" height={340} /> + <p class="hint">起点对齐为 1,仅叠加不同起点的曲线时只在各自区间内展示。</p> + </div> + {:else} + <Status busy={false} empty="请再选中至少一个已完成的回测以展示比较" /> + {/if} + {/if} + {/if} + </div> +</Page> diff --git a/frontend/src/pages/UsagePage.svelte b/frontend/src/pages/UsagePage.svelte new file mode 100644 index 0000000..88dd8a5 --- /dev/null +++ b/frontend/src/pages/UsagePage.svelte @@ -0,0 +1,84 @@ +<script lang="ts"> + import Page from '../components/Page.svelte'; + import Status from '../components/Status.svelte'; + import { getAIUsage } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import { fmtDateTime, fmtNum } from '../lib/format'; + import type { AIUsage } from '../lib/types'; + + let usage = $state<AIUsage | null>(null); + let busy = $state(true); + let error = $state<string | null>(null); + + $effect(() => { + void load(); + }); + + async function load() { + busy = true; + error = null; + try { + usage = await getAIUsage(); + } catch (e) { + error = extractStatusMessage(e); + } finally { + busy = false; + } + } +</script> + +<Page title="AI 用量" subtitle="内部 POC 阶段的用量计量与限额参考。不含报价或计费"> + <div class="stack"> + <Status busy={busy} error={error} busyText="正在载入用量…" empty={null} /> + {#if usage} + <div class="card"> + <div class="row spaced"> + <div> + <div class="mlabel">模型请求</div> + <div class="mvalue">{usage.totals?.requests ?? usage.items.length}</div> + </div> + <div> + <div class="mlabel">输入 tokens</div> + <div class="mvalue">{usage.totals?.input_tokens ?? '—'}</div> + </div> + <div> + <div class="mlabel">输出 tokens</div> + <div class="mvalue">{usage.totals?.output_tokens ?? '—'}</div> + </div> + </div> + {#if usage.internal_poc} + <p class="hint">内部 POC:请在当前配额内使用,不对实际价格负责;接受模型输出的决策需重新人工复核。</p> + {/if} + </div> + + {#if usage.items.length > 0} + <div class="card" style="padding:0.4rem 0.6rem"> + <table class="data"> + <thead> + <tr> + <th>时间</th> + <th>模型</th> + <th>输入</th> + <th>输出</th> + <th>状态</th> + </tr> + </thead> + <tbody> + {#each usage.items as it, i (it.id)} + <tr> + <td>{fmtDateTime(it.created_at)}</td> + <td>{it.model}</td> + <td class="num">{it.input_tokens ?? '—'}</td> + <td class="num">{it.output_tokens ?? '—'}</td> + <td class="badge">{it.status ?? '—'}</td> + </tr> + {/each} + </tbody> + </table> + </div> + {:else} + <Status busy={false} empty="尚未使用 AI 建议" emptyHint="在项目「策略」标签发起一次 AI 修改,记录会出现在这里" /> + {/if} + {/if} + </div> +</Page> diff --git a/frontend/src/test-setup.ts b/frontend/src/test-setup.ts new file mode 100644 index 0000000..cdd6071 --- /dev/null +++ b/frontend/src/test-setup.ts @@ -0,0 +1,36 @@ + +// jsdom does not implement Range.getClientRects, which CodeMirror's +// measurement layer calls for drawing decorations (diagnostics markers). +// Without it the suite emits an unhandled +// `textRange(...).getClientRects is not a function` TypeError even though +// assertions pass. Narrow geometry shim ONLY for the missing API; it returns +// one empty rect, from which CodeMirror derives null geometry — no other +// errors are suppressed. +if (typeof Range !== 'undefined' && !Range.prototype.getClientRects) { + const EMPTY_RECT = { left: 0, right: 0, top: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0, toJSON() { return {}; } }; + Range.prototype.getClientRects = function getClientRects() { + return [EMPTY_RECT] as unknown as DOMRectList; + }; +} + +// jsdom ships no canvas backend: HTMLCanvasElement.prototype.getContext exists +// but throws "Not implemented". Charts render with ECharts' SVG renderer; the +// only canvas user in tests is zrender's text-metrics helper (measureText +// paints into a hidden canvas). Replace getContext ONLY when the native +// implementation is the throwing stub, and provide just the measurement +// approximation — SVG chart rendering itself (the part regressions inspect) +// is untouched and fully real. +if (typeof HTMLCanvasElement !== 'undefined') { + HTMLCanvasElement.prototype.getContext = function getContext(this: HTMLCanvasElement, contextId: string) { + if (contextId === '2d') { + return { + canvas: this, + font: '', + measureText(text: string) { + return { width: String(text).length * 7, actualBoundingBoxAscent: 7, actualBoundingBoxDescent: 1 }; + } + } as unknown as CanvasRenderingContext2D; + } + return null; + } as unknown as typeof HTMLCanvasElement.prototype.getContext; +} diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js new file mode 100644 index 0000000..21b9399 --- /dev/null +++ b/frontend/svelte.config.js @@ -0,0 +1,5 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +export default { + preprocess: vitePreprocess() +}; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..9dea8ab --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "allowJs": true, + "checkJs": true, + "isolatedModules": true, + "moduleDetection": "force", + "verbatimModuleSyntax": true, + "types": ["vite/client", "vitest/globals"], + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..3178f19 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from 'vitest/config'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; + +export default defineConfig({ + plugins: [svelte()], + // resolve svelte's browser (client) build so tests can actually mount + // components; production client bundles already resolve the same entry + resolve: { conditions: ['browser'] }, + build: { target: 'es2020' }, + test: { + environment: 'jsdom', + include: ['src/**/*.test.ts'], + // geometry shim: jsdom lacks Range.getClientRects which CodeMirror's + // measurement layer needs (narrow; see src/test-setup.ts) + setupFiles: ['./src/test-setup.ts'], + // let vitest resolve the browser (client) build of svelte so component + // mounting is possible in jsdom regression tests + resolve: { conditions: ['browser'] } + }, + server: { + proxy: { + '/api': { + target: 'http://127.0.0.1:8787', + changeOrigin: true + } + } + } +}); |
