diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..5f893b7 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,25 @@ + + + + + + + 云村小助手 + + +
+ + + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..4367275 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1630 @@ +{ + "name": "yunvillage-frontend", + "version": "2.3.21", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "yunvillage-frontend", + "version": "2.3.21", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "~5.6.3", + "vite": "^5.4.11" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz", + "integrity": "sha512-J25QJU+B78T4FhhBsNpLJyVWOi31mwtpcMwywHmOKH65Q9IWGA81gPj+dnwlhU8wktVriYE+tFAaQgrnJRzAZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.5.tgz", + "integrity": "sha512-wlJEERGfeuHeBavCL2qVnNacOK43NDoZM4sjkeRPymd04OAE9T1zBqDJgmZ+CIsPTYKwdzpUC8vmOw84dwY4Tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.5.tgz", + "integrity": "sha512-4nJJGg5jbo2wwPP4JP+LfEBA3bvP8rU9CLuhp7jWvq9sxEyhjQFTFdrqi+/dHEin/pd8jpT0vcehIpnZtmEdcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.5.tgz", + "integrity": "sha512-DrZbyCDF1hneuO6jRbvZ2D7+PIBM6yIwYnJpg2vIk58T+wuFpiaGZrfUr59lDWw45bg+IrpTGLPiNi/Fk4w3Cg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.5.tgz", + "integrity": "sha512-gqfUVMJMB3mehqywxp6hTBFfgtMQykZY19+cfiaYP0toIJLb/1DZRJHVkQQGP13W4TAwfZDWeg1qBcheTRioXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.5.tgz", + "integrity": "sha512-CFmhpvAwzSaWMlN3VN7UtmoTihlZNzoP0juQib5TQRnYUyDV8dXeWOp29sobWAT6gXl/hQgAClLlEiYozQG3OQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.5.tgz", + "integrity": "sha512-Uc9H8eXCOayV6JLTH5bXKMId6qbhNHa818/BgYjm4jrlq3vZquC9cqyvHBw17xy5Mnj5f+I3gFK5JcEf3hSqrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.5.tgz", + "integrity": "sha512-VcPr/szv/1BFw112Kt//fxulXt/JPqzzidU84iW68L2DdjnOO8QFUv2zTSYBEPHD6movBD4z+bbr5y60GYM7Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.5.tgz", + "integrity": "sha512-BnxtJ5/91BrIHYIkGrmjz/lbMhqEHt1dPFqIxIFR+jPn0xVc/oUSCtIT089zfp5ufwGDlYz2UC+Fe1SRBpYFbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.5.tgz", + "integrity": "sha512-LrYcHZwF+fAMNKHYTOQ5osWM4AZF7YF6D+XtsjDyEvljtt11twc+zHVXBLNEjxVSUnKYsOhvVz4Z213eW02COQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.5.tgz", + "integrity": "sha512-nj7QKQePAAUpCpJHtg0pR0W/b92A9NO17JS3BAQmHDn/yhmkir2p8llrKY9TOhleKIaSzy1JhxS3T9FVld6coA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.5.tgz", + "integrity": "sha512-5ylkX6dWMeBKge9nTU+Rxfb+ZfaCIJ9lRqIFaK0eAMcWp7OJbYnLveLgXmm0VrvuLKb8qIK+mHyH0qu88RM+iA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.5.tgz", + "integrity": "sha512-oHK4ZHYFDKjZviK34I+NwgfbGxgI7ztrNxj2hPTSSNFgeq1a/lEd7dHV2fdGAuTH4Iym3RHJg+vAbWaWG4B7Zg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.5.tgz", + "integrity": "sha512-UcetmHZ6XOXuUByiKZyQmb55ZPr0LABr3Ec/HB9wKZn6CEAFWZkE+hsJErJ9hbPBC7nI0dKuELx7CoV6IM7TMg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.5.tgz", + "integrity": "sha512-C5CmDPQBtvjVo8cgQsBs+w6WB0JLkiixhgi6hVLV11hERWdn/p0XcPU2OUcZzac9BPOFq7SbaHFa8r3SWEysCQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.5.tgz", + "integrity": "sha512-lHVQHJFKsuuxLMi3MQO9XVL8Tje3JR82CzB+QDKC5NWBcsIWuwsn9uIM5e3lBhI+fF1/s63qnyYqsg65+8rV/w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.5.tgz", + "integrity": "sha512-3W9bTFcQNJn71cSJVM9RKIiZOy8DO/XLDii8Uv/Pm6WKqDRj7JV3ZfuXIEfyuy5LXpIzAbB/1M4Ukp9GKNa7nA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.5.tgz", + "integrity": "sha512-VDC7rRJlee/scpki96GZ27Omf6yU87s1YXwVTpjE5841faVlDYYT565rgfmoR1U0sqL7z5ivQSDjcsF6VRXyBA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.5.tgz", + "integrity": "sha512-z86Ok2p4pTdv5xqCKZsTooO7yBEiaJR/HzU3Wx8RmWsPoLppnMKROhJusQob8B3IE1ghC343kUW9rC2r+Wf3ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.5.tgz", + "integrity": "sha512-IzQmj+xXwQFGhMAMKMQVXkMwMZN3TqkJgAE0nSsqvVwWWciP4AIPMmWRqOQ2GfX7TUDZr+xqGFcBS36CRPGw0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.5.tgz", + "integrity": "sha512-F6qpTaPc9bwBH85kjy0/BLmLSW1uv7AoOXCoRIkg2arlgCYlWYcAbiMkvZuAcaWk9TpCRG//okznLAqLGshkMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.5.tgz", + "integrity": "sha512-igoDsTFhhwECBeGbUuLeIk7t8Y1apa+cs6mDWpx2EZ0ch7oEQgzHbFUXN9euoHekCAQzXdXApAGkV6jznS7tWw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.5", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.5", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.26", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.1", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.25", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.438", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001812", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.439", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "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" + } + }, + "node_modules/esbuild/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.57", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.28", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.5", + "@rollup/rollup-android-arm64": "4.63.5", + "@rollup/rollup-darwin-arm64": "4.63.5", + "@rollup/rollup-darwin-x64": "4.63.5", + "@rollup/rollup-freebsd-arm64": "4.63.5", + "@rollup/rollup-freebsd-x64": "4.63.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.5", + "@rollup/rollup-linux-arm-musleabihf": "4.63.5", + "@rollup/rollup-linux-arm64-gnu": "4.63.5", + "@rollup/rollup-linux-arm64-musl": "4.63.5", + "@rollup/rollup-linux-loong64-gnu": "4.63.5", + "@rollup/rollup-linux-loong64-musl": "4.63.5", + "@rollup/rollup-linux-ppc64-gnu": "4.63.5", + "@rollup/rollup-linux-ppc64-musl": "4.63.5", + "@rollup/rollup-linux-riscv64-gnu": "4.63.5", + "@rollup/rollup-linux-riscv64-musl": "4.63.5", + "@rollup/rollup-linux-s390x-gnu": "4.63.5", + "@rollup/rollup-linux-x64-gnu": "4.63.5", + "@rollup/rollup-linux-x64-musl": "4.63.5", + "@rollup/rollup-openbsd-x64": "4.63.5", + "@rollup/rollup-openharmony-arm64": "4.63.5", + "@rollup/rollup-win32-arm64-msvc": "4.63.5", + "@rollup/rollup-win32-ia32-msvc": "4.63.5", + "@rollup/rollup-win32-x64-gnu": "4.63.5", + "@rollup/rollup-win32-x64-msvc": "4.63.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.5.tgz", + "integrity": "sha512-LDopB3zuZM5Ux9TT2luNEBJW/tYbGU2g1d+VpKk6I+gSKDb+/7sYE6M225gRQt4RbMX6MSwMsVR/phdjVUgRLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/scheduler": { + "version": "0.23.2", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "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 + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..04ee8fb --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "yunvillage-frontend", + "private": true, + "version": "2.3.21", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "tsc --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "~5.6.3", + "vite": "^5.4.11" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..2d321a4 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,22 @@ +import { useEffect, useRef } from 'react' +import { Skeleton } from './ui/Skeleton' + +export default function App() { + const booted = useRef(false) + useEffect(() => { + if (booted.current) return + booted.current = true + // 骨架已提交 DOM 后再启动控制器:旧脚本假定 DOM 就绪(原脚本位于 body 末尾) + import('./controllers/legacy') + .then((m) => { + m.__legacyBoot() + return import('./controllers/auth') + }) + .then((a) => a.__authBoot()) + .catch((e) => { + console.error('前端启动失败:', e) + ;(window.__errs = window.__errs || []).push('BOOT: ' + ((e && (e as Error).message) || String(e))) + }) + }, []) + return +} diff --git a/frontend/src/controllers/auth.ts b/frontend/src/controllers/auth.ts new file mode 100644 index 0000000..4701a62 --- /dev/null +++ b/frontend/src/controllers/auth.ts @@ -0,0 +1,1446 @@ +// @ts-nocheck +/* 站点账号系统(由旧版 static/site_auth.js 逐字移植)。 + * 与主逻辑通过 window.* 钩子双向协作——与旧版一致,暂不模块化。 + * 全部副作用包在 __authBoot() 中——React 挂载骨架后由 App 调用。 */ +import { openModal as busOpen, closeModal as busClose, isModalOpen } from '../ui/modalBus' +export function __authBoot() { +/* ============================================================ + * 云村小助手 - 站点账号系统前端逻辑(按次计费版) + * 未经授权不得复制、修改或用于商业用途 + * ============================================================ */ +/* Promise.finally 老内核 polyfill(与 index.html 同一份标准实现—— + index.html 的脚本先加载,这里再兜底一次确保独立引用时也安全) */ + if (!Promise.prototype.finally) { + Promise.prototype.finally = function (cb) { + var P = this.constructor || Promise; + return this.then( + function (v) { return P.resolve(cb()).then(function () { return v; }); }, + function (e) { return P.resolve(cb()).then(function () { throw e; }); } + ); + }; + } + + var SITE_VERSION = '2.4.0'; // 与后端 APP_VERSION、index.html CHANGELOG 对齐 + var CHANGELOG = [ + { v: '2.4.0', date: '2026-09-21', items: ['修复:老内核浏览器(Chrome 57-62)操作按钮卡加载的问题', '安全:登录失败次数过多临时锁定;公网错误信息不再暴露内部细节', '优化:用户列表不再携带明文网易云 Cookie(按需单独查看)'] }, + { v: '2.1.0', date: '2026-08-28', items: ['计费改为按次计费:一首歌 1 次,歌单打包按歌曲数量扣减', '免费试用改为 3 次(3 首歌),注册页领取,每设备限一次', '卡密系统升级为次数卡,后台可管理用户次数'] }, + { v: '2.0.0', date: '2026-08-28', items: ['新增站点账号系统:强制登录、邮箱验证码注册', '新增管理后台:用户管理、全局Cookie、SMTP、日志、卡密、公告、数据库备份', '新增个人中心:剩余次数、卡密充值、公告查看'] }, + { v: '1.2.0', date: '2026-03-29', items: ['修复部分歌曲解析失败的问题', '优化界面交互体验'] } + ]; + + function $(s, root) { return (root || document).querySelector(s); } + function $$(s, root) { return Array.prototype.slice.call((root || document).querySelectorAll(s)); } + + /* ---------------- toast ---------------- */ + var toastWrap = null; + function toast(msg, type?: any) { + if (!toastWrap) { + toastWrap = document.createElement('div'); + toastWrap.style.cssText = 'position:fixed;top:18px;left:50%;transform:translateX(-50%);z-index:99999;display:flex;flex-direction:column;gap:8px;align-items:center;pointer-events:none;'; + document.body.appendChild(toastWrap); + } + var t = document.createElement('div'); + t.textContent = msg; + t.style.cssText = 'padding:10px 18px;border-radius:10px;font-size:13px;color:#fff;background:' + + (type === 'error' ? 'linear-gradient(135deg,#ff5f57,#e0483f)' : + type === 'success' ? 'linear-gradient(135deg,#34c759,#2aa64c)' : 'rgba(30,30,30,.92)') + + ';box-shadow:0 6px 24px rgba(0,0,0,.25);opacity:0;transform:translateY(-8px);transition:all .25s;max-width:80vw;'; + toastWrap.appendChild(t); + requestAnimationFrame(function () { t.style.opacity = '1'; t.style.transform = 'translateY(0)'; }); + setTimeout(function () { + t.style.opacity = '0'; t.style.transform = 'translateY(-8px)'; + setTimeout(function () { t.remove(); }, 300); + }, 2600); + } + + /* ---------------- 请求 ---------------- */ + function api(url, params) { + var body = Object.keys(params || {}).map(function (k) { + return encodeURIComponent(k) + '=' + encodeURIComponent(params[k] == null ? '' : params[k]); + }).join('&'); + // 30 秒超时:服务端挂起时不再无限等待(登录含省份查询约 5s 上限,留足余量; + // 无 AbortController 的老内核优雅降级为无超时) + var ctl = (typeof AbortController !== 'undefined') ? new AbortController() : null; + var timer = null; + if (ctl) timer = setTimeout(function () { try { ctl.abort(); } catch (e) {} }, 30000); + return fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, + body: body, + credentials: 'same-origin', + signal: ctl ? ctl.signal : undefined + }).then(function (r) { + // 双回调解包(不用 Promise.finally:兼容 Chromium 57+ 老内核) + return r.json().then(function (d) { if (timer) clearTimeout(timer); return d; }, + function (e) { if (timer) clearTimeout(timer); throw e; }); + }).catch(function () { + // 默认失败兜底——网络断/超时/非 JSON 响应不再产生 unhandledrejection + // 噪音(喂 fe_error 上报通道),统一转为标准失败对象走调用方既有的 toast + if (timer) clearTimeout(timer); + return { status: 0, success: false, message: '网络异常,请检查连接后重试' }; + }); + } + function apiGet(url) { + var ctl = (typeof AbortController !== 'undefined') ? new AbortController() : null; + var timer = null; + if (ctl) timer = setTimeout(function () { try { ctl.abort(); } catch (e) {} }, 15000); + // 注意:apiGet 不做默认失败兜底——/auth/me 的调用方依赖"失败保持原登录态" + //(拒绝会跳过 then 体,不清空已有 user),转成 resolved 失败对象会让 UI 掉成游客 + return fetch(url, { credentials: 'same-origin', signal: ctl ? ctl.signal : undefined }) + .then(function (r) { + return r.json().then(function (d) { if (timer) clearTimeout(timer); return d; }, + function (e) { if (timer) clearTimeout(timer); throw e; }); + }); + } + +/* ---------------- 设备标识 ---------------- */ +function getDeviceId() { + /* 与 index.html 的 getDeviceId 统一为同一键/同一值—— + 注册指纹与下载任务/心跳的设备号不再各走一套; + 内存缓存兜底 localStorage 不可写时同一页面内保持稳定。 */ + if (window.__siteDeviceIdCache) return window.__siteDeviceIdCache; + var KEY = 'netease_device_id'; + var d = ''; + try { d = localStorage.getItem(KEY) || ''; } catch (e) { } + if (!d) { + d = 'dev-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10); + try { localStorage.setItem(KEY, d); } catch (e) { } + } + window.__siteDeviceIdCache = d; + return d; +} + + /* ---------------- 状态 ---------------- */ + var user = null; + var durationText = ''; + var ann = null; + var smtpOk = false; // fail-closed——/auth/me 返回前不显示注册入口(防止未配置 SMTP 的窗口期误开) + var registerCredits = 3; // 注册赠送次数由 /auth/me 下发,文案不再硬编码 + + /* ---------------- 弹窗 ---------------- */ + var BUS_MODALS = ['changelog-modal', 'stats-modal', 'api-modal', 'cookie-modal', + 'login-modal', 'register-modal', 'announce-modal']; + function openModal(id) { + if (BUS_MODALS.indexOf(id) !== -1) { busOpen(id); return; } + var m = $('#' + id); if (m) m.classList.add('show'); + } + function closeModal(id) { + if (BUS_MODALS.indexOf(id) !== -1) { busClose(id); return; } + var m = $('#' + id); if (m) m.classList.remove('show'); + } + + /* ---------------- 导航栏 ---------------- */ + function renderAuthArea() { + // 同步全局登录状态,供主脚本 requireLogin() 使用 + window.SITE_LOGGED_IN = !!user; + // 未开放邮箱注册(SMTP 未配置)时,隐藏所有注册入口,避免用户注册卡死 + var showRegister = !user && smtpOk; + var regBtn = $('#btn-open-register'); + if (regBtn) regBtn.hidden = !showRegister; + ['goto-register', 'goto-register2'].forEach(function (id) { + var a = $('#' + id); if (a) a.hidden = !showRegister; + }); + // 两个弹窗(登录/注册)里的试用提示都要受管——原 querySelector 只命中第一个 + $$('.auth-trial-box').forEach(function (box) { box.hidden = !showRegister; }); + $$('.reg-gift-num').forEach(function (el) { el.textContent = registerCredits; }); + var guest = $('#auth-guest-area'), uarea = $('#auth-user-area'); + if (guest && uarea) { + if (user) { + guest.hidden = true; uarea.hidden = false; + var pill = $('#auth-duration-pill'); + if (pill) pill.textContent = '⏳ ' + (durationText || '0 次'); + var av = $('#user-avatar'); + if (av) { + av.textContent = (user.username || 'U').charAt(0).toUpperCase(); + av.title = user.username + (user.role === 'admin' ? '(管理员)' : ''); + } + } else { + guest.hidden = false; uarea.hidden = true; + } + } + var ucTab = $('#tab-usercenter'), uaTab = $('#tab-useradmin'); + if (ucTab) ucTab.hidden = !user; + if (uaTab) uaTab.hidden = !(user && user.role === 'admin'); + // 全局账号歌单仅管理员可见 + var gTab = $('#tab-global'); + if (gTab) gTab.hidden = !(user && user.role === 'admin'); + } + + /* ---------------- 会话 ---------------- */ + function refreshSession() { + return apiGet('/auth/me').then(function (data) { + var d = data.data || {}; + user = d.user || null; + durationText = d.duration_text || ''; + ann = d.announcement || null; + smtpOk = !!d.smtp_configured; + // 注册赠送次数动态下发(/auth/me),文案不再硬编码 + if (typeof d.register_credits === 'number') registerCredits = d.register_credits; + renderAuthArea(); + renderUserCenter(); + maybeShowAnnouncement(); + // 确认登录后再恢复下载任务(游客调任务接口会 401 误弹登录框) + if (user && typeof window.SITE_TASKS_BOOTSTRAP === 'function') { + try { window.SITE_TASKS_BOOTSTRAP(); } catch (e) {} + } + return user; + }).catch(function () { return null; }); + } + + // 全局暴露给主脚本 apiPost 的 401 统一处理(同一提示 5 秒内不重复弹出) + var _lastAuthToast = 0; + function authToastOnce(msg, type) { + var now = Date.now(); + if (now - _lastAuthToast < 5000) return; + _lastAuthToast = now; + toast(msg, type || 'error'); + } + window.handleAuthExpired = function (msg, expired) { + if (expired) { + authToastOnce(msg || '下载次数已用尽,请充值卡密'); + showExpiredModal(); + return; + } + // 会话失效时清掉内存里的旧用户状态再引导登录 + //(原实现保留旧 user,页面继续显示"已登录"却处处 401) + user = null; + durationText = ''; + renderAuthArea(); + renderUserCenter(); + authToastOnce(msg || '登录已失效,请重新登录'); + // 登录弹窗已打开且正处于找回/两步验证视图时,不复位视图—— + // 未登录期间后台任务轮询/心跳会持续吃 401 反复进入本函数,若每次都把 + // 弹窗拽回登录视图,管理员正在输入的验证码会被打断、票据被清空 + var _lm = document.getElementById('login-modal'); + var _fv = document.getElementById('forgot-view'); + var _a2v = document.getElementById('admin2fa-view'); + var _authViewOpen = _lm && _lm.classList.contains('show') && + ((_fv && !_fv.hidden) || (_a2v && !_a2v.hidden)); + if (!_authViewOpen) { + hideForgotView(); + hideAdmin2faView(); + } + // 会话失效弹登录窗前,先关掉可能叠开的注册弹窗(双遮罩错乱), + // 已 show 则短路——401 风暴(1.5s 轮询)期间不反复重开 + var _reg = document.getElementById('register-modal'); + if (_reg && _reg.classList.contains('show')) closeModal('register-modal'); + if (_lm && _lm.classList.contains('show')) return; + openModal('login-modal'); + }; + // 暴露给主脚本:扣费/任务结束后刷新顶栏剩余次数与登录态 + window.refreshSITECredits = refreshSession; + window.renderSITEUserCenter = renderUserCenter; + // 暴露当前剩余下载次数给主脚本预检(未登录 -1,不限 999999) + window.getSITECredits = function () { + if (!user) return -1; + var c = (typeof user.duration_seconds === 'number') ? user.duration_seconds : 0; + return c < 0 ? 999999 : c; + }; + + /* 次数用尽弹窗(下载触发,带「去充值」入口) */ + function showExpiredModal() { + // 先移除可能残留的旧实例(此前被 Escape 隐藏但未删 DOM 时,旧实现会直接 + // return,导致该弹窗从此再也弹不出来) + var old = $('#site-expired-modal'); + if (old) old.remove(); + var mask = document.createElement('div'); + mask.className = 'modal-mask show'; + mask.id = 'site-expired-modal'; + mask.setAttribute('data-dynamic', '1'); // Escape 关闭时连同 DOM 一并移除 + mask.innerHTML = + ''; + document.body.appendChild(mask); + mask.addEventListener('click', function (e) { if (e.target === mask) mask.remove(); }); + var go = $('#site-expired-go'), close = $('#site-expired-close'); + if (go) go.addEventListener('click', function () { + mask.remove(); + var t = $('#tab-usercenter'); + if (t) t.click(); + }); + if (close) close.addEventListener('click', function () { mask.remove(); }); + } + + function afterAuth(data) { + var d = data.data || {}; + user = d.user || null; + durationText = d.duration_text || ''; + renderAuthArea(); + renderUserCenter(); + // 登录成功后立即恢复本设备的进行中任务 + if (user && typeof window.SITE_TASKS_BOOTSTRAP === 'function') { + try { window.SITE_TASKS_BOOTSTRAP(); } catch (e) {} + } + } + + /* ---------------- 登录 / 注册弹窗 ---------------- */ + // 注册唯一入口——导航按钮与登录弹窗两处链接(全站仅有的 3 条打开 + // 路径)统一经此:未同意协议 → cookieGate('register') 弹「隐私与协议设置」 + // 注册模式(16 秒强制阅读计时)并中止;已同意 → 直接进注册弹窗。 + // 提交侧 doRegister 另有 fail-closed 兜底闸,程序化打开也绕不过。 + function openRegisterFlow() { + closeModal('login-modal'); // 先关登录弹窗,避免协议窗被压在下方 + var gate = window.SITE_COOKIE_GATE; + if (typeof gate !== 'function') { + // fail-closed:协议闸未就绪(主脚本异常)时不放行注册 + toast('页面协议组件未就绪,请刷新页面后重试', 'error'); + return; + } + if (!gate('register')) return; // 未同意:闸内已弹协议窗并提示 + openModal('register-modal'); + } + function bindAuthModal() { + var lo = $('#btn-open-login'), ro = $('#btn-open-register'); + if (lo) lo.addEventListener('click', function () { closeModal('register-modal'); hideForgotView(); hideAdmin2faView(); openModal('login-modal'); }); + if (ro) ro.addEventListener('click', function () { closeModal('login-modal'); openRegisterFlow(); }); + ['login-close', 'register-close', 'announce-close'].forEach(function (id) { + var b = $('#' + id); + if (b) b.addEventListener('click', function () { + if (id.indexOf('login') === 0 && window.resetAuthViews) window.resetAuthViews(); // 关闭侧复位 + closeModal(id.indexOf('login') === 0 ? 'login-modal' : id.indexOf('register') === 0 ? 'register-modal' : 'announce-modal'); + }); + }); + [['login-modal', 'login-close'], ['register-modal', 'register-close']].forEach(function (p) { + var m = $('#' + p[0]); + if (m) m.addEventListener('click', function (e) { + if (e.target === m) { + if (p[0] === 'login-modal' && window.resetAuthViews) window.resetAuthViews(); // 关闭侧复位 + closeModal(p[0]); + } + }); + }); + var g1 = $('#goto-register'), g2 = $('#goto-register2'); + if (g1) g1.addEventListener('click', function () { closeModal('login-modal'); openRegisterFlow(); }); + if (g2) g2.addEventListener('click', function () { closeModal('login-modal'); openRegisterFlow(); }); + var gl1 = $('#goto-login'); + if (gl1) gl1.addEventListener('click', function () { closeModal('register-modal'); hideForgotView(); hideAdmin2faView(); openModal('login-modal'); }); + // 找回密码入口/返回(只交付 DOM 漏了绑定) + var gf = $('#goto-forgot'); + if (gf) gf.addEventListener('click', showForgotView); + var fbl = $('#forgot-back-login'); + if (fbl) fbl.addEventListener('click', hideForgotView); + // 管理员两步验证视图的返回链接 + var a2b = $('#a2fa-back'); + if (a2b) a2b.addEventListener('click', hideAdmin2faView); + + // 注册即送 3 次,无需 Tab 切换 + } + + /* ---------------- 登录 ---------------- */ + function doLogin() { + var btn = $('#login-submit'); + var username = ($('#login-username') || {}).value || ''; + var password = ($('#login-password') || {}).value || ''; + if (!username || !password) { toast('请输入用户名/邮箱和密码', 'error'); return; } + btn.disabled = true; btn.textContent = '登录中...'; + api('/auth/login', { username: username, password: password }) + .then(function (data) { + if (data.status !== 200) throw new Error(data.message || '登录失败'); + // 管理员密码验证通过——转两步验证视图(服务端未建会话) + if (data.data && data.data.admin_2fa) { + showAdmin2faView(data.data.ticket, data.data.email_masked); + toast('密码验证通过,请完成邮箱验证', 'success'); + return; + } + afterAuth(data); + closeModal('login-modal'); + toast('登录成功,欢迎回来,' + (user ? user.username : ''), 'success'); + if (data.data && data.data.redirect === 'admin') { + var uaTab = $('#tab-useradmin'); + if (uaTab) uaTab.click(); + } else { + var ucTab = $('#tab-usercenter'); + if (ucTab) ucTab.click(); + } + }) + .catch(function (e) { toast(e.message || '登录失败', 'error'); }) + .finally(function () { btn.disabled = false; btn.textContent = '登 录'; }); + } + + /* ---------------- 注册 ---------------- */ + var _REG_EMAIL_RE = /^[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}$/; + var _REG_NAME_RE = /^[A-Za-z0-9_\u4e00-\u9fa5]{3,20}$/; + // 注册弹窗内常驻错误行(长文案 2.6s toast 看不完) + function regShowError(msg) { + toast(msg, 'error'); + var line = $('#reg-error-line'); + if (line) { line.textContent = msg; line.hidden = false; } + } + function regClearError() { + var line = $('#reg-error-line'); + if (line) { line.textContent = ''; line.hidden = true; } + } + function sendCode() { + var btn = $('#reg-send-code'); + var email = ($('#reg-email') || {}).value || ''; + if (!_REG_EMAIL_RE.test(email.trim())) { toast('邮箱格式不正确', 'error'); return; } + regClearError(); + btn.disabled = true; btn.textContent = '发送中...'; + api('/auth/send_code', { email: email, purpose: 'register' }) + .then(function (data) { + if (data.status !== 200) throw new Error(data.message || '发送失败'); + toast('验证码已发送,请查收邮箱', 'success'); + var left = 60; + window.__regCodeTimer = setInterval(function () { + left--; btn.textContent = left + 's'; + if (left <= 0) { clearInterval(window.__regCodeTimer); btn.textContent = '发送验证码'; btn.disabled = false; } + }, 1000); + }) + .catch(function (e) { + toast(e.message || '发送失败', 'error'); + btn.disabled = false; btn.textContent = '发送验证码'; + }); + } + + /* ---------------- 找回密码 ---------------- */ + // 只交付了找回视图 DOM 未实现 JS——此处补齐全链路。 + // 后端 /auth/forgot_password 已就绪:验证码原子消费、防枚举 + // 统一文案、login_seq 踢全会话、5 次/分/IP 限流;发码复用 /auth/send_code。 + function forgotShowError(msg) { + toast(msg, 'error'); + var line = $('#forgot-error-line'); + if (line) { line.textContent = msg; line.hidden = false; } + } + function forgotClearError() { + var line = $('#forgot-error-line'); + if (line) { line.textContent = ''; line.hidden = true; } + } + function forgotStopCodeTimer() { + clearInterval(window.__forgotCodeTimer || 0); + var btn = $('#forgot-send-code'); + if (btn && btn.disabled) { btn.disabled = false; btn.textContent = '发送验证码'; } + } + function showForgotView() { + var form = document.querySelector('#login-modal .auth-form'); + var fv = $('#forgot-view'); + if (!form || !fv) return; + form.classList.add('forgot-mode'); + fv.hidden = false; + forgotClearError(); + var em = $('#forgot-email'); + if (em) { try { em.focus(); } catch (e) {} } + } + function hideForgotView() { + var form = document.querySelector('#login-modal .auth-form'); + var fv = $('#forgot-view'); + if (form) form.classList.remove('forgot-mode'); + if (fv && !fv.hidden) fv.hidden = true; + forgotClearError(); + forgotStopCodeTimer(); + } + function doForgotSendCode() { + var btn = $('#forgot-send-code'); + var email = ($('#forgot-email') || {}).value || ''; + if (!_REG_EMAIL_RE.test(email.trim())) { forgotShowError('邮箱格式不正确'); return; } + forgotClearError(); + btn.disabled = true; btn.textContent = '发送中...'; + api('/auth/send_code', { email: email, purpose: 'reset' }) + .then(function (data) { + if (data.status !== 200) throw new Error(data.message || '发送失败'); + toast('验证码已发送,请查收邮箱', 'success'); + var left = 60; + window.__forgotCodeTimer = setInterval(function () { + left--; btn.textContent = left + 's'; + if (left <= 0) { clearInterval(window.__forgotCodeTimer); btn.textContent = '发送验证码'; btn.disabled = false; } + }, 1000); + }) + .catch(function (e) { + forgotShowError(e.message || '发送失败'); + btn.disabled = false; btn.textContent = '发送验证码'; + }); + } + function doForgotReset() { + var btn = $('#forgot-submit'); + var email = ($('#forgot-email') || {}).value || ''; + var code = ($('#forgot-code') || {}).value || ''; + var password = ($('#forgot-password') || {}).value || ''; + forgotClearError(); + if (!email || !code || !password) { forgotShowError('请填写完整找回信息'); return; } + if (!_REG_EMAIL_RE.test(email.trim())) { forgotShowError('邮箱格式不正确'); return; } + if (password.length < 8 || password.length > 128) { forgotShowError('新密码长度需在 8-128 位之间'); return; } + btn.disabled = true; btn.textContent = '重置中...'; + api('/auth/forgot_password', { email: email, code: code, password: password }) + .then(function (data) { + if (data.status !== 200) throw new Error(data.message || '重置失败'); + var fe = $('#forgot-email'), fc = $('#forgot-code'), fp = $('#forgot-password'); + if (fe) fe.value = ''; if (fc) fc.value = ''; if (fp) fp.value = ''; + forgotStopCodeTimer(); + hideForgotView(); + toast('密码已重置,请用新密码登录', 'success'); + }) + .catch(function (e) { forgotShowError(e.message || '重置失败'); }) + .finally(function () { btn.disabled = false; btn.textContent = '重置密码'; }); + } + + /* ---------------- 管理员两步验证 ---------------- + * Step1 用户名+密码 → 服务端签发票据(不建会话);本视图完成 + * Step2 绑定邮箱比对(触发发码)与 Step3 验证码登录。普通用户不经过此流程。 */ + var _a2faTicket = ''; + function a2faShowError(msg) { + toast(msg, 'error'); + var line = $('#a2fa-error-line'); + if (line) { line.textContent = msg; line.hidden = false; } + } + function a2faClearError() { + var line = $('#a2fa-error-line'); + if (line) { line.textContent = ''; line.hidden = true; } + } + function a2faStopTimer() { + clearInterval(window.__a2faTimer || 0); + var btn = $('#a2fa-send'); + if (btn && btn.disabled) { btn.disabled = false; btn.textContent = '发送验证码'; } + } + function showAdmin2faView(ticket, emailMask) { + var form = document.querySelector('#login-modal .auth-form'); + var v = $('#admin2fa-view'); + if (!form || !v) return; + _a2faTicket = ticket || ''; + var mask = $('#a2fa-email-mask'); + if (mask) mask.textContent = emailMask || '-'; + // 清掉上次会话残留的输入(错码/旧邮箱不再被原样重复提交) + var ae = $('#a2fa-email'), ac = $('#a2fa-code'); + if (ae) ae.value = ''; + if (ac) ac.value = ''; + form.classList.add('admin2fa-mode'); + v.hidden = false; + a2faClearError(); + a2faStopTimer(); + var em = $('#a2fa-email'); + if (em) { try { em.focus(); } catch (e) {} } + } + function hideAdmin2faView() { + var form = document.querySelector('#login-modal .auth-form'); + var v = $('#admin2fa-view'); + if (form) form.classList.remove('admin2fa-mode'); + if (v && !v.hidden) v.hidden = true; + _a2faTicket = ''; + a2faClearError(); + a2faStopTimer(); + } + // 关闭侧统一复位——登录弹窗的 X/遮罩/Escape/doLogout 关闭路径都调用, + // 不再只依赖"打开侧纪律";同时供 index.html 的 Escape 通用分支使用 + window.resetAuthViews = function () { hideForgotView(); hideAdmin2faView(); }; + function doA2faSendCode() { + var btn = $('#a2fa-send'); + var email = ($('#a2fa-email') || {}).value || ''; + if (!email.trim()) { a2faShowError('请输入管理员绑定邮箱'); return; } + a2faClearError(); + btn.disabled = true; btn.textContent = '发送中...'; + api('/auth/admin_login/email', { ticket: _a2faTicket, email: email }) + .then(function (data) { + if (data.status === 401) { + // 先复位视图再提示——错误行随视图隐藏,此处 toast 是唯一可达提示 + hideAdmin2faView(); + a2faShowError(data.message || '请重新登录'); + return; + } + if (data.status !== 200) throw new Error(data.message || '发送失败'); + toast('验证码已发送至管理员邮箱,请查收', 'success'); + var ci = $('#a2fa-code'); + if (ci) { try { ci.focus(); } catch (e) {} } + var left = 60; + window.__a2faTimer = setInterval(function () { + left--; btn.textContent = left + 's'; + if (left <= 0) { clearInterval(window.__a2faTimer); btn.textContent = '发送验证码'; btn.disabled = false; } + }, 1000); + }) + .catch(function (e) { + a2faShowError(e.message || '发送失败'); + btn.disabled = false; btn.textContent = '发送验证码'; + }); + } + function doA2faVerify() { + var btn = $('#a2fa-submit'); + var email = ($('#a2fa-email') || {}).value || ''; + var code = ($('#a2fa-code') || {}).value || ''; + a2faClearError(); + if (!email.trim()) { a2faShowError('请输入管理员绑定邮箱'); return; } + if (!code.trim()) { a2faShowError('请先获取并输入邮箱验证码'); return; } + btn.disabled = true; btn.textContent = '验证中...'; + api('/auth/admin_login/verify', { ticket: _a2faTicket, code: code }) + .then(function (data) { + if (data.status === 401) { + // 先复位视图再提示(同上,错误行随视图隐藏) + hideAdmin2faView(); + a2faShowError(data.message || '请重新登录'); + return; + } + if (data.status !== 200) throw new Error(data.message || '验证失败'); + a2faClearError(); + afterAuth(data); + closeModal('login-modal'); + toast('登录成功,欢迎回来,' + (user ? user.username : ''), 'success'); + var uaTab = $('#tab-useradmin'); + if (uaTab) uaTab.click(); + }) + .catch(function (e) { a2faShowError(e.message || '验证失败'); }) + .finally(function () { btn.disabled = false; btn.textContent = '验证并登录'; }); + } + + function doRegister() { + var btn = $('#reg-submit'); + var username = ($('#reg-username') || {}).value || ''; + var password = ($('#reg-password') || {}).value || ''; + var email = ($('#reg-email') || {}).value || ''; + var code = ($('#reg-code') || {}).value || ''; + regClearError(); + // 提交侧兜底闸(入口已统一走 openRegisterFlow,此处防任何程序化 + // 打开路径绕过)。fail-closed:协议闸缺失视为未同意。注册语境只查协议同意 + // (功能性 Cookie 不拦注册,拦解析/下载时另有提示);未同意时闸内已自动 + // 弹「隐私与协议设置」注册模式并 toast,此处不再重复提示 + if (typeof window.SITE_COOKIE_GATE !== 'function') { + toast('页面协议组件未就绪,请刷新页面后重试', 'error'); + return; + } + if (!window.SITE_COOKIE_GATE('register')) return; + // 前端即时校验(口径与后端一致,省一次网络往返) + if (!username || !password || !email || !code) { regShowError('请填写完整注册信息'); return; } + if (!_REG_NAME_RE.test(username)) { regShowError('用户名需 3-20 位,仅支持中文、字母、数字、下划线,且不能纯下划线'); return; } + if (!_REG_EMAIL_RE.test(email.trim())) { regShowError('邮箱格式不正确'); return; } + if (password.length < 8 || password.length > 128) { regShowError('密码长度需在 8-128 位之间'); return; } + btn.disabled = true; btn.textContent = '注册中...'; + api('/auth/register', { + username: username, password: password, email: email, code: code, + device_id: getDeviceId(), agree: 1 + }) + .then(function (data) { + if (data.status !== 200) throw new Error(data.message || '注册失败'); + regClearError(); + afterAuth(data); + closeModal('register-modal'); + toast('注册成功,已自动登录,赠送的下载次数可在个人中心查看', 'success'); + var ucTab = $('#tab-usercenter'); + if (ucTab) ucTab.click(); + }) + .catch(function (e) { regShowError(e.message || '注册失败'); }) + .finally(function () { btn.disabled = false; btn.textContent = '注 册'; }); + } + + /* ---------------- 退出登录 ---------------- */ + function doLogout() { + api('/auth/logout', {}).then(function () { + user = null; durationText = ''; + renderAuthArea(); + renderUserCenter(); // 同步个人中心 gate/content 显隐,与其他登出路径一致 + if (window.resetAuthViews) window.resetAuthViews(); // 登出复位登录弹窗视图 + closeModal('register-modal'); closeModal('login-modal'); + // 退出连带清除个人网易云登录(index.html 注册的钩子: + // 清 localStorage 的个人 Cookie/解析账号选择并复位账号栏) + if (typeof window.SITE_ON_LOGOUT === 'function') { + try { window.SITE_ON_LOGOUT(); } catch (e) {} + } + var ex = $('#site-expired-modal'); if (ex) ex.remove(); + // 切回搜索页,避免停留在个人中心 / 管理后台 / 管理员歌单 + var search = document.querySelector('.tab[data-tab="search"]') || $('#tab-search'); + if (search) search.click(); + toast('已退出登录', 'success'); + }); + } + + /* ---------------- 个人中心 ---------------- */ + function fmtTime(ts) { + if (!ts) return '-'; + var d = new Date(ts * 1000); + return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + + String(d.getDate()).padStart(2, '0'); + } + + function renderUserCenter() { + var el; + // 登录态切换:隐藏 🔒 占位、显示个人中心内容 + var gate = $('#uc-gate'), content = $('#uc-content'); + if (gate && content) { + gate.hidden = !!user; + content.hidden = !user; + } + if (!user) return; + if ((el = $('#uc-avatar'))) el.textContent = (user.username || 'U').charAt(0).toUpperCase(); + if ((el = $('#uc-username'))) el.textContent = user ? user.username : '-'; + if ((el = $('#uc-role'))) el.textContent = user ? (user.role === 'admin' ? '管理员 · 不限次数' + : (typeof user.duration_seconds === 'number' && user.duration_seconds < 0 ? 'VIP · 不限次数' : '普通用户')) : '-'; + if ((el = $('#uc-duration'))) el.textContent = user ? (durationText || '0 次') : '-'; + if ((el = $('#uc-email'))) el.textContent = (user && user.email) || '未绑定'; + if ((el = $('#uc-created'))) el.textContent = user ? fmtTime(user.created_at) : '-'; + var cl = $('#uc-changelog'); + if (cl) { + cl.innerHTML = CHANGELOG.map(function (v) { + return '
v' + v.v + ' ' + + v.date + '
' + + v.items.map(function (i) { return '· ' + i; }).join('
') + '
'; + }).join('') + '
当前版本 v' + SITE_VERSION + + '
'; + } + } + + function doRedeem() { + var input = $('#uc-card-input'); + var code = (input && input.value || '').trim(); + if (!code) { toast('请输入卡密', 'error'); return; } + var btn = $('#uc-card-redeem'); + btn.disabled = true; + api('/auth/redeem', { code: code }) + .then(function (data) { + if (data.status !== 200) throw new Error(data.message || '兑换失败'); + durationText = (data.data && data.data.duration_text) || durationText; + // 同步更新内存余额(批量下载预检读的是 user.duration_seconds, + // 原实现只改显示文字,预检仍用充值前的旧值) + if (user && data.data && typeof data.data.duration_seconds === 'number') { + user.duration_seconds = data.data.duration_seconds; + return null; + } + return refreshSession(); + }) + .then(function () { + renderAuthArea(); renderUserCenter(); + if (input) input.value = ''; + toast('充值成功', 'success'); + }) + .catch(function (e) { toast(e.message || '兑换失败', 'error'); }) + .finally(function () { btn.disabled = false; }); + } + + + /* ---------------- 公告 ---------------- */ + function maybeShowAnnouncement(force) { + // 手动"查看公告"(force=true)无条件展示; + // 自动弹出仍按 popup_mode 与 once 已读版本判断 + if (!force && (!ann || !ann.content || ann.popup_mode === 'off')) return; + if (!force && ann && ann.popup_mode === 'once') { + // once 模式的已读判断:登录用户比对服务端 ann_seen; + // 游客比对本地 localStorage(原实现只查 user,游客每次刷新都重复弹窗) + if (user && user.ann_seen && String(user.ann_seen) === String(ann.updated_at)) return; + if (!user) { + var guestSeen = ''; + try { guestSeen = localStorage.getItem('netease_ann_once_seen') || ''; } catch (e) {} + if (guestSeen === String(ann.updated_at)) return; + } + } + if (!ann || !ann.content) { toast('当前没有公告内容', 'info'); return; } + var t = $('#announce-title'), c = $('#announce-content'); + if (t) t.textContent = ann.title || '系统公告'; + if (c) c.innerHTML = String(ann.content).split('\n').map(function (l) { return escapeHtml(l); }).join('
'); + // 首访 Cookie 协议窗显示中不叠加公告弹窗(双遮罩错乱); + // 跳过时不记已读,下次进入页面再弹 + var _cm = document.getElementById('cookie-modal'); + if (_cm && _cm.classList.contains('show')) return; + openModal('announce-modal'); + if (user) api('/auth/ann_seen', {}); + else { try { localStorage.setItem('netease_ann_once_seen', String(ann.updated_at)); } catch (e) {} } + } + + function escapeHtml(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); + } + + /* ---------------- 管理后台 ---------------- */ + var uaLoaded = false; + var uaLogsType = 'success'; + + function uaShowGate() { + var gate = $('#ua-gate'), content = $('#ua-content'); + if (!gate || !content) return; + var isAdmin = !!(user && user.role === 'admin'); + gate.hidden = isAdmin; + content.hidden = !isAdmin; + if (isAdmin && !uaLoaded) { uaLoaded = true; uaLoadUsers(); uaLoadCookieStatus(); uaLoadSmtp(); uaLoadLogs(); uaLoadCards(); uaLoadAnn(); uaLoadDb(); } + } + + function uaBindSide() { + $$('#admin-side .admin-side-item[data-view]').forEach(function (item) { + // 侧栏项补键盘语义(同型——tabindex/role + Enter/空格激活) + item.setAttribute('tabindex', '0'); + item.setAttribute('role', 'button'); + item.addEventListener('click', function () { + $$('#admin-side .admin-side-item').forEach(function (i) { i.classList.remove('active'); }); + item.classList.add('active'); + $$('.admin-view').forEach(function (v) { v.classList.toggle('active', v.getAttribute('data-aview') === item.getAttribute('data-view')); }); + }); + item.addEventListener('keydown', function (e) { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); item.click(); } + }); + }); + var back = $('#ua-back'); + if (back) back.addEventListener('click', function () { + var t = $('#tab-useradmin'); if (t) t.classList.remove('active'); + var search = document.querySelector('.tab[data-tab="search"]'); + if (search) search.click(); + }); + var lo = $('#ua-logout'); + if (lo) lo.addEventListener('click', doLogout); + // 返回/退出两个无 data-view 的侧栏项同样补键盘语义 + [back, lo].forEach(function (el) { + if (!el) return; + el.setAttribute('tabindex', '0'); + el.setAttribute('role', 'button'); + el.addEventListener('keydown', function (e) { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); el.click(); } + }); + }); + /* ---- 播放 API 密钥 ---- */ + var pkShow = $('#ua-playkey-show'); + var pkInput = $('#ua-playkey-value'); + function pkCopyToClipboard(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(function () { toast('密钥已复制', 'success'); }, + function () { toast('复制失败,请手动选择文本复制', 'error'); }); + } else { + toast('复制失败,请点击「显示密钥」后手动复制', 'error'); + } + } + if (pkShow) pkShow.addEventListener('click', function () { + if (pkShow.dataset.state === 'shown') { + pkInput.value = ''; + pkInput.placeholder = '点击「显示密钥」查看'; + pkShow.textContent = '显示密钥'; + pkShow.dataset.state = ''; + return; + } + api('/admin/site/play_key/get', {}).then(function (d) { + if (d.status !== 200) { toast(d.message || '获取失败', 'error'); return; } + pkInput.value = d.data.key; + pkShow.textContent = '隐藏'; + pkShow.dataset.state = 'shown'; + }); + }); + var pkCopy = $('#ua-playkey-copy'); + if (pkCopy) pkCopy.addEventListener('click', function () { + api('/admin/site/play_key/get', {}).then(function (d) { + if (d.status !== 200) { toast(d.message || '获取失败', 'error'); return; } + pkCopyToClipboard(d.data.key); + }); + }); + var pkRotate = $('#ua-playkey-rotate'); + if (pkRotate) pkRotate.addEventListener('click', function () { + if (!confirm('刷新后旧密钥立即全部失效(外部旧地址将打不开),确定继续?')) return; + api('/admin/site/play_key/rotate', {}).then(function (d) { + if (d.status !== 200) { toast(d.message || '刷新失败', 'error'); return; } + pkInput.value = d.data.key; + pkShow.textContent = '隐藏'; + pkShow.dataset.state = 'shown'; + toast('密钥已刷新,旧密钥已全部失效', 'success'); + }); + }); + } + + /* ---- 用户管理 ---- */ + function uaInjectUserToolbar() { + var refresh = $('#ua-users-refresh'); + if (!refresh || $('#ua-user-create')) return; + var btn = document.createElement('button'); + btn.className = 'btn btn-sm btn-primary'; + btn.id = 'ua-user-create'; + btn.type = 'button'; + btn.textContent = '➕ 添加用户'; + btn.addEventListener('click', uaCreateUserModal); + refresh.parentNode.insertBefore(btn, refresh); + } + + function uaCreateUserModal() { + var old = $('#site-create-user-modal'); + if (old) old.remove(); + var mask = document.createElement('div'); + mask.className = 'modal-mask show'; + mask.id = 'site-create-user-modal'; + mask.setAttribute('data-dynamic', '1'); // Escape 关闭时连同 DOM 一并移除 + mask.innerHTML = + ''; + document.body.appendChild(mask); + mask.addEventListener('click', function (e) { if (e.target === mask) mask.remove(); }); + var cancel = $('#cnu-cancel'); + if (cancel) cancel.addEventListener('click', function () { mask.remove(); }); + var submit = $('#cnu-submit'); + if (submit) submit.addEventListener('click', function () { + var body = { + username: ($('#cnu-username') || {}).value || '', + password: ($('#cnu-password') || {}).value || '', + email: ($('#cnu-email') || {}).value || '', + count: ($('#cnu-count') || {}).value || 0 + }; + if (!body.username || body.password.length < 8) { toast('请填写用户名和至少 8 位密码', 'error'); return; } + submit.disabled = true; + api('/admin/site/user/create', body).then(function (d) { + toast(d.message || (d.status === 200 ? '创建成功' : '创建失败'), d.status === 200 ? 'success' : 'error'); + if (d.status === 200) { mask.remove(); uaLoadUsers(); } + }).finally(function () { submit.disabled = false; }); + }); + } + + function uaLoadUsers() { + uaInjectUserToolbar(); + api('/admin/site/users', {}).then(function (data) { + var body = $('#ua-users-body'); + if (!body) return; + if (data.status !== 200) { body.innerHTML = '' + escapeHtml(data.message || '加载失败') + ''; return; } + var users = (data.data && data.data.users) || []; + var cnt = $('#ua-users-count'); + if (cnt) cnt.textContent = users.length; + if (!users.length) { body.innerHTML = '暂无用户'; return; } + body.innerHTML = users.map(function (u) { + var isAdmin = u.role === 'admin'; + var disabled = (u.status === 0); + var banned = (u.status === 2); + // 数值字段同过转义(纵深防御——后端口径若变为字符串 + // 不再形成管理后台存储型 XSS 面,与同表 username/email 口径对齐) + var uid = escapeHtml(String(u.id)); + var dur = escapeHtml(String(u.duration_seconds)); + var cred = u.duration_seconds < 0 ? '不限' : (dur + ' 次'); + var dev = [u.register_ip, u.trial_device].filter(Boolean).join(' · ') || '-'; // IP 优先显示 + if (u.last_province) dev += ' · ' + u.last_province; + return '' + + '' + uid + '' + + '' + escapeHtml(u.username) + + (banned ? ' 已封禁' : '') + + (disabled ? ' 已禁用' : '') + '' + + '' + escapeHtml(u.email || '-') + '' + + '' + (isAdmin ? '管理员' : '用户') + '' + + '' + cred + '' + + '' + escapeHtml(dev) + '' + + '' + + '' + + ' ' + + (isAdmin ? '' : + ' ' + + ' ' + + ' ' + + '') + + ''; + }).join(''); + $$('[data-uedit]', body).forEach(function (b) { + b.addEventListener('click', function () { + var v = prompt('设置该用户的剩余次数(-1 表示不限):', b.getAttribute('data-cur')); + if (v === null) return; + api('/admin/site/user/duration', { id: b.getAttribute('data-uedit'), count: v }) + .then(function (d) { toast(d.message || (d.status === 200 ? '已修改' : '修改失败'), d.status === 200 ? 'success' : 'error'); if (d.status === 200) uaLoadUsers(); }); + }); + }); + // 管理员自助改密(需验证旧密码;改完其他设备全部下线) + $$('[data-uself]', body).forEach(function (b) { + b.addEventListener('click', function () { + var oldpwd = prompt('请输入当前密码:', ''); + if (oldpwd === null) return; + var newpwd = prompt('请输入新密码(至少 8 位):', ''); + if (newpwd === null) return; + api('/admin/site/user/reset_password', { + id: b.getAttribute('data-uself'), password: newpwd, old_password: oldpwd + }).then(function (d) { + toast(d.message || (d.status === 200 ? '密码已修改' : '修改失败'), d.status === 200 ? 'success' : 'error'); + }); + }); + }); + $$('[data-upwd]', body).forEach(function (b) { + b.addEventListener('click', function () { + var v = prompt('为用户「' + b.getAttribute('data-name') + '」设置新密码(至少 8 位):', ''); + if (v === null) return; + api('/admin/site/user/reset_password', { id: b.getAttribute('data-upwd'), password: v }) + .then(function (d) { toast(d.message || (d.status === 200 ? '已重置' : '重置失败'), d.status === 200 ? 'success' : 'error'); }); + }); + }); + $$('[data-utoggle]', body).forEach(function (b) { + b.addEventListener('click', function () { + api('/admin/site/user/toggle', { id: b.getAttribute('data-utoggle') }) + .then(function (d) { toast(d.message || '操作成功', d.status === 200 ? 'success' : 'error'); if (d.status === 200) uaLoadUsers(); }); + }); + }); + $$('[data-uban]', body).forEach(function (b) { + b.addEventListener('click', function () { + var name = b.getAttribute('data-name'); + if (b.textContent === '封禁') { + var reason = prompt('封禁用户「' + name + '」\n请输入封禁原因(将展示给用户):', '违规使用,封禁处理'); + if (reason === null) return; + api('/admin/site/user/ban', { id: b.getAttribute('data-uban'), action: 'ban', reason: reason }) + .then(function (d) { toast(d.message || (d.status === 200 ? '已封禁' : '操作失败'), d.status === 200 ? 'success' : 'error'); if (d.status === 200) uaLoadUsers(); }); + } else { + if (!window.confirm('解封用户「' + name + '」?解封后可正常登录使用。')) return; + api('/admin/site/user/ban', { id: b.getAttribute('data-uban'), action: 'unban' }) + .then(function (d) { toast(d.message || (d.status === 200 ? '已解封' : '操作失败'), d.status === 200 ? 'success' : 'error'); if (d.status === 200) uaLoadUsers(); }); + } + }); + }); + $$('[data-udel]', body).forEach(function (b) { + b.addEventListener('click', function () { + if (!window.confirm('确定删除用户「' + b.getAttribute('data-name') + '」?')) return; + api('/admin/site/user/delete', { id: b.getAttribute('data-udel') }) + .then(function (d) { toast(d.message || '已删除', d.status === 200 ? 'success' : 'error'); uaLoadUsers(); }); + }); + }); + $$('[data-ucookie]', body).forEach(function (b) { + b.addEventListener('click', function () { + api('/admin/site/user/cookie', { username: b.getAttribute('data-ucookie') }) + .then(function (d) { + if (d.status !== 200) { toast(d.message || '获取失败', 'error'); return; } + var ck = (d.data && d.data.cookie) || ''; + if (!ck) { toast('该用户未登录过网易云账号', 'info'); return; } + prompt('用户网易云 Cookie(Ctrl+C 复制):', ck); + }); + }); + }); + }); + } + + /* ---- 全局 Cookie ---- */ + var uaQrTimer = null, uaUnikey = '', uaQrGen = 0; // 轮询代次令牌(删除 Cookie 后作废旧轮询) + function uaLoadCookieStatus() { + api('/admin/cookie/status', {}).then(function (data) { + var el = $('#ua-cookie-status'); + if (!el) return; + if (data.status !== 200) { el.textContent = data.message || '获取失败'; return; } + var info = data.data || {}; + el.textContent = (info.is_valid ? '✅ 有效' : '❌ 未登录') + + (info.cookie_count != null ? ' · Cookie ' + info.cookie_count + ' 个' : ''); + }); + } + function uaQrCreate() { + api('/admin/qr/create', {}).then(function (data) { + if (data.status !== 200) { toast(data.message || '生成二维码失败', 'error'); return; } + var d = data.data || {}; + uaUnikey = d.unikey || ''; + var box = $('#ua-qr-box'), tip = $('#ua-qr-tip'); + if (d.qr_svg) { + var uri = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(d.qr_svg))); + box.innerHTML = '二维码'; + } + if (tip) tip.textContent = '请使用网易云音乐手机 APP 扫码'; + if (uaQrTimer) clearInterval(uaQrTimer); + uaQrGen++; // 新轮询代次 + uaQrTimer = setInterval(uaQrPoll, 3000); + }); + } + function uaQrPoll() { + if (!uaUnikey) { if (uaQrTimer) clearInterval(uaQrTimer); return; } + var myGen = uaQrGen; // 本次轮询所属代次 + api('/admin/qr/check', { unikey: uaUnikey }).then(function (data) { + if (myGen !== uaQrGen) return; // 期间已删除 Cookie/重开扫码,丢弃迟到响应 + var tip = $('#ua-qr-tip'); + if (data.status !== 200) { + if (uaQrTimer) clearInterval(uaQrTimer); + if (tip) tip.textContent = data.message || '检查失败'; + return; + } + var st = data.data && data.data.status; + if (st === 'waiting') { if (tip) tip.textContent = '等待扫码...'; } + else if (st === 'scanned') { if (tip) tip.textContent = '✅ 已扫码,请在手机上确认'; } + else if (st === 'success') { + if (uaQrTimer) clearInterval(uaQrTimer); + if (tip) tip.textContent = '🎉 登录成功,Cookie 已保存'; + toast('全局账号登录成功', 'success'); + uaLoadCookieStatus(); + } else { + if (uaQrTimer) clearInterval(uaQrTimer); + if (tip) tip.textContent = '二维码已过期,请重新生成'; + } + }); + } + function uaCookieDelete() { + if (!window.confirm('确定删除全局 Cookie?删除后需重新扫码登录。')) return; + api('/admin/cookie/delete', {}).then(function (data) { + // 作废在途扫码轮询——迟到的 803 成功响应不得把刚删除的 Cookie 写回 + if (uaQrTimer) clearInterval(uaQrTimer); + uaUnikey = ''; + uaQrGen++; + toast(data.message || '已删除', data.status === 200 ? 'success' : 'error'); + var box = $('#ua-qr-box'), tip = $('#ua-qr-tip'); + if (box) box.innerHTML = ''; + if (tip) tip.textContent = '点击下方按钮生成二维码'; + uaLoadCookieStatus(); + }); + } + + /* ---- SMTP ---- */ + function uaLoadSmtp() { + api('/admin/site/smtp/get', {}).then(function (data) { + if (data.status !== 200) return; + var d = data.data || {}; + if ($('#ua-smtp-host')) $('#ua-smtp-host').value = d.host || ''; + if ($('#ua-smtp-port')) $('#ua-smtp-port').value = d.port || 587; + if ($('#ua-smtp-sender')) $('#ua-smtp-sender').value = d.sender || ''; + }); + } + function uaSaveSmtp() { + api('/admin/site/smtp/save', { + host: ($('#ua-smtp-host') || {}).value || '', + port: ($('#ua-smtp-port') || {}).value || 587, + sender: ($('#ua-smtp-sender') || {}).value || '', + password: ($('#ua-smtp-pass') || {}).value || '' + }).then(function (d) { + toast(d.message || '已保存', d.status === 200 ? 'success' : 'error'); + var p = $('#ua-smtp-pass'); if (p) p.value = ''; + }); + } + function uaTestSmtp() { + var to = ($('#ua-smtp-test-to') || {}).value || ''; + if (!to) { toast('请填写测试收件邮箱', 'error'); return; } + var btn = $('#ua-smtp-test'); + btn.disabled = true; + api('/admin/site/smtp/test', { to: to }).then(function (d) { + toast(d.message || '已发送', d.status === 200 ? 'success' : 'error'); + }).finally(function () { btn.disabled = false; }); + } + + /* ---- 日志 ---- */ + var uaLogPage = 1; + function uaLoadLogs() { + api('/admin/site/logs', { type: uaLogsType, page: uaLogPage }).then(function (data) { + var box = $('#ua-log-list'); + if (!box) return; + if (data.status !== 200) { box.textContent = data.message || '加载失败'; return; } + var logs = (data.data && data.data.logs) || []; + var total = (data.data && data.data.total) || 0; + var pages = (data.data && data.data.pages) || 1; + uaLogPage = (data.data && data.data.page) || 1; + window._uaLogPages = pages; // next 按钮按真实页数封顶 + var el; + if ((el = $('#ua-log-page'))) el.textContent = uaLogPage; + if ((el = $('#ua-log-pages'))) el.textContent = pages; + if ((el = $('#ua-log-total'))) el.textContent = total; + if (!logs.length) { box.textContent = '暂无日志'; return; } + box.innerHTML = logs.map(function (l) { + return '
[' + escapeHtml(l.time) + '] ' + escapeHtml(l.username || '-') + + ' · ' + escapeHtml(l.action) + ' · IP ' + escapeHtml(l.ip) + + (l.detail ? ' · ' + escapeHtml(l.detail) : '') + '
'; + }).join(''); + }); + } + + function uaClearLogs() { + var name = uaLogsType === 'fail' ? '失败' : '成功'; + if (!window.confirm('确定清除全部「' + name + '」日志?该操作不可恢复。')) return; + api('/admin/site/logs/clear', { type: uaLogsType }).then(function (d) { + toast(d.message || (d.status === 200 ? '已清除' : '清除失败'), d.status === 200 ? 'success' : 'error'); + uaLogPage = 1; + uaLoadLogs(); + }); + } + + /* ---- 卡密 ---- */ + function uaGenCards() { + var creditsVal = ($('#ua-card-minutes') || {}).value || '10'; + if (creditsVal === 'custom') { + creditsVal = ($('#ua-card-custom') || {}).value || '0'; + if (!(parseInt(creditsVal, 10) > 0)) { toast('请填写自定义次数(正整数)', 'error'); return; } + } + api('/admin/site/cards/generate', { + count: ($('#ua-card-count') || {}).value || 1, + credits: creditsVal, + prefix: ($('#ua-card-prefix') || {}).value || 'VIP', + note: ($('#ua-card-note') || {}).value || '', + valid_days: ($('#ua-card-days') || {}).value || 0 + }).then(function (data) { + if (data.status !== 200) { toast(data.message || '生成失败', 'error'); return; } + var box = $('#ua-card-codes'); + if (box) { + box.hidden = false; + box.textContent = (data.data.codes || []).join('\n'); + } + toast('已生成 ' + (data.data.count || 0) + ' 张卡密(' + (data.data.credits || 0) + ' 次/张),已复制到文本框,请及时保存', 'success'); + uaLoadCards(0); + }); + } + // 卡密管理操作列(禁用/启用/删除)+ 分页(后端已支持, + // 不分页则超出最新 100 张的旧卡密在界面上永远管理不到) + var _uaCardPage = 0; + var _uaCardTotal = 0; + var UA_CARD_PAGE_SIZE = 100; + function uaCardAction(act, code) { + if (!code) return; + if (act === 'delete' && !window.confirm('确定删除卡密 ' + code.slice(0, 4) + '**** 吗?删除后不可恢复。')) return; + var url = act === 'delete' ? '/admin/site/cards/delete' : '/admin/site/cards/disable'; + var body = { code: code, disabled: act === 'enable' ? '0' : '1' }; + api(url, body).then(function (data) { + toast(data.message || (data.status === 200 ? '操作成功' : '操作失败'), + data.status === 200 ? 'success' : 'error'); + if (data.status === 200) uaLoadCards(); + }); + } + function _uaRenderCardPager() { + var pager = $('#ua-cards-pager'); + if (!pager) return; + if (_uaCardTotal <= 0) { pager.hidden = true; return; } + var pages = Math.max(1, Math.ceil(_uaCardTotal / UA_CARD_PAGE_SIZE)); + pager.hidden = false; + pager.textContent = ''; + var prev = document.createElement('button'); + prev.className = 'btn btn-sm'; prev.type = 'button'; prev.textContent = '上一页'; + prev.disabled = _uaCardPage <= 0; + prev.addEventListener('click', function () { uaLoadCards(_uaCardPage - 1); }); + var next = document.createElement('button'); + next.className = 'btn btn-sm'; next.type = 'button'; next.textContent = '下一页'; + next.disabled = _uaCardPage >= pages - 1; + next.addEventListener('click', function () { uaLoadCards(_uaCardPage + 1); }); + var info = document.createElement('span'); + info.className = 'hint'; + info.textContent = '第 ' + (_uaCardPage + 1) + ' / ' + pages + ' 页 · 共 ' + _uaCardTotal + ' 张'; + pager.appendChild(prev); + pager.appendChild(info); + pager.appendChild(next); + } + function uaLoadCards(page) { + if (typeof page === 'number') _uaCardPage = Math.max(0, page); + api('/admin/site/cards/list', { limit: UA_CARD_PAGE_SIZE, offset: _uaCardPage * UA_CARD_PAGE_SIZE }) + .then(function (data) { + var body = $('#ua-cards-body'); + if (!body) return; + if (data.status !== 200) { body.innerHTML = '加载失败'; return; } + var cards = (data.data && data.data.cards) || []; + _uaCardTotal = (data.data && data.data.total) || 0; + if (!cards.length) { + body.innerHTML = '暂无卡密'; + _uaRenderCardPager(); + return; + } + body.innerHTML = cards.map(function (c) { + var st, act; + if (c.used_by) { + st = '已使用'; + act = '—'; // 兑换凭证保留,不给删除/禁用 + } else if (c.disabled) { + st = '已禁用'; + act = ' ' + + ''; + } else { + // 过期判定用服务端下发的 expired(客户端时钟偏差会误标) + st = c.expired ? '已过期' : '未使用'; + act = ' ' + + ''; + } + return '' + escapeHtml(c.code) + '' + escapeHtml(String(c.minutes)) + ' 次' + st + '' + escapeHtml(c.used_by || '-') + '' + + escapeHtml(c.note || '-') + '' + + (c.expire_at ? new Date(c.expire_at * 1000).toLocaleDateString() : '永久') + '' + + escapeHtml(c.created_at ? new Date(c.created_at * 1000).toLocaleDateString() : '-') + '' + act + ''; + }).join(''); + _uaRenderCardPager(); + Array.prototype.forEach.call(body.querySelectorAll('button[data-card-act]'), function (b) { + b.addEventListener('click', function () { + uaCardAction(b.getAttribute('data-card-act'), b.getAttribute('data-card-code')); + }); + }); + }); + } + + /* ---- 公告 ---- */ + function uaLoadAnn() { + api('/admin/site/ann/get', {}).then(function (data) { + if (data.status !== 200) return; + var d = data.data || {}; + if ($('#ua-ann-title')) $('#ua-ann-title').value = d.title || ''; + if ($('#ua-ann-content')) $('#ua-ann-content').value = d.content || ''; + if ($('#ua-ann-mode')) $('#ua-ann-mode').value = d.popup_mode || 'off'; + }); + } + function uaSaveAnn() { + api('/admin/site/ann/save', { + title: ($('#ua-ann-title') || {}).value || '', + content: ($('#ua-ann-content') || {}).value || '', + popup_mode: ($('#ua-ann-mode') || {}).value || 'off' + }).then(function (d) { toast(d.message || '已保存', d.status === 200 ? 'success' : 'error'); }); + } + + /* ---- 数据库 ---- */ + function fmtSize(n) { + if (!n && n !== 0) return '-'; + if (n > 1048576) return (n / 1048576).toFixed(2) + ' MB'; + if (n > 1024) return (n / 1024).toFixed(1) + ' KB'; + return n + ' B'; + } + function uaLoadDb() { + api('/admin/site/backup/list', {}).then(function (data) { + var body = $('#ua-db-body'); + if (!body) return; + if (data.status !== 200) { body.innerHTML = '加载失败'; return; } + var d = data.data || {}; + var size = $('#ua-db-size'); if (size) size.textContent = fmtSize(d.db_size || 0); + var hours = $('#ua-db-hours'); if (hours && d.interval_hours != null) hours.value = d.interval_hours; + var baks = d.backups || []; + if (!baks.length) { body.innerHTML = '暂无备份'; return; } + body.innerHTML = baks.map(function (b) { + return '' + escapeHtml(b.name) + '' + fmtSize(b.size) + '' + + escapeHtml(b.time) + '' + + '下载 ' + + ' ' + + ''; + }).join(''); + $$('[data-restore]', body).forEach(function (b) { + b.addEventListener('click', function () { + if (!window.confirm('确定用该备份覆盖当前数据库?')) return; + api('/admin/site/backup/restore', { name: b.getAttribute('data-restore') }) + .then(function (d2) { + toast(d2.message || '已恢复', d2.status === 200 ? 'success' : 'error'); + if (d2.status === 200) setTimeout(function () { location.reload(); }, 1200); + }); + }); + }); + $$('[data-bdel]', body).forEach(function (b) { + b.addEventListener('click', function () { + if (!window.confirm('确定删除该备份?')) return; + api('/admin/site/backup/delete', { name: b.getAttribute('data-bdel') }) + .then(function (d2) { toast(d2.message || '已删除', d2.status === 200 ? 'success' : 'error'); uaLoadDb(); }); + }); + }); + }); + } + function uaBackup() { + api('/admin/site/backup/create', {}).then(function (d) { + toast(d.message || '备份完成', d.status === 200 ? 'success' : 'error'); + uaLoadDb(); + }); + } + function uaBackupAuto() { + api('/admin/site/backup/auto', { hours: ($('#ua-db-hours') || {}).value || 0 }) + .then(function (d) { toast(d.message || '已保存', d.status === 200 ? 'success' : 'error'); }); + } + function uaBackupUpload(file) { + var fd = new FormData(); + fd.append('file', file); + fetch('/admin/site/backup/upload', { method: 'POST', body: fd, credentials: 'same-origin' }) + .then(function (r) { + if (!r.ok) throw new Error('上传失败(HTTP ' + r.status + ')'); + return r.json(); + }) + .then(function (d) { + toast(d.message || '上传完成', d.status === 200 ? 'success' : 'error'); + uaLoadDb(); + }) + .catch(function (err) { + // 全站唯一绕过统一请求封装的调用此前无失败兜底—— + // 网络失败/非 JSON 响应时管理员毫无感知还制造 unhandledrejection 噪音 + toast((err && err.message) || '备份上传失败,请检查网络后重试', 'error'); + }); + } + + /* ---------------- 绑定 ---------------- */ + function bind() { + bindAuthModal(); + var s1 = $('#login-submit'); if (s1) s1.addEventListener('click', doLogin); + var s2 = $('#login-password'); if (s2) s2.addEventListener('keydown', function (e) { if (e.key === 'Enter') doLogin(); }); + // 回车提交覆盖全部输入框(登录用户名框 / 注册四框) + var s2u = $('#login-username'); if (s2u) s2u.addEventListener('keydown', function (e) { if (e.key === 'Enter') doLogin(); }); + var s3 = $('#reg-submit'); if (s3) s3.addEventListener('click', doRegister); + var s4 = $('#reg-send-code'); if (s4) s4.addEventListener('click', sendCode); + ['#reg-username', '#reg-password', '#reg-email', '#reg-code'].forEach(function (sel) { + var el = $(sel); + if (el) el.addEventListener('keydown', function (e) { if (e.key === 'Enter') doRegister(); }); + }); + // 密码明文切换(登录/注册/找回) + [['#login-pwd-eye', '#login-password'], ['#reg-pwd-eye', '#reg-password'], ['#forgot-pwd-eye', '#forgot-password']].forEach(function (pair) { + var eye = $(pair[0]), input = $(pair[1]); + if (!eye || !input) return; + eye.addEventListener('click', function () { + var show = input.type === 'password'; + input.type = show ? 'text' : 'password'; + eye.textContent = show ? '隐藏' : '显示'; + }); + }); + // 发码后修改邮箱 → 提前终止倒计时并恢复按钮,提示需重发 + var regEmail = $('#reg-email'); + if (regEmail) regEmail.addEventListener('input', function () { + var btn = $('#reg-send-code'); + if (btn && btn.disabled && /^\d+s$/.test(btn.textContent)) { + clearInterval(window.__regCodeTimer || 0); + btn.disabled = false; btn.textContent = '发送验证码'; + toast('邮箱已修改,请重新发送验证码', 'info'); + } + }); + // 找回视图——发码/提交/回车/改邮箱终止倒计时(对齐注册交互) + var fsc = $('#forgot-send-code'); if (fsc) fsc.addEventListener('click', doForgotSendCode); + var fsub = $('#forgot-submit'); if (fsub) fsub.addEventListener('click', doForgotReset); + ['#forgot-email', '#forgot-code', '#forgot-password'].forEach(function (sel) { + var el = $(sel); + if (el) el.addEventListener('keydown', function (e) { if (e.key === 'Enter') doForgotReset(); }); + }); + var forgotEmail = $('#forgot-email'); + if (forgotEmail) forgotEmail.addEventListener('input', function () { + var btn = $('#forgot-send-code'); + if (btn && btn.disabled && /^\d+s$/.test(btn.textContent)) { + clearInterval(window.__forgotCodeTimer || 0); + btn.disabled = false; btn.textContent = '发送验证码'; + toast('邮箱已修改,请重新发送验证码', 'info'); + } + }); + // 管理员两步验证——发码/验证提交/回车提交 + var asc = $('#a2fa-send'); if (asc) asc.addEventListener('click', doA2faSendCode); + var asb = $('#a2fa-submit'); if (asb) asb.addEventListener('click', doA2faVerify); + ['#a2fa-email', '#a2fa-code'].forEach(function (sel) { + var el = $(sel); + if (el) el.addEventListener('keydown', function (e) { if (e.key === 'Enter') doA2faVerify(); }); + }); + var s6 = $('#announce-ok'); if (s6) s6.addEventListener('click', function () { closeModal('announce-modal'); }); + + var ucTab = $('#tab-usercenter'); + if (ucTab) ucTab.addEventListener('click', renderUserCenter); + var uaTab = $('#tab-useradmin'); + if (uaTab) uaTab.addEventListener('click', uaShowGate); + var av = $('#user-avatar'); + if (av) av.addEventListener('click', function () { + if (user && user.role === 'admin') { if (uaTab) uaTab.click(); } + else { var t = $('#tab-usercenter'); if (t) t.click(); } + }); + var ucRedeem = $('#uc-card-redeem'); if (ucRedeem) ucRedeem.addEventListener('click', doRedeem); + var ucAnn = $('#uc-ann-view'); if (ucAnn) ucAnn.addEventListener('click', function () { maybeShowAnnouncement(true); }); // 手动查看强制展示 + var ucLogout = $('#uc-logout'); if (ucLogout) ucLogout.addEventListener('click', doLogout); + + uaBindSide(); + var b1 = $('#ua-users-refresh'); if (b1) b1.addEventListener('click', uaLoadUsers); + var b2 = $('#ua-cookie-refresh'); if (b2) b2.addEventListener('click', uaLoadCookieStatus); + var b3 = $('#ua-qr-create'); if (b3) b3.addEventListener('click', uaQrCreate); + var b4 = $('#ua-cookie-delete'); if (b4) b4.addEventListener('click', uaCookieDelete); + var b5 = $('#ua-smtp-save'); if (b5) b5.addEventListener('click', uaSaveSmtp); + var b6 = $('#ua-smtp-test'); if (b6) b6.addEventListener('click', uaTestSmtp); + var b7 = $('#ua-log-refresh'); if (b7) b7.addEventListener('click', function () { uaLoadLogs(); }); + var b8 = $('#ua-log-tab-ok'); if (b8) b8.addEventListener('click', function () { uaLogsType = 'success'; uaLogPage = 1; uaLoadLogs(); }); + var b9 = $('#ua-log-tab-fail'); if (b9) b9.addEventListener('click', function () { uaLogsType = 'fail'; uaLogPage = 1; uaLoadLogs(); }); + var blogPrev = $('#ua-log-prev'); if (blogPrev) blogPrev.addEventListener('click', function () { if (uaLogPage > 1) { uaLogPage--; uaLoadLogs(); } }); + // next 补边界(原实现可翻到空页——前端无 pages 时放宽为 999 防呆) + var blogNext = $('#ua-log-next'); if (blogNext) blogNext.addEventListener('click', function () { var maxP = window._uaLogPages || 999; if (uaLogPage < maxP) { uaLogPage++; uaLoadLogs(); } }); + var blogClear = $('#ua-log-clear'); if (blogClear) blogClear.addEventListener('click', uaClearLogs); + var b10 = $('#ua-card-gen'); if (b10) b10.addEventListener('click', uaGenCards); + var cardType = $('#ua-card-minutes'); + if (cardType) cardType.addEventListener('change', function () { + var custom = $('#ua-card-custom'); + if (custom) custom.hidden = cardType.value !== 'custom'; + if (cardType.value === 'custom' && custom) custom.focus(); + }); + var b11 = $('#ua-ann-save'); if (b11) b11.addEventListener('click', uaSaveAnn); + var b12 = $('#ua-db-backup'); if (b12) b12.addEventListener('click', uaBackup); + var b13 = $('#ua-db-auto'); if (b13) b13.addEventListener('click', uaBackupAuto); + var b14 = $('#ua-db-upload'); + if (b14) b14.addEventListener('change', function () { + if (b14.files && b14.files[0]) uaBackupUpload(b14.files[0]); + b14.value = ''; + }); + + } + + function init() { + bind(); + refreshSession(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + +} diff --git a/frontend/src/controllers/legacy.ts b/frontend/src/controllers/legacy.ts new file mode 100644 index 0000000..1af52d9 --- /dev/null +++ b/frontend/src/controllers/legacy.ts @@ -0,0 +1,4474 @@ +// @ts-nocheck +/* 主业务控制器(由旧版 templates/index.html 内联脚本逐字移植)。 + * 全局状态 store、弹窗显隐 modalBus、Tab 显隐由 React 接管; + * 其余动态区域沿用 id + innerHTML 的命令式实现,保证行为一致。 + * 全部副作用包在 __legacyBoot() 中——旧脚本位于 body 末尾可直取 DOM, + * 模块化后 React 尚未渲染骨架,必须等挂载完成再绑定。 */ +import { store, notify, toast } from '../lib/store' +import { openModal, closeModal, isModalOpen, closeTopmost, openDrawer as busOpenDrawer, closeDrawer as busCloseDrawer, isDrawerOpen } from '../ui/modalBus' +function safeAuthExpired(msg, expired) { if (window.handleAuthExpired) window.handleAuthExpired(msg, expired); else toast(msg, 'warning'); } +export function __legacyBoot() { + /* Promise.finally 老内核 polyfill(MDN 标准实现,ES5 语法)。 + finally 需 Chrome 63+;支持矩阵承诺 Chromium 57-85 可用降级, + 20 处业务代码的 .finally 依赖此垫片在 57-62 上正常工作 */ + + + var $ = function (s) { return document.querySelector(s); }; + var $$ = function (s) { return Array.prototype.slice.call(document.querySelectorAll(s)); }; + + /* ================= 全局状态 ================= */ + ; + + /* VIP 类型 → 名称 / 可解析音质 */ + var VIP_NAME = { 0: '普通用户', 1: '音乐包', 11: '黑胶VIP', 100: '黑胶VIP', 10: '黑胶SVIP', 110: '黑胶SVIP' }; + var VIP_QUALITIES = { + 0: [['standard', '标准音质'], ['exhigh', '极高音质']], + 1: [['standard', '标准音质'], ['exhigh', '极高音质'], ['lossless', '无损音质'], ['hires', 'Hi-Res音质']], + 11: [['standard', '标准音质'], ['exhigh', '极高音质'], ['lossless', '无损音质'], ['hires', 'Hi-Res音质']], + 100: [['standard', '标准音质'], ['exhigh', '极高音质'], ['lossless', '无损音质'], ['hires', 'Hi-Res音质']], + 10: [['standard', '标准音质'], ['exhigh', '极高音质'], ['lossless', '无损音质'], ['hires', 'Hi-Res音质'], + ['sky', '沉浸环绕声'], ['jyeffect', '高清环绕声'], ['jymaster', '超清母带'], ['dolby', '杜比全景声']], + 110: [['standard', '标准音质'], ['exhigh', '极高音质'], ['lossless', '无损音质'], ['hires', 'Hi-Res音质'], + ['sky', '沉浸环绕声'], ['jyeffect', '高清环绕声'], ['jymaster', '超清母带'], ['dolby', '杜比全景声']] + }; + var AUTO_LADDER = ['jymaster', 'jyeffect', 'sky', 'hires', 'lossless', 'exhigh', 'standard']; + + /* ================= 设置 ================= */ + var THEME_COLORS = [ + { name: '默认蓝', color: '#1677ff' }, + { name: '红色', color: '#f5222d' }, + { name: '橙色', color: '#fa8c16' }, + { name: '火山', color: '#d4380d' }, + { name: '青色', color: '#13c2c2' }, + { name: '绿色', color: '#52c41a' }, + { name: '粉红', color: '#eb2f96' }, + { name: '紫罗兰', color: '#722ed1' } + ]; + + function loadSettings() { + // 可读部分先应用;单条目损坏不阻断整体恢复; + // primaryColor 校验为合法 #rrggbb(数值等坏值不再炸 applyTheme) + var s = {}; + try { s = JSON.parse(localStorage.getItem('netease_settings') || '{}') || {}; } catch (e) { s = {}; } + if (typeof s !== 'object') s = {}; + // 一次性迁移:批量下载默认改为 ZIP 打包(老用户未主动开启过打包也升级,之后可在设置中关闭) + // 迁移结果必须持久化(原实现只写完成标志,第二次加载回退旧值) + try { + if (!localStorage.getItem('netease_batchzip_migrated')) { + if (s.batchZip !== true) { delete s.batchZip; } + localStorage.setItem('netease_settings', JSON.stringify(s)); + localStorage.setItem('netease_batchzip_migrated', '1'); + } + } catch (e) {} + if (typeof s.primaryColor === 'string' && /^#[0-9a-fA-F]{6}$/.test(s.primaryColor)) { + store.settings.primaryColor = s.primaryColor; + } + if (s.themeMode) store.settings.themeMode = s.themeMode; + ['ktv', 'lyric2line', 'progressMemory', 'showComments', 'smtc', 'writeMeta', + 'batchWriteMeta', 'batchZip', 'zipEnabled'].forEach(function (k) { + if (typeof s[k] === 'boolean') store.settings[k] = s[k]; + }); + if (s.filenameFormat) store.settings.filenameFormat = s.filenameFormat; + if (s.lyricFormat) store.settings.lyricFormat = s.lyricFormat; + if (typeof s.lyricFont === 'number' && isFinite(s.lyricFont)) { + // 歌词字号:限幅 0.7–1.6 并按 5% 步进取整(坏值/超范围自动收回) + store.settings.lyricFont = Math.max(0.7, Math.min(1.6, Math.round(s.lyricFont * 20) / 20)); + } + if (s.metaFields && typeof s.metaFields === 'object') { + Object.keys(store.settings.metaFields).forEach(function (k) { + if (typeof s.metaFields[k] === 'boolean') store.settings.metaFields[k] = s.metaFields[k]; + }); + } + if (s.cookiePrefs && typeof s.cookiePrefs === 'object') { + if (typeof s.cookiePrefs.functional === 'boolean') store.settings.cookiePrefs.functional = s.cookiePrefs.functional; + if (typeof s.cookiePrefs.accepted === 'boolean') store.settings.cookiePrefs.accepted = s.cookiePrefs.accepted; + } + } + + function saveSettings() { + // 返回是否真的持久化成功(调用方据此给出准确提示,不再一律"已保存") + try { + localStorage.setItem('netease_settings', JSON.stringify(store.settings)); + return true; + } catch (e) { return false; } + } + + function applyTheme() { + var root = document.documentElement; + // 主题切换平滑过渡:切换期间短暂启用全变量颜色过渡 + root.classList.add('theme-fading'); + setTimeout(function () { root.classList.remove('theme-fading'); }, 320); + var mode = store.settings.themeMode; + var dark = mode === 'dark' || (mode === 'system' && + window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches); + root.classList.toggle('dark', dark); + root.style.setProperty('--color-primary', store.settings.primaryColor); + root.style.setProperty('--color-primary-bg', mixRgba(store.settings.primaryColor, 0.12)); + root.style.setProperty('--color-primary-hover', mixRgba(store.settings.primaryColor, 0.9)); + root.style.setProperty('--color-primary-active', mixRgba(store.settings.primaryColor, 0.7)); + $('#theme-toggle').innerHTML = dark ? '☀️ 浅色' : '🌙 深色'; + } + + function mixRgba(hex, alpha) { + var r = parseInt(hex.slice(1, 3), 16); + var g = parseInt(hex.slice(3, 5), 16); + var b = parseInt(hex.slice(5, 7), 16); + // alpha > 0.5 视为向白色混合得到 hover/active 色,否则返回半透明色 + if (alpha > 0.5) { + var k = 1 - alpha; + return 'rgb(' + Math.round(r + (255 - r) * k) + ',' + + Math.round(g + (255 - g) * k) + ',' + + Math.round(b + (255 - b) * k) + ')'; + } + return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')'; + } + + function renderSettingsUI() { + // 主题色 + var palette = $('#color-palette'); + palette.innerHTML = THEME_COLORS.map(function (c) { + return '
'; + }).join(''); + // 外观模式 + $$('#mode-seg .seg-item').forEach(function (el) { + el.classList.toggle('active', el.getAttribute('data-mode') === store.settings.themeMode); + }); + // 开关 + $('#switch-ktv').classList.toggle('on', store.settings.ktv); + $('#switch-lyric2line').classList.toggle('on', store.settings.lyric2line); + $('#switch-progress').classList.toggle('on', store.settings.progressMemory); + $('#switch-comments').classList.toggle('on', store.settings.showComments); + $('#switch-smtc').classList.toggle('on', store.settings.smtc); + // 下载配置 + $('#cfg-filename-format').value = store.settings.filenameFormat; + $('#cfg-write-meta').classList.toggle('on', store.settings.writeMeta); + $('#cfg-zip').classList.toggle('on', store.settings.zipEnabled); + $('#cfg-batch-meta').classList.toggle('on', store.settings.batchWriteMeta); + $('#cfg-batch-zip').classList.toggle('on', store.settings.batchZip); + $('#cfg-lyric-format').value = store.settings.lyricFormat; + // 写入字段复选框 + $$('#cfg-meta-fields-wrap input[type=checkbox]').forEach(function (cb) { + cb.checked = !!store.settings.metaFields[cb.getAttribute('data-field')]; + cb.disabled = !store.settings.writeMeta; + }); + // 功能性 Cookie + $('#switch-cookie-func').classList.toggle('on', store.settings.cookiePrefs.functional); + $('#switch-cookie-func').setAttribute('aria-checked', store.settings.cookiePrefs.functional ? 'true' : 'false'); + $('#cookie-func-tag').textContent = store.settings.cookiePrefs.functional ? '已启用' : '已禁用'; + // 主题色点键盘可达(同型——动态生成后统一补属性) + $$('#color-palette .color-dot').forEach(function (el) { + el.setAttribute('tabindex', '0'); + el.setAttribute('role', 'button'); + }); + } + + $('#color-palette').addEventListener('click', function (e) { + var dot = e.target.closest('.color-dot'); + if (!dot) return; + store.settings.primaryColor = dot.getAttribute('data-color'); + renderSettingsUI(); + applyTheme(); + }); + // 主题色点 Enter/空格激活 + $('#color-palette').addEventListener('keydown', function (e) { + if (e.key !== 'Enter' && e.key !== ' ') return; + var dot = e.target.closest ? e.target.closest('.color-dot') : null; + if (!dot) return; + e.preventDefault(); + dot.click(); + }); + + $('#mode-seg').addEventListener('click', function (e) { + var item = e.target.closest('.seg-item'); + if (!item) return; + store.settings.themeMode = item.getAttribute('data-mode'); + renderSettingsUI(); + applyTheme(); + }); + // 外观模式分段键盘可达 + $('#mode-seg').addEventListener('keydown', function (e) { + if (e.key !== 'Enter' && e.key !== ' ') return; + var item = e.target.closest ? e.target.closest('.seg-item') : null; + if (!item) return; + e.preventDefault(); + item.click(); + }); + + function bindSwitch(id, key) { + var el = $(id); + if (!el) return; + // 自定义开关补全键盘语义(tabindex/role/aria + Enter/空格激活) + el.setAttribute('tabindex', '0'); + el.setAttribute('role', 'switch'); + var activate = function () { + store.settings[key] = !store.settings[key]; + renderSettingsUI(); + applySettingsEffects(); + }; + el.addEventListener('click', activate); + el.addEventListener('keydown', function (e) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + activate(); + } + }); + } + bindSwitch('#switch-ktv', 'ktv'); + bindSwitch('#switch-lyric2line', 'lyric2line'); + bindSwitch('#switch-progress', 'progressMemory'); + bindSwitch('#switch-comments', 'showComments'); + bindSwitch('#switch-smtc', 'smtc'); + + // 配置区开关同样支持键盘操作 + function bindCfgSwitch(id, fn) { + var el = $(id); + if (!el) return; + el.setAttribute('tabindex', '0'); + el.setAttribute('role', 'switch'); + el.addEventListener('click', fn); + el.addEventListener('keydown', function (e) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + fn(); + } + }); + } + + // 下载/批量/歌词 配置控件 + $('#cfg-filename-format').addEventListener('change', function () { + store.settings.filenameFormat = this.value; + }); + bindCfgSwitch('#cfg-write-meta', function () { + store.settings.writeMeta = !store.settings.writeMeta; + renderSettingsUI(); + }); + bindCfgSwitch('#cfg-zip', function () { + store.settings.zipEnabled = !store.settings.zipEnabled; + renderSettingsUI(); + }); + bindCfgSwitch('#cfg-batch-meta', function () { + store.settings.batchWriteMeta = !store.settings.batchWriteMeta; + renderSettingsUI(); + }); + bindCfgSwitch('#cfg-batch-zip', function () { + store.settings.batchZip = !store.settings.batchZip; + renderSettingsUI(); + }); + $('#cfg-lyric-format').addEventListener('change', function () { + store.settings.lyricFormat = this.value; + }); + $('#cfg-meta-fields-wrap').addEventListener('change', function (e) { + var cb = e.target; + if (cb.getAttribute('type') !== 'checkbox') return; + store.settings.metaFields[cb.getAttribute('data-field')] = cb.checked; + }); + // 各分区保存按钮 + // 保存提示反映真实持久化结果(存储被禁/配额满时不再谎报成功) + function saveSettingsToast(okMsg) { + toast(saveSettings() ? okMsg : '设置未能保存(浏览器存储不可用),仅本次生效', 'warning'); + } + $('#cfg-save-download').addEventListener('click', function () { saveSettingsToast('下载配置已保存'); }); + $('#cfg-save-batch').addEventListener('click', function () { saveSettingsToast('批量下载配置已保存'); }); + $('#cfg-save-lyric').addEventListener('click', function () { saveSettingsToast('歌词配置已保存'); }); + + // 下载配置参数构建(供解析/下载请求使用) + function downloadConfig() { + var fields = Object.keys(store.settings.metaFields).filter(function (k) { + return store.settings.metaFields[k]; + }); + return { + filename_format: store.settings.filenameFormat, + write_meta: store.settings.writeMeta ? 'true' : 'false', + meta_fields: store.settings.writeMeta && fields.length ? fields.join(',') : '__none__', + lyric_format: store.settings.lyricFormat, + zip: store.settings.zipEnabled ? 'true' : 'false' + }; + } + + function applySettingsEffects() { + // 评论区显隐 + $('#comment-panel').hidden = !(store.settings.showComments && store.currentSong); + updateCommentComposer(); + // 评论从关到开时,为当前歌曲补加载评论(原来只显示面板, + // 可能展示旧歌评论或空白) + if (store.settings.showComments && store.currentSong + && !store.commentTotal && !store._commentsLoading) { + loadComments(store.currentSong.id, 1, true); + } + // KTV 从开到关时终止已启动的动画帧循环(原来旧帧继续更新并排下一帧) + if (!store.settings.ktv && store.ktvRunning) { + store.ktvRunning = false; + } + // 重绘歌词(KTV / 翻译分行模式) + if (store.rawLyric != null) { + store.lyrics = buildLyricLines(); + renderLyrics(); + } + } + + $('#settings-save').addEventListener('click', function () { + saveSettingsToast('设置已保存'); + closeDrawer(); + }); + $('#settings-reset').addEventListener('click', function () { + store.settings = { + primaryColor: '#1677ff', + themeMode: 'system', + ktv: false, + lyric2line: true, + progressMemory: true, + showComments: true, + smtc: true, + filenameFormat: 'artist_song', + writeMeta: true, + metaFields: { name: true, artist: true, album: true, cover: true, lyrics: false, date: false }, + zipEnabled: false, + batchWriteMeta: true, + batchZip: true, // 批量下载默认 ZIP 打包(任务面板有进度,一次下载一个包) + lyricFormat: 'lrc', + lyricFont: 1, // 恢复默认设置时字号一并回 100% + cookiePrefs: { functional: true, accepted: false } // 新用户默认未同意 + }; + renderSettingsUI(); + applyTheme(); + applySettingsEffects(); + applyLyricFont(); + saveSettings(); + toast('已恢复默认设置', 'info'); + }); + + /* ================= 接口请求统计 ================= */ + var statsTimer = null; + function loadStats() { + apiPost('/api/topen', {}) + .then(function (data) { + if (data.status !== 200) throw new Error(data.message || '获取统计失败'); + var d = data.data; + $('#stat-total').textContent = fmtCount(d.total); + $('#stat-endpoints').textContent = d.endpoint_count; + $('#stat-max').textContent = fmtCount(d.max_count); + var now = new Date(); + $('#stats-refresh-time').textContent = + String(now.getHours()).padStart(2, '0') + ':' + + String(now.getMinutes()).padStart(2, '0') + ':' + + String(now.getSeconds()).padStart(2, '0'); + var list = d.endpoints || []; + $('#stats-list').innerHTML = list.map(function (ep, i) { + return '
' + + '#' + (i + 1) + '' + + '
' + + '
' + escapeHtml(ep.name) + '
' + + '
' + escapeHtml(ep.path) + '
' + + '
' + + '' + escapeHtml(String(fmtCount(ep.count))) + '次 (' + escapeHtml(String(ep.percent)) + '%)' + + '
'; + }).join('') || '
暂无请求记录
'; + }) + .catch(function (err) { + toast(err.message || '获取统计失败', 'error'); + }); + } + function openStatsModal() { + openModal('stats-modal'); + loadStats(); + if (statsTimer) clearInterval(statsTimer); + statsTimer = setInterval(loadStats, 10000); + } + function closeStatsModal() { + closeModal('stats-modal'); + if (statsTimer) { clearInterval(statsTimer); statsTimer = null; } + } + $('#cfg-stats-view').addEventListener('click', openStatsModal); + $('#cfg-stats-refresh').addEventListener('click', openStatsModal); + $('#stats-refresh-btn').addEventListener('click', loadStats); + $('#stats-close').addEventListener('click', closeStatsModal); + $('#stats-modal').addEventListener('click', function (e) { if (e.target === this) closeStatsModal(); }); + + /* ================= 隐私与协议(Cookie 偏好) ================= */ + // mode='register' 为注册门槛模式——打开即自动计时 16 秒强制阅读, + // 计时归零且勾选同意后才允许「同意并继续注册」进入注册弹窗(同意一次永久记住, + // 撤回/清浏览器数据后重新计时)。普通模式(首访/设置入口)行为不变。 + var _cookieRegMode = false; + var _cookieReadTimer = null; + var _cookieReadLeft = 0; + var _cookieTitleDefault = null; + function _cookieStopReadTimer() { + if (_cookieReadTimer) { clearInterval(_cookieReadTimer); _cookieReadTimer = null; } + } + function _cookieUpdateContinueBtn() { + var btn = $('#cookie-agree-continue'); + if (!btn) return; + if (_cookieReadLeft > 0) { + btn.disabled = true; + btn.textContent = '请阅读协议(' + _cookieReadLeft + 's)'; + return; + } + btn.textContent = '同意并继续注册'; + btn.disabled = !$('#cookie-agree').checked; + } + function _cookieSetRegMode(isReg) { + _cookieRegMode = isReg; + $('#cookie-withdraw').hidden = isReg; + $('#cookie-save').hidden = isReg; + var cont = $('#cookie-agree-continue'); + if (cont) cont.hidden = !isReg; + var title = $('#cookie-modal .modal-title'); + if (title) { + if (_cookieTitleDefault === null) _cookieTitleDefault = title.textContent; + title.textContent = isReg ? '注册前请阅读并同意协议' : _cookieTitleDefault; + } + } + function openCookieModal(mode) { + // 反向叠窗守卫——公告先开时 Cookie 窗(强制同意门) + // 优先,收起公告弹窗再开(游客 once 模式已在打开时记已读,属可接受取舍) + var _am = document.getElementById('announce-modal'); + if (isModalOpen('announce-modal')) closeModal('announce-modal'); + var isReg = mode === 'register' && !store.settings.cookiePrefs.accepted; + $('#cookie-agree').checked = store.settings.cookiePrefs.accepted; + renderSettingsUI(); + _cookieSetRegMode(isReg); + var m = $('#cookie-modal'); + openModal('cookie-modal'); + _cookieStopReadTimer(); + if (isReg) { + // 16 秒强制阅读:弹窗被任何方式关闭(Escape/遮罩/关闭钮)由 tick + // 自检停止——不碰 MutationObserver(渲染器活锁教训) + _cookieReadLeft = 16; + _cookieUpdateContinueBtn(); + _cookieReadTimer = setInterval(function () { + if (!isModalOpen('cookie-modal')) { _cookieStopReadTimer(); return; } + _cookieReadLeft = Math.max(0, _cookieReadLeft - 1); + _cookieUpdateContinueBtn(); + if (_cookieReadLeft <= 0) _cookieStopReadTimer(); + }, 1000); + } + } + function closeCookieModal() { + _cookieStopReadTimer(); + _cookieReadLeft = 0; + closeModal('cookie-modal'); + _cookieSetRegMode(false); // 复位为常规 footer,普通入口打开状态正确 + } + $('#cfg-cookie-prefs').addEventListener('click', function () { openCookieModal(); }); // 显式包裹,避免事件对象被误当 mode 实参 + $('#cookie-close').addEventListener('click', closeCookieModal); + $('#cookie-modal').addEventListener('click', function (e) { if (e.target === this) closeCookieModal(); }); + $('#cookie-agree').addEventListener('change', function () { + if (_cookieRegMode) _cookieUpdateContinueBtn(); + }); + + var _toggleCookieFunc = function () { + store.settings.cookiePrefs.functional = !store.settings.cookiePrefs.functional; + renderSettingsUI(); + toast(store.settings.cookiePrefs.functional ? '功能性 Cookie 已启用' : '功能性 Cookie 已禁用(解析与下载将不可用)', 'warning'); + }; + $('#switch-cookie-func').addEventListener('click', _toggleCookieFunc); + // 功能性 Cookie 开关键盘可达 + $('#switch-cookie-func').addEventListener('keydown', function (e) { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _toggleCookieFunc(); } + }); + + $('#cookie-save').addEventListener('click', function () { + var agreed = $('#cookie-agree').checked; + if (!agreed) { + toast('请先勾选已阅读并同意《用户协议》和《隐私政策》', 'warning'); + return; + } + store.settings.cookiePrefs.accepted = true; + saveSettings(); + closeCookieModal(); + toast('Cookie 偏好已保存', 'success'); + }); + // 注册门槛模式的「同意并继续注册」——保存同意与功能性偏好, + // 关闭本弹窗并放行进入注册弹窗(聚焦用户名框) + $('#cookie-agree-continue').addEventListener('click', function () { + if (this.disabled) return; + if (!$('#cookie-agree').checked) { + toast('请先勾选已阅读并同意《用户协议》和《隐私政策》', 'warning'); + return; + } + store.settings.cookiePrefs.accepted = true; + saveSettings(); + renderSettingsUI(); + closeCookieModal(); + toast('已同意《用户协议》与《隐私政策》,请继续注册', 'success'); + var rm = $('#register-modal'); + if (rm) { + openModal('register-modal'); + var un = $('#reg-username'); + if (un) { try { un.focus(); } catch (e) {} } + } + }); + $('#cookie-withdraw').addEventListener('click', function () { + store.settings.cookiePrefs = { functional: true, accepted: false }; + saveSettings(); + renderSettingsUI(); + closeCookieModal(); + toast('已撤回同意', 'info'); + }); + + // 功能性 Cookie 关闭时禁止解析/下载;mode='register' 时只强制协议同意 + // (功能性 Cookie 不拦注册——注册本身不依赖它,拦解析/下载时再报) +function cookieGate(mode) { + if (!store.settings.cookiePrefs.accepted) { + var reg = mode === 'register'; + toast(reg ? '注册前请先阅读并同意《用户协议》与《隐私政策》' : '请先阅读并同意《用户协议》和《隐私政策》', 'warning'); + openCookieModal(reg ? 'register' : undefined); + return false; + } + if (mode !== 'register' && !store.settings.cookiePrefs.functional) { + toast('功能性 Cookie 已关闭,无法解析与下载音乐,请在「设置 → 隐私与协议」中开启', 'warning'); + openCookieModal(); + return false; + } + return true; +} +// 暴露给 site_auth.js——注册提交前强制隐私同意(未同意则弹协议窗并中止) +window.SITE_COOKIE_GATE = cookieGate; + + var _drawerPrevFocus = null; + function openDrawer() { + // 打开抽屉时把焦点移入抽屉(键盘/读屏用户不再滞留在页面背后) + _drawerPrevFocus = document.activeElement; + busOpenDrawer(); + busOpenDrawer(); + var first = $('#settings-drawer').querySelector('button, [href], input, select, [tabindex]:not([tabindex="-1"])'); + if (first) try { first.focus(); } catch (e) {} + } + function closeDrawer() { + busCloseDrawer(); + busCloseDrawer(); + // 关闭后把焦点还给触发控件 + if (_drawerPrevFocus && typeof _drawerPrevFocus.focus === 'function') { + try { _drawerPrevFocus.focus(); } catch (e) {} + } + _drawerPrevFocus = null; + } + // Escape 关闭抽屉/弹窗(键盘可达性)——弹窗栈由 modalBus 管理 + document.addEventListener('keydown', function (e) { + if (e.key !== 'Escape') return; + var consumed = closeTopmost(); + if (consumed) return; + // 无弹窗时 Escape 仍关抽屉(弹窗优先于抽屉的顺序修正) + if (isDrawerOpen()) busCloseDrawer(); + }); + $('#btn-settings').addEventListener('click', openDrawer); + $('#drawer-close').addEventListener('click', closeDrawer); + $('#drawer-mask').addEventListener('click', closeDrawer); + + /* ================= 更新日志 ================= */ + var CHANGELOG = [ + { v: '2.4.0', date: '2026-09-21', items: ['修复:解析结果在快速连续解析两首歌时可能互相覆盖(串台)的问题', '修复:超长曲目(100 分钟以上)歌词第 100 分钟起丢失的问题', '修复:连播切歌后歌词/评论区仍显示上一首的问题', '修复:歌名含特殊字符时下载领取失败的问题', '修复:老内核浏览器(Chrome 57-62)操作按钮卡加载的问题', '安全:登录失败次数过多临时锁定;公网错误信息不再暴露内部细节', '优化:下载结果保留期与「领取」入口统一为 35 分钟'] }, + { v: '2.1.0', date: '2026-08-28', items: ['调整:计费改为按次计费,一首歌消耗 1 次,歌单打包下载按歌曲数量扣减(如 100 首歌单打包 = 100 次)', '调整:免费试用改为 3 次(3 首歌),注册页领取,每台设备限领一次', '调整:卡密升级为次数卡,后台可生成 3/10/50/100/500/2000 次卡'] }, + { v: '1.6.0', date: '2026-08-28', items: ['新增:账号系统(强制登录):邮箱验证码注册、免费体验5分钟(设备识别防重复)、使用时长管理', '新增:管理后台:用户管理(删除/时长修改/网易云Cookie查看)、全局Cookie管理、SMTP邮箱配置、成功/失败日志、次数卡密生成、公告设置、数据库备份/下载/上传恢复/定时备份', '新增:个人中心:剩余时长、卡密充值、公告查看、版本日志、联系方式、退出登录', '新增:导航栏登录/注册按钮,登录后显示头像,管理员/普通用户角色识别与定向跳转', '调整:全局账号歌单仅管理员可见;解析功能需登录后使用'] }, + { v: '1.5.0', date: '2026-08-28', items: ['新增:播客解析模块,支持电台/单集链接与ID解析、在线试听、音频直链获取', '新增:播客下载方式:单集服务器下载(ZIP 可选)、整台播客打包下载,接入任务进度悬浮窗', '新增:播客搜索与热门播客分类卡片墙', '新增:「关于网站」菜单:项目介绍、功能说明、更新日志与作者信息'] }, + { v: '1.4.8', date: '2026-08-05', items: ['新增:单曲解析结果下方新增「下载歌词」按钮', '新增:播放器新增「播放进度记忆」功能', '优化:歌词下载自动过滤 YRC/LRC 头部的 JSON 元数据行'] }, + { v: '1.4.7', date: '2026-07-22', items: ['新增:设置「下载配置」分类中加入「写入字段」勾选区', '优化:未勾选字段不会覆盖原音频文件中的值'] }, + { v: '1.4.6', date: '2026-07-21', items: ['新增:单曲解析结果新增「自定义元数据」开关', '优化:每次解析新歌曲时自动重置自定义元数据'] }, + { v: '1.4.5', date: '2026-07-20', items: ['新增:逐字歌词(KTV 模式),支持逐字渐变高亮', '优化:歌词头部元数据(作词/作曲/编曲等)自动识别展示'] }, + { v: '1.4.4', date: '2026-07-19', items: ['新增:沉浸环绕声(sky)支持子类型 c51 / ste / aac', '优化:兼容网易云逐字歌词(YRC)'] }, + { v: '1.4.3', date: '2026-07-13', items: ['新增:歌手解析 Tab,支持热门 / 全部歌曲', '优化:更新日志改为独立页面'] }, + { v: '1.4.2', date: '2026-07-13', items: ['新增:8 种主题颜色,应用到全站强调色', '新增:独立「歌词」分类设置', '优化:榜单改为响应式卡片网格'] }, + { v: '1.4.1', date: '2026-07-05', items: ['新增:GitHub Issues 同步页', '新增:API 请求失败通知'] }, + { v: '1.4.0', date: '2026-07-02', items: ['重构:设置 / 统计由右侧 Drawer 改为独立页面'] }, + { v: '1.3.9', date: '2026-06-21', items: ['重构:移除 APlayer,自研全新 AudioPlayer', '优化:LRC 歌词毫秒精度高亮、点击歌词行跳转'] }, + { v: '1.3.8', date: '2026-06-20', items: ['新增:单曲信息下方评论区,支持分页加载', '优化:评论显示 VIP / SVIP / 音乐包标识、IP 归属地'] }, + { v: '1.3.6', date: '2026-05-24', items: ['新增:所有官方榜单全量支持', '新增:歌单 / 专辑批量下载本页'] }, + { v: '1.2.0', date: '2026-03-29', items: ['修复部分歌曲解析失败的问题', '优化界面交互体验'] } + ]; + + function renderChangelog() { + $('#changelog-body').innerHTML = CHANGELOG.map(function (v) { + return '
' + + '
v' + v.v + '' + + '' + v.date + '
' + + '
    ' + v.items.map(function (i) { + return '
  • ' + escapeHtml(i) + '
  • '; + }).join('') + '
'; + }).join(''); + } + function openChangelog() { renderChangelog(); openModal('changelog-modal'); } + $('#btn-changelog').addEventListener('click', openChangelog); + $('#changelog-close').addEventListener('click', function () { closeModal('changelog-modal'); }); + $('#changelog-modal').addEventListener('click', function (e) { + if (e.target === this) closeModal('changelog-modal'); + }); + + /* ================= 页面加载 ================= */ + function __removePageLoading() { + setTimeout(function () { + var el = $('#page-loading'); + if (el) el.remove(); + }, 300); + } + if (document.readyState === 'complete') __removePageLoading(); + else window.addEventListener('load', __removePageLoading); + + /* ================= Toast ================= */ + + + /* ================= 深色模式(顶部按钮) ================= */ + $('#theme-toggle').addEventListener('click', function () { + store.settings.themeMode = document.documentElement.classList.contains('dark') ? 'light' : 'dark'; + renderSettingsUI(); + applyTheme(); + saveSettings(); + }); + + /* ================= 解析账号栏 ================= */ + var _acctGen = 0; // 账号栏代次令牌——连点刷新/快速切账号时迟到响应不再覆盖新状态 + function renderAccountBar() { + // 个人账号时携带本机保存的个人 Cookie(原实现只发 account:user, + // 后端拿不到个人凭据,永远显示"未登录") + var gen = ++_acctGen; + apiPost('/account/vip', { + account: store.parseAccount, + user_cookie: store.parseAccount === 'user' ? store.personal.cookie : '' + }) + .then(function (data) { + if (gen !== _acctGen) return; // 已有新一轮刷新,丢弃迟到响应 + if (data.status !== 200) throw new Error(data.message || '获取账号信息失败'); + var d = data.data || {}; + var badge = $('#account-vip-badge'); + var isUser = d.account === 'user'; + var label = (isUser ? '个人账号' : '全局账号') + ' · ' + (d.vip_name || '未知'); + badge.textContent = label; + if (isUser) { + badge.style.borderColor = 'var(--color-warning)'; + badge.style.color = 'var(--color-warning)'; + badge.style.background = 'rgba(250,173,20,0.12)'; + } else { + badge.style.borderColor = 'var(--color-primary)'; + badge.style.color = 'var(--color-primary)'; + badge.style.background = 'var(--color-primary-bg)'; + } + // 可解析音质列表:优先使用接口实测结果 + var quals = []; + if (d.levels_ok && d.levels_ok.length) { + store.levelsOk = d.levels_ok; + quals = d.levels_ok.map(function (lv) { return [lv, levelName(lv)]; }); + } else { + store.levelsOk = []; + quals = VIP_QUALITIES[d.vip_type] || VIP_QUALITIES[0]; + } + $('#account-qualities').textContent = '可解析:' + quals.map(function (q) { return q[1]; }).join('、'); + // 提示 + var note = $('#account-note'); + if (isUser && !d.logged_in) { + note.innerHTML = '⚠️ 个人账号未登录,当前仍使用全局账号解析。可在「个人歌单」扫码登录你自己的账号。'; + } else if (isUser) { + note.innerHTML = '💡 当前使用个人账号解析与下载(' + escapeHtml(d.nickname || '') + ' · ' + escapeHtml(d.vip_name || '') + ')。可在「个人歌单」随时切换回全局账号。'; + } else if (!d.logged_in) { + note.innerHTML = '💡 小提示:可在「个人歌单」登录你自己的 SVIP 网易云音乐账号,并切换为个人账号解析,即可下载/试听更高音质。' + + (d.note ? '
⚠️ ' + escapeHtml(d.note) + + ';当前检测为「' + escapeHtml(d.vip_name || '普通用户') + '」。可在「管理员设置」重新扫码登录全局账号。' + : '
⚠️ 全局 Cookie 未关联有效账号信息,可在「管理员设置」重新扫码登录。'); + } else { + note.innerHTML = '💡 小提示:可在「个人歌单」登录你自己的 SVIP 网易云音乐账号,并切换为个人账号解析,即可下载/试听更高音质。'; + } + }) + .catch(function (err) { + if (gen !== _acctGen) return; + $('#account-vip-badge').textContent = '获取失败'; + // 探测失败清空 levelsOk——避免 auto 音质阶梯继续用 + // 旧账号(全局↔个人切换窗口)的能力列表逐级白试 + store.levelsOk = []; + toast(err.message || '获取账号信息失败', 'error'); + }); + } + $('#account-refresh').addEventListener('click', renderAccountBar); + + /* ================= Tab 切换 ================= */ + + + function switchTab(name) { + store.currentTab = name; + notify(); + // 切页签(含键盘回车激活,不产生 click)时关掉字号弹层, + // 否则 fs-pop-open 卡在 html 上,排队/任务浮窗在其他页签一直隐藏 + if (typeof setLyricFsPop === 'function') setLyricFsPop(false); + + + setTimeout(function () { + var input = { + search: '#search-input', + song: '#song-input', + playlist: '#playlist-input', + album: '#album-input', + artist: '#artist-input', + admin: '#admin-password' + }[name]; + if (input) { + var el = $(input); + if (el && !el.value) el.focus(); + } + }, 60); + // 进入榜单 Tab 时按需加载 + if (name === 'toplist' && !store.toplistLoaded) loadToplists(); + // 进入管理员设置:未验证则停留在密码门禁 + if (name === 'admin' && !store.adminAuthed) { + $('#admin-gate').hidden = false; + $('#admin-content').hidden = true; + } + // 进入个人歌单:检查登录状态 + if (name === 'personal') personalCheckLogin(); + // 进入全局账号歌单:检查登录状态 + if (name === 'global') globalCheckLogin(); + // 进入播客:首次加载热门播客 + if (name === 'podcast' && !store.podcast.hotLoaded) loadPodcastHot(); + // 进入关于网站:渲染更新日志 + if (name === 'about' && !$('#about-changelog').innerHTML) renderAboutChangelog(); + // 进入个人中心:需登录 + if (name === 'usercenter' && window.renderSITEUserCenter) window.renderSITEUserCenter(); + // 进入管理后台:权限门由 site_auth.js 的 uaShowGate 处理 + //(原这里调用不存在的 renderUserAdmin(),浏览器抛 ReferenceError) + if (name === 'useradmin' && window.renderSITEUserCenter) window.renderSITEUserCenter(); + } + + $('#brand-home').addEventListener('click', function () { + window.scrollTo({ top: 0, behavior: 'smooth' }); + switchTab('search'); + }); + + /* ================= 工具函数 ================= */ + function extractId(text) { + text = (text || '').trim(); + if (!text) return ''; + if (text.indexOf('163cn.tv') !== -1) return text; + var m = text.match(/[?&]id=(\d+)/); + if (m) return m[1]; + var n = text.match(/\b\d{4,}\b/); + if (n) return n[0]; + return text; + } + + function escapeHtml(str) { + return String(str == null ? '' : str) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + } + + function fmtTime(sec) { + if (isNaN(sec) || sec < 0) sec = 0; + var m = Math.floor(sec / 60); + var s = Math.floor(sec % 60); + return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s; + } + + function fmtCount(n) { + n = n || 0; + if (n >= 1e8) return (n / 1e8).toFixed(1) + '亿'; + if (n >= 1e4) return (n / 1e4).toFixed(1) + '万'; + return String(n); + } + + function fmtRelative(ts) { + if (!ts) return ''; + var diff = Math.floor(Date.now() / 1000 - ts / 1000); + if (diff < 60) return '刚刚'; + if (diff < 3600) return Math.floor(diff / 60) + ' 分钟前'; + if (diff < 86400) return Math.floor(diff / 3600) + ' 小时前'; + if (diff < 86400 * 30) return Math.floor(diff / 86400) + ' 天前'; + var d = new Date(ts); + return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + + String(d.getDate()).padStart(2, '0'); + } + + function levelName(level) { + if (level === 'auto') return '最高音质(自动)'; + var map = { + standard: '标准音质', exhigh: '极高音质', lossless: '无损音质', + hires: 'Hi-Res音质', sky: '沉浸环绕声', jyeffect: '高清环绕声', + jymaster: '超清母带', dolby: '杜比全景声' + }; + return map[level] || level; + } + + function apiPost(url, params) { + var body = Object.keys(params).map(function (k) { + return encodeURIComponent(k) + '=' + encodeURIComponent(params[k] == null ? '' : params[k]); + }).join('&'); + var attempt = function () { + // 请求超时:常规 15 秒;歌单/专辑/歌手/播客解析这类重端点放宽到 45 秒 + // (大歌单服务端处理较久);AbortController 老内核缺失时优雅降级为无超时 + var heavy = /\/(playlist|album|artist_songs|podcast\/parse)$/.test(url); + var ctl = (typeof AbortController !== 'undefined') ? new AbortController() : null; + var timer = null; + if (ctl) timer = setTimeout(function () { try { ctl.abort(); } catch (e) {} }, heavy ? 45000 : 15000); + return fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, + body: body, + signal: ctl ? ctl.signal : undefined + }).then(function (r) { + // 超时计时器在"响应正文读完"之后再清除 + //(原实现在 r.json() 之前 clear,正文读取阶段失去超时保护) + return r.json().catch(function () { throw new Error('服务响应异常'); }) + .then(function (data) { if (timer) clearTimeout(timer); return data; }, + function (err) { if (timer) clearTimeout(timer); throw err; }); + }, function (err) { + if (timer) clearTimeout(timer); + throw err; + }).then(function (data) { + // 统一处理强制登录:未登录 / 次数耗尽时弹出登录框 + if (data && data.status === 401 && (data.error_code === 'unauthorized' || data.error_code === 'expired')) { + if (url.indexOf('/auth/') !== 0) { + safeAuthExpired(data.message || '请先登录', data.error_code === 'expired'); + } + } + return data; + }); + }; + // 网络层失败自动重试 1 次(丢包/断流/超时): + // 仅限幂等查询端点——下载提交/任务操作/评论/收藏/Cookie/扫码/登录一律不重试, + // 防止写操作重复执行;服务端有明确响应(4xx/5xx)不重试。 + var retriable = url.indexOf('download') === -1 + && url.indexOf('/auth/') !== 0 + && url.indexOf('/api/task') === -1 + && url.indexOf('/user/') === -1 + && url.indexOf('/comments') === -1; + return attempt().catch(function (err) { + var networkLevel = err && (err.name === 'TypeError' || err.name === 'AbortError' || + err.message === '服务响应异常'); + if (retriable && networkLevel) { + return new Promise(function (res) { setTimeout(res, 700); }).then(attempt); + } + throw err; + }); + } + + /* 登录前置检查:未登录时弹窗提示并打开登录框(状态由 site_auth.js 同步) */ + function requireLogin() { + if (window.SITE_LOGGED_IN) return true; + if (window.handleAuthExpired) { + window.handleAuthExpired('解析功能需要登录后才能使用', false); + } else { + toast('解析功能需要登录后才能使用', 'warning'); + } + return false; + } + + function setBtnLoading(btn, loading, loadingText?: string) { + if (!btn) return; + if (loading) { + // 重入保护——已在加载中不覆盖原始标签(重复提交后原标签丢失) + if (!btn.dataset.originText) btn.dataset.originText = btn.textContent; + btn.dataset.loadingCount = String((parseInt(btn.dataset.loadingCount, 10) || 0) + 1); + btn.disabled = true; + btn.innerHTML = ' ' + escapeHtml(loadingText || '处理中...'); + } else { + var n = (parseInt(btn.dataset.loadingCount, 10) || 1) - 1; + if (n > 0) { btn.dataset.loadingCount = String(n); return; } // 还有在途请求,保持加载态 + btn.disabled = false; + delete btn.dataset.loadingCount; + if (btn.dataset.originText) { + btn.textContent = btn.dataset.originText; + delete btn.dataset.originText; + } + } + } + + /* ================= 解析历史 ================= */ + var HISTORY_KEY = 'netease_parse_history'; + function getHistory() { + // 结构校验——null/对象/坏成员一律回退为空数组, + // 损坏历史不再中断页面初始化或播放器更新 + var raw = null; + try { raw = JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]'); } catch (e) { return []; } + if (!Array.isArray(raw)) return []; + return raw.filter(function (h) { + return h && typeof h === 'object' && h.id != null && typeof h.name !== 'undefined'; + }).map(function (h) { + return { id: String(h.id), name: String(h.name == null ? h.id : h.name), t: Number(h.t) || 0 }; + }); + } + function addHistory(id, name) { + var list = getHistory(); + list = list.filter(function (h) { return String(h.id) !== String(id); }); + list.unshift({ id: String(id), name: name || String(id), t: Date.now() }); + if (list.length > 8) list.length = 8; + try { localStorage.setItem(HISTORY_KEY, JSON.stringify(list)); } catch (e) {} + renderHistory(); + } + function renderHistory() { + var list = getHistory(); + var row = $('#history-row'); + var box = $('#history-list'); + if (!list.length) { row.hidden = true; return; } + row.hidden = false; + box.innerHTML = list.map(function (h) { + return '' + + '' + escapeHtml(h.name) + '' + + '×'; + }).join(' '); + } + $('#history-list').addEventListener('click', function (e) { + var close = e.target.closest('.tag-close'); + if (close) { + var id = close.getAttribute('data-close'); + var list = getHistory().filter(function (h) { return String(h.id) !== id; }); + try { localStorage.setItem(HISTORY_KEY, JSON.stringify(list)); } catch (err) {} + renderHistory(); + return; + } + var tag = e.target.closest('.tag'); + if (tag) { + $('#song-input').value = tag.getAttribute('data-id'); + switchTab('song'); + parseSong(); + } + }); + renderHistory(); + + /* ================= 搜索 ================= */ + var _searchGen = 0; + function doSearch() { + if (!requireLogin()) return; + var keyword = $('#search-input').value.trim(); + if (!keyword) { toast('请输入搜索关键词', 'warning'); $('#search-input').focus(); return; } + var btn = $('#search-btn'); + var gen = ++_searchGen; // 代次守卫——回车入口绕过按钮禁用,迟到响应不得覆盖新结果 + setBtnLoading(btn, true, '搜索中...'); + apiPost('/search', { + keyword: keyword, + limit: $('#search-limit').value, + type: $('#search-type').value + }).then(function (data) { + if (gen !== _searchGen) return; // 迟到响应丢弃 + if (data.status !== 200) throw new Error(data.message || '搜索失败'); + renderSearchList(data.data || []); + }).catch(function (err) { + if (gen !== _searchGen) return; + toast(err.message || '搜索失败,请重试', 'error'); + }).finally(function () { + // setBtnLoading 是重入计数器——Enter 连发时两次 + // 调用各自 +1,finally 必须无条件配对复位,否则旧代次被 gen 拦下后 + // 计数永远无法归零、按钮永久卡死;代次条件只保留在 then/catch 的状态写入 + setBtnLoading(btn, false); + }); + } + + function renderSearchList(list) { + var box = $('#search-list'); + var result = $('#search-result'); + if (!list || !list.length) { + result.hidden = false; + $('#search-count').textContent = '共 0 条'; + box.innerHTML = '
🎵
未找到相关内容
'; + return; + } + $('#search-count').textContent = '共 ' + list.length + ' 条'; + box.innerHTML = list.map(function (item) { + var html = ''; + if (item.type === 'song') { + html = '
' + + '' + + '
' + + '
' + escapeHtml(item.name) + + '' + escapeHtml(item.album || '') + '
' + + '
' + escapeHtml(item.artists || '') + '
' + + '
' + + '
' + + '' + + '' + + '
'; + } else if (item.type === 'playlist') { + html = '
' + + '' + + '
' + + '
' + escapeHtml(item.name) + '歌单
' + + '
by ' + escapeHtml(item.creator || '未知') + ' · ' + fmtCount(item.trackCount) + ' 首
' + + '
' + + '
'; + } else if (item.type === 'album') { + html = '
' + + '' + + '
' + + '
' + escapeHtml(item.name) + '专辑
' + + '
' + escapeHtml(item.artists || '') + '
' + + '
' + + '
'; + } else if (item.type === 'artist') { + html = '
' + + '' + + '
' + + '
' + escapeHtml(item.name) + '歌手
' + + '
' + escapeHtml(item.alias || '') + '
' + + '
' + + '
'; + } + return html; + }).join(''); + result.hidden = false; + } + + $('#search-btn').addEventListener('click', doSearch); + $('#search-input').addEventListener('keydown', function (e) { if (e.key === 'Enter') doSearch(); }); + + /* ================= 单曲解析 ================= */ + function requestSong(params) { + // 附带解析账号;个人账号失效时自动回退全局账号 + params.account = store.parseAccount; + if (store.parseAccount === 'user') params.user_cookie = store.personal.cookie; + return apiPost('/song', params).then(function (data) { + if (data.status === 401 && store.parseAccount === 'user') { + store.parseAccount = 'admin'; + try { localStorage.setItem('netease_parse_account', 'admin'); } catch (e) {} + renderAccountBar(); + renderPersonalToggle(); + toast('个人账号未登录或已失效,已自动切换为全局账号解析', 'warning'); + params.account = 'admin'; + return apiPost('/song', params); + } + return data; + }); + } + + function apiResolveLevel(idInput) { + // 'auto':按账号实测可解析列表从高到低尝试,返回首个可解析的音质 + var lvl = $('#global-level').value; + if (lvl !== 'auto') return Promise.resolve(lvl); + var ladder = store.levelsOk && store.levelsOk.length ? store.levelsOk : AUTO_LADDER; + var idx = 0; + return new Promise(function (resolve) { + (function tryNext() { + if (idx >= ladder.length) { resolve('standard'); return; } + requestSong({ url: idInput, level: ladder[idx], type: 'url' }) + .then(function (d) { + // 返回的音质必须与请求一致才算真正解析成功(否则视为降级,继续尝试) + if (d.status === 200 && d.data && d.data.url && d.data.level === ladder[idx]) { + resolve(ladder[idx]); + } else { idx++; tryNext(); } + }) + .catch(function () { idx++; tryNext(); }); + })(); + }); + } + + var _parseGen = 0; // 单曲解析代次令牌(家族补漏——回车/搜索行/历史标签 + // 三个入口绕过按钮禁用,连点两首歌时迟到响应覆盖新结果) + function parseSong() { + if (!requireLogin()) return; + var raw = $('#song-input').value.trim(); + if (!raw) { toast('请输入歌曲ID或网易云音乐链接', 'warning'); $('#song-input').focus(); return; } + if (!cookieGate()) return; + var btn = $('#parse-btn'); + var gen = ++_parseGen; + setBtnLoading(btn, true, '解析中...'); + // 新解析一旦提交即收起上一首的结果——但 #player/#lyric-panel/ + // #comment-panel 都嵌套在 #song-result 卡片内部,整卡隐藏会把正在播放的 + // 旧歌播放器条一起藏掉(失败路径音频"隐身"继续播)。改为只藏 .song-detail + // 信息块(旧歌封面/名称/下载按钮),播放器照常可见可操作,歌词/评论照旧收起 + $('#song-result .song-detail').hidden = true; + $('#lyric-panel').hidden = true; + $('#comment-panel').hidden = true; + store.lyrics = []; + apiResolveLevel(raw) + .then(function (level) { + return requestSong({ url: raw, level: level, type: 'json' }); + }) + .then(function (data) { + if (gen !== _parseGen) return; // 已有更新的解析,丢弃迟到响应 + if (data.status !== 200) throw new Error((data.data && data.data.msg) || data.message || '解析失败'); + renderSongResult(data.data); + }) + .catch(function (err) { + if (gen !== _parseGen) return; + toast(err.message || '解析失败,请检查歌曲ID或链接', 'error'); + }) + .then(function () { setBtnLoading(btn, false); }, + function () { setBtnLoading(btn, false); }); + } + + function parseSongById(id, name) { + $('#song-input').value = id; + switchTab('song'); + $('#song-input').focus(); + if (name) toast('正在解析:' + name, 'info'); + parseSong(); + } + + function renderSongResult(d) { + store.currentSong = d; + store.global.playQueue = []; // 单曲解析时清空播放歌单队列,避免串歌 + store.global.playQueueIndex = 0; + store.lyrics = []; + store.rawLyric = null; + store.rawTlyric = null; + store.commentPage = 1; + store.commentTotal = 0; + store.commentReply = null; // 切歌清掉旧评论回复对象(防跨歌错配) + $('#detail-cover').src = d.pic || ''; + $('#detail-name').textContent = d.name || '未知歌曲'; + $('#detail-artist').textContent = d.ar_name || '未知歌手'; + $('#detail-album').textContent = d.al_name || '未知专辑'; + $('#detail-level').textContent = '音质:' + levelName(d.level); + $('#detail-size').textContent = d.size || '大小未知'; + + var tip = $('#detail-tip'); + if (!d.url) { + tip.hidden = false; + tip.textContent = '⚠️ 当前音质无法获取下载链接,可能是版权限制或音质不支持,请尝试其他音质。'; + } else { + tip.hidden = true; + } + + $('#song-result').hidden = false; + $('#song-result .song-detail').hidden = false; // 恢复提交时收起的信息块 + window.scrollTo({ top: $('#song-result').offsetTop - 80, behavior: 'smooth' }); + + // 重置歌词偏移校准 + store.lyricOffset = 0; + $('#lyric-offset-label').textContent = '偏移 0.00s'; + + addHistory(d.id, d.name); + + // 歌词 + initLyrics(d.lyric || '', d.tlyric || '', d.yrc || ''); + + // 播放器 + if (d.url) { + initPlayer(d.url, d.name || '', d.ar_name || '', d.pic || ''); + } else { + stopPlayer(); + } + + // 评论区 + if (store.settings.showComments) { + $('#comment-panel').hidden = false; + updateCommentComposer(); + loadComments(d.id, 1, true); + } else { + $('#comment-panel').hidden = true; + } + } + + $('#parse-btn').addEventListener('click', parseSong); + $('#song-input').addEventListener('keydown', function (e) { if (e.key === 'Enter') parseSong(); }); + + /* ================= 歌词处理 ================= */ + function lrctrim(lyrics) { + var lines = String(lyrics || '').split('\n'); + var data = []; + lines.forEach(function (line, index) { + // 分钟放宽到 3 位(同型漏网——后端 SRT 当年修了, + // 前端没修:超 100 分钟曲目第 100 分钟起歌词行全部丢失) + var matches = line.match(/\[(\d{1,3}):(\d{1,2}[.:]?\d*)]/); + if (matches) { + var minutes = parseInt(matches[1], 10); + var seconds = parseFloat(matches[2].replace(':', '.')) || 0; + var timestamp = minutes * 60 + seconds; + var text = line.replace(/\[\d{1,3}:\d{1,2}[.:]?\d*]/g, '').trim(); + text = text.replace(/\s\s+/g, ' '); + if (text) data.push({ time: timestamp, text: text, index: index }); + } + }); + data.sort(function (a, b) { return a.time - b.time; }); + return data; + } + + function mergeTranslation(lyric, tlyric) { + var trans = lrctrim(tlyric); + if (!trans.length) return lyric; + // 精确匹配优先,其次取时间最近者;每条译文只配一行原文, + // 不再被"第一个邻近"遮蔽精确配对或重复使用 + var usedTrans = {}; + return lyric.map(function (l) { + var best = null, bestDt = Infinity, bestIdx = -1; + for (var i = 0; i < trans.length; i++) { + if (usedTrans[i]) continue; + var dt = Math.abs(trans[i].time - l.time); + if (dt > 0.5) { + if (trans[i].time > l.time + 0.5) break; + continue; + } + if (dt < bestDt - 1e-9) { bestDt = dt; best = trans[i]; bestIdx = i; } + if (dt === 0) break; // 精确命中立即采用 + } + if (best && best.text) { + usedTrans[bestIdx] = true; + // 保留纯原文——渲染层还原原文时不再依赖对拼接串做正则截断 + //(原文/译文本身含 " (翻译:" 时贪婪正则会吃掉原文尾部) + var merged = { time: l.time, end: l.end, words: l.words, trans: best.text, index: l.index, orig: l.text }; + if (store.settings.lyric2line) { + merged.text = l.text + '\n' + best.text; + } else { + merged.text = l.text + ' (翻译:' + best.text + ')'; + } + return merged; + } + return l; + }); + } + + // 解析网易云 YRC 逐字歌词:行头 [startMs,durMs],每字 (startMs,durMs,0)字 + // 字与字之间的正文允许包含半角括号——只把"合法时间标记"当分隔符, + // 不再把 (world) 这类正文当标记吃掉 + function parseYrc(text) { + var out = []; + String(text || '').split('\n').forEach(function (line) { + var m = line.match(/^\[(\d+),(\d+)\]/); + if (!m) return; + var start = parseInt(m[1], 10) / 1000, dur = parseInt(m[2], 10) / 1000; + if (dur <= 0) return; // [0,0] 元数据行 + var rest = line.slice(m[0].length); + var marks = []; + var re = /\((\d+),(\d+)(?:,\d+)?\)/g, w; + while ((w = re.exec(rest)) !== null) { + marks.push({ s: parseInt(w[1], 10) / 1000, d: parseInt(w[2], 10) / 1000, start: w.index, end: w.index + w[0].length }); + } + var words = [], chars = ''; + for (var i = 0; i < marks.length; i++) { + // 字的正文 = 本标记之后到下一标记之前(保留其中的一切字符,含括号) + var segEnd = (i + 1 < marks.length) ? marks[i + 1].start : rest.length; + var wordText = rest.slice(marks[i].end, segEnd); + if (!wordText) continue; + words.push({ c: wordText, s: marks[i].s, d: marks[i].d }); + chars += wordText; + } + chars = chars.replace(/\s\s+/g, ' ').trim(); + if (!words.length || !chars) return; + out.push({ time: start, end: start + dur, text: chars, words: words, index: out.length }); + }); + // YRC 时间轴排序,覆盖已排序 LRC 前先规范时间轴 + out.sort(function (a, b) { return a.time - b.time; }); + out.forEach(function (l, i) { l.index = i; }); + return out; + } + + // 由原始歌词构建行数据:LRC 为底,YRC 逐字轴覆盖过半时替换, + // 未被覆盖的 LRC 行保留(不再整体丢弃剩余歌词行),再合并翻译 + function buildLyricLines() { + var lines = lrctrim(store.rawLyric || ''); + var wordLines = parseYrc(store.rawYrc || ''); + if (wordLines.length && wordLines.length >= Math.max(1, Math.ceil(lines.length * 0.5))) { + // 把 LRC 里没有被 YRC 覆盖(±1s 内无对应行)的行补回来 + var merged = wordLines.slice(); + lines.forEach(function (l) { + var covered = wordLines.some(function (w) { return Math.abs(w.time - l.time) <= 1; }); + if (!covered) merged.push(l); + }); + merged.sort(function (a, b) { return a.time - b.time; }); + merged.forEach(function (l, i) { l.index = i; }); + lines = merged; + } + if (store.rawTlyric) lines = mergeTranslation(lines, store.rawTlyric); + return lines; + } + + function initLyrics(lrc, tlrc, yrc) { + store.rawLyric = lrc || ''; + store.rawTlyric = tlrc || ''; + store.rawYrc = yrc || ''; + store.lyrics = buildLyricLines(); + renderLyrics(); + var panel = $('#lyric-panel'); + panel.hidden = false; + if (!store.lyrics.length) { + $('#lyric-box').innerHTML = '
暂无歌词
'; + } + } + + function renderLyrics() { + var box = $('#lyric-box'); + if (!store.lyrics.length) { + box.innerHTML = '
暂无歌词
'; + return; + } + var twoLine = store.settings.lyric2line; + box.innerHTML = store.lyrics.map(function (l, i) { + var text = l.text || '♪'; + var cls = store.settings.ktv ? 'lyric-line ktv' : 'lyric-line'; + var inner; + if (store.settings.ktv && l.words && l.words.length) { + // 逐字行:每字一个 span,由 rAF 按真实字级时间轴推进高亮 + cls += ' kw-line'; + inner = l.words.map(function (w) { return '' + escapeHtml(w.c) + ''; }).join(''); + if (twoLine && l.trans) inner += '
' + escapeHtml(l.trans) + ''; + else if (l.trans) inner += ' (翻译:' + escapeHtml(l.trans) + ')'; + } else { + // 译文统一走 .ktr 弱化样式(与 KTV 分支同构,所有模式层级一致)。 + // 数据层把译文并进了 text(双行"原文\n译文"/单行"原文 (翻译:…)"), + // 优先用 mergeTranslation 保留的纯原文 orig,避免正则截断的边角误伤 + if (l.trans) { + var base = l.orig || text; + var orig = twoLine ? base.split('\n')[0] : base.replace(/ \(翻译:.*\)$/, ''); + inner = escapeHtml(orig); + if (twoLine) inner += '
' + escapeHtml(l.trans) + ''; + else inner += ' (翻译:' + escapeHtml(l.trans) + ')'; + } else { + inner = twoLine ? escapeHtml(text).replace(/\n/g, '
') : escapeHtml(text); + } + } + return '
' + inner + '
'; + }).join(''); + // 新歌歌词从顶部开始,避免残留上一首歌的滚动位置 + box.scrollTop = 0; + // 重绘后行元素已重建,KTV 填充缓存与行高亮状态一并重置 + // 行元素随重绘缓存——applyActiveLyric 由 rAF 以 60fps 调用, + // 原实现每帧 querySelectorAll 全部歌词行(百行 × 60fps),低端机无谓 CPU + store.lastActiveLine = -1; + store.ktvFillIdx = -1; + store.ktvWordEls = null; + store.ktvLineEls = Array.prototype.slice.call(box.children); + } + + $('#lyric-box').addEventListener('click', function (e) { + var line = e.target.closest('.lyric-line'); + if (!line) return; + var i = parseInt(line.getAttribute('data-i'), 10); + var audio = $('#player audio'); + if (audio && audio.src && store.lyrics[i]) { + // 点击定位与高亮使用互逆变换——高亮是 currentTime+offset, + // 定位就必须 currentTime = 行时间 - offset(原实现同向相加,点哪句跳错句) + audio.currentTime = Math.max(0, store.lyrics[i].time - store.lyricOffset); + // 裸 play() 的 Promise 拒绝会喂错误上报(token 过期后点歌词必现) + if (audio.paused) audio.play().catch(function () {}); + } + }); + + // 歌词偏移校准 + function applyLyricOffset(delta) { + store.lyricOffset = Math.max(-30, Math.min(30, Math.round((store.lyricOffset + delta) * 100) / 100)); + $('#lyric-offset-label').textContent = '偏移 ' + (store.lyricOffset > 0 ? '+' : '') + store.lyricOffset.toFixed(2) + 's'; + toast('歌词偏移:' + (store.lyricOffset > 0 ? '+' : '') + store.lyricOffset.toFixed(2) + ' 秒', 'info'); + var audio = $('#player audio'); + if (audio && !audio.paused) updateActiveLyric(audio.currentTime); + } + $('#lyric-delay').addEventListener('click', function () { applyLyricOffset(0.25); }); + $('#lyric-advance').addEventListener('click', function () { applyLyricOffset(-0.25); }); + + // 歌词字号调节(A± 弹层):0.7–1.6 倍、5% 步进,立即生效并持久化; + // 缩放通过 CSS 变量 --lyric-fs 挂在 #lyric-box 上,歌词重绘(切歌/切开关)后依然有效 + var LYRIC_FONT_MIN = 0.7, LYRIC_FONT_MAX = 1.6, LYRIC_FONT_STEP = 0.05; + function clampLyricFont(v) { + if (typeof v !== 'number' || !isFinite(v)) return 1; + return Math.max(LYRIC_FONT_MIN, Math.min(LYRIC_FONT_MAX, Math.round(v * 20) / 20)); + } + function applyLyricFont() { + store.settings.lyricFont = clampLyricFont(store.settings.lyricFont); + $('#lyric-box').style.setProperty('--lyric-fs', String(store.settings.lyricFont)); + $('#lyric-fs-val').textContent = Math.round(store.settings.lyricFont * 100) + '%'; + $('#lyric-fs-dec').disabled = store.settings.lyricFont <= LYRIC_FONT_MIN; + $('#lyric-fs-inc').disabled = store.settings.lyricFont >= LYRIC_FONT_MAX; + } + function lyricFsPopOpen() { return !$('#lyric-fs-pop').hidden; } + function setLyricFsPop(open) { + $('#lyric-fs-pop').hidden = !open; + $('#lyric-fontsize').setAttribute('aria-expanded', open ? 'true' : 'false'); + // 弹层开着时隐藏排队/任务浮窗,贴顶场景不再被盖住 + document.documentElement.classList.toggle('fs-pop-open', open); + if (open) applyLyricFont(); + } + function nudgeLyricFont(dir) { + store.settings.lyricFont = clampLyricFont(store.settings.lyricFont + dir * LYRIC_FONT_STEP); + applyLyricFont(); + saveSettings(); + } + $('#lyric-fontsize').addEventListener('click', function () { setLyricFsPop(lyricFsPopOpen() ? false : true); }); + $('#lyric-fs-inc').addEventListener('click', function () { nudgeLyricFont(1); }); + $('#lyric-fs-dec').addEventListener('click', function () { nudgeLyricFont(-1); }); + $('#lyric-fs-reset').addEventListener('click', function () { + store.settings.lyricFont = 1; + applyLyricFont(); + saveSettings(); + toast('歌词字号已恢复默认', 'info'); + }); + // 点弹层与触发按钮以外的任意位置关闭(捕获期,不干扰歌词行点击等其他处理器) + document.addEventListener('click', function (e) { + if (!lyricFsPopOpen()) return; + if (e.target.closest('#lyric-fs-pop') || e.target.closest('#lyric-fontsize')) return; + setLyricFsPop(false); + }, true); + // 捕获期监听(先于冒泡处理器执行)——抽屉/模态开着时让上层 + // 先消费 Escape 并跳过,避免同一次按键把键盘焦点偷换到遮罩后面的 A± 按钮 + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape' && lyricFsPopOpen()) { + if (document.querySelector('.drawer.show, .modal-mask.show')) return; + setLyricFsPop(false); + $('#lyric-fontsize').focus(); + } + }, true); + + function findActiveLyric(t) { + var active = -1; + for (var i = 0; i < store.lyrics.length; i++) { + if (store.lyrics[i].time <= t + 0.2) active = i; + else break; + } + return active; + } + + // KTV 填充:有逐字轴的行按字推进(唱到哪个字填到哪个字); + // 无 YRC 的行不再做仿逐字整行渐变(owner 2026-09-22 拍板撤销),仅普通高亮,无需逐帧写 --p + function fillKtvLine(el, idx, t) { + var line = store.lyrics[idx]; + if (!el || !line) return; + if (line.words && line.words.length) { + if (store.ktvFillIdx !== idx) { + store.ktvFillIdx = idx; + store.ktvWordEls = el.querySelectorAll('.kw'); + } + var spans = store.ktvWordEls; + for (var i = 0; i < line.words.length; i++) { + var w = line.words[i], sp = spans[i]; + if (!sp) break; + if (t >= w.s + w.d) { + if (!sp.classList.contains('on')) { sp.classList.add('on'); sp.classList.remove('cur'); } + } else if (t >= w.s) { + if (!sp.classList.contains('cur')) { sp.classList.add('cur'); sp.classList.remove('on'); } + sp.style.setProperty('--pw', Math.min(100, (t - w.s) / w.d * 100) + '%'); + } else if (sp.classList.contains('on') || sp.classList.contains('cur')) { + sp.classList.remove('on'); + sp.classList.remove('cur'); + } + } + } + } + + function applyActiveLyric(active, t) { + // 优先用缓存行(renderLyrics 时重建),缓存缺失(外部清空 lyric-box 等)才回退查询 + var lines = store.ktvLineEls && store.ktvLineEls.length ? store.ktvLineEls + : Array.prototype.slice.call($('#lyric-box').children); + for (var j = 0; j < lines.length; j++) { + lines[j].classList.toggle('active', j === active); + } + if (active >= 0 && store.settings.ktv) fillKtvLine(lines[active], active, t); + if (active >= 0 && active !== store.lastActiveLine) { + store.lastActiveLine = active; + var box = $('#lyric-box'); + var el = lines[active]; + if (el) { + var top = el.offsetTop - box.clientHeight / 2 + el.clientHeight / 2; + box.scrollTo({ top: Math.max(0, top), behavior: 'smooth' }); + } + } + } + + function updateActiveLyric(time) { + var t = time + store.lyricOffset; + applyActiveLyric(findActiveLyric(t), t); + } + + // KTV 帧循环:播放中按 60fps 平滑推进填充(timeupdate 仅约 4Hz,直接驱动会显得卡顿) + function ktvFrame() { + if (!store.ktvRunning) return; + var audio = $('#player audio'); + if (!audio || audio.paused) { store.ktvRunning = false; return; } + var t = audio.currentTime + store.lyricOffset; + applyActiveLyric(findActiveLyric(t), t); + requestAnimationFrame(ktvFrame); + } + function ktvStart() { + if (!store.ktvRunning && store.settings.ktv) { + store.ktvRunning = true; + requestAnimationFrame(ktvFrame); + } + } + + /* ================= 评论区(分页,10条/页) ================= */ + function loadComments(songId, page, reset) { + if (reset) { + page = 1; + store.commentPage = 1; + store.commentTotal = 0; + } + var offset = (page - 1) * 10; + store._commentsLoading = true; // 防止评论开关快速切换时重复加载 + // 评论翻页代次守卫——快速连点下一页时迟到旧页响应 + // 不得把页码与内容一起回滚(只防了切歌,这里补同歌翻页) + store._commentGen = (store._commentGen || 0) + 1; + var gen = store._commentGen; + apiPost('/comments', { id: songId, limit: 10, offset: offset }) + .then(function (data) { + if (gen !== store._commentGen) return; // 迟到响应丢弃 + if (data.status !== 200) throw new Error(data.message || '获取评论失败'); + // 迟到响应不得覆盖已切歌的评论区(评论对象以 songId 为准) + if (store.currentSong && String(store.currentSong.id) !== String(songId)) return; + var d = data.data; + store.commentTotal = d.total || 0; + $('#comment-count').textContent = store.commentTotal ? ('(' + fmtCount(store.commentTotal) + ')') : ''; + + if (page === 1) { + var hot = d.hotComments || []; + $('#comment-hot-title').hidden = !hot.length; + $('#comment-hot-list').innerHTML = hot.length ? hot.map(commentHtml).join('') : + '
暂无热门评论
'; + } + + var list = d.comments || []; + $('#comment-all-list').innerHTML = list.length ? list.map(commentHtml).join('') : + '
暂无评论
'; + + store.commentPage = page; + renderCommentPager(); + }) + .catch(function (err) { + if (gen !== store._commentGen) return; // 迟到响应丢弃(与 then 同口径) + toast(err.message || '获取评论失败', 'error'); + }) + .then(function () { if (gen === store._commentGen) store._commentsLoading = false; }, + function () { if (gen === store._commentGen) store._commentsLoading = false; }); + } + + function renderCommentPager() { + var pages = Math.max(1, Math.ceil(store.commentTotal / 10)); + var cur = store.commentPage; + $('#comment-page-info').textContent = '第 ' + cur + ' / ' + pages + ' 页'; + $('#comment-prev').disabled = cur <= 1; + $('#comment-next').disabled = cur >= pages; + $('#comment-pager').hidden = store.commentTotal <= 10; + } + + $('#comment-prev').addEventListener('click', function () { + if (store.currentSong && store.commentPage > 1) { + loadComments(store.currentSong.id, store.commentPage - 1, false); + } + }); + $('#comment-next').addEventListener('click', function () { + if (store.currentSong) { + loadComments(store.currentSong.id, store.commentPage + 1, false); + } + }); + + /* ================= 发布评论 / 回复评论(个人账号) ================= */ + store.commentReply = null; // {id, nickname} + + function updateCommentComposer() { + var composer = $('#comment-composer'); + if (!composer) return; + composer.hidden = !store.personal.authed; + if (!store.personal.authed) { + store.commentReply = null; + $('#comment-reply-to').hidden = true; + return; + } + $('#comment-input').placeholder = store.commentReply + ? '回复 @' + store.commentReply.nickname + '...' : '说点什么吧...(140字以内)'; + } + + function setCommentReply(id, nickname) { + store.commentReply = { id: id, nickname: nickname }; + $('#comment-reply-name').textContent = nickname; + $('#comment-reply-to').hidden = false; + updateCommentComposer(); + $('#comment-input').focus(); + } + + $('#comment-reply-cancel').addEventListener('click', function () { + store.commentReply = null; + $('#comment-reply-to').hidden = true; + updateCommentComposer(); + }); + + document.addEventListener('click', function (e) { + var rb = e.target.closest('.comment-reply-btn'); + if (rb) { + setCommentReply(rb.getAttribute('data-id'), rb.getAttribute('data-nickname')); + } + // 回复内容「详情」按钮:展开/收起完整内容 + var detail = e.target.closest('.cr-detail'); + if (detail) { + var contentEl = detail.closest('.comment-reply').querySelector('.cr-content'); + if (!contentEl) return; + if (detail.classList.contains('expanded')) { + detail.classList.remove('expanded'); + detail.textContent = '详情'; + contentEl.textContent = contentEl.getAttribute('data-short'); + contentEl.style.whiteSpace = 'nowrap'; + contentEl.style.overflow = 'hidden'; + } else { + detail.classList.add('expanded'); + detail.textContent = '收起'; + contentEl.textContent = contentEl.getAttribute('data-full'); + contentEl.style.whiteSpace = 'normal'; + contentEl.style.overflow = 'visible'; + contentEl.style.wordBreak = 'break-word'; + } + } + }); + + $('#comment-submit').addEventListener('click', function () { + if (!store.personal.authed || !store.personal.cookie) { + toast('请先在「个人歌单」扫码登录你的账号后再评论', 'warning'); + switchTab('personal'); + return; + } + if (!store.currentSong) { toast('请先解析一首歌曲', 'warning'); return; } + var content = $('#comment-input').value.trim(); + if (!content) { toast('评论内容不能为空', 'warning'); $('#comment-input').focus(); return; } + if (content.length > 140) { toast('评论内容不能超过 140 字', 'warning'); return; } + + var btn = $('#comment-submit'); + var isReply = !!store.commentReply; + var oldText = btn.textContent; + btn.disabled = true; + btn.textContent = '发布中...'; + apiPost('/user/comment', { + song_id: store.currentSong.id, + content: content, + action: isReply ? 2 : 1, + comment_id: isReply ? store.commentReply.id : '', + user_cookie: store.personal.cookie + }) + .then(function (data) { + if (data.status !== 200) throw new Error(data.message || '评论失败'); + toast(isReply ? '✅ 回复成功' : '✅ 评论成功', 'success'); + $('#comment-input').value = ''; + store.commentReply = null; + $('#comment-reply-to').hidden = true; + updateCommentComposer(); + loadComments(store.currentSong.id, 1, true); // 刷新评论 + }) + .catch(function (err) { + toast(err.message || '评论失败', 'error'); + }) + .finally(function () { + btn.disabled = false; + btn.textContent = oldText; + }); + }); + + function commentHtml(c) { + var u = c.user || {}; + var vip = ''; + if (u.vipType === 11 || u.vipType === 100) vip = '黑胶VIP'; + else if (u.vipType === 10 || u.vipType === 110) vip = '黑胶SVIP'; + else if (u.vipType === 1) vip = '音乐包'; + var location = u.location ? (' · ' + escapeHtml(u.location)) : ''; + // 被回复信息显示(昵称蓝色、内容10字内省略 + 详情按钮悬浮/点击查看完整) + var reply = ''; + if (c.beReplied && c.beReplied.length) { + var br = c.beReplied[0] || {}; + var rNick = br.nickname || ''; + var rContent = br.content || ''; + if (rNick || rContent) { + var rDisplay = rNick || '已删除用户'; + var rShort = rContent.length > 10 ? rContent.slice(0, 10) + '…' : rContent; + reply = '
' + + '回复' + + '@' + escapeHtml(rDisplay) + '' + + '' + escapeHtml(rShort) + '' + + (rContent.length > 10 + ? '' + : '') + + '
'; + } + } + // 回复按钮(个人账号登录后显示) + var replyBtn = store.personal.authed + ? '' + : ''; + return '
' + + '' + + '
' + + '
' + + '' + escapeHtml(u.nickname || '匿名') + '' + vip + + '' + fmtRelative(c.time) + location + '' + + '
' + + '
' + escapeHtml(c.content || '') + '
' + reply + + '
👍 ' + fmtCount(c.likedCount) + '' + + replyBtn + '
' + + '
'; + } + + + /* ================= 播放器 ================= */ + var playerState = {}; + + function initPlayer(url, name, artist, cover) { + var player = $('#player'); + player.hidden = false; + // 与播客试听互斥——开播音乐先停播客音频(反向在 podcastRenderProgram 处理) + try { + var pa = $('#podcast-audio'); + if (pa && !pa.paused) pa.pause(); + } catch (e) {} + // 关键:先停止并清空上一个音频,避免播放旧歌曲导致歌词与歌曲不对应 + var oldAudio = player.querySelector('audio'); + if (oldAudio) { + oldAudio.pause(); + oldAudio.removeAttribute('src'); + oldAudio.load(); + } + $('#player-cover').src = cover; + $('#player-name').textContent = name; + $('#player-artist').textContent = artist; + $('#player-toggle').textContent = '▶'; + $('#player-duration').textContent = '00:00'; + $('#player-current').textContent = '00:00'; + $('#player-progress-fill').style.width = '0%'; + playerState.url = url; + // 保留用户上一次的音量/静音选择(原实现每次切歌都重置为 0.9/取消静音) + if (typeof playerState.volume !== 'number') { playerState.volume = 0.9; playerState.muted = false; } + updateVolumeUI(); + updateMuteUI(); + // SMTC:系统媒体控制面板显示播放信息 + if (store.settings.smtc && 'mediaSession' in navigator && 'MediaMetadata' in window) { + try { + navigator.mediaSession.metadata = new MediaMetadata({ + title: name, + artist: artist, + album: (store.currentSong && store.currentSong.al_name) || '', + artwork: cover ? [{ src: cover, sizes: '512x512' }] : [] + }); + // 系统媒体的 play/pause 分别对应明确的播放/暂停(toggle 不幂等, + // 面板点"暂停"曾被切换成继续播放) + navigator.mediaSession.setActionHandler('play', function () { + var a = ensureAudio(); + if (a && a.paused) togglePlay(); + }); + navigator.mediaSession.setActionHandler('pause', function () { + var a = ensureAudio(); + if (a && !a.paused) togglePlay(); + }); + navigator.mediaSession.setActionHandler('seekto', function (d) { + var a = ensureAudio(); + if (a && d.seekTime != null) a.currentTime = d.seekTime; + }); + } catch (e) {} + } else if ('mediaSession' in navigator) { + // 关闭 SMTC 设置时撤销既有处理器 + try { + navigator.mediaSession.setActionHandler('play', null); + navigator.mediaSession.setActionHandler('pause', null); + navigator.mediaSession.setActionHandler('seekto', null); + } catch (e) {} + } + $('#player').onclick = function (e) { + if (e.target.closest('.player-ctrl') || e.target.closest('.progress') || + e.target.closest('.volume-btn') || e.target.closest('.volume-slider')) return; + togglePlay(); + }; + } + + function stopPlayer() { + var player = $('#player'); + player.hidden = true; + var audio = player.querySelector('audio'); + if (audio) { audio.pause(); audio.removeAttribute('src'); audio.load(); } + } + + function ensureAudio() { + var player = $('#player'); + var audio = player.querySelector('audio'); + if (!audio) { + audio = document.createElement('audio'); + audio.preload = 'metadata'; + player.appendChild(audio); + + audio.addEventListener('timeupdate', function () { + $('#player-current').textContent = fmtTime(audio.currentTime); + if (audio.duration) { + $('#player-progress-fill').style.width = + (audio.currentTime / audio.duration * 100) + '%'; + } + updateActiveLyric(audio.currentTime); + ktvStart(); // 兜底:中途开启 KTV 开关也能启动帧循环 + if (store.settings.progressMemory && store.currentSong) { + try { + var key = 'netease_progress_' + store.currentSong.id; + var last = parseFloat(localStorage.getItem(key) || '0'); + if (Math.abs(audio.currentTime - last) > 5) { + localStorage.setItem(key, audio.currentTime); + // 进度键无界清理(跳过场景永不删,长期用户无限累积) + // 超过 100 个键时清掉当前曲之外的全部(进度记忆为锦上添花) + var pk = []; + for (var i = 0; i < localStorage.length; i++) { + var k = localStorage.key(i); + if (k && k.indexOf('netease_progress_') === 0 && k !== key) pk.push(k); + } + if (pk.length >= 100) { + pk.forEach(function (k2) { localStorage.removeItem(k2); }); + } + } + } catch (e) {} + } + }); + audio.addEventListener('play', function () { ktvStart(); }); + + audio.addEventListener('loadedmetadata', function () { + $('#player-duration').textContent = fmtTime(audio.duration); + try { + if ('mediaSession' in navigator) { + navigator.mediaSession.setPositionState({ + duration: audio.duration || 0, + playbackRate: 1, + position: audio.currentTime || 0 + }); + } + } catch (e) {} + }); + audio.addEventListener('ended', function () { + $('#player-toggle').textContent = '▶'; + $('#player-progress-fill').style.width = '0%'; + $('#player-current').textContent = '00:00'; + try { if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'none'; } catch (e) {} + if (store.currentSong) { + try { localStorage.removeItem('netease_progress_' + store.currentSong.id); } catch (e) {} + } + // 播放歌单(公开播放 API 队列)自动连播下一首 + var gq = store.global && store.global.playQueue; + if (gq && gq.length) { + store.global.playQueueIndex = (store.global.playQueueIndex || 0) + 1; + if (store.global.playQueueIndex < gq.length) { + var nx = gq[store.global.playQueueIndex]; + // 连播切歌同步 currentSong/进度键——原实现 currentSong + // 仍是上一首,B 的播放进度会写进 A 的存储键 + store.currentSong = { id: nx.id, name: nx.name, ar_name: nx.artists || '', al_name: nx.album || '' }; + initPlayer(nx.url || '', nx.name || '', nx.artists || '', nx.picUrl || ''); + // 连播不带歌词/评论数据——清空旧歌词与评论区(原实现 + // 第二首起歌词还是上一首且 KTV 错位高亮、评论区不换) + store.rawLyric = null; + store.rawTlyric = null; + store.rawYrc = null; + store.lyrics = []; + renderLyrics(); + $('#comment-panel').hidden = true; + // 连播换歌同样清评论计数并重置歌词偏移——否则 A 的旧 + // commentTotal 挡住补加载(开关一扳就复活 A 的评论区), + // A 校准过的偏移也会带进 B 造成整片高亮错位 + store.commentTotal = 0; + store.commentPage = 1; + $('#comment-count').textContent = ''; + $('#comment-hot-list').innerHTML = '
暂无热门评论
'; + $('#comment-all-list').innerHTML = '
暂无评论
'; + store.lyricOffset = 0; + $('#lyric-offset-label').textContent = '偏移 0.00s'; + // 连播语义=顺序完整播放,跳过该曲的历史进度续播 + // (一次性标志,togglePlay 内消费;用户进度键保留不动) + store._queueNoResume = true; + toast('▶ 正在播放:' + (nx.name || ''), 'info'); + togglePlay(); + store._queueNoResume = false; + } else { + store.global.playQueue = []; + store.global.playQueueIndex = 0; + } + } + }); + audio.addEventListener('play', function () { + $('#player-toggle').textContent = '⏸'; + try { if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing'; } catch (e) {} + }); + audio.addEventListener('pause', function () { + $('#player-toggle').textContent = '▶'; + try { if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused'; } catch (e) {} + }); + audio.addEventListener('error', function () { + toast('音频播放失败:可能是链接失效或该格式不支持在线播放', 'error'); + $('#player-toggle').textContent = '▶'; + }); + } + return audio; + } + + function togglePlay() { + var audio = ensureAudio(); + var key = 'netease_progress_' + (store.currentSong ? store.currentSong.id : ''); + if (audio.paused && !audio.src) { + audio.src = playerState.url; + if (store.settings.progressMemory && !store._queueNoResume) { + // 连播场景(_queueNoResume)跳过续播,从头完整播放 + try { + var saved = parseFloat(localStorage.getItem(key)); + if (saved > 0) audio.currentTime = saved; // 续播:恢复到上次播放位置 + } catch (e) {} + } + audio.volume = playerState.volume; + audio.muted = playerState.muted; + } + if (audio.paused) { + audio.play().catch(function () { toast('播放失败,请稍后重试', 'error'); }); + } else { + audio.pause(); + if (store.settings.progressMemory) { + try { localStorage.setItem(key, audio.currentTime); } catch (e) {} + } + } + } + + $('#player-toggle').addEventListener('click', function (e) { + e.stopPropagation(); + togglePlay(); + }); + + $('#player-restart').addEventListener('click', function (e) { + e.stopPropagation(); + var audio = ensureAudio(); + if (audio.src) { + audio.currentTime = 0; + // 裸 play() 同样会喂 unhandledrejection 上报 + if (audio.paused) audio.play().catch(function () {}); + } else { + togglePlay(); + } + }); + + $('#player-progress').addEventListener('click', function (e) { + var rect = this.getBoundingClientRect(); + var ratio = (e.clientX - rect.left) / rect.width; + var audio = ensureAudio(); + if (audio.src && audio.duration) { + audio.currentTime = ratio * audio.duration; + } + }); + + function updateVolumeUI() { + $('#player-volume-fill').style.width = (playerState.volume * 100) + '%'; + } + function updateMuteUI() { + $('#player-mute').textContent = playerState.muted ? '🔇' : '🔊'; + var audio = $('#player audio'); + if (audio) { audio.muted = playerState.muted; audio.volume = playerState.volume; } + } + $('#player-volume').addEventListener('click', function (e) { + var rect = this.getBoundingClientRect(); + playerState.volume = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + playerState.muted = false; + updateVolumeUI(); + updateMuteUI(); + }); + $('#player-mute').addEventListener('click', function (e) { + e.stopPropagation(); + playerState.muted = !playerState.muted; + updateMuteUI(); + }); + + /* ================= 下载 ================= */ + function downloadSong(id, name) { + if (!id) { toast('无法获取音乐ID', 'error'); return; } + if (!cookieGate()) return; + apiResolveLevel(id) + .then(function (level) { + toast('正在准备下载:' + (name || id) + '(' + levelName(level) + ')...', 'info'); + doDownload(id, name, level); + }); + } + + function doDownload(id, name, level) { + // 同曲在途去重——双击「下载」会提交两个任务扣两次费 + // (批量入口当年做了去重,单曲入口漏了同型) + var inFlight = tasks.some(function (t) { + return t.label === ('单曲 · ' + (name || id)) && + (t.status === 'queued' || t.status === 'running' || t.status === 'paused'); + }); + if (inFlight) { toast('这首歌已在下载队列中,请勿重复提交', 'warning'); return; } + var cfg = downloadConfig(); + var taskId = genTaskId(); + apiPost('/download', { + id: id, + quality: level, + task_id: taskId, + device_id: getDeviceId(), + account: store.parseAccount, + user_cookie: store.parseAccount === 'user' ? store.personal.cookie : '', + filename_format: cfg.filename_format, + write_meta: cfg.write_meta, + meta_fields: cfg.meta_fields, + lyric_format: cfg.lyric_format, + zip: cfg.zip + }).then(function (data) { + if (data.status === 401 && store.parseAccount === 'user') { + store.parseAccount = 'admin'; + try { localStorage.setItem('netease_parse_account', 'admin'); } catch (e) {} + renderAccountBar(); + renderPersonalToggle(); + toast('个人账号未登录或已失效,已自动切换为全局账号下载', 'warning'); + doDownload(id, name, level); + return; + } + if (data.status !== 200) { + toast(data.message || '下载提交失败', 'error'); + return; + } + // 加入任务悬浮窗,完成后自动下载文件并通知 + addTask({ + id: taskId, type: 'single', + label: '单曲 · ' + (name || id), + total: 1, done: 0, status: 'queued' + }); + }).catch(function () { + toast('下载提交失败,请检查网络', 'error'); + }); + } + + /* ================= 下载歌词 ================= */ + function lyricToFormat(lines, fmt) { + // lines: [{time, text}];fmt: lrc | srt + if (fmt === 'srt') { + function srtTime(t) { + var h = Math.floor(t / 3600); + var m = Math.floor((t % 3600) / 60); + var s = Math.floor(t % 60); + var ms = Math.round((t - Math.floor(t)) * 1000); + if (ms >= 1000) ms = 999; + return String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') + ':' + + String(s).padStart(2, '0') + ',' + String(ms).padStart(3, '0'); + } + return lines.map(function (l, i) { + var end = lines[i + 1] ? lines[i + 1].time : l.time + 5; + // 同时间多行给 1 秒最小区间(前端同型漏网—— + // 后端当年修了倒挂区间,前端 lyricToFormat 没修,部分播放器拒收) + if (end <= l.time) end = l.time + 1; + // SRT 支持块内多行——双行模式原文/译文各占一行,不再删换行粘连 + return (i + 1) + '\n' + srtTime(l.time) + ' --> ' + srtTime(end) + '\n' + l.text + '\n'; + }).join('\n'); + } + return lines.map(function (l) { + var m = Math.floor(l.time / 60); + var s = l.time - m * 60; + var ms = Math.round((s - Math.floor(s)) * 1000); + // 双行模式换行改为" / "分隔(原实现直接删除,原文译文无缝粘连) + return '[' + String(m).padStart(2, '0') + ':' + String(Math.floor(s)).padStart(2, '0') + + '.' + String(Math.min(ms, 999)).padStart(3, '0') + ']' + l.text.replace(/\n/g, ' / '); + }).join('\n'); + } + + $('#detail-lyric-download').addEventListener('click', function () { + if (!store.currentSong || !store.lyrics.length) { + toast('暂无歌词可下载', 'warning'); + return; + } + var fmt = store.settings.lyricFormat === 'srt' ? 'srt' : 'lrc'; + var content = lyricToFormat(store.lyrics, fmt); + var blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = (store.currentSong.name || 'lyric') + '.' + fmt; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(function () { URL.revokeObjectURL(url); }, 3000); + toast('歌词已下载(' + fmt.toUpperCase() + ')', 'success'); + }); + + function fallbackCopy(text) { + var ta = document.createElement('textarea'); + ta.value = text; + document.body.appendChild(ta); + ta.select(); + // execCommand 返回 false 也是失败(原实现只在抛异常时报错,静默失败仍提示成功) + try { + var ok = document.execCommand('copy'); + if (ok) toast('已复制到剪贴板', 'success'); + else toast('复制失败,请手动复制', 'error'); + } + catch (e) { toast('复制失败,请手动复制', 'error'); } + document.body.removeChild(ta); + } + + /* ================= 歌单解析 ================= */ + // 合集渲染代次守卫——快速切换解析目标时,迟到的旧响应不再覆盖新页面 + function _collGen(type) { + store.collGen = store.collGen || {}; + store.collGen[type] = (store.collGen[type] || 0) + 1; + return store.collGen[type]; + } + function _collStale(type, gen) { + return store.collGen[type] !== gen; + } + function doPlaylist() { + if (!requireLogin()) return; + var raw = $('#playlist-input').value.trim(); + var id = extractId(raw); + if (!id) { toast('请输入歌单ID或链接', 'warning'); $('#playlist-input').focus(); return; } + var btn = $('#playlist-btn'); + var gen = _collGen('playlist'); + setBtnLoading(btn, true, '解析中...'); + apiPost('/playlist', { id: id }) + .then(function (data) { + if (_collStale('playlist', gen)) return; + if (data.status !== 200) throw new Error((data.data && data.data.msg) || data.message || '歌单解析失败'); + renderCollection('playlist', data.data.playlist); + }) + .catch(function (err) { + if (_collStale('playlist', gen)) return; + toast(err.message || '歌单解析失败', 'error'); + }) + .then(function () { setBtnLoading(btn, false); }, function () { setBtnLoading(btn, false); }); + } + + function loadPlaylistById(id, name) { + $('#playlist-input').value = id; + switchTab('playlist'); + $('#playlist-input').focus(); + if (name) toast('正在解析歌单:' + name, 'info'); + doPlaylist(); + } + + /* ================= 专辑解析 ================= */ + function doAlbum() { + if (!requireLogin()) return; + var raw = $('#album-input').value.trim(); + var id = extractId(raw); + if (!id) { toast('请输入专辑ID或链接', 'warning'); $('#album-input').focus(); return; } + var btn = $('#album-btn'); + var gen = _collGen('album'); + setBtnLoading(btn, true, '解析中...'); + apiPost('/album', { id: id }) + .then(function (data) { + if (_collStale('album', gen)) return; + if (data.status !== 200) throw new Error((data.data && data.data.msg) || data.message || '专辑解析失败'); + renderCollection('album', data.data.album); + }) + .catch(function (err) { + if (_collStale('album', gen)) return; + toast(err.message || '专辑解析失败', 'error'); + }) + .then(function () { setBtnLoading(btn, false); }, function () { setBtnLoading(btn, false); }); + } + + function loadAlbumById(id, name) { + $('#album-input').value = id; + switchTab('album'); + $('#album-input').focus(); + if (name) toast('正在解析专辑:' + name, 'info'); + doAlbum(); + } + + /* 歌曲行 HTML(absIndex 为绝对序号) */ + function songRowHtml(song, absIndex) { + return '
' + + '' + absIndex + '' + + '' + + '
' + + '
' + escapeHtml(song.name) + + '' + escapeHtml(song.album || '') + '
' + + '
' + escapeHtml(song.artists || '') + '
' + + '
' + + '
' + + '' + + '' + + '
'; + } + + /* 通用歌曲列表分页(10条/页),prefix 对应各区域 id 前缀 */ + var PAGE_SIZE = 10; + function renderPagedTracks(prefix, allTracks, page) { + allTracks = allTracks || []; + var total = allTracks.length; + var pages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + page = Math.max(1, Math.min(page || 1, pages)); + var start = (page - 1) * PAGE_SIZE; + var slice = allTracks.slice(start, start + PAGE_SIZE); + + var box = $('#' + prefix + '-tracks'); + if (!slice.length) { + box.innerHTML = '
📄
暂无歌曲(可能为受版权保护的歌曲)
'; + } else { + box.innerHTML = slice.map(function (song, i) { + return songRowHtml(song, start + i + 1); + }).join(''); + } + + var pager = $('#' + prefix + '-pager'); + if (pager) { + pager.hidden = total <= PAGE_SIZE; + $('#' + prefix + '-page-info').textContent = '第 ' + page + ' / ' + pages + ' 页'; + $('#' + prefix + '-prev').disabled = page <= 1; + $('#' + prefix + '-next').disabled = page >= pages; + } + var dlBtn = $('#' + prefix + '-download-all'); + if (dlBtn && total) dlBtn.textContent = '⬇ 批量下载全部(共 ' + total + ' 首)'; + store['page_' + prefix] = page; + store['tracks_' + prefix] = allTracks; + return page; + } + + /* 绑定各区域分页按钮 */ + ['playlist', 'album', 'artist', 'personal', 'global'].forEach(function (p) { + $('#' + p + '-prev').addEventListener('click', function () { + renderPagedTracks(p, store['tracks_' + p], store['page_' + p] - 1); + }); + $('#' + p + '-next').addEventListener('click', function () { + renderPagedTracks(p, store['tracks_' + p], store['page_' + p] + 1); + }); + }); + + function renderCollection(type, info) { + if (!info) { toast('解析结果为空', 'error'); return; } + var prefix = type; + ['playlist', 'album', 'artist'].forEach(function (t) { $('#' + t + '-result').hidden = true; }); + var card = $('#' + prefix + '-result'); + card.hidden = false; + + var tracks = info.tracks || info.songs || []; + var isPlaylist = prefix === 'playlist'; + // 残缺歌单显式提示(后端标记 trackIds 与实际拿到的曲目数不一致) + if (info.incomplete && info.missing) { + toast('注意:该歌单不完整,缺少 ' + info.missing + ' 首(可能版权受限或接口失败)', 'warning'); + } + + // 记录当前歌单/专辑(供收藏按钮使用) + store.currentCollection = { type: prefix, id: info.id, name: info.name }; + + $('#' + prefix + '-cover').src = info.coverImgUrl || ''; + $('#' + prefix + '-name').textContent = info.name || '未知'; + if (isPlaylist) { + $('#playlist-creator').textContent = 'by ' + (info.creator || '未知'); + $('#playlist-count').textContent = info.trackCount != null ? info.trackCount : tracks.length; + $('#playlist-desc').hidden = !(info.description && info.description.trim()); + if (!$('#playlist-desc').hidden) $('#playlist-desc').textContent = info.description; + } else { + $('#album-artist').textContent = info.artist ? ('歌手:' + info.artist) : ''; + $('#album-count').textContent = tracks.length; + $('#album-desc').hidden = !(info.description && info.description.trim()); + if (!$('#album-desc').hidden) $('#album-desc').textContent = info.description; + } + + renderPagedTracks(prefix, tracks, 1); + updateCollectButtons(); + window.scrollTo({ top: card.offsetTop - 80, behavior: 'smooth' }); + } + + $('#playlist-btn').addEventListener('click', doPlaylist); + $('#playlist-input').addEventListener('keydown', function (e) { if (e.key === 'Enter') doPlaylist(); }); + $('#album-btn').addEventListener('click', doAlbum); + $('#album-input').addEventListener('keydown', function (e) { if (e.key === 'Enter') doAlbum(); }); + + /* ================= 歌手解析 ================= */ + function doArtist() { + if (!requireLogin()) return; + var raw = $('#artist-input').value.trim(); + var id = extractId(raw); + if (!id) { toast('请输入歌手ID或链接', 'warning'); $('#artist-input').focus(); return; } + var btn = $('#artist-btn'); + var gen = _collGen('artist'); + setBtnLoading(btn, true, '解析中...'); + apiPost('/artist_songs', { id: id }) + .then(function (data) { + if (_collStale('artist', gen)) return; + if (data.status !== 200) throw new Error((data.data && data.data.msg) || data.message || '歌手解析失败'); + renderArtist(data.data); + }) + .catch(function (err) { + if (_collStale('artist', gen)) return; + toast(err.message || '歌手解析失败', 'error'); + }) + .then(function () { setBtnLoading(btn, false); }, function () { setBtnLoading(btn, false); }); + } + + function loadArtistById(id, name) { + $('#artist-input').value = id; + switchTab('artist'); + $('#artist-input').focus(); + if (name) toast('正在解析歌手:' + name, 'info'); + doArtist(); + } + + function renderArtist(data) { + ['playlist', 'album', 'artist'].forEach(function (t) { $('#' + t + '-result').hidden = true; }); + var card = $('#artist-result'); + card.hidden = false; + var artist = data.artist || {}; + var songs = data.songs || []; + $('#artist-cover').src = artist.picUrl || ''; + $('#artist-name').textContent = artist.name || '未知歌手'; + $('#artist-alias').textContent = artist.alias || ''; + $('#artist-count').textContent = artist.musicSize != null ? artist.musicSize : songs.length; + $('#artist-desc').hidden = !(artist.briefDesc && artist.briefDesc.trim()); + if (!$('#artist-desc').hidden) $('#artist-desc').textContent = artist.briefDesc; + + if (!songs.length) { + $('#artist-tracks').innerHTML = '
🎤
该歌手暂无热门歌曲
'; + } else { + renderPagedTracks('artist', songs, 1); + } + window.scrollTo({ top: card.offsetTop - 80, behavior: 'smooth' }); + } + + $('#artist-btn').addEventListener('click', doArtist); + $('#artist-input').addEventListener('keydown', function (e) { if (e.key === 'Enter') doArtist(); }); + + /* ================= 榜单 ================= */ + store.toplistLoaded = false; + + function loadToplists() { + if (!requireLogin()) { + var _tl = document.getElementById('toplist-loading'); + if (_tl) { _tl.innerHTML = '
🔒
请先登录后查看榜单
'; } + return; + } + if (store.toplistLoaded) return; + $('#toplist-loading').hidden = false; + $('#toplist-grid').hidden = true; + apiPost('/toplist', {}) + .then(function (data) { + if (data.status !== 200) throw new Error(data.message || '获取榜单失败'); + var list = data.data || []; + // 空结果也标记为已加载(原文案"暂无榜单"可见即可), + // 但 loading/error 节点在完成后不再被 finally 一律隐藏 + store.toplistLoaded = true; + renderToplists(list); + }) + .catch(function (err) { + // 失败态保持可见(错误信息 + 重试按钮),不被 finally 藏起来 + $('#toplist-loading').hidden = false; + $('#toplist-loading').innerHTML = '
📊
' + escapeHtml(err.message || '获取榜单失败') + '
'; + }); + } + + function renderToplists(list) { + var grid = $('#toplist-grid'); + if (!list || !list.length) { + grid.hidden = true; + $('#toplist-loading').hidden = false; + $('#toplist-loading').innerHTML = '
📊
暂无榜单数据
'; + return; + } + $('#toplist-loading').hidden = true; // 有数据时才隐藏提示区 + grid.hidden = false; + grid.innerHTML = list.map(function (t) { + return '
' + + '' + + '
' + escapeHtml(t.name) + '
' + + '
' + escapeHtml(t.updateFrequency || '') + + (t.playCount ? ' · 播放 ' + fmtCount(t.playCount) : '') + '
' + + '
'; + }).join(''); + } + + $('#toplist-grid').addEventListener('click', function (e) { + var card = e.target.closest('.toplist-card'); + if (!card) return; + var id = card.getAttribute('data-id'); + var name = card.getAttribute('data-name'); + toast('正在加载榜单:' + name, 'info'); + $('#playlist-input').value = id; + switchTab('playlist'); + doPlaylist(); + }); + document.addEventListener('click', function (e) { + if (e.target.closest('#toplist-retry')) { + // 重试必须重置"已加载"标志(原实现标志仍真,重试是空操作) + store.toplistLoaded = false; + $('#toplist-loading').innerHTML = ''; + loadToplists(); + } + }); + + /* ================= 播客(电台 / 单集声音) ================= */ + var podcastHotCache = { categories: [] }; // 播客分类缓存 + + function podcastRadioCardHtml(r) { + return '
' + + '' + + '
' + escapeHtml(r.name) + '
' + + '
' + escapeHtml(r.category || '播客') + + (r.program_count ? ' · ' + escapeHtml(r.program_count) + ' 集' : '') + + (r.sub_count_text && r.sub_count_text !== '0' ? ' · 订阅 ' + escapeHtml(r.sub_count_text) : '') + '
' + + '
'; + } + + function renderPodcastCates() { + var bar = $('#podcast-cate-bar'); + var cats = [{ id: null, name: '热门推荐' }].concat(podcastHotCache.categories); + bar.innerHTML = cats.map(function (c) { + var active = (c.id == null && !store.podcast.hotCate) || String(c.id) === String(store.podcast.hotCate || ''); + return '' + escapeHtml(c.name) + ''; + }).join(''); + } + + var _podcastHotBusy = false; + function loadPodcastHot(cateId, append) { + var grid = $('#podcast-hot-grid'); + // 在途请求锁——双击"加载更多"不再用同一 offset 重复请求 + if (append && _podcastHotBusy) return; + var loadingHtml = '
🎙️
加载中...
'; + if (!append) grid.innerHTML = loadingHtml; + _podcastHotBusy = true; + apiPost('/podcast/hot', { limit: 30, offset: append ? store.podcast.hotOffset : 0, cate_id: cateId || '' }) + .then(function (data) { + if (data.status !== 200) throw new Error(data.message || '获取热门播客失败'); + var d = data.data || {}; + if (d.categories && d.categories.length) podcastHotCache.categories = d.categories; + store.podcast.hotLoaded = true; + store.podcast.hotCate = cateId || null; + store.podcast.hotOffset = (append ? store.podcast.hotOffset : 0) + (d.radios || []).length; + renderPodcastCates(); + var html = (d.radios || []).map(podcastRadioCardHtml).join(''); + if (append) grid.insertAdjacentHTML('beforeend', html); + else grid.innerHTML = html || '
🎙️
该分类暂无热门播客
'; + // 按服务端 has_more 显隐(空页/短页没有更多) + $('#podcast-hot-more-wrap').hidden = !(d.radios || []).length || !d.has_more; + }) + .catch(function (err) { + if (!append) grid.innerHTML = '
😕
' + escapeHtml(err.message || '加载失败') + '
'; + toast(err.message || '获取热门播客失败', 'error'); + }) + .then(function () { _podcastHotBusy = false; }, function () { _podcastHotBusy = false; }); + } + + $('#podcast-cate-bar').addEventListener('click', function (e) { + var item = e.target.closest('.podcast-cate-item'); + if (!item) return; + var cid = item.getAttribute('data-cate-id'); + loadPodcastHot(cid || null, false); + }); + $('#podcast-hot-more').addEventListener('click', function () { + loadPodcastHot(store.podcast.hotCate, true); + }); + $('#podcast-hot-grid').addEventListener('click', function (e) { + var card = e.target.closest('.toplist-card'); + if (!card) return; + $('#podcast-input').value = card.getAttribute('data-radio-id'); + podcastParse(); + }); + + /* ---- 解析入口 ---- */ + var _podcastGen = 0; + function podcastParse() { + if (!requireLogin()) return; + if (!cookieGate()) return; // 播客入口补齐撤回闸(原音乐四入口有、播客三入口漏) + var raw = $('#podcast-input').value.trim(); + if (!raw) { toast('请输入播客电台/单集链接或ID', 'warning'); return; } + var btn = $('#podcast-btn'); + var gen = ++_podcastGen; // 代次守卫——回车绕过按钮禁用,迟到响应不得覆盖新解析 + setBtnLoading(btn, true, '解析中...'); + podcastShowLoading(true); + $('#podcast-radio-result').hidden = true; + $('#podcast-program-result').hidden = true; + apiPost('/podcast/parse', { id: raw, quality: $('#podcast-quality').value }) + .then(function (data) { + if (gen !== _podcastGen) return; // 迟到响应丢弃 + if (data.status !== 200) throw new Error(data.message || '解析失败'); + var d = data.data || {}; + if (d.kind === 'radio') podcastRenderRadio(d); + else podcastRenderProgram(d.program, true); + }) + .catch(function (err) { + if (gen !== _podcastGen) return; + toast(err.message || '解析失败', 'error'); + }) + .finally(function () { + // 按钮无条件配对复位(重入计数器,见 doSearch); + // 全局 spinner 保持代次门——旧请求不提前收起新请求的 loading + setBtnLoading(btn, false); + if (gen === _podcastGen) podcastShowLoading(false); + }); + } + function podcastShowLoading(show) { $('#podcast-loading').hidden = !show; } + + $('#podcast-btn').addEventListener('click', podcastParse); + $('#podcast-input').addEventListener('keydown', function (e) { if (e.key === 'Enter') podcastParse(); }); + + /* ---- 电台渲染 ---- */ + function podcastRenderRadio(d) { + var radio = d.radio || {}; + store.podcast.radio = radio; + store.podcast.radioId = radio.id; + store.podcast.offset = (d.programs || []).length; + store.podcast.count = d.count || 0; + store.podcast.asc = true; + $('#podcast-radio-cover').src = radio.pic_url || ''; + $('#podcast-radio-name').textContent = radio.name || ''; + $('#podcast-radio-meta').textContent = + '主播:' + (radio.dj_name || '未知') + + (radio.category ? ' · 分类:' + radio.category : '') + + (radio.program_count ? ' · 共 ' + radio.program_count + ' 集' : '') + + (radio.sub_count_text && radio.sub_count_text !== '0' ? ' · 订阅 ' + radio.sub_count_text : ''); + $('#podcast-radio-desc').textContent = radio.desc || ''; + $('#podcast-radio-desc').hidden = !radio.desc; + $('#podcast-radio-order').textContent = '⇅ 最新在前'; + renderPodcastEpisodes(d.programs || [], false); + $('#podcast-radio-result').hidden = false; + $('#podcast-program-result').hidden = true; + $('#podcast-search-result').hidden = true; + } + + function podcastEpisodeRowHtml(p, idx) { + var serial = p.serial_num || (idx + 1); + var meta = []; + if (p.duration_text) meta.push(p.duration_text); + if (p.create_date) meta.push(p.create_date); + if (p.listener_count_text && p.listener_count_text !== '0') meta.push('收听 ' + p.listener_count_text); + return '
' + + '
' + escapeHtml(serial) + '
' + + '
' + + '
' + escapeHtml(p.name) + '
' + + '
' + escapeHtml(meta.join(' · ')) + '
' + + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
'; + } + + function renderPodcastEpisodes(programs, append) { + var box = $('#podcast-episodes'); + var html = programs.map(podcastEpisodeRowHtml).join(''); + if (append) box.insertAdjacentHTML('beforeend', html); + else box.innerHTML = html || '
该电台暂无单集
'; + $('#podcast-episode-count').textContent = store.podcast.count ? store.podcast.count + ' 集' : ''; + $('#podcast-more-wrap').hidden = !store.podcast.count || store.podcast.offset >= store.podcast.count; + } + + var _podcastMoreBusy = false; + $('#podcast-more').addEventListener('click', function () { + // 在途请求锁——双击"加载更多"不再用同一 offset 发两次请求 + //(原实现两次 append 相同页后 offset 跳两页,漏掉一页) + if (_podcastMoreBusy) return; + _podcastMoreBusy = true; + apiPost('/podcast/programs', { + radio_id: store.podcast.radioId, + limit: 30, offset: store.podcast.offset, asc: store.podcast.asc + }).then(function (data) { + if (data.status !== 200) throw new Error(data.message || '加载失败'); + var d = data.data || {}; + store.podcast.offset += (d.programs || []).length; + renderPodcastEpisodes(d.programs || [], true); + }).catch(function (err) { toast(err.message || '加载更多失败', 'error'); }) + .then(function () { _podcastMoreBusy = false; }, function () { _podcastMoreBusy = false; }); + }); + + $('#podcast-radio-order').addEventListener('click', function () { + var prevAsc = store.podcast.asc, prevOffset = store.podcast.offset; + store.podcast.asc = !store.podcast.asc; + this.textContent = store.podcast.asc ? '⇅ 最新在前' : '⇅ 第1集在前'; + store.podcast.offset = 0; + apiPost('/podcast/programs', { + radio_id: store.podcast.radioId, + limit: 30, offset: 0, asc: store.podcast.asc + }).then(function (data) { + if (data.status !== 200) throw new Error(data.message || '加载失败'); + var d = data.data || {}; + store.podcast.offset = (d.programs || []).length; + renderPodcastEpisodes(d.programs || [], false); + }).catch(function (err) { + // 排序失败回滚状态与按钮文案(原实现失败后界面方向与数据不一致) + store.podcast.asc = prevAsc; + store.podcast.offset = prevOffset; + // 原实现的 [0] 取值恒为 undefined 导致短路,恢复文案从未生效 + $('#podcast-radio-order').textContent = + store.podcast.asc ? '⇅ 最新在前' : '⇅ 第1集在前'; + toast(err.message || '切换排序失败', 'error'); + }); + }); + + /* ---- 整台打包 ---- */ + $('#podcast-radio-zip').addEventListener('click', function () { + if (!cookieGate()) return; // 整台打包补齐撤回闸 + if (!store.podcast.radioId) return; + var name = (store.podcast.radio && store.podcast.radio.name) || store.podcast.radioId; + var credits = (window.getSITECredits && window.getSITECredits()) || 0; + // 预检上限取 min(实际集数, 200)——原实现固定按 200 集比较, + // 3 集的小电台也会误报"不足以打包全部单集" + var limit = Math.min(500, store.podcast.count || 200); // 与后端 500 上限对齐(原 200 使 201-500 集电台永远打不满) + if (credits < 0) { + // 未登录明确拦截(原实现游客 -1 穿过预检,提交后才吃 401) + if (window.handleAuthExpired) window.handleAuthExpired('请先登录后再使用打包下载', false); + return; + } + if (credits >= 0) { + if (credits <= 0) { if (window.handleAuthExpired) window.handleAuthExpired('', true); return; } + if (credits < limit) { + if (!window.confirm('您的剩余下载次数为 ' + credits + ' 次,不足以打包全部单集。\n确定后将仅打包前 ' + credits + ' 集,是否继续?')) return; + limit = credits; + } + } + if (!window.confirm('将下载「' + name + '」的单集(最多 ' + limit + ' 集)并打包为 ZIP,大电台耗时较长,确认开始?')) return; + var taskId = genTaskId(); + apiPost('/podcast/download/batch_zip', { + radio_id: store.podcast.radioId, + limit: limit, + quality: $('#podcast-quality').value, + task_id: taskId, + device_id: getDeviceId() + }).then(function (data) { + if (data.status !== 200) throw new Error(data.message || '提交失败'); + addTask({ id: taskId, type: 'batch', label: '播客打包 · ' + name, total: 0, done: 0, status: 'queued' }); + toast('打包任务已提交,可在进度悬浮窗查看', 'success'); + }).catch(function (err) { toast(err.message || '打包提交失败', 'error'); }); + }); + + /* ---- 单集列表操作(事件委托) ---- */ + $('#podcast-episodes').addEventListener('click', function (e) { + var btn = e.target.closest('button[data-act]'); + if (!btn) return; + var act = btn.getAttribute('data-act'); + var pid = btn.getAttribute('data-id'); + var name = btn.getAttribute('data-name'); + if (act === 'detail') podcastOpenProgram(pid, name, true, btn); + else if (act === 'direct') podcastCopyDirectLink(pid, name, btn); + else if (act === 'download') podcastDownloadEpisode(pid, name); + }); + + /* ---- 单集详情 / 试听 ---- */ + function podcastRenderProgram(program, autoplay) { + store.podcast.program = program; + $('#podcast-program-cover').src = program.cover_url || ''; + $('#podcast-program-name').textContent = program.name || ''; + $('#podcast-program-radio').textContent = program.radio_name || '-'; + $('#podcast-program-dj').textContent = program.dj_name || '-'; + var meta = []; + if (program.duration_text) meta.push('时长 ' + program.duration_text); + if (program.create_date) meta.push('发布 ' + program.create_date); + if (program.serial_num) meta.push('第 ' + program.serial_num + ' 集'); + $('#podcast-program-meta').textContent = meta.join(' · ') || '-'; + var audio = program.audio; + var badges = $('#podcast-program-badges'); + var openBtn = $('#podcast-program-open'); + var canPlay = audio && audio.url; + badges.innerHTML = canPlay + ? '' + escapeHtml(audio.level_name || audio.level || '标准') + '' + + '' + escapeHtml((audio.type || 'mp3').toUpperCase()) + ' · ' + escapeHtml(audio.size_formatted || '') + '' + + (audio.br ? '' + Math.round(audio.br / 1000) + ' kbps' : '') + : '未获取到音频直链'; + var player = $('#podcast-audio'); + if (canPlay) { + // 与音乐播放器互斥——开播播客先暂停音乐 + try { + var ma = $('#player audio'); + if (ma && !ma.paused) ma.pause(); + } catch (e) {} + player.src = audio.url; + player.hidden = false; + // 试听音量随主播放器(原实现原生控件独立,主播放器调低音量后 + // 切播客试听仍 100% 首响) + try { + if (typeof playerState.volume === 'number') { + player.volume = playerState.volume; + player.muted = !!playerState.muted; + } + } catch (e) {} + openBtn.href = audio.url; + openBtn.hidden = false; + // 试听链接 36 分钟过期(=35 分钟保留期+60s),失效时给出明确指引(重新解析即可刷新) + player.onerror = function () { + toast('试听链接已过期或失效,请重新点击「试听」刷新', 'warning'); + }; + if (autoplay) { player.play().catch(function () { /* 自动播放被拦截时忽略 */ }); } + } else { + player.removeAttribute('src'); + player.hidden = true; + openBtn.hidden = true; + } + $('#podcast-program-tip').hidden = !!canPlay; + $('#podcast-program-tip').textContent = '该单集暂无可用音频(可能已下架或受版权限制)'; + $('#podcast-program-result').hidden = false; + $('#podcast-radio-result').hidden = true; + } + + function podcastOpenProgram(pid, name, autoplay, btn) { + var restore = function () { if (btn) setBtnLoading(btn, false); }; + if (btn) setBtnLoading(btn, true, '...'); + podcastShowLoading(true); + apiPost('/podcast/parse', { id: pid, quality: $('#podcast-quality').value }) + .then(function (data) { + if (data.status !== 200) throw new Error(data.message || '解析失败'); + podcastRenderProgram((data.data || {}).program, autoplay); + }) + .catch(function (err) { toast(err.message || '解析失败', 'error'); }) + .finally(function () { podcastShowLoading(false); restore(); }); + } + + /* ---- 直链复制 ---- */ + function podcastCopyDirectLink(pid, name, btn) { + var restore = function () { if (btn) setBtnLoading(btn, false); }; + if (btn) setBtnLoading(btn, true, '...'); + apiPost('/podcast/parse', { id: pid, quality: $('#podcast-quality').value }) + .then(function (data) { + if (data.status !== 200) throw new Error(data.message || '解析失败'); + var audio = ((data.data || {}).program || {}).audio; + if (!audio || !audio.url) throw new Error('未获取到音频直链'); + try { + navigator.clipboard.writeText(audio.url) + .then(function () { toast('直链已复制:' + name, 'success'); }) + .catch(function () { fallbackCopy(audio.url); }); + } catch (e2) { fallbackCopy(audio.url); } + }) + .catch(function (err) { toast(err.message || '获取直链失败', 'error'); }) + .finally(restore); + } + + /* ---- 单集下载 ---- */ + function podcastDownloadEpisode(pid, name) { + if (!requireLogin()) return; + if (!cookieGate()) return; // 单集下载补齐撤回闸 + var taskId = genTaskId(); + apiPost('/podcast/download', { + id: pid, + quality: $('#podcast-quality').value, + task_id: taskId, + device_id: getDeviceId() + }).then(function (data) { + if (data.status !== 200) throw new Error(data.message || '提交失败'); + addTask({ id: taskId, type: 'single', label: '播客 · ' + (name || pid), total: 1, done: 0, status: 'queued' }); + toast('下载任务已提交:' + name, 'success'); + }).catch(function (err) { toast(err.message || '下载提交失败', 'error'); }); + } + + /* ---- 单集详情卡片按钮 ---- */ + $('#podcast-program-download').addEventListener('click', function () { + var p = store.podcast.program; + if (!p) return; + podcastDownloadEpisode(p.id, p.name); + }); + + /* ---- 播客搜索 ---- */ + function podcastSearch() { + if (!requireLogin()) return; + var kw = $('#podcast-search-input').value.trim(); + if (!kw) { toast('请输入搜索关键词', 'warning'); return; } + var btn = $('#podcast-search-btn'); + setBtnLoading(btn, true, '搜索中...'); + apiPost('/podcast/search', { keyword: kw, limit: 20, type: 'all' }) + .then(function (data) { + if (data.status !== 200) throw new Error(data.message || '搜索失败'); + var d = data.data || {}; + var radios = d.radios || []; + var voices = d.voices || []; + var html = radios.map(function (r) { + var meta = []; + if (r.dj_name) meta.push('主播 ' + r.dj_name); + if (r.program_count) meta.push(r.program_count + ' 集'); + if (r.sub_count_text && r.sub_count_text !== '0') meta.push('订阅 ' + r.sub_count_text); + return '
' + + '' + + '
' + + '
' + escapeHtml(r.name) + '
' + + '
' + escapeHtml(meta.join(' · ')) + '
' + + '
' + + '
' + + '' + + '
'; + }).join(''); + html += voices.map(function (v) { + var meta = []; + if (v.radio_name) meta.push('来自「' + v.radio_name + '」'); + if (v.duration_text) meta.push(v.duration_text); + if (v.create_date) meta.push(v.create_date); + return '
' + + '' + + '
' + + '
' + escapeHtml(v.name) + '
' + + '
' + escapeHtml(meta.join(' · ')) + '
' + + '
' + + '
' + + '' + + '
'; + }).join(''); + $('#podcast-search-list').innerHTML = html || + '
未找到相关播客
'; + $('#podcast-search-count').textContent = (radios.length + voices.length) + ' 条'; + var tip = $('#podcast-search-tip'); + if (!voices.length && radios.length) { + tip.textContent = '💡 受网易接口限制,单集(声音)搜索可能无结果;可先解析电台后浏览全部单集。'; + tip.hidden = false; + } else tip.hidden = true; + $('#podcast-search-result').hidden = false; + }) + .catch(function (err) { toast(err.message || '搜索失败', 'error'); }) + .finally(function () { setBtnLoading(btn, false); }); + } + $('#podcast-search-btn').addEventListener('click', podcastSearch); + $('#podcast-search-input').addEventListener('keydown', function (e) { if (e.key === 'Enter') podcastSearch(); }); + $('#podcast-search-list').addEventListener('click', function (e) { + var radioBtn = e.target.closest('button[data-search-radio]'); + if (radioBtn) { + $('#podcast-input').value = radioBtn.getAttribute('data-search-radio'); + podcastParse(); + window.scrollTo({ top: 0, behavior: 'smooth' }); + return; + } + var voiceBtn = e.target.closest('button[data-search-voice]'); + if (voiceBtn) podcastOpenProgram(voiceBtn.getAttribute('data-search-voice'), voiceBtn.getAttribute('data-voice-name'), true, voiceBtn); + }); + + /* ================= 关于网站 ================= */ + function renderAboutChangelog() { + // 版本号与更新日志联动(原实现写死 v1.5.0,与日志最新 2.1.0 不一致) + var ver = $('#about-version'); + if (ver && CHANGELOG[0]) ver.textContent = 'v' + CHANGELOG[0].v; + $('#about-changelog').innerHTML = CHANGELOG.map(function (v) { + return '
' + + '
v' + v.v + '' + + '' + v.date + '
' + + '
    ' + v.items.map(function (i) { + return '
  • ' + escapeHtml(i) + '
  • '; + }).join('') + '
'; + }).join(''); + } + + /* ================= 管理员设置(二维码登录 / Cookie 管理) ================= */ + // 旧版前端写死口令已废除——本面板全部接口仅认站点管理员会话; + // 入口改为直接用会话探测(管理员已登录 site_uid 即放行,否则引导去管理后台) + + function adminEnter() { + apiPost('/admin/cookie/status', {}) + .then(function (data) { + if (data.status !== 200) throw new Error(data.message || '需要管理员权限'); + store.adminAuthed = true; + $('#admin-gate').hidden = true; + $('#admin-content').hidden = false; + toast('验证成功,欢迎进入管理员设置', 'success'); + adminLoadStatus(); + loadAdminLogs(); + }) + .catch(function (err) { + toast('请先用管理员账号登录站点,管理功能已并入「管理后台」页签', 'error'); + }); + } + + function adminLoadStatus() { + apiPost('/admin/cookie/status', {}) + .then(function (data) { + if (data.status !== 200) throw new Error(data.message || '获取状态失败'); + var info = data.data || {}; + var badge = $('#admin-status-badge'); + var desc = $('#admin-status-desc'); + if (info.is_valid) { + badge.textContent = '有效'; + badge.style.borderColor = 'var(--color-success)'; + badge.style.color = 'var(--color-success)'; + badge.style.background = 'rgba(82,196,26,0.1)'; + desc.textContent = '已登录,Cookie 自动保存,无需重复扫码。'; + } else { + badge.textContent = '未登录'; + badge.style.borderColor = 'var(--color-error)'; + badge.style.color = 'var(--color-error)'; + badge.style.background = 'rgba(255,77,79,0.1)'; + desc.textContent = info.file_exists ? + 'Cookie 文件存在但缺少关键字段,建议重新扫码登录。' : + '尚未配置 Cookie,请扫码登录。'; + } + $('#admin-cookie-file').textContent = info.file_path || '-'; + var parts = []; + if (info.cookie_count != null) parts.push('Cookie ' + info.cookie_count + ' 个'); + if (info.last_modified) parts.push('最后更新 ' + info.last_modified); + if (info.important_cookies_present && info.important_cookies_present.length) { + parts.push('包含 ' + info.important_cookies_present.join(', ')); + } + $('#admin-cookie-time').textContent = parts.join(' · ') || '-'; + }) + .catch(function (err) { + toast(err.message || '获取 Cookie 状态失败', 'error'); + }); + } + + function adminStopQrPoll() { + if (store.adminQrTimer) { + clearInterval(store.adminQrTimer); + store.adminQrTimer = null; + } + } + + function adminQrCreate() { + if (!store.adminAuthed) return; + setBtnLoading($('#admin-qr-btn'), true, '生成中...'); + apiPost('/admin/qr/create', {}) + .then(function (data) { + if (data.status !== 200) throw new Error(data.message || '生成二维码失败'); + var d = data.data; + store.adminUnikey = d.unikey; + var svg = d.qr_svg || ''; + var dataUri = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg))); + $('#admin-qr-img').src = dataUri; + $('#admin-qr-area').hidden = false; + $('#admin-qr-status').textContent = '请使用网易云音乐手机 APP 扫描二维码登录'; + $('#admin-qr-status').style.color = ''; + toast('二维码已生成,请用手机扫码', 'info'); + adminStopQrPoll(); + // 换新码即作废旧 unikey 的在途 check 响应(代次令牌) + store.adminQrGen = (store.adminQrGen || 0) + 1; + store.adminQrTimer = setInterval(adminQrPoll, 3000); + }) + .catch(function (err) { + toast(err.message || '生成二维码失败', 'error'); + }) + .finally(function () { setBtnLoading($('#admin-qr-btn'), false); }); + } + + function adminQrPoll() { + if (!store.adminUnikey) { adminStopQrPoll(); return; } + // 代次令牌——删除 Cookie/重新生成二维码后,旧 unikey 的 + // 在途 check 响应不得杀掉新一轮轮询或误报「登录成功,Cookie 已自动保存」 + store.adminQrGen = (store.adminQrGen || 0) + 1; + var gen = store.adminQrGen; + apiPost('/admin/qr/check', { unikey: store.adminUnikey }) + .then(function (data) { + if (gen !== store.adminQrGen) return; // 迟到响应丢弃 + if (data.status !== 200) { + adminStopQrPoll(); + $('#admin-qr-status').textContent = data.message || '检查登录状态失败'; + $('#admin-qr-status').style.color = 'var(--color-error)'; + return; + } + var st = data.data.status; + if (st === 'waiting') { + $('#admin-qr-status').textContent = '等待扫码...'; + $('#admin-qr-status').style.color = ''; + } else if (st === 'scanned') { + $('#admin-qr-status').textContent = '✅ 已扫码,请在手机上确认登录'; + $('#admin-qr-status').style.color = 'var(--color-warning)'; + } else if (st === 'success') { + adminStopQrPoll(); + $('#admin-qr-status').textContent = '🎉 登录成功,Cookie 已自动保存'; + $('#admin-qr-status').style.color = 'var(--color-success)'; + toast('登录成功,Cookie 已自动保存', 'success'); + adminLoadStatus(); + renderAccountBar(); + } else if (st === 'expired') { + adminStopQrPoll(); + $('#admin-qr-status').textContent = '二维码已过期,请点击「生成登录二维码」重新获取'; + $('#admin-qr-status').style.color = 'var(--color-error)'; + } else { + adminStopQrPoll(); + $('#admin-qr-status').textContent = data.message || '登录状态异常'; + $('#admin-qr-status').style.color = 'var(--color-error)'; + } + }) + .catch(function (err) { + if (gen !== store.adminQrGen) return; // 迟到响应丢弃 + adminStopQrPoll(); + $('#admin-qr-status').textContent = err.message || '检查登录状态失败'; + $('#admin-qr-status').style.color = 'var(--color-error)'; + }); + } + + function adminCookieDelete() { + if (!store.adminAuthed) return; + if (!window.confirm('确定要删除当前 Cookie 吗?\n删除后将需要重新扫码登录。')) return; + setBtnLoading($('#admin-cookie-delete'), true, '删除中...'); + apiPost('/admin/cookie/delete', {}) + .then(function (data) { + if (data.status !== 200) throw new Error(data.message || '删除失败'); + adminStopQrPoll(); + // 删除后作废在途 check 响应(代次令牌) + store.adminQrGen = (store.adminQrGen || 0) + 1; + $('#admin-qr-area').hidden = true; + store.adminUnikey = ''; + toast('Cookie 已删除,可重新扫码登录', 'success'); + adminLoadStatus(); + renderAccountBar(); + }) + .catch(function (err) { + toast(err.message || '删除 Cookie 失败', 'error'); + }) + .finally(function () { setBtnLoading($('#admin-cookie-delete'), false); }); + } + + $('#admin-enter').addEventListener('click', adminEnter); + $('#admin-password').addEventListener('keydown', function (e) { if (e.key === 'Enter') adminEnter(); }); + $('#admin-qr-btn').addEventListener('click', adminQrCreate); + $('#admin-cookie-delete').addEventListener('click', adminCookieDelete); + $('#admin-status-refresh').addEventListener('click', adminLoadStatus); + + /* ================= 管理员:用户 IP 解析/下载记录 ================= */ + var adminLogsPage = 1; + function loadAdminLogs() { + if (!store.adminAuthed) return; + apiPost('/admin/logs', { page: adminLogsPage, page_size: 10 }) + .then(function (data) { + if (data.status !== 200) throw new Error(data.message || '获取记录失败'); + var d = data.data; + var box = $('#admin-logs-list'); + if (!d.items || !d.items.length) { + box.innerHTML = '
暂无解析/下载记录
'; + } else { + box.innerHTML = d.items.map(function (it) { + var tagCls = it.action === '下载' ? ' style="border-color:var(--color-success);color:var(--color-success);background:rgba(82,196,26,0.1);"' : + ' style="border-color:var(--color-primary);color:var(--color-primary);background:var(--color-primary-bg);"'; + return '
' + + '' + escapeHtml(it.action || '-') + '' + + '' + escapeHtml(it.ip || '-') + '' + + '' + escapeHtml(it.song || '') + '' + + '' + escapeHtml(it.time || '') + '' + + '
'; + }).join(''); + } + var pages = Math.max(1, d.pages || 1); + $('#admin-logs-info').textContent = '第 ' + d.page + ' / ' + pages + ' 页'; + $('#admin-logs-prev').disabled = d.page <= 1; + $('#admin-logs-next').disabled = d.page >= pages; + $('#admin-logs-pager').hidden = d.total <= 10; + }) + .catch(function (err) { + toast(err.message || '获取记录失败', 'error'); + }); + } + $('#admin-logs-refresh').addEventListener('click', loadAdminLogs); + $('#admin-logs-prev').addEventListener('click', function () { + if (adminLogsPage > 1) { adminLogsPage--; loadAdminLogs(); } + }); + $('#admin-logs-next').addEventListener('click', function () { + adminLogsPage++; loadAdminLogs(); + }); + + /* ================= 个人歌单(游客扫码登录自己的账号) ================= */ + function personalCheckLogin() { + apiPost('/user/status', { user_cookie: store.personal.cookie }) + .then(function (data) { + if (data.status !== 200) throw new Error(data.message || '获取登录状态失败'); + var d = data.data || {}; + var info = d.cookie || {}; + if (info.is_valid && d.user) { + store.personal.authed = true; + store.personal.user = d.user; + $('#personal-gate').hidden = true; + $('#personal-content').hidden = false; + personalRenderUser(); + personalLoadPlaylists(); + renderAccountBar(); + updateCollectButtons(); + updateCommentComposer(); + } else { + personalResetGate(info.is_valid); + } + }) + .catch(function (err) { + toast(err.message || '获取登录状态失败', 'error'); + personalResetGate(false); + }); + } + + function personalResetGate(hasCookie) { + store.personal.authed = false; + $('#personal-content').hidden = true; + $('#personal-gate').hidden = false; + $('#personal-qr-area').hidden = true; + $('#personal-refresh').hidden = !hasCookie; + updateCollectButtons(); + updateCommentComposer(); + if (hasCookie) { + $('#personal-gate-hint').textContent = '检测到登录信息已失效(或未关联有效账号),请重新扫码登录。'; + $('#personal-refresh').hidden = false; + } else { + $('#personal-gate-hint').innerHTML = '扫码登录你自己的网易云账号,即可查看收藏的歌单(包括「我喜欢的音乐」)。' + + '个人登录仅用于获取你的歌单,解析与下载音乐仍使用管理员全局账号。'; + $('#personal-refresh').hidden = true; + } + } + + function vipBadgeHtml(vipType) { + if (vipType === 11 || vipType === 100) return '黑胶VIP'; + if (vipType === 10 || vipType === 110) return '黑胶SVIP'; + if (vipType === 1) return '音乐包'; + return '普通用户'; + } + + function personalRenderUser() { + var u = store.personal.user || {}; + $('#personal-avatar').src = u.avatarUrl || ''; + $('#personal-nickname').innerHTML = escapeHtml(u.nickname || '未命名用户') + ' ' + vipBadgeHtml(u.vipType); + $('#personal-signature').textContent = u.signature || ''; + renderPersonalToggle(); + } + + function renderPersonalToggle() { + var btn = $('#personal-toggle-account'); + if (store.parseAccount === 'user') { + btn.textContent = '切换为全局账号解析'; + btn.className = 'btn btn-sm btn-primary'; + } else { + btn.textContent = '切换为个人账号解析'; + btn.className = 'btn btn-sm'; + } + } + + $('#personal-toggle-account').addEventListener('click', function () { + if (store.parseAccount === 'admin') { + if (!store.personal.authed) { + toast('请先在「个人歌单」扫码登录你自己的账号', 'warning'); + return; + } + store.parseAccount = 'user'; + toast('已切换为个人账号解析与下载(全局菜单均生效)', 'success'); + } else { + store.parseAccount = 'admin'; + toast('已切换为全局账号解析与下载', 'success'); + } + try { localStorage.setItem('netease_parse_account', store.parseAccount); } catch (e) {} + renderPersonalToggle(); + renderAccountBar(); + }); + + function personalLoadPlaylists() { + apiPost('/user/playlists', { user_cookie: store.personal.cookie }) + .then(function (data) { + if (data.status !== 200) throw new Error(data.message || '获取歌单失败'); + store.personal.user = data.data.user; + personalRenderUser(); + personalRenderPlaylists(data.data.playlists || []); + }) + .catch(function (err) { + if (err.message && err.message.indexOf('请先扫码') !== -1) { + toast(err.message, 'warning'); + personalResetGate(false); + } else { + toast(err.message || '获取歌单失败', 'error'); + } + }); + } + + function personalRenderPlaylists(list) { + store.personal.playlists = list; + var box = $('#personal-playlists'); + $('#personal-playlist-count').textContent = '共 ' + list.length + ' 个'; + if (!list.length) { + box.innerHTML = '
📂
暂无歌单
'; + return; + } + box.innerHTML = list.map(function (p) { + var badge = ''; + if (p.specialType === 5) { + badge = '❤️ 我喜欢的音乐'; + } else if (p.is_own) { + badge = '我创建的'; + } else { + badge = '收藏的'; + } + return '
' + + '' + + '
' + + '
' + escapeHtml(p.name) + ' ' + badge + '
' + + '
' + fmtCount(p.trackCount) + ' 首 · 播放 ' + fmtCount(p.playCount) + + (p.creator ? ' · by ' + escapeHtml(p.creator) : '') + '
' + + '
' + + '
' + + '' + + '
'; + }).join(''); + } + + function personalOpenPlaylist(id, name) { + if (!id) return; + store.personal.current = { id: id, name: name }; + var gen = _collGen('personal'); // 迟到响应不得覆盖新打开的歌单 + $('#personal-tracks-wrap').hidden = false; + $('#personal-tracks-title').textContent = '歌单歌曲:' + (name || id); + $('#personal-tracks').innerHTML = '
⏳
加载中...
'; + window.scrollTo({ top: $('#personal-tracks-wrap').offsetTop - 80, behavior: 'smooth' }); + apiPost('/user/playlist', { id: id, user_cookie: store.personal.cookie }) + .then(function (data) { + if (_collStale('personal', gen)) return; + if (data.status !== 200) throw new Error((data.data && data.data.msg) || data.message || '获取歌单失败'); + personalRenderTracks(data.data.playlist || {}); + }) + .catch(function (err) { + if (_collStale('personal', gen)) return; + $('#personal-tracks').innerHTML = '
❌
' + escapeHtml(err.message || '获取歌单失败') + '
'; + }); + } + + function personalRenderTracks(info) { + var tracks = info.tracks || []; + // 空列表同样走 renderPagedTracks——清空 tracks_personal 状态与分页条, + // 原实现提前 return,旧歌单的下载数据残留(批量下载会下到旧歌) + renderPagedTracks('personal', tracks, 1); + } + + function personalStopQrPoll() { + if (store.personal.qrTimer) { + clearInterval(store.personal.qrTimer); + store.personal.qrTimer = null; + } + } + + function personalQrCreate() { + setBtnLoading($('#personal-qr-btn'), true, '生成中...'); + apiPost('/user/qr/create', {}) + .then(function (data) { + if (data.status !== 200) throw new Error(data.message || '生成二维码失败'); + var d = data.data; + store.personal.unikey = d.unikey; + var dataUri = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(d.qr_svg || ''))); + $('#personal-qr-img').src = dataUri; + $('#personal-qr-area').hidden = false; + $('#personal-qr-status').textContent = '请使用网易云音乐手机 APP 扫描二维码登录'; + $('#personal-qr-status').style.color = ''; + toast('二维码已生成,请用手机扫码', 'info'); + personalStopQrPoll(); + store.personal.qrGen = (store.personal.qrGen || 0) + 1; // 新轮询代次 + store.personal.qrTimer = setInterval(personalQrPoll, 3000); + }) + .catch(function (err) { + toast(err.message || '生成二维码失败', 'error'); + }) + .finally(function () { setBtnLoading($('#personal-qr-btn'), false); }); + } + + function personalQrPoll() { + if (!store.personal.unikey) { personalStopQrPoll(); return; } + var myGen = store.personal.qrGen; // 本次轮询所属代次,退出后作废 + apiPost('/user/qr/check', { unikey: store.personal.unikey }) + .then(function (data) { + if (myGen !== store.personal.qrGen) return; // 期间已退出/重开,丢弃迟到响应 + if (data.status !== 200) { + personalStopQrPoll(); + $('#personal-qr-status').textContent = data.message || '检查登录状态失败'; + $('#personal-qr-status').style.color = 'var(--color-error)'; + return; + } + var st = data.data.status; + if (st === 'waiting') { + $('#personal-qr-status').textContent = '等待扫码...'; + $('#personal-qr-status').style.color = ''; + } else if (st === 'scanned') { + $('#personal-qr-status').textContent = '✅ 已扫码,请在手机上确认登录'; + $('#personal-qr-status').style.color = 'var(--color-warning)'; + } else if (st === 'success') { + personalStopQrPoll(); + // 个人登录 Cookie 保存在本机浏览器; + // 站点账号登录时后端会同步一份到站点库供管理后台查看(口径对齐) + if (data.data && data.data.cookie) { + store.personal.cookie = data.data.cookie; + try { localStorage.setItem('netease_user_cookie', store.personal.cookie); } catch (e) {} + } $('#personal-qr-status').textContent = '🎉 登录成功,正在加载你的歌单...'; + $('#personal-qr-status').style.color = 'var(--color-success)'; + toast('登录成功,正在加载你的歌单', 'success'); + personalCheckLogin(); + } else if (st === 'expired') { + personalStopQrPoll(); + $('#personal-qr-status').textContent = '二维码已过期,请点击「扫码登录我的账号」重新获取'; + $('#personal-qr-status').style.color = 'var(--color-error)'; + } else { + personalStopQrPoll(); + $('#personal-qr-status').textContent = data.message || '登录状态异常'; + $('#personal-qr-status').style.color = 'var(--color-error)'; + } + }) + .catch(function (err) { + personalStopQrPoll(); + $('#personal-qr-status').textContent = err.message || '检查登录状态失败'; + $('#personal-qr-status').style.color = 'var(--color-error)'; + }); + } + + function personalLogout() { + if (!window.confirm('确定要退出登录当前网易云账号吗?\n退出后需要重新扫码才能查看个人歌单。')) return; + apiPost('/user/logout', {}) + .then(function (data) { + if (data.status !== 200) throw new Error(data.message || '退出失败'); + personalStopQrPoll(); + store.personal.qrGen = (store.personal.qrGen || 0) + 1; // 作废在途轮询响应 + store.personal.authed = false; + store.personal.user = null; + store.personal.playlists = []; + store.personal.current = null; + // 清除本机浏览器的个人 Cookie + store.personal.cookie = ''; + try { localStorage.removeItem('netease_user_cookie'); } catch (e) {} + $('#personal-tracks-wrap').hidden = true; + // 若正在使用个人账号解析,退出后回退全局账号 + if (store.parseAccount === 'user') { + store.parseAccount = 'admin'; + try { localStorage.setItem('netease_parse_account', 'admin'); } catch (e) {} + toast('已退出个人账号,解析账号已切换为全局账号', 'warning'); + } else { + toast('已退出登录', 'success'); + } + renderPersonalToggle(); + renderAccountBar(); + personalResetGate(false); + }) + .catch(function (err) { + toast(err.message || '退出登录失败', 'error'); + }); + } + + $('#personal-qr-btn').addEventListener('click', personalQrCreate); + $('#personal-refresh').addEventListener('click', personalCheckLogin); + $('#personal-logout').addEventListener('click', personalLogout); + $('#personal-playlists-refresh').addEventListener('click', personalLoadPlaylists); + + // 歌单列表点击查看 + $('#personal-playlists').addEventListener('click', function (e) { + // 取消收藏按钮的点击不触发"打开歌单"(两条无关操作不再同时发生) + if (e.target.closest('.act-uncollect')) return; + var btn = e.target.closest('.act-personal-open'); + var row = e.target.closest('.personal-pl'); + if (btn) { + personalOpenPlaylist(btn.getAttribute('data-id'), btn.getAttribute('data-name')); + } else if (row) { + personalOpenPlaylist(row.getAttribute('data-id'), row.getAttribute('data-name')); + } + }); + + $('#personal-download-all').addEventListener('click', function () { + batchDownloadAll(this, 'personal'); + }); + + /* ================= 全局账号歌单(管理员 cookie.txt) ================= */ + function globalCheckLogin() { + apiPost('/global/playlists', {}) + .then(function (data) { + if (data.status === 401) { + globalRenderGate(false); + return; + } + if (data.status !== 200) throw new Error(data.message || '获取全局账号歌单失败'); + var d = data.data || {}; + store.global.user = d.user || null; + store.global.playlists = d.playlists || []; + $('#global-gate').hidden = true; + $('#global-content').hidden = false; + globalRenderUser(); + globalRenderLists(); + }) + .catch(function (err) { + toast(err.message || '获取全局账号歌单失败', 'error'); + globalRenderGate(false); + }); + } + + function globalRenderGate(hasCookie) { + $('#global-content').hidden = true; + $('#global-gate').hidden = false; + $('#global-refresh').hidden = !hasCookie; + } + + function globalRenderUser() { + var u = store.global.user || {}; + $('#global-avatar').src = u.avatarUrl || ''; + $('#global-nickname').innerHTML = escapeHtml(u.nickname || '未命名用户') + ' ' + vipBadgeHtml(u.vipType); + $('#global-signature').textContent = u.signature || ''; + } + + function globalLoadPlaylists() { + apiPost('/global/playlists', {}) + .then(function (data) { + if (data.status !== 200) throw new Error(data.message || '获取全局账号歌单失败'); + store.global.user = data.data.user || null; + store.global.playlists = data.data.playlists || []; + globalRenderUser(); + globalRenderLists(); + }) + .catch(function (err) { + toast(err.message || '获取全局账号歌单失败', 'error'); + }); + } + + /* 歌单行 HTML(isFav 区分「我喜欢的音乐」列表) */ + function globalPlaylistRowHtml(p) { + var badge = ''; + if (p.specialType === 5) { + badge = '❤️ 我喜欢的音乐'; + } else if (p.is_own) { + badge = '我创建的'; + } else { + badge = '收藏的'; + } + return '
' + + '' + + '
' + + '
' + escapeHtml(p.name) + ' ' + badge + '
' + + '
' + fmtCount(p.trackCount) + ' 首 · 播放 ' + fmtCount(p.playCount) + + (p.creator ? ' · by ' + escapeHtml(p.creator) : '') + '
' + + '
' + + '
' + + '' + + '' + + '
'; + } + + function globalRenderLists() { + var list = store.global.playlists; + var fav = list.filter(function (p) { return p.specialType === 5; }); + var collect = list.filter(function (p) { return p.specialType !== 5; }); + + $('#global-fav-count').textContent = '共 ' + fav.length + ' 个'; + $('#global-collect-count').textContent = '共 ' + collect.length + ' 个'; + + $('#global-fav-playlists').innerHTML = fav.length + ? fav.map(globalPlaylistRowHtml).join('') + : '
📂
暂无「我喜欢的音乐」
'; + $('#global-collect-playlists').innerHTML = collect.length + ? collect.map(globalPlaylistRowHtml).join('') + : '
📂
暂无收藏的歌单
'; + } + + function globalOpenPlaylist(id, name) { + if (!id) return; + store.global.current = { id: id, name: name }; + var gen = _collGen('global'); // 迟到响应不得覆盖新打开的歌单 + $('#global-tracks-wrap').hidden = false; + $('#global-tracks-title').textContent = '歌单歌曲:' + (name || id); + $('#global-tracks').innerHTML = '
⏳
加载中...
'; + $('#global-play-playlist').hidden = false; + $('#global-play-playlist').setAttribute('data-id', id); + $('#global-play-playlist').setAttribute('data-name', name || id); + window.scrollTo({ top: $('#global-tracks-wrap').offsetTop - 80, behavior: 'smooth' }); + apiPost('/global/playlist', { id: id }) + .then(function (data) { + if (_collStale('global', gen)) return; + if (data.status !== 200) throw new Error((data.data && data.data.msg) || data.message || '获取歌单失败'); + globalRenderTracks(data.data.playlist || {}); + }) + .catch(function (err) { + if (_collStale('global', gen)) return; + $('#global-tracks').innerHTML = '
❌
' + escapeHtml(err.message || '获取歌单失败') + '
'; + }); + } + + function globalRenderTracks(info) { + var tracks = info.tracks || []; + // 空列表同样走 renderPagedTracks,清空 tracks_global 状态(同 personal 侧) + renderPagedTracks('global', tracks, 1); + } + + /* 播放歌单:调用公开播放 API 获取可播放直链,加载第一首进播放器并支持自动连播。 + 端点强制校验 `key`——原实现不带 + key,管理员点「播放」100% 被 401 拒绝。此处先取播放密钥再附带请求。 */ + function globalPlayPlaylist(id, name, btn) { + if (!id) { toast('缺少歌单 ID', 'warning'); return; } + var lvl = $('#global-level').value; + if (lvl === 'auto') lvl = (store.levelsOk && store.levelsOk.length ? store.levelsOk[0] : 'lossless'); + // 代次守卫——行内 ▶ 与底部播放按钮双入口可并发, + // 迟到旧队列不得覆盖 playQueue(否则连播接着播错歌单的歌) + store._globalPlayGen = (store._globalPlayGen || 0) + 1; + var gen = store._globalPlayGen; + if (btn) setBtnLoading(btn, true, '解析中...'); + apiPost('/admin/site/play_key/get', {}).then(function (kd) { + if (gen !== store._globalPlayGen) throw new Error('__stale__'); + if (kd.status !== 200) throw new Error(kd.message || '获取播放密钥失败'); + var key = (kd.data || {}).key || ''; + return apiPost('/api/playlist/play', { id: id, level: lvl, limit: 100, key: key }); + }).then(function (data) { + if (gen !== store._globalPlayGen) return; + if (data.status !== 200) throw new Error(data.message || '获取播放列表失败'); + var d = data.data || {}; + var tracks = d.tracks || []; + if (!tracks.length) { toast('该歌单暂无可以播放的歌曲(可能均为版权受限)', 'warning'); return; } + store.global.playQueue = tracks; + store.global.playQueueIndex = 0; + var first = tracks[0]; + initPlayer(first.url || '', first.name || '', first.artists || '', first.picUrl || ''); + toast('✅ 已获取 ' + tracks.length + ' 首可播放歌曲(' + (d.level_name || lvl) + '),请点击播放器 ▶ 开始播放:' + first.name, 'success'); + if (store.global.current && String(store.global.current.id) === String(id)) { + $('#global-tracks-title').textContent = '歌单歌曲:' + (name || id) + '(播放 API 返回 ' + tracks.length + ' 首可播放)'; + } + }) + .catch(function (err) { + if (gen !== store._globalPlayGen || err.message === '__stale__') return; + toast(err.message || '获取播放列表失败', 'error'); + }) + .finally(function () { + // 双入口是不同按钮——每个调用无条件复位自己的 + // 按钮(重入计数器配对),否则被作废旧入口的按钮永久卡"解析中..." + if (btn) setBtnLoading(btn, false); + }); + } + + /* ================= 公开播放 API 说明弹窗 ================= */ + function openApiModal(playlistId) { + var base = (location.origin + location.pathname).replace(/\/+$/, ''); + var apiUrl = base + '/api/playlist/play'; + var url = apiUrl + '?id=' + encodeURIComponent(playlistId || ''); + // 代次守卫——快速连开两个歌单的 API 弹窗时,迟到响应 + // 不得把复制地址写回旧歌单(复制出去即错链) + store._apiModalGen = (store._apiModalGen || 0) + 1; + var gen = store._apiModalGen; + // 密钥实时取(管理端点仅站点管理员可达),弹窗里的地址总是可直接使用 + apiPost('/admin/site/play_key/get', {}).then(function (d) { + if (gen !== store._apiModalGen) return; // 迟到响应丢弃 + var key = (d && d.data && d.data.key) || ''; + var withKey = key ? url + '&key=' + encodeURIComponent(key) : url; + $('#api-url').textContent = withKey; + $('#api-example').textContent = + 'curl "' + withKey + '&level=lossless&limit=100"\n' + + '// POST 表单:id=' + (playlistId || '') + '&level=lossless&limit=100&key=' + key; + }).catch(function () { + if (gen !== store._apiModalGen) return; + $('#api-url').textContent = url; + }); + openModal('api-modal'); + } + + function closeApiModal() { closeModal('api-modal'); } + + function copyText(text, tip) { + if (!text) return; + function fallback() { + var ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + // execCommand 返回 false 也是失败,不再谎报已复制 + try { + var ok = document.execCommand('copy'); + if (ok) toast(tip || '已复制', 'success'); + else toast('复制失败,请手动选择文本复制', 'error'); + } + catch (e) { toast('复制失败,请手动选择文本复制', 'error'); } + document.body.removeChild(ta); + } + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(function () { toast(tip || '已复制', 'success'); }) + .catch(fallback); + } else { fallback(); } + } + + $('#global-goto-admin').addEventListener('click', function () { switchTab('admin'); }); + $('#global-refresh').addEventListener('click', globalCheckLogin); + $('#global-playlists-refresh').addEventListener('click', globalLoadPlaylists); + $('#global-api-open').addEventListener('click', function () { openApiModal(''); }); + $('#global-play-playlist').addEventListener('click', function () { + globalPlayPlaylist(this.getAttribute('data-id'), this.getAttribute('data-name'), this); + }); + $('#global-download-all').addEventListener('click', function () { batchDownloadAll(this, 'global'); }); + + // 歌单列表点击查看 / 播放 / API(收藏歌单与「我喜欢的音乐」共用) + function globalPlListClick(e) { + var btn = e.target.closest('.act-global-play, .act-global-open, .act-global-api'); + var row = e.target.closest('.global-pl'); + var id = btn ? btn.getAttribute('data-id') : (row ? row.getAttribute('data-id') : ''); + var name = btn ? btn.getAttribute('data-name') : (row ? row.getAttribute('data-name') : ''); + if (!id) return; + if (btn && btn.classList.contains('act-global-play')) { + globalPlayPlaylist(id, name, btn); + } else if (btn && btn.classList.contains('act-global-api')) { + openApiModal(id); + } else { + globalOpenPlaylist(id, name); + } + } + $('#global-fav-playlists').addEventListener('click', globalPlListClick); + $('#global-collect-playlists').addEventListener('click', globalPlListClick); + + // 歌单解析结果卡片中的「公开播放 API」入口 + $('#playlist-api-btn').addEventListener('click', function () { + var c = store.currentCollection; + openApiModal(c && c.type === 'playlist' ? c.id : ''); + }); + + $('#api-close').addEventListener('click', closeApiModal); + $('#api-modal').addEventListener('click', function (e) { if (e.target === this) closeApiModal(); }); + $('#api-copy-url').addEventListener('click', function () { copyText($('#api-url').textContent, '接口地址已复制'); }); + $('#api-copy-example').addEventListener('click', function () { copyText($('#api-example').textContent, '请求示例已复制'); }); + + /* ================= 收藏功能(个人账号登录后显示) ================= */ + function updateCollectButtons() { + var show = store.personal.authed; + var pb = $('#collect-playlist-btn'); + var ab = $('#collect-album-btn'); + if (pb) pb.hidden = !show; + if (ab) ab.hidden = !show; + if (show && store.currentCollection) { + // 已收藏的歌单显示「已收藏」 + var c = store.currentCollection; + var inList = c.type === 'playlist' && (store.personal.playlists || []).some(function (p) { + return String(p.id) === String(c.id); + }); + if (c.type === 'album') { + // 专辑收藏状态依据本会话收藏结果,不再无条件还原为未收藏 + if (c.albumCollected) { + $('#collect-album-btn').textContent = '✓ 已收藏专辑'; + $('#collect-album-btn').classList.add('btn-success'); + } else { + $('#collect-album-btn').textContent = '♡ 收藏专辑'; + $('#collect-album-btn').classList.remove('btn-success'); + } + } else { + if (inList) { + $('#collect-playlist-btn').textContent = '✓ 已收藏'; + $('#collect-playlist-btn').classList.add('btn-success'); + } else { + $('#collect-playlist-btn').textContent = '♡ 收藏歌单'; + $('#collect-playlist-btn').classList.remove('btn-success'); + } + } + } + } + + function collectTarget(type, id, action) { + if (!store.personal.authed || !store.personal.cookie) { + toast('请先在「个人歌单」扫码登录你的账号', 'warning'); + switchTab('personal'); + return; + } + var tip = action === 1 ? '收藏' : '取消收藏'; + apiPost('/user/collect', { + id: id, type: type, action: action, + user_cookie: store.personal.cookie + }) + .then(function (data) { + if (data.status !== 200) throw new Error(data.message || tip + '失败'); + toast(action === 1 ? '✅ 已收藏到你的个人歌单' : '已取消收藏', 'success'); + // 专辑收藏结果记入 currentCollection,供 updateCollectButtons 正确渲染 + if (store.currentCollection && String(store.currentCollection.id) === String(id)) { + store.currentCollection.albumCollected = (type === 'album' && action === 1); + } + // 按钮状态即时更新 + var btn = type === 'album' ? $('#collect-album-btn') : $('#collect-playlist-btn'); + if (btn) { + if (action === 1) { + btn.textContent = type === 'album' ? '✓ 已收藏专辑' : '✓ 已收藏'; + btn.classList.add('btn-success'); + } else { + btn.textContent = type === 'album' ? '♡ 收藏专辑' : '♡ 收藏歌单'; + btn.classList.remove('btn-success'); + } + } + // 收藏后自动刷新个人歌单 + if (store.personal.authed) personalLoadPlaylists(); + updateCollectButtons(); + }) + .catch(function (err) { + toast(err.message || tip + '失败', 'error'); + }); + } + + $('#collect-playlist-btn').addEventListener('click', function () { + if (store.currentCollection) { + var inList = (store.personal.playlists || []).some(function (p) { + return String(p.id) === String(store.currentCollection.id); + }); + collectTarget('playlist', store.currentCollection.id, inList ? 2 : 1); + } + }); + $('#collect-album-btn').addEventListener('click', function () { + if (store.currentCollection) collectTarget('album', store.currentCollection.id, 1); + }); + + /* ================= 列表内按钮事件委托 ================= */ + document.addEventListener('click', function (e) { + var parseBtn = e.target.closest('.act-parse'); + if (parseBtn) { + parseSongById(parseBtn.getAttribute('data-id'), parseBtn.getAttribute('data-name')); + return; + } + var dlBtn = e.target.closest('.act-download'); + if (dlBtn) { + downloadSong(dlBtn.getAttribute('data-id'), dlBtn.getAttribute('data-name')); + return; + } + var gp = e.target.closest('.act-goto-playlist'); + if (gp) { loadPlaylistById(gp.getAttribute('data-id'), gp.getAttribute('data-name')); return; } + var ga = e.target.closest('.act-goto-album'); + if (ga) { loadAlbumById(ga.getAttribute('data-id'), ga.getAttribute('data-name')); return; } + var gs = e.target.closest('.act-goto-artist'); + if (gs) { loadArtistById(gs.getAttribute('data-id'), gs.getAttribute('data-name')); return; } + var unc = e.target.closest('.act-uncollect'); + if (unc) { + collectTarget('playlist', unc.getAttribute('data-id'), 2); + return; + } + }); + + /* ================= 批量下载 ================= */ + /* 次数不足时的选择框:仅下载前 credits 首 / 取消 */ + function confirmPartialDownload(need, credits, cb) { + // 先移除残留实例——连点两次下载会叠两个弹窗,$('#cpd-ok') + // 永远命中第一个,第二个弹窗按钮失效(遮罩还盖着页面) + var old = document.querySelector('.modal-mask[data-dynamic="1"].show'); + if (old) old.remove(); + var mask = document.createElement('div'); + mask.className = 'modal-mask show'; + mask.setAttribute('data-dynamic', '1'); // Escape 关闭时连同 DOM 一并移除 + mask.innerHTML = + ''; + document.body.appendChild(mask); + mask.addEventListener('click', function (e) { if (e.target === mask) { mask.remove(); cb(false); } }); + $('#cpd-ok').addEventListener('click', function () { mask.remove(); cb(true); }); + $('#cpd-cancel').addEventListener('click', function () { mask.remove(); cb(false); }); + } + + function checkDownloadQuota(list, btn, cb) { + /* 返回 true 表示次数充足直接继续;不足时弹选择框后按结果回调截断列表 */ + var credits = (window.getSITECredits && window.getSITECredits()) || 0; + if (credits < 0) { if (window.handleAuthExpired) window.handleAuthExpired(); cb(null); return; } + if (list.length <= credits) { cb(list); return; } + if (credits <= 0) { if (window.handleAuthExpired) window.handleAuthExpired('', true); cb(null); return; } + setBtnLoading(btn, false); + confirmPartialDownload(list.length, credits, function (ok) { + cb(ok ? list.slice(0, credits) : null); + }); + } + + function batchDownload(btn, tracks, _prechecked) { + // 入口按 ID 去重(服务端同样去重计费,预检与实际口径一致) + var seen = {}; + var list = (tracks || []).filter(function (t) { + if (!t || !t.id || seen[t.id]) return false; + seen[t.id] = true; + return true; + }); + if (!list.length) { toast('暂无可以下载的歌曲', 'warning'); return; } + if (!cookieGate()) { setBtnLoading(btn, false); return; } + if (!_prechecked) { + checkDownloadQuota(list, btn, function (allowed) { + if (allowed) batchDownload(btn, allowed, true); + }); + return; + } + + // 批量 ZIP 打包模式(后台任务:提交即返回 task_id,任务面板显示进度/取消,完成后自动下载) + if (store.settings.batchZip) { + setBtnLoading(btn, true, '打包中...'); + var cfg = downloadConfig(); + // 批量模式的元数据字段跟随"批量写入"开关,不受单曲开关影响 + //(原实现单曲关闭时这里会提交 __none__,批量开启也写不进任何字段) + var batchFields = Object.keys(store.settings.metaFields).filter(function (k) { + return store.settings.metaFields[k]; + }); + var batchMetaFields = store.settings.batchWriteMeta && batchFields.length + ? batchFields.join(',') : '__none__'; + var lvl = $('#global-level').value; + if (lvl === 'auto') lvl = (store.levelsOk && store.levelsOk[0]) || 'lossless'; + var ids = list.map(function (t) { return t.id; }).join(','); + var taskId = genTaskId(); + apiPost('/download/batch_zip', { + ids: ids, + quality: lvl, + task_id: taskId, + device_id: getDeviceId(), + account: store.parseAccount, + user_cookie: store.parseAccount === 'user' ? store.personal.cookie : '', + filename_format: cfg.filename_format, + write_meta: store.settings.batchWriteMeta ? 'true' : 'false', + meta_fields: batchMetaFields, + lyric_format: cfg.lyric_format + }).then(function (data) { + setBtnLoading(btn, false); + if (data.status !== 200) { + toast(data.message || '批量打包提交失败', 'error'); + return; + } + addTask({ + id: taskId, type: 'batch', + label: '批量打包 · ' + list.length + ' 首', + total: list.length, done: 0, status: 'queued' + }); + if (window.refreshSITECredits) window.refreshSITECredits(); // 立即同步顶栏剩余次数 + }).catch(function () { + setBtnLoading(btn, false); + toast('批量打包提交失败,请检查网络', 'error'); + }); + return; + } + + // 逐个下载 + setBtnLoading(btn, true, '批量下载中...'); + var i = 0, okCount = 0, failCount = 0, unknownCount = 0; // 逐首结果如实汇总 + function next() { + if (i >= list.length) { + setBtnLoading(btn, false); + var parts = []; + if (okCount) parts.push('成功 ' + okCount + ' 首'); + if (failCount) parts.push('失败 ' + failCount + ' 首'); + if (unknownCount) parts.push(unknownCount + ' 首结果未知(任务过期,请到任务面板重试)'); + if (failCount && !okCount && !unknownCount) toast('批量下载全部失败(' + failCount + ' 首)', 'error'); + else if (failCount || unknownCount) toast('批量下载结束:' + parts.join(','), 'warning'); + else toast('批量下载完成,共 ' + list.length + ' 首', 'success'); + return; + } + var song = list[i]; + toast('正在下载:' + (song.name || song.id) + '(' + (i + 1) + '/' + list.length + ')', 'info'); + downloadOne(song, function (success) { + i++; + if (success === false) failCount++; + else if (success === 'unknown') unknownCount++; + else okCount++; + next(); + }); + } + next(); + } + + function downloadOne(song, done) { + if (!cookieGate()) { done(); return; } + apiResolveLevel(song.id) + .then(function (level) { + var cfg = downloadConfig(); + // 逐首分支的元数据字段同样跟随批量开关(与 ZIP 分支同口径) + var oneFields = Object.keys(store.settings.metaFields).filter(function (k) { + return store.settings.metaFields[k]; + }); + var oneMetaFields = store.settings.batchWriteMeta && oneFields.length + ? oneFields.join(',') : '__none__'; + var taskId = genTaskId(); + apiPost('/download', { + id: song.id, + quality: level, + task_id: taskId, + device_id: getDeviceId(), + account: store.parseAccount, + user_cookie: store.parseAccount === 'user' ? store.personal.cookie : '', + filename_format: cfg.filename_format, + write_meta: store.settings.batchWriteMeta ? 'true' : 'false', + meta_fields: oneMetaFields, + lyric_format: cfg.lyric_format, + zip: cfg.zip + }).then(function (data) { + if (data.status === 401 && store.parseAccount === 'user') { + store.parseAccount = 'admin'; + try { localStorage.setItem('netease_parse_account', 'admin'); } catch (e) {} + renderAccountBar(); + renderPersonalToggle(); + toast('个人账号未登录,已自动切换为全局账号下载', 'warning'); + downloadOne(song, done); + return; + } + if (data.status !== 200) { + toast(data.message || ('下载失败:' + (song.name || song.id)), 'error'); + done(false); // 提交失败如实计为失败 + return; + } + // 任务加入悬浮窗;完成后自动下载该文件并继续下一首 + addTask({ + id: taskId, type: 'single', + label: '单曲 · ' + (song.name || song.id), + total: 1, done: 0, status: 'queued', + doneCb: function (success) { done(success); } // 透传 true/false/'unknown' + }); + if (window.refreshSITECredits) window.refreshSITECredits(); // 立即同步顶栏剩余次数 + }).catch(function () { + toast('下载提交失败:' + (song.name || song.id), 'error'); + done(false); + }); + }) + .catch(function () { toast('下载失败:' + (song.name || song.id), 'error'); done(false); }); + } + + /* 批量下载全部歌曲(从 state 中的完整列表取,不分页) */ + function batchDownloadAll(btn, prefix) { + var tracks = (store['tracks_' + prefix] || []).map(function (t) { + return { id: t.id, name: t.name }; + }); + batchDownload(btn, tracks); + } + $('#playlist-download-all').addEventListener('click', function () { batchDownloadAll(this, 'playlist'); }); + $('#album-download-all').addEventListener('click', function () { batchDownloadAll(this, 'album'); }); + $('#artist-download-all').addEventListener('click', function () { batchDownloadAll(this, 'artist'); }); + + /* ================= 下载按钮(单曲详情) ================= */ + $('#detail-download').addEventListener('click', function () { + if (!store.currentSong) return; + downloadSong(store.currentSong.id, store.currentSong.name); + }); + + /* ================= 美化增强(纯观感,防御式,不影响任何功能) ================= */ + // 顶栏滚动感知:页面下滑后加深顶栏阴影与发丝线 + try { + var _hdr = document.querySelector('.app-header'); + if (_hdr) { + var _hdrScroll = function () { + if (window.scrollY > 8) _hdr.classList.add('scrolled'); + else _hdr.classList.remove('scrolled'); + }; + window.addEventListener('scroll', _hdrScroll, { passive: true }); + _hdrScroll(); + } + } catch (e) {} + // 播放态光晕:audio 播放/暂停时给播放器挂 .playing(media 事件不冒泡,用捕获) + try { + document.addEventListener('play', function (e) { + if (e.target && e.target.tagName === 'AUDIO') { + var p = $('#player'); + if (p) p.classList.add('playing'); + } + }, true); + document.addEventListener('pause', function (e) { + if (e.target && e.target.tagName === 'AUDIO') { + var p = $('#player'); + if (p) p.classList.remove('playing'); + } + }, true); + } catch (e) {} + // 弹窗/抽屉打开时锁定背景滚动(防移动端滚动穿透;监听 class 变化,避开 rAF 高频 style 写入) + // 同时挂 body.overlay-open 类:隐藏实时排队/任务面板浮窗(它们 z-index 高于弹层, + // 否则会盖住设置抽屉/弹窗内容——owner 实测报障) + try { + var _scrollLockMO = new MutationObserver(function () { + var locked = document.querySelector('.modal-mask.show, .drawer.show'); + document.body.style.overflow = locked ? 'hidden' : ''; + // 状态类挂 而非 :本观察器监听 body 子树的 class 变化, + // 回调若改 body 自身 class 会形成自反馈,真实鼠标输入下引发渲染器活锁(实测卡死) + if (locked) document.documentElement.classList.add('overlay-open'); + else document.documentElement.classList.remove('overlay-open'); + }); + _scrollLockMO.observe(document.body, { attributes: true, attributeFilter: ['class'], subtree: true }); + } catch (e) {} + // 删除注册四框的 Enter→提交冗余绑定——site_auth.js:988 已对 + // 全部四框绑 Enter→doRegister,此处旧绑定只挂 #reg-code,两处叠加导致 + // 一次回车 doRegister 执行两次(第二请求被验证码原子消费挡下但报错闪烁) + // 播放器缓冲预览:进度条底层显示已缓冲长度(CSS 变量 --buffered,纯读取 audio.buffered,不改播放逻辑) + try { + var _updBuf = function (a) { + var p = document.getElementById('player-progress'); + if (!p) return; + if (!a || !a.duration || !a.buffered || !a.buffered.length) { + p.style.setProperty('--buffered', '0%'); return; + } + var end = a.buffered.end(a.buffered.length - 1); + p.style.setProperty('--buffered', Math.min(100, end / a.duration * 100) + '%'); + }; + document.addEventListener('progress', function (e) { + if (e.target && e.target.tagName === 'AUDIO') _updBuf(e.target); + }, true); + ['timeupdate', 'loadstart', 'emptied'].forEach(function (ev) { + document.addEventListener(ev, function (e) { + if (e.target && e.target.tagName === 'AUDIO' && ev !== 'timeupdate') _updBuf(null); + else if (e.target && e.target.tagName === 'AUDIO') _updBuf(e.target); + }, true); + }); + } catch (e) {} + // 键盘快捷键:空格 播放/暂停、←/→ 快退/快进 5 秒(输入框聚焦时不拦截) + try { + document.addEventListener('keydown', function (e) { + var t = e.target; + // 排除输入控件与可点击元素(BUTTON/A/[role=button]):空格在按钮上的默认行为是"按下按钮", + // 不能被快捷键抢占,否则键盘用户聚焦按钮后按空格会变成切歌 + if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || + t.tagName === 'BUTTON' || t.tagName === 'A' || t.isContentEditable || + t.getAttribute && t.getAttribute('role') === 'button')) return; + if (e.key !== ' ' && e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; + var audio = document.querySelector('#player audio'); + if (!audio || !audio.src) return; + if (e.key === ' ') { + e.preventDefault(); + var b = document.getElementById('player-toggle'); + if (b) b.click(); + } else if (e.key === 'ArrowLeft') { + e.preventDefault(); + audio.currentTime = Math.max(0, audio.currentTime - 5); + } else { + e.preventDefault(); + audio.currentTime = Math.min(audio.duration || Infinity, audio.currentTime + 5); + } + }); + } catch (e) {} + // 播放时标签页标题显示当前内容,暂停/停止恢复站名 + try { + var _baseTitle = document.title; + var _nowName = function (t) { + var name = ''; + if (t && t.id === 'podcast-audio') { + var pn = document.getElementById('podcast-program-name'); + name = pn ? pn.textContent : ''; + } else { + var n = document.getElementById('player-name'); + name = n ? n.textContent : ''; + } + return name; + }; + document.addEventListener('play', function (e) { + if (!e.target || e.target.tagName !== 'AUDIO') return; + var nm = _nowName(e.target); + document.title = nm ? '▶ ' + nm + ' - ' + _baseTitle : _baseTitle; + }, true); + document.addEventListener('pause', function (e) { + if (!e.target || e.target.tagName !== 'AUDIO') return; + document.title = _baseTitle; + }, true); + } catch (e) {} + // 兼容层 v9+(2026-09-19):① 极老内核(IE/360 兼容模式等,无 fetch)阻断提示; + // ② 键盘导航焦点环 polyfill(:focus-visible 需 Chromium 86+,老内核由 Tab 键挂类激活) + try { + (function () { + var uaL = (navigator.userAgent || ''); + var isTrident = /MSIE|Trident\//.test(uaL); + var isTooOld = !window.fetch || !window.Promise || !document.addEventListener; + if (isTrident || isTooOld) { + var el = document.getElementById('legacy-browser-blocker'); + if (el) el.className = 'show'; + var pl = document.getElementById('page-loading'); + if (pl) pl.style.display = 'none'; + try { document.documentElement.style.background = '#1f2329'; } catch (e2) {} + return; // 阻断页已接管,不再执行后续增强逻辑 + } + // 键盘焦点环:Tab 导航时挂 kbd-nav 类(激活 :focus 规则),鼠标按下时移除 + document.addEventListener('keydown', function (e) { + if ((e.key === 'Tab' || e.keyCode === 9) && + !document.documentElement.className.match(/\bkbd-nav\b/)) { + document.documentElement.className += ' kbd-nav'; + } + }, true); + document.addEventListener('mousedown', function () { + document.documentElement.className = + document.documentElement.className.replace(/\s*kbd-nav\b/, ''); + }, true); + })(); + } catch (e) {} + // 微信/QQ 内置浏览器:显示下载受限引导条 + 点击下载按钮时 toast 提醒(不拦截下载本身) + // v9+ 扩展:支付宝/钉钉/微博/企业微信等 App 内置浏览器同样提示(企业微信 UA 含 MicroMessenger 自动覆盖) + try { + var _ua = navigator.userAgent || ''; + var _inWeChat = _ua.indexOf('MicroMessenger') !== -1; + var _inQQWV = _ua.indexOf(' QQ/') !== -1 && _ua.indexOf('MQQBrowser') === -1; + var _otherAppWV = /AlipayClient|DingTalk|Weibo|HeyTapBrowser|HiSpace|HMSCore/.test(_ua); + if (_inWeChat || _inQQWV || _otherAppWV) { + var _seen = false; + try { _seen = sessionStorage.getItem('netease_wv_guide_closed') === '1'; } catch (e2) {} + var _guide = document.getElementById('webview-guide'); + if (_guide && !_seen) { + if (_otherAppWV && !_inWeChat && !_inQQWV) { + var _wgText = _guide.querySelector('.wg-text'); + if (_wgText) _wgText.innerHTML = '💬 当前在 App 内置浏览器中,音乐下载可能受限。建议复制链接到系统浏览器打开,获得完整体验。'; + } + _guide.hidden = false; + } + var _gclose = document.getElementById('webview-guide-close'); + if (_gclose) _gclose.addEventListener('click', function () { + var g = document.getElementById('webview-guide'); + if (g) g.hidden = true; + try { sessionStorage.setItem('netease_wv_guide_closed', '1'); } catch (e2) {} + }); + document.addEventListener('click', function (e) { + var btn = e.target && e.target.closest ? e.target.closest('button, a') : null; + if (!btn) return; + var sig = ((btn.id || '') + ' ' + (btn.className || '')).toLowerCase(); + var txt = btn.textContent || ''; + if (/download|zip/.test(sig) || txt.indexOf('⬇') !== -1 || txt.indexOf('下载') !== -1 || txt.indexOf('打包') !== -1) { + toast((_inWeChat || _inQQWV) + ? '微信/QQ 内置浏览器可能无法下载文件,建议点右上角「···」→「在浏览器中打开」' + : '当前 App 内置浏览器可能无法下载文件,建议复制链接到系统浏览器打开', 'warning'); + } + }, true); + } + } catch (e) {} + // 前端错误日志上报:JS 运行错误 / Promise 拒绝 / 资源加载失败 → /api/fe_error + // 用于排查老内核浏览器"API 行为不一致 / 样式错位"类问题。去重 + 限频,任何异常静默。 + try { + var _feSeen = {}; + var _feCount = 0; + var _feSend = function (type, msg, stack) { + try { + if (_feCount >= 8) return; // 单会话最多 8 条 + var key = type + '|' + msg; + if (_feSeen[key]) return; // 同一错误只报一次 + _feSeen[key] = 1; + _feCount++; + var body = new URLSearchParams({ + type: type, msg: msg, stack: stack || '', + page: location.href.slice(0, 120), + env: (navigator.userAgent || '').slice(0, 180) + + ' | ' + window.innerWidth + 'x' + window.innerHeight + + ' | dpr' + (window.devicePixelRatio || 1) + }); + if (navigator.sendBeacon) { + navigator.sendBeacon('/api/fe_error', body); + } else { + fetch('/api/fe_error', { method: 'POST', body: body, keepalive: true }) + .catch(function () {}); + } + } catch (e) {} + }; + window.addEventListener('error', function (e) { + // 资源错误只报静态资源(IMG/SCRIPT/LINK);AUDIO 的加载错误多为 + // 流媒体 seek 时的瞬时抖动,用户在播放器上即可感知,上报只会产生噪音 + if (e.target && e.target !== window && (e.target.tagName === 'IMG' || e.target.tagName === 'SCRIPT' || e.target.tagName === 'LINK')) { + // 空 src 的占位封面(初始 src="")在部分内核触发 error 事件且 + // src 属性解析为页面 URL,属噪音(实测曾占满上报通道),跳过 + if (e.target.tagName === 'IMG' && !(e.target.getAttribute('src') || '').trim()) return; + _feSend('resource', (e.target.tagName || '') + ' 加载失败: ' + ((e.target.src || e.target.href || '').slice(-80))); + return; + } + _feSend('error', e.message || '未知错误', (e.filename || '') + ':' + (e.lineno || 0)); + }, true); + window.addEventListener('unhandledrejection', function (e) { + var r = e.reason; + _feSend('promise', (r && (r.message || String(r))) || '未处理的 Promise 拒绝', + (r && r.stack ? String(r.stack).slice(0, 300) : '')); + }); + } catch (e) {} + // 键盘可达性:Tab 页签可用 Tab 键聚焦、回车激活(Enter 触发既有点击处理,零逻辑改动) + try { + $$('#tabs .tab').forEach(function (t) { + t.setAttribute('tabindex', '0'); + t.setAttribute('role', 'button'); + t.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { e.preventDefault(); t.click(); } + }); + }); + } catch (e) {} + + /* ================= 初始化 ================= */ + // 全局音质:恢复上次选择(默认最高音质自动) + var VALID_LEVELS = ['auto', 'jymaster', 'jyeffect', 'sky', 'hires', 'lossless', 'exhigh', 'standard', 'dolby']; + try { + var savedLevel = localStorage.getItem('netease_global_level'); + if (savedLevel && VALID_LEVELS.indexOf(savedLevel) !== -1) $('#global-level').value = savedLevel; + } catch (e) {} + $('#global-level').addEventListener('change', function () { + try { localStorage.setItem('netease_global_level', $('#global-level').value); } catch (e) {} + }); + $('#quality-save').addEventListener('click', function () { + try { localStorage.setItem('netease_global_level', $('#global-level').value); } catch (e) {} + toast('全局音质已保存:' + levelName($('#global-level').value), 'success'); + }); + + // 解析账号:恢复上次选择(admin 全局 / user 个人) + try { + var savedAccount = localStorage.getItem('netease_parse_account'); + if (savedAccount === 'user' || savedAccount === 'admin') store.parseAccount = savedAccount; + } catch (e) {} + // 个人登录 Cookie:仅保存在本机浏览器 + try { + var savedUserCookie = localStorage.getItem('netease_user_cookie'); + if (savedUserCookie) store.personal.cookie = savedUserCookie; + } catch (e) {} + renderAccountBar(); + + loadSettings(); + applyLyricFont(); // 启动即应用持久化的歌词字号(CSS 变量挂 #lyric-box,重绘不丢) + renderSettingsUI(); + applyTheme(); + renderHistory(); + + // 重新打开网站:找回本设备未结束的下载任务(断线自动恢复,接着下载) + // 登录后才会调用(游客请求该接口会 401 并误弹登录框); + // 登录成功由 site_auth 的 afterAuth 回调再触发一次 + window.SITE_TASKS_BOOTSTRAP = loadMyTasks; + // 站点退出时连带清除个人网易云登录——共享电脑场景下, + // 下一位使用者不得沿用前一人的网易云身份(发评/收藏/看歌单)。 + // 清理强度与 personalLogout 对齐(对抗复审):作废在途轮询(防 Cookie 写回)、 + // 清个人面板数据与展示(防下一位用户闪现上一人的头像/歌单) + window.SITE_ON_LOGOUT = function () { + try { localStorage.removeItem('netease_user_cookie'); } catch (e) {} + personalStopQrPoll(); + store.personal.qrGen = (store.personal.qrGen || 0) + 1; + store.personal.authed = false; + store.personal.user = null; + store.personal.playlists = []; + store.personal.current = null; + store.personal.cookie = ''; + var _ptw = $('#personal-tracks-wrap'); + if (_ptw) _ptw.hidden = true; + if (store.parseAccount === 'user') { + store.parseAccount = 'admin'; + try { localStorage.setItem('netease_parse_account', 'admin'); } catch (e) {} + } else { + try { localStorage.removeItem('netease_parse_account'); } catch (e) {} + } + renderPersonalToggle(); + personalResetGate(false); + renderAccountBar(); + }; + if (window.SITE_LOGGED_IN) loadMyTasks(); + + // 新用户首次访问:弹出隐私与协议同意 + if (!store.settings.cookiePrefs.accepted) { + setTimeout(function () { + openCookieModal(); + toast('首次使用请阅读并同意《用户协议》与《隐私政策》', 'info'); + }, 600); + } + + /* ================= 实时排队 / 预计等待 悬浮框 ================= */ + function formatWaitTime(sec) { + if (sec == null || !isFinite(sec) || sec <= 0) return '--'; + if (sec < 60) return '约 ' + Math.max(1, Math.round(sec)) + ' 秒'; + if (sec < 3600) return '约 ' + Math.ceil(sec / 60) + ' 分钟'; + return '约 ' + (sec / 3600).toFixed(1) + ' 小时'; + } + function loadQueue() { + apiPost('/api/queue', {}) + .then(function (data) { + if (data.status !== 200) throw new Error(data.message || '获取排队状态失败'); + var d = data.data || {}; + var waiting = (d.batch_waiting || 0) + (d.download_waiting || 0); + $('#queue-num').textContent = waiting; + $('#queue-download').textContent = + (d.download_active || 0) + '/' + (d.download_capacity || '-'); + $('#queue-batch').textContent = + (d.batch_active || 0) + '/' + (d.batch_capacity || '-'); + // 预计等待 ≈ 批量排队人数 × 平均每批耗时 + var avg = d.avg_batch_seconds; + var est = avg ? (d.batch_waiting || 0) * avg : null; + $('#queue-est').textContent = '预计等待:' + formatWaitTime(est); + $('#queue-float').classList.toggle('busy', waiting > 0); + }) + .catch(function () { /* 拉取失败保持原状,不打扰用户 */ }); + } + loadQueue(); + setInterval(loadQueue, 3000); + + /* ================= 下载任务悬浮窗(批量打包 + 单曲下载,可取消) ================= */ + function getDeviceId() { + var key = 'netease_device_id'; + // 页面内存缓存兜底——localStorage 不可写时,同一页面内标识保持稳定, + // 心跳/任务归属不再每次漂移 + if (window.__deviceIdCache) return window.__deviceIdCache; + var id = null; + try { id = localStorage.getItem(key); } catch (e) {} + if (!id) { + id = 'd' + Date.now().toString(36) + Math.random().toString(36).slice(2, 12); + try { localStorage.setItem(key, id); } catch (e) {} + } + window.__deviceIdCache = id; + return id; + } + function genTaskId() { + return 'u' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); + } + function fmtDur(sec) { + if (sec == null || !isFinite(sec) || sec <= 0) return '--'; + sec = Math.max(0, Math.round(sec)); + var h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60; + if (h > 0) return h + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0'); + return String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0'); + } + var tasks = []; // {id, type, label, total, done, status, statusText, doneCb} + var taskPollTimer = null; + + function addTask(task) { + tasks.push(task); + renderTaskPanel(); + $('#task-panel').classList.add('show'); + if (!taskPollTimer) taskPollTimer = setInterval(pollTasks, 1500); + pollTasks(); + } + function renderTaskPanel() { + var list = $('#task-list'); + $('#task-count').textContent = tasks.length; + list.innerHTML = tasks.map(function (t) { + var pct; + if (t.status === 'done') pct = 100; + else if (t.status === 'running' && typeof t.livePct === 'number') + pct = Math.max(1, Math.min(99, Math.round(t.livePct))); // 服务端实时百分比(单曲为字节级) + else pct = (t.total ? Math.round((t.done || 0) / t.total * 100) : 0); + var statusText = t.statusText || ( + t.status === 'done' ? '完成' : + t.status === 'error' ? '失败' : + t.status === 'canceled' ? '已取消' : + t.status === 'paused' ? '已暂停' : (pct + '%') + ); + var bar = (t.status === 'done' || t.status === 'error' || t.status === 'canceled') + ? '' : '
'; + var cancelBtn = (t.status === 'queued' || t.status === 'running' || t.status === 'paused') + ? '' : ''; + // 已交付条目:领取(35 分钟保留期内可重复下载)+ 手动移除 + var claimBtn = t.claimable + ? '' : ''; + var dismissBtn = t.claimable + ? '' : ''; + // 任务 ID 一律转义后再进属性(防属性注入) + return '
' + + '
' + + '' + escapeHtml(t.label) + '' + + '' + escapeHtml(statusText) + '' + + cancelBtn + claimBtn + dismissBtn + + '
' + bar + '
'; + }).join('') || '
暂无任务
'; + $$('#task-list .task-item-cancel').forEach(function (btn) { + btn.addEventListener('click', function () { cancelTask(btn.getAttribute('data-id')); }); + }); + $$('#task-list .task-item-claim').forEach(function (btn) { + btn.addEventListener('click', function () { claimTask(btn.getAttribute('data-id')); }); + }); + $$('#task-list .task-item-dismiss').forEach(function (btn) { + btn.addEventListener('click', function () { + var id = btn.getAttribute('data-id'); + tasks = tasks.filter(function (t) { return t.id !== id; }); + renderTaskPanel(); + if (!tasks.length) $('#task-panel').classList.remove('show'); + }); + }); + } + var _pollBusy = false; // 在途锁——15s 超时下轮询响应重叠时,同一终态会弹两次 toast + function pollTasks() { + if (_pollBusy) return; + var active = tasks.filter(function (t) { + return t.status === 'queued' || t.status === 'running' || t.status === 'paused'; + }); + if (!active.length) { + if (taskPollTimer) { clearInterval(taskPollTimer); taskPollTimer = null; } + return; + } + _pollBusy = true; + var doneCount = 0; + function _pollDone() { doneCount++; if (doneCount >= active.length) _pollBusy = false; } + active.forEach(function (t) { + apiPost('/api/batch_progress', { task_id: t.id }) + .then(function (data) { + if (data.status === 404) { + // 任务已被服务端清理(过期):只收尾一次(404 重叠不再重复推进队列) + if (t._gone) return; + t._gone = true; + toast('任务已过期:' + t.label, 'info'); + removeTaskLater(t.id, 500); + renderTaskPanel(); + // 过期=交付结果未知,明确以 'unknown' 传给逐首队列(不再虚报成功) + if (typeof t.doneCb === 'function') { t.doneCb('unknown'); } + return; + } + if (data.status !== 200) return; + var d = data.data || {}; + // 陈旧响应不得把终态任务拉回运行中(以 _done/_ended 为准); + // total 同步服务端(播客总数解析后进度条口径一致) + if (t._done || t._ended) return; + t.done = d.done || 0; + if (typeof d.total === 'number' && d.total > 0) t.total = d.total; + if (d.status === 'running') { + t.status = 'running'; + var pct = Math.round(d.percent || 0); + t.livePct = (typeof d.percent === 'number') ? d.percent : t.livePct; // 进度条实时化 + t.statusText = pct + '%' + (d.eta ? ' · 剩' + fmtDur(d.eta) : ''); + t.statusText += (d.failed ? ' · 失败' + d.failed : ''); + } else { + t.status = d.status || t.status; + t.statusText = d.msg || d.status; + } + if (d.status === 'done') { + if (d.refunded > 0) { + t.statusText = (t.statusText || '完成') + ' · 已退回 ' + d.refunded + ' 次'; + toast('任务完成:' + t.label + ',失败部分已退回 ' + d.refunded + ' 次下载次数', 'info'); + } + if (window.refreshSITECredits) window.refreshSITECredits(); // 同步顶栏次数(含退费) + onTaskDone(t); + } + else if (d.status === 'error') { + t._ended = true; + toast('下载失败:' + t.label + (d.msg ? '(' + d.msg + ')' : ''), 'error'); + if (window.refreshSITECredits) window.refreshSITECredits(); // 同步顶栏次数(含退费) + // 失败但已有部分成果(has_result)→ 照常领取; + // 逐首队列的回调也要推进并如实报失败(原实现 error/canceled 不调 doneCb,队列卡死) + if (d.has_result) { onTaskDone(t); } + else if (typeof t.doneCb === 'function') { t.doneCb(false); removeTaskLater(t.id, 8000); } + else removeTaskLater(t.id, 8000); + } else if (d.status === 'canceled') { + t._ended = true; + if (window.refreshSITECredits) window.refreshSITECredits(); // 同步顶栏次数(含退费) + toast('已取消:' + t.label, 'info'); + if (d.has_result) { onTaskDone(t); } + else if (typeof t.doneCb === 'function') { t.doneCb(false); removeTaskLater(t.id, 2500); } + else removeTaskLater(t.id, 2500); + } + renderTaskPanel(); + }) + .catch(function () {}) + .then(function () { _pollDone(); }); // 链尾统一释放(成功/失败各恰一次) + }); + } + // 触发结果文件下载(单曲走 /download/result,批量走 /download/batch_zip/result) + function triggerTaskDownload(t) { + var url = (t.type === 'batch' ? '/download/batch_zip/result' : '/download/result') + + '?task_id=' + encodeURIComponent(t.id) + + '&device_id=' + encodeURIComponent(getDeviceId()); + var a = document.createElement('a'); + a.href = url; + a.download = ''; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } + // 手动重领:浏览器拦截自动下载时,35 分钟保留期内可反复领取 + function claimTask(id) { + var target = null; + tasks.forEach(function (x) { if (x.id === id) target = x; }); + if (!target) return; + triggerTaskDownload(target); + toast('正在重新领取:' + target.label, 'info'); + } + // 已结束条目最多保留 max 条(防止任务面板无界增长) + function trimEndedTasks(max) { + var ended = tasks.filter(function (x) { return x.claimable || x._ended; }); + var extra = ended.length - max; + for (var i = 0; i < extra; i++) { + (function (eid) { + tasks = tasks.filter(function (t) { return t.id !== eid; }); + })(ended[i].id); + } + } + function onTaskDone(t) { + if (t._done) return; // 幂等:防止并发轮询重复触发下载 + t._done = true; + toast('✅ 下载完成:' + t.label, 'success'); + triggerTaskDownload(t); + // 完成条目保留「领取」入口(原实现 4 秒即移除——浏览器拦截自动下载时 + // 用户毫无补救办法,文件在服务端留 35 分钟却取不到) + t.claimable = true; + removeTaskLater(t.id, 35 * 60 * 1000); // 与服务端 35 分钟保留期完全一致(原 30 分钟末尾领取必 404) + trimEndedTasks(20); + renderTaskPanel(); + if (t.doneCb) t.doneCb(t); + } + function cancelTask(id) { + apiPost('/api/task/cancel', { task_id: id, device_id: getDeviceId() }) + .then(function (data) { + if (data.status !== 200) { toast(data.message || '取消失败', 'error'); return; } + toast('已请求取消任务', 'info'); + }) + .catch(function () { toast('取消失败,请检查网络', 'error'); }); + } + function removeTaskLater(id, ms) { + setTimeout(function () { + tasks = tasks.filter(function (t) { return t.id !== id; }); + renderTaskPanel(); + if (!tasks.length) $('#task-panel').classList.remove('show'); + }, ms); + } + /* 设备心跳:保持任务活跃;离线超时任务会被暂停让位,重新打开页面自动恢复 */ + function sendHeartbeat() { + var hasTasks = tasks.some(function (t) { + return t.status === 'queued' || t.status === 'running' || t.status === 'paused'; + }); + if (!hasTasks) return; + apiPost('/api/task/heartbeat', { device_id: getDeviceId() }) + .then(function (data) { + if (data.status === 200 && data.data && data.data.resumed > 0) { + toast('任务已恢复,继续下载', 'info'); + pollTasks(); + } + }) + .catch(function () {}); + } + setInterval(sendHeartbeat, 15000); + /* 重新打开网站:找回本设备未结束的任务,重新进入排队列表接着下载 */ + function loadMyTasks() { + apiPost('/api/task/list', { device_id: getDeviceId() }) + .then(function (data) { + if (data.status !== 200) return; var my = data.data.tasks || []; + if (!my.length) return; + var added = false; + my.forEach(function (t) { + if (tasks.some(function (x) { return x.id === t.task_id; })) return; + tasks.push({ + id: t.task_id, + type: t.type, + label: t.kind === 'podcast' ? '播客打包 · ' + t.total + ' 集' : '批量打包 · ' + t.total + ' 首', + total: t.total, + done: t.done || 0, + status: t.status, + statusText: t.status === 'paused' ? '已暂停(恢复中...)' : (t.msg || '') + }); + added = true; + }); + if (added) { + renderTaskPanel(); + $('#task-panel').classList.add('show'); + // 恢复任务后启动持续轮询(原实现只查一次, + // 刷新页面恢复的任务永远等不到 done) + if (!taskPollTimer) taskPollTimer = setInterval(pollTasks, 1500); + pollTasks(); + } + }) + .catch(function () {}); + } + $('#task-panel-close').addEventListener('click', function () { + $('#task-panel').classList.remove('show'); + }); + +__legacyApi.switchTab = switchTab; +__legacyApi.closeStatsModal = closeStatsModal; +__legacyApi.closeCookieModal = closeCookieModal; +} +const __legacyApi: any = { switchTab: null, closeStatsModal: null, closeCookieModal: null } +export { __legacyApi } +export function switchTab(): void { return __legacyApi.switchTab && __legacyApi.switchTab.apply(this, arguments as any) } diff --git a/frontend/src/global.d.ts b/frontend/src/global.d.ts new file mode 100644 index 0000000..455d498 --- /dev/null +++ b/frontend/src/global.d.ts @@ -0,0 +1,5 @@ +/* 旧脚本通过 window.* 全局钩子双向协作(与旧版一致,暂保留该机制)。 + * 这里放宽 window 索引类型,避免逐个声明钩子。 */ +interface Window { + [key: string]: any +} diff --git a/frontend/src/lib/store.ts b/frontend/src/lib/store.ts new file mode 100644 index 0000000..f441c0f --- /dev/null +++ b/frontend/src/lib/store.ts @@ -0,0 +1,85 @@ +/* 轻量全局 store:保持旧版「单一可变 state 对象 + 显式 notify」的心智模型。 + * 组件通过 useAppVersion() 订阅版本号,读取 store 字段渲染; + * 逻辑层照旧直接改 store 字段后调 notify()——与旧版改 state + render*() 等价。 */ +import { useSyncExternalStore } from 'react' +import type { AppState } from './types' +import { DEFAULT_SETTINGS } from './types' + +export const store: AppState = { + currentSong: null, + lyrics: [], + lyricsTrans: [], + rawLyric: null, + rawTlyric: null, + rawYrc: null, + currentTab: 'search', + commentOffset: 0, + commentTotal: 0, + commentHasMore: false, + commentPage: 1, + commentReply: null, + settings: { ...DEFAULT_SETTINGS, metaFields: { ...DEFAULT_SETTINGS.metaFields }, cookiePrefs: { ...DEFAULT_SETTINGS.cookiePrefs } }, + adminAuthed: false, + adminPassword: '', + adminQrTimer: null, + adminUnikey: '', + personal: { authed: false, qrTimer: null, unikey: '', user: null, playlists: [], current: null, cookie: '' }, + global: { user: null, playlists: [], current: null, playQueue: [], playQueueIndex: 0 }, + parseAccount: 'admin', + levelsOk: [], + lyricOffset: 0, + currentCollection: null, + podcast: { hotLoaded: false, hotCate: null, hotOffset: 0, radio: null, radioId: null, program: null, offset: 0, count: 0, asc: true }, + auth: { user: null, checked: false, annSeen: '' }, + toplistLoaded: false, + collGen: {}, + dark: false, +} + +let version = 0 +const subs = new Set<() => void>() + +export function notify() { + version++ + subs.forEach((f) => f()) +} + +function subscribe(f: () => void) { + subs.add(f) + return () => { + subs.delete(f) + } +} + +function getVersion() { + return version +} + +/** 订阅全局 store 版本号;组件内配合直接读 store 字段使用。 */ +export function useAppVersion(): number { + return useSyncExternalStore(subscribe, getVersion) +} + +/* ================= Toast ================= */ +export interface ToastItem { + id: number + msg: string + type: string +} + +let toastSeq = 0 +export const toasts: ToastItem[] = [] + +export function toast(msg: string, type?: string) { + const t = type || 'info' + const id = ++toastSeq + toasts.push({ id, msg, type: t }) + notify() + setTimeout(() => { + const i = toasts.findIndex((x) => x.id === id) + if (i >= 0) { + toasts.splice(i, 1) + notify() + } + }, 2900) +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts new file mode 100644 index 0000000..b863c5a --- /dev/null +++ b/frontend/src/lib/types.ts @@ -0,0 +1,126 @@ +/* 全局应用状态类型定义。 + * 与旧版 index.html 的 state 对象一一对应,保证移植行为一致。 */ + +export interface Settings { + primaryColor: string + themeMode: 'light' | 'dark' | 'system' + ktv: boolean + lyric2line: boolean + progressMemory: boolean + showComments: boolean + smtc: boolean + filenameFormat: string + writeMeta: boolean + metaFields: Record + zipEnabled: boolean + batchWriteMeta: boolean + batchZip: boolean + lyricFormat: 'lrc' | 'srt' + lyricFont: number + cookiePrefs: { functional: boolean; accepted: boolean } +} + +export interface PersonalState { + authed: boolean + qrTimer: ReturnType | null + unikey: string + user: any | null + playlists: any[] + current: { id: string; name: string } | null + cookie: string +} + +export interface GlobalState { + user: any | null + playlists: any[] + current: { id: string; name: string } | null + playQueue: any[] + playQueueIndex?: number +} + +export interface PodcastState { + hotLoaded: boolean + hotCate: string | null + hotOffset: number + radio: any | null + radioId: string | null + program: any | null + offset: number + count: number + asc: boolean +} + +export interface AuthState { + user: any | null + checked: boolean + annSeen: string +} + +export interface AppState { + currentSong: any | null + lyrics: any[] + lyricsTrans: any[] + rawLyric: string | null + rawTlyric: string | null + rawYrc: string | null + currentTab: string + commentOffset: number + commentTotal: number + commentHasMore: boolean + commentPage: number + commentReply: { id: string; nickname: string } | null + settings: Settings + adminAuthed: boolean + adminPassword: string + adminQrTimer: ReturnType | null + adminUnikey: string + personal: PersonalState + global: GlobalState + parseAccount: 'admin' | 'user' + levelsOk: string[] + lyricOffset: number + currentCollection: { type: string; id: any; name: string } | null + podcast: PodcastState + auth: AuthState + /* 运行时附加状态(与旧版一致,动态挂在 state 上) */ + toplistLoaded: boolean + collGen: Record + page_playlist?: number + page_album?: number + page_artist?: number + page_personal?: number + page_global?: number + tracks_playlist?: any[] + tracks_album?: any[] + tracks_artist?: any[] + tracks_personal?: any[] + tracks_global?: any[] + _commentsLoading?: boolean + _commentGen?: number + ktvRunning?: boolean + lastActiveLine?: number + ktvFillIdx?: number + ktvWordEls?: NodeListOf | null + ktvLineEls?: Element[] | null + _queueNoResume?: boolean + dark?: boolean +} + +export const DEFAULT_SETTINGS: Settings = { + primaryColor: '#1677ff', + themeMode: 'system', + ktv: false, + lyric2line: true, + progressMemory: true, + showComments: true, + smtc: true, + filenameFormat: 'artist_song', + writeMeta: true, + metaFields: { name: true, artist: true, album: true, cover: true, lyrics: false, date: false }, + zipEnabled: false, + batchWriteMeta: true, + batchZip: true, + lyricFormat: 'lrc', + lyricFont: 1, + cookiePrefs: { functional: true, accepted: false }, +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..234017a --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,6 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './styles.css' + +ReactDOM.createRoot(document.getElementById('root')!).render() diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..26d469c --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,1771 @@ + :root { + --color-primary: #1677ff; + --color-primary-hover: #4096ff; + --color-primary-active: #0958d9; + --color-primary-bg: rgba(22, 119, 255, 0.1); + --color-success: #52c41a; + --color-warning: #faad14; + --color-error: #ff4d4f; + --color-text: rgba(0, 0, 0, 0.88); + --color-text-secondary: rgba(0, 0, 0, 0.45); + --color-text-tertiary: rgba(0, 0, 0, 0.25); + --color-border: #d9d9d9; + --color-border-split: rgba(5, 5, 5, 0.06); + --color-bg-layout: #f0f2f5; + --color-bg-container: #ffffff; + --color-bg-elevated: #ffffff; + --color-bg-subtle: #f5f6f8; + --color-bg-hover: rgba(0, 0, 0, 0.03); + --color-fill: rgba(0, 0, 0, 0.04); + --ease-out: cubic-bezier(0.22, 1, 0.36, 1); + --color-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 2px 8px rgba(15, 23, 42, 0.04); + --color-shadow-heavy: 0 2px 4px rgba(15, 23, 42, 0.03), 0 12px 32px -8px rgba(15, 23, 42, 0.12), 0 32px 64px -24px rgba(15, 23, 42, 0.10); + --player-bg: linear-gradient(135deg, #2a2a35, #1c1c26); + --player-text: #ffffff; + --vip-green: #52c41a; /* --vip-gold/--vip-red 死变量已删 */ + --color-bg-header: rgba(255, 255, 255, 0.82); + } + html.dark { + --color-primary: #1668dc; + --color-primary-hover: #3c89e8; + --color-primary-active: #1554ad; + --color-primary-bg: rgba(22, 104, 220, 0.2); + --color-text: rgba(255, 255, 255, 0.85); + --color-text-secondary: rgba(255, 255, 255, 0.45); + --color-text-tertiary: rgba(255, 255, 255, 0.25); + --color-border: #424242; + --color-border-split: rgba(253, 253, 253, 0.12); + --color-bg-layout: #000000; + --color-bg-container: #141414; + --color-bg-elevated: #1f1f1f; + --color-bg-subtle: #1c1c20; + --color-bg-hover: rgba(255, 255, 255, 0.08); + --color-fill: rgba(255, 255, 255, 0.12); + --ease-out: cubic-bezier(0.22, 1, 0.36, 1); + --color-shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3); + --color-shadow-heavy: 0 2px 4px rgba(0, 0, 0, 0.4), 0 12px 32px -8px rgba(0, 0, 0, 0.55), 0 32px 64px -24px rgba(0, 0, 0, 0.45); + --color-bg-header: rgba(20, 20, 20, 0.82); + } + * { box-sizing: border-box; } + body { + background-color: var(--color-bg-layout); + color: var(--color-text); + margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, + 'Noto Sans', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; + font-size: 14px; + line-height: 1.5715; + -webkit-font-smoothing: antialiased; + min-height: 100vh; + display: flex; + flex-direction: column; + } + a { color: var(--color-primary); text-decoration: none; } + a:hover { color: var(--color-primary-hover); } + + /* ============ 顶部导航 ============ */ + .app-header { + background: var(--color-bg-container); + background: var(--color-bg-header); + backdrop-filter: blur(14px) saturate(1.6); + -webkit-backdrop-filter: blur(14px) saturate(1.6); + box-shadow: var(--color-shadow); + position: sticky; + top: 0; + z-index: 100; + } + .app-header-inner { + max-width: 1250px; + margin: 0 auto; + padding: 0 24px; + height: 64px; + display: flex; + align-items: center; + justify-content: space-between; + } + .brand { display: flex; align-items: center; gap: 12px; cursor: pointer; user-select: none; } + .brand-logo { + width: 36px; height: 36px; border-radius: 10px; + background: linear-gradient(135deg, #f5576c, #ec4141); + color: #fff; font-size: 20px; + display: flex; align-items: center; justify-content: center; + flex: none; box-shadow: 0 2px 6px rgba(236, 65, 65, 0.35); + } + .brand-title { font-size: 17px; font-weight: 600; color: var(--color-text); } + .brand-sub { font-size: 12px; color: var(--color-text-secondary); } + + .header-actions { display: flex; align-items: center; gap: 10px; } + .icon-btn { + border: 1px solid var(--color-border); + background: var(--color-bg-container); + color: var(--color-text); + border-radius: 8px; + height: 34px; padding: 0 12px; + font-size: 14px; cursor: pointer; + display: inline-flex; align-items: center; gap: 6px; + transition: all 0.2s; + } + .icon-btn:hover { border-color: var(--color-primary); color: var(--color-primary); } + + /* ============ 主容器 ============ */ + .main-container { + width: 100%; + max-width: 1250px; + margin: 0 auto; + padding: 24px; + flex: 1; + } + + /* ============ 卡片 ============ */ + .card { + background: var(--color-bg-container); + border-radius: 12px; + box-shadow: var(--color-shadow); + padding: 24px; + margin-bottom: 24px; + } + .card-title { + font-size: 16px; font-weight: 600; margin-bottom: 16px; + display: flex; align-items: center; justify-content: space-between; + } + .card-title .count { font-size: 13px; font-weight: 400; color: var(--color-text-secondary); } + + /* ============ Tab 栏 ============ */ + .tabs { + display: flex; + border-bottom: 1px solid var(--color-border-split); + margin-bottom: 20px; + overflow-x: auto; + } + .tab { + padding: 12px 0; + margin-right: 32px; + font-size: 15px; + color: var(--color-text-secondary); + cursor: pointer; + white-space: nowrap; + position: relative; + transition: color 0.2s; + } + .tab:hover { color: var(--color-primary); } + .tab.active { color: var(--color-primary); font-weight: 500; } + .tab::after { + content: ''; + position: absolute; + left: 0; right: 0; bottom: -1px; + height: 2px; + background: var(--color-primary); + border-radius: 2px; + transform: scaleX(0); + opacity: 0; + transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s; + } + .tab.active::after { + transform: scaleX(1); + opacity: 1; + } + + .panel { display: none; } + .panel.active { display: block; } + + /* ============ 全局音质选择栏 ============ */ + .global-quality-bar { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + padding-bottom: 16px; + border-bottom: 1px solid var(--color-border-split); + } + .gq-label { + font-size: 14px; + font-weight: 500; + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--color-text); + } + .gq-hint { font-size: 12px; color: var(--color-text-tertiary); } + + /* ============ 表单控件(AntD 风格) ============ */ + .input-row { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + } + .input-row > .input { flex: 1; min-width: 220px; } + .input, .select { + height: 36px; + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-bg-container); + color: var(--color-text); + padding: 4px 11px; + font-size: 14px; + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; + } + .input:focus, .select:focus { + border-color: var(--color-primary); + box-shadow: 0 0 0 2px var(--color-primary-bg); + } + .input::placeholder { color: var(--color-text-tertiary); } + .select { cursor: pointer; } + + .btn { + height: 36px; + padding: 4px 16px; + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-bg-container); + color: var(--color-text); + font-size: 14px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + white-space: nowrap; + transition: all 0.2s; + user-select: none; + } + .btn:hover { border-color: var(--color-primary); color: var(--color-primary); } + .btn:disabled { opacity: 0.6; cursor: not-allowed; } + .btn-primary { + background: var(--color-primary); + border-color: var(--color-primary); + color: #fff; + } + .btn-primary:hover { background: var(--color-primary-hover); border-color: var(--color-primary-hover); color: #fff; } + .btn-primary:active { background: var(--color-primary-active); } + .btn-success { background: var(--color-success); border-color: var(--color-success); color: #fff; } + .btn-success:hover { background: #73d13d; border-color: #73d13d; color: #fff; } + .btn-danger { background: var(--color-error); border-color: var(--color-error); color: #fff; } + .btn-danger:hover { background: #ff7875; border-color: #ff7875; color: #fff; } + .btn-sm { height: 30px; padding: 0 12px; font-size: 13px; border-radius: 6px; } + .btn-link { border: none; background: transparent; color: var(--color-primary); height: auto; padding: 2px 6px; } + .btn-link:hover { color: var(--color-primary-hover); } + + .btn .spinner { + width: 14px; height: 14px; + border: 2px solid rgba(255,255,255,0.4); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + /* `.btn-default-loading .spinner` 死规则已删(setBtnLoading + 走 .btn .spinner 且全部用于彩色按钮,本选择器从未被命中) */ + @keyframes spin { to { transform: rotate(360deg); } } + + .hint { font-size: 13px; color: var(--color-text-secondary); margin-top: 10px; } + + /* ============ 历史标签 ============ */ + .history-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 16px; } + .history-label { font-size: 13px; color: var(--color-text-secondary); } + .tag { + display: inline-flex; align-items: center; gap: 4px; + background: var(--color-fill); + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 1px 8px; + font-size: 12px; + color: var(--color-text); + cursor: pointer; + transition: all 0.2s; + max-width: 260px; + } + .tag:hover { border-color: var(--color-primary); color: var(--color-primary); } + .tag .tag-close { color: var(--color-text-tertiary); font-size: 11px; } + .tag .tag-close:hover { color: var(--color-error); } + .tag-empty { font-size: 12px; color: var(--color-text-tertiary); } + + /* ============ 歌曲列表 ============ */ + .song-list { display: flex; flex-direction: column; } + .song-row { + display: flex; align-items: center; gap: 12px; + padding: 10px 12px; + border-radius: 10px; + transition: background 0.2s; + } + .song-row:hover { background: var(--color-bg-hover); } + .song-cover { + width: 48px; height: 48px; + border-radius: 8px; + object-fit: cover; + background: var(--color-fill); + flex: none; + } + .song-info { flex: 1; min-width: 0; } + .song-name { + font-weight: 500; + display: flex; align-items: center; gap: 6px; + min-width: 0; flex-wrap: wrap; + } + .song-name .n { + flex: 0 1 auto; min-width: 0; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + .song-name .tag, + .song-name .badge { + flex: 0 1 auto; min-width: 0; max-width: 100%; + white-space: normal; + } + .song-meta { + font-size: 13px; color: var(--color-text-secondary); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + .song-actions { display: flex; gap: 8px; flex: none; } + .divider { height: 1px; background: var(--color-border-split); margin: 8px 0; } + + /* ============ 单曲解析结果 ============ */ + .song-detail { display: flex; gap: 24px; flex-wrap: wrap; } + .song-detail-cover { + width: 200px; height: 200px; + border-radius: 16px; + object-fit: cover; + box-shadow: var(--color-shadow-heavy); + background: var(--color-fill); + flex: none; + } + .song-detail-info { flex: 1; min-width: 260px; } + .detail-name { font-size: 22px; font-weight: 600; margin: 4px 0 8px; word-break: break-all; } + .detail-meta { margin: 4px 0; color: var(--color-text-secondary); font-size: 14px; } + .detail-meta b { color: var(--color-text); font-weight: 500; margin-right: 8px; } + .detail-actions { margin-top: 16px; display: flex; gap: 10px; flex-wrap: wrap; } + .detail-badges { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; } + .badge { + display: inline-flex; align-items: center; + font-size: 12px; padding: 2px 10px; border-radius: 999px; + border: 1px solid var(--color-primary); + color: var(--color-primary); + background: var(--color-primary-bg); + } + .badge-green { border-color: var(--color-success); color: var(--color-success); background: rgba(82, 196, 26, 0.1); } + + /* ============ 播放器(自定义,深色) ============ */ + .player { + margin-top: 24px; + border-radius: 12px; + background: var(--player-bg); + color: var(--player-text); + padding: 16px 20px; + box-shadow: var(--color-shadow-heavy); + } + .player-top { display: flex; align-items: center; gap: 14px; } + .player-cover { + width: 56px; height: 56px; border-radius: 10px; + object-fit: cover; background: rgba(255,255,255,0.15); flex: none; + } + .player-track { flex: 1; min-width: 0; } + .player-name { + font-size: 15px; font-weight: 500; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + .player-artist { font-size: 13px; color: rgba(255,255,255,0.55); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .player-controls { display: flex; align-items: center; gap: 10px; flex: none; } + .player-ctrl { + width: 42px; height: 42px; border-radius: 50%; + background: rgba(255,255,255,0.15); + border: none; color: #fff; font-size: 16px; + cursor: pointer; display: flex; align-items: center; justify-content: center; + transition: background 0.2s, transform 0.1s; flex: none; + } + .player-ctrl:hover { background: rgba(255,255,255,0.28); } + .player-ctrl:active { transform: scale(0.95); } + .player-ctrl-sm { width: 30px; height: 30px; border-radius: 8px; font-size: 13px; background: transparent; } + .player-ctrl-sm:hover { background: rgba(255,255,255,0.2); } + .player-bottom { display: flex; align-items: center; gap: 12px; margin-top: 14px; } + .player-time { font-size: 12px; color: rgba(255,255,255,0.6); font-variant-numeric: tabular-nums; flex: none; } + .progress { + flex: 1; height: 4px; border-radius: 2px; + background: rgba(255,255,255,0.2); + cursor: pointer; position: relative; + } + .progress-fill { + height: 100%; border-radius: 2px; + background: var(--color-primary); + width: 0; + position: relative; + } + .progress-fill::after { + content: ''; position: absolute; right: -5px; top: 50%; + width: 10px; height: 10px; border-radius: 50%; + background: #fff; transform: translateY(-50%); + box-shadow: 0 1px 3px rgba(0,0,0,0.4); + opacity: 0; transition: opacity 0.2s; + } + .progress:hover .progress-fill::after { opacity: 1; } + .volume-wrap { display: flex; align-items: center; gap: 8px; flex: 0 0 140px; } + .volume-btn { background: none; border: none; color: rgba(255,255,255,0.7); cursor: pointer; font-size: 15px; padding: 2px; } + .volume-slider { flex: 1; height: 4px; border-radius: 2px; background: rgba(255,255,255,0.2); cursor: pointer; position: relative; } + .volume-fill { height: 100%; border-radius: 2px; background: rgba(255,255,255,0.7); width: 100%; } + + /* ============ 歌词 ============ */ + .lyric-panel { + margin-top: 20px; + background: var(--color-bg-container); + border-radius: 12px; + box-shadow: var(--color-shadow); + padding: 16px 20px; + } + .lyric-panel-title { font-size: 14px; font-weight: 600; margin-bottom: 12px; } + .lyric-calib { display: inline-flex; align-items: center; gap: 6px; position: relative; } /* relative:字号弹层以整组右缘定位,任何视口都不越出面板 */ + .btn-mini { height: 24px; padding: 0 8px; font-size: 12px; border-radius: 6px; } + .lyric-offset-label { font-size: 12px; color: var(--color-text-tertiary); font-variant-numeric: tabular-nums; min-width: 70px; } + /* 歌词字号弹层(#lyric-fontsize 展开):变量 --lyric-fs 挂在 #lyric-box 上,重绘歌词不丢 */ + .lyric-fs-pop { + position: absolute; top: calc(100% + 8px); right: 0; z-index: 50; + display: inline-flex; align-items: center; gap: 8px; + background: var(--color-bg-container); + border: 1px solid var(--color-border); + border-radius: 10px; + box-shadow: var(--color-shadow-heavy); + padding: 8px 10px; + animation: rowIn 0.18s var(--ease-out); + } + .lyric-fs-val { font-size: 12px; color: var(--color-text-secondary); font-variant-numeric: tabular-nums; min-width: 38px; text-align: center; } + .lyric-fs-btn { height: 30px; min-width: 34px; padding: 0 10px; font-size: 13px; border-radius: 8px; } + .lyric-fs-btn:disabled { opacity: .45; cursor: not-allowed; } + /* 弹层展开中:触发按钮主色高亮,开合状态一眼可见 */ + #lyric-fontsize[aria-expanded="true"] { border-color: var(--color-primary); color: var(--color-primary); background: var(--color-primary-bg); } + .lyric-box { + /* 高度随字号适度放大(上限 420px):大字号不再一屏只有两三对;老内核不支持 min() 回落 280px */ + max-height: 280px; + max-height: min(calc(280px * var(--lyric-fs, 1)), 420px); + overflow-y: auto; + scrollbar-width: thin; + /* 右侧 16px:激活行 scale(1.02) 只向右伸长(origin left),宽屏下 ~14px 的伸长量落在这里,末字不再被裁 */ + padding: 4px 16px 4px 0; + position: relative; /* 作为歌词行的定位祖先,保证 offsetTop 相对本容器 */ + } + .lyric-box::-webkit-scrollbar { width: 6px; } + .lyric-box::-webkit-scrollbar-thumb { background: var(--color-border); border-radius: 3px; } + .lyric-line { + padding: 6px 10px; + border-radius: 6px; + color: var(--color-text-secondary); /* 未播放的歌词为灰色 */ + font-size: calc(14px * var(--lyric-fs, 1)); /* 用户可调字号(#lyric-fontsize),默认 100% */ + line-height: 1.6; + cursor: pointer; + transform-origin: left center; /* 高亮放大从左缘锚定:左对齐不变,最左字不再被 overflow-x:hidden 裁掉 */ + transition: color 0.25s, background 0.2s, transform 0.25s; + } + .lyric-line:hover { background: var(--color-bg-hover); } + .lyric-line + .lyric-line { margin-top: 3px; } /* 行对之间稍松、行对内部紧凑(原文+译文),段落节奏更清楚 */ + .lyric-line.active { /* 当前播放的句子居中并标蓝 */ + color: var(--color-primary); + font-weight: 600; + transform: scale(1.02); + } + /* KTV 逐字高亮:有逐字时间轴(YRC)的行按字渲染(.kw)逐字推进填充; + 无 YRC 的行不做仿逐字整行渐变扫色(owner 2026-09-22 拍板撤销原"回退整行渐变"),仅普通高亮(.active 整行点亮) */ + .lyric-line.ktv.active.kw-line .kw { color: var(--color-text-secondary); } + .lyric-line.ktv.active.kw-line .kw.on { color: var(--color-primary); } + .lyric-line.ktv.active.kw-line .kw.cur { + background-image: linear-gradient(to right, + var(--color-primary) 0%, var(--color-primary) var(--pw, 0%), + var(--color-text-secondary) var(--pw, 0%), var(--color-text-secondary) 100%); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + color: transparent; + } + /* 译文弱化:所有模式统一(原先仅 KTV 生效,非 KTV 译文与原文同权); + 激活行切换时译文随 0.25s 柔和淡入主色(与原文行的变色节奏一致;reduced-motion 全局规则自动关闭) */ + .lyric-line .ktr { color: var(--color-text-tertiary); font-weight: 400; font-size: 0.9em; + transition: color 0.25s var(--ease-out), -webkit-text-fill-color 0.25s var(--ease-out); } + /* 激活行译文跟主色但不加粗(层级靠字号与字重,对齐官方播放器观感);自身声明优先级高于行继承 */ + .lyric-line.active .ktr { color: var(--color-primary); } + .lyric-empty { color: var(--color-text-tertiary); font-size: 13px; padding: 10px; } + + /* ============ 评论区 ============ */ + .comment-panel { + margin-top: 20px; + background: var(--color-bg-container); + border-radius: 12px; + box-shadow: var(--color-shadow); + padding: 16px 20px; + } + .comment-panel-title { font-size: 14px; font-weight: 600; margin-bottom: 12px; } + .comment-list { display: flex; flex-direction: column; } + .comment-item { + display: flex; gap: 12px; + padding: 12px 8px; + border-bottom: 1px solid var(--color-border-split); + } + .comment-item:last-child { border-bottom: none; } + .comment-avatar { + width: 34px; height: 34px; border-radius: 50%; + object-fit: cover; background: var(--color-fill); flex: none; + } + .comment-body { flex: 1; min-width: 0; } + .comment-head { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } + .comment-nickname { font-weight: 500; font-size: 13px; } + .vip-badge { + font-size: 11px; padding: 0 6px; border-radius: 4px; + font-weight: 500; line-height: 18px; display: inline-flex; + } + .vip-vip { background: linear-gradient(90deg, #f7ba2a, #ea5455); color: #fff; } + .vip-svip { background: linear-gradient(90deg, #c8161d, #8f0f14); color: #fff; } + /* .vip-musician 死规则已删(前端不产生该类名) */ + .vip-pkg { background: rgba(82, 196, 26, 0.15); color: var(--vip-green); border: 1px solid rgba(82,196,26,0.4); } + .comment-meta { font-size: 12px; color: var(--color-text-tertiary); } + .comment-content { font-size: 14px; margin-top: 4px; word-break: break-word; } + .comment-reply { + margin-top: 6px; font-size: 13px; + background: var(--color-fill); border-radius: 6px; + padding: 6px 10px; color: var(--color-text-secondary); + } + .comment-actions { margin-top: 6px; display: flex; align-items: center; gap: 14px; font-size: 12px; color: var(--color-text-tertiary); } + .comment-like { display: inline-flex; align-items: center; gap: 4px; } + .comment-more { text-align: center; margin-top: 12px; } + .comment-empty { color: var(--color-text-tertiary); font-size: 13px; padding: 16px 0; text-align: center; } + + /* ============ 合集头部(歌单/专辑/歌手) ============ */ + .collection-header { display: flex; gap: 20px; flex-wrap: wrap; } + .collection-cover { + width: 160px; height: 160px; border-radius: 16px; + object-fit: cover; box-shadow: var(--color-shadow-heavy); + background: var(--color-fill); flex: none; + } + .collection-cover-round { border-radius: 50%; } + .collection-info { flex: 1; min-width: 240px; } + .collection-name { font-size: 20px; font-weight: 600; margin: 4px 0 8px; } + .collection-meta { color: var(--color-text-secondary); font-size: 13px; margin: 3px 0; } + .collection-desc { + margin-top: 8px; font-size: 13px; color: var(--color-text-secondary); + max-height: 60px; overflow-y: auto; + background: var(--color-fill); border-radius: 8px; padding: 8px 12px; + } + .collection-actions { margin-top: 12px; } + + /* ============ 榜单卡片墙 ============ */ + .toplist-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 16px; + } + .toplist-card { + cursor: pointer; + border-radius: 12px; + padding: 12px; + transition: all 0.2s; + border: 1px solid transparent; + } + .toplist-card:hover { background: var(--color-bg-hover); transform: translateY(-2px); } + .toplist-card .tl-cover { + width: 100%; aspect-ratio: 1; + border-radius: 12px; object-fit: cover; + background: var(--color-fill); box-shadow: var(--color-shadow); + } + .toplist-card .tl-name { + font-size: 14px; font-weight: 500; margin-top: 8px; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + .toplist-card .tl-meta { font-size: 12px; color: var(--color-text-tertiary); margin-top: 2px; } + + /* ============ 播客 ============ */ + .podcast-cate-bar { + display: flex; gap: 8px; overflow-x: auto; flex: 1; + padding: 4px 2px; scrollbar-width: thin; + } + .podcast-cate-item { + flex: none; font-size: 12px; padding: 4px 12px; + border-radius: 999px; border: 1px solid var(--color-border); + color: var(--color-text-secondary); cursor: pointer; + transition: all 0.2s; user-select: none; white-space: nowrap; + } + .podcast-cate-item:hover { border-color: var(--color-primary); color: var(--color-primary); } + .podcast-cate-item.active { + background: var(--color-primary); border-color: var(--color-primary); color: #fff; + } + .podcast-desc { + display: -webkit-box; -webkit-line-clamp: 2; + -webkit-box-orient: vertical; overflow: hidden; + } + .podcast-ep-index { + width: 30px; text-align: center; color: var(--color-text-tertiary); + font-size: 13px; flex: none; + } + + /* ============ 关于网站 ============ */ + .about-hero { display: flex; align-items: center; gap: 16px; padding: 8px 0 4px; flex-wrap: wrap; } + .about-logo { + width: 64px; height: 64px; border-radius: 18px; + background: linear-gradient(135deg, var(--color-primary), #722ed1); + color: #fff; font-size: 30px; + display: flex; align-items: center; justify-content: center; + box-shadow: var(--color-shadow-heavy); flex: none; + } + .about-title { font-size: 22px; font-weight: 600; } + .about-sub { color: var(--color-text-secondary); font-size: 13px; margin-top: 4px; } + .about-text { color: var(--color-text-secondary); font-size: 14px; line-height: 1.9; margin: 6px 0 0; } + .about-text b { color: var(--color-text); } + .about-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 12px; margin-top: 10px; + } + .about-card { + border: 1px solid var(--color-border-split); + background: var(--color-bg-layout); + border-radius: 12px; padding: 14px 16px; + transition: all 0.2s; + } + .about-card:hover { transform: translateY(-2px); box-shadow: var(--color-shadow); } + .about-card-title { font-weight: 600; font-size: 14px; margin-bottom: 6px; display: flex; align-items: center; gap: 6px; } + .about-card-desc { font-size: 12.5px; color: var(--color-text-secondary); line-height: 1.7; } + /* .about-author/.about-qq 等 7 条死规则已删(作者卡片在 + 改名时整体移除,样式残留且曾误导 v7 美化层给死类追加规则) */ + + /* ============ 设置抽屉 ============ */ + .drawer-mask { + position: fixed; top: 0; right: 0; bottom: 0; left: 0; inset: 0; background: rgba(0, 0, 0, 0.45); + z-index: 200; opacity: 0; transition: opacity 0.25s; pointer-events: none; + } + .drawer-mask.show { opacity: 1; pointer-events: auto; } + .drawer { + position: fixed; top: 0; right: 0; bottom: 0; + width: 340px; max-width: 90vw; + background: var(--color-bg-container); + box-shadow: -6px 0 16px -8px rgba(0,0,0,0.2); + z-index: 201; + transform: translateX(100%); + transition: transform 0.25s; + display: flex; flex-direction: column; + } + .drawer.show { transform: translateX(0); } + .drawer-header { + padding: 16px 20px; + border-bottom: 1px solid var(--color-border-split); + display: flex; align-items: center; justify-content: space-between; + } + .drawer-title { font-size: 16px; font-weight: 600; } + .drawer-close { + border: none; background: none; font-size: 18px; + color: var(--color-text-secondary); cursor: pointer; line-height: 1; + } + .drawer-body { flex: 1; overflow-y: auto; padding: 8px 20px 20px; } + .settings-section { margin-top: 20px; } + .settings-section-title { + font-size: 13px; color: var(--color-text-secondary); + margin-bottom: 12px; font-weight: 500; + } + .settings-section-head { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 10px; + } + .settings-section-head .settings-section-title { margin-bottom: 0; } + .check-grid { display: flex; flex-wrap: wrap; gap: 8px 16px; } + .check-item { + display: inline-flex; align-items: center; gap: 6px; + font-size: 13px; cursor: pointer; color: var(--color-text); + } + .check-item input { accent-color: var(--color-primary); cursor: pointer; } + .stat-grid { display: flex; gap: 12px; flex-wrap: wrap; } + .stat-card { + flex: 1; min-width: 90px; + background: var(--color-fill); border-radius: 10px; + padding: 12px; text-align: center; + } + .stat-card .stat-num { font-size: 20px; font-weight: 600; color: var(--color-primary); } + .stat-card .stat-label { font-size: 12px; color: var(--color-text-secondary); margin-top: 2px; } + .stat-list-item { + display: flex; align-items: center; gap: 12px; + padding: 8px 6px; border-bottom: 1px solid var(--color-border-split); + font-size: 13px; + } + .stat-list-item:last-child { border-bottom: none; } + .stat-rank { width: 26px; text-align: center; color: var(--color-text-tertiary); font-weight: 600; flex: none; } + .stat-endpoint { flex: 1; min-width: 0; } + .stat-endpoint .ep-name { font-weight: 500; } + .stat-endpoint .ep-path { font-size: 12px; color: var(--color-text-tertiary); font-family: monospace; } + .stat-count { flex: none; color: var(--color-text-secondary); font-variant-numeric: tabular-nums; } + .stat-refresh-info { font-size: 12px; color: var(--color-text-tertiary); text-align: center; margin-top: 10px; } + .setting-item { + display: flex; align-items: center; justify-content: space-between; + padding: 10px 0; gap: 12px; + } + .setting-label { font-size: 14px; } + .setting-desc { font-size: 12px; color: var(--color-text-tertiary); margin-top: 2px; } + .switch { + width: 40px; height: 22px; border-radius: 999px; + background: var(--color-border); position: relative; + cursor: pointer; transition: background 0.2s; flex: none; + } + .switch::after { + content: ''; position: absolute; top: 2px; left: 2px; + width: 18px; height: 18px; border-radius: 50%; + background: #fff; transition: left 0.2s; box-shadow: 0 1px 3px rgba(0,0,0,0.3); + } + .switch.on { background: var(--color-primary); } + .switch.on::after { left: 20px; } + .color-palette { display: flex; gap: 10px; flex-wrap: wrap; } + .color-dot { + width: 28px; height: 28px; border-radius: 50%; + cursor: pointer; border: 2px solid transparent; + transition: all 0.15s; position: relative; + } + .color-dot:hover { transform: scale(1.1); } + .color-dot.active { border-color: var(--color-text); box-shadow: 0 0 0 2px var(--color-bg-container); } + .color-dot.active::after { + content: '✓'; position: absolute; top: 0; right: 0; bottom: 0; left: 0; inset: 0; + display: flex; align-items: center; justify-content: center; + color: #fff; font-size: 14px; font-weight: 700; + } + .mode-seg { display: flex; background: var(--color-fill); border-radius: 8px; padding: 3px; gap: 3px; } + .mode-seg .seg-item { + flex: 1; text-align: center; font-size: 13px; + padding: 5px 0; border-radius: 6px; cursor: pointer; + color: var(--color-text-secondary); transition: all 0.15s; + } + .mode-seg .seg-item.active { background: var(--color-bg-container); color: var(--color-primary); font-weight: 500; box-shadow: var(--color-shadow); } + .drawer-footer { + padding: 14px 20px; + border-top: 1px solid var(--color-border-split); + display: flex; gap: 10px; + } + .drawer-footer .btn { flex: 1; } + + /* ============ 弹窗(更新日志) ============ */ + .modal-mask { + position: fixed; top: 0; right: 0; bottom: 0; left: 0; inset: 0; background: rgba(0, 0, 0, 0.45); + z-index: 300; display: none; align-items: center; justify-content: center; + padding: 20px; + } + .modal-mask.show { display: flex; } + .modal { + background: var(--color-bg-container); + border-radius: 12px; + box-shadow: var(--color-shadow-heavy); + width: 640px; max-width: 100%; + max-height: 80vh; + display: flex; flex-direction: column; + } + .modal-header { + padding: 16px 20px; border-bottom: 1px solid var(--color-border-split); + display: flex; align-items: center; justify-content: space-between; + } + .modal-title { font-size: 16px; font-weight: 600; } + .modal-body { flex: 1; overflow-y: auto; padding: 12px 20px 20px; } + .version-item { padding: 12px 0; border-bottom: 1px solid var(--color-border-split); } + .version-item:last-child { border-bottom: none; } + .version-head { display: flex; align-items: center; gap: 10px; } + .version-num { font-weight: 600; font-size: 14px; color: var(--color-primary); } + .version-date { font-size: 12px; color: var(--color-text-tertiary); } + .version-items { margin: 8px 0 0; padding-left: 20px; font-size: 13px; color: var(--color-text-secondary); } + .version-items li { margin: 3px 0; } + + /* ============ 认证(登录/注册/头像) ============ */ + .auth-area { display: flex; align-items: center; gap: 8px; } + .user-avatar { + width: 34px; height: 34px; border-radius: 50%; + background: var(--color-primary); color: #fff; + display: flex; align-items: center; justify-content: center; + font-size: 15px; font-weight: 600; cursor: pointer; + user-select: none; border: 2px solid var(--color-primary-bg); + transition: transform 0.15s; + } + .user-avatar:hover { transform: scale(1.08); } + .user-avatar.admin { background: var(--color-warning); border-color: rgba(250, 173, 20, 0.2); } + .auth-form { display: flex; flex-direction: column; gap: 12px; padding: 6px 2px; } + .auth-form .input { width: 100%; } + .auth-form-row { display: flex; gap: 10px; } + .auth-form-row .input { flex: 1; } + .auth-switch { text-align: center; font-size: 13px; color: var(--color-text-secondary); } + .auth-switch a { color: var(--color-primary); cursor: pointer; text-decoration: none; } + .auth-trial-box { + border: 1px dashed var(--color-border); border-radius: 10px; + padding: 12px; text-align: center; font-size: 13px; color: var(--color-text-secondary); + } + /* .auth-tabs 两条死规则已删(登录/注册弹窗早已改为四视图切换) */ + + /* ============ 美化层(视觉与交互打磨,全部基于现有变量体系) ============ */ + html { scroll-behavior: smooth; } + ::selection { background: var(--color-primary-bg); } + img { -webkit-user-drag: none; user-select: none; } + + /* 全局细滚动条 */ + ::-webkit-scrollbar { width: 8px; height: 8px; } + ::-webkit-scrollbar-track, ::-webkit-scrollbar-corner { background: transparent; } + ::-webkit-scrollbar-thumb { background: var(--color-border); border-radius: 4px; } + ::-webkit-scrollbar-thumb:hover { background: var(--color-text-tertiary); } + html { scrollbar-width: thin; scrollbar-color: var(--color-border) transparent; } + + /* 键盘操作焦点环(鼠标点击不显示) */ + :focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; border-radius: 4px; } + .input:focus-visible, .select:focus-visible { outline: none; } + .btn:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--color-primary-bg); } + .tab:focus-visible { outline: none; } + + /* 按钮微交互:悬浮轻抬、按下回落 */ + .btn:not(:disabled):hover { transform: translateY(-1px); box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08); } + .btn-primary:not(:disabled):hover { box-shadow: 0 4px 14px var(--color-primary-bg); } + .btn:not(:disabled):active { transform: translateY(0) scale(0.97); box-shadow: none; } + .icon-btn:hover { background: var(--color-bg-hover); } + + /* Tab 切换:面板淡入上浮 */ + .panel.active { animation: panelIn 0.22s ease; } + @keyframes panelIn { + from { opacity: 0; transform: translateY(5px); } + to { opacity: 1; transform: none; } + } + + /* 歌曲行:封面悬浮微缩放 */ + .song-cover { transition: transform 0.2s; } + .song-row:hover .song-cover { transform: scale(1.04); } + + /* 播放器进度条:悬浮加粗 */ + .progress { transition: height 0.15s; } + .progress:hover { height: 6px; } + + /* 弹窗:磨砂遮罩 + 弹入动画 */ + .modal-mask, .drawer-mask { backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px); } + .modal-mask.show .modal { animation: modalIn 0.22s cubic-bezier(0.4, 0, 0.2, 1); } + @keyframes modalIn { + from { opacity: 0; transform: translateY(10px) scale(0.985); } + to { opacity: 1; transform: none; } + } + + /* 表单细节:输入框悬浮着色 + 文本域舒展 */ + .input:hover, .select:hover { border-color: var(--color-primary-hover); } + textarea.input { height: auto; min-height: 52px; padding: 8px 11px; resize: vertical; line-height: 1.6; } + + /* 评论行悬浮着色 */ + .comment-item { border-radius: 8px; transition: background 0.15s; } + .comment-item:hover { background: var(--color-bg-hover); } + + /* 详情/合集封面悬浮微放大 */ + .song-detail-cover, .collection-cover { transition: transform 0.3s, box-shadow 0.3s; } + .song-detail-cover:hover, .collection-cover:hover { transform: scale(1.02); } + + /* 主题切换平滑过渡(applyTheme 挂 theme-fading 类) */ + html.theme-fading, html.theme-fading body, html.theme-fading * { + transition: background-color 0.3s ease, color 0.3s ease, + border-color 0.3s ease, box-shadow 0.3s ease !important; + } + + /* 尊重系统"减弱动态效果"偏好 */ + @media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + } + + /* 表单控件字体继承(按钮/输入框默认不继承字体栈,导致全站按钮字体不统一) */ + button, input, select, textarea { font-family: inherit; } + + /* 管理后台:表格行过渡已有着色,补充侧栏与卡片细节 */ + .site-table td { transition: background 0.15s; } + .uc-card { transition: transform 0.2s, box-shadow 0.2s; } + .uc-card:hover { transform: translateY(-2px); box-shadow: var(--color-shadow); } + .stat-card { transition: transform 0.2s; } + .stat-card:hover { transform: translateY(-2px); } + + /* 弹窗/抽屉关闭键交互 */ + .drawer-close { transition: color 0.15s, transform 0.15s; } + .drawer-close:hover { color: var(--color-text); transform: scale(1.12); } + + /* 链接式按钮下划线 */ + .btn-link:hover { text-decoration: underline; text-underline-offset: 2px; } + + /* KTV 已唱文字微光晕(呼应主题色) */ + .lyric-line.ktv.active.kw-line .kw.on { text-shadow: 0 0 14px var(--color-primary-bg); } + + /* ============ 个人中心 / 管理后台 ============ */ + .uc-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; } + .uc-card { + border: 1px solid var(--color-border-split); border-radius: 12px; + padding: 16px; background: var(--color-bg-subtle); + } + .uc-card-title { font-size: 12px; color: var(--color-text-tertiary); margin-bottom: 6px; } + .uc-card-value { font-size: 17px; font-weight: 600; word-break: break-all; } + /* .uc-card-value.danger 死规则已删 */ + .uc-section { margin-top: 16px; } + .admin-layout { display: flex; gap: 16px; align-items: flex-start; } + .admin-side { + flex: none; width: 150px; display: flex; flex-direction: column; gap: 4px; + border: 1px solid var(--color-border-split); border-radius: 12px; padding: 8px; + background: var(--color-bg-subtle); position: sticky; top: 80px; + } + .admin-side-item { + padding: 9px 12px; border-radius: 8px; font-size: 13px; cursor: pointer; + color: var(--color-text-secondary); transition: all 0.15s; white-space: nowrap; + } + .admin-side-item:hover { background: var(--color-fill); color: var(--color-text); } + .admin-side-item.active { background: var(--color-primary-bg); color: var(--color-primary); font-weight: 600; } + .admin-side-sep { height: 1px; background: var(--color-border-split); margin: 6px 4px; } + .admin-main { flex: 1; min-width: 0; } + .admin-view { display: none; } + .admin-view.active { display: block; } + .admin-toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; } + .admin-toolbar .input, .admin-toolbar .select { width: auto; } + .site-table { width: 100%; border-collapse: collapse; font-size: 13px; } + .site-table th, .site-table td { + text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--color-border-split); + word-break: break-all; + } + .site-table th { color: var(--color-text-tertiary); font-weight: 500; font-size: 12px; white-space: nowrap; } + .site-table tr:hover td { background: var(--color-fill); } + .tag-pill { + display: inline-block; padding: 2px 8px; border-radius: 20px; + font-size: 12px; border: 1px solid var(--color-border); color: var(--color-text-secondary); + } + .tag-pill.ok { border-color: var(--color-success); color: var(--color-success); } + .log-list { max-height: 420px; overflow-y: auto; } /* ua-log-list(管理后台日志页)在用,复核保留 */ + /* .tag-pill.used/.log-item/.log-item .log-time 三条死规则已删 + (管理后台日志走 site-table 渲染,log-item 类从未产生) */ + .code-box { + background: var(--color-fill); border-radius: 8px; padding: 10px 12px; + font-family: monospace; font-size: 13px; word-break: break-all; + max-height: 180px; overflow-y: auto; line-height: 1.6; + } + .announce-body { white-space: pre-wrap; line-height: 1.7; font-size: 14px; color: var(--color-text); } + @media (max-width: 720px) { + .admin-layout { flex-direction: column; } + .admin-side { width: 100%; flex-direction: row; flex-wrap: wrap; position: static; } + .admin-side-sep { display: none; } + .app-header-inner .brand-sub { display: none; } + .icon-btn span { display: none; } + /* 窄屏顶栏防挤压:标题不折行,次数 pill 收进个人中心(避免竖排破版) */ + .brand-title { white-space: nowrap; font-size: 16px; } + #auth-duration-pill { display: none; } + .icon-btn { padding: 0 10px; } + .app-header-inner { padding: 0 16px; } + } + + /* ============ Toast ============ */ + .toast-wrap { + position: fixed; top: 20px; left: 50%; transform: translateX(-50%); + z-index: 1000; display: flex; flex-direction: column; gap: 10px; align-items: center; + pointer-events: none; + } + .toast { + padding: 9px 16px; + border-radius: 8px; + background: var(--color-bg-elevated); + color: var(--color-text); + box-shadow: var(--color-shadow-heavy); + font-size: 14px; + display: flex; align-items: center; gap: 8px; + opacity: 0; + transform: translateY(-8px); + transition: all 0.25s; + pointer-events: auto; + max-width: 90vw; + } + .toast.show { opacity: 1; transform: translateY(0); } + .toast-success { border-left: 3px solid var(--color-success); } + .toast-error { border-left: 3px solid var(--color-error); } + .toast-warning { border-left: 3px solid var(--color-warning); } + .toast-info { border-left: 3px solid var(--color-primary); } + + /* ============ 空状态 / 加载 ============ */ + .empty-state { + text-align: center; color: var(--color-text-tertiary); + padding: 48px 0; font-size: 14px; + } + .empty-state .empty-icon { font-size: 42px; margin-bottom: 8px; opacity: 0.4; } + + .page-loading { + position: fixed; top: 0; right: 0; bottom: 0; left: 0; inset: 0; background: var(--color-bg-layout); + display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 16px; z-index: 999; + } + .page-loading .ring { + width: 40px; height: 40px; + border: 3px solid var(--color-border); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + .page-loading span { color: var(--color-text-secondary); font-size: 14px; } + + /* ============ 页脚 ============ */ + .footer { + text-align: center; + padding: 20px; + color: var(--color-text-secondary); + font-size: 13px; + } + + [hidden] { display: none !important; } + + /* 登录弹窗找回密码视图模式——隐藏登录字段只留找回表单 */ + .auth-form.forgot-mode > *:not(#forgot-view) { display: none; } + + /* 登录弹窗管理员两步验证视图模式——隐藏登录字段只留 2FA 表单 */ + .auth-form.admin2fa-mode > *:not(#admin2fa-view) { display: none; } + + @media (max-width: 768px) { + .main-container { padding: 16px; } + .card { padding: 16px; } + .song-detail-cover { width: 100%; height: auto; aspect-ratio: 1; } + .volume-wrap { display: none; } + .header-actions .brand-sub { display: none; } + .toplist-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); } + } + /* 实时排队 / 预计等待 悬浮框 */ + .queue-float { + position: fixed; right: 18px; bottom: 18px; z-index: 9999; + display: flex; flex-direction: column; align-items: center; gap: 3px; + min-width: 98px; padding: 10px 14px; border-radius: 14px; + background: var(--color-bg-elevated); color: var(--color-text); + box-shadow: var(--color-shadow-heavy); border: 1px solid var(--color-border-split); + font-size: 12px; cursor: default; user-select: none; opacity: .92; transition: opacity .3s; + } + .queue-float:hover { opacity: 1; } + .queue-float-head { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--color-text-secondary); } + .queue-float-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--color-success); } + .queue-float.busy .queue-float-dot { background: var(--color-error); animation: queue-pulse 1.2s infinite; } + @keyframes queue-pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(255, 77, 79, 0.5); } + 50% { box-shadow: 0 0 0 6px rgba(255, 77, 79, 0); } + } + .queue-float-num { font-size: 24px; font-weight: 700; line-height: 1.15; } + .queue-float-sub { font-size: 11px; color: var(--color-text-tertiary); white-space: nowrap; } + /* 下载任务悬浮窗(批量打包 + 单曲下载,可取消) */ + .task-panel { + position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); + z-index: 9999; width: 340px; max-width: calc(100vw - 32px); + max-height: 300px; display: none; flex-direction: column; + background: var(--color-bg-elevated); color: var(--color-text); + box-shadow: var(--color-shadow-heavy); border: 1px solid var(--color-border-split); + border-radius: 14px; overflow: hidden; + } + .task-panel.show { display: flex; } + .task-panel-head { + display: flex; justify-content: space-between; align-items: center; + padding: 10px 14px; font-size: 13px; font-weight: 600; + border-bottom: 1px solid var(--color-border-split); flex-shrink: 0; + } + .tp-count { color: var(--color-primary); font-weight: 700; } + .tp-close { border: none; background: none; cursor: pointer; color: var(--color-text-secondary); font-size: 14px; padding: 2px 6px; } + .tp-close:hover { color: var(--color-text); } + .task-panel-list { overflow-y: auto; padding: 8px 12px; } + .task-item { padding: 8px 0; border-bottom: 1px solid var(--color-border-split); } + .task-item:last-child { border-bottom: none; } + .task-item-top { display: flex; align-items: center; gap: 8px; font-size: 12px; } + .task-item-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .task-item-status { color: var(--color-text-secondary); flex-shrink: 0; } + .task-item-cancel { + flex-shrink: 0; border: 1px solid var(--color-border); background: none; cursor: pointer; + color: var(--color-error); font-size: 11px; padding: 2px 8px; border-radius: 6px; + } + .task-item-cancel:hover { background: var(--color-error); color: #fff; } + /* 已完成任务的重领/移除按钮(服务端文件保留 35 分钟内可重复领取) */ + .task-item-claim { + flex-shrink: 0; border: 1px solid var(--color-primary); background: none; cursor: pointer; + color: var(--color-primary); font-size: 11px; padding: 2px 8px; border-radius: 6px; + } + .task-item-claim:hover { background: var(--color-primary); color: #fff; } + .task-item-dismiss { + flex-shrink: 0; border: none; background: none; cursor: pointer; + color: var(--color-text-tertiary); font-size: 13px; padding: 2px 4px; border-radius: 6px; + line-height: 1; + } + .task-item-dismiss:hover { color: var(--color-text-secondary); background: var(--color-fill); } + .task-item-bar { height: 6px; border-radius: 3px; background: var(--color-fill); overflow: hidden; margin-top: 6px; } + .task-item-bar > div { height: 100%; width: 0; border-radius: 3px; background: var(--color-primary); transition: width .3s; } + .task-item.ended .task-item-bar { display: none; } + + /* ============================================================ + 美化层 v4(2026-09-17 全面高级感打磨) + 原则:全部为叠加式覆盖,不修改上方任何既有规则;只动观感与过渡, + 不改变任何布局结构 / 功能行为。后续视觉微调优先改本段。 + ============================================================ */ + + /* ---------- 全局基调:柔和背景氛围 + 字体细节 ---------- */ + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, + 'MiSans', 'HarmonyOS Sans SC', 'Noto Sans', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; + background-color: var(--color-bg-layout); + background-image: + radial-gradient(1100px 480px at 12% -6%, rgba(100, 116, 139, 0.055), transparent 62%), + radial-gradient(900px 420px at 105% -2%, rgba(100, 116, 139, 0.035), transparent 58%); + background-repeat: no-repeat; + text-rendering: optimizeLegibility; + } + html.dark body { + background-image: + radial-gradient(1100px 480px at 12% -6%, rgba(148, 163, 184, 0.06), transparent 62%), + radial-gradient(900px 420px at 105% -2%, rgba(148, 163, 184, 0.04), transparent 58%); + } + a { transition: color 0.15s; } + .detail-name, .collection-name, .about-title, .drawer-title, .modal-title { letter-spacing: 0.3px; } + [id$='-page-info'], .tp-count, .queue-float-num, .player-time { font-variant-numeric: tabular-nums; } + code { + background: var(--color-fill); border-radius: 5px; padding: 1px 6px; + font-size: 0.92em; font-family: ui-monospace, SFMono-Regular, 'Cascadia Mono', Consolas, monospace; + } + img[src=''] { opacity: 0; } /* 封面未加载时空 src 不显示破图 */ + + /* ---------- 顶栏:滚动感知阴影 + 品牌微动效 ---------- */ + .app-header { + border-bottom: 1px solid transparent; + transition: box-shadow 0.3s, background 0.3s, border-color 0.3s; + } + .app-header.scrolled { + border-bottom-color: var(--color-border-split); + box-shadow: 0 8px 24px -12px rgba(15, 23, 42, 0.14); + } + html.dark .app-header.scrolled { box-shadow: 0 8px 24px -12px rgba(0, 0, 0, 0.6); } + .brand-logo { transition: transform 0.3s var(--ease-out); } + .brand:hover .brand-logo { transform: rotate(-10deg) scale(1.08); } + .brand-title { letter-spacing: 0.5px; } + .icon-btn { border-radius: 999px; } + .icon-btn:not(:disabled):active { transform: scale(0.96); } + + /* ---------- 卡片与区块标题 ---------- */ + .card { + border: 1px solid var(--color-border-split); + border-radius: 14px; + animation: cardIn 0.3s var(--ease-out); + } + @keyframes cardIn { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: none; } + } + .card-title { position: relative; padding-left: 13px; } + .card-title::before { + content: ''; position: absolute; left: 0; top: 2px; bottom: 2px; + width: 4px; border-radius: 2px; + background: linear-gradient(180deg, var(--color-primary-hover), var(--color-primary)); + } + .main-container { padding-bottom: 40px; } + + /* ---------- Tab 字感 ---------- */ + .tab { letter-spacing: 0.3px; } + .tab.active { font-weight: 600; } + + /* ---------- 按钮:高光内阴影 / 渐变色钮 / 列表内稳定化 ---------- */ + .btn { border-radius: 9px; } + .btn-sm { border-radius: 8px; } + .btn-primary { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.16), 0 1px 2px rgba(15, 23, 42, 0.10); } + .btn-primary:not(:disabled):hover { + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.16), 0 4px 14px var(--color-primary-bg); + } + .btn-success { background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0) 60%); } + .btn-success:not(:disabled):hover { + background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0) 60%); + box-shadow: 0 4px 14px rgba(82, 196, 26, 0.25); + } + .btn-danger { background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0) 60%); } + .btn-danger:not(:disabled):hover { + background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0) 60%); + box-shadow: 0 4px 14px rgba(255, 77, 79, 0.25); + } + /* 表格 / 歌曲行内的动作小按钮:不做抬升,避免列表观感跳动 */ + .site-table .btn:not(:disabled):hover, + .song-actions .btn:not(:disabled):hover, + .task-item .btn:not(:disabled):hover { transform: none; box-shadow: none; } + + /* ---------- 表单 ---------- */ + .input, .select { border-radius: 9px; } + .input { caret-color: var(--color-primary); } + .auth-form .input { height: 38px; border-radius: 10px; } + .auth-trial-box { background: var(--color-primary-bg); border-color: transparent; border-radius: 10px; } + + /* ---------- 歌曲行:级联入场 + 细节 ---------- */ + .song-row { animation: rowIn 0.3s var(--ease-out) both; } + .song-row:nth-child(2) { animation-delay: 0.02s; } + .song-row:nth-child(3) { animation-delay: 0.04s; } + .song-row:nth-child(4) { animation-delay: 0.06s; } + .song-row:nth-child(5) { animation-delay: 0.08s; } + .song-row:nth-child(6) { animation-delay: 0.10s; } + .song-row:nth-child(7) { animation-delay: 0.12s; } + .song-row:nth-child(8) { animation-delay: 0.14s; } + .song-row:nth-child(9) { animation-delay: 0.16s; } + .song-row:nth-child(10) { animation-delay: 0.18s; } + @keyframes rowIn { + from { opacity: 0; transform: translateY(5px); } + to { opacity: 1; transform: none; } + } + .song-row { border-radius: 12px; } + .song-row:hover { background: linear-gradient(0deg, var(--color-bg-hover), var(--color-bg-hover)); } + .song-row > .song-meta:first-child { + color: var(--color-text-tertiary); font-size: 12px; + font-variant-numeric: tabular-nums; padding-top: 2px; + } + .song-cover { border-radius: 10px; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.12); } + html.dark .song-cover { box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); } + .song-name .tag { + border-color: transparent; background: var(--color-fill); + color: var(--color-text-secondary); font-weight: 400; + } + .song-row:hover .song-name .tag { background: var(--color-bg-container); } + + /* ---------- 播放器:播放态光晕 / 渐变播放键 ---------- */ + .player, .lyric-panel, .comment-panel { animation: rowIn 0.3s var(--ease-out); } + .player { + border-radius: 16px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), var(--color-shadow-heavy); + } + .player-cover { border-radius: 12px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35); } + #player-toggle { + background: linear-gradient(180deg, var(--color-primary-hover), var(--color-primary)); + box-shadow: 0 2px 10px var(--color-primary-bg); + transition: background 0.2s, transform 0.1s, box-shadow 0.3s; + } + #player-toggle:hover { background: linear-gradient(180deg, var(--color-primary), var(--color-primary-active)); } + .player.playing #player-toggle { + box-shadow: 0 0 0 5px var(--color-primary-bg), 0 2px 10px var(--color-primary-bg); + } + .progress-fill { background: linear-gradient(90deg, var(--color-primary), var(--color-primary-hover)); } + + /* ---------- 歌词:边缘渐隐 + 滚动不穿透 ---------- */ + .lyric-box { + overscroll-behavior: contain; + overflow-x: hidden; /* 激活行 scale(1.02) 的横向溢出不再拉出横向滚动条 */ + -webkit-mask-image: linear-gradient(180deg, transparent 0, #000 16px, #000 calc(100% - 20px), transparent 100%); + mask-image: linear-gradient(180deg, transparent 0, #000 16px, #000 calc(100% - 20px), transparent 100%); + } + + /* ---------- Tab 栏:隐藏横向滚动条(保留滚动能力) ---------- */ + .tabs { scrollbar-width: none; } + .tabs::-webkit-scrollbar { display: none; } + + /* ---------- 评论 ---------- */ + .comment-item { animation: rowIn 0.28s var(--ease-out) both; } + .comment-avatar { box-shadow: 0 0 0 1px var(--color-border-split); } + + /* ---------- 榜单/播客卡片墙:悬浮阴影 + 封面缩放 + 级联入场 ---------- */ + .toplist-card { border-radius: 14px; animation: rowIn 0.35s var(--ease-out) both; } + .toplist-card:nth-child(2) { animation-delay: 0.03s; } + .toplist-card:nth-child(3) { animation-delay: 0.06s; } + .toplist-card:nth-child(4) { animation-delay: 0.09s; } + .toplist-card:nth-child(5) { animation-delay: 0.12s; } + .toplist-card:nth-child(6) { animation-delay: 0.15s; } + .toplist-card:nth-child(7) { animation-delay: 0.18s; } + .toplist-card:nth-child(8) { animation-delay: 0.21s; } + .toplist-card:hover { box-shadow: 0 10px 24px -10px rgba(15, 23, 42, 0.18); } + html.dark .toplist-card:hover { box-shadow: 0 10px 24px -10px rgba(0, 0, 0, 0.6); } + .toplist-card .tl-cover { transition: transform 0.35s var(--ease-out); } + .toplist-card:hover .tl-cover { transform: scale(1.04); } + .toplist-card .tl-name { letter-spacing: 0.2px; } + + /* ---------- 弹窗 / 抽屉 ---------- */ + .modal { border-radius: 16px; border: 1px solid var(--color-border-split); } + .modal-header { padding: 18px 22px; } + .modal-body { padding: 14px 22px 22px; } + .modal-mask.show { animation: maskIn 0.2s ease; } + @keyframes maskIn { from { opacity: 0; } to { opacity: 1; } } + .modal-body, .drawer-body, .task-panel-list, .log-list { overscroll-behavior: contain; } + .drawer { border-left: 1px solid var(--color-border-split); border-radius: 16px 0 0 16px; } + + /* ---------- Toast:类型着色 + 玻璃质感 ---------- */ + .toast { border-radius: 10px; } + .toast { background: rgba(255, 255, 255, 0.88); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); } + html.dark .toast { background: rgba(31, 31, 31, 0.88); } + .toast-success { background-image: linear-gradient(0deg, rgba(82, 196, 26, 0.08), rgba(82, 196, 26, 0.08)); } + .toast-error { background-image: linear-gradient(0deg, rgba(255, 77, 79, 0.08), rgba(255, 77, 79, 0.08)); } + .toast-warning { background-image: linear-gradient(0deg, rgba(250, 173, 20, 0.10), rgba(250, 173, 20, 0.10)); } + .toast-info { background-image: linear-gradient(0deg, rgba(22, 119, 255, 0.07), rgba(22, 119, 255, 0.07)); } + + /* ---------- 空状态:图标轻浮动 ---------- */ + .empty-state .empty-icon { display: inline-block; animation: floatY 3s ease-in-out infinite alternate; } + @keyframes floatY { from { transform: translateY(0); } to { transform: translateY(-6px); } } + + /* ---------- 悬浮窗(排队 / 下载任务) ---------- */ + .task-panel.show { animation: taskIn 0.28s var(--ease-out); } + @keyframes taskIn { + from { opacity: 0; transform: translate(-50%, 14px); } + to { opacity: 1; transform: translate(-50%, 0); } + } + .task-item-bar > div { background: linear-gradient(90deg, var(--color-primary), var(--color-primary-hover)); } + .queue-float { + background: rgba(255, 255, 255, 0.85); + backdrop-filter: blur(10px) saturate(1.5); + -webkit-backdrop-filter: blur(10px) saturate(1.5); + } + html.dark .queue-float { background: rgba(31, 31, 31, 0.85); } + + /* ---------- 管理后台表格 / 侧栏 / 统计卡 ---------- */ + .site-table th { border-bottom: 1px solid var(--color-border); padding-bottom: 10px; letter-spacing: 0.3px; } + .site-table tbody tr:nth-child(even) td { background: rgba(15, 23, 42, 0.014); } + html.dark .site-table tbody tr:nth-child(even) td { background: rgba(255, 255, 255, 0.025); } + .site-table tbody tr:hover td { background: var(--color-fill); } + .admin-side-item { border-radius: 9px; } + .admin-side-item.active { box-shadow: inset 0 0 0 1px var(--color-primary-bg); } + .stat-card { border-radius: 12px; background: var(--color-bg-subtle); } + .stat-card .stat-num { font-variant-numeric: tabular-nums; } + .code-box { border: 1px solid var(--color-border-split); font-size: 12.5px; } + + /* ---------- 杂项细节 ---------- */ + audio { border-radius: 10px; } + .version-item { border-radius: 8px; padding: 12px 10px; margin: 0 -10px; transition: background 0.15s; } + .version-item:hover { background: var(--color-bg-hover); } + .about-card:hover { border-color: var(--color-primary-bg); } + .user-avatar { box-shadow: 0 2px 8px rgba(15, 23, 42, 0.15); } + .user-avatar:not(.admin) { background: linear-gradient(135deg, var(--color-primary), var(--color-primary-active)); } + .podcast-cate-item.active { + background-image: linear-gradient(180deg, var(--color-primary-hover), var(--color-primary)); + box-shadow: 0 2px 8px var(--color-primary-bg); + } + .switch:active::after { transform: scaleX(1.18); } + .history-row .tag { border-radius: 999px; } + + /* ============================================================ + 美化层 v5(2026-09-17 多端响应式适配 + 触屏体验打磨) + 覆盖:小屏手机(≤400) / 常规手机(≤480) / 平板竖屏(481-768) / + 平板横屏(769-1180) / 大屏桌面(≥1680) / 手机横屏矮视口 / 刘海屏安全区。 + 同样为叠加式覆盖,不修改上方任何既有规则。 + ============================================================ */ + + /* ---------- 触屏与移动基础 ---------- */ + html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; } + .btn, .icon-btn, .tab, .player-ctrl, .admin-side-item, .podcast-cate-item, .color-dot, .mode-seg .seg-item, .switch { + touch-action: manipulation; /* 消除老设备双击缩放延迟 */ + } + + /* 触屏设备(无真实悬浮):关闭悬浮抬升,避免"粘滞悬浮" */ + @media (hover: none) { + .btn:not(:disabled):hover { transform: none; box-shadow: none; } + .btn-primary:not(:disabled):hover { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.16), 0 1px 2px rgba(15, 23, 42, 0.10); } + .song-row:hover .song-cover { transform: none; } + .toplist-card:hover { transform: none; box-shadow: none; } + .toplist-card:hover .tl-cover { transform: none; } + .song-detail-cover:hover, .collection-cover:hover { transform: none; } + .uc-card:hover, .stat-card:hover, .about-card:hover { transform: none; } + .user-avatar:hover { transform: none; } + .color-dot:hover { transform: none; } + .brand:hover .brand-logo { transform: none; } + } + + /* 粗指针设备:加大触控目标 */ + @media (pointer: coarse) { + .btn-sm { min-height: 34px; } + .icon-btn { min-height: 38px; min-width: 38px; } + .btn-mini { height: 34px; min-height: 34px; padding: 0 10px; } /* 触屏统一 34px 触控标准 */ + .player-ctrl-sm { width: 36px; height: 36px; } + .lyric-line { padding: 8px 10px; } + .history-row .tag { padding: 4px 10px; } + .tp-close { padding: 6px 10px; } + .task-item-cancel { padding: 4px 10px; } + } + + /* ---------- 小屏手机(≤400px,iPhone SE / 小屏安卓) ---------- */ + @media (max-width: 400px) { + /* 开放邮箱注册后游客顶栏多出「注册」按钮,小屏压缩防横向溢出: + 注册/登录按钮缩 padding,图标按钮缩 padding 并收紧间距/标题字号 */ + #btn-open-register { padding: 0 8px; font-size: 12px; } + #btn-open-login { padding: 0 10px; } + .icon-btn { padding: 0 6px; } + .header-actions { gap: 4px; } + .brand { gap: 6px; } + .brand-title { font-size: 14px; } + .app-header-inner { padding: 0 12px; } + + .main-container { padding: 12px; } + .card { padding: 14px; border-radius: 12px; } + .tabs { margin-bottom: 14px; } + .tabs .tab { margin-right: 18px; font-size: 14px; } + .toplist-grid { grid-template-columns: repeat(2, 1fr); gap: 10px; } + .about-grid { grid-template-columns: 1fr; } + .song-row { gap: 10px; padding: 10px 6px; } + .song-actions { gap: 6px; } + .detail-name { font-size: 19px; } + .collection-name { font-size: 18px; } + .hint { font-size: 12.5px; } + } + + /* ---------- 常规手机(≤480px) ---------- */ + @media (max-width: 480px) { + /* iOS 聚焦防自动缩放:输入类控件字号 ≥16px */ + .input, .select { font-size: 16px; } + .collection-cover { width: 120px; height: 120px; } + .song-detail { gap: 16px; } + .collection-header { gap: 14px; } + .player { padding: 12px 14px; } + .player-cover { width: 48px; height: 48px; } + #player-toggle { width: 40px; height: 40px; font-size: 15px; } + .lyric-box { max-height: 230px; max-height: min(calc(230px * var(--lyric-fs, 1)), 345px); } + .comment-avatar { width: 30px; height: 30px; } + .comment-item { gap: 10px; } + .empty-state { padding: 36px 0; } + .stat-card .stat-num { font-size: 18px; } + /* 弹窗全宽化 + 内边距收紧(width:100% 相对遮罩内容盒,避免 100vw 把滚动条宽度算进去) */ + .modal-mask { padding: 12px; } + .modal { width: 100% !important; max-width: 100% !important; max-height: 86vh; } + .modal-header { padding: 14px 16px; } + .modal-body { padding: 12px 16px 16px; } + .drawer { width: 100%; max-width: 100vw; } + /* 管理后台表格在窄屏更紧凑 */ + .site-table { font-size: 12px; } + .site-table th, .site-table td { padding: 8px 8px; } + /* 固定元素避让刘海屏安全区 */ + .toast-wrap { top: calc(10px + env(safe-area-inset-top, 0px)); width: calc(100vw - 24px); } + .toast { font-size: 13px; } + .queue-float { + right: calc(10px + env(safe-area-inset-right, 0px)); + bottom: calc(10px + env(safe-area-inset-bottom, 0px)); + min-width: 84px; padding: 8px 12px; + } + .queue-float-num { font-size: 20px; } + .task-panel { bottom: calc(12px + env(safe-area-inset-bottom, 0px)); width: calc(100vw - 20px); } + .drawer-footer { padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px)); } + } + + /* ---------- 大屏手机 / 平板竖屏(481–768px) ---------- */ + @media (min-width: 481px) and (max-width: 768px) { + .collection-cover { width: 140px; height: 140px; } + .toplist-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } + } + + /* ---------- 平板横屏 / 小桌面(769–1180px) ---------- */ + @media (min-width: 769px) and (max-width: 1180px) { + .main-container { padding: 20px; } + .admin-side { width: 136px; } + } + + /* ---------- 大屏桌面(≥1680px):内容区适度加宽 ---------- */ + @media (min-width: 1680px) { + .app-header-inner, .main-container { max-width: 1360px; } + } + + /* ---------- 手机横屏(矮视口):压缩纵向占用 ---------- */ + @media (max-height: 520px) and (orientation: landscape) { + .app-header-inner { height: 52px; } + .brand-logo { width: 30px; height: 30px; font-size: 16px; } + .brand-sub { display: none; } + .main-container { padding: 12px 16px; } + .lyric-box { max-height: 180px; } /* 横屏矮视口:垂直空间稀缺,不随字号放大 */ + /* 弹窗横屏 92dvh 已移至 v6 视口块之后(修复同特异性级联覆盖) */ + } + + /* ============================================================ + 美化层 v6(2026-09-17 细节质感 + 移动端体验修复,第三轮) + ============================================================ */ + + /* ---------- iOS 动态视口(地址栏收缩时 100vh 偏大) ---------- + 此块必须位于 v5 横屏 92dvh 规则之前——同特异性按源序 + 后者胜,v6 初版写在横屏块之后曾把"横屏 92dvh"静默覆盖成 80dvh */ + body { min-height: 100vh; min-height: 100dvh; } + .modal { max-height: 80vh; max-height: 80dvh; } + @media (max-width: 480px) { .modal { max-height: 86vh; max-height: 86dvh; } } /* 恢复 v5 手机端高度 */ + @media (max-height: 520px) and (orientation: landscape) { .modal { max-height: 92vh; max-height: 92dvh; } } /* 横屏最高(移到 80dvh 之后修复级联覆盖) */ + + /* ---------- 播放中:歌名旁均衡器律动(纯伪元素,零 DOM) ---------- */ + .player.playing .player-name::before, + .player.playing .player-name::after { + content: ''; display: inline-block; + width: 3px; height: 4px; border-radius: 1px; + background: rgba(255, 255, 255, 0.85); + margin: 0 0 0 4px; vertical-align: middle; + animation: eqBar 0.9s ease-in-out infinite; + } + .player.playing .player-name::after { + animation-delay: 0.3s; + box-shadow: 5px 0 0 -0.5px rgba(255, 255, 255, 0.5); /* 第三根柱随动 */ + margin-right: 8px; + } + .player.playing .player-name::before { animation-delay: 0.15s; } + @keyframes eqBar { 0%, 100% { height: 4px; } 50% { height: 13px; } } + + /* ---------- 下拉框:统一自制箭头(替换各平台原生样式) ---------- */ + .select { + appearance: none; -webkit-appearance: none; + padding-right: 30px; + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23999999' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + } + html.dark .select { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23aaaaaa' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + } + + /* ---------- 区块计数:浅底胶囊(空计数自动隐藏) ---------- */ + .card-title .count { + background: var(--color-fill); border-radius: 999px; + padding: 1px 10px; font-variant-numeric: tabular-nums; + } + .card-title .count:empty { display: none; } + + /* ---------- 音量条悬浮加粗(与进度条一致) ---------- */ + .volume-slider { transition: height 0.15s; } + .volume-slider:hover { height: 6px; } + + /* ---------- 扫码二维码:呼吸光晕引导注意 ---------- */ + #admin-qr-img, #personal-qr-img, #ua-qr-box img { + border-radius: 14px; + animation: qrBreath 2.6s ease-in-out infinite; + } + @keyframes qrBreath { + 0%, 100% { box-shadow: 0 0 0 0 var(--color-primary-bg), var(--color-shadow-heavy); } + 50% { box-shadow: 0 0 0 12px rgba(0, 0, 0, 0), var(--color-shadow-heavy); } + } + + /* ---------- 空状态图标光晕 / 统计数字渐变 ---------- */ + .empty-state .empty-icon { filter: drop-shadow(0 6px 14px var(--color-primary-bg)); } + .stat-card .stat-num { + background: linear-gradient(135deg, var(--color-primary-hover), var(--color-primary)); + -webkit-background-clip: text; background-clip: text; + -webkit-text-fill-color: transparent; color: transparent; + } + + /* ---------- 交互控件统一缓动曲线 ---------- */ + .btn, .icon-btn, .tab, .tag, .podcast-cate-item, .admin-side-item { transition-timing-function: var(--ease-out); } + + /* ---------- 页脚避让全面屏底部安全区 ---------- */ + body > footer { padding-bottom: calc(26px + env(safe-area-inset-bottom, 0px)) !important; } + + /* ---------- 超宽桌面(≥2200px):内容区再放宽 ---------- */ + @media (min-width: 2200px) { + .app-header-inner, .main-container { max-width: 1480px; } + } + + /* ---------- 小屏手机:排队悬浮窗移到右上,避让底部下载任务面板 ---------- */ + @media (max-width: 480px) { + .queue-float { + top: calc(74px + env(safe-area-inset-top, 0px)); + bottom: auto; + min-width: 78px; + } + } + + /* ============================================================ + 美化层 v7(2026-09-17 部署前最终打磨) + ============================================================ */ + + /* ---------- 登录/注册弹窗:品牌头部 ---------- */ + .auth-brand { text-align: center; padding: 2px 0 14px; } + .auth-brand-logo { + width: 52px; height: 52px; border-radius: 16px; margin: 0 auto 10px; + background: linear-gradient(135deg, #f5576c, #ec4141); color: #fff; + font-size: 26px; display: flex; align-items: center; justify-content: center; + box-shadow: 0 6px 16px -6px rgba(236, 65, 65, 0.5); + } + .auth-brand-name { font-size: 17px; font-weight: 600; letter-spacing: 0.5px; } + .auth-brand-sub { font-size: 12px; color: var(--color-text-tertiary); margin-top: 3px; } + + /* ---------- 页面加载动画:品牌双色旋转环 ---------- */ + .page-loading .ring { + border: none; + background: conic-gradient(from 0deg, var(--color-primary) 0 25%, var(--color-border) 25% 100%); + -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 3px)); + mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 3px)); + } + + /* ---------- 更新日志:时间线样式(圆点 + 连线) ---------- */ + .version-item { position: relative; padding-left: 18px; border-bottom: none !important; } + .version-item::before { + content: ''; position: absolute; left: 0; top: 16px; + width: 8px; height: 8px; border-radius: 50%; + background: var(--color-primary); box-shadow: 0 0 0 3px var(--color-primary-bg); + } + .version-item::after { + content: ''; position: absolute; left: 3.5px; top: 30px; bottom: -12px; + width: 1px; background: var(--color-border-split); + } + .version-item:last-child::after { display: none; } + + /* ---------- 管理后台视图切换淡入 / 列表行 hover ---------- */ + .admin-view.active { animation: rowIn 0.22s var(--ease-out); } + .stat-list-item { border-radius: 8px; transition: background 0.15s; } + .stat-list-item:hover { background: var(--color-bg-hover); } + /* .log-item 两条死规则(含本层追加的 hover)已随基座一并删除 */ + + /* ---------- 防移动端误触下拉刷新 ---------- */ + html { overscroll-behavior-y: none; } + + /* ---------- 隐私协议:details 展开指示箭头 ---------- */ + #cookie-modal summary { list-style: none; position: relative; padding-right: 24px; user-select: none; } + #cookie-modal summary::-webkit-details-marker { display: none; } + #cookie-modal summary::after { + content: ''; position: absolute; right: 4px; top: 50%; + width: 7px; height: 7px; + border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; + transform: translateY(-70%) rotate(45deg); + transition: transform 0.2s var(--ease-out); + } + #cookie-modal details[open] summary::after { transform: translateY(-30%) rotate(225deg); } + + /* ---------- 自动填充修正(Chrome 自动填充的土黄底在深色模式下很突兀) ---------- */ + input.input:-webkit-autofill { + -webkit-box-shadow: 0 0 0 1000px var(--color-bg-container) inset; + -webkit-text-fill-color: var(--color-text); + transition: background-color 99999s ease-out; + } + + /* ============================================================ + 美化层 v8(2026-09-17 部署前最后补充) + ============================================================ */ + + /* ---------- 播放器进度条:缓冲预览层(JS 注入 --buffered) ---------- */ + .progress::before { + content: ''; position: absolute; left: 0; top: 0; bottom: 0; + width: var(--buffered, 0%); + background: rgba(255, 255, 255, 0.18); + border-radius: 2px; + transition: width 0.4s ease; + pointer-events: none; + } + .progress:hover::before { border-radius: 3px; } + + /* ============================================================ + 美化层 v9(2026-09-17 国内浏览器兼容加固) + 目标:微信 XWeb/X5、QQ 内置、360/QQ/UC 桌面(Blink 老内核)、 + iOS WKWebView 老版本。全部为降级兜底,不影响现代浏览器效果。 + ============================================================ */ + + /* ---------- inset 展开:Chromium <87 的老 X5 不识别 inset 简写 ---------- */ + .drawer-mask, .modal-mask, .page-loading { top: 0; right: 0; bottom: 0; left: 0; } + .color-dot.active::after { top: 0; right: 0; bottom: 0; left: 0; } + + /* ---------- aspect-ratio 兜底:Chromium <88 / iOS <15 封面给固定高 ---------- */ + @supports not (aspect-ratio: 1) { + .toplist-card .tl-cover { height: 170px; } + @media (max-width: 768px) { + .song-detail-cover { height: auto; min-height: 200px; } + } + } + + /* ---------- conic-gradient 兜底:页面加载环在 Chromium <69 恢复转圈边框方案 ---------- */ + @supports not (background: conic-gradient(from 0deg, #fff 0%, #000 100%)) { + .page-loading .ring { border: 3px solid var(--color-border); border-top-color: var(--color-primary); background: none; } + } + + /* ---------- backdrop-filter 兜底:完全不支持毛玻璃时改用不透明背景(避免透视叠影) ---------- */ + @supports not ((backdrop-filter: blur(2px)) or (-webkit-backdrop-filter: blur(2px))) { + .app-header { background: var(--color-bg-container); } + .toast { background: var(--color-bg-elevated); } + .queue-float { background: var(--color-bg-elevated); } + } + + /* ---------- 微信/QQ 内置浏览器引导条 ---------- */ + /* 弹窗/抽屉打开时隐藏实时排队与任务面板浮窗(状态类挂在 上,避免观察器自反馈) */ + html.overlay-open .queue-float, + html.overlay-open .task-panel { opacity: 0 !important; pointer-events: none !important; } + /* 字号弹层打开期间同样隐藏两浮窗(弹层贴顶时不再被排队悬浮窗压住)—— + 由 setLyricFsPop 直接挂/摘 html.fs-pop-open 类,不经 MutationObserver(活锁教训) */ + html.fs-pop-open .queue-float, + html.fs-pop-open .task-panel { opacity: 0 !important; pointer-events: none !important; } + .webview-guide { + display: flex; align-items: center; gap: 10px; flex-wrap: wrap; + padding: 10px 16px; margin-bottom: 16px; + background: rgba(250, 173, 20, 0.10); + border: 1px solid rgba(250, 173, 20, 0.35); + border-radius: 12px; + font-size: 13px; color: var(--color-text-secondary); + animation: rowIn 0.3s var(--ease-out); + } + + /* ---------- 兼容层 v9+(2026-09-19 补充):极老内核阻断提示 + 键盘焦点环 ---------- */ + /* IE / 360 兼容模式等 Trident 内核不支持 CSS 变量,页面必然错乱 —— 用纯字面色值的阻断页告知 */ + #legacy-browser-blocker { + display: none; position: fixed; top: 0; right: 0; bottom: 0; left: 0; + z-index: 99999; background: #1f2329; color: #e8eaed; + padding: 40px 28px; text-align: center; + font-family: "Microsoft YaHei", "PingFang SC", sans-serif; + } + #legacy-browser-blocker.show { display: block; } + #legacy-browser-blocker .lb-icon { font-size: 44px; margin-bottom: 16px; } + #legacy-browser-blocker h2 { font-size: 20px; margin: 0 0 14px; color: #fff; } + #legacy-browser-blocker p { font-size: 14px; line-height: 1.9; margin: 0 0 8px; opacity: .85; } + #legacy-browser-blocker .lb-list { + margin-top: 18px; font-size: 13px; line-height: 2; opacity: .7; text-align: left; + display: inline-block; + } + /* 键盘焦点环::focus-visible 仅 Chromium 86+;老内核由 JS 在 Tab 导航时给 html 挂 + kbd-nav 类激活以下 :focus 规则(鼠标点击不显示,色值带字面量兜底) */ + html.kbd-nav :focus { outline: 2px solid var(--color-primary); outline: 2px solid #4a7dff; outline-offset: 2px; border-radius: 4px; } + html.kbd-nav .btn:focus, html.kbd-nav .tab:focus { outline: none; box-shadow: 0 0 0 3px var(--color-primary-bg); outline: 2px solid #4a7dff; } + html.kbd-nav .input:focus, html.kbd-nav .select:focus { outline: 2px solid var(--color-primary); outline: 2px solid #4a7dff; outline-offset: 0; } + .webview-guide .wg-icon { font-size: 16px; flex: none; } + .webview-guide .wg-text b { color: var(--color-text); } + .webview-guide .wg-close { + margin-left: auto; flex: none; height: 26px; padding: 0 12px; + border: 1px solid rgba(250, 173, 20, 0.5); border-radius: 999px; + background: transparent; color: var(--color-text-secondary); + font-size: 12px; cursor: pointer; transition: all 0.2s; + } + .webview-guide .wg-close:hover { background: rgba(250, 173, 20, 0.15); color: var(--color-text); } + + /* ============================================================ + 美化层 v10(2026-09-17 系统与极端环境覆盖) + ============================================================ */ + + /* ---------- 超小屏(≤359px:手表浏览器 / 折叠屏外屏) ---------- + v10 两批次叠加曾让 gap:3px/font-size:14px 被本块后写 + (gap:6px/display:none)静默作废——已合并为最终生效值,消除改前不生效陷阱 */ + @media (max-width: 359px) { + #btn-open-register { padding: 0 6px; } + #btn-open-login { padding: 0 8px; } + .icon-btn { padding: 0 4px; } + .header-actions { gap: 6px; } + + .main-container { padding: 8px; } + .card { padding: 12px; border-radius: 10px; } + .tabs .tab { margin-right: 14px; font-size: 13px; } + .toplist-grid { grid-template-columns: 1fr 1fr; gap: 8px; } + .song-actions .btn-sm { padding: 0 8px; } + .detail-name { font-size: 17px; } + .collection-cover { width: 100px; height: 100px; } + .player { padding: 10px 12px; } + /* 空间寸土寸金:排队悬浮窗让位,核心下载任务面板保留 */ + .queue-float { display: none; } + /* 顶栏必须塞进视口:其内容最小宽度(约368px)超出 320px 视口时,移动端内核会把整个 + 布局视口撑宽(实测 innerWidth 320→352),遮罩/弹窗/全部 width:100% 元素跟着越界。 + 处置:隐藏站名文字只留 logo、收紧内边距与按钮间距(v10 超小屏层既有取舍的延伸) */ + .app-header-inner { padding: 0 10px; } + .brand-title { display: none; } + } + + /* ---------- Windows 高对比度(forced-colors):保住进度指示 ---------- */ + @media (forced-colors: active) { + .progress-fill, .task-item-bar > div, .progress::before { forced-color-adjust: none; } + .card, .uc-card, .admin-side, .modal, .drawer { forced-color-adjust: auto; } + } + + /* ============================================================ + 美化层 v11(2026-09-27 品牌氛围 + Tab 药丸化) + 延续 v4-v10 原则:叠加式覆盖,不动布局结构 / 类名 / JS 契约。 + ============================================================ */ + + /* ---------- 背景氛围:品牌红 + 主题蓝 双色辉光(替换 v4 灰调氛围) ---------- */ + body { + background-image: + radial-gradient(1100px 480px at 10% -8%, rgba(236, 65, 65, 0.05), transparent 60%), + radial-gradient(950px 460px at 104% -4%, rgba(22, 119, 255, 0.055), transparent 58%); + } + html.dark body { + background-image: + radial-gradient(1100px 480px at 10% -8%, rgba(236, 65, 65, 0.07), transparent 60%), + radial-gradient(950px 460px at 104% -4%, rgba(22, 104, 220, 0.09), transparent 58%); + } + + /* ---------- 卡片:顶部内高光,浅色主题更挺括 ---------- */ + .card { box-shadow: var(--color-shadow), inset 0 1px 0 rgba(255, 255, 255, 0.65); } + html.dark .card { box-shadow: var(--color-shadow), inset 0 1px 0 rgba(255, 255, 255, 0.04); } + + /* ---------- 账号栏与音质栏之间补一条分隔线(音质栏自带下边框,上下呼应) ---------- */ + .account-bar { border-bottom: 1px solid var(--color-border-split); } + + /* ---------- Tab 药丸化:下划线 → 软色块,hover 有底、active 有片 ---------- + 用 .tabs .tab 权重(0-2-0)压过基础层 .tab 与 v10 超小屏的同权重规则; + ::after 下划线整体隐藏,active 态由药丸底色表达 */ + .tabs .tab { + padding: 8px 16px; + margin-right: 6px; + border-radius: 999px; + transition: color 0.2s, background 0.2s, transform 0.15s; + } + .tabs .tab:hover { background: var(--color-fill); } + .tabs .tab:active { transform: scale(0.96); } + .tabs .tab.active { background: var(--color-primary-bg); } + .tabs .tab::after { display: none; } + + /* ---------- 主按钮:与 success/danger 一致的顶部高光渐变 ---------- */ + .btn-primary { + background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0) 60%); + } + + /* ---------- 空状态图标:v4 层已有 floatY 浮动,此处不再重复 ---------- */ + + /* ---------- 页脚字距 ---------- */ + .footer { letter-spacing: 0.3px; } + diff --git a/frontend/src/ui/Skeleton.tsx b/frontend/src/ui/Skeleton.tsx new file mode 100644 index 0000000..e399bf9 --- /dev/null +++ b/frontend/src/ui/Skeleton.tsx @@ -0,0 +1,1347 @@ +/* 自动生成的静态骨架(源自旧版 index.html,机械转换 + React 显隐接线)。 + * 列表/播放器等动态区域由 controllers 层按 id 写入,本组件不参与其重渲。 */ +import { store, useAppVersion, toasts } from '../lib/store' +import { isModalOpen, isDrawerOpen } from './modalBus' +import { switchTab } from '../controllers/legacy' + +const TAB_LABELS: Record = {'search': '搜索', 'song': '单曲', 'playlist': '歌单', 'album': '专辑', 'podcast': '播客', 'admin': '管理员设置', 'global': '全局账号歌单', 'artist': '歌手', 'toplist': '榜单', 'personal': '个人歌单', 'usercenter': '个人中心', 'useradmin': '管理后台', 'about': '关于网站'} + +function ToastLayer() { + useAppVersion() + return ( +
+ {toasts.map((t) => ( +
{t.msg}
+ ))} +
+ ) +} + +export function Skeleton() { + useAppVersion() + return ( + <> +// 自动转换片段(供手工组装) +{/* 兼容层 v9+:IE/360 兼容模式等极老内核阻断提示(JS 检测后显示;现代浏览器不可见) */} + + +// 自动转换片段(供手工组装) +{/* 页面加载动画 */} +
+
+ 正在加载云村小助手... +
+ +// 自动转换片段(供手工组装) +{/* 顶部导航 */} +
+
+
+
♪
+
+
云村小助手
+
实时解析网易云音乐单曲、获取真实下载地址及封面信息
+
+
+
+
+ + +
+ + + + +
+
+
+ +
+ {/* 微信/QQ 内置浏览器引导(UA 检测后显示,JS 控制) */} + + + {/* 功能卡片 */} +
+ {/* 当前解析账号信息 */} +
+ 👤 解析账号 + 获取中... + + +
+
+ 💡 小提示:可在「个人歌单」登录你自己的 SVIP 网易云音乐账号,并切换为个人账号解析,即可下载/试听更高音质。 +
+ + {/* 全局音质选择(对所有模块生效) */} +
+ 🎧 全局音质 + + + 全局生效:单曲解析、音乐下载、批量下载均使用该音质;「最高音质(自动)」会按账号会员类型自动尝试最高可解析音质。 +
+ +
+
switchTab('search')}>搜索
+
switchTab('song')}>单曲
+
switchTab('playlist')}>歌单
+
switchTab('album')}>专辑
+
switchTab('podcast')}>播客
+ + +
switchTab('artist')}>歌手
+
switchTab('toplist')}>榜单
+
switchTab('personal')}>个人歌单
+ + +
switchTab('about')}>关于网站
+
+ {/* 搜索面板 */} + +
+
+
🔍
+
+ + + +
+ +
+
支持搜索歌曲、歌单、专辑、歌手;结果点击即可跳转对应功能解析。
+
+
+
+ + +
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + + +
+
+ + +
+
支持 电台链接(djradio?id=)、单集链接(program?id= / sound?id=)、163cn.tv 短链或纯数字ID;解析后可在线试听、获取直链、下载单集、整台打包 ZIP。
+ + + + {/* 电台解析结果:电台信息 + 单集列表 */} + + + {/* 单集解析结果:详情 + 播放器 + 直链/下载 */} + + + {/* 播客搜索结果 */} + + + {/* 热门播客(分类卡片墙) */} +
+
+
+
热门播客
+
+
+
+ +
+
+
+ {/* 密码门禁 */} +
+
+ + +
+
管理员设置:全局二维码登录与 Cookie 管理。登录后 Cookie 自动保存,无需重复扫码。
+
+ + {/* 管理员内容 */} + +
+
+ {/* 全局账号未登录 */} +
+
+
+ 全局账号歌单展示系统中全局账号(管理员 cookie.txt)收藏的歌单与「我喜欢的音乐」。 + 当前全局账号未登录或 Cookie 已失效,请先在「管理员设置」扫码登录全局账号。 +
+
+ + +
+
+
+ + {/* 已登录:账号信息 + 歌单列表 */} + +
+
+
+ + +
+
+
+
+ 网易云官方实时排行榜,点击卡片即可查看榜单歌曲 +
+
📊
加载中...
+ +
+
+ {/* 未登录:扫码登录自己的账号 */} +
+
+
+ 扫码登录你自己的网易云账号,即可查看收藏的歌单(包括「我喜欢的音乐」)。 + 个人登录仅用于获取你的歌单,解析与下载音乐仍使用管理员全局账号。 +
+
+ + +
+
+ +
+ + {/* 已登录:账号信息 + 歌单列表 */} + +
+
+
+
♪
+
+
云村小助手
+
基于 Flask + 原生前端的云村小助手 · 当前版本 v2.1.0
+
+
+ +
+
项目介绍
+

+ 本项目是一个开箱即用的云村小助手,支持歌曲搜索、单曲 / 歌单 / 专辑 / 歌手解析、官方榜单、个人歌单、播客解析等完整功能, + 提供多音质试听与下载(标准 / 极高 / 无损 / Hi-Res / 沉浸环绕声等)、批量下载与 ZIP 打包、元数据写入、歌词下载(LRC / SRT)、 + 自定义播放器与逐字歌词、评论区、多账号体系(全局账号 + 个人账号)与下载任务进度管理。 + 服务端基于 Flask 与网易云官方接口实现,前端零框架原生实现,支持深色模式与响应式布局,可通过 Docker 一键部署。 +

+ +
+
功能说明
+
+
+
🔍 多类型搜索
+
支持按歌曲、歌单、专辑、歌手搜索,结果一键跳转对应功能解析。
+
+
+
🎵 单曲 / 歌单 / 专辑 / 歌手解析
+
输入链接或ID即可解析,自动按账号实测最高可解析音质输出,含封面、歌词与直链。
+
+
+
🎙️ 播客解析
+
支持播客电台与单集解析:在线试听、音频直链、单集下载、整台打包 ZIP,热门播客分类浏览。
+
+
+
📊 官方榜单
+
63 个网易云官方实时排行榜卡片墙,点击即看榜单歌曲。
+
+
+
⬇ 下载能力
+
单曲 / 批量下载、ZIP 打包、文件命名格式、ffmpeg 元数据写入、LRC / SRT 歌词。
+
+
+
🎧 播放器与歌词
+
自定义深色播放器、SMTC 系统媒体控制、逐字 KTV 歌词高亮、翻译双行、偏移校准。
+
+
+
👥 多账号体系
+
全局账号负责解析下载;个人账号扫码登录查看自己的歌单,Cookie 保存在本机浏览器(站点账号登录时管理后台可按需查看)。
+
+
+
🔒 隐私与任务管理
+
隐私协议 Cookie 偏好、下载任务进度悬浮窗、断线续传与离线暂停恢复、接口统计。
+
+
+ +
+
更新日志
+
+ +
本工具仅供个人学习交流使用,音频内容版权归网易云音乐及内容创作者所有,请勿用于商业用途。
+
+
+
🔒
请先登录后查看个人中心
+ +
+
+
🔒
仅管理员可访问管理后台
+ +
+
+{/* 搜索结果 */} + + + {/* 单曲解析结果 */} + + + {/* 歌单结果 */} + + + {/* 专辑结果 */} + + + {/* 歌手结果 */} + + +
+ +// 自动转换片段(供手工组装) +{/* 设置抽屉 */} +
+ +// 自动转换片段(供手工组装) + + +// 自动转换片段(供手工组装) +{/* 更新日志弹窗 */} +
+
+
+
更新日志
+ +
+
+
+
+ +// 自动转换片段(供手工组装) +{/* 接口请求统计弹窗 */} +
+
+
+
接口请求统计
+
+ + +
+
+
+
+
-
总请求数
+
-
不同端点数
+
-
最高端点请求数
+
+
按接口端点明细(按请求数倒序)
+
+
数据来源:GET /api/topen · 每 10 秒自动刷新 · 最近刷新:-
+
+
+
+ +// 自动转换片段(供手工组装) +{/* 歌单播放 API 说明弹窗 */} +
+
+
+
🔗 公开歌单播放 API
+ +
+
+
公开接口,无需登录即可调用:返回歌单信息 + 每首歌的可播放直链(使用全局账号解析音质)。
+ +
+
+
接口地址
+
-
+
+ +
+
+
+
请求方式
+
GET / POST(均支持表单或 JSON 参数)
+
+
+ +
请求参数
+
+
· id 必填:歌单 ID 或网易云分享链接
+
· level 选填:音质等级,默认 lossless(standard/exhigh/lossless/hires/sky/jyeffect/jymaster/dolby)
+
· limit 选填:最多返回歌曲数,默认 100,最大 500(0 按封顶 500 计)
+
+ +
请求示例
+
-
+
+ +
+ +
响应字段
+
+
· playlist:歌单信息(id / name / coverImgUrl / creator / trackCount)
+
· tracks[]:可播放歌曲列表(id / name / artists / album / url / level / size_formatted)
+
· total / playable / skipped:总歌曲数 / 可播放数 / 跳过数(版权或无法解析)
+
+
+
+
+ +// 自动转换片段(供手工组装) +{/* 隐私与协议设置弹窗 */} + + +// 自动转换片段(供手工组装) +{/* 登录弹窗 */} +
+
+
+
登录账号
+ +
+
+
+
♪
+
云村小助手
+
登录后继续解析与下载
+
+
+ +
+ + +
+ +
还没有账号?立即注册 · 忘记密码?
+
🎁 新用户?注册账号 注册即送 3 次免费下载次数
+ {/* 找回密码视图(复用验证码通道),默认隐藏 */} + + {/* 管理员两步验证视图(密码验证通过后:绑定邮箱 → 邮箱验证码) */} + +
+
+
+
+ +// 自动转换片段(供手工组装) +{/* 注册弹窗 */} +
+
+
+
注册账号
+ +
+
+ {/* 邮箱验证码注册 */} +
+
♪
+
创建账号
+
注册即送 3 次免费下载次数
+
+
+ +
+ + +
+
+ + +
+ + + +
已有账号?直接登录
+
🎁 注册成功即赠送 3 次免费下载次数(解析与在线试听免费)
+
+
+
+
+ +// 自动转换片段(供手工组装) +{/* 公告弹窗 */} +
+
+
+
系统公告
+ +
+
+
+
+
+ +
+
+
+ +// 自动转换片段(供手工组装) + + +// 自动转换片段(供手工组装) +{/* 实时排队 / 预计等待 悬浮框 */} +
+
实时排队
+
0
+
预计等待:--
+
下载 - · 打包 -
+
+ +// 自动转换片段(供手工组装) +{/* 下载任务悬浮窗(批量打包 + 单曲下载,可取消) */} +
+
+ 📥 下载任务 0 + +
+
+
+ + ) +} diff --git a/frontend/src/ui/modalBus.ts b/frontend/src/ui/modalBus.ts new file mode 100644 index 0000000..9d4f118 --- /dev/null +++ b/frontend/src/ui/modalBus.ts @@ -0,0 +1,72 @@ +/* 弹窗/抽屉显隐总线:React 状态驱动的 show 类。 + * 控制器层一律通过 openModal/closeModal 切换,不再直接 classList.add('show')。 */ +import { notify } from '../lib/store' + +export type ModalKey = + | 'changelog-modal' + | 'stats-modal' + | 'api-modal' + | 'cookie-modal' + | 'login-modal' + | 'register-modal' + | 'announce-modal' + +const _openStack: ModalKey[] = [] +let _drawer = false + +export function isModalOpen(key: ModalKey): boolean { + return _openStack.indexOf(key) !== -1 +} + +export function openModal(key: ModalKey) { + if (_openStack.indexOf(key) === -1) _openStack.push(key) + notify() +} + +export function closeModal(key: ModalKey) { + const i = _openStack.indexOf(key) + if (i !== -1) _openStack.splice(i, 1) + notify() +} + +/** 视觉最上层弹窗(后开在上),无则 null */ +export function topmostModal(): ModalKey | null { + return _openStack.length ? _openStack[_openStack.length - 1] : null +} + +/** Escape 通用分支:关掉最上层弹窗(含专用清理钩子),返回是否消费了按键 */ +export function closeTopmost(): boolean { + const top = topmostModal() + if (!top) return false + if (top === 'stats-modal' || top === 'cookie-modal') { + // 专用关闭(清轮询定时器/复位注册门槛视图),经 __legacyApi 间接调用 + import('../controllers/legacy').then((m) => { + const api = (m as any).__legacyApi + if (top === 'stats-modal') api.closeStatsModal() + else api.closeCookieModal() + }) + closeModal(top) + return true + } + closeModal(top) + return true +} + +export function isDrawerOpen(): boolean { + return _drawer +} + +export function openDrawer() { + _drawer = true + notify() +} + +export function closeDrawer() { + _drawer = false + notify() +} + +export function toggleDrawer() { + _drawer = !_drawer + notify() +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..2f62fc4 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": false, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitAny": false, + "allowJs": true + }, + "include": ["src"], + "exclude": ["src/controllers/legacy.ts", "src/controllers/auth.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..e4f52af --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,35 @@ +// Vite 配置:构建产物输出到 frontend/dist,后端 web/routers/index.py 直接服务它。 +// 开发模式:API 全在根路径(/song、/search、/auth/...),把非 Vite 内部路径全部 +// 反代到本机后端(uvicorn 0.0.0.0:5000),浏览器里前后端如同同源。 +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +const BACKEND = process.env.BACKEND_ORIGIN || 'http://127.0.0.1:5000' + +export default defineConfig({ + plugins: [react()], + build: { + outDir: 'dist', + sourcemap: true, + // 固定产物文件名(不带内容 hash):index.html 字节级稳定, + // tests/test_golden.py 的黄金快照契约才能跨构建成立 + rollupOptions: { + output: { + entryFileNames: 'assets/app.js', + chunkFileNames: 'assets/[name].js', + assetFileNames: 'assets/app.[ext]', + }, + }, + }, + server: { + port: 5173, + proxy: { + // Vite 自身路径放行,其余(API 与页面)全部反代后端 + '/@vite': { target: BACKEND, bypass: (req) => req.url || undefined }, + '/@react-refresh': { target: BACKEND, bypass: (req) => req.url || undefined }, + '/src': { target: BACKEND, bypass: (req) => req.url || undefined }, + '/node_modules': { target: BACKEND, bypass: (req) => req.url || undefined }, + '/': { target: BACKEND, changeOrigin: true }, + }, + }, +})