refactor: restructure project to standard Go layout

- Reorganize code into cmd/bilidown and internal/ packages
- Rename client/ to web/ for frontend source
- Remove systray dependency for headless web service
- Embed static files into binary using go:embed
- Update import paths to use internal/ prefix
- Update .gitignore with common patterns

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-02 17:21:49 +08:00
parent 4facf82956
commit dd8eaef97d
61 changed files with 244 additions and 869 deletions
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bilidown</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="src/index.ts"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{
"name": "bilidown",
"version": "1.0.0",
"description": "",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@types/bootstrap": "^5.2.10",
"@types/uuid": "^10.0.0",
"sass-embedded": "^1.80.4",
"vite": "^5.4.8"
},
"type": "module",
"dependencies": {
"bootstrap": "^5.3.3",
"p-queue": "^8.0.1",
"plyr": "^3.7.8",
"uuid": "^10.0.0",
"vanjs-core": "^1.5.2",
"vanjs-router": "^2.1.0"
},
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0"
}
+904
View File
@@ -0,0 +1,904 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
bootstrap:
specifier: ^5.3.3
version: 5.3.3(@popperjs/core@2.11.8)
p-queue:
specifier: ^8.0.1
version: 8.0.1
plyr:
specifier: ^3.7.8
version: 3.7.8
uuid:
specifier: ^10.0.0
version: 10.0.0
vanjs-core:
specifier: ^1.5.2
version: 1.5.2
vanjs-router:
specifier: ^2.1.0
version: 2.1.0
devDependencies:
'@types/bootstrap':
specifier: ^5.2.10
version: 5.2.10
'@types/uuid':
specifier: ^10.0.0
version: 10.0.0
sass-embedded:
specifier: ^1.80.4
version: 1.80.6
vite:
specifier: ^5.4.8
version: 5.4.11(sass-embedded@1.80.6)
packages:
'@bufbuild/protobuf@2.2.2':
resolution: {integrity: sha512-UNtPCbrwrenpmrXuRwn9jYpPoweNXj8X5sMvYgsqYyaH8jQ6LfUJSk3dJLnBK+6sfYPrF4iAIo5sd5HQ+tg75A==, tarball: https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.2.2.tgz}
'@esbuild/aix-ppc64@0.21.5':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==, tarball: https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.21.5':
resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==, tarball: https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.21.5':
resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==, tarball: https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.21.5':
resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==, tarball: https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.21.5':
resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==, tarball: https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.21.5':
resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==, tarball: https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.21.5':
resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==, tarball: https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.21.5':
resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==, tarball: https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.21.5':
resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==, tarball: https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.21.5':
resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==, tarball: https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.21.5':
resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==, tarball: https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.21.5':
resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==, tarball: https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.21.5':
resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==, tarball: https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.21.5':
resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==, tarball: https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.21.5':
resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==, tarball: https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.21.5':
resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==, tarball: https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.21.5':
resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==, tarball: https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-x64@0.21.5':
resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==, tarball: https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-x64@0.21.5':
resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==, tarball: https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [x64]
os: [openbsd]
'@esbuild/sunos-x64@0.21.5':
resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==, tarball: https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.21.5':
resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==, tarball: https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.21.5':
resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==, tarball: https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.21.5':
resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==, tarball: https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz}
engines: {node: '>=12'}
cpu: [x64]
os: [win32]
'@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==, tarball: https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz}
'@rollup/rollup-android-arm-eabi@4.25.0':
resolution: {integrity: sha512-CC/ZqFZwlAIbU1wUPisHyV/XRc5RydFrNLtgl3dGYskdwPZdt4HERtKm50a/+DtTlKeCq9IXFEWR+P6blwjqBA==, tarball: https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.25.0.tgz}
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm64@4.25.0':
resolution: {integrity: sha512-/Y76tmLGUJqVBXXCfVS8Q8FJqYGhgH4wl4qTA24E9v/IJM0XvJCGQVSW1QZ4J+VURO9h8YCa28sTFacZXwK7Rg==, tarball: https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.25.0.tgz}
cpu: [arm64]
os: [android]
'@rollup/rollup-darwin-arm64@4.25.0':
resolution: {integrity: sha512-YVT6L3UrKTlC0FpCZd0MGA7NVdp7YNaEqkENbWQ7AOVOqd/7VzyHpgIpc1mIaxRAo1ZsJRH45fq8j4N63I/vvg==, tarball: https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.25.0.tgz}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.25.0':
resolution: {integrity: sha512-ZRL+gexs3+ZmmWmGKEU43Bdn67kWnMeWXLFhcVv5Un8FQcx38yulHBA7XR2+KQdYIOtD0yZDWBCudmfj6lQJoA==, tarball: https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.25.0.tgz}
cpu: [x64]
os: [darwin]
'@rollup/rollup-freebsd-arm64@4.25.0':
resolution: {integrity: sha512-xpEIXhiP27EAylEpreCozozsxWQ2TJbOLSivGfXhU4G1TBVEYtUPi2pOZBnvGXHyOdLAUUhPnJzH3ah5cqF01g==, tarball: https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.25.0.tgz}
cpu: [arm64]
os: [freebsd]
'@rollup/rollup-freebsd-x64@4.25.0':
resolution: {integrity: sha512-sC5FsmZGlJv5dOcURrsnIK7ngc3Kirnx3as2XU9uER+zjfyqIjdcMVgzy4cOawhsssqzoAX19qmxgJ8a14Qrqw==, tarball: https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.25.0.tgz}
cpu: [x64]
os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.25.0':
resolution: {integrity: sha512-uD/dbLSs1BEPzg564TpRAQ/YvTnCds2XxyOndAO8nJhaQcqQGFgv/DAVko/ZHap3boCvxnzYMa3mTkV/B/3SWA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.25.0.tgz}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm-musleabihf@4.25.0':
resolution: {integrity: sha512-ZVt/XkrDlQWegDWrwyC3l0OfAF7yeJUF4fq5RMS07YM72BlSfn2fQQ6lPyBNjt+YbczMguPiJoCfaQC2dnflpQ==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.25.0.tgz}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm64-gnu@4.25.0':
resolution: {integrity: sha512-qboZ+T0gHAW2kkSDPHxu7quaFaaBlynODXpBVnPxUgvWYaE84xgCKAPEYE+fSMd3Zv5PyFZR+L0tCdYCMAtG0A==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.25.0.tgz}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-arm64-musl@4.25.0':
resolution: {integrity: sha512-ndWTSEmAaKr88dBuogGH2NZaxe7u2rDoArsejNslugHZ+r44NfWiwjzizVS1nUOHo+n1Z6qV3X60rqE/HlISgw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.25.0.tgz}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-powerpc64le-gnu@4.25.0':
resolution: {integrity: sha512-BVSQvVa2v5hKwJSy6X7W1fjDex6yZnNKy3Kx1JGimccHft6HV0THTwNtC2zawtNXKUu+S5CjXslilYdKBAadzA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.25.0.tgz}
cpu: [ppc64]
os: [linux]
'@rollup/rollup-linux-riscv64-gnu@4.25.0':
resolution: {integrity: sha512-G4hTREQrIdeV0PE2JruzI+vXdRnaK1pg64hemHq2v5fhv8C7WjVaeXc9P5i4Q5UC06d/L+zA0mszYIKl+wY8oA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.25.0.tgz}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-s390x-gnu@4.25.0':
resolution: {integrity: sha512-9T/w0kQ+upxdkFL9zPVB6zy9vWW1deA3g8IauJxojN4bnz5FwSsUAD034KpXIVX5j5p/rn6XqumBMxfRkcHapQ==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.25.0.tgz}
cpu: [s390x]
os: [linux]
'@rollup/rollup-linux-x64-gnu@4.25.0':
resolution: {integrity: sha512-ThcnU0EcMDn+J4B9LD++OgBYxZusuA7iemIIiz5yzEcFg04VZFzdFjuwPdlURmYPZw+fgVrFzj4CA64jSTG4Ig==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.25.0.tgz}
cpu: [x64]
os: [linux]
'@rollup/rollup-linux-x64-musl@4.25.0':
resolution: {integrity: sha512-zx71aY2oQxGxAT1JShfhNG79PnjYhMC6voAjzpu/xmMjDnKNf6Nl/xv7YaB/9SIa9jDYf8RBPWEnjcdlhlv1rQ==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.25.0.tgz}
cpu: [x64]
os: [linux]
'@rollup/rollup-win32-arm64-msvc@4.25.0':
resolution: {integrity: sha512-JT8tcjNocMs4CylWY/CxVLnv8e1lE7ff1fi6kbGocWwxDq9pj30IJ28Peb+Y8yiPNSF28oad42ApJB8oUkwGww==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.25.0.tgz}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.25.0':
resolution: {integrity: sha512-dRLjLsO3dNOfSN6tjyVlG+Msm4IiZnGkuZ7G5NmpzwF9oOc582FZG05+UdfTbz5Jd4buK/wMb6UeHFhG18+OEg==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.25.0.tgz}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.25.0':
resolution: {integrity: sha512-/RqrIFtLB926frMhZD0a5oDa4eFIbyNEwLLloMTEjmqfwZWXywwVVOVmwTsuyhC9HKkVEZcOOi+KV4U9wmOdlg==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.25.0.tgz}
cpu: [x64]
os: [win32]
'@types/bootstrap@5.2.10':
resolution: {integrity: sha512-F2X+cd6551tep0MvVZ6nM8v7XgGN/twpdNDjqS1TUM7YFNEtQYWk+dKAnH+T1gr6QgCoGMPl487xw/9hXooa2g==, tarball: https://registry.npmjs.org/@types/bootstrap/-/bootstrap-5.2.10.tgz}
'@types/estree@1.0.6':
resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==, tarball: https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz}
'@types/uuid@10.0.0':
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==, tarball: https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz}
bootstrap@5.3.3:
resolution: {integrity: sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==, tarball: https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz}
peerDependencies:
'@popperjs/core': ^2.11.8
buffer-builder@0.2.0:
resolution: {integrity: sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==, tarball: https://registry.npmjs.org/buffer-builder/-/buffer-builder-0.2.0.tgz}
colorjs.io@0.5.2:
resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==, tarball: https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz}
core-js@3.39.0:
resolution: {integrity: sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==, tarball: https://registry.npmjs.org/core-js/-/core-js-3.39.0.tgz}
custom-event-polyfill@1.0.7:
resolution: {integrity: sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==, tarball: https://registry.npmjs.org/custom-event-polyfill/-/custom-event-polyfill-1.0.7.tgz}
esbuild@0.21.5:
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==, tarball: https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz}
engines: {node: '>=12'}
hasBin: true
eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==, tarball: https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==, tarball: https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==, tarball: https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz}
engines: {node: '>=8'}
immutable@4.3.7:
resolution: {integrity: sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==, tarball: https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz}
loadjs@4.3.0:
resolution: {integrity: sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==, tarball: https://registry.npmjs.org/loadjs/-/loadjs-4.3.0.tgz}
nanoid@3.3.7:
resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==, tarball: https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
p-queue@8.0.1:
resolution: {integrity: sha512-NXzu9aQJTAzbBqOt2hwsR63ea7yvxJc0PwN/zobNAudYfb1B7R08SzB4TsLeSbUCuG467NhnoT0oO6w1qRO+BA==, tarball: https://registry.npmjs.org/p-queue/-/p-queue-8.0.1.tgz}
engines: {node: '>=18'}
p-timeout@6.1.3:
resolution: {integrity: sha512-UJUyfKbwvr/uZSV6btANfb+0t/mOhKV/KXcCUTp8FcQI+v/0d+wXqH4htrW0E4rR6WiEO/EPvUFiV9D5OI4vlw==, tarball: https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.3.tgz}
engines: {node: '>=14.16'}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==, tarball: https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz}
plyr@3.7.8:
resolution: {integrity: sha512-yG/EHDobwbB/uP+4Bm6eUpJ93f8xxHjjk2dYcD1Oqpe1EcuQl5tzzw9Oq+uVAzd2lkM11qZfydSiyIpiB8pgdA==, tarball: https://registry.npmjs.org/plyr/-/plyr-3.7.8.tgz}
postcss@8.4.49:
resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==, tarball: https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz}
engines: {node: ^10 || ^12 || >=14}
rangetouch@2.0.1:
resolution: {integrity: sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==, tarball: https://registry.npmjs.org/rangetouch/-/rangetouch-2.0.1.tgz}
rollup@4.25.0:
resolution: {integrity: sha512-uVbClXmR6wvx5R1M3Od4utyLUxrmOcEm3pAtMphn73Apq19PDtHpgZoEvqH2YnnaNUuvKmg2DgRd2Sqv+odyqg==, tarball: https://registry.npmjs.org/rollup/-/rollup-4.25.0.tgz}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
rxjs@7.8.1:
resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==, tarball: https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz}
sass-embedded-android-arm64@1.80.6:
resolution: {integrity: sha512-4rC4ZGM/k4ENVjLXnK3JTst8e8FI9MHSol2Fl7dCdYyJ3KLnlt4qL4AEYfU8zq1tcBb7CBOSZVR+CzCKubnXdg==, tarball: https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [android]
sass-embedded-android-arm@1.80.6:
resolution: {integrity: sha512-UeUKMTRsnz4/dh7IzvhjONxa4/jmVp539CHDd8VZOsqg9M3HcNJNIkUzQWbuwZ+nSlWrTuo7Tvn3XlypopCBzw==, tarball: https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [arm]
os: [android]
sass-embedded-android-ia32@1.80.6:
resolution: {integrity: sha512-Lxz2SXE2KdHnynuHF+D6flDvrd55/zaEAWUeka9MxEr6FmR66d8UBOIy5ETwCSUd//S/SE5Jl6oTnHppgD1zNA==, tarball: https://registry.npmjs.org/sass-embedded-android-ia32/-/sass-embedded-android-ia32-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [ia32]
os: [android]
sass-embedded-android-riscv64@1.80.6:
resolution: {integrity: sha512-hKdxY/oOqB+JJhSoBTDM5DJO1j/xtxQgayh2cLCCUx37IQQe3SEdc3V2JFf/4mIo5peaS4cjqwwSATF+l2zaXg==, tarball: https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [riscv64]
os: [android]
sass-embedded-android-x64@1.80.6:
resolution: {integrity: sha512-Eap2Fi3kTx/rVLBsOnOp5RYPr5+lFjTZ652zR24dmYFe9/sDgasakJIOPjOvD2bRuL9z0uWEY1AXVeeOPeZKrg==, tarball: https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [android]
sass-embedded-darwin-arm64@1.80.6:
resolution: {integrity: sha512-0mnAx8Vq6Gxj3PQt3imgITfK33hhqrSKpyHSuab71gZZni5opsdtoggq2JawW+1taRFTEZwbZJLKZ0MBDbwCCA==, tarball: https://registry.npmjs.org/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [darwin]
sass-embedded-darwin-x64@1.80.6:
resolution: {integrity: sha512-Ib20yNZFOrJ7YVT+ltoe+JQNKPcRclM3iLAK69XZZYcSeFM/72SCoQBAaVGIpT23dxDp7FXiE4lO602c3xTRwQ==, tarball: https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [darwin]
sass-embedded-linux-arm64@1.80.6:
resolution: {integrity: sha512-n5r98pBXawrQQKaxIYCMM1zDpnngsqxTkOrmvsYLFiAMCSbR0lWf/7sBB33k/Pm0D6dsbp3jpHilCoQNKI3jIw==, tarball: https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [linux]
sass-embedded-linux-arm@1.80.6:
resolution: {integrity: sha512-QR0Q6TZox/ThuU2r9c0s3fKCgU2rXAEocpitdgxFp6tta+GsQlMFV3oON2unAa8Bwnuxkmf0YOaK0Oy/TwzkXw==, tarball: https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [arm]
os: [linux]
sass-embedded-linux-ia32@1.80.6:
resolution: {integrity: sha512-O6dWZdcOkryRdDCxVMGOeVowgblpDgVcAuRtZ1F1X7XfbpDriTQm64D+9vVZIrywYSPoJfQMJJ662cr0wUs9IQ==, tarball: https://registry.npmjs.org/sass-embedded-linux-ia32/-/sass-embedded-linux-ia32-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [ia32]
os: [linux]
sass-embedded-linux-musl-arm64@1.80.6:
resolution: {integrity: sha512-VeUSHUi3MAsvOlg9QI4X/2j04h1659aE+7qKP/282CYBTrGkjFGSXZhIki9WKWDgIpDiSInRYXfQQRWhPhjCDg==, tarball: https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [linux]
sass-embedded-linux-musl-arm@1.80.6:
resolution: {integrity: sha512-X9FC8s8fvQGRiXc+eATlZ57N44Iq3nNa0M0ugi3ysdJwkaNYvOeS4QzBHKQAaw3QiTqdxTnLUHHVBkyzdCi9pw==, tarball: https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [arm]
os: [linux]
sass-embedded-linux-musl-ia32@1.80.6:
resolution: {integrity: sha512-GqitS2Nab8ah0+wfCqaxW1hnI1piC08FimL6+lM9YWK5DbCOOF82IapbvJOy0feUmd/wNnHmyNTgE9h0zVMFdQ==, tarball: https://registry.npmjs.org/sass-embedded-linux-musl-ia32/-/sass-embedded-linux-musl-ia32-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [ia32]
os: [linux]
sass-embedded-linux-musl-riscv64@1.80.6:
resolution: {integrity: sha512-ySs15z7QSRRQK/aByEEqaJLYW/sTpfynefNPZCtsVNVEzNRwy+DRpxNChtxo+QjKq97ocXETbdG5KLik7QOTJg==, tarball: https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [riscv64]
os: [linux]
sass-embedded-linux-musl-x64@1.80.6:
resolution: {integrity: sha512-DzeNqU/SN0mWFznoOH4RtVGcrg3Eoa41pUQhKMtrhNbCmIE1zNDunUiAEVTNpdHJF4nxf7ELUPXWmStM31CbUQ==, tarball: https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [linux]
sass-embedded-linux-riscv64@1.80.6:
resolution: {integrity: sha512-AyoHJ3icV9xuJjq1YzJqpEj2XfiC/KBkVYTUrCELKiXP0DN1gi/BpUwZNCAgCM3CyEdMef4LQM/ztCYJxYzdyg==, tarball: https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [riscv64]
os: [linux]
sass-embedded-linux-x64@1.80.6:
resolution: {integrity: sha512-EohsE9CEqx0ycylnsEj/0DNPG99Tb0qAVZspiAs5xHFCJjXOFfp3cRQu0BRf+lZ1b72IhPFXymzVtojvzUHb7g==, tarball: https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [linux]
sass-embedded-win32-arm64@1.80.6:
resolution: {integrity: sha512-29wETQi1ykeVvpd4zMVokpQKFSOZskGJzZawuuNCdo7BHjHKIRDsqbz8YT1CewHPBshI0hfD21fenmjxYjGXPQ==, tarball: https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [win32]
sass-embedded-win32-ia32@1.80.6:
resolution: {integrity: sha512-1s3OpK2iTIfIL/a91QhAQnffsbuWfnsM8Lx4Fxt0f7ErnxjCV6q8MUFTV/UhcLtLyTFnPCA62DLjp2KGCjMI9A==, tarball: https://registry.npmjs.org/sass-embedded-win32-ia32/-/sass-embedded-win32-ia32-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [ia32]
os: [win32]
sass-embedded-win32-x64@1.80.6:
resolution: {integrity: sha512-0pH4Zr9silHkcmLPC0ghnD3DI0vMsjA7dKvGR32/RbbjOSvHV5cDQRLiuVJAPp34dfMA7kJd1ysSchRdH0igAQ==, tarball: https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.80.6.tgz}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [win32]
sass-embedded@1.80.6:
resolution: {integrity: sha512-Og4aqBnaA3oJfIpHaLuNATAqzBRgUJDYJy2X15V59cot2wYOtiT/ciPnyuq1o7vpDEeOkHhEd+mSviSlXoETug==, tarball: https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.80.6.tgz}
engines: {node: '>=16.0.0'}
hasBin: true
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==, tarball: https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz}
engines: {node: '>=0.10.0'}
supports-color@8.1.1:
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==, tarball: https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz}
engines: {node: '>=10'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==, tarball: https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz}
url-polyfill@1.1.12:
resolution: {integrity: sha512-mYFmBHCapZjtcNHW0MDq9967t+z4Dmg5CJ0KqysK3+ZbyoNOWQHksGCTWwDhxGXllkWlOc10Xfko6v4a3ucM6A==, tarball: https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.12.tgz}
uuid@10.0.0:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==, tarball: https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz}
hasBin: true
vanjs-core@1.5.2:
resolution: {integrity: sha512-z9MHAzxYbNFJ6MYP+4j9l9KMxDU3ryI65JjIfUbGQwF1paLfiuqtIiq9Ve1UGKLd774Z7mtdWzd+/FR785dKjQ==, tarball: https://registry.npmjs.org/vanjs-core/-/vanjs-core-1.5.2.tgz}
vanjs-router@2.1.0:
resolution: {integrity: sha512-ti9qYYLsPu/GoTYOoZz94T3Abd26rdgFjoiRTI/TkbrtjYa20lAaJ+U7VIGypKB/2rnoKaFR6O9qFwLtuKBCVw==, tarball: https://registry.npmjs.org/vanjs-router/-/vanjs-router-2.1.0.tgz}
varint@6.0.0:
resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==, tarball: https://registry.npmjs.org/varint/-/varint-6.0.0.tgz}
vite@5.4.11:
resolution: {integrity: sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==, tarball: https://registry.npmjs.org/vite/-/vite-5.4.11.tgz}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
'@types/node': ^18.0.0 || >=20.0.0
less: '*'
lightningcss: ^1.21.0
sass: '*'
sass-embedded: '*'
stylus: '*'
sugarss: '*'
terser: ^5.4.0
peerDependenciesMeta:
'@types/node':
optional: true
less:
optional: true
lightningcss:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
snapshots:
'@bufbuild/protobuf@2.2.2': {}
'@esbuild/aix-ppc64@0.21.5':
optional: true
'@esbuild/android-arm64@0.21.5':
optional: true
'@esbuild/android-arm@0.21.5':
optional: true
'@esbuild/android-x64@0.21.5':
optional: true
'@esbuild/darwin-arm64@0.21.5':
optional: true
'@esbuild/darwin-x64@0.21.5':
optional: true
'@esbuild/freebsd-arm64@0.21.5':
optional: true
'@esbuild/freebsd-x64@0.21.5':
optional: true
'@esbuild/linux-arm64@0.21.5':
optional: true
'@esbuild/linux-arm@0.21.5':
optional: true
'@esbuild/linux-ia32@0.21.5':
optional: true
'@esbuild/linux-loong64@0.21.5':
optional: true
'@esbuild/linux-mips64el@0.21.5':
optional: true
'@esbuild/linux-ppc64@0.21.5':
optional: true
'@esbuild/linux-riscv64@0.21.5':
optional: true
'@esbuild/linux-s390x@0.21.5':
optional: true
'@esbuild/linux-x64@0.21.5':
optional: true
'@esbuild/netbsd-x64@0.21.5':
optional: true
'@esbuild/openbsd-x64@0.21.5':
optional: true
'@esbuild/sunos-x64@0.21.5':
optional: true
'@esbuild/win32-arm64@0.21.5':
optional: true
'@esbuild/win32-ia32@0.21.5':
optional: true
'@esbuild/win32-x64@0.21.5':
optional: true
'@popperjs/core@2.11.8': {}
'@rollup/rollup-android-arm-eabi@4.25.0':
optional: true
'@rollup/rollup-android-arm64@4.25.0':
optional: true
'@rollup/rollup-darwin-arm64@4.25.0':
optional: true
'@rollup/rollup-darwin-x64@4.25.0':
optional: true
'@rollup/rollup-freebsd-arm64@4.25.0':
optional: true
'@rollup/rollup-freebsd-x64@4.25.0':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.25.0':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.25.0':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.25.0':
optional: true
'@rollup/rollup-linux-arm64-musl@4.25.0':
optional: true
'@rollup/rollup-linux-powerpc64le-gnu@4.25.0':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.25.0':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.25.0':
optional: true
'@rollup/rollup-linux-x64-gnu@4.25.0':
optional: true
'@rollup/rollup-linux-x64-musl@4.25.0':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.25.0':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.25.0':
optional: true
'@rollup/rollup-win32-x64-msvc@4.25.0':
optional: true
'@types/bootstrap@5.2.10':
dependencies:
'@popperjs/core': 2.11.8
'@types/estree@1.0.6': {}
'@types/uuid@10.0.0': {}
bootstrap@5.3.3(@popperjs/core@2.11.8):
dependencies:
'@popperjs/core': 2.11.8
buffer-builder@0.2.0: {}
colorjs.io@0.5.2: {}
core-js@3.39.0: {}
custom-event-polyfill@1.0.7: {}
esbuild@0.21.5:
optionalDependencies:
'@esbuild/aix-ppc64': 0.21.5
'@esbuild/android-arm': 0.21.5
'@esbuild/android-arm64': 0.21.5
'@esbuild/android-x64': 0.21.5
'@esbuild/darwin-arm64': 0.21.5
'@esbuild/darwin-x64': 0.21.5
'@esbuild/freebsd-arm64': 0.21.5
'@esbuild/freebsd-x64': 0.21.5
'@esbuild/linux-arm': 0.21.5
'@esbuild/linux-arm64': 0.21.5
'@esbuild/linux-ia32': 0.21.5
'@esbuild/linux-loong64': 0.21.5
'@esbuild/linux-mips64el': 0.21.5
'@esbuild/linux-ppc64': 0.21.5
'@esbuild/linux-riscv64': 0.21.5
'@esbuild/linux-s390x': 0.21.5
'@esbuild/linux-x64': 0.21.5
'@esbuild/netbsd-x64': 0.21.5
'@esbuild/openbsd-x64': 0.21.5
'@esbuild/sunos-x64': 0.21.5
'@esbuild/win32-arm64': 0.21.5
'@esbuild/win32-ia32': 0.21.5
'@esbuild/win32-x64': 0.21.5
eventemitter3@5.0.1: {}
fsevents@2.3.3:
optional: true
has-flag@4.0.0: {}
immutable@4.3.7: {}
loadjs@4.3.0: {}
nanoid@3.3.7: {}
p-queue@8.0.1:
dependencies:
eventemitter3: 5.0.1
p-timeout: 6.1.3
p-timeout@6.1.3: {}
picocolors@1.1.1: {}
plyr@3.7.8:
dependencies:
core-js: 3.39.0
custom-event-polyfill: 1.0.7
loadjs: 4.3.0
rangetouch: 2.0.1
url-polyfill: 1.1.12
postcss@8.4.49:
dependencies:
nanoid: 3.3.7
picocolors: 1.1.1
source-map-js: 1.2.1
rangetouch@2.0.1: {}
rollup@4.25.0:
dependencies:
'@types/estree': 1.0.6
optionalDependencies:
'@rollup/rollup-android-arm-eabi': 4.25.0
'@rollup/rollup-android-arm64': 4.25.0
'@rollup/rollup-darwin-arm64': 4.25.0
'@rollup/rollup-darwin-x64': 4.25.0
'@rollup/rollup-freebsd-arm64': 4.25.0
'@rollup/rollup-freebsd-x64': 4.25.0
'@rollup/rollup-linux-arm-gnueabihf': 4.25.0
'@rollup/rollup-linux-arm-musleabihf': 4.25.0
'@rollup/rollup-linux-arm64-gnu': 4.25.0
'@rollup/rollup-linux-arm64-musl': 4.25.0
'@rollup/rollup-linux-powerpc64le-gnu': 4.25.0
'@rollup/rollup-linux-riscv64-gnu': 4.25.0
'@rollup/rollup-linux-s390x-gnu': 4.25.0
'@rollup/rollup-linux-x64-gnu': 4.25.0
'@rollup/rollup-linux-x64-musl': 4.25.0
'@rollup/rollup-win32-arm64-msvc': 4.25.0
'@rollup/rollup-win32-ia32-msvc': 4.25.0
'@rollup/rollup-win32-x64-msvc': 4.25.0
fsevents: 2.3.3
rxjs@7.8.1:
dependencies:
tslib: 2.8.1
sass-embedded-android-arm64@1.80.6:
optional: true
sass-embedded-android-arm@1.80.6:
optional: true
sass-embedded-android-ia32@1.80.6:
optional: true
sass-embedded-android-riscv64@1.80.6:
optional: true
sass-embedded-android-x64@1.80.6:
optional: true
sass-embedded-darwin-arm64@1.80.6:
optional: true
sass-embedded-darwin-x64@1.80.6:
optional: true
sass-embedded-linux-arm64@1.80.6:
optional: true
sass-embedded-linux-arm@1.80.6:
optional: true
sass-embedded-linux-ia32@1.80.6:
optional: true
sass-embedded-linux-musl-arm64@1.80.6:
optional: true
sass-embedded-linux-musl-arm@1.80.6:
optional: true
sass-embedded-linux-musl-ia32@1.80.6:
optional: true
sass-embedded-linux-musl-riscv64@1.80.6:
optional: true
sass-embedded-linux-musl-x64@1.80.6:
optional: true
sass-embedded-linux-riscv64@1.80.6:
optional: true
sass-embedded-linux-x64@1.80.6:
optional: true
sass-embedded-win32-arm64@1.80.6:
optional: true
sass-embedded-win32-ia32@1.80.6:
optional: true
sass-embedded-win32-x64@1.80.6:
optional: true
sass-embedded@1.80.6:
dependencies:
'@bufbuild/protobuf': 2.2.2
buffer-builder: 0.2.0
colorjs.io: 0.5.2
immutable: 4.3.7
rxjs: 7.8.1
supports-color: 8.1.1
varint: 6.0.0
optionalDependencies:
sass-embedded-android-arm: 1.80.6
sass-embedded-android-arm64: 1.80.6
sass-embedded-android-ia32: 1.80.6
sass-embedded-android-riscv64: 1.80.6
sass-embedded-android-x64: 1.80.6
sass-embedded-darwin-arm64: 1.80.6
sass-embedded-darwin-x64: 1.80.6
sass-embedded-linux-arm: 1.80.6
sass-embedded-linux-arm64: 1.80.6
sass-embedded-linux-ia32: 1.80.6
sass-embedded-linux-musl-arm: 1.80.6
sass-embedded-linux-musl-arm64: 1.80.6
sass-embedded-linux-musl-ia32: 1.80.6
sass-embedded-linux-musl-riscv64: 1.80.6
sass-embedded-linux-musl-x64: 1.80.6
sass-embedded-linux-riscv64: 1.80.6
sass-embedded-linux-x64: 1.80.6
sass-embedded-win32-arm64: 1.80.6
sass-embedded-win32-ia32: 1.80.6
sass-embedded-win32-x64: 1.80.6
source-map-js@1.2.1: {}
supports-color@8.1.1:
dependencies:
has-flag: 4.0.0
tslib@2.8.1: {}
url-polyfill@1.1.12: {}
uuid@10.0.0: {}
vanjs-core@1.5.2: {}
vanjs-router@2.1.0:
dependencies:
vanjs-core: 1.5.2
varint@6.0.0: {}
vite@5.4.11(sass-embedded@1.80.6):
dependencies:
esbuild: 0.21.5
postcss: 8.4.49
rollup: 4.25.0
optionalDependencies:
fsevents: 2.3.3
sass-embedded: 1.80.6
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+22
View File
@@ -0,0 +1,22 @@
import van from 'vanjs-core'
import { Route } from 'vanjs-router'
import { GLOBAL_ERROR_MESSAGE, GLOBAL_HIDE_PAGE } from '../mixin'
const { button, div } = van.tags
export default () => Route({
rule: 'error',
Loader() {
return div({ class: 'py-5 px-4 container' },
div({ class: 'py-5 px-3 border rounded-4 vstack align-items-center gap-4' },
div({ class: 'fs-2 fw-bold' }, '错误提示'),
div({ class: 'text-danger fs-4' }, () => GLOBAL_ERROR_MESSAGE.val || '请刷新页面重试'),
button({
class: 'btn btn-warning', onclick() {
location.href = location.pathname
}
}, '刷新页面')
)
)
},
})
+27
View File
@@ -0,0 +1,27 @@
import van from 'vanjs-core'
import { now } from 'vanjs-router'
import { GLOBAL_HAS_LOGIN } from './mixin'
const { a, div } = van.tags
export default () => {
const classStr = (name: string) => van.derive(() => `text-nowrap nav-link ${now.val.split('/')[0] == name ? 'active' : ''}`)
return div({ class: 'hstack gap-4' },
div({ class: 'fs-4 fw-bold text-nowrap' }, 'Bilidown'),
div({ class: 'nav nav-underline flex-nowrap overflow-auto' },
div({ class: 'nav-item', hidden: () => !GLOBAL_HAS_LOGIN.val },
a({ class: classStr('work'), href: '#/work' }, '视频解析')
),
div({ class: 'nav-item', hidden: () => !GLOBAL_HAS_LOGIN.val },
a({ class: classStr('task'), href: '#/task' }, '任务列表')
),
div({ class: 'nav-item', hidden: () => !GLOBAL_HAS_LOGIN.val },
a({ class: classStr('setting'), href: '#/setting' }, '设置中心')
),
div({ class: 'nav-item', hidden: GLOBAL_HAS_LOGIN },
a({ class: classStr('login'), href: '#/login' }, '扫码登录')
),
)
)
}
+28
View File
@@ -0,0 +1,28 @@
/// <reference types="vite/client" />
import van from 'vanjs-core'
import Header from './header'
import Work from './work'
import Task from './task'
import Login from './login'
import Setting from './setting'
import _Error from './error'
import { redirect } from 'vanjs-router'
import { GLOBAL_HIDE_PAGE } from './mixin'
import 'bootstrap/dist/css/bootstrap.min.css'
import './scss/index.scss'
import { PlayerModalComp } from './task/playerModal'
const { div } = van.tags
redirect('home', 'work')
van.add(document.body,
div({ class: 'container py-4 vstack gap-4', hidden: GLOBAL_HIDE_PAGE },
Header(),
Work(),
Task(),
Login(),
Setting(),
),
_Error()
)
+13
View File
@@ -0,0 +1,13 @@
import { ResJSON } from '../mixin'
/** 获取新的二维码信息,包含二维码 Base64 数据和二维码 Key */
export const getQRInfo = async () => {
const res = await fetch('/api/getQRInfo').then(res => res.json()) as ResJSON<{ image: string, key: string }>
if (!res.success) throw new Error(res.message)
return res.data
}
/** 根据二维码 Key 获取二维码状态 */
export const getQRStatus = async (key: string) => {
return await fetch('/api/getQRStatus?key=' + key).then(res => res.json()) as ResJSON
}
+111
View File
@@ -0,0 +1,111 @@
import van from 'vanjs-core'
import { Route, goto, nowHash } from 'vanjs-router'
import { checkLogin, GLOBAL_HAS_LOGIN } from '../mixin'
import { getQRInfo, getQRStatus } from './data'
const { div, img } = van.tags
export default () => {
const qrSrc = van.state('')
const errorMessage = van.state('')
const qrStatusMessage = van.state('')
return Route({
rule: 'login',
Loader() {
return div(
div({ class: 'card card-body rounded-4' },
div({ class: 'row' },
div({ class: 'col-xl-3 col-lg-4 col-md-5 col-sm-6' },
div({ class: 'ratio ratio-1x1' },
img({
src: qrSrc,
class: 'w-100',
hidden: true,
onload(this: HTMLImageElement) {
this.hidden = false
},
ondragstart: event => event.preventDefault(),
})
)
),
div({ class: 'col-xl-9 col-lg-8 col-md-7 col-sm-6' },
div({ class: 'vstack gap-3 h-100 px-3 justify-content-center align-items-center align-items-sm-start pb-4 pb-sm-0' },
div({ class: 'fs-1' }, '扫码登录'),
div({ class: 'fs-4' }, '使用哔哩哔哩 APP 扫码登录'),
div({ class: 'text-danger fw-bold', hidden: () => !errorMessage.val }, errorMessage),
div({ class: 'text-primary fw-bold', hidden: errorMessage },
() => qrStatusMessage.val.replace('未扫码', '').replace('二维码已扫码未确认', '已扫码,请点击确认')
)
)
)
)
)
)
},
async onFirst() {
if (await checkLogin()) return goto('work')
},
async onLoad() {
// 检查登录标识,如果已经登录过了,则重定向到工作页
if (GLOBAL_HAS_LOGIN.val) return goto('work')
// 当前活动的二维码标识
let qrKey = ''
/** 刷新二维码 */
const refreshQR = async () => {
try {
const qrInfo = await getQRInfo()
// 更新二维码组件内容
qrSrc.val = qrInfo.image
// 更新当前活动的二维码标识
qrKey = qrInfo.key
} catch (error) {
// 加载二维码时失败
errorMessage.val = `加载二维码失败,请刷新页面重试`
clearTimeout(timer)
clearTimeout(statusTimer)
}
}
/** 检查二维码状态 */
const checkQRStatus = async () => {
try {
const status = await getQRStatus(qrKey)
if (status.success) {
clearTimeout(statusTimer)
GLOBAL_HAS_LOGIN.val = true
const url = new URL(window.location.href)
url.hash = ''
window.location.href = url.toString()
} else {
qrStatusMessage.val = status.message
}
} catch (error) {
// 出错了,显示错误信息
errorMessage.val = `获取二维码状态失败,请刷新页面重试`
clearTimeout(statusTimer)
clearTimeout(timer)
}
}
// 载入初始二维码
refreshQR()
/** 用于定时刷新二维码的定时器 */
const timer = setInterval(async () => {
if (nowHash().split('/')[0] != 'login')
return clearInterval(timer)
refreshQR()
}, 120000)
/** 用于轮询检查二维码状态的定时器 */
const statusTimer = setInterval(async () => {
if (nowHash().split('/')[0] != 'login')
return clearInterval(statusTimer)
checkQRStatus()
}, 1000)
},
})
}
+69
View File
@@ -0,0 +1,69 @@
import van from 'vanjs-core'
import { goto } from 'vanjs-router'
export type ResJSON<Data = null> = {
success: boolean
data: Data
message: string
}
/** 创建请求超时控制器 */
export const timeoutController = (ms = 15000): {
signal: AbortSignal
timer: number
} => {
const controller = new AbortController()
const timer = setTimeout(() => {
controller.abort(new Error('请求超时'))
}, ms)
return { signal: controller.signal, timer }
}
/** 全局登录状态 */
export const GLOBAL_HAS_LOGIN = van.state(false)
/** 设置页面整体隐藏,该状态值用于设置根 DOM 的 `hidden` 属性 */
export const GLOBAL_HIDE_PAGE = van.state(true)
/** 全局错误信息,用于在统一的错误提示页面中展示 */
export const GLOBAL_ERROR_MESSAGE = van.state('')
/** 跳转到统一的错误提示页面 */
export const showErrorPage = (message: string) => {
GLOBAL_HIDE_PAGE.val = true
GLOBAL_ERROR_MESSAGE.val = message
goto('error')
}
/** 检查后端是否登录,如果未登录,则跳转到登录页
*
* 登录成功或失败,都将更新 `GLOBAL_HAS_LOGIN` 的值,并返回 `boolean`,并将 `GLOBAL_HIDE_PAGE` 设置为 `false`
*
* 用法注意:每个路由 `onFirst` 和 `onLoad` 只能其中一个使用该函数,否则会导致执行两次请求
* 一般在 `onFirst` 中执行本方法,在 `onLoad` 中执行 `if (!GLOBAL_HAS_LOGIN.val) return goto('login')`
*/
export const checkLogin = async (): Promise<boolean> => {
if (GLOBAL_HAS_LOGIN.val) return GLOBAL_HIDE_PAGE.val = false, true
const res = await fetch('/api/checkLogin').then(res => res.json()) as ResJSON
GLOBAL_HAS_LOGIN.val = res.success
GLOBAL_HIDE_PAGE.val = false
if (!res.success) return goto('login'), false
return true
}
export interface VanComponent {
element: HTMLElement
}
export const formatSeconds = (seconds: number) => {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
let str = ''
if (hours > 0) str += `${hours}`
if (minutes > 0) str += `${minutes}`
if (secs > 0) str += `${secs}`
return str
}
+51
View File
@@ -0,0 +1,51 @@
@media (max-width: 768px) {
.position-relative-sm-down {
position: relative !important;
}
}
.max-height-description {
max-height: 300px;
}
.video-item-btn {
cursor: pointer;
transition: all 0.2s;
border-color: transparent;
outline: none;
.text-muted {
transition: all 0.2s;
}
&:hover:not(.active),
&:focus-visible:not(.active) {
--bs-bg-opacity: 0.2;
border-width: 1px !important;
border-style: solid !important;
border-color: var(--bs-success) !important;
}
&.active {
--bs-bg-opacity: 0.8;
color: white !important;
.text-muted {
color: white !important;
}
}
}
.hover-btn svg {
cursor: pointer;
&:hover {
fill: red;
}
}
* {
word-wrap: break-word;
word-break: break-all;
}
+20
View File
@@ -0,0 +1,20 @@
import { Fields } from '.'
import { ResJSON } from '../mixin'
export const getFields = async (): Promise<Fields> => {
const res = await fetch('/api/getFields').then(res => res.json()) as ResJSON<Fields>
if (!res.success) throw new Error(res.message)
return res.data
}
export const saveFields = async (fields: [string, string][]) => {
const res = await fetch('/api/saveFields', {
method: 'POST',
body: JSON.stringify(fields),
headers: {
'Content-Type': 'application/json'
}
}).then(res => res.json()) as ResJSON
if (!res.success) throw new Error(res.message)
return res.message
}
+79
View File
@@ -0,0 +1,79 @@
import van from 'vanjs-core'
import { Route, goto } from 'vanjs-router'
import { checkLogin, GLOBAL_HAS_LOGIN, VanComponent } from '../mixin'
import { SaveFolderSetting } from './view'
import { getFields } from './data'
import { LoadingBox } from '../view'
const { button, div } = van.tags
export type Fields = Record<keyof SettingRoute['fields'], string>
export class SettingRoute implements VanComponent {
element: HTMLElement
loading = van.state(true)
fields = {
download_folder: van.state('')
}
constructor() {
this.element = this.Root()
}
Root() {
const _that = this
return Route({
rule: 'setting',
Loader() {
return div(
() => _that.loading.val ? LoadingBox() : '',
() => _that.loading.val ? '' : div({ class: 'vstack gap-4' },
SaveFolderSetting(_that),
div({ class: 'hstack gap-3' },
button({
class: 'btn btn-outline-secondary', onclick() {
if (!confirm('确定要关闭软件吗?')) return
fetch('/api/quit').then(res => res.json()).then(res => {
if (!res.success) alert(res.message)
else document.write(`<h2 style="text-align: center; padding: 30px 20px;">软件已关闭</h2>`)
})
}
}, '关闭软件'),
button({
class: 'btn btn-outline-danger', onclick() {
if (!confirm('确定要退出登录吗?')) return
fetch('/api/logout').then(res => res.json()).then(res => {
if (!res.success) alert(res.message)
else location.reload()
})
}
}, '退出登录'),
)
)
)
},
async onFirst() {
if (!await checkLogin()) return
_that.loading.val = true
getFields().then(fields => {
for (const key in fields) {
_that.fields[key as keyof Fields].val = fields[key as keyof Fields]
}
setTimeout(() => {
_that.loading.val = false
}, 200)
})
},
onLoad() {
if (!GLOBAL_HAS_LOGIN.val) return goto('login')
},
})
}
}
export default () => new SettingRoute().element
+34
View File
@@ -0,0 +1,34 @@
import van from 'vanjs-core'
import { SettingRoute } from '.'
import { saveFields } from './data'
const { a, button, div, input } = van.tags
export const SaveFolderSetting = (route: SettingRoute) => {
const saveFolder = route.fields.download_folder
const folderPickerDisabled = van.state(false)
const buttonText = '保存'
return div({ class: 'input-group' },
div({ class: 'input-group-text' }, '下载目录'),
input({
class: 'form-control',
value: saveFolder,
oninput: event => saveFolder.val = event.target.value,
}),
button({
class: 'btn btn-success', onclick() {
folderPickerDisabled.val = true
saveFields([
['download_folder', saveFolder.val]
]).then(message => {
alert(message)
}).catch(error => {
if (error instanceof Error) alert(error.message)
}).finally(() => {
folderPickerDisabled.val = false
})
}, disabled: folderPickerDisabled
}, buttonText)
)
}
+74
View File
@@ -0,0 +1,74 @@
import { ResJSON } from "../mixin"
import { TaskInDB, TaskStatus, VideoFormat } from "../work/type"
let getActiveTaskController: AbortController | undefined
export const getActiveTask = async (): Promise<ActiveTask[] | null> => {
getActiveTaskController?.abort()
getActiveTaskController = new AbortController()
try {
const res = await fetch('/api/getActiveTask', {
signal: getActiveTaskController.signal
}).then(res => res.json()) as ResJSON<ActiveTask[]>
if (!res.success) throw new Error(res.message)
return res.data
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') return null
throw error
}
}
let getTaskListController: AbortController | undefined
export const getTaskList = async (page: number, pageSize: number): Promise<TaskInDB[] | null> => {
getTaskListController?.abort()
getTaskListController = new AbortController()
try {
const res = await fetch(`/api/getTaskList?page=${page}&pageSize=${pageSize}`, {
signal: getTaskListController.signal
}).then(res => res.json()) as ResJSON<TaskInDB[]>
if (!res.success) throw new Error(res.message)
return res.data
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') return null
throw error
}
}
export const showFile = async (path: string) => {
const res = await fetch(`/api/showFile?filePath=${encodeURIComponent(path)}`).then(res => res.json()) as ResJSON
if (!res.success) throw new Error(res.message)
}
/** 用于刷新任务实时进度 */
type ActiveTask = {
bvid: string
cid: number
/** 分辨率代码 */
format: VideoFormat
/** 视频标题 */
title: string
/** 视频发布者 */
owner: string
/** 视频封面 */
cover: string
/** 任务进度 */
status: TaskStatus
/** 文件保存到的目录 */
folder: string
/** 任务 ID */
id: number
/** 音频文件下载进度 */
audioProgress: number
/** 视频文件下载进度 */
videoProgress: number
/** 音视频合并进度 */
mergeProgress: number
/** 视频时长,秒 */
duration: number
}
export const deleteTask = async (id: number) => {
const res = await fetch(`/api/deleteTask?id=${id}`).then(res => res.json()) as ResJSON
if (!res.success) throw new Error(res.message)
}
+236
View File
@@ -0,0 +1,236 @@
import van, { State } from 'vanjs-core'
import { Route, goto, now } from 'vanjs-router'
import { checkLogin, GLOBAL_HAS_LOGIN, GLOBAL_HIDE_PAGE, ResJSON, VanComponent } from '../mixin'
import { deleteTask, getActiveTask, getTaskList, showFile } from './data'
import { TaskInDB, TaskStatus } from '../work/type'
import { LoadingBox } from '../view'
import { PlayerModalComp } from './playerModal'
const { div, span } = van.tags
const { svg, path } = van.tags('http://www.w3.org/2000/svg')
export class TaskRoute implements VanComponent {
element: HTMLElement
/** 包含视频播放器的模态框 */
playerModalComp = new PlayerModalComp()
loading = van.state(false)
taskList: State<(TaskInDB & {
/** 音频下载进度百分比 */
audioProgress: State<number>
/** 视频下载进度百分比 */
videoProgress: State<number>
/** 合并进度百分比 */
mergeProgress: State<number>
/** 任务状态 */
statusState: State<TaskStatus>
/** 是否正在打开 */
opening: State<boolean>
/** 是否正在删除 */
deleting: State<boolean>
})[]> = van.state([])
constructor() {
this.element = this.Root()
}
Root() {
const _that = this
return Route({
rule: 'task',
Loader() {
return div(
() => _that.loading.val ? LoadingBox() : '',
() => div({ class: 'list-group', hidden: _that.loading.val },
_that.taskList.val.map(task => {
const ext = task.downloadType === 'audio' ? '.m4a' : '.mp4'
const filename = `${task.title} ${btoa(task.id.toString()).replace(/=/g, '')}${ext}`
return div({
class: () => `list-group-item p-0 hstack user-select-none ${task.statusState.val != 'done' && task.statusState.val != 'error' || task.opening.val ? 'disabled' : ''}`,
hidden: task.deleting,
},
div({
class: 'vstack gap-2 py-2 px-3',
style: `cursor: pointer;`,
onclick() {
const src = `/api/downloadVideo?path=${encodeURIComponent(
`${task.folder}\\${filename}`
)}`
if (task.statusState.val != 'done') return
_that.playerModalComp.open(src, task.title, task.downloadType === 'audio' ? 'audio' : 'video')
}
},
div({
class: () => `
${task.statusState.val == 'error' ? 'text-danger' : ''}
${task.statusState.val == 'waiting' || task.statusState.val == 'running'
? 'text-primary' : ''}`
},
() => {
if (task.opening.val) return '正在打开文件位置...'
return div(
span({
class: `me-2 badge ${task.downloadType === 'audio' ? 'bg-success' : 'bg-primary'}`,
title: task.downloadType === 'audio' ? '音频' : '视频'
}, task.downloadType === 'audio' ? 'A' : 'V'),
span({}, filename),
)
}),
div({ class: 'text-secondary small' },
() => {
if (task.statusState.val == 'waiting') return '等待下载'
if (task.statusState.val == 'error') return '下载失败'
if (task.statusState.val == 'done') return task.folder
if (task.videoProgress.val == 0) {
return `正在下载音频 (${(task.audioProgress.val * 100).toFixed(2)}%)`
} else if (task.mergeProgress.val == 0) {
return `正在下载视频 (${(task.videoProgress.val * 100).toFixed(2)}%)`
} else if (task.statusState.val == 'running') {
return `正在合并音视频 (${(task.mergeProgress.val * 100).toFixed(2)}%)`
} else {
return task.folder
}
}
),
div({
class: `progress`,
style: `height: 5px`,
hidden: () => task.statusState.val == 'done' || task.statusState.val == 'error'
},
div({
class: () => `progress-bar progress-bar-striped progress-bar-animated bg-${(() => {
if (task.videoProgress.val == 0) return 'primary'
if (task.mergeProgress.val == 0) return 'success'
else return 'info'
})()}`,
style: () => {
let width = 0
if (task.videoProgress.val == 0) width = task.audioProgress.val * 100
else if (task.mergeProgress.val == 0) width = task.videoProgress.val * 100
else width = task.mergeProgress.val * 100
return `width: ${width}%`
}
}),
)
),
div({
class: 'me-4',
hidden: task.statusState.val != 'done'
|| task.opening.val // 正在打开文件位置时,不应该显示删除按钮
|| task.deleting.val // 正在删除时,不应该显示删除按钮
},
div({
class: 'hover-btn', title: '打开文件位置',
onclick() {
showFile(`${task.folder}\\${filename}`)
task.opening.val = true
setTimeout(() => {
task.opening.val = false
}, 3000)
}
},
_that.FolderSVG()
)
),
div({
class: 'me-4',
hidden: task.statusState.val != 'done'
&& task.statusState.val != 'error'
|| task.opening.val // 正在打开文件位置时,不应该显示删除按钮
|| task.deleting.val // 正在删除时,不应该显示删除按钮
},
div({
class: 'hover-btn', title: '删除视频',
onclick() {
task.deleting.val = true
deleteTask(task.id).then(() => {
_that.taskList.val = _that.taskList.val.filter(taskInDB => taskInDB.id != task.id)
}).catch(error => {
alert(error.message)
})
}
},
_that.DeleteSVG()
)
),
)
})
)
)
},
async onFirst() {
if (!await checkLogin()) return
},
async onLoad() {
if (!GLOBAL_HAS_LOGIN.val) return goto('login')
_that.loading.val = true
getTaskList(0, 360).then(taskList => {
if (!taskList) return
_that.taskList.val = taskList.map(task => ({
...task,
audioProgress: van.state(1),
videoProgress: van.state(1),
mergeProgress: van.state(1),
statusState: van.state(task.status),
opening: van.state(false),
deleting: van.state(false)
}))
const refresh = async () => {
const activeTaskList = await getActiveTask()
if (!activeTaskList) return false
setTimeout(() => {
_that.loading.val = false
}, 200)
_that.taskList.val.forEach(taskInDB => {
activeTaskList.forEach(task => {
if (taskInDB.id == task.id) {
taskInDB.audioProgress.val = task.audioProgress
taskInDB.videoProgress.val = task.videoProgress
taskInDB.mergeProgress.val = task.mergeProgress
taskInDB.statusState.val = task.status
}
})
})
if (activeTaskList.filter(task => task.status == 'running').length == 0) {
clearInterval(timer)
clearInterval(helper)
}
return true
}
refresh()
let timer = setInterval(() => {
refresh()
}, 1000)
let helper = setInterval(() => {
if (now.val.split('/')[0] != 'task') {
clearInterval(helper)
clearInterval(timer)
}
})
})
},
})
}
DeleteSVG() {
return svg({ style: `width: 1em; height: 1em`, fill: "currentColor", class: "bi bi-trash3", viewBox: "0 0 16 16" },
path({ "d": "M6.5 1h3a.5.5 0 0 1 .5.5v1H6v-1a.5.5 0 0 1 .5-.5M11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3A1.5 1.5 0 0 0 5 1.5v1H1.5a.5.5 0 0 0 0 1h.538l.853 10.66A2 2 0 0 0 4.885 16h6.23a2 2 0 0 0 1.994-1.84l.853-10.66h.538a.5.5 0 0 0 0-1zm1.958 1-.846 10.58a1 1 0 0 1-.997.92h-6.23a1 1 0 0 1-.997-.92L3.042 3.5zm-7.487 1a.5.5 0 0 1 .528.47l.5 8.5a.5.5 0 0 1-.998.06L5 5.03a.5.5 0 0 1 .47-.53Zm5.058 0a.5.5 0 0 1 .47.53l-.5 8.5a.5.5 0 1 1-.998-.06l.5-8.5a.5.5 0 0 1 .528-.47M8 4.5a.5.5 0 0 1 .5.5v8.5a.5.5 0 0 1-1 0V5a.5.5 0 0 1 .5-.5" }),
)
}
FolderSVG() {
return svg({ style: `width: 1em; height: 1em`, fill: "currentColor", class: "bi bi-folder2", viewBox: "0 0 16 16" },
path({ "d": "M1 3.5A1.5 1.5 0 0 1 2.5 2h2.764c.958 0 1.76.56 2.311 1.184C7.985 3.648 8.48 4 9 4h4.5A1.5 1.5 0 0 1 15 5.5v7a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 1 12.5zM2.5 3a.5.5 0 0 0-.5.5V6h12v-.5a.5.5 0 0 0-.5-.5H9c-.964 0-1.71-.629-2.174-1.154C6.374 3.334 5.82 3 5.264 3zM14 7H2v5.5a.5.5 0 0 0 .5.5h11a.5.5 0 0 0 .5-.5z" }),
)
}
}
export default () => new TaskRoute().element
+143
View File
@@ -0,0 +1,143 @@
import { VanComponent } from '../mixin'
import van from 'vanjs-core'
import { Modal } from 'bootstrap'
import Plyr from 'plyr'
import 'plyr/dist/plyr.css'
const { a, button, div, video, audio } = van.tags
export class PlayerComp implements VanComponent {
element: HTMLElement
/** 使用该对象前,请先将元素加入文档树后调用 `initPlayer` 方法 */
player!: Plyr
videoElement: HTMLVideoElement
audioElement: HTMLAudioElement
videoPlayer!: Plyr
audioPlayer!: Plyr
type = van.state<'video' | 'audio'>('video')
src = van.state('')
filename = van.state('')
constructor() {
this.videoElement = video()
this.audioElement = audio()
this.element = this.Root()
}
Root(): HTMLElement {
const that = this
return div({ class: 'player-container' },
div({ class: 'video-wrapper', hidden: () => that.type.val !== 'video' }, this.videoElement),
div({ class: 'audio-wrapper', hidden: () => that.type.val !== 'audio' }, this.audioElement)
)
}
initPlayer() {
if (!this.element.parentNode) throw new Error('请将播放器元素加入 DOM 树')
this.videoPlayer = new Plyr(this.videoElement, {})
this.audioPlayer = new Plyr(this.audioElement, {})
// 设置当前活动的player
this.updateActivePlayer()
}
private updateActivePlayer() {
this.player = this.type.val === 'video' ? this.videoPlayer : this.audioPlayer
}
setType(type: 'video' | 'audio') {
this.type.val = type
this.updateActivePlayer()
// 清除非活动元素的src
if (type === 'video') {
this.audioElement.src = ''
} else {
this.videoElement.src = ''
}
}
setSrc(src: string) {
this.src.val = src
if (this.type.val === 'video') {
this.videoElement.src = src
this.audioElement.src = ''
} else {
this.audioElement.src = src
this.videoElement.src = ''
}
}
}
export class PlayerModalComp implements VanComponent {
element: HTMLElement
playerComp: PlayerComp
modal: Modal
type = van.state<'video' | 'audio'>('video')
title = van.state('视频播放器')
fileExtension = van.state('.mp4')
constructor() {
this.playerComp = new PlayerComp()
this.element = this.Root()
this.initModalEvent()
van.add(document.body, this.element)
this.playerComp.initPlayer()
this.modal = new Modal(this.element)
}
initModalEvent() {
this.element.addEventListener('shown.bs.modal', () => {
this.playerComp.player.play()
})
this.element.addEventListener('hide.bs.modal', () => {
if (!this.playerComp.player.stopped) {
this.playerComp.player.stop()
this.playerComp.setSrc('')
}
})
}
open(src: string, filename: string, type: 'video' | 'audio') {
this.type.val = type
this.title.val = type === 'video' ? '视频播放器' : '音频播放器'
this.fileExtension.val = type === 'video' ? '.mp4' : '.m4a'
this.playerComp.setType(type)
this.playerComp.setSrc(src)
this.playerComp.filename.val = filename
this.modal.show()
}
Root() {
const that = this
return div({ class: `modal fade`, tabIndex: -1 },
div({ class: 'modal-dialog modal-xl modal-fullscreen-xl-down' },
div({ class: `modal-content` },
div({ class: `modal-header` },
div({ class: `h5 modal-title` }, () => this.title.val),
button({ class: `btn-close`, 'data-bs-dismiss': `modal` })
),
div({ class: 'modal-body p-0' },
div({ class: () => that.type.val === 'video' ? 'ratio ratio-16x9' : '' }, this.playerComp.element),
div({ class: 'vstack p-3 gap-3' },
div({}, that.playerComp.filename)
)
),
div({ class: 'modal-footer' },
button({ class: 'btn btn-secondary', 'data-bs-dismiss': `modal` }, '关闭'),
button({
class: 'btn btn-primary', onclick() {
const link = a({
download: () => that.playerComp.filename.val + that.fileExtension.val,
href: that.playerComp.src
})
that.modal.hide()
link.click()
}
}, '下载')
)
)
)
)
}
}
+11
View File
@@ -0,0 +1,11 @@
import van from 'vanjs-core'
const { div } = van.tags
export const LoadingBox = (color = 'primary') => div({
class: 'py-4 hstack justify-content-center',
},
div({
class: `spinner-border text-${color}`,
}, div({ class: 'visually-hidden' }, 'Loading...'))
)
+88
View File
@@ -0,0 +1,88 @@
import { ResJSON, timeoutController } from '../mixin'
import { FavList, PlayInfo, SeasonInfo, TaskInitData, VideoInfo } from './type'
/**
* 获取视频信息
*
* @throws {Error}
*/
export const getVideoInfo = async (bvid: string): Promise<VideoInfo> => {
const { signal, timer } = timeoutController()
const res = await fetch(`/api/getVideoInfo?bvid=${bvid}`, {
signal
}).then(res => res.json()) as ResJSON<VideoInfo>
if (!res.success) throw new Error(res.message)
clearTimeout(timer)
return res.data
}
/**
* 获取剧集信息
* @param epid EP 号
* @param ssid SS 号
* @throws {Error}
*/
export const getSeasonInfo = async (epid: number, ssid: number): Promise<SeasonInfo> => {
const { signal, timer } = timeoutController()
const res = await fetch(`/api/getSeasonInfo?epid=${epid}&ssid=${ssid}`, {
signal
}).then(res => res.json()) as ResJSON<SeasonInfo>
if (!res.success) throw new Error(res.message)
clearTimeout(timer)
return res.data
}
export const getPlayInfo = async (bvid: string, cid: number, controller: AbortController): Promise<PlayInfo> => {
const res = await fetch(`/api/getPlayInfo?bvid=${bvid}&cid=${cid}`, {
signal: controller.signal
}).then(res => res.json()) as ResJSON<PlayInfo>
if (!res.success) throw new Error(res.message)
return res.data
}
export const createTask = async (tasks: TaskInitData[]): Promise<ResJSON> => {
const { signal, timer } = timeoutController()
const res = await fetch('/api/createTask', {
method: 'POST',
signal,
body: JSON.stringify(tasks),
headers: {
'Content-Type': 'application/json'
}
}).then(res => res.json()) as ResJSON
clearTimeout(timer)
if (!res.success) throw new Error(res.message)
return res
}
export const getPopularVideoBvIds = async (): Promise<string[]> => {
const res = await fetch('/api/getPopularVideos').then(res => res.json()) as ResJSON<string[]>
if (!res.success) throw new Error(res.message)
return res.data
}
export const getRedirectedLocation = async (url: string): Promise<string> => {
return fetch(`/api/getRedirectedLocation?url=${encodeURIComponent(url)}`)
.then(res => res.json())
.then((data: ResJSON<string>) => {
if (!data.success) throw new Error(data.message)
return data.data
})
}
/** 获取收藏夹内视频列表 */
export const getFavList = async (mediaId: number): Promise<FavList> => {
return fetch(`/api/getFavList?mediaId=${mediaId}`)
.then(res => res.json())
.then((body: ResJSON<FavList>) => {
if (!body.success) throw new Error(body.message)
return body.data
})
}
+126
View File
@@ -0,0 +1,126 @@
import van, { State } from 'vanjs-core'
import { Route, goto } from 'vanjs-router'
import VideoInfoCard from './view/videoInfoCard'
import { checkLogin, GLOBAL_HAS_LOGIN, showErrorPage } from '../mixin'
import { VideoParseResult, VideoInfoCardMode, SectionItem } from './type'
import { IDType, start } from './mixin'
import { ParseModalComp } from './view/parseModal'
import InputBox from './view/inputBox'
import { Modal } from 'bootstrap'
import { LoadingBox } from '../view'
import { getPopularVideoBvIds } from './data'
const { div } = van.tags
export class WorkRoute {
element: HTMLDivElement
/** 输入框内容是否标识为异常 */
urlInvalid = van.state(false)
/** 仅作为类名字符串 */
urlInvalidClass = van.derive(() => this.urlInvalid.val ? 'is-invalid' : '')
urlValue = van.state('')
videoInfoCardData = van.state<VideoParseResult>({
title: '', description: '', cover: '', publishData: '', duration: 0,
pages: [], owner: { face: '', mid: 0, name: '' },
dimension: { width: 0, height: 0, rotate: 0 },
staff: [], status: '', areas: [], styles: [], targetURL: '',
section: []
})
/** 标识视频信息卡片应该显示普通视频还是剧集,值为 `hide` 时隐藏卡片 */
videoInfoCardMode: VideoInfoCardMode = van.state('hide')
ownerFaceHide = van.derive(() => this.videoInfoCardData.val.owner.face == '')
/** 全部选项卡和列表数据 */
allSection
/** 当前选项卡的按钮列表 */
sectionPages
/** 当前选中的按钮列表 */
selectedPages
/** 视频列表批量解析模态框 */
parseModal: Modal
parseModalComp: ParseModalComp
/** 按钮是否处于 `loading` 状态,如果是则按钮设置为 `disabled` */
btnLoading = van.state(false)
/** 页面初始加载的 loading 状态 */
initLoading = van.state(true)
/** 是否是初始的热门推荐结果 */
isInitPopular = van.state(false)
sectionTabsActiveIndex = van.state(0)
constructor() {
const _that = this
this.allSection = van.derive(() => {
const list = (
this.videoInfoCardMode.val == 'season'
|| this.videoInfoCardMode.val == 'video' && this.videoInfoCardData.val.section.length == 0
? [{ title: '正片', pages: this.videoInfoCardData.val.pages }] : []
).concat(this.videoInfoCardData.val.section)
return list
})
this.sectionPages = van.derive(() => {
return this.allSection.val[this.sectionTabsActiveIndex.val]?.pages || []
})
this.selectedPages = van.derive(() => this.sectionPages.val.filter(page => page.selected.val))
this.parseModalComp = new ParseModalComp({ workRoute: this })
van.add(document.body, this.parseModalComp.element)
this.parseModal = new Modal(this.parseModalComp.element)
this.element = Route({
rule: 'work',
Loader() {
return div(
() => _that.initLoading.val ? LoadingBox() : '',
div({ class: 'vstack gap-3', hidden: _that.initLoading },
InputBox(_that),
div({ hidden: () => _that.videoInfoCardMode.val == 'hide' || _that.btnLoading.val },
VideoInfoCard(_that),
),
)
)
},
async onFirst() {
if (!await checkLogin()) return
let idType = this.args[0] as IDType
let value = this.args[1]
// if (!value) return goto('work'), _that.initLoading.val = false
if (!value) {
_that.isInitPopular.val = true
const popularBvidList = await getPopularVideoBvIds()
idType = 'bv'
value = popularBvidList[Math.floor(Math.random() * popularBvidList.length)]
}
if (idType == 'bv' && !value.match(/^BV1[a-zA-Z0-9]+$/)) return goto('work')
if ((idType == 'ep' || idType == 'ss' || idType == 'fav')
&& !value.match(/^\d+$/)) return goto('work')
if (idType == 'bv' && !_that.isInitPopular.val) _that.urlValue.val = value
else if (idType == 'ep' || idType == 'ss') _that.urlValue.val = `${idType}${value}`
start(_that, {
idType,
value,
from: 'onFirst'
}).catch(error => {
const errorMessage = `获取视频信息失败:${error.message}`
showErrorPage(errorMessage)
_that.videoInfoCardMode.val = 'hide'
}).finally(() => {
_that.btnLoading.val = false
_that.isInitPopular.val = false
setTimeout(() => {
_that.initLoading.val = false
}, 200)
})
},
async onLoad() {
if (!GLOBAL_HAS_LOGIN.val) return goto('login')
}
})
}
}
export default () => new WorkRoute().element
+196
View File
@@ -0,0 +1,196 @@
import { getFavList, getRedirectedLocation, getSeasonInfo, getVideoInfo } from './data'
import { WorkRoute } from '.'
import van from 'vanjs-core'
import { Episode, PageInParseResult, VideoParseResult } from './type'
import { ResJSON } from '../mixin'
/** 点击按钮开始解析 */
export const start = async (
workRoute: WorkRoute,
option: {
/** 标识字段类型 */
idType: IDType
/** 标识字段值 */
value: string | number
/** 调用来源 */
from: 'click' | 'onFirst'
}) => {
// 如果初始载入路由时,路由参数不带有视频信息,则会随机选择一个热门视频
// 这种情况展示的解析结果,不应该同步修改路由参数,用户直接刷新网页,可以继续随机推荐不同的视频
// 而如果是单击按钮或者路由参数带有视频信息,那么实现的解析结果需要同步修改路由参数
if (!workRoute.isInitPopular.val)
history.replaceState(null, '', `#/work/${option.idType}/${option.value}`)
workRoute.sectionTabsActiveIndex.val = 0
if (option.idType === 'bv') {
const bvid = option.value as string
await getVideoInfo(bvid).then(info => {
workRoute.videoInfoCardData.val = {
section: (info.ugc_season.sections || []).map(i => {
let index = 0
return {
title: (info.ugc_season.sections || []).length == 1 ? info.ugc_season.title : i.title,
pages: i.episodes.flatMap(j => j.pages.map(k => {
index++
return {
bvid: j.bvid,
cid: k.cid,
dimension: k.dimension,
duration: k.duration,
page: index,
part: j.title,
badge: index.toString(),
selected: van.state(bvid == j.bvid)
}
}))
}
}),
targetURL: `https://www.bilibili.com/video/${bvid}`,
areas: [],
styles: [],
status: '',
cover: info.pic,
title: info.title,
description: info.desc,
publishData: new Date(info.pubdate * 1000).toLocaleString(),
duration: info.duration,
pages: info.pages.map((page, index) => ({
...page,
bvid,
badge: (index + 1).toString(),
selected: van.state(info.pages.length == 1)
})),
dimension: info.dimension,
owner: info.owner,
staff: info.staff?.map(i => `${i.name}[${i.title}]`) || []
}
workRoute.videoInfoCardMode.val = 'video'
})
} else if (option.idType === 'ep' || option.idType === 'ss') {
const epid = option.idType === 'ep' ? option.value as number : 0
const ssid = option.idType === 'ss' ? option.value as number : 0
await getSeasonInfo(epid, ssid).then(info => {
workRoute.videoInfoCardData.val = {
section: (info.section || []).map(i => ({
pages: i.episodes.map(episodeToPage),
title: i.title
})),
targetURL: `https://www.bilibili.com/bangumi/play/${option.idType}${option.value}`,
areas: info.areas.map(i => i.name),
styles: info.styles,
duration: 0,
cover: info.cover,
description: info.evaluate,
owner: { name: info.actors, face: '', mid: 0 },
pages: info.episodes.map(episodeToPage),
status: info.new_ep.desc,
publishData: new Date().toLocaleDateString(),
staff: info.actors.split('\n'),
dimension: { height: 0, rotate: 0, width: 0 },
title: info.title
}
workRoute.videoInfoCardMode.val = 'season'
})
} else if (option.idType == 'fav') {
const mediaId = option.value as number
await getFavList(mediaId).then(favList => {
workRoute.videoInfoCardData.val = {
areas: [],
cover: favList[0].cover,
description: favList[0].intro,
dimension: { height: 0, width: 0, rotate: 0 },
duration: favList[0].duration,
owner: favList[0].upper,
pages: favList.map((info, index) => ({
badge: (index + 1).toString(),
selected: van.state(index == 0),
bvid: info.bvid,
cid: info.ugc.first_cid,
dimension: { height: 0, width: 0, rotate: 0 },
duration: info.duration,
page: index,
part: info.title,
})),
publishData: new Date(favList[0].pubtime * 1000).toLocaleString(),
section: [],
staff: [],
status: '',
styles: [],
targetURL: `https://www.bilibili.com/video/${favList[0].bvid}`,
title: favList[0].title
}
workRoute.videoInfoCardMode.val = 'video'
})
}
}
const episodeToPage = (episode: Episode, index: number): PageInParseResult => {
return {
bvid: episode.bvid,
cid: episode.cid,
dimension: episode.dimension,
duration: episode.duration,
page: index + 1,
part: episode.long_title || (episode.title.match(/^\d+$/) ? `${episode.title}` : episode.title),
badge: episode.title,
selected: van.state(false)
}
}
export type IDType = 'bv' | 'ep' | 'ss' | 'fav'
/**
* 校验用户输入的待解析的视频链接
* @param url 待解析的视频链接
* @returns 如果校验成功,则返回 BV 号,否则返回 `false`
*/
export const checkURL = (url: string): {
type: IDType
value: string | number
} => {
const matchBvid = url.match(/^(?:https?:\/\/www\.bilibili\.com\/video\/)?(BV1[a-zA-Z0-9]+)/)
if (matchBvid) return { type: 'bv', value: matchBvid[1] }
const matchSeason = url.match(/^(?:https?:\/\/www\.bilibili\.com\/bangumi\/play\/)?(ep|ss)(\d+)/)
if (matchSeason) return { type: matchSeason[1] as 'ep' | 'ss', value: parseInt(matchSeason[2]) }
try {
const _url = new URL(url)
const mediaId = parseInt(_url.searchParams.get('fid') || '')
if (_url.hostname == 'space.bilibili.com' && _url.pathname.match(/^\/\d+\/favlist$/) && !isNaN(mediaId)) {
return { type: 'fav', value: mediaId }
}
const mlMatch = url.match(/^https:\/\/www.bilibili.com\/medialist\/detail\/ml(\d+)/)
if (mlMatch) return { type: 'fav', value: mlMatch[1] }
} catch { }
throw new Error('您输入的视频链接格式错误')
}
/** 将秒数转换为 `mm:ss` */
export const secondToTime = (second: number) => {
return `${Math.floor(second / 60)}:${(second % 60).toString().padStart(2, '0')}`
}
/** 如果是 B23 地址,则返回重定向后的地址,否则返回 `false` */
export const handleB23 = async (url: string): Promise<string | false> => {
if (!url.match(/^https:\/\/b23.tv\//)) return false
const epMatch = url.match(/^https:\/\/b23.tv\/(ep|ss)(\d+)/)
if (epMatch) return `https://www.bilibili.com/bangumi/play/${epMatch[1]}${epMatch[2]}`
const location = await getRedirectedLocation(url)
return location
}
export const handleSeasonsArchivesList = async (url: string): Promise<string | false> => {
try { new URL(url) } catch { return false }
const _url = new URL(url)
const mid = _url.pathname.match(/^\/(\d+)\/channel\/collectiondetail$/)?.[1]
const seasonId = parseInt(_url.searchParams.get('sid') || '')
if (_url.hostname == 'space.bilibili.com' && mid && !isNaN(seasonId)) {
return fetch(`/api/getSeasonsArchivesListFirstBvid?mid=${mid}&seasonId=${seasonId}`)
.then(res => res.json())
.then((body: ResJSON<string>) => {
if (!body.success) throw new Error(body.message)
return body.data
})
}
return false
}
+277
View File
@@ -0,0 +1,277 @@
import { State } from 'vanjs-core'
/**
* 参考:[qn 视频清晰度标识](https://socialsisteryi.github.io/bilibili-API-collect/docs/video/videostream_url.html#qn%E8%A7%86%E9%A2%91%E6%B8%85%E6%99%B0%E5%BA%A6%E6%A0%87%E8%AF%86)
*
* | 值 | 含义 |
* | --- | ----------: |
* | 6 | 240P 极速 |
* | 16 | 360P 流畅 |
* | 32 | 480P 清晰 |
* | 64 | 720P 高清 |
* | 74 | 720P60 高帧率 |
* | 80 | 1080P 高清 |
* | 112 | 1080P+ 高码率 |
* | 116 | 1080P60 高帧率 |
* | 120 | 4K 超清 |
* | 125 | HDR 真彩色 |
* | 126 | 杜比视界 |
* | 127 | 8K 超高清 |
*/
export type VideoFormat = 6 | 16 | 32 | 64 | 74 | 80 | 112 | 116 | 120 | 125 | 126 | 127
/** 视频解析结果,数据用于 DOM 渲染,同时兼容 BV、EP、SS */
export type VideoParseResult = {
/** 合集标题 */
title: string
/** 合集描述 */
description: string
/** 合集发布时间 */
publishData: string
/** 合集封面 */
cover: string
/** 合集总时长 */
duration: number
/** 分集列表 */
pages: PageInParseResult[]
section: SectionItem[]
/** 合集作者 */
owner: {
mid: number
name: string
face: string
}
/** 合集分辨率 */
dimension: {
width: number
height: number
rotate: number
}
staff: string[]
/** 更新状态 */
status: string
/** 地区信息 */
areas: string[]
/** 分类标签 */
styles: string[]
/** 播放页面 */
targetURL: string
}
export type SectionItem = {
title: string
pages: PageInParseResult[]
}
export type PageInParseResult = {
/** 分集 CID */
cid: number
/** 分集 BVID */
bvid: string
/** 分集在合集中的序号,从 1 开始 */
page: number
/** 分集标题 */
part: string
/** 分集时长 */
duration: number
/** 分集分辨率 */
dimension: {
width: number
height: number
rotate: number
}
/** 前置胶囊标签内容 */
badge: string
/** 是否选中 */
selected: State<boolean>
}
export type StaffItem = {
mid: number
title: string
name: string
face: string
}
type PageInVideoInfo = {
cid: number
page: number
from: string
part: string
duration: number
dimension: {
width: number
height: number
rotate: number
}
}
/** 接口返回的视频信息 */
export type VideoInfo = {
aid: number
staff: null | StaffItem[]
title: string
desc: string
pubdate: number
pic: string
duration: number
bvid: string
pages: PageInVideoInfo[]
owner: {
mid: number
name: string
face: string
}
dimension: {
width: number
height: number
rotate: number
},
ugc_season: {
sections: {
title: string
episodes: {
title: string
pages: PageInVideoInfo[]
bvid: string
}[]
}[] | null
title: string
}
}
/** 接口返回的剧集信息 */
export type SeasonInfo = {
actors: string
areas: {
id: number
name: string
}[]
cover: string
evaluate: string
publish: {
is_finish: number
pub_time: string
};
season_id: number
season_title: string
stat: {
coins: number
danmakus: number
favorite: number
favorites: number
likes: number
reply: number
share: number
views: number
}
styles: string[]
title: string
total: number
episodes: Episode[]
new_ep: {
desc: string
is_new: number
}
section: {
title: string
episodes: Episode[]
}[] | null
}
export type Episode = {
aid: number
bvid: string
cid: number
cover: string
dimension: {
width: number
height: number
rotate: number
}
duration: number
ep_id: number
long_title: string
pub_time: number
title: string
}
export type VideoInfoCardMode = State<"video" | "season" | "hide">
export type Media<Type = 'video' | 'audio'> = {
id: Type extends 'video' ? VideoFormat : number
baseUrl: string
backupUrl: string[]
bandwidth: number
mimeType: string
codecs: string
width: number
height: number
frameRate: string
codecid: number
}
export type PlayInfo = {
// accept_description: string[]
accept_quality: VideoFormat[]
// support_formats: {
// quality: number
// format: string
// new_description: string
// codecs: string[]
// }[]
dash: {
duration: number
video: Media<'video'>[]
audio: Media<'audio'>[]
flac: {
audio: Media
} | null
}
}
/** 创建任务时的初始数据 */
export type TaskInitData = {
bvid: string
cid: number
format: number
title: string
owner: string
cover: string
audio: string
video: string
duration: number
downloadType: 'audio' | 'video' | 'merge'
}
/** 任务数据库中的数据 */
export type TaskInDB = TaskInitData & {
id: number
folder: string
createAt: string
status: TaskStatus
}
export type TaskStatus = 'done' | 'waiting' | 'running' | 'error'
export type FavList = FavListItem[]
export type FavListItem = {
title: string
cover: string
intro: string
duration: number
upper: {
mid: number
name: string
face: string
}
pubtime: number
bvid: string
ugc: {
first_cid: number
}
}
+85
View File
@@ -0,0 +1,85 @@
import van from 'vanjs-core'
import { goto } from 'vanjs-router'
import { v4 } from 'uuid'
import { checkURL, handleB23, handleSeasonsArchivesList, start } from '../mixin'
import { WorkRoute } from '..'
import { VanComponent } from '../../mixin'
const { button, div, input, label, span } = van.tags
class InputBoxComp implements VanComponent {
element: HTMLElement
btnID = v4()
constructor(public workRoute: WorkRoute) {
this.element = div(
div({ class: () => `hstack gap-3 align-items-stretch ${workRoute.urlInvalidClass.val}` },
div({ class: () => `form-floating flex-fill` },
input({
class: () => `form-control border-3 ${workRoute.urlInvalidClass.val}`,
placeholder: '请输入待解析的视频链接',
value: workRoute.urlValue,
oninput: event => workRoute.urlValue.val = event.target.value,
onkeyup: event => {
if (event.key === 'Enter') document.getElementById(this.btnID)?.click()
}
}),
label({ class: 'w-100' }, '请输入视频链接或 BV/EP/SS 号')
),
ParseButton(this, false, this.btnID),
ParseButton(this, true)
),
div({ class: 'invalid-feedback' }, () => workRoute.urlInvalid.val ? '您输入的视频链接格式错误' : ''),
)
}
}
const ParseButton = (parent: InputBoxComp, large: boolean, id: string = '') => {
const { workRoute } = parent
return button({
class: `btn btn-success text-nowrap ${large ? `btn-lg d-none d-md-block` : 'd-md-none'}`,
async onclick() {
try {
workRoute.btnLoading.val = true
workRoute.urlValue.val = workRoute.urlValue.val.trim()
try {
const handleB23Result = await handleB23(workRoute.urlValue.val)
if (handleB23Result) workRoute.urlValue.val = handleB23Result
} catch (error) {
if (error instanceof Error) return alert(error.message)
}
try {
const handleSeasonsArchivesListResult = await handleSeasonsArchivesList(workRoute.urlValue.val)
if (handleSeasonsArchivesListResult) workRoute.urlValue.val = handleSeasonsArchivesListResult
} catch (error) {
if (error instanceof Error) return alert(error.message)
}
const { type, value } = checkURL(workRoute.urlValue.val)
workRoute.urlInvalid.val = false
await start(workRoute, {
idType: type,
value,
from: 'click'
}).catch(error => {
const errorMessage = `获取视频信息失败:${error.message}`
alert(errorMessage)
goto('work')
workRoute.videoInfoCardMode.val = 'hide'
})
} catch (error) {
workRoute.urlInvalid.val = true
} finally {
setTimeout(() => {
workRoute.btnLoading.val = false
}, 200)
}
},
id,
disabled: workRoute.btnLoading
}, span({ class: 'spinner-border spinner-border-sm me-2', hidden: () => !workRoute.btnLoading.val }),
() => workRoute.btnLoading.val ? '解析中' : '解析视频'
)
}
export default (workRoute: WorkRoute) => new InputBoxComp(workRoute).element
+337
View File
@@ -0,0 +1,337 @@
import van, { State } from 'vanjs-core'
import { VanComponent, formatSeconds } from '../../mixin'
import { PageInParseResult, PlayInfo, VideoFormat } from '../type'
import { WorkRoute } from '..'
import { createTask, getPlayInfo } from '../data'
import PQueue from 'p-queue'
const { a, button, div, input, select, option, label } = van.tags
type Option = {
workRoute: WorkRoute
}
const videoFormatMap: Record<VideoFormat, string> = {
127: "超高清 8K",
126: "杜比视界",
125: "真彩 HDR",
120: "超清 4K",
116: "高清 1080P60",
112: "高清 1080P+",
80: "高清 1080P",
74: "高清 720P60",
64: "高清 720P",
32: "清晰 480P",
16: "流畅 360P",
6: "极速 240P",
}
const codecMap: Record<12 | 7 | 13, string> = {
12: "HEVC (hev1)",
7: "AVC (avc1)",
13: "AV1 (av01)",
}
export class ParseModalComp implements VanComponent {
element: HTMLElement
totalCount: State<number>
finishCount = van.state(0)
abortControllers: AbortController[] = []
currentController?: AbortController
allPlayInfo: State<{
page: PageInParseResult
info: PlayInfo | null
selected: State<boolean>
formatIndex: State<number>
}[]> = van.state([])
/** 该属性用于在点击“开始下载”按钮后使按钮变为禁用状态,防止多次点击 */
downloadBtnDisabled = van.state(false)
/** 下载类型:audio 仅音频,video 仅视频,merge 音视频合并 */
downloadType = van.state<'audio' | 'video' | 'merge'>('merge')
/** 优先视频编码格式:12 hev1, 7 avc1, 13 av01 */
preferredCodec = van.state<12 | 7 | 13>(12)
/** 是否优先使用HiResflac)音频 */
preferHiResAudio = van.state(true)
errorList: State<string[]> = van.state([])
constructor(public option: Option) {
this.totalCount = van.derive(() => option.workRoute.selectedPages.val.length)
const allFinish = van.derive(() => this.totalCount.val == this.finishCount.val)
this.element = div({ class: `modal fade`, tabIndex: -1 },
div({ class: () => `modal-dialog modal-xl modal-fullscreen-xl-down ${(this.totalCount.val + this.errorList.val.length) < 10 ? '' : 'modal-dialog-scrollable'}` },
div({ class: `modal-content` },
div({ class: `modal-header` },
div({ class: `h5 modal-title` }, () => allFinish.val ? '批量下载' : '批量解析'),
button({ class: `btn-close`, 'data-bs-dismiss': `modal` })
),
div({ class: `modal-body vstack gap-3`, tabIndex: -1, style: 'outline: none;' },
this.ParseProgress(),
div({ class: 'vstack gap-2', hidden: () => this.errorList.val.length == 0 || !allFinish.val },
div({ class: 'text-danger' }, () => `以下 ${this.errorList.val.length} 个视频解析失败`),
() => div({ class: 'list-group' },
this.errorList.val.map(error => div({ class: 'list-group-item disabled' }, error))
)
),
this.ListGroup()
),
this.ModalFooter()
)
)
)
this.element.addEventListener('hidden.bs.modal', () => {
this.allPlayInfo.val = []
this.finishCount.val = this.totalCount.val
for (const controller of this.abortControllers) {
controller.abort()
}
this.abortControllers = []
})
this.element.addEventListener('show.bs.modal', () => {
this.start()
})
}
/** 开始解析 */
async start() {
this.finishCount.val = 0
this.errorList.val = []
const queue = new PQueue({ concurrency: 10 })
for (const page of this.option.workRoute.selectedPages.val) {
queue.add(async () => {
if (this.totalCount.val == this.finishCount.val) return
const controller = new AbortController()
this.abortControllers.push(controller)
const playInfo = await getPlayInfo(page.bvid, page.cid, controller)
playInfo.accept_quality = [...new Set(playInfo.dash.video.map(video => video.id))].sort((a, b) => b - a)
this.allPlayInfo.val = this.allPlayInfo.val.concat({
page,
info: playInfo,
selected: van.state(true),
formatIndex: van.state(0),
})
this.finishCount.val++
}).catch(() => {
this.finishCount.val++
const badgeNotNum = !page.badge.match(/^\d+$/)
this.errorList.val = this.errorList.val.concat(`${page.part}${badgeNotNum ? ` - ${page.badge}` : ''}`)
})
}
await queue.onIdle()
}
download() {
const selectedPlayInfos = this.allPlayInfo.val.filter(info => info.selected.val)
const workRoute = this.option.workRoute
this.downloadBtnDisabled.val = true
// 需要传递给服务器,需要创建下载任务的数据列表
createTask(selectedPlayInfos.map(info => {
const badgeNotNum = !info.page.badge.match(/^\d+$/)
const isVideoMode = workRoute.videoInfoCardMode.val == 'video'
const cardTitle = workRoute.videoInfoCardData.val.title
const owner = workRoute.videoInfoCardData.val.staff.length > 0
? workRoute.videoInfoCardData.val.staff[0].split("[")[0].trim()
: workRoute.videoInfoCardData.val.owner.name.trim()
const activeVideoInfo = getActiveFormatVideo(info.info!, info.info!.accept_quality[info.formatIndex.val], this.preferredCodec.val)
const pagesLength = workRoute.videoInfoCardData.val.pages.length
return ({
bvid: info.page.bvid,
cid: info.page.cid,
cover: workRoute.videoInfoCardData.val.cover,
title: (badgeNotNum
? [
info.page.part.trim(),
`[${info.page.badge.trim()}]`,
`[${cardTitle.trim()}]`,
`[${videoFormatMap[info.info!.accept_quality[info.formatIndex.val]]}]`,
`[${formatSeconds(info.info!.dash.duration)}]`
]
: [
pagesLength == 1 ? workRoute.allSection.val[workRoute.sectionTabsActiveIndex.val].title : `[${cardTitle.trim()}]`,
workRoute.sectionPages.val.length == 1 ? '' : `[${info.page.badge.trim()}]`,
info.page.part.trim(),
isVideoMode ? `[${owner}]` : '',
`[${videoFormatMap[info.info!.accept_quality[info.formatIndex.val]]}]`,
`[${formatSeconds(info.info!.dash.duration)}]`
]).filter(p => p).join(' '),
format: info.info!.accept_quality[info.formatIndex.val],
owner,
audio: getAudioURL(info.info!, this.preferHiResAudio.val),
duration: info.info!.dash.duration,
downloadType: this.downloadType.val,
...activeVideoInfo
})
})).then(() => {
workRoute.parseModal.hide()
}).catch(error => {
alert(error.message)
}).finally(() => {
this.downloadBtnDisabled.val = false
})
}
ParseProgress() {
return div({ class: 'vstack gap-3', hidden: () => this.totalCount.val == this.finishCount.val },
div({ class: 'text-center fs-5' }, () => `正在解析,剩余 ${this.totalCount.val - this.finishCount.val}`),
div({ class: 'progress' }, div({
class: 'progress-bar progress-bar-striped progress-bar-animated',
style: () => `width: ${this.finishCount.val / this.totalCount.val * 100}%`
},)),
)
}
ListGroup() {
return () => div({ class: 'list-group', hidden: () => this.totalCount.val != this.finishCount.val },
this.allPlayInfo.val.filter(info => info.info)
.sort((a, b) => a.page.page - b.page.page)
.map(info => {
const badgeNotNum = !info.page.badge.match(/^\d+$/)
return div({
class: () => `list-group-item user-select-none py-0 ${info.info ? '' : 'disabled'}`,
role: 'button',
onclick(event) {
if ((event.target as HTMLElement).getAttribute('class')?.match(/dropdown-?/)) return
info.selected.val = !info.selected.val
}
},
div({ class: 'hstack gap-2' },
div({ class: 'hstack gap-3 flex-fill py-1' },
input({
class: 'form-check-input', type: 'checkbox', checked: info.selected,
}),
div({},
div((badgeNotNum ? '' : `${info.page.badge}. `) + info.page.part),
badgeNotNum ? div({ class: info.page.part ? 'small text-secondary' : '' },
info.page.badge) : ''
),
),
div({ class: 'dropdown' },
div({ class: 'dropdown-toggle py-2 text-primary', 'data-bs-toggle': 'dropdown' },
() => videoFormatMap[info.info!.accept_quality[info.formatIndex.val]]
),
() => {
return div({ class: 'dropdown-menu shadow' },
info.info!.accept_quality.map((formatID, index) => {
return div({
class: () => `dropdown-item ${info.formatIndex.val == index ? 'active' : ''}`,
onclick() {
info.formatIndex.val = index
}
}, videoFormatMap[formatID as VideoFormat])
})
)
}
)
)
)
})
)
}
ModalFooter() {
const _that = this
const selectedCount = van.derive(() => this.allPlayInfo.val.filter(info => info.selected.val).length)
const totalCount = van.derive(() => this.allPlayInfo.val.length)
/** 解析完成列表全部选中 */
const allSelected = van.derive(() => selectedCount.val == totalCount.val)
/** 是否全部解析完成 */
const allFinish = van.derive(() => this.totalCount.val == this.finishCount.val)
return div({ class: `modal-footer` },
div({ class: 'me-auto', hidden: () => !allFinish.val || totalCount.val == 0 },
div({ class: 'hstack gap-3 text-nowrap' },
() => `已选择 (${selectedCount.val}/${totalCount.val}) 项`,
select({
class: 'form-select form-select-sm',
value: _that.downloadType,
oninput: (e) => _that.downloadType.val = (e.target as HTMLSelectElement).value as 'audio' | 'video' | 'merge'
},
option({ value: 'merge' }, '音视频合并'),
option({ value: 'audio' }, '仅音频'),
option({ value: 'video' }, '仅视频')
),
select({
class: 'form-select form-select-sm',
value: _that.preferredCodec,
oninput: (e) => _that.preferredCodec.val = Number((e.target as HTMLSelectElement).value) as 12 | 7 | 13
},
option({ value: '12' }, 'HEVC (hev1)'),
option({ value: '7' }, 'AVC (avc1)'),
option({ value: '13' }, 'AV1 (av01)')
),
div({ class: 'form-check form-check-inline' },
input({
type: 'checkbox',
class: 'form-check-input',
id: 'preferHiResAudio',
checked: _that.preferHiResAudio,
oninput: (e) => _that.preferHiResAudio.val = (e.target as HTMLInputElement).checked
}),
label({ class: 'form-check-label', for: 'preferHiResAudio' }, 'Hi-Res')
)
)
),
button({
class: `btn btn-secondary`,
'data-bs-dismiss': `modal`,
hidden: allFinish
}, '取消解析'),
button({
class: 'btn btn-secondary', hidden: () => !allFinish.val || allSelected.val || totalCount.val == 0,
onclick() {
_that.allPlayInfo.val.forEach(info => info.selected.val = true)
}
}, '全选'),
button({
class: 'btn btn-warning', hidden: () => !allFinish.val || !allSelected.val || totalCount.val == 0,
onclick() {
_that.allPlayInfo.val.forEach(info => info.selected.val = false)
}
}, '全不选'),
button({
class: `btn btn-primary`, onclick() {
_that.download()
},
hidden: () => !allFinish.val,
disabled: () => selectedCount.val <= 0 || _that.downloadBtnDisabled.val
}, '开始下载'),
)
}
}
const getAudioURL = (playInfo: PlayInfo, preferHiRes: boolean = true): string => {
if (preferHiRes && playInfo.dash.flac) {
return playInfo.dash.flac.audio.baseUrl
} else {
return playInfo.dash.audio.sort((a, b) => b.id - a.id)[0].baseUrl
}
}
const getActiveFormatVideo = (playInfo: PlayInfo, format: VideoFormat, preferredCodec: 12 | 7 | 13 = 12): { video: string, width: number, height: number } => {
// 优先级顺序:用户首选编码格式,然后按默认优先级 12 > 7 > 13
const codecOrder = [preferredCodec, 12, 7, 13].filter((value, index, self) => self.indexOf(value) === index)
for (const code of codecOrder) {
for (const item of playInfo.dash.video) {
if (item.id == format && item.codecid == code) {
return {
video: item.baseUrl,
width: item.width,
height: item.height
}
}
}
}
throw new Error('未找到对应视频分辨率格式')
}
+198
View File
@@ -0,0 +1,198 @@
import van, { State, Val } from 'vanjs-core'
import { VideoParseResult, VideoInfoCardMode } from '../type'
import { secondToTime } from '../mixin'
import { VanComponent } from '../../mixin'
import VideoItemList from './videoItemList'
import { WorkRoute } from '..'
const { a, div, img } = van.tags
class VideoInfoCardComp implements VanComponent {
element: HTMLElement
constructor(
public workRoute: WorkRoute,
) {
const {
videoInfoCardData: data,
videoInfoCardMode: mode,
ownerFaceHide
} = workRoute
this.element = div({ class: 'card border-2 shadow-sm' },
div({ class: 'card-header' },
a({
class: 'link-dark text-decoration-none fw-bold focus-ring', href: () => data.val.targetURL,
target: '_blank',
},
() => data.val.title,
)
),
div({ class: 'card-body vstack gap-4' },
div({ class: 'vstack gap-3' },
div({ class: 'row gx-3 gy-3' },
// 封面
div({
class: () => mode.val == 'video'
? 'col-md-5 col-xl-4'
: 'col-8 col-sm-6 mx-auto col-md-5 col-lg-3 col-xl-2'
},
div({ class: 'position-relative shadow-sm rounded overflow-hidden' },
a({
href: () => data.val.targetURL,
title: () => `打开视频播放页面`,
target: '_blank',
},
img({
src: () => data.val.cover,
class: 'w-100',
ondragstart: event => event.preventDefault(),
referrerPolicy: 'no-referrer',
})
),
a({
href: () => `https://space.bilibili.com/${data.val.owner.mid}`,
title: () => `查看用户主页:${data.val.owner.name}`,
target: '_blank',
tabIndex: () => mode.val == 'video' ? 0 : -1,
},
img({
src: () => data.val.owner.face,
hidden: ownerFaceHide,
referrerPolicy: 'no-referrer',
ondragstart: event => event.preventDefault(),
style: `right: 1rem; bottom: 1rem;`,
class: 'rounded-3 border shadow position-absolute w-25'
})
),
),
),
// 字段信息
div({
class: () => mode.val == 'video'
? 'col-md-7 col-xl-8 vstack gap-2'
: 'col-md-7 col-lg-9 col-xl-10 vstack gap-2'
},
div({ class: 'position-relative h-100' },
div({ class: 'position-absolute top-0 bottom-0 position-relative-sm-down' },
Right(this)
)
),
),
),
DescriptionGroup(this, true),
),
VideoItemList(workRoute)
)
)
}
}
const Right = (parent: VideoInfoCardComp) => {
const mode = parent.workRoute.videoInfoCardMode
const data = parent.workRoute.videoInfoCardData
return div({ class: 'vstack gap-2 h-100' },
div({ class: 'row gx-2 gy-2' },
div({ class: 'col-xl-7 col-xxl-8' },
InputGroup(
van.derive(() => mode.val == 'video'
? (data.val.staff.length > 0 ? '制作信息' : '发布者')
: '参演人员'),
van.derive(() => {
if (data.val.staff.length > 0)
return data.val.staff.map(i => i.trim()).join(', ')
return data.val.owner.name
}), { disabled: true }
),
),
div({ class: 'col-xl-5 col-xxl-4' },
InputGroup('发布时间',
van.derive(() => data.val.publishData), { disabled: true }
)
),
div({ class: 'col-sm col-md-12 col-lg-4', hidden: () => mode.val != 'video' },
InputGroup('分辨率',
van.derive(() => `${data.val.dimension.width}x${data.val.dimension.height}`),
{ disabled: true }
)
),
div({ class: 'col col-lg-4', hidden: () => mode.val != 'video' },
InputGroup('时长',
van.derive(() => `${secondToTime(data.val.duration)}`),
{ disabled: true }
)
),
div({ class: 'col col-lg-4', hidden: () => mode.val != 'video' },
InputGroup('集数',
van.derive(() => data.val.pages.length.toString()),
{ disabled: true }
)
),
div({ class: 'col-md-12 col-lg-4', hidden: () => mode.val != 'season' },
InputGroup('状态',
van.derive(() => data.val.status),
{ disabled: true }
)
),
div({ class: 'col-sm col-lg-4', hidden: () => mode.val != 'season' },
InputGroup('地区',
van.derive(() => data.val.areas.map(i => i.trim()).join(', ')),
{ disabled: true }
)
),
div({ class: 'col-sm col-lg-4', hidden: () => mode.val != 'season' },
InputGroup('标签',
van.derive(() => data.val.styles.join(', ')),
{ disabled: true }
)
),
),
DescriptionGroup(parent),
)
}
/** 用于显示 `description` 字段的 `.input-group`
*
* @param parent 父组件
* @param bottom 是否在底部
*/
const DescriptionGroup = (parent: VideoInfoCardComp, bottom = false) => {
const mode = parent.workRoute.videoInfoCardMode
const data = parent.workRoute.videoInfoCardData
const _class = van.derive(() => mode.val == 'video' ? 'd-md-flex' : '')
const size = van.derive(() => mode.val == 'video' ? 'lg' : 'lg')
return div({
class: () => `shadow-sm input-group input-group-sm ${bottom
? `d-none d-lg-none ${_class.val}`
: `overflow-hidden flex-fill ${mode.val == 'video' ? 'd-md-none d-lg-flex ' : ''}`
}`,
},
div({ class: 'input-group-text align-items-start' }, '描述'),
() => {
const lines = (data.val.description.match(/^(\s*|.)$/) ? '暂无描述' : data.val.description).split('\n')
return div({ class: `form-control overflow-auto ${bottom ? `max-height-description` : `h-100`}` },
lines.map(line => div(line))
)
}
)
}
const InputGroup = (title: Val<string>, value: State<string>, option?: {
disabled?: Val<boolean>
elementType?: 'input' | 'textarea'
}) => {
return div({ class: 'input-group input-group-sm shadow-sm rounded' },
div({ class: 'input-group-text' }, title),
van.tags[option?.elementType || 'input']({
class: 'form-control bg-white',
disabled: option?.disabled || false,
style: 'cursor: text;',
value
})
)
}
export default (
workRoute: WorkRoute
) => new VideoInfoCardComp(workRoute).element
+102
View File
@@ -0,0 +1,102 @@
import van, { State } from 'vanjs-core'
import { VideoParseResult, VideoInfoCardMode, PageInParseResult, SectionItem } from '../type'
import { VanComponent } from '../../mixin'
import { WorkRoute } from '..'
const { button, div, span } = van.tags
class VideoItemListComp implements VanComponent {
element: HTMLElement
constructor(
public workRoute: WorkRoute
) {
const { videoInfoCardData: data } = workRoute
this.element = div({
hidden: () => false && data.val.pages.length <= 1,
class: 'vstack gap-4'
},
div({ class: 'vstack gap-4' },
div({ hidden: () => workRoute.allSection.val.length == 1 && workRoute.allSection.val[0].title == '正片' }, SectionTabs(this, workRoute.allSection)),
ButtonGroup(workRoute),
ListBox(workRoute.sectionPages),
)
)
}
}
const SectionTabs = (parent: VideoItemListComp, allSection: State<SectionItem[]>) => {
return () => div({ class: 'nav nav-underline' },
allSection.val.map((item, index) => div({ class: 'nav-item', role: 'button' },
div({
tabIndex: 0,
class: `nav-link ${parent.workRoute.sectionTabsActiveIndex.val == index ? 'active' : ''}`,
onclick() {
parent.workRoute.sectionTabsActiveIndex.val = index
},
onkeyup(e) {
if (e.key == 'Enter') {
e.target.click()
}
}
}, () => item.title)
))
)
}
const ButtonGroup = (workRoute: WorkRoute) => {
const pages = workRoute.sectionPages
const selectedCount = van.derive(() => pages.val.filter(page => page.selected.val).length)
const totalCount = van.derive(() => pages.val.length)
return div({ class: 'hstack gap-3' },
button({
class: 'btn btn-secondary',
onclick() {
pages.val.forEach(page => page.selected.val = selectedCount.val < totalCount.val)
}
}, () => `${selectedCount.val < totalCount.val ? '全选' : '取消全选'} (${selectedCount.val}/${totalCount.val})`),
button({
class: 'btn btn-primary',
disabled: () => selectedCount.val <= 0,
async onclick() {
workRoute.parseModal.show()
}
}, '解析选中项目')
)
}
const ListBox = (pages: State<PageInParseResult[]>) => {
return () => div({ class: 'row gy-3 gx-3' },
pages.val.map(page => {
const badgeNotNum = !page.badge.match(/^\d+$/)
const active = page.selected
return div({ class: 'col-xxl-3 col-lg-4 col-md-6' },
div({
tabIndex: 0,
class: () => `${badgeNotNum
? `vstack gap-2 justify-content-center`
: `hstack gap-3`
} shadow-sm h-100 text-break user-select-none card card-body video-item-btn bg-success bg-opacity-10 ${active.val ? 'active' : ''}`,
onclick() {
active.val = !active.val
},
onkeyup(e) {
if (e.key == 'Enter') {
active.val = !active.val
}
}
},
span({ class: 'badge text-bg-success bg-opacity-75 border', hidden: badgeNotNum }, page.badge),
div(page.part),
div({ class: `${page.part ? 'small text-muted' : ''}`, hidden: !badgeNotNum }, page.badge),
)
)
}),
)
}
export default (
workRoute: WorkRoute
) => new VideoItemListComp(workRoute).element
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"strict": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ESNext"
}
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
export default defineConfig({
clearScreen: false,
server: {
proxy: {
'/api': 'http://127.0.0.1:8098'
},
host: '0.0.0.0'
},
build: {
outDir: '../build/static',
emptyOutDir: true,
},
css: {
preprocessorOptions: {
scss: {
api: 'modern-compiler'
}
}
}
})