improve: frontend & backend
This commit is contained in:
parent
429efa0c9f
commit
50d6e0f498
29
bun.lock
29
bun.lock
@ -203,6 +203,7 @@
|
||||
"@nivo/line": "^0.99.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@react-router/node": "^7.7.1",
|
||||
@ -518,6 +519,14 @@
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="],
|
||||
|
||||
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.6", "", { "dependencies": { "@floating-ui/dom": "^1.7.4" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw=="],
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
||||
|
||||
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.1", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-sPxgEWtPUR3EnRJCEtbGZG2iX8LQDUls2wUS3o27jg07KqJFMq6YDeWvMo1wfpmy3rqRdS0rivpLwhqQtEyCuQ=="],
|
||||
|
||||
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="],
|
||||
@ -782,10 +791,14 @@
|
||||
|
||||
"@rabbit-company/argon2id": ["@rabbit-company/argon2id@2.1.0", "", { "peerDependencies": { "typescript": "^5.6.2" } }, "sha512-X/kt89qjmS9+Zh+DYCGcWeTwHa4C8vY8T3EnSma+vWj7spMzAYX4F8vmGUkny9hygpTOeC/yXwAUdJAfJ52H+w=="],
|
||||
|
||||
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||
|
||||
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="],
|
||||
|
||||
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
|
||||
|
||||
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
|
||||
|
||||
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
|
||||
@ -804,6 +817,8 @@
|
||||
|
||||
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
|
||||
|
||||
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
|
||||
|
||||
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
|
||||
|
||||
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
|
||||
@ -812,6 +827,8 @@
|
||||
|
||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
|
||||
|
||||
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
|
||||
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
||||
|
||||
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
|
||||
@ -826,6 +843,16 @@
|
||||
|
||||
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
|
||||
|
||||
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
|
||||
|
||||
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
|
||||
|
||||
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
|
||||
|
||||
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
|
||||
|
||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
||||
|
||||
"@react-router/dev": ["@react-router/dev@7.9.5", "", { "dependencies": { "@babel/core": "^7.27.7", "@babel/generator": "^7.27.5", "@babel/parser": "^7.27.7", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/preset-typescript": "^7.27.1", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@npmcli/package-json": "^4.0.1", "@react-router/node": "7.9.5", "@remix-run/node-fetch-server": "^0.9.0", "arg": "^5.0.1", "babel-dead-code-elimination": "^1.0.6", "chokidar": "^4.0.0", "dedent": "^1.5.3", "es-module-lexer": "^1.3.1", "exit-hook": "2.2.1", "isbot": "^5.1.11", "jsesc": "3.0.2", "lodash": "^4.17.21", "p-map": "^7.0.3", "pathe": "^1.1.2", "picocolors": "^1.1.1", "prettier": "^3.6.2", "react-refresh": "^0.14.0", "semver": "^7.3.7", "tinyglobby": "^0.2.14", "valibot": "^1.1.0", "vite-node": "^3.2.2" }, "peerDependencies": { "@react-router/serve": "^7.9.5", "@vitejs/plugin-rsc": "*", "react-router": "^7.9.5", "typescript": "^5.1.0", "vite": "^5.1.0 || ^6.0.0 || ^7.0.0", "wrangler": "^3.28.2 || ^4.0.0" }, "optionalPeers": ["@react-router/serve", "@vitejs/plugin-rsc", "typescript", "wrangler"], "bin": { "react-router": "bin.js" } }, "sha512-MkWI4zN7VbQ0tteuJtX5hmDINNS26IW236a8lM8+o1344xdnT/ZsBvcUh8AkzDdCRYEz1blgzgirpj0Wc1gmXg=="],
|
||||
|
||||
"@react-router/express": ["@react-router/express@7.9.5", "", { "dependencies": { "@react-router/node": "7.9.5" }, "peerDependencies": { "express": "^4.17.1 || ^5", "react-router": "7.9.5", "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-Mg94Tw9JSaRuwkvIC6PaODRzsLs6mo70ppz5qdIK/G3iotSxsH08TDNdzot7CaXXevk/pIiD/+Tbn0H/asHsYA=="],
|
||||
@ -3064,6 +3091,8 @@
|
||||
|
||||
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@react-router/dev/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
||||
|
||||
"@react-router/serve/express": ["express@4.21.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "0.19.0", "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA=="],
|
||||
|
||||
@ -13,8 +13,11 @@ export const getMileStoneETAfactor = (x: number) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const getClosetMilestone = (views: number) => {
|
||||
export const getClosetMilestone = (views: number, strict: boolean = false) => {
|
||||
if (views < 100000) return 100000;
|
||||
if (views < 1000000) return 1000000;
|
||||
if (views < 10000000) {
|
||||
return strict ? 10000000 : Math.ceil(views / 1000000) * 1000000;
|
||||
}
|
||||
return Math.ceil(views / 1000000) * 1000000;
|
||||
};
|
||||
|
||||
@ -11,6 +11,7 @@ import logger from "@core/log";
|
||||
import { SnapshotQueue } from "mq/index";
|
||||
import { sql } from "@core/db/dbNew";
|
||||
import { jobCounter, jobDurationRaw } from "metrics";
|
||||
import { getClosetMilestone as closetMilestone } from "@core/lib/milestone";
|
||||
|
||||
const priorityMap: { [key: string]: number } = {
|
||||
milestone: 1,
|
||||
@ -88,11 +89,4 @@ export const snapshotTickWorker = async (_job: Job) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const closetMilestone = (views: number, strict: boolean = false) => {
|
||||
if (views < 100000) return 100000;
|
||||
if (views < 1000000) return 1000000;
|
||||
if (views < 10000000) {
|
||||
return strict ? 10000000 : Math.ceil(views / 1000000) * 1000000;
|
||||
}
|
||||
return Math.ceil(views / 1000000) * 1000000;
|
||||
};
|
||||
export { closetMilestone };
|
||||
|
||||
@ -2,6 +2,9 @@ import { Elysia, t } from "elysia";
|
||||
import { biliIDToAID } from "@elysia/lib/bilibiliID";
|
||||
import { requireAuth } from "@elysia/middlewares/auth";
|
||||
import { LatestVideosQueue } from "@elysia/lib/mq";
|
||||
import { db } from "@core/drizzle";
|
||||
import { songs } from "@core/drizzle/main/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
export const addSongHandler = new Elysia()
|
||||
.use(requireAuth)
|
||||
@ -10,6 +13,23 @@ export const addSongHandler = new Elysia()
|
||||
async ({ body, status }) => {
|
||||
const id = body.id;
|
||||
const aid = biliIDToAID(id);
|
||||
if (!aid) {
|
||||
return status(400, {
|
||||
message:
|
||||
"We cannot parse the video ID, or we currently do not support this format."
|
||||
});
|
||||
}
|
||||
const aidExistsInSongs = await db
|
||||
.select()
|
||||
.from(songs)
|
||||
.where(and(eq(songs.aid, aid), eq(songs.deleted, false)))
|
||||
.limit(1);
|
||||
if (aidExistsInSongs.length > 0) {
|
||||
return {
|
||||
jobID: -1,
|
||||
message: "Video already exists in the songs table."
|
||||
};
|
||||
}
|
||||
const job = await LatestVideosQueue.add("getVideoInfo", {
|
||||
aid: aid,
|
||||
insertSongs: true
|
||||
@ -30,6 +50,9 @@ export const addSongHandler = new Elysia()
|
||||
message: t.String(),
|
||||
jobID: t.String()
|
||||
}),
|
||||
400: t.Object({
|
||||
message: t.String()
|
||||
}),
|
||||
401: t.Object({
|
||||
message: t.String()
|
||||
}),
|
||||
@ -46,6 +69,15 @@ export const addSongHandler = new Elysia()
|
||||
"/song/import/:id/status",
|
||||
async ({ params, status }) => {
|
||||
const jobID = params.id;
|
||||
if (parseInt(jobID) === -1) {
|
||||
return {
|
||||
id: jobID,
|
||||
state: "completed",
|
||||
result: {
|
||||
message: "Video already exists in the songs table."
|
||||
}
|
||||
}
|
||||
}
|
||||
const job = await LatestVideosQueue.getJob(jobID);
|
||||
if (!job) {
|
||||
return status(404, {
|
||||
|
||||
55
packages/elysia/routes/video/eta.ts
Normal file
55
packages/elysia/routes/video/eta.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { db } from "@core/drizzle";
|
||||
import { eta } from "@core/drizzle/main/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { biliIDToAID } from "@elysia/lib/bilibiliID";
|
||||
|
||||
export const songEtaHandler = new Elysia({ prefix: "/video" }).get(
|
||||
"/:id/eta",
|
||||
async ({ params, status }) => {
|
||||
const id = params.id;
|
||||
const aid = biliIDToAID(id);
|
||||
if (!aid) {
|
||||
return status(400, {
|
||||
code: "MALFORMED_SLOT",
|
||||
message: "We cannot parse the video ID, or we currently do not support this format."
|
||||
});
|
||||
}
|
||||
const data = await db.select().from(eta).where(eq(eta.aid, aid));
|
||||
if (data.length === 0) {
|
||||
return status(404, {
|
||||
code: "VIDEO_NOT_FOUND",
|
||||
message: "Video not found."
|
||||
});
|
||||
}
|
||||
return {
|
||||
aid: data[0].aid,
|
||||
eta: data[0].eta,
|
||||
views: data[0].currentViews,
|
||||
speed: data[0].speed,
|
||||
updatedAt: data[0].updatedAt
|
||||
};
|
||||
},
|
||||
{
|
||||
response: {
|
||||
200: t.Object({
|
||||
aid: t.Number(),
|
||||
eta: t.Number(),
|
||||
views: t.Number(),
|
||||
speed: t.Number(),
|
||||
updatedAt: t.String()
|
||||
}),
|
||||
400: t.Object({
|
||||
code: t.String(),
|
||||
message: t.String()
|
||||
}),
|
||||
404: t.Object({
|
||||
code: t.String(),
|
||||
message: t.String()
|
||||
})
|
||||
},
|
||||
headers: t.Object({
|
||||
Authorization: t.Optional(t.String())
|
||||
})
|
||||
}
|
||||
);
|
||||
@ -13,6 +13,7 @@ import { searchHandler } from "@elysia/routes/search";
|
||||
import { getVideoSnapshotsHandler } from "@elysia/routes/video/snapshots";
|
||||
import { addSongHandler } from "@elysia/routes/song/add";
|
||||
import { deleteSongHandler } from "@elysia/routes/song/delete";
|
||||
import { songEtaHandler } from "@elysia/routes/video/eta";
|
||||
|
||||
const [host, port] = getBindingInfo();
|
||||
logStartup(host, port);
|
||||
@ -43,6 +44,7 @@ const app = new Elysia({
|
||||
.use(getVideoSnapshotsHandler)
|
||||
.use(addSongHandler)
|
||||
.use(deleteSongHandler)
|
||||
.use(songEtaHandler)
|
||||
.listen(15412);
|
||||
|
||||
export const VERSION = "0.7.0";
|
||||
|
||||
@ -4,7 +4,7 @@ import { Search } from "./Search";
|
||||
export function Layout({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="w-screen min-h-screen relative left-0 top-0 flex justify-center">
|
||||
<main className="w-full max-sm:mx-3 pt-14 sm:w-xl xl:w-2xl">
|
||||
<main className="w-full max-sm:mx-3 pt-14 sm:w-xl xl:w-2xl 2xl:w-3xl mb-20">
|
||||
<div className="flex items-center justify-between">
|
||||
<a href="/">
|
||||
<h1 className="text-3xl mb-5">中V档案馆</h1>
|
||||
@ -41,7 +41,7 @@ const LoginOrLogout = () => {
|
||||
export function LayoutWithoutSearch({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="w-screen min-h-screen relative left-0 top-0 flex justify-center">
|
||||
<main className="w-full max-sm:mx-3 pt-14 sm:w-xl xl:w-2xl">
|
||||
<main className="w-full max-sm:mx-3 pt-14 sm:w-xl xl:w-2xl 2xl:w-3xl mb-20">
|
||||
<div className="flex items-center justify-between">
|
||||
<a href="/">
|
||||
<h1 className="text-3xl mb-5">中V档案馆</h1>
|
||||
|
||||
185
packages/temp_frontend/app/components/ui/select.tsx
Normal file
185
packages/temp_frontend/app/components/ui/select.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib//utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@ -4,8 +4,8 @@ export default [
|
||||
index("routes/home.tsx"),
|
||||
route("song/:id/info", "routes/song/[id]/info/index.tsx"),
|
||||
route("song/:id/add", "routes/song/[id]/add.tsx"),
|
||||
route("chart-demo", "routes/chartDemo.tsx"),
|
||||
route("search", "routes/search/index.tsx"),
|
||||
route("login", "routes/login.tsx"),
|
||||
route("video/:id/info", "routes/video/[id]/info/index.tsx"),
|
||||
route("util/time-calculator", "routes/util/time-calculator.tsx"),
|
||||
] satisfies RouteConfig;
|
||||
|
||||
@ -1,10 +1,30 @@
|
||||
import { Layout } from "@/components/Layout";
|
||||
import type { Route } from "./+types/home";
|
||||
import { useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: "中V档案馆" }];
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return <Layout></Layout>;
|
||||
const [input, setInput] = useState("");
|
||||
return (
|
||||
<Layout>
|
||||
<h2 className="text-2xl mt-5 mb-2">小工具</h2>
|
||||
<div className="flex items-center gap-7">
|
||||
<Button>
|
||||
<a href="/util/time-calculator">时间计算器</a>
|
||||
</Button>
|
||||
|
||||
<div className="flex w-96 gap-3">
|
||||
<Input placeholder="输入BV号或av号" value={input} onChange={(e) => setInput(e.target.value)} />
|
||||
<Button >
|
||||
<a href={`/song/${input}/add`}>收录视频</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { Route } from "./+types/index";
|
||||
import { treaty } from "@elysiajs/eden";
|
||||
import type { App } from "@elysia/src";
|
||||
import { useEffect, useState } from "react";
|
||||
import { memo, useEffect, useState, useMemo } from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import { Title } from "@/components/Title";
|
||||
@ -11,9 +11,10 @@ import { Layout } from "@/components/Layout";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { formatDateTime } from "@/components/SearchResults";
|
||||
import { ViewsChart } from "./views-chart";
|
||||
import { processSnapshots } from "./lib";
|
||||
import { processSnapshots, detectMilestoneAchievements, type MilestoneAchievement } from "./lib";
|
||||
import { DataTable } from "./data-table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { If, Then } from "react-if";
|
||||
@ -35,14 +36,29 @@ import { columns, type Snapshot } from "./columns";
|
||||
const app = treaty<App>(import.meta.env.VITE_API_URL!);
|
||||
|
||||
type SongInfo = Awaited<ReturnType<ReturnType<typeof app.song>["info"]["get"]>>["data"];
|
||||
type EtaInfo = Awaited<ReturnType<ReturnType<typeof app.video>["eta"]["get"]>>["data"];
|
||||
export type Snapshots = Awaited<ReturnType<ReturnType<typeof app.video>["snapshots"]["get"]>>["data"];
|
||||
type SongInfoError = Awaited<ReturnType<ReturnType<typeof app.song>["info"]["get"]>>["error"];
|
||||
type SnapshotsError = Awaited<ReturnType<ReturnType<typeof app.video>["snapshots"]["get"]>>["error"];
|
||||
type EtaInfoError = Awaited<ReturnType<ReturnType<typeof app.video>["eta"]["get"]>>["error"];
|
||||
|
||||
export async function clientLoader({ params }: Route.LoaderArgs) {
|
||||
return { id: params.id };
|
||||
}
|
||||
|
||||
function formatHours(hours: number): string {
|
||||
if (hours >= 24 * 14) return `${Math.floor(hours / 24)} 天`;
|
||||
if (hours >= 24) return `${Math.floor(hours / 24)} 天 ${hours % 24} 小时`;
|
||||
if (hours >= 1) return `${Math.floor(hours)} 时 ${Math.round((hours % 1) * 60)} 分`;
|
||||
return `${Math.round(hours * 60)} 分钟`;
|
||||
}
|
||||
|
||||
function addHoursToNow(hours: number): string {
|
||||
const d = new Date();
|
||||
d.setHours(d.getHours() + hours);
|
||||
return `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, "0")}-${d.getDate().toString().padStart(2, "0")} ${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
const StatsTable = ({ snapshots }: { snapshots: Snapshots | null }) => {
|
||||
if (!snapshots || snapshots.length === 0) {
|
||||
return null;
|
||||
@ -61,7 +77,58 @@ const StatsTable = ({ snapshots }: { snapshots: Snapshots | null }) => {
|
||||
return <DataTable columns={columns} data={tableData} />;
|
||||
};
|
||||
|
||||
const SnapshotsView = ({ snapshots }: { snapshots: Snapshots | null }) => {
|
||||
const getMileStoneName = (views: number) => {
|
||||
if (views < 100000) return "殿堂";
|
||||
if (views < 1000000) return "传说";
|
||||
return "神话";
|
||||
};
|
||||
|
||||
const SnapshotsView = ({
|
||||
snapshots,
|
||||
etaData,
|
||||
publishedAt,
|
||||
}: {
|
||||
snapshots: Snapshots | null;
|
||||
etaData: EtaInfo | null;
|
||||
publishedAt?: string;
|
||||
}) => {
|
||||
const [timeRange, setTimeRange] = useState<string>("all");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize] = useState(10);
|
||||
const [timeOffsetHours, setTimeOffsetHours] = useState(0);
|
||||
|
||||
// Calculate time range in hours
|
||||
const timeRangeHours = useMemo(() => {
|
||||
switch (timeRange) {
|
||||
case "6h":
|
||||
return 6;
|
||||
case "12h":
|
||||
return 12;
|
||||
case "24h":
|
||||
return 24;
|
||||
case "3d":
|
||||
return 72;
|
||||
case "7d":
|
||||
return 168;
|
||||
case "14d":
|
||||
return 336;
|
||||
case "30d":
|
||||
return 720;
|
||||
default:
|
||||
return undefined; // "all"
|
||||
}
|
||||
}, [timeRange]);
|
||||
|
||||
// Pagination for table data
|
||||
const paginatedSnapshots = useMemo(() => {
|
||||
if (!snapshots) return [];
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
return snapshots.slice(startIndex, endIndex);
|
||||
}, [snapshots, currentPage, pageSize]);
|
||||
|
||||
const totalPages = snapshots ? Math.ceil(snapshots.length / pageSize) : 0;
|
||||
|
||||
if (!snapshots) {
|
||||
return <Skeleton className="w-full h-50 rounded-lg mt-4" />;
|
||||
}
|
||||
@ -74,49 +141,168 @@ const SnapshotsView = ({ snapshots }: { snapshots: Snapshots | null }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const processedData = processSnapshots(snapshots);
|
||||
const processedData = processSnapshots(snapshots, timeRangeHours, timeOffsetHours);
|
||||
const milestoneAchievements = detectMilestoneAchievements(snapshots, publishedAt);
|
||||
|
||||
// Handle time range navigation
|
||||
const totalDataHours =
|
||||
snapshots && snapshots.length > 0
|
||||
? (new Date(snapshots[snapshots.length - 1].createdAt).getTime() -
|
||||
new Date(snapshots[0].createdAt).getTime()) /
|
||||
(1000 * 60 * 60)
|
||||
: 0;
|
||||
|
||||
// Simplified logic: always allow navigation if we have a time range
|
||||
const canGoBack = timeRangeHours !== undefined && timeRangeHours > 0;
|
||||
const canGoForward = timeRangeHours !== undefined && timeRangeHours > 0;
|
||||
|
||||
const handleBack = () => {
|
||||
if (timeRangeHours && timeRangeHours > 0) {
|
||||
setTimeOffsetHours((prev) => prev + timeRangeHours);
|
||||
}
|
||||
};
|
||||
|
||||
const handleForward = () => {
|
||||
if (timeRangeHours && timeRangeHours > 0) {
|
||||
setTimeOffsetHours((prev) => Math.max(0, prev - timeRangeHours));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<p>
|
||||
播放: {snapshots[0].views.toLocaleString()}
|
||||
<span className="text-secondary-foreground"> 更新于 {formatDateTime(new Date(snapshots[0].createdAt))}</span>
|
||||
<span className="text-secondary-foreground">
|
||||
{" "}
|
||||
更新于 {formatDateTime(new Date(snapshots[0].createdAt))}
|
||||
</span>
|
||||
</p>
|
||||
{etaData && (
|
||||
<>
|
||||
{etaData!.views <= 10000000 && (
|
||||
<p>
|
||||
下一个成就:{getMileStoneName(etaData!.views)}
|
||||
<span className="text-secondary-foreground">
|
||||
{" "}
|
||||
预计 {formatHours(etaData!.eta)} 后({addHoursToNow(etaData!.eta)})达成
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{milestoneAchievements.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-secondary-foreground">成就达成时间:</p>
|
||||
{milestoneAchievements.map((achievement) => (
|
||||
<p key={achievement.milestone} className="text-sm text-secondary-foreground ml-2">
|
||||
{achievement.milestoneName}({achievement.milestone.toLocaleString()}) -{" "}
|
||||
{formatDateTime(new Date(achievement.achievedAt))}
|
||||
{achievement.timeTaken && ` - 用时 ${achievement.timeTaken}`}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="chart" className="mt-4">
|
||||
<div className="flex justify-between mb-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-2xl font-medium">数据</h2>
|
||||
<TabsList>
|
||||
<TabsTrigger value="chart">图表</TabsTrigger>
|
||||
<TabsTrigger value="table">表格</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="chart">图表</TabsTrigger>
|
||||
<TabsTrigger value="table">表格</TabsTrigger>
|
||||
</TabsList>
|
||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="时间范围" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="6h">6小时</SelectItem>
|
||||
<SelectItem value="12h">12小时</SelectItem>
|
||||
<SelectItem value="24h">24小时</SelectItem>
|
||||
<SelectItem value="3d">3天</SelectItem>
|
||||
<SelectItem value="7d">7天</SelectItem>
|
||||
<SelectItem value="14d">14天</SelectItem>
|
||||
<SelectItem value="30d">30天</SelectItem>
|
||||
<SelectItem value="all">全部</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabsContent value="chart">
|
||||
<ViewsChart chartData={processedData} />
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<Button variant="outline" size="sm" onClick={handleBack} disabled={!canGoBack}>
|
||||
上一个时间段
|
||||
</Button>
|
||||
<span className="text-sm text-secondary-foreground">
|
||||
{timeRangeHours ? `${timeRangeHours}小时范围` : "全部数据"}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={handleForward} disabled={!canGoForward}>
|
||||
下一个时间段
|
||||
</Button>
|
||||
</div>
|
||||
<ViewsChart chartData={processedData} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="table">
|
||||
<StatsTable snapshots={snapshots} />
|
||||
<StatsTable snapshots={paginatedSnapshots} />
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center items-center gap-2 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-sm text-secondary-foreground">
|
||||
第 {currentPage} 页,共 {totalPages} 页
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((prev) => Math.min(prev + 1, totalPages))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MemoizedSnapshotsView = memo(SnapshotsView);
|
||||
|
||||
export default function SongInfo({ loaderData }: Route.ComponentProps) {
|
||||
const [songInfo, setData] = useState<SongInfo | null>(null);
|
||||
const [snapshots, setSnapshots] = useState<Snapshots | null>(null);
|
||||
const [error, setError] = useState<SongInfoError | SnapshotsError | null>(null);
|
||||
const [etaData, setEtaData] = useState<EtaInfo | null>(null);
|
||||
const [error, setError] = useState<SongInfoError | SnapshotsError | EtaInfoError | null>(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [songName, setSongName] = useState("");
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const getEta = async (aid: number) => {
|
||||
const { data, error } = await app.video({ id: `av${aid}` }).eta.get();
|
||||
if (error) {
|
||||
console.log(error);
|
||||
return;
|
||||
}
|
||||
setEtaData(data);
|
||||
};
|
||||
|
||||
const getSnapshots = async (aid: number) => {
|
||||
const { data, error } = await app.video({ id: `av${aid}` }).snapshots.get();
|
||||
if (error) {
|
||||
console.log(error);
|
||||
setError(error);
|
||||
return;
|
||||
}
|
||||
setSnapshots(data);
|
||||
@ -141,6 +327,7 @@ export default function SongInfo({ loaderData }: Route.ComponentProps) {
|
||||
const aid = songInfo.aid;
|
||||
if (!aid) return;
|
||||
getSnapshots(aid);
|
||||
getEta(aid);
|
||||
}, [songInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -330,7 +517,11 @@ export default function SongInfo({ loaderData }: Route.ComponentProps) {
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
<SnapshotsView snapshots={snapshots} />
|
||||
<MemoizedSnapshotsView
|
||||
snapshots={snapshots}
|
||||
etaData={etaData}
|
||||
publishedAt={songInfo!.publishedAt || undefined}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,23 +1,31 @@
|
||||
import { HOUR, MINUTE } from "@core/lib";
|
||||
import type { Snapshots } from "./index";
|
||||
|
||||
const getDataIntervalMins = (interval: number) => {
|
||||
export interface MilestoneAchievement {
|
||||
milestone: number;
|
||||
milestoneName: string;
|
||||
achievedAt: string;
|
||||
views: number;
|
||||
timeTaken?: string;
|
||||
}
|
||||
|
||||
const getDataIntervalMins = (interval: number, timeRangeHours?: number) => {
|
||||
if (!timeRangeHours ||timeRangeHours >= 7 * 24) {
|
||||
return 24 * 60;
|
||||
}
|
||||
if (interval >= 6 * HOUR) {
|
||||
return 6 * 60;
|
||||
}
|
||||
else if (interval >= 1 * HOUR) {
|
||||
} else if (interval >= 1 * HOUR) {
|
||||
return 60;
|
||||
}
|
||||
else if (interval >= 15 * MINUTE) {
|
||||
} else if (interval >= 15 * MINUTE) {
|
||||
return 15;
|
||||
}
|
||||
else if (interval >= 5 * MINUTE) {
|
||||
} else if (interval >= 5 * MINUTE) {
|
||||
return 5;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
export const processSnapshots = (snapshots: Snapshots | null, timeRangeHours: number = 14 * 24) => {
|
||||
export const processSnapshots = (snapshots: Snapshots | null, timeRangeHours?: number, timeOffsetHours: number = 0) => {
|
||||
if (!snapshots || snapshots.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@ -27,17 +35,24 @@ export const processSnapshots = (snapshots: Snapshots | null, timeRangeHours: nu
|
||||
);
|
||||
|
||||
const oldestDate = new Date(sortedSnapshots[0].createdAt);
|
||||
const newestDate = new Date(sortedSnapshots[sortedSnapshots.length - 1].createdAt);
|
||||
|
||||
const targetStartTime = new Date(new Date().getTime() - timeRangeHours * HOUR);
|
||||
// Calculate the time range with offset
|
||||
const targetEndTime = timeRangeHours ? new Date(newestDate.getTime() - timeOffsetHours * HOUR) : newestDate;
|
||||
const targetStartTime = timeRangeHours ? new Date(targetEndTime.getTime() - timeRangeHours * HOUR) : null;
|
||||
|
||||
const startTime = oldestDate > targetStartTime ? oldestDate : targetStartTime;
|
||||
const startTime = targetStartTime ? (oldestDate > targetStartTime ? oldestDate : targetStartTime) : oldestDate;
|
||||
const endTime = targetEndTime;
|
||||
|
||||
const hourlyTimePoints: Date[] = [];
|
||||
const currentTime = new Date(sortedSnapshots[sortedSnapshots.length - 1].createdAt);
|
||||
const currentTime = endTime;
|
||||
const timeDiff = currentTime.getTime() - startTime.getTime();
|
||||
const length = sortedSnapshots.filter((s) => new Date(s.createdAt) >= startTime).length;
|
||||
const length = sortedSnapshots.filter((s) => {
|
||||
const snapshotTime = new Date(s.createdAt).getTime();
|
||||
return snapshotTime >= startTime.getTime() && snapshotTime <= endTime.getTime();
|
||||
}).length;
|
||||
const avgInterval = timeDiff / length;
|
||||
const dataIntervalMins = getDataIntervalMins(avgInterval);
|
||||
const dataIntervalMins = getDataIntervalMins(avgInterval, timeRangeHours);
|
||||
|
||||
for (let time = new Date(startTime); time <= currentTime; time.setMinutes(time.getMinutes() + dataIntervalMins)) {
|
||||
hourlyTimePoints.push(new Date(time));
|
||||
@ -45,9 +60,15 @@ export const processSnapshots = (snapshots: Snapshots | null, timeRangeHours: nu
|
||||
|
||||
const processedData = hourlyTimePoints
|
||||
.map((timePoint) => {
|
||||
const previousSnapshots = sortedSnapshots.filter((s) => new Date(s.createdAt) <= timePoint);
|
||||
|
||||
const nextSnapshots = sortedSnapshots.filter((s) => new Date(s.createdAt) >= timePoint);
|
||||
const previousSnapshots = sortedSnapshots.filter((s) => {
|
||||
const snapshotTime = new Date(s.createdAt).getTime();
|
||||
return snapshotTime <= timePoint.getTime() && snapshotTime >= startTime.getTime();
|
||||
});
|
||||
|
||||
const nextSnapshots = sortedSnapshots.filter((s) => {
|
||||
const snapshotTime = new Date(s.createdAt).getTime();
|
||||
return snapshotTime >= timePoint.getTime() && snapshotTime <= endTime.getTime();
|
||||
});
|
||||
|
||||
const previousSnapshot = previousSnapshots[previousSnapshots.length - 1];
|
||||
const nextSnapshot = nextSnapshots[0];
|
||||
@ -117,4 +138,100 @@ export const processSnapshots = (snapshots: Snapshots | null, timeRangeHours: nu
|
||||
.filter((d) => d !== null);
|
||||
|
||||
return processedData;
|
||||
};
|
||||
};
|
||||
|
||||
export const detectMilestoneAchievements = (snapshots: Snapshots | null, publishedAt?: string): MilestoneAchievement[] => {
|
||||
if (!snapshots || snapshots.length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sortedSnapshots = [...snapshots].sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
);
|
||||
|
||||
const milestones = [100000, 1000000, 10000000];
|
||||
const milestoneNames = ["殿堂", "传说", "神话"];
|
||||
const achievements: MilestoneAchievement[] = [];
|
||||
|
||||
// Find the earliest snapshot for each milestone
|
||||
const earliestAchievements = new Map<number, MilestoneAchievement>();
|
||||
|
||||
for (let i = 1; i < sortedSnapshots.length; i++) {
|
||||
const prevSnapshot = sortedSnapshots[i - 1];
|
||||
const currentSnapshot = sortedSnapshots[i];
|
||||
|
||||
const prevTime = new Date(prevSnapshot.createdAt).getTime();
|
||||
const currentTime = new Date(currentSnapshot.createdAt).getTime();
|
||||
const timeDiff = currentTime - prevTime;
|
||||
|
||||
// Check if snapshots are within 10 minutes
|
||||
if (timeDiff <= 10 * 60 * 1000) {
|
||||
for (let j = 0; j < milestones.length; j++) {
|
||||
const milestone = milestones[j];
|
||||
const milestoneName = milestoneNames[j];
|
||||
|
||||
// Check if milestone was crossed between these two snapshots
|
||||
if (prevSnapshot.views < milestone && currentSnapshot.views >= milestone) {
|
||||
// Find the exact time when milestone was reached (linear interpolation)
|
||||
const ratio = (milestone - prevSnapshot.views) / (currentSnapshot.views - prevSnapshot.views);
|
||||
const milestoneTime = new Date(prevTime + ratio * timeDiff);
|
||||
|
||||
const achievement: MilestoneAchievement = {
|
||||
milestone,
|
||||
milestoneName,
|
||||
achievedAt: milestoneTime.toISOString(),
|
||||
views: milestone,
|
||||
};
|
||||
|
||||
// Only keep the earliest achievement for each milestone
|
||||
if (
|
||||
!earliestAchievements.has(milestone) ||
|
||||
new Date(achievement.achievedAt) < new Date(earliestAchievements.get(milestone)!.achievedAt)
|
||||
) {
|
||||
earliestAchievements.set(milestone, achievement);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a snapshot exactly equals a milestone
|
||||
if (prevSnapshot.views === milestone || currentSnapshot.views === milestone) {
|
||||
const exactSnapshot = prevSnapshot.views === milestone ? prevSnapshot : currentSnapshot;
|
||||
const achievement: MilestoneAchievement = {
|
||||
milestone,
|
||||
milestoneName,
|
||||
achievedAt: exactSnapshot.createdAt,
|
||||
views: milestone,
|
||||
};
|
||||
|
||||
if (
|
||||
!earliestAchievements.has(milestone) ||
|
||||
new Date(achievement.achievedAt) < new Date(earliestAchievements.get(milestone)!.achievedAt)
|
||||
) {
|
||||
earliestAchievements.set(milestone, achievement);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert map to array and sort by milestone value
|
||||
const achievementsWithTime = Array.from(earliestAchievements.values()).sort((a, b) => a.milestone - b.milestone);
|
||||
|
||||
// Calculate time taken for each achievement
|
||||
if (publishedAt) {
|
||||
const publishTime = new Date(publishedAt).getTime();
|
||||
|
||||
for (const achievement of achievementsWithTime) {
|
||||
const achievementTime = new Date(achievement.achievedAt).getTime();
|
||||
const timeDiffMs = achievementTime - publishTime;
|
||||
|
||||
// Convert to days, hours, minutes
|
||||
const days = Math.floor(timeDiffMs / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((timeDiffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((timeDiffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
achievement.timeTaken = `${days} 天 ${hours} 时 ${minutes} 分`;
|
||||
}
|
||||
}
|
||||
|
||||
return achievementsWithTime;
|
||||
};
|
||||
|
||||
31
packages/temp_frontend/app/routes/util/time-calculator.tsx
Normal file
31
packages/temp_frontend/app/routes/util/time-calculator.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { Layout } from "@/components/Layout";
|
||||
import type { Route } from "./+types/time-calculator";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { formatDateTime } from "@/components/SearchResults";
|
||||
|
||||
export default function Home() {
|
||||
const now = new Date();
|
||||
const [time1Input, setTime1Input] = useState(formatDateTime(now));
|
||||
const [time2Input, setTime2Input] = useState(formatDateTime(now));
|
||||
|
||||
const time1 = new Date(time1Input);
|
||||
const time2 = new Date(time2Input);
|
||||
const difference = time2.getTime() - time1.getTime();
|
||||
const days = Math.floor(difference / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor(difference / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((difference / (1000 * 60)) % 60);
|
||||
const diffString = `${days || 0} 天 ${hours || 0} 时 ${minutes || 0} 分`;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<h1 className="my-5 text-2xl">时间计算器</h1>
|
||||
<p>在下方输入两个时间点,即可得到两个时间点之间的时间差</p>
|
||||
<div className="flex gap-5 mt-3">
|
||||
<Input className="text-center w-50" value={time1Input} onChange={(e) => setTime1Input(e.target.value)} />
|
||||
<Input className="text-center w-50" value={time2Input} onChange={(e) => setTime2Input(e.target.value)} />
|
||||
</div>
|
||||
<p className="mt-3">{diffString}</p>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@ -16,6 +16,7 @@
|
||||
"@nivo/line": "^0.99.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@react-router/node": "^7.7.1",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user