diff --git a/package-lock.json b/package-lock.json index 718a0da..1403dbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "canvas-confetti": "^1.9.4", "react": "^19.0.0", "react-dom": "^19.0.0", + "recharts": "^3.10.1", "zustand": "^5.0.0" }, "devDependencies": { @@ -1079,6 +1080,32 @@ "node": ">= 8" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.13.0.tgz", + "integrity": "sha512-f6jwVRfwW30+tnllz4YcsHHpPYYOE7abcHkyUCovD+P7DyhdAI5prz2Fh/8TLhakjpN+7aIEion1s5L/tvlYlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1436,6 +1463,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tauri-apps/api": { "version": "2.10.1", "resolved": "https://registry.npmmirror.com/@tauri-apps/api/-/api-2.10.1.tgz", @@ -1753,6 +1792,69 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -1787,6 +1889,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -2093,6 +2201,15 @@ "node": ">= 6" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmmirror.com/commander/-/commander-4.1.1.tgz", @@ -2144,6 +2261,127 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -2183,6 +2421,12 @@ "dev": true, "license": "MIT" }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmmirror.com/didyoumean/-/didyoumean-1.2.2.tgz", @@ -2224,6 +2468,18 @@ "dev": true, "license": "MIT" }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", @@ -2286,6 +2542,12 @@ "@types/estree": "^1.0.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -2437,6 +2699,25 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/immer": { + "version": "11.1.21", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.21.tgz", + "integrity": "sha512-bZSadeCPGdaY5/qRLgr6mDSH1ra21rD3rqpiWk7+QTr4ayYPTE9qZYdLh/NdPhMe0wSrTvHD/Lk8q5EwhUs0aw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -3035,6 +3316,36 @@ "react": "^19.2.4" } }, + "node_modules/react-is": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.1.tgz", + "integrity": "sha512-RRieF6IyxQBkcK6pP8xOHprLTPW6QjRkbGdImtqNme+/hYVwCZkLQLdlZGOhUqXwkS9IEynaGvPJtv6XB/cz0Q==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz", @@ -3068,6 +3379,51 @@ "node": ">=8.10.0" } }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -3078,6 +3434,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.11.tgz", @@ -3329,6 +3691,12 @@ "node": ">=0.8" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", @@ -3518,6 +3886,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -3525,6 +3902,28 @@ "dev": true, "license": "MIT" }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", diff --git a/package.json b/package.json index efaa44c..d9ced47 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "canvas-confetti": "^1.9.4", "react": "^19.0.0", "react-dom": "^19.0.0", + "recharts": "^3.10.1", "zustand": "^5.0.0" }, "devDependencies": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 875a127..6570304 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2561,6 +2561,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num_threads" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4" +dependencies = [ + "libc", +] + [[package]] name = "objc2" version = "0.6.4" @@ -3305,6 +3314,7 @@ dependencies = [ "tauri-plugin-single-instance", "tauri-plugin-updater", "thiserror 2.0.18", + "time", "tokio", "tracing", "tracing-appender", @@ -4870,7 +4880,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a44a451..375ce6e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,6 +28,10 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } tracing-appender = "0.2" +# 本地时区日界计算(history::get_stats 的 daily_7d)。 +# 已在依赖树中(传递依赖),显式声明并开启 local-offset。 +time = { version = "0.3", features = ["local-offset"] } + thiserror = "2" async-trait = "0.1" diff --git a/src-tauri/src/domain/history.rs b/src-tauri/src/domain/history.rs index 83559f3..193ec5a 100644 --- a/src-tauri/src/domain/history.rs +++ b/src-tauri/src/domain/history.rs @@ -161,6 +161,7 @@ impl HistoryRepository { let now_ms = crate::types::now_unix_ms(); let ms_7_days: i64 = 7 * 24 * 60 * 60 * 1000; let ms_30_days: i64 = 30 * 24 * 60 * 60 * 1000; + let ms_24h: i64 = 24 * 60 * 60 * 1000; let last_7_days: u64 = conn .query_row( @@ -180,6 +181,41 @@ impl HistoryRepository { .map(|n| n as u64) .map_err(|e| AppError::DatabaseError(e.to_string()))?; + let last_24h: u64 = conn + .query_row( + "SELECT COUNT(*) FROM translation_records WHERE created_at >= ?1", + rusqlite::params![now_ms - ms_24h], + |row| row.get::<_, i64>(0), + ) + .map(|n| n as u64) + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + + // 近 7 天逐日计数(本地时区日界):SQL 只按毫秒区间过滤, + // 日归属在 Rust 侧做 —— SQLite 的日期函数走 UTC,本地时区的 + // 「今天」与其不一致,混用会让趋势图整体错位数小时。 + let mut daily_7d: Vec = Vec::with_capacity(7); + { + let today_midnight = local_midnight_ms(now_ms); + for i in (0..7).rev() { + let day_start = today_midnight - i as i64 * ms_24h; + let day_end = day_start + ms_24h; + // 次数与字符量一次查询同时取回(悬浮提示需要字符明细) + let (count, chars): (i64, i64) = conn + .query_row( + "SELECT COUNT(*), COALESCE(SUM(LENGTH(source_text)), 0) + FROM translation_records WHERE created_at >= ?1 AND created_at < ?2", + rusqlite::params![day_start, day_end], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)), + ) + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + daily_7d.push(crate::types::DailyCount { + date_ms: day_start, + count: count as u64, + chars: chars as u64, + }); + } + } + let mut by_provider: HashMap = HashMap::new(); { let mut stmt = conn @@ -206,6 +242,8 @@ impl HistoryRepository { by_provider, last_7_days, last_30_days, + last_24h, + daily_7d, }) } @@ -260,6 +298,23 @@ impl HistoryRepository { // ---- 内部查询辅助函数 ---- +/// 计算给定 Unix 毫秒时刻所在「本地时区当天 0 点」的 Unix 毫秒。 +/// +/// 用 `time` crate 的本地偏移(已在依赖树中,Windows 走 WinAPI / +/// macOS 走 localtime_r,比手搓 libc 声明可靠)。DST 切换日一天可能 +/// 是 23/25 小时,7 天窗口的日边界因此整体平移 ≤1h,对趋势图无感知。 +fn local_midnight_ms(now_ms: i64) -> i64 { + use time::OffsetDateTime; + let now = OffsetDateTime::from_unix_timestamp(now_ms.div_euclid(1000)) + .unwrap_or(OffsetDateTime::UNIX_EPOCH); + // to_offset(UtcOffset) 失败(拿不到本地时区)时按 UTC 兜底: + // 误差只是时区偏移,趋势图仍然成立 + let local = + now.to_offset(time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC)); + let day_start = local.replace_time(time::Time::MIDNIGHT); + day_start.unix_timestamp() * 1000 +} + fn query_all( conn: &Connection, limit: i64, diff --git a/src-tauri/src/system/tray.rs b/src-tauri/src/system/tray.rs index 56db8ef..6b5dab9 100644 --- a/src-tauri/src/system/tray.rs +++ b/src-tauri/src/system/tray.rs @@ -425,8 +425,10 @@ fn open_settings_at(app: &AppHandle, tab: Option<&str>) { match WebviewWindowBuilder::new(app, "settings", WebviewUrl::App(url.into())) .title("QuickTranslate 设置") .additional_browser_args(crate::system::BROWSER_ARGS) - .inner_size(600.0, 480.0) - .min_inner_size(500.0, 400.0) + // 仪表盘(趋势图 + 指标卡)需要纵向空间:480 高的首屏只露得出 + // 健康横幅。640×560 让首屏能完整呈现仪表盘 + 第一组设置项。 + .inner_size(640.0, 560.0) + .min_inner_size(520.0, 440.0) .resizable(true) .center() .build() diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 39a567c..8bc0c2d 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -99,6 +99,21 @@ pub struct StatsResult { pub by_provider: HashMap, pub last_7_days: u64, pub last_30_days: u64, + /// 近 24 小时翻译次数(仪表盘「最近 24h」卡片) + pub last_24h: u64, + /// 近 7 天逐日翻译次数,按时间升序;末位是今天(部分)。 + /// 每日边界按本地时区计算 —— 仪表盘趋势图的横轴。 + pub daily_7d: Vec, +} + +/// 单日翻译计数(daily_7d 的元素) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DailyCount { + /// 当日 0 点的 Unix 毫秒(本地时区) + pub date_ms: i64, + pub count: u64, + /// 当日翻译的源字符总量(悬浮提示的详细数据) + pub chars: u64, } /// 浮窗定位信息(Rust → 前端 event) diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx index b8af115..6885080 100644 --- a/src/components/CopyButton.tsx +++ b/src/components/CopyButton.tsx @@ -40,13 +40,13 @@ export function CopyButton({ text, className = "", label = "复制" }: CopyButto onClick={handleCopy} title={copied ? "已复制" : `复制${label}`} className={[ - "inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs", + "inline-flex items-center gap-1 px-2 py-1 rounded-md text-[12.5px] font-medium", "transition-all duration-150", "hover:bg-[var(--hover-bg)] active:bg-[var(--active-bg)]", "active:scale-95", copied - ? "text-green-500 dark:text-green-400" - : "text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]", + ? "text-green-600 dark:text-green-400" + : "text-[var(--text-secondary)] hover:text-[var(--text-primary)]", className, ].join(" ")} > diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts index c99ffc6..ef1eecd 100644 --- a/src/hooks/useTheme.ts +++ b/src/hooks/useTheme.ts @@ -32,6 +32,7 @@ export function useTheme() { // 监听系统主题变化(仅 "system" 模式时生效) const systemMqRef = useRef(null); + const systemPollRef = useRef | null>(null); useEffect(() => { // 清理旧的系统主题监听器 @@ -39,6 +40,10 @@ export function useTheme() { systemMqRef.current.onchange = null; systemMqRef.current = null; } + if (systemPollRef.current) { + clearInterval(systemPollRef.current); + systemPollRef.current = null; + } applyTheme(theme); @@ -48,11 +53,22 @@ export function useTheme() { mq.onchange = (e) => { document.documentElement.classList.toggle("dark", e.matches); }; + // WebView2 的 matchMedia change 事件在部分运行时上不可靠 + // (prefers-color-scheme 动态刷新是已知薄弱点,实机 2026-10-20 + // 验证「跟随系统」下切系统主题浮窗纹丝不动)。轮询重读兜底: + // applyTheme("system") 幂等 —— 明暗没变时 toggle 不产生 DOM + // mutation,每 2s 的开销只是一次 matchMedia 查询。 + systemPollRef.current = setInterval(() => applyTheme("system"), 2000); } return () => { if (systemMqRef.current) { systemMqRef.current.onchange = null; + systemMqRef.current = null; + } + if (systemPollRef.current) { + clearInterval(systemPollRef.current); + systemPollRef.current = null; } }; }, [theme]); diff --git a/src/lib/commands.ts b/src/lib/commands.ts index ee0cb72..cd1f765 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -40,6 +40,9 @@ export interface StatsResult { by_provider: Record; last_7_days: number; last_30_days: number; + last_24h: number; + /** 近 7 天逐日计数,升序,末位是今天(部分)。date_ms 为本地时区当日 0 点 */ + daily_7d: Array<{ date_ms: number; count: number; chars: number }>; } export interface AppConfig { diff --git a/src/styles/globals.css b/src/styles/globals.css index df2d01c..cc45c6a 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -38,10 +38,11 @@ /* ── Text Colors ── */ /* 对比度校准(WCAG AA ≥ 4.5:1 for body, ≥ 3:1 for large/hint text): secondary 深一档保证正文级辅助文本可读;tertiary 用于元信息, - 从 #AEAEB2(≈2.3:1,几乎不可读)提到 #86868B(≈3.9:1)。 */ + #86868B ≈3.4:1 在玻璃材质上几乎不可读(实机验证 2026-10-20), + 提到 #6E6E73(≈5.0:1)。 */ --text-primary: #1D1D1F; --text-secondary: #515154; - --text-tertiary: #86868B; + --text-tertiary: #6E6E73; --text-placeholder: #A1A1A6; /* ── Border / Divider ── */ @@ -58,13 +59,16 @@ --focus-ring: rgba(0, 122, 255, 0.35); /* ── Popup / Card ── */ - /* 着色层刻意极薄(0.22):系统材质(Acrylic/Popover)才是模糊的本体, - 这层只是轻微提亮与文字衬底。0.55 的版本会把材质完全闷成色块 —— - 「PRD 要玻璃但看到的是半透明板」的第二个根因(第一个是窗口不透明, - 已由 transparent(true) + set_effects 解决)。 */ - --popup-bg: rgba(255, 255, 255, 0.22); - --popup-border: rgba(255, 255, 255, 0.55); - --popup-shadow: 0 12px 40px rgba(0, 0, 0, 0.18), 0 2px 10px rgba(0, 0, 0, 0.10), 0 0 0 0.5px rgba(0, 0, 0, 0.06); + /* 着色层刻意极薄(0.10):实机对照(黑底白字探头,2026-10-20)证明 + Windows DWM Acrylic(DWMSBT_TRANSIENTWINDOW)本身在工作 —— 背后的 + 文字被模糊成晕影 —— 但 0.22 的白 tint + 高光渐变把材质洗成了雾白 + 平板,玻璃感全靠 0.22 那点可怜的透度硬撑。0.10 让系统材质成为绝对 + 主角;文字衬底改由 ::before 的受光渐变承担(深色背景半区更暗、 + 浅色背景半区更亮的真实玻璃行为)。0.55 的教训(闷成色块)与 0.22 + 的教训(雾白板)都写在这里供后人参考。 */ + --popup-bg: rgba(255, 255, 255, 0.10); + --popup-border: rgba(255, 255, 255, 0.50); + --popup-shadow: 0 12px 40px rgba(0, 0, 0, 0.22), 0 2px 10px rgba(0, 0, 0, 0.12), 0 0 0 0.5px rgba(0, 0, 0, 0.06); --card-bg: rgba(255, 255, 255, 0.60); --card-border: rgba(0, 0, 0, 0.06); --card-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); @@ -102,8 +106,8 @@ --surface-elevated: rgba(44, 44, 46, 0.78); --text-primary: #F5F5F7; - --text-secondary: #A8A8AD; - --text-tertiary: #7C7C81; + --text-secondary: #C0C0C5; + --text-tertiary: #98989D; --text-placeholder: #6E6E73; --border-primary: rgba(255, 255, 255, 0.12); @@ -117,8 +121,8 @@ --active-bg: rgba(255, 255, 255, 0.10); --focus-ring: rgba(0, 122, 255, 0.45); - --popup-bg: rgba(28, 28, 30, 0.28); - --popup-border: rgba(255, 255, 255, 0.16); + --popup-bg: rgba(28, 28, 30, 0.16); + --popup-border: rgba(255, 255, 255, 0.14); --popup-shadow: 0 14px 44px rgba(0, 0, 0, 0.55), 0 2px 12px rgba(0, 0, 0, 0.38), 0 0 0 0.5px rgba(255, 255, 255, 0.10); --card-bg: rgba(44, 44, 46, 0.60); --card-border: rgba(255, 255, 255, 0.08); @@ -185,7 +189,10 @@ html, body, #root { 会与 PopupWindow 的两段式量高(F6)互相打架 */ } -/* 光学层 1:顶部内高光 + 上半区渐变 —— 模拟玻璃受光面 */ +/* 光学层 1:顶部内高光 + 上半区渐变 —— 模拟玻璃受光面。 + tint 减薄到 0.10 后,文字可读性主要靠这层兜底:高光从顶部压暗版 + 换成「亮顶 + 快速衰减」,让浅色背景下标题区仍亮、深色背景下顶部 + 有一道真实玻璃的反光带。 */ .popup-container::before { content: ""; position: absolute; @@ -194,15 +201,15 @@ html, body, #root { border-radius: inherit; background: /* 左上角一点镜面高光 */ - radial-gradient(120% 60% at 8% 0%, rgba(255, 255, 255, 0.30) 0%, transparent 60%), - /* 纵向受光渐变 */ + radial-gradient(120% 60% at 8% 0%, rgba(255, 255, 255, 0.38) 0%, transparent 55%), + /* 纵向受光渐变:顶部亮带快速衰减 */ linear-gradient( to bottom, - rgba(255, 255, 255, 0.16) 0%, - rgba(255, 255, 255, 0.05) 24%, - transparent 58% + rgba(255, 255, 255, 0.20) 0%, + rgba(255, 255, 255, 0.06) 22%, + transparent 52% ); - box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.42); + box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.45); z-index: 1; } @@ -214,27 +221,27 @@ html, body, #root { pointer-events: none; border-radius: inherit; box-shadow: - inset 0 0 0 1px rgba(255, 255, 255, 0.22), - inset 0 -1px 0 0 rgba(255, 255, 255, 0.06); + inset 0 0 0 1px rgba(255, 255, 255, 0.28), + inset 0 -1px 0 0 rgba(255, 255, 255, 0.08); z-index: 1; } :root.dark .popup-container::before { background: - radial-gradient(120% 60% at 8% 0%, rgba(255, 255, 255, 0.14) 0%, transparent 62%), + radial-gradient(120% 60% at 8% 0%, rgba(255, 255, 255, 0.16) 0%, transparent 58%), linear-gradient( to bottom, - rgba(255, 255, 255, 0.09) 0%, - rgba(255, 255, 255, 0.025) 24%, - transparent 58% + rgba(255, 255, 255, 0.10) 0%, + rgba(255, 255, 255, 0.03) 22%, + transparent 52% ); - box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.16); + box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.18); } :root.dark .popup-container::after { box-shadow: - inset 0 0 0 1px rgba(255, 255, 255, 0.10), - inset 0 -1px 0 0 rgba(255, 255, 255, 0.03); + inset 0 0 0 1px rgba(255, 255, 255, 0.12), + inset 0 -1px 0 0 rgba(255, 255, 255, 0.04); } /* 内容必须压在光学层之上(两层伪元素都是 z-index:1) */ @@ -255,6 +262,38 @@ html, body, #root { /* 32px 容纳 26px 命中区(14px 色点 + 上下各 6px padding) */ height: 32px; padding: 2px 0.75rem 0; + /* 折叠/展开时标题栏高度固定,让窗口 resize 的视觉落点稳定 */ + transition: height 180ms var(--ease-out-expo); +} + +/* 折叠态:标题栏吃掉整个 44px 窗口高度,信息垂直居中。 + 此前 32px 标题栏挂在 44px 窗口顶部,底部 12px 是一截无意义的空玻璃 + —— 折叠态应当是一条完整、自洽的信息条,而不是「被裁掉的展开态」。 */ +.popup-titlebar--collapsed { + height: 44px; + padding: 0 0.75rem; +} + +/* 折叠态状态点:颜色即状态(与展开态内容视图一致的信息密度) */ +.popup-status-dot { + width: 6px; + height: 6px; + border-radius: var(--radius-full); + flex-shrink: 0; + transition: background-color 200ms ease; +} +.popup-status-dot--success { background-color: var(--system-green); } +.popup-status-dot--loading { + background-color: var(--system-orange); + /* 呼吸:唯一在动的元素,语义是「还在进行」 */ + animation: statusBreathe 1.4s ease-in-out infinite; +} +.popup-status-dot--error { background-color: var(--system-red); } +.popup-status-dot--idle { background-color: var(--text-tertiary); opacity: 0.55; } + +@keyframes statusBreathe { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.45; transform: scale(0.82); } } /* 折叠态摘要:单行截断显示译文 */ @@ -264,11 +303,39 @@ html, body, #root { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-size: 11.5px; - color: var(--text-secondary); + font-size: 12px; + font-weight: 500; + letter-spacing: 0.005em; + color: var(--text-primary); animation: fadeIn 180ms var(--ease-out-expo) forwards; } +/* 折叠态语言方向:右端弱化的元信息,与摘要构成「内容 + 上下文」一行 */ +.popup-titlebar__lang { + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 10px; + font-weight: 500; + letter-spacing: 0.02em; + color: var(--text-tertiary); + animation: fadeIn 220ms var(--ease-out-expo) forwards; +} + +.popup-titlebar__lang-arrow { + opacity: 0.55; + font-size: 9px; +} + +/* 展开/宽度切换的内容入场:纯 opacity + 极轻上浮,掩住 OS 窗口 + 瞬时改宽引发的文本重排跳变。180ms 与窗口 resize 同步收尾。 */ +@keyframes contentEnter { + from { opacity: 0; transform: translateY(3px); } + to { opacity: 1; transform: translateY(0); } +} +.popup-content-enter { animation: contentEnter 180ms var(--ease-out-expo) both; } + /* 键盘按键提示胶囊 */ .kbd-hint { font-family: var(--font-ui); @@ -317,6 +384,15 @@ html, body, #root { /* 字形默认隐藏,hover 任意按钮时整组显出 */ opacity: 0; transition: opacity 150ms ease; + /* 图标语义切换(折叠↔展开 / 标准↔阅读宽)时重挂载并轻缩放入场, + 与 hover 的 opacity 淡入叠加不冲突(transform 独立于 opacity) */ + animation: glyphSwap 160ms var(--ease-out-expo) both; +} + +/* 字形互换:从 0.82 轻弹到位,避免硬切 */ +@keyframes glyphSwap { + from { transform: scale(0.82); } + to { transform: scale(1); } } .traffic-lights.group:hover .traffic-light:not(:disabled) svg { opacity: 1; } @@ -512,6 +588,14 @@ select.input-field { .animate-popup { animation: popupIn 140ms var(--ease-out-expo) both; } .animate-popup-out { animation: popupOut 90ms ease-in both; } +/* 设置面板:Tab 内容切换 / 徽章出现的轻淡入(纯 opacity,不位移 —— + 内容区切换带位移会让人「页面跳了一下」) */ +@keyframes fadeInSoft { + from { opacity: 0; } + to { opacity: 1; } +} +.animate-fade-in { animation: fadeInSoft 150ms ease-out both; } + /* 引导向导:步骤切换(fade + 上浮,略带弹性) */ @keyframes stepIn { from { opacity: 0; transform: translateY(14px) scale(0.99); } diff --git a/src/windows/popup/ErrorView.tsx b/src/windows/popup/ErrorView.tsx index b2a725b..95b09ae 100644 --- a/src/windows/popup/ErrorView.tsx +++ b/src/windows/popup/ErrorView.tsx @@ -44,7 +44,7 @@ export function ErrorView({ code, message }: ErrorViewProps) { {/* 错误信息 */} -

+

{displayMessage}

@@ -59,7 +59,7 @@ export function ErrorView({ code, message }: ErrorViewProps) { 前往设置 )} - + 重新复制文字即可重试 diff --git a/src/windows/popup/PopupWindow.tsx b/src/windows/popup/PopupWindow.tsx index 908709f..d90f9a1 100644 --- a/src/windows/popup/PopupWindow.tsx +++ b/src/windows/popup/PopupWindow.tsx @@ -3,7 +3,7 @@ import { useEffect, useCallback, useRef, useState } from "react"; import { getCurrentWindow } from "@tauri-apps/api/window"; -import { EVENTS } from "@/lib/constants"; +import { EVENTS, getLangName } from "@/lib/constants"; import { hidePopup, resizePopup, @@ -357,7 +357,17 @@ export function PopupWindow() { sync(); const observer = new MutationObserver(sync); observer.observe(root, { attributes: true, attributeFilter: ["class"] }); - return () => observer.disconnect(); + + // 双保险:系统主题变化(跟随系统模式)在某些 WebView2 运行时上 + // matchMedia 的 change 事件不可靠,主动轮询兜底 —— popup 窗口长期 + // 驻留,用户切了系统深色后浮窗材质应尽快跟上。轮询很便宜:只是读 + // 一次 classList,真正的 IPC 有 lastDark 守着。 + const interval = window.setInterval(sync, 2000); + + return () => { + observer.disconnect(); + window.clearInterval(interval); + }; }, []); // ── 渲染 ── @@ -370,8 +380,15 @@ export function PopupWindow() { closing ? "animate-popup-out" : "", ].join(" ")} > - {/* ── 标题栏:红绿灯 + 折叠态摘要 ── */} -
+ {/* ── 标题栏:红绿灯 + 折叠态一体化信息条 ── + 折叠态承载的必要信息按优先级:状态(点)→ 译文摘要 → 语言方向。 + 翻译源标识刻意不放 —— 折叠时它属于噪音,展开即见。 */} +
{collapsed && ( - - {status === "success" && result - ? result.translated_text - : status === "loading" - ? "翻译中…" - : status === "error" - ? "翻译失败" - : "等待翻译…"} - + <> +
- {/* ── 内容区(折叠时隐藏)── */} + {/* ── 内容区(折叠时隐藏)── + key 绑定宽度档位:绿黄灯切换时重挂载以重放 180ms 淡入, + 掩住 OS 窗口瞬时改宽带来的文本重排跳变(纯视觉过渡,无状态损失 + —— 内容视图本身是无状态的) */} {!collapsed && ( - <> +
{status === "idle" && (
等待翻译… @@ -411,7 +444,7 @@ export function PopupWindow() { message={errorMessage ?? undefined} /> )} - +
)}
diff --git a/src/windows/popup/ResultView.tsx b/src/windows/popup/ResultView.tsx index 768e524..2c5ac42 100644 --- a/src/windows/popup/ResultView.tsx +++ b/src/windows/popup/ResultView.tsx @@ -19,9 +19,11 @@ export function ResultView({ result, showKeyboardHint = false }: ResultViewProps return (
- {/* ── 顶部:语言方向标签(pt 收窄,上方已有红绿灯标题栏)── */} + {/* ── 顶部:语言方向标签(pt 收窄,上方已有红绿灯标题栏)── + 11px 是 PRD 规格,但颜色用 secondary 而非 tertiary —— 方向标签 + 是理解「译成了什么语言」的关键信息,不是装饰 */}
- + {sourceLang} - + {targetLang} {result.truncated && ( @@ -50,9 +52,11 @@ export function ResultView({ result, showKeyboardHint = false }: ResultViewProps {/* ── macOS 分隔线 ── */}
- {/* ── 翻译结果文本 ── */} + {/* ── 翻译结果文本 ── + 译文是浮窗的主角:14.5px / 500 字重(实机验证反馈 13.5px/400 + 在玻璃材质上存在感不足),行高放宽到 1.8 保证中文可读性 */}
{result.translated_text} @@ -75,7 +79,7 @@ export function ResultView({ result, showKeyboardHint = false }: ResultViewProps "点击浮窗后按空格关闭"告知正确的两步操作。 不加响应式前缀 —— sm: 断点是 640px,浮窗视口仅 280–520px, `hidden sm:flex` 会让本提示永不渲染(F7) */} - + {showKeyboardHint ? ( <> 空格 @@ -86,11 +90,11 @@ export function ResultView({ result, showKeyboardHint = false }: ResultViewProps )} {import.meta.env.DEV && ( - + {result.duration_ms}ms )} - + {providerLabel}
diff --git a/src/windows/popup/TrafficLights.tsx b/src/windows/popup/TrafficLights.tsx index 1550bb2..10a17ba 100644 --- a/src/windows/popup/TrafficLights.tsx +++ b/src/windows/popup/TrafficLights.tsx @@ -104,6 +104,9 @@ function TrafficLight({ glyph: React.ReactNode; disabled?: boolean; }) { + // 字形按语义键重挂载:折叠 ↔ 展开、标准 ↔ 阅读宽 的图标互换 + // 由 CSS 动画交叉淡入,而不是硬切(硬切在 150ms 的 hover 淡入 + // 旁边显得突兀,像闪了一下) return ( diff --git a/src/windows/settings/SettingsWindow.tsx b/src/windows/settings/SettingsWindow.tsx index f804ce0..602b3af 100644 --- a/src/windows/settings/SettingsWindow.tsx +++ b/src/windows/settings/SettingsWindow.tsx @@ -1,7 +1,7 @@ // src/windows/settings/SettingsWindow.tsx // 设置面板 — macOS 简约明亮灵动风格 -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback, useMemo } from "react"; import { getConfig, setConfigBatch, @@ -20,8 +20,9 @@ import { type RuntimeDiagnostics, } from "@/lib/commands"; import { toast } from "@/components/ToastManager"; -import { SUPPORTED_LANGUAGES, PROVIDERS, PROVIDER_LABELS, ERROR_MESSAGES } from "@/lib/constants"; +import { SUPPORTED_LANGUAGES, PROVIDERS, ERROR_MESSAGES } from "@/lib/constants"; import { useConfigStore } from "@/stores/configStore"; +import { TrendChart, ProviderChart } from "./StatsDashboard"; import { applyTheme } from "@/hooks/useTheme"; import { AboutTab } from "./AboutTab"; @@ -30,6 +31,28 @@ const CREDENTIAL_KEYS = PROVIDERS .filter((p) => p.requiresApiKey) .flatMap((p) => p.credentialFields.map((f) => f.key)); +// dirty 判定涉及的键:非凭证配置键(凭证输入框从空白开始,语义是 +// 「输入新值覆盖」,不构成「未保存的更改」);theme 即时持久化,同样排除 +const DIRTY_KEYS: Array = [ + "target_lang", + "provider", + "auto_start", + "history_limit", + "fallback_enabled", + "clipboard_monitor_enabled", +]; + +/** 把 draft 压成 dirty 键的快照串(供 useMemo 浅比较) */ +function dirtyBaseline(cfg: AppConfig): string { + return DIRTY_KEYS.map((k) => `${String(k)}=${String((cfg as unknown as Record)[k])}`).join("|"); +} + +/** draft 是否与基准有差异 */ +function snapshotDirtyKeys(cfg: AppConfig, baseline: string): boolean { + if (!baseline) return false; + return dirtyBaseline(cfg) !== baseline; +} + type TabId = "general" | "provider" | "about"; export function SettingsWindow({ initialTab }: { initialTab?: "general" | "provider" | "about" }) { @@ -57,6 +80,15 @@ export function SettingsWindow({ initialTab }: { initialTab?: "general" | "provi const [runtimeSnapshot, setRuntimeSnapshot] = useState(null); const [runtimeDiag, setRuntimeDiag] = useState(null); + // 未保存更改追踪:draft 与「已保存基准」的浅比较(仅非凭证键)。 + // 主题已改为即时持久化,不参与 dirty 判定 —— 否则刚切完主题保存栏 + // 会永远挂着「未保存」徽章。 + const [savedBaseline, setSavedBaseline] = useState(""); + const dirty = useMemo(() => { + if (!draft) return false; + return snapshotDirtyKeys(draft, savedBaseline); + }, [draft, savedBaseline]); + useEffect(() => { let cancelled = false; const refresh = () => { @@ -143,6 +175,7 @@ export function SettingsWindow({ initialTab }: { initialTab?: "general" | "provi const mergedCfg = cleaned as unknown as AppConfig; setConfig(mergedCfg); setDraft(mergedCfg); + setSavedBaseline(dirtyBaseline(mergedCfg)); }) .catch((e) => { toast("加载配置失败:" + String(e), "error"); @@ -211,6 +244,7 @@ export function SettingsWindow({ initialTab }: { initialTab?: "general" | "provi await setConfigBatch(updates); setConfig(draft); applyTheme(draft.theme); + setSavedBaseline(dirtyBaseline(draft)); setSaveStatus("ok"); toast("设置已保存", "success"); setTimeout(() => setSaveStatus("idle"), 2500); @@ -229,6 +263,20 @@ export function SettingsWindow({ initialTab }: { initialTab?: "general" | "provi } }, [draft, setConfig]); + // Ctrl+S / Cmd+S 触发保存:桌面应用的保存肌肉记忆。只在非「关于」Tab + // 时拦截(关于页没有草稿,让浏览器默认行为或系统层处理) + useEffect(() => { + if (activeTab === "about") return; + const handler = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") { + e.preventDefault(); + if (!saving) void handleSave(); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [activeTab, saving, handleSave]); + // 测试翻译源 const handleTest = useCallback(async (providerId: string) => { setTestingId(providerId); @@ -284,20 +332,21 @@ export function SettingsWindow({ initialTab }: { initialTab?: "general" | "provi return (
- {/* ── 标题栏 ── */} -
-
-

- 设置 -

-
-

+ {/* ── 标题栏(单行紧凑:标题 + 副题同行,Tab 就地跟随)── + 此前「设置」+「QuickTranslate」分两行各占一段纵向空间,600×480 + 默认窗口下首屏只能看到健康横幅的上半截 —— 信息密度太低。 + 压缩成一行后垂直空间让给仪表盘。 */} +

+

+ 设置 +

+

QuickTranslate

{/* ── macOS 分段 Tab ── */} -
+
- {/* ── 内容区 ── */} -
+ {/* ── 内容区(Tab 切换 150ms 淡入,消除硬切感)── */} +
{activeTab === "general" && ( )} @@ -345,6 +394,13 @@ export function SettingsWindow({ initialTab }: { initialTab?: "general" | "provi {/* 「关于」页无草稿配置,不显示保存栏 */} {activeTab !== "about" && (
+ {/* 未保存提示:改了配置却看不到任何「待保存」迹象,是草稿模型 + 最大的心智成本 —— 徽章 + 按钮=主色双通道提示 */} + {saveStatus === "idle" && dirty && ( + + 有未保存的更改 + + )} {saveStatus === "ok" && ( @@ -359,7 +415,12 @@ export function SettingsWindow({ initialTab }: { initialTab?: "general" | "provi @@ -431,8 +492,17 @@ function GeneralTab({ id="setting-theme" value={draft.theme} onChange={(e) => { - onChange("theme", e.target.value as AppConfig["theme"]); - applyTheme(e.target.value); + const v = e.target.value as AppConfig["theme"]; + onChange("theme", v); + // 主题即时持久化 + 广播:历史/浮窗等其它窗口依赖 + // theme-changed 广播刷新,等「保存」按钮意味着用户切完主题 + // 别的窗口纹丝不动(实机 2026-10-20 验证的历史面板 bug)。 + // 主题是安全键(无凭证、无破坏性),即时生效符合直觉。 + applyTheme(v); + setConfigBatch([["theme", v]]).catch((err) => { + console.error("[SettingsWindow] 主题即时保存失败:", err); + toast("主题保存失败:" + String(err), "error"); + }); }} className="input-field w-32" > @@ -554,17 +624,18 @@ function Dashboard({ )}
- {/* ── 关键指标 2×2 ── */} + {/* ── 关键指标(累计 2 + 近期 3):五卡一行自适应,窄窗折两行 ── */} {statsLoading ? ( -
- {[1, 2, 3, 4].map((i) => ( -
+
+ {[1, 2, 3, 4, 5].map((i) => ( +
))}
) : stats ? ( -
+
+
@@ -574,9 +645,19 @@ function Dashboard({
)} - {/* ── 翻译源使用分布 ── */} + {/* ── 近 7 天趋势(recharts 面积图)── */} + {stats && ( +
+

+ 近 7 天趋势 +

+ +
+ )} + + {/* ── 翻译源使用分布(recharts 横向条形图)── */} {stats && Object.keys(stats.by_provider).length > 0 && ( -
+

翻译源分布 @@ -585,40 +666,7 @@ function Dashboard({ {Object.keys(stats.by_provider).length} 个来源

- {(() => { - const entries = Object.entries(stats.by_provider).sort(([, a], [, b]) => b - a); - const max = Math.max(...entries.map(([, c]) => c), 1); - const total = entries.reduce((sum, [, c]) => sum + c, 0) || 1; - return ( -
- {entries.map(([provider, count]) => ( -
-
- - {PROVIDER_LABELS[provider] ?? provider} - - - {count} 次 - - {Math.round((count / total) * 100)}% - - -
-
-
-
-
- ))} -
- ); - })()} +
)}
diff --git a/src/windows/settings/StatsDashboard.tsx b/src/windows/settings/StatsDashboard.tsx new file mode 100644 index 0000000..4acaf2f --- /dev/null +++ b/src/windows/settings/StatsDashboard.tsx @@ -0,0 +1,220 @@ +// src/windows/settings/StatsDashboard.tsx +// 设置页统计仪表盘 — recharts 可视化 +// +// 数据来自 get_stats()(Rust 侧聚好簇再过 IPC,前端不持有原始记录)。 +// 所有颜色走 CSS 变量,深浅主题自动跟随。 +// +// tooltip 设计:悬浮一个数据点应回答「那天翻译了多少、多少字、占这周 +// 几成」—— 只显示次数的 tooltip 信息密度太低,用户悬浮是在找细节。 + +import { useMemo } from "react"; +import { + AreaChart, + Area, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Cell, +} from "recharts"; +import { PROVIDER_LABELS } from "@/lib/constants"; +import type { StatsResult } from "@/lib/commands"; + +// 主题色通过 CSS 变量注入 recharts(SVG fill/stroke 接受 var()) +const CHART_ACCENT = "var(--system-blue)"; +const CHART_GRID = "var(--border-secondary)"; +const CHART_AXIS = "var(--text-tertiary)"; + +const TICK_STYLE = { fontSize: 10.5, fill: CHART_AXIS } as const; + +const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]; + +function dayLabel(dateMs: number): string { + const d = new Date(dateMs); + const today = new Date(); + const isToday = + d.getFullYear() === today.getFullYear() && + d.getMonth() === today.getMonth() && + d.getDate() === today.getDate(); + return isToday ? "今天" : WEEKDAYS[d.getDay()]; +} + +/** 完整日期:2026/10/20(tooltip 里用,横轴空间不够) */ +function fullDate(dateMs: number): string { + const d = new Date(dateMs); + return `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`; +} + +function formatChars(n: number): string { + if (n >= 10_000) return (n / 10_000).toFixed(1) + " 万"; + if (n >= 1_000) return (n / 1_000).toFixed(1) + "K"; + return String(n); +} + +/** 近 7 天趋势 —— 渐变面积图 + 详细悬浮卡 */ +function TrendChart({ daily }: { daily: StatsResult["daily_7d"] }) { + const data = useMemo( + () => + daily.map((d) => ({ + label: dayLabel(d.date_ms), + date_ms: d.date_ms, + count: d.count, + chars: d.chars, + })), + [daily] + ); + const weekTotal = useMemo(() => data.reduce((s, d) => s + d.count, 0), [data]); + + return ( + + + + {/* 面积渐变:主色由浓到透,替代纯色平涂 */} + + + + + + + + + } + // 跟手 + 无黑框;cursor 线对齐到最近数据点,悬浮不差像素 + cursor={{ stroke: CHART_AXIS, strokeDasharray: "3 3" }} + wrapperStyle={{ outline: "none", zIndex: 10 }} + /> + + + + ); +} + +/** 趋势图悬浮卡:日期 + 次数 + 字符量 + 周占比 */ +function TrendTooltip({ active, payload, weekTotal }: { + active?: boolean; + payload?: Array<{ payload: { label: string; date_ms: number; count: number; chars: number } }>; + weekTotal: number; +}) { + if (!active || !payload?.length) return null; + const d = payload[0].payload; + const pct = weekTotal > 0 ? Math.round((d.count / weekTotal) * 100) : 0; + return ( +
+

+ {fullDate(d.date_ms)} + {d.label} +

+
+

+ 翻译 + {d.count} 次 +

+

+ 字符 + {d.chars.toLocaleString()} +

+

+ 占本周 + {pct}% +

+
+
+ ); +} + +/** 翻译源分布 —— 渐变横向条形图 + 详细悬浮卡 */ +function ProviderChart({ byProvider }: { byProvider: StatsResult["by_provider"] }) { + const data = useMemo( + () => + Object.entries(byProvider) + .sort(([, a], [, b]) => b - a) + .map(([provider, count]) => ({ + name: PROVIDER_LABELS[provider] ?? provider, + count, + })), + [byProvider] + ); + const total = useMemo(() => data.reduce((s, d) => s + d.count, 0), [data]); + + return ( + + + + + } + cursor={{ fill: "var(--hover-bg)" }} + wrapperStyle={{ outline: "none", zIndex: 10 }} + /> + + {data.map((_, i) => ( + + ))} + + + + ); +} + +/** 分布图悬浮卡:次数 + 占比 */ +function ProviderTooltip({ active, payload, total }: { + active?: boolean; + payload?: Array<{ payload: { name: string; count: number } }>; + total: number; +}) { + if (!active || !payload?.length) return null; + const d = payload[0].payload; + const pct = total > 0 ? Math.round((d.count / total) * 100) : 0; + return ( +
+

{d.name}

+

+ {d.count} 次 + · {pct}% +

+
+ ); +} + +// 翻译源条形图配色:主色 + 同明度的 macOS 系统色序列(深浅主题都可读) +const PROVIDER_COLORS = [ + "var(--system-blue)", + "var(--system-teal)", + "var(--system-purple)", + "var(--system-orange)", + "var(--system-green)", + "var(--system-pink)", +]; + +export { TrendChart, ProviderChart }; diff --git a/vite.config.ts b/vite.config.ts index 5b4eb33..6c7d8e4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -18,6 +18,10 @@ export default defineConfig(async () => ({ server: { port: 5173, strictPort: true, + // 钉在 IPv4:Node ≥17 对 `localhost` 的 DNS 解析常落在 ::1,Vite 便只绑 + // IPv6,而 tauri CLI 探测 devUrl(localhost:5173)时走 127.0.0.1 —— + // 探测 180s 连不上,`npm run tauri dev` 直接失败。显式 host 消除歧义。 + host: "127.0.0.1", watch: { // Watch for changes in the Tauri source ignored: ["**/src-tauri/**"],