From 50d6e0f498bbdbbcb7d213c195b640e91c7db030 Mon Sep 17 00:00:00 2001 From: alikia2x Date: Mon, 10 Nov 2025 07:30:21 +0800 Subject: [PATCH] improve: frontend & backend --- bun.lock | 29 +++ packages/core/lib/milestone.ts | 5 +- packages/crawler/mq/exec/snapshotTick.ts | 10 +- packages/elysia/routes/song/add.ts | 32 +++ packages/elysia/routes/video/eta.ts | 55 +++++ packages/elysia/src/index.ts | 2 + .../temp_frontend/app/components/Layout.tsx | 4 +- .../app/components/ui/select.tsx | 185 +++++++++++++++ packages/temp_frontend/app/routes.ts | 2 +- packages/temp_frontend/app/routes/home.tsx | 22 +- .../app/routes/song/[id]/info/index.tsx | 221 ++++++++++++++++-- .../app/routes/song/[id]/info/lib.ts | 153 ++++++++++-- .../app/routes/util/time-calculator.tsx | 31 +++ packages/temp_frontend/package.json | 1 + 14 files changed, 706 insertions(+), 46 deletions(-) create mode 100644 packages/elysia/routes/video/eta.ts create mode 100644 packages/temp_frontend/app/components/ui/select.tsx create mode 100644 packages/temp_frontend/app/routes/util/time-calculator.tsx diff --git a/bun.lock b/bun.lock index 821bccc..64ee960 100644 --- a/bun.lock +++ b/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=="], diff --git a/packages/core/lib/milestone.ts b/packages/core/lib/milestone.ts index e607e73..a89d2ad 100644 --- a/packages/core/lib/milestone.ts +++ b/packages/core/lib/milestone.ts @@ -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; }; diff --git a/packages/crawler/mq/exec/snapshotTick.ts b/packages/crawler/mq/exec/snapshotTick.ts index f813857..6f73499 100644 --- a/packages/crawler/mq/exec/snapshotTick.ts +++ b/packages/crawler/mq/exec/snapshotTick.ts @@ -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 }; diff --git a/packages/elysia/routes/song/add.ts b/packages/elysia/routes/song/add.ts index 5825cd2..fd16239 100644 --- a/packages/elysia/routes/song/add.ts +++ b/packages/elysia/routes/song/add.ts @@ -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, { diff --git a/packages/elysia/routes/video/eta.ts b/packages/elysia/routes/video/eta.ts new file mode 100644 index 0000000..4e570cf --- /dev/null +++ b/packages/elysia/routes/video/eta.ts @@ -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()) + }) + } +); diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 0af735e..1124ce7 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -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"; diff --git a/packages/temp_frontend/app/components/Layout.tsx b/packages/temp_frontend/app/components/Layout.tsx index 7fe4d52..0c09435 100644 --- a/packages/temp_frontend/app/components/Layout.tsx +++ b/packages/temp_frontend/app/components/Layout.tsx @@ -4,7 +4,7 @@ import { Search } from "./Search"; export function Layout({ children }: { children?: React.ReactNode }) { return (
-
+

中V档案馆

@@ -41,7 +41,7 @@ const LoginOrLogout = () => { export function LayoutWithoutSearch({ children }: { children?: React.ReactNode }) { return (
-
+

中V档案馆

diff --git a/packages/temp_frontend/app/components/ui/select.tsx b/packages/temp_frontend/app/components/ui/select.tsx new file mode 100644 index 0000000..40ba499 --- /dev/null +++ b/packages/temp_frontend/app/components/ui/select.tsx @@ -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) { + return +} + +function SelectGroup({ + ...props +}: React.ComponentProps) { + return +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = "popper", + align = "center", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/packages/temp_frontend/app/routes.ts b/packages/temp_frontend/app/routes.ts index 8c96834..1fc5733 100644 --- a/packages/temp_frontend/app/routes.ts +++ b/packages/temp_frontend/app/routes.ts @@ -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; diff --git a/packages/temp_frontend/app/routes/home.tsx b/packages/temp_frontend/app/routes/home.tsx index 650d436..171b588 100644 --- a/packages/temp_frontend/app/routes/home.tsx +++ b/packages/temp_frontend/app/routes/home.tsx @@ -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 ; + const [input, setInput] = useState(""); + return ( + +

小工具

+
+ + +
+ setInput(e.target.value)} /> + +
+
+ + ); } diff --git a/packages/temp_frontend/app/routes/song/[id]/info/index.tsx b/packages/temp_frontend/app/routes/song/[id]/info/index.tsx index 43fc482..ab5861f 100644 --- a/packages/temp_frontend/app/routes/song/[id]/info/index.tsx +++ b/packages/temp_frontend/app/routes/song/[id]/info/index.tsx @@ -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(import.meta.env.VITE_API_URL!); type SongInfo = Awaited["info"]["get"]>>["data"]; +type EtaInfo = Awaited["eta"]["get"]>>["data"]; export type Snapshots = Awaited["snapshots"]["get"]>>["data"]; type SongInfoError = Awaited["info"]["get"]>>["error"]; type SnapshotsError = Awaited["snapshots"]["get"]>>["error"]; +type EtaInfoError = Awaited["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 ; }; -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("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 ; } @@ -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 (

播放: {snapshots[0].views.toLocaleString()} - 更新于 {formatDateTime(new Date(snapshots[0].createdAt))} + + {" "} + 更新于 {formatDateTime(new Date(snapshots[0].createdAt))} +

+ {etaData && ( + <> + {etaData!.views <= 10000000 && ( +

+ 下一个成就:{getMileStoneName(etaData!.views)} + + {" "} + 预计 {formatHours(etaData!.eta)} 后({addHoursToNow(etaData!.eta)})达成 + +

+ )} + + {milestoneAchievements.length > 0 && ( +
+

成就达成时间:

+ {milestoneAchievements.map((achievement) => ( +

+ {achievement.milestoneName}({achievement.milestone.toLocaleString()}) -{" "} + {formatDateTime(new Date(achievement.achievedAt))} + {achievement.timeTaken && ` - 用时 ${achievement.timeTaken}`} +

+ ))} +
+ )} + + )} + -
+

数据

- - 图表 - 表格 - +
+ + 图表 + 表格 + + +
- +
+
+ + + {timeRangeHours ? `${timeRangeHours}小时范围` : "全部数据"} + + +
+ +
- + + {totalPages > 1 && ( +
+ + + 第 {currentPage} 页,共 {totalPages} 页 + + +
+ )}
); }; +const MemoizedSnapshotsView = memo(SnapshotsView); + export default function SongInfo({ loaderData }: Route.ComponentProps) { const [songInfo, setData] = useState(null); const [snapshots, setSnapshots] = useState(null); - const [error, setError] = useState(null); + const [etaData, setEtaData] = useState(null); + const [error, setError] = useState(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) {
- + ); } diff --git a/packages/temp_frontend/app/routes/song/[id]/info/lib.ts b/packages/temp_frontend/app/routes/song/[id]/info/lib.ts index 60711e3..6d041db 100644 --- a/packages/temp_frontend/app/routes/song/[id]/info/lib.ts +++ b/packages/temp_frontend/app/routes/song/[id]/info/lib.ts @@ -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; -}; \ No newline at end of file +}; + +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(); + + 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; +}; diff --git a/packages/temp_frontend/app/routes/util/time-calculator.tsx b/packages/temp_frontend/app/routes/util/time-calculator.tsx new file mode 100644 index 0000000..ba1f69f --- /dev/null +++ b/packages/temp_frontend/app/routes/util/time-calculator.tsx @@ -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 ( + +

时间计算器

+

在下方输入两个时间点,即可得到两个时间点之间的时间差

+
+ setTime1Input(e.target.value)} /> + setTime2Input(e.target.value)} /> +
+

{diffString}

+
+ ); +} diff --git a/packages/temp_frontend/package.json b/packages/temp_frontend/package.json index 733b550..ede1801 100644 --- a/packages/temp_frontend/package.json +++ b/packages/temp_frontend/package.json @@ -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",