mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2025-09-09 01:22:41 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
586ab2a058 | ||
|
|
451257fc8e | ||
|
|
ba1b1226c0 | ||
|
|
0eb4ef344c | ||
|
|
7898873ba5 | ||
|
|
617d4ae2d5 | ||
|
|
2a338f1bab | ||
|
|
56edd35675 | ||
|
|
cf80aa954b | ||
|
|
918943e173 | ||
|
|
ac504a3f2e | ||
|
|
475ff202a1 | ||
|
|
d004722d89 | ||
|
|
515e16862d | ||
|
|
e2445f307f | ||
|
|
feefed9dff | ||
|
|
6bea88748d | ||
|
|
d29a8879b3 | ||
|
|
01ce6fc835 | ||
|
|
7bcd6f705b | ||
|
|
f29ede11bd | ||
|
|
ee065262ac | ||
|
|
a88dd7c40c | ||
|
|
571b685c01 | ||
|
|
2fd4a2d961 | ||
|
|
d216904b76 | ||
|
|
c6c9785bb7 | ||
|
|
1149c0ef45 | ||
|
|
c60681a92b | ||
|
|
b72efc7fcc | ||
|
|
b4e81d94eb | ||
|
|
179e74bf45 | ||
|
|
de66c9afa0 | ||
|
|
3bab1a9a11 | ||
|
|
31cd866a15 | ||
|
|
e1bd80436c | ||
|
|
b4fdd811b5 | ||
|
|
ca9a7403eb | ||
|
|
aec039e42e | ||
|
|
145111d3ca | ||
|
|
524b2c7657 | ||
|
|
ac564b24ae | ||
|
|
698fb919c6 | ||
|
|
4bda206888 | ||
|
|
e4a69e3394 | ||
|
|
0ed97dc1de | ||
|
|
88f661268e | ||
|
|
5798a6c63b | ||
|
|
72e8ade882 | ||
|
|
e31b362726 | ||
|
|
391ec3143e | ||
|
|
e34ee46e95 | ||
|
|
0d536381ca | ||
|
|
746ebaf96c | ||
|
|
2889cadd41 | ||
|
|
671f498f16 | ||
|
|
34ceb3c26a | ||
|
|
3535748d28 | ||
|
|
8c1a26a4e8 | ||
|
|
05b604d398 | ||
|
|
c225fa5850 | ||
|
|
7328170a3d | ||
|
|
9bbf40f909 | ||
|
|
fc3bbee74a | ||
|
|
5c997db7d6 | ||
|
|
652a38356c | ||
|
|
6fc590cca1 | ||
|
|
1b5685c1bb | ||
|
|
f30fd6aeea | ||
|
|
d627c4da01 | ||
|
|
fbb68a4654 | ||
|
|
e8be9a169a | ||
|
|
4d42043350 | ||
|
|
757e493916 | ||
|
|
8d0f01e0c4 | ||
|
|
c2048bd45f | ||
|
|
dbbdf47aab | ||
|
|
c0d53ee5dd | ||
|
|
0351c6f68d | ||
|
|
dc446efe10 | ||
|
|
98835391b5 | ||
|
|
714c4e0e9c | ||
|
|
ad9fe27637 | ||
|
|
963d163186 | ||
|
|
e885705ecd | ||
|
|
e5357fab31 | ||
|
|
44f611cf40 | ||
|
|
5ce5158447 | ||
|
|
5b23cdd497 | ||
|
|
93d5146733 | ||
|
|
197596671f | ||
|
|
9159b9a1d9 | ||
|
|
2c02db06ce | ||
|
|
ec90fafc39 | ||
|
|
840487acb0 | ||
|
|
4be9788668 | ||
|
|
58be9b01aa | ||
|
|
f52048ec2e | ||
|
|
394730327b | ||
|
|
2c53d0e0b9 | ||
|
|
2671a92264 | ||
|
|
50926567b8 | ||
|
|
dde604283b | ||
|
|
45bb86830e | ||
|
|
d97b3c1eb0 | ||
|
|
c8ab36f44f | ||
|
|
942f6cc1dd | ||
|
|
d7a00ced00 | ||
|
|
fdb9e4aeff | ||
|
|
6d4846d0b1 | ||
|
|
214aee1cb4 | ||
|
|
a97208f582 | ||
|
|
312c9db3fd | ||
|
|
2ed32f5896 | ||
|
|
48d1be771d | ||
|
|
ea8413f60e | ||
|
|
f0e171e71f | ||
|
|
8b9be4bb19 |
@@ -0,0 +1,2 @@
|
||||
.env
|
||||
.env*.local
|
||||
@@ -0,0 +1,84 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
node: true,
|
||||
},
|
||||
plugins: ['@typescript-eslint', 'simple-import-sort', 'unused-imports'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'next',
|
||||
'next/core-web-vitals',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'prettier',
|
||||
],
|
||||
rules: {
|
||||
'no-unused-vars': 'off',
|
||||
'no-console': 'warn',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'react/no-unescaped-entities': 'off',
|
||||
|
||||
'react/display-name': 'off',
|
||||
'react/jsx-curly-brace-presence': [
|
||||
'warn',
|
||||
{ props: 'never', children: 'never' },
|
||||
],
|
||||
|
||||
//#region //*=========== Unused Import ===========
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'unused-imports/no-unused-imports': 'warn',
|
||||
'unused-imports/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
vars: 'all',
|
||||
varsIgnorePattern: '^_',
|
||||
args: 'after-used',
|
||||
argsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
//#endregion //*======== Unused Import ===========
|
||||
|
||||
//#region //*=========== Import Sort ===========
|
||||
'simple-import-sort/exports': 'warn',
|
||||
'simple-import-sort/imports': [
|
||||
'warn',
|
||||
{
|
||||
groups: [
|
||||
// ext library & side effect imports
|
||||
['^@?\\w', '^\\u0000'],
|
||||
// {s}css files
|
||||
['^.+\\.s?css$'],
|
||||
// Lib and hooks
|
||||
['^@/lib', '^@/hooks'],
|
||||
// static data
|
||||
['^@/data'],
|
||||
// components
|
||||
['^@/components', '^@/container'],
|
||||
// zustand store
|
||||
['^@/store'],
|
||||
// Other imports
|
||||
['^@/'],
|
||||
// relative paths up until 3 level
|
||||
[
|
||||
'^\\./?$',
|
||||
'^\\.(?!/?$)',
|
||||
'^\\.\\./?$',
|
||||
'^\\.\\.(?!/?$)',
|
||||
'^\\.\\./\\.\\./?$',
|
||||
'^\\.\\./\\.\\.(?!/?$)',
|
||||
'^\\.\\./\\.\\./\\.\\./?$',
|
||||
'^\\.\\./\\.\\./\\.\\.(?!/?$)',
|
||||
],
|
||||
['^@/types'],
|
||||
// other that didnt fit in
|
||||
['^'],
|
||||
],
|
||||
},
|
||||
],
|
||||
//#endregion //*======== Import Sort ===========
|
||||
},
|
||||
globals: {
|
||||
React: true,
|
||||
JSX: true,
|
||||
},
|
||||
};
|
||||
@@ -1,19 +1,22 @@
|
||||
name: Build & Push Docker image
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['Release']
|
||||
types:
|
||||
- completed
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Docker 标签'
|
||||
required: false
|
||||
default: 'latest'
|
||||
type: string
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
pull_request:
|
||||
branches: [ main, master ]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
REGISTRY_IMAGE: moontechlab/lunatv
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
@@ -36,8 +39,7 @@ jobs:
|
||||
echo "PLATFORM_NAME=${{ matrix.platform }}" | sed 's|/|-|g' >> $GITHUB_ENV
|
||||
|
||||
- name: Checkout source code
|
||||
run: |
|
||||
git clone https://${{ secrets.PUSH_TOKEN }}@${{ secrets.REPO_URL }}.git .
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -49,27 +51,18 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set lowercase repository owner
|
||||
id: lowercase
|
||||
run: echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
|
||||
with:
|
||||
images: ghcr.io/${{ env.REGISTRY_IMAGE }}
|
||||
images: ghcr.io/moontechlab/lunatv
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=${{ env.latest_tag }},enable={{is_default_branch}}
|
||||
|
||||
- name: Get latest tag
|
||||
run: |
|
||||
echo "latest_tag=$(
|
||||
curl -s -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://api.github.com/repos/${{ github.repository }}/tags" | \
|
||||
jq -r '.[0].name // "latest"' | sed 's/^v//'
|
||||
)" >> $GITHUB_ENV
|
||||
|
||||
- name: Get commit SHA
|
||||
if: github.ref == 'refs/heads/main'
|
||||
id: vars
|
||||
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
type=raw,value=${{ github.event.inputs.tag || 'latest' }},enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
@@ -79,13 +72,11 @@ jobs:
|
||||
file: ./Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
SHA=${{ steps.vars.outputs.sha_short }}
|
||||
outputs: type=image,name=ghcr.io/${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
tags: ghcr.io/moontechlab/lunatv:${{ github.event.inputs.tag || 'latest' }}
|
||||
outputs: type=image,name=ghcr.io/moontechlab/lunatv,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
rm -rf /tmp/digests
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
@@ -120,37 +111,21 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Get latest tag
|
||||
run: |
|
||||
echo "latest_tag=$(
|
||||
curl -s -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://api.github.com/repos/${{ github.repository }}/tags" | \
|
||||
jq -r '.[0].name // "latest"' | sed 's/^v//'
|
||||
)" >> $GITHUB_ENV
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ env.REGISTRY_IMAGE }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=${{ env.latest_tag }},enable={{is_default_branch}}
|
||||
- name: Set lowercase repository owner
|
||||
id: lowercase
|
||||
run: echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf 'ghcr.io/${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
|
||||
docker buildx imagetools create -t ghcr.io/moontechlab/lunatv:${{ github.event.inputs.tag || 'latest' }} \
|
||||
$(printf 'ghcr.io/moontechlab/lunatv@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ghcr.io/${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
cleanup:
|
||||
cleanup-refresh:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- merge
|
||||
if: always()
|
||||
steps:
|
||||
- name: Delete workflow runs
|
||||
uses: Mattraks/delete-workflow-runs@main
|
||||
@@ -158,4 +133,4 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: ${{ github.repository }}
|
||||
retain_days: 0
|
||||
keep_minimum_runs: 4
|
||||
keep_minimum_runs: 2
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
name: NoMore Spam
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
models: read
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
spam-detection:
|
||||
runs-on: ubuntu-latest
|
||||
name: NoMore Spam
|
||||
|
||||
steps:
|
||||
- name: Fuck All the Shit
|
||||
uses: JohnsonRan/nomore-spam@main
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
ai-base-url: ${{ secrets.AI_BASE_URL }}
|
||||
ai-api-key: ${{ secrets.AI_API_KEY }}
|
||||
ai-model: ${{ secrets.AI_MODEL }}
|
||||
blacklist: ${{ secrets.BLACKLIST }}
|
||||
@@ -1,106 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TZ: Asia/Shanghai
|
||||
steps:
|
||||
- name: Checkout source code
|
||||
run: |
|
||||
git clone https://${{ secrets.PUSH_TOKEN }}@${{ secrets.REPO_URL }}.git .
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
TAG_NAME=${GITHUB_REF#refs/tags/}
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT
|
||||
echo "Version: $VERSION"
|
||||
echo "Tag: $TAG_NAME"
|
||||
|
||||
- name: Get tag message
|
||||
run: |
|
||||
TAG_NAME=${{ steps.version.outputs.tag }}
|
||||
RELEASE_BODY=$(curl -s -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://api.github.com/repos/${{ github.repository }}/releases/tags/$TAG_NAME" | jq -r '.body // ""')
|
||||
printf '%s\n' "$RELEASE_BODY" > /tmp/tag_message.txt
|
||||
echo "Tag message saved to /tmp/tag_message.txt"
|
||||
echo "Tag message content:"
|
||||
cat /tmp/tag_message.txt
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Update version files and generate changelog
|
||||
run: |
|
||||
CURRENT_DATE=$(date +%Y-%m-%d)
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
TAG_MESSAGE=$(cat /tmp/tag_message.txt)
|
||||
echo "Updating CHANGELOG file..."
|
||||
{
|
||||
echo "## [$VERSION] - $CURRENT_DATE"
|
||||
echo ""
|
||||
cat /tmp/tag_message.txt
|
||||
echo ""
|
||||
} > /tmp/new_entry.txt
|
||||
|
||||
if [ -f "CHANGELOG" ]; then
|
||||
cp CHANGELOG CHANGELOG.bak
|
||||
cat /tmp/new_entry.txt CHANGELOG.bak > CHANGELOG
|
||||
else
|
||||
cp /tmp/new_entry.txt CHANGELOG
|
||||
fi
|
||||
rm CHANGELOG.bak
|
||||
echo "✅ Updated CHANGELOG with new entry for version $VERSION"
|
||||
|
||||
echo "Updating VERSION.txt..."
|
||||
echo "$VERSION" > VERSION.txt
|
||||
echo "✅ Updated VERSION.txt to $VERSION"
|
||||
|
||||
echo "Generating changelog TypeScript file..."
|
||||
node scripts/convert-changelog.js
|
||||
|
||||
echo "Updating version.ts..."
|
||||
sed -i "s/const CURRENT_VERSION = '[^']*';/const CURRENT_VERSION = '$VERSION';/" src/lib/version.ts
|
||||
echo "✅ Updated version.ts to $VERSION"
|
||||
|
||||
- name: Commit to Codeberg
|
||||
run: |
|
||||
git config --local user.email "${{ secrets.PUSH_EMAIL }}"
|
||||
git config --local user.name "${{ secrets.PUSH_NAME }}"
|
||||
git add CHANGELOG VERSION.txt src/lib/changelog.ts src/lib/version.ts
|
||||
git commit -m "chore: Bump to ${{ steps.version.outputs.version }}"
|
||||
git push https://${{ secrets.PUSH_TOKEN }}@${{ secrets.REPO_URL }}.git HEAD:main
|
||||
rm -rf .git
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
path: 'github'
|
||||
ref: 'main'
|
||||
|
||||
- name: Commit to Github
|
||||
run: |
|
||||
cd github
|
||||
cp ../{CHANGELOG,VERSION.txt} .
|
||||
git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git add CHANGELOG VERSION.txt
|
||||
git commit -m "chore: Bump to ${{ steps.version.outputs.version }}"
|
||||
git push -u https://${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git main
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# next-sitemap
|
||||
sitemap.xml
|
||||
sitemap-*.xml
|
||||
|
||||
# generated files
|
||||
src/lib/runtime.ts
|
||||
public/manifest.json
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npx --no-install commitlint --edit "$1"
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
pnpm install
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
.next
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# changelog
|
||||
CHANGELOG.md
|
||||
|
||||
pnpm-lock.yaml
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
arrowParens: 'always',
|
||||
singleQuote: true,
|
||||
jsxSingleQuote: true,
|
||||
tabWidth: 2,
|
||||
semi: true,
|
||||
};
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"Region CSS": {
|
||||
"prefix": "regc",
|
||||
"body": [
|
||||
"/* #region /**=========== ${1} =========== */",
|
||||
"$0",
|
||||
"/* #endregion /**======== ${1} =========== */"
|
||||
]
|
||||
}
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"recommendations": [
|
||||
// Tailwind CSS Intellisense
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"aaron-bond.better-comments"
|
||||
]
|
||||
}
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"css.validate": false,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.tabSize": 2,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit"
|
||||
},
|
||||
"[css]": { "editor.defaultFormatter": "esbenp.prettier-vscode" },
|
||||
// Tailwind CSS Autocomplete, add more if used in projects
|
||||
"tailwindCSS.classAttributes": [
|
||||
"class",
|
||||
"className",
|
||||
"classNames",
|
||||
"containerClassName"
|
||||
],
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative"
|
||||
}
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
{
|
||||
//#region //*=========== React ===========
|
||||
"import React": {
|
||||
"prefix": "ir",
|
||||
"body": ["import * as React from 'react';"]
|
||||
},
|
||||
"React.useState": {
|
||||
"prefix": "us",
|
||||
"body": [
|
||||
"const [${1}, set${1/(^[a-zA-Z])(.*)/${1:/upcase}${2}/}] = React.useState<$3>(${2:initial${1/(^[a-zA-Z])(.*)/${1:/upcase}${2}/}})$0"
|
||||
]
|
||||
},
|
||||
"React.useEffect": {
|
||||
"prefix": "uf",
|
||||
"body": ["React.useEffect(() => {", " $0", "}, []);"]
|
||||
},
|
||||
"React.useReducer": {
|
||||
"prefix": "ur",
|
||||
"body": [
|
||||
"const [state, dispatch] = React.useReducer(${0:someReducer}, {",
|
||||
" ",
|
||||
"})"
|
||||
]
|
||||
},
|
||||
"React.useRef": {
|
||||
"prefix": "urf",
|
||||
"body": ["const ${1:someRef} = React.useRef($0)"]
|
||||
},
|
||||
"React Functional Component": {
|
||||
"prefix": "rc",
|
||||
"body": [
|
||||
"import * as React from 'react';\n",
|
||||
"export default function ${1:${TM_FILENAME_BASE}}() {",
|
||||
" return (",
|
||||
" <div>",
|
||||
" $0",
|
||||
" </div>",
|
||||
" )",
|
||||
"}"
|
||||
]
|
||||
},
|
||||
"React Functional Component with Props": {
|
||||
"prefix": "rcp",
|
||||
"body": [
|
||||
"import * as React from 'react';\n",
|
||||
"import clsxm from '@/lib/clsxm';\n",
|
||||
"type ${1:${TM_FILENAME_BASE}}Props= {\n",
|
||||
"} & React.ComponentPropsWithoutRef<'div'>\n",
|
||||
"export default function ${1:${TM_FILENAME_BASE}}({className, ...rest}: ${1:${TM_FILENAME_BASE}}Props) {",
|
||||
" return (",
|
||||
" <div className={clsxm(['', className])} {...rest}>",
|
||||
" $0",
|
||||
" </div>",
|
||||
" )",
|
||||
"}"
|
||||
]
|
||||
},
|
||||
//#endregion //*======== React ===========
|
||||
|
||||
//#region //*=========== Commons ===========
|
||||
"Region": {
|
||||
"prefix": "reg",
|
||||
"scope": "javascript, typescript, javascriptreact, typescriptreact",
|
||||
"body": [
|
||||
"//#region //*=========== ${1} ===========",
|
||||
"${TM_SELECTED_TEXT}$0",
|
||||
"//#endregion //*======== ${1} ==========="
|
||||
]
|
||||
},
|
||||
"Region CSS": {
|
||||
"prefix": "regc",
|
||||
"scope": "css, scss",
|
||||
"body": [
|
||||
"/* #region /**=========== ${1} =========== */",
|
||||
"${TM_SELECTED_TEXT}$0",
|
||||
"/* #endregion /**======== ${1} =========== */"
|
||||
]
|
||||
},
|
||||
//#endregion //*======== Commons ===========
|
||||
|
||||
//#region //*=========== Next.js ===========
|
||||
"Next Pages": {
|
||||
"prefix": "np",
|
||||
"body": [
|
||||
"import * as React from 'react';\n",
|
||||
"import Layout from '@/components/layout/Layout';",
|
||||
"import Seo from '@/components/Seo';\n",
|
||||
"export default function ${1:${TM_FILENAME_BASE/(^[a-zA-Z])(.*)/${1:/upcase}${2}/}}Page() {",
|
||||
" return (",
|
||||
" <Layout>",
|
||||
" <Seo templateTitle='${1:${TM_FILENAME_BASE/(^[a-zA-Z])(.*)/${1:/upcase}${2}/}}' />\n",
|
||||
" <main>\n",
|
||||
" <section className=''>",
|
||||
" <div className='layout py-20 min-h-screen'>",
|
||||
" $0",
|
||||
" </div>",
|
||||
" </section>",
|
||||
" </main>",
|
||||
" </Layout>",
|
||||
" )",
|
||||
"}"
|
||||
]
|
||||
},
|
||||
"Next API": {
|
||||
"prefix": "napi",
|
||||
"body": [
|
||||
"import { NextApiRequest, NextApiResponse } from 'next';\n",
|
||||
"export default async function handler(req: NextApiRequest, res: NextApiResponse) {",
|
||||
" if (req.method === 'GET') {",
|
||||
" res.status(200).json({ name: 'Bambang' });",
|
||||
" } else {",
|
||||
" res.status(405).json({ message: 'Method Not Allowed' });",
|
||||
" }",
|
||||
"}"
|
||||
]
|
||||
},
|
||||
"Get Static Props": {
|
||||
"prefix": "gsp",
|
||||
"body": [
|
||||
"export const getStaticProps = async (context: GetStaticPropsContext) => {",
|
||||
" return {",
|
||||
" props: {}",
|
||||
" };",
|
||||
"}"
|
||||
]
|
||||
},
|
||||
"Get Static Paths": {
|
||||
"prefix": "gspa",
|
||||
"body": [
|
||||
"export const getStaticPaths: GetStaticPaths = async () => {",
|
||||
" return {",
|
||||
" paths: [",
|
||||
" { params: { $1 }}",
|
||||
" ],",
|
||||
" fallback: ",
|
||||
" };",
|
||||
"}"
|
||||
]
|
||||
},
|
||||
"Get Server Side Props": {
|
||||
"prefix": "gssp",
|
||||
"body": [
|
||||
"export const getServerSideProps = async (context: GetServerSidePropsContext) => {",
|
||||
" return {",
|
||||
" props: {}",
|
||||
" };",
|
||||
"}"
|
||||
]
|
||||
},
|
||||
"Infer Get Static Props": {
|
||||
"prefix": "igsp",
|
||||
"body": "InferGetStaticPropsType<typeof getStaticProps>"
|
||||
},
|
||||
"Infer Get Server Side Props": {
|
||||
"prefix": "igssp",
|
||||
"body": "InferGetServerSidePropsType<typeof getServerSideProps>"
|
||||
},
|
||||
"Import useRouter": {
|
||||
"prefix": "imust",
|
||||
"body": ["import { useRouter } from 'next/router';"]
|
||||
},
|
||||
"Import Next Image": {
|
||||
"prefix": "imimg",
|
||||
"body": ["import Image from 'next/image';"]
|
||||
},
|
||||
"Import Next Link": {
|
||||
"prefix": "iml",
|
||||
"body": ["import Link from 'next/link';"]
|
||||
},
|
||||
//#endregion //*======== Next.js ===========
|
||||
|
||||
//#region //*=========== Snippet Wrap ===========
|
||||
"Wrap with Fragment": {
|
||||
"prefix": "ff",
|
||||
"body": ["<>", "\t${TM_SELECTED_TEXT}", "</>"]
|
||||
},
|
||||
"Wrap with clsx": {
|
||||
"prefix": "cx",
|
||||
"body": ["{clsx([${TM_SELECTED_TEXT}$0])}"]
|
||||
},
|
||||
"Wrap with clsxm": {
|
||||
"prefix": "cxm",
|
||||
"body": ["{clsxm([${TM_SELECTED_TEXT}$0, className])}"]
|
||||
},
|
||||
//#endregion //*======== Snippet Wrap ===========
|
||||
|
||||
"Logger": {
|
||||
"prefix": "lg",
|
||||
"body": [
|
||||
"logger({ ${1:${CLIPBOARD}} }, '${TM_FILENAME} line ${TM_LINE_NUMBER}')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,241 @@
|
||||
## [2.2.0] - 2025-08-14
|
||||
|
||||
## [100.0.0] - 2025-08-26
|
||||
|
||||
### Added
|
||||
|
||||
- 新增对 SITE_BASE 环境变量的支持,解决 m3u8 重写时 base url 错误的问题
|
||||
|
||||
### Changed
|
||||
|
||||
- 移除授权相关逻辑
|
||||
- 移除代码混淆
|
||||
- 移除 melody-cdn-sharon
|
||||
|
||||
## [4.3.0] - 2025-08-26
|
||||
|
||||
### Added
|
||||
|
||||
- 支持将 IPTV 频道添加到收藏中
|
||||
|
||||
### Changed
|
||||
|
||||
- 禁用 flv 直播,仅支持 m3u8 直播
|
||||
- 降低代理 ts 分片的内存占用
|
||||
|
||||
## [4.2.1] - 2025-08-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- 修复直播源加载失败或离开页面后依然无限加载的问题
|
||||
|
||||
## [4.2.0] - 2025-08-26
|
||||
|
||||
### Added
|
||||
|
||||
- 支持 flv 直播和直播地址解析到 mp4 的处理
|
||||
- 增加直播台标的 proxy 以防止 cors
|
||||
- 支持播放页选集分组的滚动翻页
|
||||
|
||||
### Changed
|
||||
|
||||
- 管理后台页面的按钮增加加载中的 UI
|
||||
|
||||
### Fixed
|
||||
|
||||
- /api/proxy/m3u8 仅对 m3u8 内容反序列化,降低内存和 CPU 消耗
|
||||
|
||||
## [4.1.1] - 2025-08-25
|
||||
|
||||
### Changed
|
||||
|
||||
- 增加对 url-tvg 和多 epg url 的支持
|
||||
|
||||
### Fixed
|
||||
|
||||
- 修复 epg 数据清洗中去重叠逻辑未考虑日期导致的问题
|
||||
|
||||
## [4.1.0] - 2025-08-24
|
||||
|
||||
### Added
|
||||
|
||||
- 解析 m3u 自带的 epg 和自定义 epg,增加今日节目单
|
||||
|
||||
### Changed
|
||||
|
||||
- 直播源数据刷新改为并发刷新
|
||||
|
||||
## [4.0.0] - 2025-08-24
|
||||
|
||||
### Added
|
||||
|
||||
- 增加 iptv 订阅和播放功能
|
||||
|
||||
### Changed
|
||||
|
||||
- 搜索页面视频卡片移动端/右键菜单添加豆瓣链接
|
||||
- 搜索建议遵循色情过滤
|
||||
|
||||
## [3.2.1] - 2025-08-22
|
||||
|
||||
### Changed
|
||||
|
||||
- 新增色色过滤分类
|
||||
- 调整搜索建议框层级
|
||||
|
||||
## [3.2.0] - 2025-08-22
|
||||
|
||||
### Added
|
||||
|
||||
- 视频源管理支持批量启用、禁用、删除
|
||||
- 用户管理支持批量设置用户组
|
||||
- 视频卡片右键/长按菜单新增新标签页播放
|
||||
|
||||
### Changed
|
||||
|
||||
- 视频卡片移动端 hover 时仅保留播放按钮
|
||||
- 微调管理页面 UI 和视频卡片右键/长按菜单中的收藏样式
|
||||
|
||||
### Fixed
|
||||
|
||||
- 修复了搜索栏 enter 键自动选中第一个建议项的问题
|
||||
|
||||
## [3.1.2] - 2025-08-22
|
||||
|
||||
### Fixed
|
||||
|
||||
- 修复移动端卡片无法点击的问题
|
||||
|
||||
## [3.1.1] - 2025-08-21
|
||||
|
||||
### Fixed
|
||||
|
||||
- 修复了视频卡片 hover 的非播放按钮点击后进入播放页的问题
|
||||
|
||||
## [3.1.0] - 2025-08-21
|
||||
|
||||
### Added
|
||||
|
||||
- 增加用户组管理和用户组播放源限制
|
||||
- 增加管理面板视频源有效性检查
|
||||
- 搜索栏增加一键删除按钮
|
||||
|
||||
### Changed
|
||||
|
||||
- 放宽授权心跳对于网络问题的判断标准
|
||||
- 统一管理面板弹窗使用 createPortal
|
||||
- VideoCard 允许移动端响应 hover 事件
|
||||
- 移动端布局 header 常驻,搜索按钮移动到 header 右侧
|
||||
- 调大搜索接口超时时间
|
||||
|
||||
### Fixed
|
||||
|
||||
- 修复 bangumi 返回的整数评分无小数导致 UI 不对齐的问题
|
||||
|
||||
## [3.0.2] - 2025-08-20
|
||||
|
||||
### Changed
|
||||
|
||||
- 优化机器码生成逻辑
|
||||
|
||||
### Fixed
|
||||
|
||||
- 修复 redis url 不支持 rediss 协议的问题
|
||||
|
||||
## [3.0.1] - 2025-08-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- 修复授权初始化错误
|
||||
|
||||
## [3.0.0] - 2025-08-20
|
||||
|
||||
### Added
|
||||
|
||||
- 防盗卖加固
|
||||
- 支持自定义用户可用视频源
|
||||
|
||||
### Changed
|
||||
|
||||
- 右键视频卡片可弹出操作菜单
|
||||
|
||||
### Fixed
|
||||
|
||||
- 过滤掉集数为 0 的搜索结果
|
||||
|
||||
## [2.7.1] - 2025-08-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- 修复 iOS 下版本面板可穿透滚动背景的问题
|
||||
|
||||
## [2.7.0] - 2025-08-17
|
||||
|
||||
### Added
|
||||
|
||||
- 视频卡片新增移动端操作面板,优化触控屏操作体验
|
||||
|
||||
### Changed
|
||||
|
||||
- 优化集数标题的匹配和展示逻辑
|
||||
|
||||
### Fixed
|
||||
|
||||
- 修复设置面板和修改密码面板背景可被拖动的问题
|
||||
|
||||
## [2.6.0] - 2025-08-17
|
||||
|
||||
### Added
|
||||
|
||||
- 新增搜索流式输出接口,并设置流式搜索为默认搜索接口,优化搜索体验
|
||||
- 新增源站搜索结果内存缓存,粒度为源站+关键词+页数,缓存 10 分钟
|
||||
- 新增豆瓣 CDN provided by @JohnsonRan
|
||||
|
||||
### Changed
|
||||
|
||||
- 搜索结果默认为无排序状态,不再默认按照年份排序
|
||||
- 常规搜索接口无结果时,不再设置响应的缓存头
|
||||
- 移除豆瓣数据源中的 cors-anywhere 方式
|
||||
|
||||
### Fixed
|
||||
|
||||
- 数据导出时导出站长密码,保证迁移到新账户时原站长用户可正常登录
|
||||
- 聚合卡片优化移动端源信息展示
|
||||
|
||||
## [2.4.1] - 2025-08-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- 对导入和 db 读取的配置文件做自检,防止 USERNAME 修改导致用户状态异常
|
||||
|
||||
## [2.4.0] - 2025-08-15
|
||||
|
||||
### Added
|
||||
|
||||
- 支持 kvrocks 存储(持久化 kv 存储)
|
||||
|
||||
### Fixed
|
||||
|
||||
- 修复搜索结果排序不稳定的问题
|
||||
- 导入数据时同时更新内存缓存的管理员配置
|
||||
|
||||
## [2.3.0] - 2025-08-15
|
||||
|
||||
### Added
|
||||
|
||||
- 支持站长导入导出整站数据
|
||||
|
||||
### Changed
|
||||
|
||||
- 仅允许站长操作配置文件
|
||||
- 微调搜索结果过滤面板的移动端样式
|
||||
|
||||
## [2.2.1] - 2025-08-14
|
||||
|
||||
### Fixed
|
||||
|
||||
- 修复了筛选 panel 打开时滚动页面 panel 不跟随的问题
|
||||
|
||||
## [2.2.0] - 2025-08-14
|
||||
|
||||
### Added
|
||||
|
||||
- 搜索结果支持按播放源、标题和年份筛选,支持按年份排序
|
||||
@@ -8,10 +244,10 @@
|
||||
### Fixed
|
||||
|
||||
- 修复 /api/search/resources 返回空的问题
|
||||
- 修复 upstash 实例无法编辑自定义分类的问题
|
||||
|
||||
## [2.1.0] - 2025-08-13
|
||||
|
||||
- 修复 upstash 实例无法编辑自定义分类的问题
|
||||
|
||||
## [2.1.0] - 2025-08-13
|
||||
|
||||
### Added
|
||||
|
||||
- 支持通过订阅获取配置文件
|
||||
@@ -19,20 +255,20 @@
|
||||
### Changed
|
||||
|
||||
- 微调部分文案和 UI
|
||||
- 删除部分无用代码
|
||||
|
||||
## [2.0.1] - 2025-08-13
|
||||
|
||||
- 删除部分无用代码
|
||||
|
||||
## [2.0.1] - 2025-08-13
|
||||
|
||||
### Changed
|
||||
|
||||
- 版本检查和变更日志请求 Github
|
||||
|
||||
### Fixed
|
||||
|
||||
- 微调管理面板样式
|
||||
|
||||
## [2.0.0] - 2025-08-13
|
||||
|
||||
- 微调管理面板样式
|
||||
|
||||
## [2.0.0] - 2025-08-13
|
||||
|
||||
### Added
|
||||
|
||||
- 支持配置文件在线配置和编辑
|
||||
@@ -45,60 +281,60 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- 限制设置面板的最大长度,防止超出视口
|
||||
|
||||
## [1.1.1] - 2025-08-12
|
||||
|
||||
### Changed
|
||||
- 修正 zwei 提供的 cors proxy 地址
|
||||
- 移除废弃代码
|
||||
|
||||
### Fixed
|
||||
- [运维] docker workflow release 日期使用东八区日期
|
||||
|
||||
## [1.1.0] - 2025-08-12
|
||||
|
||||
### Added
|
||||
- 每日新番放送功能,展示每日新番放送的番剧
|
||||
|
||||
### Fixed
|
||||
- 修复远程 CHANGELOG 无法提取变更内容的问题
|
||||
|
||||
## [1.0.5] - 2025-08-12
|
||||
|
||||
### Changed
|
||||
- 实现基于 Git 标签的自动 Release 工作流
|
||||
|
||||
## [1.0.4] - 2025-08-11
|
||||
|
||||
### Added
|
||||
- 优化版本管理工作流,实现单点修改
|
||||
|
||||
### Changed
|
||||
- 版本号现在从 CHANGELOG 自动提取,无需手动维护 VERSION.txt
|
||||
|
||||
## [1.0.3] - 2025-08-11
|
||||
|
||||
### Changed
|
||||
|
||||
- 升级播放器 Artplayer 至版本 5.2.5
|
||||
|
||||
## [1.0.2] - 2025-08-11
|
||||
|
||||
### Changed
|
||||
|
||||
- 版本号比较机制恢复为数字比较,仅当最新版本大于本地版本时才认为有更新
|
||||
- [运维] 自动替换 version.ts 中的版本号为 VERSION.txt 中的版本号
|
||||
|
||||
## [1.0.1] - 2025-08-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- 修复版本检查功能,只要与最新版本号不一致即认为有更新
|
||||
|
||||
## [1.0.0] - 2025-08-10
|
||||
|
||||
### Added
|
||||
|
||||
- 基于 Semantic Versioning 的版本号机制
|
||||
- 限制设置面板的最大长度,防止超出视口
|
||||
|
||||
## [1.1.1] - 2025-08-12
|
||||
|
||||
### Changed
|
||||
- 修正 zwei 提供的 cors proxy 地址
|
||||
- 移除废弃代码
|
||||
|
||||
### Fixed
|
||||
- [运维] docker workflow release 日期使用东八区日期
|
||||
|
||||
## [1.1.0] - 2025-08-12
|
||||
|
||||
### Added
|
||||
- 每日新番放送功能,展示每日新番放送的番剧
|
||||
|
||||
### Fixed
|
||||
- 修复远程 CHANGELOG 无法提取变更内容的问题
|
||||
|
||||
## [1.0.5] - 2025-08-12
|
||||
|
||||
### Changed
|
||||
- 实现基于 Git 标签的自动 Release 工作流
|
||||
|
||||
## [1.0.4] - 2025-08-11
|
||||
|
||||
### Added
|
||||
- 优化版本管理工作流,实现单点修改
|
||||
|
||||
### Changed
|
||||
- 版本号现在从 CHANGELOG 自动提取,无需手动维护 VERSION.txt
|
||||
|
||||
## [1.0.3] - 2025-08-11
|
||||
|
||||
### Changed
|
||||
|
||||
- 升级播放器 Artplayer 至版本 5.2.5
|
||||
|
||||
## [1.0.2] - 2025-08-11
|
||||
|
||||
### Changed
|
||||
|
||||
- 版本号比较机制恢复为数字比较,仅当最新版本大于本地版本时才认为有更新
|
||||
- [运维] 自动替换 version.ts 中的版本号为 VERSION.txt 中的版本号
|
||||
|
||||
## [1.0.1] - 2025-08-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- 修复版本检查功能,只要与最新版本号不一致即认为有更新
|
||||
|
||||
## [1.0.0] - 2025-08-10
|
||||
|
||||
### Added
|
||||
|
||||
- 基于 Semantic Versioning 的版本号机制
|
||||
- 版本信息面板,展示本地变更日志和远程更新日志
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
# ---- 第 1 阶段:安装依赖 ----
|
||||
FROM node:20-alpine AS deps
|
||||
|
||||
# 启用 corepack 并激活 pnpm(Node20 默认提供 corepack)
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 仅复制依赖清单,提高构建缓存利用率
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
# 安装所有依赖(含 devDependencies,后续会裁剪)
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# ---- 第 2 阶段:构建项目 ----
|
||||
FROM node:20-alpine AS builder
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
WORKDIR /app
|
||||
|
||||
# 复制依赖
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
# 复制全部源代码
|
||||
COPY . .
|
||||
|
||||
# 在构建阶段也显式设置 DOCKER_ENV,
|
||||
ENV DOCKER_ENV=true
|
||||
|
||||
# 生成生产构建
|
||||
RUN pnpm run build
|
||||
|
||||
# ---- 第 3 阶段:生成运行时镜像 ----
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
# 创建非 root 用户
|
||||
RUN addgroup -g 1001 -S nodejs && adduser -u 1001 -S nextjs -G nodejs
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
ENV PORT=3000
|
||||
ENV DOCKER_ENV=true
|
||||
|
||||
# 从构建器中复制 standalone 输出
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
# 从构建器中复制 scripts 目录
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
|
||||
# 从构建器中复制 start.js
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/start.js ./start.js
|
||||
# 从构建器中复制 public 和 .next/static 目录
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
# 切换到非特权用户
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# 使用自定义启动脚本,先预加载配置再启动服务器
|
||||
CMD ["node", "start.js"]
|
||||
@@ -0,0 +1,437 @@
|
||||
Attribution-NonCommercial-ShareAlike 4.0 International
|
||||
|
||||
=======================================================================
|
||||
|
||||
Creative Commons Corporation ("Creative Commons") is not a law firm and
|
||||
does not provide legal services or legal advice. Distribution of
|
||||
Creative Commons public licenses does not create a lawyer-client or
|
||||
other relationship. Creative Commons makes its licenses and related
|
||||
information available on an "as-is" basis. Creative Commons gives no
|
||||
warranties regarding its licenses, any material licensed under their
|
||||
terms and conditions, or any related information. Creative Commons
|
||||
disclaims all liability for damages resulting from their use to the
|
||||
fullest extent possible.
|
||||
|
||||
Using Creative Commons Public Licenses
|
||||
|
||||
Creative Commons public licenses provide a standard set of terms and
|
||||
conditions that creators and other rights holders may use to share
|
||||
original works of authorship and other material subject to copyright
|
||||
and certain other rights specified in the public license below. The
|
||||
following considerations are for informational purposes only, are not
|
||||
exhaustive, and do not form part of our licenses.
|
||||
|
||||
Considerations for licensors: Our public licenses are
|
||||
intended for use by those authorized to give the public
|
||||
permission to use material in ways otherwise restricted by
|
||||
copyright and certain other rights. Our licenses are
|
||||
irrevocable. Licensors should read and understand the terms
|
||||
and conditions of the license they choose before applying it.
|
||||
Licensors should also secure all rights necessary before
|
||||
applying our licenses so that the public can reuse the
|
||||
material as expected. Licensors should clearly mark any
|
||||
material not subject to the license. This includes other CC-
|
||||
licensed material, or material used under an exception or
|
||||
limitation to copyright. More considerations for licensors:
|
||||
wiki.creativecommons.org/Considerations_for_licensors
|
||||
|
||||
Considerations for the public: By using one of our public
|
||||
licenses, a licensor grants the public permission to use the
|
||||
licensed material under specified terms and conditions. If
|
||||
the licensor's permission is not necessary for any reason--for
|
||||
example, because of any applicable exception or limitation to
|
||||
copyright--then that use is not regulated by the license. Our
|
||||
licenses grant only permissions under copyright and certain
|
||||
other rights that a licensor has authority to grant. Use of
|
||||
the licensed material may still be restricted for other
|
||||
reasons, including because others have copyright or other
|
||||
rights in the material. A licensor may make special requests,
|
||||
such as asking that all changes be marked or described.
|
||||
Although not required by our licenses, you are encouraged to
|
||||
respect those requests where reasonable. More considerations
|
||||
for the public:
|
||||
wiki.creativecommons.org/Considerations_for_licensees
|
||||
|
||||
=======================================================================
|
||||
|
||||
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
|
||||
Public License
|
||||
|
||||
By exercising the Licensed Rights (defined below), You accept and agree
|
||||
to be bound by the terms and conditions of this Creative Commons
|
||||
Attribution-NonCommercial-ShareAlike 4.0 International Public License
|
||||
("Public License"). To the extent this Public License may be
|
||||
interpreted as a contract, You are granted the Licensed Rights in
|
||||
consideration of Your acceptance of these terms and conditions, and the
|
||||
Licensor grants You such rights in consideration of benefits the
|
||||
Licensor receives from making the Licensed Material available under
|
||||
these terms and conditions.
|
||||
|
||||
|
||||
Section 1 -- Definitions.
|
||||
|
||||
a. Adapted Material means material subject to Copyright and Similar
|
||||
Rights that is derived from or based upon the Licensed Material
|
||||
and in which the Licensed Material is translated, altered,
|
||||
arranged, transformed, or otherwise modified in a manner requiring
|
||||
permission under the Copyright and Similar Rights held by the
|
||||
Licensor. For purposes of this Public License, where the Licensed
|
||||
Material is a musical work, performance, or sound recording,
|
||||
Adapted Material is always produced where the Licensed Material is
|
||||
synched in timed relation with a moving image.
|
||||
|
||||
b. Adapter's License means the license You apply to Your Copyright
|
||||
and Similar Rights in Your contributions to Adapted Material in
|
||||
accordance with the terms and conditions of this Public License.
|
||||
|
||||
c. BY-NC-SA Compatible License means a license listed at
|
||||
creativecommons.org/compatiblelicenses, approved by Creative
|
||||
Commons as essentially the equivalent of this Public License.
|
||||
|
||||
d. Copyright and Similar Rights means copyright and/or similar rights
|
||||
closely related to copyright including, without limitation,
|
||||
performance, broadcast, sound recording, and Sui Generis Database
|
||||
Rights, without regard to how the rights are labeled or
|
||||
categorized. For purposes of this Public License, the rights
|
||||
specified in Section 2(b)(1)-(2) are not Copyright and Similar
|
||||
Rights.
|
||||
|
||||
e. Effective Technological Measures means those measures that, in the
|
||||
absence of proper authority, may not be circumvented under laws
|
||||
fulfilling obligations under Article 11 of the WIPO Copyright
|
||||
Treaty adopted on December 20, 1996, and/or similar international
|
||||
agreements.
|
||||
|
||||
f. Exceptions and Limitations means fair use, fair dealing, and/or
|
||||
any other exception or limitation to Copyright and Similar Rights
|
||||
that applies to Your use of the Licensed Material.
|
||||
|
||||
g. License Elements means the license attributes listed in the name
|
||||
of a Creative Commons Public License. The License Elements of this
|
||||
Public License are Attribution, NonCommercial, and ShareAlike.
|
||||
|
||||
h. Licensed Material means the artistic or literary work, database,
|
||||
or other material to which the Licensor applied this Public
|
||||
License.
|
||||
|
||||
i. Licensed Rights means the rights granted to You subject to the
|
||||
terms and conditions of this Public License, which are limited to
|
||||
all Copyright and Similar Rights that apply to Your use of the
|
||||
Licensed Material and that the Licensor has authority to license.
|
||||
|
||||
j. Licensor means the individual(s) or entity(ies) granting rights
|
||||
under this Public License.
|
||||
|
||||
k. NonCommercial means not primarily intended for or directed towards
|
||||
commercial advantage or monetary compensation. For purposes of
|
||||
this Public License, the exchange of the Licensed Material for
|
||||
other material subject to Copyright and Similar Rights by digital
|
||||
file-sharing or similar means is NonCommercial provided there is
|
||||
no payment of monetary compensation in connection with the
|
||||
exchange.
|
||||
|
||||
l. Share means to provide material to the public by any means or
|
||||
process that requires permission under the Licensed Rights, such
|
||||
as reproduction, public display, public performance, distribution,
|
||||
dissemination, communication, or importation, and to make material
|
||||
available to the public including in ways that members of the
|
||||
public may access the material from a place and at a time
|
||||
individually chosen by them.
|
||||
|
||||
m. Sui Generis Database Rights means rights other than copyright
|
||||
resulting from Directive 96/9/EC of the European Parliament and of
|
||||
the Council of 11 March 1996 on the legal protection of databases,
|
||||
as amended and/or succeeded, as well as other essentially
|
||||
equivalent rights anywhere in the world.
|
||||
|
||||
n. You means the individual or entity exercising the Licensed Rights
|
||||
under this Public License. Your has a corresponding meaning.
|
||||
|
||||
|
||||
Section 2 -- Scope.
|
||||
|
||||
a. License grant.
|
||||
|
||||
1. Subject to the terms and conditions of this Public License,
|
||||
the Licensor hereby grants You a worldwide, royalty-free,
|
||||
non-sublicensable, non-exclusive, irrevocable license to
|
||||
exercise the Licensed Rights in the Licensed Material to:
|
||||
|
||||
a. reproduce and Share the Licensed Material, in whole or
|
||||
in part, for NonCommercial purposes only; and
|
||||
|
||||
b. produce, reproduce, and Share Adapted Material for
|
||||
NonCommercial purposes only.
|
||||
|
||||
2. Exceptions and Limitations. For the avoidance of doubt, where
|
||||
Exceptions and Limitations apply to Your use, this Public
|
||||
License does not apply, and You do not need to comply with
|
||||
its terms and conditions.
|
||||
|
||||
3. Term. The term of this Public License is specified in Section
|
||||
6(a).
|
||||
|
||||
4. Media and formats; technical modifications allowed. The
|
||||
Licensor authorizes You to exercise the Licensed Rights in
|
||||
all media and formats whether now known or hereafter created,
|
||||
and to make technical modifications necessary to do so. The
|
||||
Licensor waives and/or agrees not to assert any right or
|
||||
authority to forbid You from making technical modifications
|
||||
necessary to exercise the Licensed Rights, including
|
||||
technical modifications necessary to circumvent Effective
|
||||
Technological Measures. For purposes of this Public License,
|
||||
simply making modifications authorized by this Section 2(a)
|
||||
(4) never produces Adapted Material.
|
||||
|
||||
5. Downstream recipients.
|
||||
|
||||
a. Offer from the Licensor -- Licensed Material. Every
|
||||
recipient of the Licensed Material automatically
|
||||
receives an offer from the Licensor to exercise the
|
||||
Licensed Rights under the terms and conditions of this
|
||||
Public License.
|
||||
|
||||
b. Additional offer from the Licensor -- Adapted Material.
|
||||
Every recipient of Adapted Material from You
|
||||
automatically receives an offer from the Licensor to
|
||||
exercise the Licensed Rights in the Adapted Material
|
||||
under the conditions of the Adapter's License You apply.
|
||||
|
||||
c. No downstream restrictions. You may not offer or impose
|
||||
any additional or different terms or conditions on, or
|
||||
apply any Effective Technological Measures to, the
|
||||
Licensed Material if doing so restricts exercise of the
|
||||
Licensed Rights by any recipient of the Licensed
|
||||
Material.
|
||||
|
||||
6. No endorsement. Nothing in this Public License constitutes or
|
||||
may be construed as permission to assert or imply that You
|
||||
are, or that Your use of the Licensed Material is, connected
|
||||
with, or sponsored, endorsed, or granted official status by,
|
||||
the Licensor or others designated to receive attribution as
|
||||
provided in Section 3(a)(1)(A)(i).
|
||||
|
||||
b. Other rights.
|
||||
|
||||
1. Moral rights, such as the right of integrity, are not
|
||||
licensed under this Public License, nor are publicity,
|
||||
privacy, and/or other similar personality rights; however, to
|
||||
the extent possible, the Licensor waives and/or agrees not to
|
||||
assert any such rights held by the Licensor to the limited
|
||||
extent necessary to allow You to exercise the Licensed
|
||||
Rights, but not otherwise.
|
||||
|
||||
2. Patent and trademark rights are not licensed under this
|
||||
Public License.
|
||||
|
||||
3. To the extent possible, the Licensor waives any right to
|
||||
collect royalties from You for the exercise of the Licensed
|
||||
Rights, whether directly or through a collecting society
|
||||
under any voluntary or waivable statutory or compulsory
|
||||
licensing scheme. In all other cases the Licensor expressly
|
||||
reserves any right to collect such royalties, including when
|
||||
the Licensed Material is used other than for NonCommercial
|
||||
purposes.
|
||||
|
||||
|
||||
Section 3 -- License Conditions.
|
||||
|
||||
Your exercise of the Licensed Rights is expressly made subject to the
|
||||
following conditions.
|
||||
|
||||
a. Attribution.
|
||||
|
||||
1. If You Share the Licensed Material (including in modified
|
||||
form), You must:
|
||||
|
||||
a. retain the following if it is supplied by the Licensor
|
||||
with the Licensed Material:
|
||||
|
||||
i. identification of the creator(s) of the Licensed
|
||||
Material and any others designated to receive
|
||||
attribution, in any reasonable manner requested by
|
||||
the Licensor (including by pseudonym if
|
||||
designated);
|
||||
|
||||
ii. a copyright notice;
|
||||
|
||||
iii. a notice that refers to this Public License;
|
||||
|
||||
iv. a notice that refers to the disclaimer of
|
||||
warranties;
|
||||
|
||||
v. a URI or hyperlink to the Licensed Material to the
|
||||
extent reasonably practicable;
|
||||
|
||||
b. indicate if You modified the Licensed Material and
|
||||
retain an indication of any previous modifications; and
|
||||
|
||||
c. indicate the Licensed Material is licensed under this
|
||||
Public License, and include the text of, or the URI or
|
||||
hyperlink to, this Public License.
|
||||
|
||||
2. You may satisfy the conditions in Section 3(a)(1) in any
|
||||
reasonable manner based on the medium, means, and context in
|
||||
which You Share the Licensed Material. For example, it may be
|
||||
reasonable to satisfy the conditions by providing a URI or
|
||||
hyperlink to a resource that includes the required
|
||||
information.
|
||||
3. If requested by the Licensor, You must remove any of the
|
||||
information required by Section 3(a)(1)(A) to the extent
|
||||
reasonably practicable.
|
||||
|
||||
b. ShareAlike.
|
||||
|
||||
In addition to the conditions in Section 3(a), if You Share
|
||||
Adapted Material You produce, the following conditions also apply.
|
||||
|
||||
1. The Adapter's License You apply must be a Creative Commons
|
||||
license with the same License Elements, this version or
|
||||
later, or a BY-NC-SA Compatible License.
|
||||
|
||||
2. You must include the text of, or the URI or hyperlink to, the
|
||||
Adapter's License You apply. You may satisfy this condition
|
||||
in any reasonable manner based on the medium, means, and
|
||||
context in which You Share Adapted Material.
|
||||
|
||||
3. You may not offer or impose any additional or different terms
|
||||
or conditions on, or apply any Effective Technological
|
||||
Measures to, Adapted Material that restrict exercise of the
|
||||
rights granted under the Adapter's License You apply.
|
||||
|
||||
|
||||
Section 4 -- Sui Generis Database Rights.
|
||||
|
||||
Where the Licensed Rights include Sui Generis Database Rights that
|
||||
apply to Your use of the Licensed Material:
|
||||
|
||||
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
|
||||
to extract, reuse, reproduce, and Share all or a substantial
|
||||
portion of the contents of the database for NonCommercial purposes
|
||||
only;
|
||||
|
||||
b. if You include all or a substantial portion of the database
|
||||
contents in a database in which You have Sui Generis Database
|
||||
Rights, then the database in which You have Sui Generis Database
|
||||
Rights (but not its individual contents) is Adapted Material,
|
||||
including for purposes of Section 3(b); and
|
||||
|
||||
c. You must comply with the conditions in Section 3(a) if You Share
|
||||
all or a substantial portion of the contents of the database.
|
||||
|
||||
For the avoidance of doubt, this Section 4 supplements and does not
|
||||
replace Your obligations under this Public License where the Licensed
|
||||
Rights include other Copyright and Similar Rights.
|
||||
|
||||
|
||||
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
|
||||
|
||||
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
|
||||
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
|
||||
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
|
||||
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
|
||||
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
|
||||
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
|
||||
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
|
||||
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
|
||||
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
|
||||
|
||||
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
|
||||
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
|
||||
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
|
||||
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
|
||||
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
|
||||
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
|
||||
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
|
||||
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
|
||||
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
|
||||
|
||||
c. The disclaimer of warranties and limitation of liability provided
|
||||
above shall be interpreted in a manner that, to the extent
|
||||
possible, most closely approximates an absolute disclaimer and
|
||||
waiver of all liability.
|
||||
|
||||
|
||||
Section 6 -- Term and Termination.
|
||||
|
||||
a. This Public License applies for the term of the Copyright and
|
||||
Similar Rights licensed here. However, if You fail to comply with
|
||||
this Public License, then Your rights under this Public License
|
||||
terminate automatically.
|
||||
|
||||
b. Where Your right to use the Licensed Material has terminated under
|
||||
Section 6(a), it reinstates:
|
||||
|
||||
1. automatically as of the date the violation is cured, provided
|
||||
it is cured within 30 days of Your discovery of the
|
||||
violation; or
|
||||
|
||||
2. upon express reinstatement by the Licensor.
|
||||
|
||||
For the avoidance of doubt, this Section 6(b) does not affect any
|
||||
right the Licensor may have to seek remedies for Your violations
|
||||
of this Public License.
|
||||
|
||||
c. For the avoidance of doubt, the Licensor may also offer the
|
||||
Licensed Material under separate terms or conditions or stop
|
||||
distributing the Licensed Material at any time; however, doing so
|
||||
will not terminate this Public License.
|
||||
|
||||
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
|
||||
License.
|
||||
|
||||
|
||||
Section 7 -- Other Terms and Conditions.
|
||||
|
||||
a. The Licensor shall not be bound by any additional or different
|
||||
terms or conditions communicated by You unless expressly agreed.
|
||||
|
||||
b. Any arrangements, understandings, or agreements regarding the
|
||||
Licensed Material not stated herein are separate from and
|
||||
independent of the terms and conditions of this Public License.
|
||||
|
||||
|
||||
Section 8 -- Interpretation.
|
||||
|
||||
a. For the avoidance of doubt, this Public License does not, and
|
||||
shall not be interpreted to, reduce, limit, restrict, or impose
|
||||
conditions on any use of the Licensed Material that could lawfully
|
||||
be made without permission under this Public License.
|
||||
|
||||
b. To the extent possible, if any provision of this Public License is
|
||||
deemed unenforceable, it shall be automatically reformed to the
|
||||
minimum extent necessary to make it enforceable. If the provision
|
||||
cannot be reformed, it shall be severed from this Public License
|
||||
without affecting the enforceability of the remaining terms and
|
||||
conditions.
|
||||
|
||||
c. No term or condition of this Public License will be waived and no
|
||||
failure to comply consented to unless expressly agreed to by the
|
||||
Licensor.
|
||||
|
||||
d. Nothing in this Public License constitutes or may be interpreted
|
||||
as a limitation upon, or waiver of, any privileges and immunities
|
||||
that apply to the Licensor or You, including from the legal
|
||||
processes of any jurisdiction or authority.
|
||||
|
||||
=======================================================================
|
||||
|
||||
Creative Commons is not a party to its public
|
||||
licenses. Notwithstanding, Creative Commons may elect to apply one of
|
||||
its public licenses to material it publishes and in those instances
|
||||
will be considered the “Licensor.” The text of the Creative Commons
|
||||
public licenses is dedicated to the public domain under the CC0 Public
|
||||
Domain Dedication. Except for the limited purpose of indicating that
|
||||
material is shared under a Creative Commons public license or as
|
||||
otherwise permitted by the Creative Commons policies published at
|
||||
creativecommons.org/policies, Creative Commons does not authorize the
|
||||
use of the trademark "Creative Commons" or any other trademark or logo
|
||||
of Creative Commons without its prior written consent including,
|
||||
without limitation, in connection with any unauthorized modifications
|
||||
to any of its public licenses or any other arrangements,
|
||||
understandings, or agreements concerning use of licensed material. For
|
||||
the avoidance of doubt, this paragraph does not form part of the
|
||||
public licenses.
|
||||
|
||||
Creative Commons may be contacted at creativecommons.org.
|
||||
@@ -20,15 +20,15 @@
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
- 🔍 **多源聚合搜索**:内置数十个免费资源站点,一次搜索立刻返回全源结果。
|
||||
- 🔍 **多源聚合搜索**:一次搜索立刻返回全源结果。
|
||||
- 📄 **丰富详情页**:支持剧集列表、演员、年份、简介等完整信息展示。
|
||||
- ▶️ **流畅在线播放**:集成 HLS.js & ArtPlayer。
|
||||
- ❤️ **收藏 + 继续观看**:支持 Redis/Upstash 存储,多端同步进度。
|
||||
- ❤️ **收藏 + 继续观看**:支持 Kvrocks/Redis/Upstash 存储,多端同步进度。
|
||||
- 📱 **PWA**:离线缓存、安装到桌面/主屏,移动端原生体验。
|
||||
- 🌗 **响应式布局**:桌面侧边栏 + 移动底部导航,自适应各种屏幕尺寸。
|
||||
- 👿 **智能去广告**:自动跳过视频中的切片广告(实验性)。
|
||||
|
||||
### 注意:本项目为闭源项目,本仓库仅用于分发 docker 镜像和收集 issue,介意请绕道
|
||||
### 注意:部署后项目为空壳项目,无内置播放源和直播源,需要自行收集
|
||||
|
||||
<details>
|
||||
<summary>点击查看项目截图</summary>
|
||||
@@ -37,9 +37,7 @@
|
||||
<img src="public/screenshot3.png" alt="项目截图" style="max-width:600px">
|
||||
</details>
|
||||
|
||||
### 请不要在 B站、小红书、微信公众号或其他中国大陆社交平台发布视频或文章宣传本项目
|
||||
|
||||
本项目为空项目,请勿 fork,喜欢可点 star
|
||||
### 请不要在 B站、小红书、微信公众号、抖音、今日头条或其他中国大陆社交平台发布视频或文章宣传本项目,不授权任何“科技周刊/月刊”类项目或站点收录本项目。
|
||||
|
||||
## 🗺 目录
|
||||
|
||||
@@ -69,14 +67,48 @@
|
||||
|
||||
本项目**仅支持 Docker 或其他基于 Docker 的平台** 部署。
|
||||
|
||||
### Redis 存储
|
||||
### Kvrocks 存储(推荐)
|
||||
|
||||
```yml
|
||||
services:
|
||||
moontv-core:
|
||||
image: ghcr.io/moontechlab/lunatv:latest
|
||||
container_name: moontv-core
|
||||
restart: on-failure
|
||||
ports:
|
||||
- '3000:3000'
|
||||
environment:
|
||||
- USERNAME=admin
|
||||
- PASSWORD=admin_password
|
||||
- NEXT_PUBLIC_STORAGE_TYPE=kvrocks
|
||||
- KVROCKS_URL=redis://moontv-kvrocks:6666
|
||||
networks:
|
||||
- moontv-network
|
||||
depends_on:
|
||||
- moontv-kvrocks
|
||||
moontv-kvrocks:
|
||||
image: apache/kvrocks
|
||||
container_name: moontv-kvrocks
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- kvrocks-data:/var/lib/kvrocks
|
||||
networks:
|
||||
- moontv-network
|
||||
networks:
|
||||
moontv-network:
|
||||
driver: bridge
|
||||
volumes:
|
||||
kvrocks-data:
|
||||
```
|
||||
|
||||
### Redis 存储(有一定的丢数据风险)
|
||||
|
||||
```yml
|
||||
services:
|
||||
moontv-core:
|
||||
image: ghcr.io/moontechlab/lunatv:latest
|
||||
container_name: moontv-core
|
||||
restart: on-failure
|
||||
ports:
|
||||
- '3000:3000'
|
||||
environment:
|
||||
@@ -112,15 +144,15 @@ services:
|
||||
moontv-core:
|
||||
image: ghcr.io/moontechlab/lunatv:latest
|
||||
container_name: moontv-core
|
||||
restart: unless-stopped
|
||||
restart: on-failure
|
||||
ports:
|
||||
- '3000:3000'
|
||||
environment:
|
||||
- USERNAME=admin
|
||||
- PASSWORD=admin_password
|
||||
- NEXT_PUBLIC_STORAGE_TYPE=upstash
|
||||
- UPSTASH_URL={上面 https 开头的 HTTPS ENDPOINT}
|
||||
- UPSTASH_TOKEN={上面的 TOKEN}
|
||||
- UPSTASH_URL=上面 https 开头的 HTTPS ENDPOINT
|
||||
- UPSTASH_TOKEN=上面的 TOKEN
|
||||
```
|
||||
|
||||
## 配置文件
|
||||
@@ -180,21 +212,23 @@ dockge/komodo 等 docker compose UI 也有自动更新功能
|
||||
|
||||
| 变量 | 说明 | 可选值 | 默认值 |
|
||||
| ----------------------------------- | -------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| USERNAME | 非 localstorage 部署时的管理员账号 | 任意字符串 | 无默认,必填字段 |
|
||||
| PASSWORD | 非 localstorage 部署时为管理员密码 | 任意字符串 | 无默认,必填字段 |
|
||||
| USERNAME | 站长账号 | 任意字符串 | 无默认,必填字段 |
|
||||
| PASSWORD | 站长密码 | 任意字符串 | 无默认,必填字段 |
|
||||
| SITE_BASE | 站点 url | 形如 https://example.com | 空 |
|
||||
| NEXT_PUBLIC_SITE_NAME | 站点名称 | 任意字符串 | MoonTV |
|
||||
| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 |
|
||||
| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | redis、upstash | 无默认,必填字段 |
|
||||
| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | redis、kvrocks、upstash | 无默认,必填字段 |
|
||||
| KVROCKS_URL | kvrocks 连接 url | 连接 url | 空 |
|
||||
| REDIS_URL | redis 连接 url | 连接 url | 空 |
|
||||
| UPSTASH_URL | upstash redis 连接 url | 连接 url | 空 |
|
||||
| UPSTASH_TOKEN | upstash redis 连接 token | 连接 token | 空 |
|
||||
| NEXT_PUBLIC_ENABLE_REGISTER | 是否开放注册,仅在非 localstorage 部署时生效 | true / false | false |
|
||||
| NEXT_PUBLIC_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 |
|
||||
| NEXT_PUBLIC_DOUBAN_PROXY_TYPE | 豆瓣数据源请求方式 | 见下方 | direct |
|
||||
| NEXT_PUBLIC_DOUBAN_PROXY | 自定义豆瓣数据代理 URL | url prefix | (空) |
|
||||
| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE | 豆瓣图片代理类型 | 见下方 | direct |
|
||||
| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY | 自定义豆瓣图片代理 URL | url prefix | (空) |
|
||||
| NEXT_PUBLIC_DISABLE_YELLOW_FILTER | 关闭色情内容过滤 | true/false | false |
|
||||
| NEXT_PUBLIC_FLUID_SEARCH | 是否开启搜索接口流式输出 | true/ false | true |
|
||||
|
||||
NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释:
|
||||
|
||||
@@ -202,7 +236,6 @@ NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释:
|
||||
- cors-proxy-zwei: 浏览器向 cors proxy 请求豆瓣数据,该 cors proxy 由 [Zwei](https://github.com/bestzwei) 搭建
|
||||
- cmliussss-cdn-tencent: 浏览器向豆瓣 CDN 请求数据,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由腾讯云 cdn 提供加速
|
||||
- cmliussss-cdn-ali: 浏览器向豆瓣 CDN 请求数据,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由阿里云 cdn 提供加速
|
||||
- cors-anywhere: 浏览器向 cors proxy 请求豆瓣数据,该 cors proxy 为公共服务 [cors-anywhere](https://cors-anywhere.com),限制每分钟 20 次请求
|
||||
- custom: 用户自定义 proxy,由 NEXT_PUBLIC_DOUBAN_PROXY 定义
|
||||
|
||||
NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE 选项解释:
|
||||
@@ -253,3 +286,7 @@ NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE 选项解释:
|
||||
- [Zwei](https://github.com/bestzwei) — 提供获取豆瓣数据的 cors proxy
|
||||
- [CMLiussss](https://github.com/cmliu) — 提供豆瓣 CDN 服务
|
||||
- 感谢所有提供免费影视接口的站点。
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#MoonTechLab/LunaTV&Date)
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2.2.0
|
||||
100.0.0
|
||||
@@ -0,0 +1,24 @@
|
||||
module.exports = {
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
rules: {
|
||||
// TODO Add Scope Enum Here
|
||||
// 'scope-enum': [2, 'always', ['yourscope', 'yourscope']],
|
||||
'type-enum': [
|
||||
2,
|
||||
'always',
|
||||
[
|
||||
'feat',
|
||||
'fix',
|
||||
'docs',
|
||||
'chore',
|
||||
'style',
|
||||
'refactor',
|
||||
'ci',
|
||||
'test',
|
||||
'perf',
|
||||
'revert',
|
||||
'vercel',
|
||||
],
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const nextJest = require('next/jest');
|
||||
|
||||
const createJestConfig = nextJest({
|
||||
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
|
||||
dir: './',
|
||||
});
|
||||
|
||||
// Add any custom config to be passed to Jest
|
||||
const customJestConfig = {
|
||||
// Add more setup options before each test is run
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||
|
||||
// if using TypeScript with a baseUrl set to the root directory then you need the below for alias' to work
|
||||
moduleDirectories: ['node_modules', '<rootDir>/'],
|
||||
|
||||
testEnvironment: 'jest-environment-jsdom',
|
||||
|
||||
/**
|
||||
* Absolute imports and Module Path Aliases
|
||||
*/
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
'^~/(.*)$': '<rootDir>/public/$1',
|
||||
'^.+\\.(svg)$': '<rootDir>/src/__mocks__/svg.tsx',
|
||||
},
|
||||
};
|
||||
|
||||
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
|
||||
module.exports = createJestConfig(customJestConfig);
|
||||
@@ -0,0 +1,5 @@
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
|
||||
// Allow router mocks.
|
||||
// eslint-disable-next-line no-undef
|
||||
jest.mock('next/router', () => require('next-router-mock'));
|
||||
@@ -0,0 +1,79 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
eslint: {
|
||||
dirs: ['src'],
|
||||
},
|
||||
|
||||
reactStrictMode: false,
|
||||
swcMinify: false,
|
||||
|
||||
experimental: {
|
||||
instrumentationHook: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
|
||||
// Uncoment to add domain whitelist
|
||||
images: {
|
||||
unoptimized: true,
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '**',
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: '**',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
webpack(config) {
|
||||
// Grab the existing rule that handles SVG imports
|
||||
const fileLoaderRule = config.module.rules.find((rule) =>
|
||||
rule.test?.test?.('.svg')
|
||||
);
|
||||
|
||||
config.module.rules.push(
|
||||
// Reapply the existing rule, but only for svg imports ending in ?url
|
||||
{
|
||||
...fileLoaderRule,
|
||||
test: /\.svg$/i,
|
||||
resourceQuery: /url/, // *.svg?url
|
||||
},
|
||||
// Convert all other *.svg imports to React components
|
||||
{
|
||||
test: /\.svg$/i,
|
||||
issuer: { not: /\.(css|scss|sass)$/ },
|
||||
resourceQuery: { not: /url/ }, // exclude if *.svg?url
|
||||
loader: '@svgr/webpack',
|
||||
options: {
|
||||
dimensions: false,
|
||||
titleProp: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Modify the file loader rule to ignore *.svg, since we have it handled now.
|
||||
fileLoaderRule.exclude = /\.svg$/i;
|
||||
|
||||
config.resolve.fallback = {
|
||||
...config.resolve.fallback,
|
||||
net: false,
|
||||
tls: false,
|
||||
crypto: false,
|
||||
};
|
||||
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
const withPWA = require('next-pwa')({
|
||||
dest: 'public',
|
||||
disable: process.env.NODE_ENV === 'development',
|
||||
register: true,
|
||||
skipWaiting: true,
|
||||
});
|
||||
|
||||
module.exports = withPWA(nextConfig);
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"name": "moontv",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm gen:manifest && next dev -H 0.0.0.0",
|
||||
"build": "pnpm gen:manifest && next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "eslint src --fix && pnpm format",
|
||||
"lint:strict": "eslint --max-warnings=0 src",
|
||||
"typecheck": "tsc --noEmit --incremental false",
|
||||
"test:watch": "jest --watch",
|
||||
"test": "jest",
|
||||
"format": "prettier -w .",
|
||||
"format:check": "prettier -c .",
|
||||
"gen:manifest": "node scripts/generate-manifest.js",
|
||||
"postbuild": "echo 'Build completed - sitemap generation disabled'",
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@headlessui/react": "^2.2.4",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@upstash/redis": "^1.25.0",
|
||||
"@vidstack/react": "^1.12.13",
|
||||
"artplayer": "^5.2.5",
|
||||
"bs58": "^6.0.0",
|
||||
"clsx": "^2.0.0",
|
||||
"crypto-js": "^4.2.0",
|
||||
"framer-motion": "^12.18.1",
|
||||
"he": "^1.2.0",
|
||||
"hls.js": "^1.6.10",
|
||||
"lucide-react": "^0.438.0",
|
||||
"media-icons": "^1.1.5",
|
||||
"next": "^14.2.23",
|
||||
"next-pwa": "^5.6.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^5.4.0",
|
||||
"redis": "^4.6.7",
|
||||
"swiper": "^11.2.8",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"vidstack": "^0.6.15",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^16.3.0",
|
||||
"@commitlint/config-conventional": "^16.2.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^15.0.7",
|
||||
"@types/bs58": "^5.0.0",
|
||||
"@types/he": "^1.2.3",
|
||||
"@types/node": "24.0.3",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/testing-library__jest-dom": "^5.14.9",
|
||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||
"@typescript-eslint/parser": "^5.62.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-next": "^14.2.23",
|
||||
"eslint-config-prettier": "^8.10.0",
|
||||
"eslint-plugin-simple-import-sort": "^7.0.0",
|
||||
"eslint-plugin-unused-imports": "^2.0.0",
|
||||
"husky": "^7.0.4",
|
||||
"jest": "^27.5.1",
|
||||
"lint-staged": "^12.5.0",
|
||||
"next-router-mock": "^0.9.0",
|
||||
"postcss": "^8.5.1",
|
||||
"prettier": "^2.8.8",
|
||||
"prettier-plugin-tailwindcss": "^0.5.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^4.9.5",
|
||||
"webpack-obfuscator": "^3.5.1"
|
||||
},
|
||||
"lint-staged": {
|
||||
"**/*.{js,jsx,ts,tsx}": [
|
||||
"eslint --max-warnings=0",
|
||||
"prettier -w"
|
||||
],
|
||||
"**/*.{json,css,scss,md,webmanifest}": [
|
||||
"prettier -w"
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@10.14.0"
|
||||
}
|
||||
Generated
+11391
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
/* eslint-disable */
|
||||
|
||||
addEventListener('fetch', (event) => {
|
||||
event.respondWith(handleRequest(event.request));
|
||||
});
|
||||
|
||||
async function handleRequest(request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// 如果访问根目录,返回HTML
|
||||
if (url.pathname === '/') {
|
||||
return new Response(getRootHtml(), {
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 从请求路径中提取目标 URL
|
||||
let actualUrlStr = decodeURIComponent(url.pathname.replace('/', ''));
|
||||
|
||||
// 判断用户输入的 URL 是否带有协议
|
||||
actualUrlStr = ensureProtocol(actualUrlStr, url.protocol);
|
||||
|
||||
// 保留查询参数
|
||||
actualUrlStr += url.search;
|
||||
|
||||
// 创建新 Headers 对象,排除以 'cf-' 开头的请求头
|
||||
const newHeaders = filterHeaders(
|
||||
request.headers,
|
||||
(name) => !name.startsWith('cf-')
|
||||
);
|
||||
|
||||
// 创建一个新的请求以访问目标 URL
|
||||
const modifiedRequest = new Request(actualUrlStr, {
|
||||
headers: newHeaders,
|
||||
method: request.method,
|
||||
body: request.body,
|
||||
redirect: 'manual',
|
||||
});
|
||||
|
||||
// 发起对目标 URL 的请求
|
||||
const response = await fetch(modifiedRequest);
|
||||
let body = response.body;
|
||||
|
||||
// 处理重定向
|
||||
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
||||
body = response.body;
|
||||
// 创建新的 Response 对象以修改 Location 头部
|
||||
return handleRedirect(response, body);
|
||||
} else if (response.headers.get('Content-Type')?.includes('text/html')) {
|
||||
body = await handleHtmlContent(
|
||||
response,
|
||||
url.protocol,
|
||||
url.host,
|
||||
actualUrlStr
|
||||
);
|
||||
}
|
||||
|
||||
// 创建修改后的响应对象
|
||||
const modifiedResponse = new Response(body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
|
||||
// 添加禁用缓存的头部
|
||||
setNoCacheHeaders(modifiedResponse.headers);
|
||||
|
||||
// 添加 CORS 头部,允许跨域访问
|
||||
setCorsHeaders(modifiedResponse.headers);
|
||||
|
||||
return modifiedResponse;
|
||||
} catch (error) {
|
||||
// 如果请求目标地址时出现错误,返回带有错误消息的响应和状态码 500(服务器错误)
|
||||
return jsonResponse(
|
||||
{
|
||||
error: error.message,
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 确保 URL 带有协议
|
||||
function ensureProtocol(url, defaultProtocol) {
|
||||
return url.startsWith('http://') || url.startsWith('https://')
|
||||
? url
|
||||
: defaultProtocol + '//' + url;
|
||||
}
|
||||
|
||||
// 处理重定向
|
||||
function handleRedirect(response, body) {
|
||||
const location = new URL(response.headers.get('location'));
|
||||
const modifiedLocation = `/${encodeURIComponent(location.toString())}`;
|
||||
return new Response(body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: {
|
||||
...response.headers,
|
||||
Location: modifiedLocation,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 处理 HTML 内容中的相对路径
|
||||
async function handleHtmlContent(response, protocol, host, actualUrlStr) {
|
||||
const originalText = await response.text();
|
||||
const regex = new RegExp('((href|src|action)=["\'])/(?!/)', 'g');
|
||||
let modifiedText = replaceRelativePaths(
|
||||
originalText,
|
||||
protocol,
|
||||
host,
|
||||
new URL(actualUrlStr).origin
|
||||
);
|
||||
|
||||
return modifiedText;
|
||||
}
|
||||
|
||||
// 替换 HTML 内容中的相对路径
|
||||
function replaceRelativePaths(text, protocol, host, origin) {
|
||||
const regex = new RegExp('((href|src|action)=["\'])/(?!/)', 'g');
|
||||
return text.replace(regex, `$1${protocol}//${host}/${origin}/`);
|
||||
}
|
||||
|
||||
// 返回 JSON 格式的响应
|
||||
function jsonResponse(data, status) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 过滤请求头
|
||||
function filterHeaders(headers, filterFunc) {
|
||||
return new Headers([...headers].filter(([name]) => filterFunc(name)));
|
||||
}
|
||||
|
||||
// 设置禁用缓存的头部
|
||||
function setNoCacheHeaders(headers) {
|
||||
headers.set('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
// 设置 CORS 头部
|
||||
function setCorsHeaders(headers) {
|
||||
headers.set('Access-Control-Allow-Origin', '*');
|
||||
headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
|
||||
headers.set('Access-Control-Allow-Headers', '*');
|
||||
}
|
||||
|
||||
// 返回根目录的 HTML
|
||||
function getRootHtml() {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css" rel="stylesheet">
|
||||
<title>Proxy Everything</title>
|
||||
<link rel="icon" type="image/png" href="https://img.icons8.com/color/1000/kawaii-bread-1.png">
|
||||
<meta name="Description" content="Proxy Everything with CF Workers.">
|
||||
<meta property="og:description" content="Proxy Everything with CF Workers.">
|
||||
<meta property="og:image" content="https://img.icons8.com/color/1000/kawaii-bread-1.png">
|
||||
<meta name="robots" content="index, follow">
|
||||
<meta http-equiv="Content-Language" content="zh-CN">
|
||||
<meta name="copyright" content="Copyright © ymyuuu">
|
||||
<meta name="author" content="ymyuuu">
|
||||
<link rel="apple-touch-icon-precomposed" sizes="120x120" href="https://img.icons8.com/color/1000/kawaii-bread-1.png">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
|
||||
<style>
|
||||
body, html {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
.background {
|
||||
background-image: url('https://imgapi.cn/bing.php');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.card {
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
transition: background-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
.card:hover {
|
||||
background-color: rgba(255, 255, 255, 1);
|
||||
box-shadow: 0px 8px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.input-field input[type=text] {
|
||||
color: #2c3e50;
|
||||
}
|
||||
.input-field input[type=text]:focus+label {
|
||||
color: #2c3e50 !important;
|
||||
}
|
||||
.input-field input[type=text]:focus {
|
||||
border-bottom: 1px solid #2c3e50 !important;
|
||||
box-shadow: 0 1px 0 0 #2c3e50 !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="background">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col s12 m8 offset-m2 l6 offset-l3">
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
<span class="card-title center-align"><i class="material-icons left">link</i>Proxy Everything</span>
|
||||
<form id="urlForm" onsubmit="redirectToProxy(event)">
|
||||
<div class="input-field">
|
||||
<input type="text" id="targetUrl" placeholder="在此输入目标地址" required>
|
||||
<label for="targetUrl">目标地址</label>
|
||||
</div>
|
||||
<button type="submit" class="btn waves-effect waves-light teal darken-2 full-width">跳转</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
|
||||
<script>
|
||||
function redirectToProxy(event) {
|
||||
event.preventDefault();
|
||||
const targetUrl = document.getElementById('targetUrl').value.trim();
|
||||
const currentOrigin = window.location.origin;
|
||||
window.open(currentOrigin + '/' + encodeURIComponent(targetUrl), '_blank');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 169 KiB |
@@ -0,0 +1,3 @@
|
||||
# 禁止所有搜索引擎爬取
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin / env node
|
||||
|
||||
/* eslint-disable */
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function parseChangelog(content) {
|
||||
const lines = content.split('\n');
|
||||
const versions = [];
|
||||
let currentVersion = null;
|
||||
let currentSection = null;
|
||||
let inVersionContent = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// 匹配版本行: ## [X.Y.Z] - YYYY-MM-DD
|
||||
const versionMatch = trimmedLine.match(
|
||||
/^## \[([\d.]+)\] - (\d{4}-\d{2}-\d{2})$/
|
||||
);
|
||||
if (versionMatch) {
|
||||
if (currentVersion) {
|
||||
versions.push(currentVersion);
|
||||
}
|
||||
|
||||
currentVersion = {
|
||||
version: versionMatch[1],
|
||||
date: versionMatch[2],
|
||||
added: [],
|
||||
changed: [],
|
||||
fixed: [],
|
||||
content: [], // 用于存储原始内容,当没有分类时使用
|
||||
};
|
||||
currentSection = null;
|
||||
inVersionContent = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果遇到下一个版本或到达文件末尾,停止处理当前版本
|
||||
if (inVersionContent && currentVersion) {
|
||||
// 匹配章节标题
|
||||
if (trimmedLine === '### Added') {
|
||||
currentSection = 'added';
|
||||
continue;
|
||||
} else if (trimmedLine === '### Changed') {
|
||||
currentSection = 'changed';
|
||||
continue;
|
||||
} else if (trimmedLine === '### Fixed') {
|
||||
currentSection = 'fixed';
|
||||
continue;
|
||||
}
|
||||
|
||||
// 匹配条目: - 内容
|
||||
if (trimmedLine.startsWith('- ') && currentSection) {
|
||||
const entry = trimmedLine.substring(2);
|
||||
currentVersion[currentSection].push(entry);
|
||||
} else if (
|
||||
trimmedLine &&
|
||||
!trimmedLine.startsWith('#') &&
|
||||
!trimmedLine.startsWith('###')
|
||||
) {
|
||||
currentVersion.content.push(trimmedLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加最后一个版本
|
||||
if (currentVersion) {
|
||||
versions.push(currentVersion);
|
||||
}
|
||||
|
||||
// 后处理:如果某个版本没有分类内容,但有 content,则将 content 放到 changed 中
|
||||
versions.forEach((version) => {
|
||||
const hasCategories =
|
||||
version.added.length > 0 ||
|
||||
version.changed.length > 0 ||
|
||||
version.fixed.length > 0;
|
||||
if (!hasCategories && version.content.length > 0) {
|
||||
version.changed = version.content;
|
||||
}
|
||||
// 清理 content 字段
|
||||
delete version.content;
|
||||
});
|
||||
|
||||
return { versions };
|
||||
}
|
||||
|
||||
function generateTypeScript(changelogData) {
|
||||
const entries = changelogData.versions
|
||||
.map((version) => {
|
||||
const addedEntries = version.added
|
||||
.map((entry) => ` "${entry}"`)
|
||||
.join(',\n');
|
||||
const changedEntries = version.changed
|
||||
.map((entry) => ` "${entry}"`)
|
||||
.join(',\n');
|
||||
const fixedEntries = version.fixed
|
||||
.map((entry) => ` "${entry}"`)
|
||||
.join(',\n');
|
||||
|
||||
return ` {
|
||||
version: "${version.version}",
|
||||
date: "${version.date}",
|
||||
added: [
|
||||
${addedEntries || ' // 无新增内容'}
|
||||
],
|
||||
changed: [
|
||||
${changedEntries || ' // 无变更内容'}
|
||||
],
|
||||
fixed: [
|
||||
${fixedEntries || ' // 无修复内容'}
|
||||
]
|
||||
}`;
|
||||
})
|
||||
.join(',\n');
|
||||
|
||||
return `// 此文件由 scripts/convert-changelog.js 自动生成
|
||||
// 请勿手动编辑
|
||||
|
||||
export interface ChangelogEntry {
|
||||
version: string;
|
||||
date: string;
|
||||
added: string[];
|
||||
changed: string[];
|
||||
fixed: string[];
|
||||
}
|
||||
|
||||
export const changelog: ChangelogEntry[] = [
|
||||
${entries}
|
||||
];
|
||||
|
||||
export default changelog;
|
||||
`;
|
||||
}
|
||||
|
||||
function updateVersionFile(version) {
|
||||
const versionTxtPath = path.join(process.cwd(), 'VERSION.txt');
|
||||
try {
|
||||
fs.writeFileSync(versionTxtPath, version, 'utf8');
|
||||
console.log(`✅ 已更新 VERSION.txt: ${version}`);
|
||||
} catch (error) {
|
||||
console.error(`❌ 无法更新 VERSION.txt:`, error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function updateVersionTs(version) {
|
||||
const versionTsPath = path.join(process.cwd(), 'src/lib/version.ts');
|
||||
try {
|
||||
let content = fs.readFileSync(versionTsPath, 'utf8');
|
||||
|
||||
// 替换 CURRENT_VERSION 常量
|
||||
const updatedContent = content.replace(
|
||||
/const CURRENT_VERSION = ['"`][^'"`]+['"`];/,
|
||||
`const CURRENT_VERSION = '${version}';`
|
||||
);
|
||||
|
||||
fs.writeFileSync(versionTsPath, updatedContent, 'utf8');
|
||||
console.log(`✅ 已更新 version.ts: ${version}`);
|
||||
} catch (error) {
|
||||
console.error(`❌ 无法更新 version.ts:`, error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
try {
|
||||
const changelogPath = path.join(process.cwd(), 'CHANGELOG');
|
||||
const outputPath = path.join(process.cwd(), 'src/lib/changelog.ts');
|
||||
|
||||
console.log('正在读取 CHANGELOG 文件...');
|
||||
const changelogContent = fs.readFileSync(changelogPath, 'utf-8');
|
||||
|
||||
console.log('正在解析 CHANGELOG 内容...');
|
||||
const changelogData = parseChangelog(changelogContent);
|
||||
|
||||
if (changelogData.versions.length === 0) {
|
||||
console.error('❌ 未在 CHANGELOG 中找到任何版本');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 获取最新版本号(CHANGELOG中的第一个版本)
|
||||
const latestVersion = changelogData.versions[0].version;
|
||||
console.log(`🔢 最新版本: ${latestVersion}`);
|
||||
|
||||
console.log('正在生成 TypeScript 文件...');
|
||||
const tsContent = generateTypeScript(changelogData);
|
||||
|
||||
// 确保输出目录存在
|
||||
const outputDir = path.dirname(outputPath);
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(outputPath, tsContent, 'utf-8');
|
||||
|
||||
// 检查是否在 GitHub Actions 环境中运行
|
||||
const isGitHubActions = process.env.GITHUB_ACTIONS === 'true';
|
||||
|
||||
if (isGitHubActions) {
|
||||
// 在 GitHub Actions 中,更新版本文件
|
||||
console.log('正在更新版本文件...');
|
||||
updateVersionFile(latestVersion);
|
||||
updateVersionTs(latestVersion);
|
||||
} else {
|
||||
// 在本地运行时,只提示但不更新版本文件
|
||||
console.log('🔧 本地运行模式:跳过版本文件更新');
|
||||
console.log('💡 版本文件更新将在 git tag 触发的 release 工作流中完成');
|
||||
}
|
||||
|
||||
console.log(`✅ 成功生成 ${outputPath}`);
|
||||
console.log(`📊 版本统计:`);
|
||||
changelogData.versions.forEach((version) => {
|
||||
console.log(
|
||||
` ${version.version} (${version.date}): +${version.added.length} ~${version.changed.length} !${version.fixed.length}`
|
||||
);
|
||||
});
|
||||
|
||||
console.log('\n🎉 转换完成!');
|
||||
} catch (error) {
|
||||
console.error('❌ 转换失败:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env node
|
||||
/* eslint-disable */
|
||||
// 根据 NEXT_PUBLIC_SITE_NAME 动态生成 manifest.json
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// 获取项目根目录
|
||||
const projectRoot = path.resolve(__dirname, '..');
|
||||
const publicDir = path.join(projectRoot, 'public');
|
||||
const manifestPath = path.join(publicDir, 'manifest.json');
|
||||
|
||||
// 从环境变量获取站点名称
|
||||
const siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV';
|
||||
|
||||
// manifest.json 模板
|
||||
const manifestTemplate = {
|
||||
name: siteName,
|
||||
short_name: siteName,
|
||||
description: '影视聚合',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#000000',
|
||||
'apple-mobile-web-app-capable': 'yes',
|
||||
'apple-mobile-web-app-status-bar-style': 'black',
|
||||
icons: [
|
||||
{
|
||||
src: '/icons/icon-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/icons/icon-256x256.png',
|
||||
sizes: '256x256',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/icons/icon-384x384.png',
|
||||
sizes: '384x384',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/icons/icon-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
try {
|
||||
// 确保 public 目录存在
|
||||
if (!fs.existsSync(publicDir)) {
|
||||
fs.mkdirSync(publicDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 写入 manifest.json
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifestTemplate, null, 2));
|
||||
console.log(`✅ Generated manifest.json with site name: ${siteName}`);
|
||||
} catch (error) {
|
||||
console.error('❌ Error generating manifest.json:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,197 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
// 支持的操作类型
|
||||
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort';
|
||||
|
||||
interface BaseBody {
|
||||
action?: Action;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
if (storageType === 'localstorage') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '不支持本地存储进行管理员配置',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = (await request.json()) as BaseBody & Record<string, any>;
|
||||
const { action } = body;
|
||||
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const username = authInfo.username;
|
||||
|
||||
// 基础校验
|
||||
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort'];
|
||||
if (!username || !action || !ACTIONS.includes(action)) {
|
||||
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 获取配置与存储
|
||||
const adminConfig = await getConfig();
|
||||
|
||||
// 权限与身份校验
|
||||
if (username !== process.env.USERNAME) {
|
||||
const userEntry = adminConfig.UserConfig.Users.find(
|
||||
(u) => u.username === username
|
||||
);
|
||||
if (!userEntry || userEntry.role !== 'admin' || userEntry.banned) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'add': {
|
||||
const { name, type, query } = body as {
|
||||
name?: string;
|
||||
type?: 'movie' | 'tv';
|
||||
query?: string;
|
||||
};
|
||||
if (!name || !type || !query) {
|
||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||
}
|
||||
// 检查是否已存在相同的查询和类型组合
|
||||
if (
|
||||
adminConfig.CustomCategories.some(
|
||||
(c) => c.query === query && c.type === type
|
||||
)
|
||||
) {
|
||||
return NextResponse.json({ error: '该分类已存在' }, { status: 400 });
|
||||
}
|
||||
adminConfig.CustomCategories.push({
|
||||
name,
|
||||
type,
|
||||
query,
|
||||
from: 'custom',
|
||||
disabled: false,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'disable': {
|
||||
const { query, type } = body as {
|
||||
query?: string;
|
||||
type?: 'movie' | 'tv';
|
||||
};
|
||||
if (!query || !type)
|
||||
return NextResponse.json(
|
||||
{ error: '缺少 query 或 type 参数' },
|
||||
{ status: 400 }
|
||||
);
|
||||
const entry = adminConfig.CustomCategories.find(
|
||||
(c) => c.query === query && c.type === type
|
||||
);
|
||||
if (!entry)
|
||||
return NextResponse.json({ error: '分类不存在' }, { status: 404 });
|
||||
entry.disabled = true;
|
||||
break;
|
||||
}
|
||||
case 'enable': {
|
||||
const { query, type } = body as {
|
||||
query?: string;
|
||||
type?: 'movie' | 'tv';
|
||||
};
|
||||
if (!query || !type)
|
||||
return NextResponse.json(
|
||||
{ error: '缺少 query 或 type 参数' },
|
||||
{ status: 400 }
|
||||
);
|
||||
const entry = adminConfig.CustomCategories.find(
|
||||
(c) => c.query === query && c.type === type
|
||||
);
|
||||
if (!entry)
|
||||
return NextResponse.json({ error: '分类不存在' }, { status: 404 });
|
||||
entry.disabled = false;
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
const { query, type } = body as {
|
||||
query?: string;
|
||||
type?: 'movie' | 'tv';
|
||||
};
|
||||
if (!query || !type)
|
||||
return NextResponse.json(
|
||||
{ error: '缺少 query 或 type 参数' },
|
||||
{ status: 400 }
|
||||
);
|
||||
const idx = adminConfig.CustomCategories.findIndex(
|
||||
(c) => c.query === query && c.type === type
|
||||
);
|
||||
if (idx === -1)
|
||||
return NextResponse.json({ error: '分类不存在' }, { status: 404 });
|
||||
const entry = adminConfig.CustomCategories[idx];
|
||||
if (entry.from === 'config') {
|
||||
return NextResponse.json(
|
||||
{ error: '该分类不可删除' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
adminConfig.CustomCategories.splice(idx, 1);
|
||||
break;
|
||||
}
|
||||
case 'sort': {
|
||||
const { order } = body as { order?: string[] };
|
||||
if (!Array.isArray(order)) {
|
||||
return NextResponse.json(
|
||||
{ error: '排序列表格式错误' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const map = new Map(
|
||||
adminConfig.CustomCategories.map((c) => [`${c.query}:${c.type}`, c])
|
||||
);
|
||||
const newList: typeof adminConfig.CustomCategories = [];
|
||||
order.forEach((key) => {
|
||||
const item = map.get(key);
|
||||
if (item) {
|
||||
newList.push(item);
|
||||
map.delete(key);
|
||||
}
|
||||
});
|
||||
// 未在 order 中的保持原顺序
|
||||
adminConfig.CustomCategories.forEach((item) => {
|
||||
if (map.has(`${item.query}:${item.type}`)) newList.push(item);
|
||||
});
|
||||
adminConfig.CustomCategories = newList;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 持久化到存储
|
||||
await db.saveAdminConfig(adminConfig);
|
||||
|
||||
return NextResponse.json(
|
||||
{ ok: true },
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('分类管理操作失败:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '分类管理操作失败',
|
||||
details: (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { AdminConfigResult } from '@/lib/admin.types';
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
if (storageType === 'localstorage') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '不支持本地存储进行管理员配置',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const username = authInfo.username;
|
||||
|
||||
try {
|
||||
const config = await getConfig();
|
||||
const result: AdminConfigResult = {
|
||||
Role: 'owner',
|
||||
Config: config,
|
||||
};
|
||||
if (username === process.env.USERNAME) {
|
||||
result.Role = 'owner';
|
||||
} else {
|
||||
const user = config.UserConfig.Users.find((u) => u.username === username);
|
||||
if (user && user.role === 'admin' && !user.banned) {
|
||||
result.Role = 'admin';
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: '你是管理员吗你就访问?' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(result, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-store', // 管理员配置不缓存
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取管理员配置失败:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '获取管理员配置失败',
|
||||
details: (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig, refineConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
if (storageType === 'localstorage') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '不支持本地存储进行管理员配置',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const username = authInfo.username;
|
||||
|
||||
try {
|
||||
// 检查用户权限
|
||||
let adminConfig = await getConfig();
|
||||
|
||||
// 仅站长可以修改配置文件
|
||||
if (username !== process.env.USERNAME) {
|
||||
return NextResponse.json(
|
||||
{ error: '权限不足,只有站长可以修改配置文件' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取请求体
|
||||
const body = await request.json();
|
||||
const { configFile, subscriptionUrl, autoUpdate, lastCheckTime } = body;
|
||||
|
||||
if (!configFile || typeof configFile !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: '配置文件内容不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证 JSON 格式
|
||||
try {
|
||||
JSON.parse(configFile);
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: '配置文件格式错误,请检查 JSON 语法' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
adminConfig.ConfigFile = configFile;
|
||||
if (!adminConfig.ConfigSubscribtion) {
|
||||
adminConfig.ConfigSubscribtion = {
|
||||
URL: '',
|
||||
AutoUpdate: false,
|
||||
LastCheck: '',
|
||||
};
|
||||
}
|
||||
|
||||
// 更新订阅配置
|
||||
if (subscriptionUrl !== undefined) {
|
||||
adminConfig.ConfigSubscribtion.URL = subscriptionUrl;
|
||||
}
|
||||
if (autoUpdate !== undefined) {
|
||||
adminConfig.ConfigSubscribtion.AutoUpdate = autoUpdate;
|
||||
}
|
||||
adminConfig.ConfigSubscribtion.LastCheck = lastCheckTime || '';
|
||||
|
||||
adminConfig = refineConfig(adminConfig);
|
||||
// 更新配置文件
|
||||
await db.saveAdminConfig(adminConfig);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '配置文件更新成功',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('更新配置文件失败:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '更新配置文件失败',
|
||||
details: (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// 权限检查:仅站长可以拉取配置订阅
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (authInfo.username !== process.env.USERNAME) {
|
||||
return NextResponse.json(
|
||||
{ error: '权限不足,只有站长可以拉取配置订阅' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { url } = await request.json();
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: '缺少URL参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 直接 fetch URL 获取配置内容
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `请求失败: ${response.status} ${response.statusText}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const configContent = await response.text();
|
||||
|
||||
// 对 configContent 进行 base58 解码
|
||||
let decodedContent;
|
||||
try {
|
||||
const bs58 = (await import('bs58')).default;
|
||||
const decodedBytes = bs58.decode(configContent);
|
||||
decodedContent = new TextDecoder().decode(decodedBytes);
|
||||
} catch (decodeError) {
|
||||
console.warn('Base58 解码失败', decodeError);
|
||||
throw decodeError;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
configContent: decodedContent,
|
||||
message: '配置拉取成功'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('拉取配置失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '拉取配置失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { promisify } from 'util';
|
||||
import { gzip } from 'zlib';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { SimpleCrypto } from '@/lib/crypto';
|
||||
import { db } from '@/lib/db';
|
||||
import { CURRENT_VERSION } from '@/lib/version';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const gzipAsync = promisify(gzip);
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// 检查存储类型
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
if (storageType === 'localstorage') {
|
||||
return NextResponse.json(
|
||||
{ error: '不支持本地存储进行数据迁移' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证身份和权限
|
||||
const authInfo = getAuthInfoFromCookie(req);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 检查用户权限(只有站长可以导出数据)
|
||||
if (authInfo.username !== process.env.USERNAME) {
|
||||
return NextResponse.json({ error: '权限不足,只有站长可以导出数据' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await db.getAdminConfig();
|
||||
if (!config) {
|
||||
return NextResponse.json({ error: '无法获取配置' }, { status: 500 });
|
||||
}
|
||||
|
||||
// 解析请求体获取密码
|
||||
const { password } = await req.json();
|
||||
if (!password || typeof password !== 'string') {
|
||||
return NextResponse.json({ error: '请提供加密密码' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 收集所有数据
|
||||
const exportData = {
|
||||
timestamp: new Date().toISOString(),
|
||||
serverVersion: CURRENT_VERSION,
|
||||
data: {
|
||||
// 管理员配置
|
||||
adminConfig: config,
|
||||
// 所有用户数据
|
||||
userData: {} as { [username: string]: any }
|
||||
}
|
||||
};
|
||||
|
||||
// 获取所有用户
|
||||
let allUsers = await db.getAllUsers();
|
||||
// 添加站长用户
|
||||
allUsers.push(process.env.USERNAME);
|
||||
allUsers = Array.from(new Set(allUsers));
|
||||
|
||||
// 为每个用户收集数据
|
||||
for (const username of allUsers) {
|
||||
const userData = {
|
||||
// 播放记录
|
||||
playRecords: await db.getAllPlayRecords(username),
|
||||
// 收藏夹
|
||||
favorites: await db.getAllFavorites(username),
|
||||
// 搜索历史
|
||||
searchHistory: await db.getSearchHistory(username),
|
||||
// 跳过片头片尾配置
|
||||
skipConfigs: await db.getAllSkipConfigs(username),
|
||||
// 用户密码(通过验证空密码来检查用户是否存在,然后获取密码)
|
||||
password: await getUserPassword(username)
|
||||
};
|
||||
|
||||
exportData.data.userData[username] = userData;
|
||||
}
|
||||
|
||||
// 覆盖站长密码
|
||||
exportData.data.userData[process.env.USERNAME].password = process.env.PASSWORD;
|
||||
|
||||
// 将数据转换为JSON字符串
|
||||
const jsonData = JSON.stringify(exportData);
|
||||
|
||||
// 先压缩数据
|
||||
const compressedData = await gzipAsync(jsonData);
|
||||
|
||||
// 使用提供的密码加密压缩后的数据
|
||||
const encryptedData = SimpleCrypto.encrypt(compressedData.toString('base64'), password);
|
||||
|
||||
// 生成文件名
|
||||
const now = new Date();
|
||||
const timestamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
|
||||
const filename = `moontv-backup-${timestamp}.dat`;
|
||||
|
||||
// 返回加密的数据作为文件下载
|
||||
return new NextResponse(encryptedData, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'Content-Length': encryptedData.length.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('数据导出失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : '导出失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数:获取用户密码(通过数据库直接访问)
|
||||
async function getUserPassword(username: string): Promise<string | null> {
|
||||
try {
|
||||
// 使用 Redis 存储的直接访问方法
|
||||
const storage = (db as any).storage;
|
||||
if (storage && typeof storage.client?.get === 'function') {
|
||||
const passwordKey = `u:${username}:pwd`;
|
||||
const password = await storage.client.get(passwordKey);
|
||||
return password;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`获取用户 ${username} 密码失败:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { promisify } from 'util';
|
||||
import { gunzip } from 'zlib';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { configSelfCheck, setCachedConfig } from '@/lib/config';
|
||||
import { SimpleCrypto } from '@/lib/crypto';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const gunzipAsync = promisify(gunzip);
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// 检查存储类型
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
if (storageType === 'localstorage') {
|
||||
return NextResponse.json(
|
||||
{ error: '不支持本地存储进行数据迁移' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证身份和权限
|
||||
const authInfo = getAuthInfoFromCookie(req);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 检查用户权限(只有站长可以导入数据)
|
||||
if (authInfo.username !== process.env.USERNAME) {
|
||||
return NextResponse.json({ error: '权限不足,只有站长可以导入数据' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 解析表单数据
|
||||
const formData = await req.formData();
|
||||
const file = formData.get('file') as File;
|
||||
const password = formData.get('password') as string;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: '请选择备份文件' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
return NextResponse.json({ error: '请提供解密密码' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 读取文件内容
|
||||
const encryptedData = await file.text();
|
||||
|
||||
// 解密数据
|
||||
let decryptedData: string;
|
||||
try {
|
||||
decryptedData = SimpleCrypto.decrypt(encryptedData, password);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: '解密失败,请检查密码是否正确' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 解压缩数据
|
||||
const compressedBuffer = Buffer.from(decryptedData, 'base64');
|
||||
const decompressedBuffer = await gunzipAsync(compressedBuffer);
|
||||
const decompressedData = decompressedBuffer.toString();
|
||||
|
||||
// 解析JSON数据
|
||||
let importData: any;
|
||||
try {
|
||||
importData = JSON.parse(decompressedData);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: '备份文件格式错误' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 验证数据格式
|
||||
if (!importData.data || !importData.data.adminConfig || !importData.data.userData) {
|
||||
return NextResponse.json({ error: '备份文件格式无效' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 开始导入数据 - 先清空现有数据
|
||||
await db.clearAllData();
|
||||
|
||||
// 导入管理员配置
|
||||
importData.data.adminConfig = configSelfCheck(importData.data.adminConfig);
|
||||
await db.saveAdminConfig(importData.data.adminConfig);
|
||||
await setCachedConfig(importData.data.adminConfig);
|
||||
|
||||
// 导入用户数据
|
||||
const userData = importData.data.userData;
|
||||
for (const username in userData) {
|
||||
const user = userData[username];
|
||||
|
||||
// 重新注册用户(包含密码)
|
||||
if (user.password) {
|
||||
await db.registerUser(username, user.password);
|
||||
}
|
||||
|
||||
// 导入播放记录
|
||||
if (user.playRecords) {
|
||||
for (const [key, record] of Object.entries(user.playRecords)) {
|
||||
await (db as any).storage.setPlayRecord(username, key, record);
|
||||
}
|
||||
}
|
||||
|
||||
// 导入收藏夹
|
||||
if (user.favorites) {
|
||||
for (const [key, favorite] of Object.entries(user.favorites)) {
|
||||
await (db as any).storage.setFavorite(username, key, favorite);
|
||||
}
|
||||
}
|
||||
|
||||
// 导入搜索历史
|
||||
if (user.searchHistory && Array.isArray(user.searchHistory)) {
|
||||
for (const keyword of user.searchHistory.reverse()) { // 反转以保持顺序
|
||||
await db.addSearchHistory(username, keyword);
|
||||
}
|
||||
}
|
||||
|
||||
// 导入跳过片头片尾配置
|
||||
if (user.skipConfigs) {
|
||||
for (const [key, skipConfig] of Object.entries(user.skipConfigs)) {
|
||||
const [source, id] = key.split('+');
|
||||
if (source && id) {
|
||||
await db.setSkipConfig(username, source, id, skipConfig as any);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: '数据导入成功',
|
||||
importedUsers: Object.keys(userData).length,
|
||||
timestamp: importData.timestamp,
|
||||
serverVersion: typeof importData.serverVersion === 'string' ? importData.serverVersion : '未知版本'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('数据导入失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : '导入失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import { refreshLiveChannels } from '@/lib/live';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// 权限检查
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
const username = authInfo?.username;
|
||||
const config = await getConfig();
|
||||
if (username !== process.env.USERNAME) {
|
||||
// 管理员
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === username
|
||||
);
|
||||
if (!user || user.role !== 'admin' || user.banned) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
// 并发刷新所有启用的直播源
|
||||
const refreshPromises = (config.LiveConfig || [])
|
||||
.filter(liveInfo => !liveInfo.disabled)
|
||||
.map(async (liveInfo) => {
|
||||
try {
|
||||
const nums = await refreshLiveChannels(liveInfo);
|
||||
liveInfo.channelNumber = nums;
|
||||
} catch (error) {
|
||||
liveInfo.channelNumber = 0;
|
||||
}
|
||||
});
|
||||
|
||||
// 等待所有刷新任务完成
|
||||
await Promise.all(refreshPromises);
|
||||
|
||||
// 保存配置
|
||||
await db.saveAdminConfig(config);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '直播源刷新成功',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('直播源刷新失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : '刷新失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/* eslint-disable no-console,no-case-declarations */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import { deleteCachedLiveChannels, refreshLiveChannels } from '@/lib/live';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// 权限检查
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
const username = authInfo?.username;
|
||||
const config = await getConfig();
|
||||
if (username !== process.env.USERNAME) {
|
||||
// 管理员
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === username
|
||||
);
|
||||
if (!user || user.role !== 'admin' || user.banned) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { action, key, name, url, ua, epg } = body;
|
||||
|
||||
if (!config) {
|
||||
return NextResponse.json({ error: '配置不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 确保 LiveConfig 存在
|
||||
if (!config.LiveConfig) {
|
||||
config.LiveConfig = [];
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'add':
|
||||
// 检查是否已存在相同的 key
|
||||
if (config.LiveConfig.some((l) => l.key === key)) {
|
||||
return NextResponse.json({ error: '直播源 key 已存在' }, { status: 400 });
|
||||
}
|
||||
|
||||
const liveInfo = {
|
||||
key: key as string,
|
||||
name: name as string,
|
||||
url: url as string,
|
||||
ua: ua || '',
|
||||
epg: epg || '',
|
||||
from: 'custom' as 'custom' | 'config',
|
||||
channelNumber: 0,
|
||||
disabled: false,
|
||||
}
|
||||
|
||||
try {
|
||||
const nums = await refreshLiveChannels(liveInfo);
|
||||
liveInfo.channelNumber = nums;
|
||||
} catch (error) {
|
||||
console.error('刷新直播源失败:', error);
|
||||
liveInfo.channelNumber = 0;
|
||||
}
|
||||
|
||||
// 添加新的直播源
|
||||
config.LiveConfig.push(liveInfo);
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
// 删除直播源
|
||||
const deleteIndex = config.LiveConfig.findIndex((l) => l.key === key);
|
||||
if (deleteIndex === -1) {
|
||||
return NextResponse.json({ error: '直播源不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
const liveSource = config.LiveConfig[deleteIndex];
|
||||
if (liveSource.from === 'config') {
|
||||
return NextResponse.json({ error: '不能删除配置文件中的直播源' }, { status: 400 });
|
||||
}
|
||||
|
||||
deleteCachedLiveChannels(key);
|
||||
|
||||
config.LiveConfig.splice(deleteIndex, 1);
|
||||
break;
|
||||
|
||||
case 'enable':
|
||||
// 启用直播源
|
||||
const enableSource = config.LiveConfig.find((l) => l.key === key);
|
||||
if (!enableSource) {
|
||||
return NextResponse.json({ error: '直播源不存在' }, { status: 404 });
|
||||
}
|
||||
enableSource.disabled = false;
|
||||
break;
|
||||
|
||||
case 'disable':
|
||||
// 禁用直播源
|
||||
const disableSource = config.LiveConfig.find((l) => l.key === key);
|
||||
if (!disableSource) {
|
||||
return NextResponse.json({ error: '直播源不存在' }, { status: 404 });
|
||||
}
|
||||
disableSource.disabled = true;
|
||||
break;
|
||||
|
||||
case 'edit':
|
||||
// 编辑直播源
|
||||
const editSource = config.LiveConfig.find((l) => l.key === key);
|
||||
if (!editSource) {
|
||||
return NextResponse.json({ error: '直播源不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 配置文件中的直播源不允许编辑
|
||||
if (editSource.from === 'config') {
|
||||
return NextResponse.json({ error: '不能编辑配置文件中的直播源' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 更新字段(除了 key 和 from)
|
||||
editSource.name = name as string;
|
||||
editSource.url = url as string;
|
||||
editSource.ua = ua || '';
|
||||
editSource.epg = epg || '';
|
||||
|
||||
// 刷新频道数
|
||||
try {
|
||||
const nums = await refreshLiveChannels(editSource);
|
||||
editSource.channelNumber = nums;
|
||||
} catch (error) {
|
||||
console.error('刷新直播源失败:', error);
|
||||
editSource.channelNumber = 0;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'sort':
|
||||
// 排序直播源
|
||||
const { order } = body;
|
||||
if (!Array.isArray(order)) {
|
||||
return NextResponse.json({ error: '排序数据格式错误' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 创建新的排序后的数组
|
||||
const sortedLiveConfig: typeof config.LiveConfig = [];
|
||||
order.forEach((key) => {
|
||||
const source = config.LiveConfig?.find((l) => l.key === key);
|
||||
if (source) {
|
||||
sortedLiveConfig.push(source);
|
||||
}
|
||||
});
|
||||
|
||||
// 添加未在排序列表中的直播源(保持原有顺序)
|
||||
config.LiveConfig.forEach((source) => {
|
||||
if (!order.includes(source.key)) {
|
||||
sortedLiveConfig.push(source);
|
||||
}
|
||||
});
|
||||
|
||||
config.LiveConfig = sortedLiveConfig;
|
||||
break;
|
||||
|
||||
default:
|
||||
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
await db.saveAdminConfig(config);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : '操作失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { resetConfig } from '@/lib/config';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
if (storageType === 'localstorage') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '不支持本地存储进行管理员配置',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const username = authInfo.username;
|
||||
|
||||
if (username !== process.env.USERNAME) {
|
||||
return NextResponse.json({ error: '仅支持站长重置配置' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
await resetConfig();
|
||||
|
||||
return NextResponse.json(
|
||||
{ ok: true },
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'no-store', // 管理员配置不缓存
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '重置管理员配置失败',
|
||||
details: (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
if (storageType === 'localstorage') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '不支持本地存储进行管理员配置',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const username = authInfo.username;
|
||||
|
||||
const {
|
||||
SiteName,
|
||||
Announcement,
|
||||
SearchDownstreamMaxPage,
|
||||
SiteInterfaceCacheTime,
|
||||
DoubanProxyType,
|
||||
DoubanProxy,
|
||||
DoubanImageProxyType,
|
||||
DoubanImageProxy,
|
||||
DisableYellowFilter,
|
||||
FluidSearch,
|
||||
} = body as {
|
||||
SiteName: string;
|
||||
Announcement: string;
|
||||
SearchDownstreamMaxPage: number;
|
||||
SiteInterfaceCacheTime: number;
|
||||
DoubanProxyType: string;
|
||||
DoubanProxy: string;
|
||||
DoubanImageProxyType: string;
|
||||
DoubanImageProxy: string;
|
||||
DisableYellowFilter: boolean;
|
||||
FluidSearch: boolean;
|
||||
};
|
||||
|
||||
// 参数校验
|
||||
if (
|
||||
typeof SiteName !== 'string' ||
|
||||
typeof Announcement !== 'string' ||
|
||||
typeof SearchDownstreamMaxPage !== 'number' ||
|
||||
typeof SiteInterfaceCacheTime !== 'number' ||
|
||||
typeof DoubanProxyType !== 'string' ||
|
||||
typeof DoubanProxy !== 'string' ||
|
||||
typeof DoubanImageProxyType !== 'string' ||
|
||||
typeof DoubanImageProxy !== 'string' ||
|
||||
typeof DisableYellowFilter !== 'boolean' ||
|
||||
typeof FluidSearch !== 'boolean'
|
||||
) {
|
||||
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||
}
|
||||
|
||||
const adminConfig = await getConfig();
|
||||
|
||||
// 权限校验
|
||||
if (username !== process.env.USERNAME) {
|
||||
// 管理员
|
||||
const user = adminConfig.UserConfig.Users.find(
|
||||
(u) => u.username === username
|
||||
);
|
||||
if (!user || user.role !== 'admin' || user.banned) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
// 更新缓存中的站点设置
|
||||
adminConfig.SiteConfig = {
|
||||
SiteName,
|
||||
Announcement,
|
||||
SearchDownstreamMaxPage,
|
||||
SiteInterfaceCacheTime,
|
||||
DoubanProxyType,
|
||||
DoubanProxy,
|
||||
DoubanImageProxyType,
|
||||
DoubanImageProxy,
|
||||
DisableYellowFilter,
|
||||
FluidSearch,
|
||||
};
|
||||
|
||||
// 写入数据库
|
||||
await db.saveAdminConfig(adminConfig);
|
||||
|
||||
return NextResponse.json(
|
||||
{ ok: true },
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'no-store', // 不缓存结果
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('更新站点配置失败:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '更新站点配置失败',
|
||||
details: (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
// 支持的操作类型
|
||||
type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort' | 'batch_disable' | 'batch_enable' | 'batch_delete';
|
||||
|
||||
interface BaseBody {
|
||||
action?: Action;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
if (storageType === 'localstorage') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '不支持本地存储进行管理员配置',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = (await request.json()) as BaseBody & Record<string, any>;
|
||||
const { action } = body;
|
||||
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const username = authInfo.username;
|
||||
|
||||
// 基础校验
|
||||
const ACTIONS: Action[] = ['add', 'disable', 'enable', 'delete', 'sort', 'batch_disable', 'batch_enable', 'batch_delete'];
|
||||
if (!username || !action || !ACTIONS.includes(action)) {
|
||||
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 获取配置与存储
|
||||
const adminConfig = await getConfig();
|
||||
|
||||
// 权限与身份校验
|
||||
if (username !== process.env.USERNAME) {
|
||||
const userEntry = adminConfig.UserConfig.Users.find(
|
||||
(u) => u.username === username
|
||||
);
|
||||
if (!userEntry || userEntry.role !== 'admin' || userEntry.banned) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'add': {
|
||||
const { key, name, api, detail } = body as {
|
||||
key?: string;
|
||||
name?: string;
|
||||
api?: string;
|
||||
detail?: string;
|
||||
};
|
||||
if (!key || !name || !api) {
|
||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||
}
|
||||
if (adminConfig.SourceConfig.some((s) => s.key === key)) {
|
||||
return NextResponse.json({ error: '该源已存在' }, { status: 400 });
|
||||
}
|
||||
adminConfig.SourceConfig.push({
|
||||
key,
|
||||
name,
|
||||
api,
|
||||
detail,
|
||||
from: 'custom',
|
||||
disabled: false,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'disable': {
|
||||
const { key } = body as { key?: string };
|
||||
if (!key)
|
||||
return NextResponse.json({ error: '缺少 key 参数' }, { status: 400 });
|
||||
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
|
||||
if (!entry)
|
||||
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
||||
entry.disabled = true;
|
||||
break;
|
||||
}
|
||||
case 'enable': {
|
||||
const { key } = body as { key?: string };
|
||||
if (!key)
|
||||
return NextResponse.json({ error: '缺少 key 参数' }, { status: 400 });
|
||||
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
|
||||
if (!entry)
|
||||
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
||||
entry.disabled = false;
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
const { key } = body as { key?: string };
|
||||
if (!key)
|
||||
return NextResponse.json({ error: '缺少 key 参数' }, { status: 400 });
|
||||
const idx = adminConfig.SourceConfig.findIndex((s) => s.key === key);
|
||||
if (idx === -1)
|
||||
return NextResponse.json({ error: '源不存在' }, { status: 404 });
|
||||
const entry = adminConfig.SourceConfig[idx];
|
||||
if (entry.from === 'config') {
|
||||
return NextResponse.json({ error: '该源不可删除' }, { status: 400 });
|
||||
}
|
||||
adminConfig.SourceConfig.splice(idx, 1);
|
||||
|
||||
// 检查并清理用户组和用户的权限数组
|
||||
// 清理用户组权限
|
||||
if (adminConfig.UserConfig.Tags) {
|
||||
adminConfig.UserConfig.Tags.forEach(tag => {
|
||||
if (tag.enabledApis) {
|
||||
tag.enabledApis = tag.enabledApis.filter(api => api !== key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 清理用户权限
|
||||
adminConfig.UserConfig.Users.forEach(user => {
|
||||
if (user.enabledApis) {
|
||||
user.enabledApis = user.enabledApis.filter(api => api !== key);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'batch_disable': {
|
||||
const { keys } = body as { keys?: string[] };
|
||||
if (!Array.isArray(keys) || keys.length === 0) {
|
||||
return NextResponse.json({ error: '缺少 keys 参数或为空' }, { status: 400 });
|
||||
}
|
||||
keys.forEach(key => {
|
||||
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
|
||||
if (entry) {
|
||||
entry.disabled = true;
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'batch_enable': {
|
||||
const { keys } = body as { keys?: string[] };
|
||||
if (!Array.isArray(keys) || keys.length === 0) {
|
||||
return NextResponse.json({ error: '缺少 keys 参数或为空' }, { status: 400 });
|
||||
}
|
||||
keys.forEach(key => {
|
||||
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
|
||||
if (entry) {
|
||||
entry.disabled = false;
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'batch_delete': {
|
||||
const { keys } = body as { keys?: string[] };
|
||||
if (!Array.isArray(keys) || keys.length === 0) {
|
||||
return NextResponse.json({ error: '缺少 keys 参数或为空' }, { status: 400 });
|
||||
}
|
||||
// 过滤掉 from=config 的源,但不报错
|
||||
const keysToDelete = keys.filter(key => {
|
||||
const entry = adminConfig.SourceConfig.find((s) => s.key === key);
|
||||
return entry && entry.from !== 'config';
|
||||
});
|
||||
|
||||
// 批量删除
|
||||
keysToDelete.forEach(key => {
|
||||
const idx = adminConfig.SourceConfig.findIndex((s) => s.key === key);
|
||||
if (idx !== -1) {
|
||||
adminConfig.SourceConfig.splice(idx, 1);
|
||||
}
|
||||
});
|
||||
|
||||
// 检查并清理用户组和用户的权限数组
|
||||
if (keysToDelete.length > 0) {
|
||||
// 清理用户组权限
|
||||
if (adminConfig.UserConfig.Tags) {
|
||||
adminConfig.UserConfig.Tags.forEach(tag => {
|
||||
if (tag.enabledApis) {
|
||||
tag.enabledApis = tag.enabledApis.filter(api => !keysToDelete.includes(api));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 清理用户权限
|
||||
adminConfig.UserConfig.Users.forEach(user => {
|
||||
if (user.enabledApis) {
|
||||
user.enabledApis = user.enabledApis.filter(api => !keysToDelete.includes(api));
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'sort': {
|
||||
const { order } = body as { order?: string[] };
|
||||
if (!Array.isArray(order)) {
|
||||
return NextResponse.json(
|
||||
{ error: '排序列表格式错误' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const map = new Map(adminConfig.SourceConfig.map((s) => [s.key, s]));
|
||||
const newList: typeof adminConfig.SourceConfig = [];
|
||||
order.forEach((k) => {
|
||||
const item = map.get(k);
|
||||
if (item) {
|
||||
newList.push(item);
|
||||
map.delete(k);
|
||||
}
|
||||
});
|
||||
// 未在 order 中的保持原顺序
|
||||
adminConfig.SourceConfig.forEach((item) => {
|
||||
if (map.has(item.key)) newList.push(item);
|
||||
});
|
||||
adminConfig.SourceConfig = newList;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 持久化到存储
|
||||
await db.saveAdminConfig(adminConfig);
|
||||
|
||||
return NextResponse.json(
|
||||
{ ok: true },
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('视频源管理操作失败:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '视频源管理操作失败',
|
||||
details: (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { API_CONFIG } from '@/lib/config';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const searchKeyword = searchParams.get('q');
|
||||
|
||||
if (!searchKeyword) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: '搜索关键词不能为空' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const apiSites = config.SourceConfig;
|
||||
|
||||
// 共享状态
|
||||
let streamClosed = false;
|
||||
|
||||
// 创建可读流
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
// 辅助函数:安全地向控制器写入数据
|
||||
const safeEnqueue = (data: Uint8Array) => {
|
||||
try {
|
||||
if (streamClosed || (!controller.desiredSize && controller.desiredSize !== 0)) {
|
||||
return false;
|
||||
}
|
||||
controller.enqueue(data);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Failed to enqueue data:', error);
|
||||
streamClosed = true;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 发送开始事件
|
||||
const startEvent = `data: ${JSON.stringify({
|
||||
type: 'start',
|
||||
totalSources: apiSites.length
|
||||
})}\n\n`;
|
||||
|
||||
if (!safeEnqueue(encoder.encode(startEvent))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 记录已完成的源数量
|
||||
let completedSources = 0;
|
||||
|
||||
// 为每个源创建验证 Promise
|
||||
const validationPromises = apiSites.map(async (site) => {
|
||||
try {
|
||||
// 构建搜索URL,只获取第一页
|
||||
const searchUrl = `${site.api}?ac=videolist&wd=${encodeURIComponent(searchKeyword)}`;
|
||||
|
||||
// 设置超时控制
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
try {
|
||||
const response = await fetch(searchUrl, {
|
||||
headers: API_CONFIG.search.headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as any;
|
||||
|
||||
// 检查结果是否有效
|
||||
let status: 'valid' | 'no_results' | 'invalid';
|
||||
if (
|
||||
data &&
|
||||
data.list &&
|
||||
Array.isArray(data.list) &&
|
||||
data.list.length > 0
|
||||
) {
|
||||
// 检查是否有标题包含搜索词的结果
|
||||
const validResults = data.list.filter((item: any) => {
|
||||
const title = item.vod_name || '';
|
||||
return title.toLowerCase().includes(searchKeyword.toLowerCase());
|
||||
});
|
||||
|
||||
if (validResults.length > 0) {
|
||||
status = 'valid';
|
||||
} else {
|
||||
status = 'no_results';
|
||||
}
|
||||
} else {
|
||||
status = 'no_results';
|
||||
}
|
||||
|
||||
// 发送该源的验证结果
|
||||
completedSources++;
|
||||
|
||||
if (!streamClosed) {
|
||||
const sourceEvent = `data: ${JSON.stringify({
|
||||
type: 'source_result',
|
||||
source: site.key,
|
||||
status
|
||||
})}\n\n`;
|
||||
|
||||
if (!safeEnqueue(encoder.encode(sourceEvent))) {
|
||||
streamClosed = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.warn(`验证失败 ${site.name}:`, error);
|
||||
|
||||
// 发送源错误事件
|
||||
completedSources++;
|
||||
|
||||
if (!streamClosed) {
|
||||
const errorEvent = `data: ${JSON.stringify({
|
||||
type: 'source_error',
|
||||
source: site.key,
|
||||
status: 'invalid'
|
||||
})}\n\n`;
|
||||
|
||||
if (!safeEnqueue(encoder.encode(errorEvent))) {
|
||||
streamClosed = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否所有源都已完成
|
||||
if (completedSources === apiSites.length) {
|
||||
if (!streamClosed) {
|
||||
// 发送最终完成事件
|
||||
const completeEvent = `data: ${JSON.stringify({
|
||||
type: 'complete',
|
||||
completedSources
|
||||
})}\n\n`;
|
||||
|
||||
if (safeEnqueue(encoder.encode(completeEvent))) {
|
||||
try {
|
||||
controller.close();
|
||||
} catch (error) {
|
||||
console.warn('Failed to close controller:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 等待所有验证完成
|
||||
await Promise.allSettled(validationPromises);
|
||||
},
|
||||
|
||||
cancel() {
|
||||
streamClosed = true;
|
||||
console.log('Client disconnected, cancelling validation stream');
|
||||
},
|
||||
});
|
||||
|
||||
// 返回流式响应
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console,@typescript-eslint/no-non-null-assertion */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
// 支持的操作类型
|
||||
const ACTIONS = [
|
||||
'add',
|
||||
'ban',
|
||||
'unban',
|
||||
'setAdmin',
|
||||
'cancelAdmin',
|
||||
'changePassword',
|
||||
'deleteUser',
|
||||
'updateUserApis',
|
||||
'userGroup',
|
||||
'updateUserGroups',
|
||||
'batchUpdateUserGroups',
|
||||
] as const;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
if (storageType === 'localstorage') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '不支持本地存储进行管理员配置',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const username = authInfo.username;
|
||||
|
||||
const {
|
||||
targetUsername, // 目标用户名
|
||||
targetPassword, // 目标用户密码(仅在添加用户时需要)
|
||||
action,
|
||||
} = body as {
|
||||
targetUsername?: string;
|
||||
targetPassword?: string;
|
||||
action?: (typeof ACTIONS)[number];
|
||||
};
|
||||
|
||||
if (!action || !ACTIONS.includes(action)) {
|
||||
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 用户组操作和批量操作不需要targetUsername
|
||||
if (!targetUsername && !['userGroup', 'batchUpdateUserGroups'].includes(action)) {
|
||||
return NextResponse.json({ error: '缺少目标用户名' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (
|
||||
action !== 'changePassword' &&
|
||||
action !== 'deleteUser' &&
|
||||
action !== 'updateUserApis' &&
|
||||
action !== 'userGroup' &&
|
||||
action !== 'updateUserGroups' &&
|
||||
action !== 'batchUpdateUserGroups' &&
|
||||
username === targetUsername
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: '无法对自己进行此操作' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取配置与存储
|
||||
const adminConfig = await getConfig();
|
||||
|
||||
// 判定操作者角色
|
||||
let operatorRole: 'owner' | 'admin';
|
||||
if (username === process.env.USERNAME) {
|
||||
operatorRole = 'owner';
|
||||
} else {
|
||||
const userEntry = adminConfig.UserConfig.Users.find(
|
||||
(u) => u.username === username
|
||||
);
|
||||
if (!userEntry || userEntry.role !== 'admin' || userEntry.banned) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||
}
|
||||
operatorRole = 'admin';
|
||||
}
|
||||
|
||||
// 查找目标用户条目(用户组操作和批量操作不需要)
|
||||
let targetEntry: any = null;
|
||||
let isTargetAdmin = false;
|
||||
|
||||
if (!['userGroup', 'batchUpdateUserGroups'].includes(action) && targetUsername) {
|
||||
targetEntry = adminConfig.UserConfig.Users.find(
|
||||
(u) => u.username === targetUsername
|
||||
);
|
||||
|
||||
if (
|
||||
targetEntry &&
|
||||
targetEntry.role === 'owner' &&
|
||||
!['changePassword', 'updateUserApis', 'updateUserGroups'].includes(action)
|
||||
) {
|
||||
return NextResponse.json({ error: '无法操作站长' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 权限校验逻辑
|
||||
isTargetAdmin = targetEntry?.role === 'admin';
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'add': {
|
||||
if (targetEntry) {
|
||||
return NextResponse.json({ error: '用户已存在' }, { status: 400 });
|
||||
}
|
||||
if (!targetPassword) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少目标用户密码' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
await db.registerUser(targetUsername!, targetPassword);
|
||||
|
||||
// 获取用户组信息
|
||||
const { userGroup } = body as { userGroup?: string };
|
||||
|
||||
// 更新配置
|
||||
const newUser: any = {
|
||||
username: targetUsername!,
|
||||
role: 'user',
|
||||
};
|
||||
|
||||
// 如果指定了用户组,添加到tags中
|
||||
if (userGroup && userGroup.trim()) {
|
||||
newUser.tags = [userGroup];
|
||||
}
|
||||
|
||||
adminConfig.UserConfig.Users.push(newUser);
|
||||
targetEntry =
|
||||
adminConfig.UserConfig.Users[
|
||||
adminConfig.UserConfig.Users.length - 1
|
||||
];
|
||||
break;
|
||||
}
|
||||
case 'ban': {
|
||||
if (!targetEntry) {
|
||||
return NextResponse.json(
|
||||
{ error: '目标用户不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
if (isTargetAdmin) {
|
||||
// 目标是管理员
|
||||
if (operatorRole !== 'owner') {
|
||||
return NextResponse.json(
|
||||
{ error: '仅站长可封禁管理员' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
targetEntry.banned = true;
|
||||
break;
|
||||
}
|
||||
case 'unban': {
|
||||
if (!targetEntry) {
|
||||
return NextResponse.json(
|
||||
{ error: '目标用户不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
if (isTargetAdmin) {
|
||||
if (operatorRole !== 'owner') {
|
||||
return NextResponse.json(
|
||||
{ error: '仅站长可操作管理员' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
targetEntry.banned = false;
|
||||
break;
|
||||
}
|
||||
case 'setAdmin': {
|
||||
if (!targetEntry) {
|
||||
return NextResponse.json(
|
||||
{ error: '目标用户不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
if (targetEntry.role === 'admin') {
|
||||
return NextResponse.json(
|
||||
{ error: '该用户已是管理员' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (operatorRole !== 'owner') {
|
||||
return NextResponse.json(
|
||||
{ error: '仅站长可设置管理员' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
targetEntry.role = 'admin';
|
||||
break;
|
||||
}
|
||||
case 'cancelAdmin': {
|
||||
if (!targetEntry) {
|
||||
return NextResponse.json(
|
||||
{ error: '目标用户不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
if (targetEntry.role !== 'admin') {
|
||||
return NextResponse.json(
|
||||
{ error: '目标用户不是管理员' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (operatorRole !== 'owner') {
|
||||
return NextResponse.json(
|
||||
{ error: '仅站长可取消管理员' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
targetEntry.role = 'user';
|
||||
break;
|
||||
}
|
||||
case 'changePassword': {
|
||||
if (!targetEntry) {
|
||||
return NextResponse.json(
|
||||
{ error: '目标用户不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
if (!targetPassword) {
|
||||
return NextResponse.json({ error: '缺少新密码' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 权限检查:不允许修改站长密码
|
||||
if (targetEntry.role === 'owner') {
|
||||
return NextResponse.json(
|
||||
{ error: '无法修改站长密码' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isTargetAdmin &&
|
||||
operatorRole !== 'owner' &&
|
||||
username !== targetUsername
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: '仅站长可修改其他管理员密码' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
await db.changePassword(targetUsername!, targetPassword);
|
||||
break;
|
||||
}
|
||||
case 'deleteUser': {
|
||||
if (!targetEntry) {
|
||||
return NextResponse.json(
|
||||
{ error: '目标用户不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// 权限检查:站长可删除所有用户(除了自己),管理员可删除普通用户
|
||||
if (username === targetUsername) {
|
||||
return NextResponse.json(
|
||||
{ error: '不能删除自己' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (isTargetAdmin && operatorRole !== 'owner') {
|
||||
return NextResponse.json(
|
||||
{ error: '仅站长可删除管理员' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
await db.deleteUser(targetUsername!);
|
||||
|
||||
// 从配置中移除用户
|
||||
const userIndex = adminConfig.UserConfig.Users.findIndex(
|
||||
(u) => u.username === targetUsername
|
||||
);
|
||||
if (userIndex > -1) {
|
||||
adminConfig.UserConfig.Users.splice(userIndex, 1);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case 'updateUserApis': {
|
||||
if (!targetEntry) {
|
||||
return NextResponse.json(
|
||||
{ error: '目标用户不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const { enabledApis } = body as { enabledApis?: string[] };
|
||||
|
||||
// 权限检查:站长可配置所有人的采集源,管理员可配置普通用户和自己的采集源
|
||||
if (
|
||||
isTargetAdmin &&
|
||||
operatorRole !== 'owner' &&
|
||||
username !== targetUsername
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: '仅站长可配置其他管理员的采集源' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// 更新用户的采集源权限
|
||||
if (enabledApis && enabledApis.length > 0) {
|
||||
targetEntry.enabledApis = enabledApis;
|
||||
} else {
|
||||
// 如果为空数组或未提供,则删除该字段,表示无限制
|
||||
delete targetEntry.enabledApis;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case 'userGroup': {
|
||||
// 用户组管理操作
|
||||
const { groupAction, groupName, enabledApis } = body as {
|
||||
groupAction: 'add' | 'edit' | 'delete';
|
||||
groupName: string;
|
||||
enabledApis?: string[];
|
||||
};
|
||||
|
||||
if (!adminConfig.UserConfig.Tags) {
|
||||
adminConfig.UserConfig.Tags = [];
|
||||
}
|
||||
|
||||
switch (groupAction) {
|
||||
case 'add': {
|
||||
// 检查用户组是否已存在
|
||||
if (adminConfig.UserConfig.Tags.find(t => t.name === groupName)) {
|
||||
return NextResponse.json({ error: '用户组已存在' }, { status: 400 });
|
||||
}
|
||||
adminConfig.UserConfig.Tags.push({
|
||||
name: groupName,
|
||||
enabledApis: enabledApis || [],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'edit': {
|
||||
const groupIndex = adminConfig.UserConfig.Tags.findIndex(t => t.name === groupName);
|
||||
if (groupIndex === -1) {
|
||||
return NextResponse.json({ error: '用户组不存在' }, { status: 404 });
|
||||
}
|
||||
adminConfig.UserConfig.Tags[groupIndex].enabledApis = enabledApis || [];
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
const groupIndex = adminConfig.UserConfig.Tags.findIndex(t => t.name === groupName);
|
||||
if (groupIndex === -1) {
|
||||
return NextResponse.json({ error: '用户组不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 查找使用该用户组的所有用户
|
||||
const affectedUsers: string[] = [];
|
||||
adminConfig.UserConfig.Users.forEach(user => {
|
||||
if (user.tags && user.tags.includes(groupName)) {
|
||||
affectedUsers.push(user.username);
|
||||
// 从用户的tags中移除该用户组
|
||||
user.tags = user.tags.filter(tag => tag !== groupName);
|
||||
// 如果用户没有其他标签了,删除tags字段
|
||||
if (user.tags.length === 0) {
|
||||
delete user.tags;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 删除用户组
|
||||
adminConfig.UserConfig.Tags.splice(groupIndex, 1);
|
||||
|
||||
// 记录删除操作的影响
|
||||
console.log(`删除用户组 "${groupName}",影响用户: ${affectedUsers.length > 0 ? affectedUsers.join(', ') : '无'}`);
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return NextResponse.json({ error: '未知的用户组操作' }, { status: 400 });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'updateUserGroups': {
|
||||
if (!targetEntry) {
|
||||
return NextResponse.json({ error: '目标用户不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
const { userGroups } = body as { userGroups: string[] };
|
||||
|
||||
// 权限检查:站长可配置所有人的用户组,管理员可配置普通用户和自己的用户组
|
||||
if (
|
||||
isTargetAdmin &&
|
||||
operatorRole !== 'owner' &&
|
||||
username !== targetUsername
|
||||
) {
|
||||
return NextResponse.json({ error: '仅站长可配置其他管理员的用户组' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 更新用户的用户组
|
||||
if (userGroups && userGroups.length > 0) {
|
||||
targetEntry.tags = userGroups;
|
||||
} else {
|
||||
// 如果为空数组或未提供,则删除该字段,表示无用户组
|
||||
delete targetEntry.tags;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case 'batchUpdateUserGroups': {
|
||||
const { usernames, userGroups } = body as { usernames: string[]; userGroups: string[] };
|
||||
|
||||
if (!usernames || !Array.isArray(usernames) || usernames.length === 0) {
|
||||
return NextResponse.json({ error: '缺少用户名列表' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 权限检查:站长可批量配置所有人的用户组,管理员只能批量配置普通用户
|
||||
if (operatorRole !== 'owner') {
|
||||
for (const targetUsername of usernames) {
|
||||
const targetUser = adminConfig.UserConfig.Users.find(u => u.username === targetUsername);
|
||||
if (targetUser && targetUser.role === 'admin' && targetUsername !== username) {
|
||||
return NextResponse.json({ error: `管理员无法操作其他管理员 ${targetUsername}` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 批量更新用户组
|
||||
for (const targetUsername of usernames) {
|
||||
const targetUser = adminConfig.UserConfig.Users.find(u => u.username === targetUsername);
|
||||
if (targetUser) {
|
||||
if (userGroups && userGroups.length > 0) {
|
||||
targetUser.tags = userGroups;
|
||||
} else {
|
||||
// 如果为空数组或未提供,则删除该字段,表示无用户组
|
||||
delete targetUser.tags;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 将更新后的配置写入数据库
|
||||
await db.saveAdminConfig(adminConfig);
|
||||
|
||||
return NextResponse.json(
|
||||
{ ok: true },
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'no-store', // 管理员配置不缓存
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('用户管理操作失败:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '用户管理操作失败',
|
||||
details: (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/* eslint-disable no-console*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
|
||||
// 不支持 localstorage 模式
|
||||
if (storageType === 'localstorage') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '不支持本地存储模式修改密码',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { newPassword } = body;
|
||||
|
||||
// 获取认证信息
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 验证新密码
|
||||
if (!newPassword || typeof newPassword !== 'string') {
|
||||
return NextResponse.json({ error: '新密码不得为空' }, { status: 400 });
|
||||
}
|
||||
|
||||
const username = authInfo.username;
|
||||
|
||||
// 不允许站长修改密码(站长用户名等于 process.env.USERNAME)
|
||||
if (username === process.env.USERNAME) {
|
||||
return NextResponse.json(
|
||||
{ error: '站长不能通过此接口修改密码' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
await db.changePassword(username, newPassword);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('修改密码失败:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '修改密码失败',
|
||||
details: (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
||||
|
||||
import * as crypto from 'crypto';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig, refineConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import { fetchVideoDetail } from '@/lib/fetchVideoDetail';
|
||||
import { refreshLiveChannels } from '@/lib/live';
|
||||
import { SearchResult } from '@/lib/types';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
console.log(request.url);
|
||||
try {
|
||||
console.log('Cron job triggered:', new Date().toISOString());
|
||||
|
||||
cronJob();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Cron job executed successfully',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Cron job failed:', error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Cron job failed',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function cronJob() {
|
||||
await refreshConfig();
|
||||
await refreshAllLiveChannels();
|
||||
await refreshRecordAndFavorites();
|
||||
}
|
||||
|
||||
async function refreshAllLiveChannels() {
|
||||
const config = await getConfig();
|
||||
|
||||
// 并发刷新所有启用的直播源
|
||||
const refreshPromises = (config.LiveConfig || [])
|
||||
.filter(liveInfo => !liveInfo.disabled)
|
||||
.map(async (liveInfo) => {
|
||||
try {
|
||||
const nums = await refreshLiveChannels(liveInfo);
|
||||
liveInfo.channelNumber = nums;
|
||||
} catch (error) {
|
||||
console.error(`刷新直播源失败 [${liveInfo.name || liveInfo.key}]:`, error);
|
||||
liveInfo.channelNumber = 0;
|
||||
}
|
||||
});
|
||||
|
||||
// 等待所有刷新任务完成
|
||||
await Promise.all(refreshPromises);
|
||||
|
||||
// 保存配置
|
||||
await db.saveAdminConfig(config);
|
||||
}
|
||||
|
||||
async function refreshConfig() {
|
||||
let config = await getConfig();
|
||||
if (config && config.ConfigSubscribtion && config.ConfigSubscribtion.URL && config.ConfigSubscribtion.AutoUpdate) {
|
||||
try {
|
||||
const response = await fetch(config.ConfigSubscribtion.URL);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`请求失败: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const configContent = await response.text();
|
||||
|
||||
// 对 configContent 进行 base58 解码
|
||||
let decodedContent;
|
||||
try {
|
||||
const bs58 = (await import('bs58')).default;
|
||||
const decodedBytes = bs58.decode(configContent);
|
||||
decodedContent = new TextDecoder().decode(decodedBytes);
|
||||
} catch (decodeError) {
|
||||
console.warn('Base58 解码失败:', decodeError);
|
||||
throw decodeError;
|
||||
}
|
||||
|
||||
try {
|
||||
JSON.parse(decodedContent);
|
||||
} catch (e) {
|
||||
throw new Error('配置文件格式错误,请检查 JSON 语法');
|
||||
}
|
||||
config.ConfigFile = decodedContent;
|
||||
config.ConfigSubscribtion.LastCheck = new Date().toISOString();
|
||||
config = refineConfig(config);
|
||||
await db.saveAdminConfig(config);
|
||||
} catch (e) {
|
||||
console.error('刷新配置失败:', e);
|
||||
}
|
||||
} else {
|
||||
console.log('跳过刷新:未配置订阅地址或自动更新');
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRecordAndFavorites() {
|
||||
try {
|
||||
const users = await db.getAllUsers();
|
||||
if (process.env.USERNAME && !users.includes(process.env.USERNAME)) {
|
||||
users.push(process.env.USERNAME);
|
||||
}
|
||||
// 函数级缓存:key 为 `${source}+${id}`,值为 Promise<VideoDetail | null>
|
||||
const detailCache = new Map<string, Promise<SearchResult | null>>();
|
||||
|
||||
// 获取详情 Promise(带缓存和错误处理)
|
||||
const getDetail = async (
|
||||
source: string,
|
||||
id: string,
|
||||
fallbackTitle: string
|
||||
): Promise<SearchResult | null> => {
|
||||
const key = `${source}+${id}`;
|
||||
let promise = detailCache.get(key);
|
||||
if (!promise) {
|
||||
promise = fetchVideoDetail({
|
||||
source,
|
||||
id,
|
||||
fallbackTitle: fallbackTitle.trim(),
|
||||
})
|
||||
.then((detail) => {
|
||||
// 成功时才缓存结果
|
||||
const successPromise = Promise.resolve(detail);
|
||||
detailCache.set(key, successPromise);
|
||||
return detail;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`获取视频详情失败 (${source}+${id}):`, err);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
return promise;
|
||||
};
|
||||
|
||||
for (const user of users) {
|
||||
console.log(`开始处理用户: ${user}`);
|
||||
|
||||
// 播放记录
|
||||
try {
|
||||
const playRecords = await db.getAllPlayRecords(user);
|
||||
const totalRecords = Object.keys(playRecords).length;
|
||||
let processedRecords = 0;
|
||||
|
||||
for (const [key, record] of Object.entries(playRecords)) {
|
||||
try {
|
||||
const [source, id] = key.split('+');
|
||||
if (!source || !id) {
|
||||
console.warn(`跳过无效的播放记录键: ${key}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const detail = await getDetail(source, id, record.title);
|
||||
if (!detail) {
|
||||
console.warn(`跳过无法获取详情的播放记录: ${key}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const episodeCount = detail.episodes?.length || 0;
|
||||
if (episodeCount > 0 && episodeCount !== record.total_episodes) {
|
||||
await db.savePlayRecord(user, source, id, {
|
||||
title: detail.title || record.title,
|
||||
source_name: record.source_name,
|
||||
cover: detail.poster || record.cover,
|
||||
index: record.index,
|
||||
total_episodes: episodeCount,
|
||||
play_time: record.play_time,
|
||||
year: detail.year || record.year,
|
||||
total_time: record.total_time,
|
||||
save_time: record.save_time,
|
||||
search_title: record.search_title,
|
||||
});
|
||||
console.log(
|
||||
`更新播放记录: ${record.title} (${record.total_episodes} -> ${episodeCount})`
|
||||
);
|
||||
}
|
||||
|
||||
processedRecords++;
|
||||
} catch (err) {
|
||||
console.error(`处理播放记录失败 (${key}):`, err);
|
||||
// 继续处理下一个记录
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`播放记录处理完成: ${processedRecords}/${totalRecords}`);
|
||||
} catch (err) {
|
||||
console.error(`获取用户播放记录失败 (${user}):`, err);
|
||||
}
|
||||
|
||||
// 收藏
|
||||
try {
|
||||
let favorites = await db.getAllFavorites(user);
|
||||
favorites = Object.fromEntries(
|
||||
Object.entries(favorites).filter(([_, fav]) => fav.origin !== 'live')
|
||||
);
|
||||
const totalFavorites = Object.keys(favorites).length;
|
||||
let processedFavorites = 0;
|
||||
|
||||
for (const [key, fav] of Object.entries(favorites)) {
|
||||
try {
|
||||
const [source, id] = key.split('+');
|
||||
if (!source || !id) {
|
||||
console.warn(`跳过无效的收藏键: ${key}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const favDetail = await getDetail(source, id, fav.title);
|
||||
if (!favDetail) {
|
||||
console.warn(`跳过无法获取详情的收藏: ${key}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const favEpisodeCount = favDetail.episodes?.length || 0;
|
||||
if (favEpisodeCount > 0 && favEpisodeCount !== fav.total_episodes) {
|
||||
await db.saveFavorite(user, source, id, {
|
||||
title: favDetail.title || fav.title,
|
||||
source_name: fav.source_name,
|
||||
cover: favDetail.poster || fav.cover,
|
||||
year: favDetail.year || fav.year,
|
||||
total_episodes: favEpisodeCount,
|
||||
save_time: fav.save_time,
|
||||
search_title: fav.search_title,
|
||||
});
|
||||
console.log(
|
||||
`更新收藏: ${fav.title} (${fav.total_episodes} -> ${favEpisodeCount})`
|
||||
);
|
||||
}
|
||||
|
||||
processedFavorites++;
|
||||
} catch (err) {
|
||||
console.error(`处理收藏失败 (${key}):`, err);
|
||||
// 继续处理下一个收藏
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`收藏处理完成: ${processedFavorites}/${totalFavorites}`);
|
||||
} catch (err) {
|
||||
console.error(`获取用户收藏失败 (${user}):`, err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('刷新播放记录/收藏任务完成');
|
||||
} catch (err) {
|
||||
console.error('刷新播放记录/收藏任务启动失败', err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getAvailableApiSites, getCacheTime } from '@/lib/config';
|
||||
import { getDetailFromApi } from '@/lib/downstream';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get('id');
|
||||
const sourceCode = searchParams.get('source');
|
||||
|
||||
if (!id || !sourceCode) {
|
||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!/^[\w-]+$/.test(id)) {
|
||||
return NextResponse.json({ error: '无效的视频ID格式' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const apiSites = await getAvailableApiSites(authInfo.username);
|
||||
const apiSite = apiSites.find((site) => site.key === sourceCode);
|
||||
|
||||
if (!apiSite) {
|
||||
return NextResponse.json({ error: '无效的API来源' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await getDetailFromApi(apiSite, id);
|
||||
const cacheTime = await getCacheTime();
|
||||
|
||||
return NextResponse.json(result, {
|
||||
headers: {
|
||||
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
||||
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Netlify-Vary': 'query',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { getCacheTime } from '@/lib/config';
|
||||
import { fetchDoubanData } from '@/lib/douban';
|
||||
import { DoubanItem, DoubanResult } from '@/lib/types';
|
||||
|
||||
interface DoubanCategoryApiResponse {
|
||||
total: number;
|
||||
items: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
card_subtitle: string;
|
||||
pic: {
|
||||
large: string;
|
||||
normal: string;
|
||||
};
|
||||
rating: {
|
||||
value: number;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
// 获取参数
|
||||
const kind = searchParams.get('kind') || 'movie';
|
||||
const category = searchParams.get('category');
|
||||
const type = searchParams.get('type');
|
||||
const pageLimit = parseInt(searchParams.get('limit') || '20');
|
||||
const pageStart = parseInt(searchParams.get('start') || '0');
|
||||
|
||||
// 验证参数
|
||||
if (!kind || !category || !type) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少必要参数: kind 或 category 或 type' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!['tv', 'movie'].includes(kind)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'kind 参数必须是 tv 或 movie' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (pageLimit < 1 || pageLimit > 100) {
|
||||
return NextResponse.json(
|
||||
{ error: 'pageSize 必须在 1-100 之间' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (pageStart < 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'pageStart 不能小于 0' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const target = `https://m.douban.com/rexxar/api/v2/subject/recent_hot/${kind}?start=${pageStart}&limit=${pageLimit}&category=${category}&type=${type}`;
|
||||
|
||||
try {
|
||||
// 调用豆瓣 API
|
||||
const doubanData = await fetchDoubanData<DoubanCategoryApiResponse>(target);
|
||||
|
||||
// 转换数据格式
|
||||
const list: DoubanItem[] = doubanData.items.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
poster: item.pic?.normal || item.pic?.large || '',
|
||||
rate: item.rating?.value ? item.rating.value.toFixed(1) : '',
|
||||
year: item.card_subtitle?.match(/(\d{4})/)?.[1] || '',
|
||||
}));
|
||||
|
||||
const response: DoubanResult = {
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
list: list,
|
||||
};
|
||||
|
||||
const cacheTime = await getCacheTime();
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
||||
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Netlify-Vary': 'query',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: '获取豆瓣数据失败', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getCacheTime } from '@/lib/config';
|
||||
import { fetchDoubanData } from '@/lib/douban';
|
||||
import { DoubanResult } from '@/lib/types';
|
||||
|
||||
interface DoubanRecommendApiResponse {
|
||||
total: number;
|
||||
items: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
year: string;
|
||||
type: string;
|
||||
pic: {
|
||||
large: string;
|
||||
normal: string;
|
||||
};
|
||||
rating: {
|
||||
value: number;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
// 获取参数
|
||||
const kind = searchParams.get('kind');
|
||||
const pageLimit = parseInt(searchParams.get('limit') || '20');
|
||||
const pageStart = parseInt(searchParams.get('start') || '0');
|
||||
const category =
|
||||
searchParams.get('category') === 'all' ? '' : searchParams.get('category');
|
||||
const format =
|
||||
searchParams.get('format') === 'all' ? '' : searchParams.get('format');
|
||||
const region =
|
||||
searchParams.get('region') === 'all' ? '' : searchParams.get('region');
|
||||
const year =
|
||||
searchParams.get('year') === 'all' ? '' : searchParams.get('year');
|
||||
const platform =
|
||||
searchParams.get('platform') === 'all' ? '' : searchParams.get('platform');
|
||||
const sort = searchParams.get('sort') === 'T' ? '' : searchParams.get('sort');
|
||||
const label =
|
||||
searchParams.get('label') === 'all' ? '' : searchParams.get('label');
|
||||
|
||||
if (!kind) {
|
||||
return NextResponse.json({ error: '缺少必要参数: kind' }, { status: 400 });
|
||||
}
|
||||
|
||||
const selectedCategories = { 类型: category } as any;
|
||||
if (format) {
|
||||
selectedCategories['形式'] = format;
|
||||
}
|
||||
if (region) {
|
||||
selectedCategories['地区'] = region;
|
||||
}
|
||||
|
||||
const tags = [] as Array<string>;
|
||||
if (category) {
|
||||
tags.push(category);
|
||||
}
|
||||
if (!category && format) {
|
||||
tags.push(format);
|
||||
}
|
||||
if (label) {
|
||||
tags.push(label);
|
||||
}
|
||||
if (region) {
|
||||
tags.push(region);
|
||||
}
|
||||
if (year) {
|
||||
tags.push(year);
|
||||
}
|
||||
if (platform) {
|
||||
tags.push(platform);
|
||||
}
|
||||
|
||||
const baseUrl = `https://m.douban.com/rexxar/api/v2/${kind}/recommend`;
|
||||
const params = new URLSearchParams();
|
||||
params.append('refresh', '0');
|
||||
params.append('start', pageStart.toString());
|
||||
params.append('count', pageLimit.toString());
|
||||
params.append('selected_categories', JSON.stringify(selectedCategories));
|
||||
params.append('uncollect', 'false');
|
||||
params.append('score_range', '0,10');
|
||||
params.append('tags', tags.join(','));
|
||||
if (sort) {
|
||||
params.append('sort', sort);
|
||||
}
|
||||
|
||||
const target = `${baseUrl}?${params.toString()}`;
|
||||
console.log(target);
|
||||
try {
|
||||
const doubanData = await fetchDoubanData<DoubanRecommendApiResponse>(
|
||||
target
|
||||
);
|
||||
const list = doubanData.items
|
||||
.filter((item) => item.type == 'movie' || item.type == 'tv')
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
poster: item.pic?.normal || item.pic?.large || '',
|
||||
rate: item.rating?.value ? item.rating.value.toFixed(1) : '',
|
||||
year: item.year,
|
||||
}));
|
||||
const response: DoubanResult = {
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
list: list,
|
||||
};
|
||||
|
||||
const cacheTime = await getCacheTime();
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
||||
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Netlify-Vary': 'query',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: '获取豆瓣数据失败', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { getCacheTime } from '@/lib/config';
|
||||
import { fetchDoubanData } from '@/lib/douban';
|
||||
import { DoubanItem, DoubanResult } from '@/lib/types';
|
||||
|
||||
interface DoubanApiResponse {
|
||||
subjects: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
cover: string;
|
||||
rate: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
// 获取参数
|
||||
const type = searchParams.get('type');
|
||||
const tag = searchParams.get('tag');
|
||||
const pageSize = parseInt(searchParams.get('pageSize') || '16');
|
||||
const pageStart = parseInt(searchParams.get('pageStart') || '0');
|
||||
|
||||
// 验证参数
|
||||
if (!type || !tag) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少必要参数: type 或 tag' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!['tv', 'movie'].includes(type)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'type 参数必须是 tv 或 movie' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (pageSize < 1 || pageSize > 100) {
|
||||
return NextResponse.json(
|
||||
{ error: 'pageSize 必须在 1-100 之间' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (pageStart < 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'pageStart 不能小于 0' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (tag === 'top250') {
|
||||
return handleTop250(pageStart);
|
||||
}
|
||||
|
||||
const target = `https://movie.douban.com/j/search_subjects?type=${type}&tag=${tag}&sort=recommend&page_limit=${pageSize}&page_start=${pageStart}`;
|
||||
|
||||
try {
|
||||
// 调用豆瓣 API
|
||||
const doubanData = await fetchDoubanData<DoubanApiResponse>(target);
|
||||
|
||||
// 转换数据格式
|
||||
const list: DoubanItem[] = doubanData.subjects.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
poster: item.cover,
|
||||
rate: item.rate,
|
||||
year: '',
|
||||
}));
|
||||
|
||||
const response: DoubanResult = {
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
list: list,
|
||||
};
|
||||
|
||||
const cacheTime = await getCacheTime();
|
||||
return NextResponse.json(response, {
|
||||
headers: {
|
||||
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
||||
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Netlify-Vary': 'query',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: '获取豆瓣数据失败', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTop250(pageStart: number) {
|
||||
const target = `https://movie.douban.com/top250?start=${pageStart}&filter=`;
|
||||
|
||||
// 直接使用 fetch 获取 HTML 页面
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
const fetchOptions = {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
||||
Referer: 'https://movie.douban.com/',
|
||||
Accept:
|
||||
'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
},
|
||||
};
|
||||
|
||||
return fetch(target, fetchOptions)
|
||||
.then(async (fetchResponse) => {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!fetchResponse.ok) {
|
||||
throw new Error(`HTTP error! Status: ${fetchResponse.status}`);
|
||||
}
|
||||
|
||||
// 获取 HTML 内容
|
||||
const html = await fetchResponse.text();
|
||||
|
||||
// 通过正则同时捕获影片 id、标题、封面以及评分
|
||||
const moviePattern =
|
||||
/<div class="item">[\s\S]*?<a[^>]+href="https?:\/\/movie\.douban\.com\/subject\/(\d+)\/"[\s\S]*?<img[^>]+alt="([^"]+)"[^>]*src="([^"]+)"[\s\S]*?<span class="rating_num"[^>]*>([^<]*)<\/span>[\s\S]*?<\/div>/g;
|
||||
const movies: DoubanItem[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = moviePattern.exec(html)) !== null) {
|
||||
const id = match[1];
|
||||
const title = match[2];
|
||||
const cover = match[3];
|
||||
const rate = match[4] || '';
|
||||
|
||||
// 处理图片 URL,确保使用 HTTPS
|
||||
const processedCover = cover.replace(/^http:/, 'https:');
|
||||
|
||||
movies.push({
|
||||
id: id,
|
||||
title: title,
|
||||
poster: processedCover,
|
||||
rate: rate,
|
||||
year: '',
|
||||
});
|
||||
}
|
||||
|
||||
const apiResponse: DoubanResult = {
|
||||
code: 200,
|
||||
message: '获取成功',
|
||||
list: movies,
|
||||
};
|
||||
|
||||
const cacheTime = await getCacheTime();
|
||||
return NextResponse.json(apiResponse, {
|
||||
headers: {
|
||||
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
||||
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Netlify-Vary': 'query',
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timeoutId);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '获取豆瓣 Top250 数据失败',
|
||||
details: (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import { Favorite } from '@/lib/types';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* GET /api/favorites
|
||||
*
|
||||
* 支持两种调用方式:
|
||||
* 1. 不带 query,返回全部收藏列表(Record<string, Favorite>)。
|
||||
* 2. 带 key=source+id,返回单条收藏(Favorite | null)。
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// 从 cookie 获取用户信息
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const key = searchParams.get('key');
|
||||
|
||||
// 查询单条收藏
|
||||
if (key) {
|
||||
const [source, id] = key.split('+');
|
||||
if (!source || !id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid key format' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const fav = await db.getFavorite(authInfo.username, source, id);
|
||||
return NextResponse.json(fav, { status: 200 });
|
||||
}
|
||||
|
||||
// 查询全部收藏
|
||||
const favorites = await db.getAllFavorites(authInfo.username);
|
||||
return NextResponse.json(favorites, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('获取收藏失败', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/favorites
|
||||
* body: { key: string; favorite: Favorite }
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// 从 cookie 获取用户信息
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { key, favorite }: { key: string; favorite: Favorite } = body;
|
||||
|
||||
if (!key || !favorite) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing key or favorite' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证必要字段
|
||||
if (!favorite.title || !favorite.source_name) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid favorite data' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const [source, id] = key.split('+');
|
||||
if (!source || !id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid key format' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const finalFavorite = {
|
||||
...favorite,
|
||||
save_time: favorite.save_time ?? Date.now(),
|
||||
} as Favorite;
|
||||
|
||||
await db.saveFavorite(authInfo.username, source, id, finalFavorite);
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('保存收藏失败', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/favorites
|
||||
*
|
||||
* 1. 不带 query -> 清空全部收藏
|
||||
* 2. 带 key=source+id -> 删除单条收藏
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
// 从 cookie 获取用户信息
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const username = authInfo.username;
|
||||
const { searchParams } = new URL(request.url);
|
||||
const key = searchParams.get('key');
|
||||
|
||||
if (key) {
|
||||
// 删除单条
|
||||
const [source, id] = key.split('+');
|
||||
if (!source || !id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid key format' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
await db.deleteFavorite(username, source, id);
|
||||
} else {
|
||||
// 清空全部
|
||||
const all = await db.getAllFavorites(username);
|
||||
await Promise.all(
|
||||
Object.keys(all).map(async (k) => {
|
||||
const [s, i] = k.split('+');
|
||||
if (s && i) await db.deleteFavorite(username, s, i);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('删除收藏失败', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
// OrionTV 兼容接口
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const imageUrl = searchParams.get('url');
|
||||
|
||||
if (!imageUrl) {
|
||||
return NextResponse.json({ error: 'Missing image URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const imageResponse = await fetch(imageUrl, {
|
||||
headers: {
|
||||
Referer: 'https://movie.douban.com/',
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
||||
},
|
||||
});
|
||||
|
||||
if (!imageResponse.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: imageResponse.statusText },
|
||||
{ status: imageResponse.status }
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = imageResponse.headers.get('content-type');
|
||||
|
||||
if (!imageResponse.body) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Image response has no body' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// 创建响应头
|
||||
const headers = new Headers();
|
||||
if (contentType) {
|
||||
headers.set('Content-Type', contentType);
|
||||
}
|
||||
|
||||
// 设置缓存头(可选)
|
||||
headers.set('Cache-Control', 'public, max-age=15720000, s-maxage=15720000'); // 缓存半年
|
||||
headers.set('CDN-Cache-Control', 'public, s-maxage=15720000');
|
||||
headers.set('Vercel-CDN-Cache-Control', 'public, s-maxage=15720000');
|
||||
headers.set('Netlify-Vary', 'query');
|
||||
|
||||
// 直接返回图片流
|
||||
return new Response(imageResponse.body, {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Error fetching image' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getCachedLiveChannels } from '@/lib/live';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const sourceKey = searchParams.get('source');
|
||||
|
||||
if (!sourceKey) {
|
||||
return NextResponse.json({ error: '缺少直播源参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
const channelData = await getCachedLiveChannels(sourceKey);
|
||||
|
||||
if (!channelData) {
|
||||
return NextResponse.json({ error: '频道信息未找到' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: channelData.channels
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: '获取频道信息失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getCachedLiveChannels } from '@/lib/live';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const sourceKey = searchParams.get('source');
|
||||
const tvgId = searchParams.get('tvgId');
|
||||
|
||||
if (!sourceKey) {
|
||||
return NextResponse.json({ error: '缺少直播源参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!tvgId) {
|
||||
return NextResponse.json({ error: '缺少频道tvg-id参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
const channelData = await getCachedLiveChannels(sourceKey);
|
||||
|
||||
if (!channelData) {
|
||||
// 频道信息未找到时返回空的节目单数据
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
tvgId,
|
||||
source: sourceKey,
|
||||
epgUrl: '',
|
||||
programs: []
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 从epgs字段中获取对应tvgId的节目单信息
|
||||
const epgData = channelData.epgs[tvgId] || [];
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
tvgId,
|
||||
source: sourceKey,
|
||||
epgUrl: channelData.epgUrl,
|
||||
programs: epgData
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: '获取节目单信息失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const url = searchParams.get('url');
|
||||
const source = searchParams.get('moontv-source');
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: 'Missing url' }, { status: 400 });
|
||||
}
|
||||
const config = await getConfig();
|
||||
const liveSource = config.LiveConfig?.find((s: any) => s.key === source);
|
||||
if (!liveSource) {
|
||||
return NextResponse.json({ error: 'Source not found' }, { status: 404 });
|
||||
}
|
||||
const ua = liveSource.ua || 'AptvPlayer/1.4.10';
|
||||
|
||||
try {
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
|
||||
const response = await fetch(decodedUrl, {
|
||||
cache: 'no-cache',
|
||||
redirect: 'follow',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'User-Agent': ua,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ error: 'Failed to fetch', message: response.statusText }, { status: 500 });
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('Content-Type');
|
||||
if (response.body) {
|
||||
response.body.cancel();
|
||||
}
|
||||
if (contentType?.includes('video/mp4')) {
|
||||
return NextResponse.json({ success: true, type: 'mp4' }, { status: 200 });
|
||||
}
|
||||
if (contentType?.includes('video/x-flv')) {
|
||||
return NextResponse.json({ success: true, type: 'flv' }, { status: 200 });
|
||||
}
|
||||
return NextResponse.json({ success: true, type: 'm3u8' }, { status: 200 });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Failed to fetch', message: error }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
console.log(request.url)
|
||||
try {
|
||||
const config = await getConfig();
|
||||
|
||||
if (!config) {
|
||||
return NextResponse.json({ error: '配置未找到' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 过滤出所有非 disabled 的直播源
|
||||
const liveSources = (config.LiveConfig || []).filter(source => !source.disabled);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: liveSources
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取直播源失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '获取直播源失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
// 读取存储类型环境变量,默认 localstorage
|
||||
const STORAGE_TYPE =
|
||||
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
|
||||
| 'localstorage'
|
||||
| 'redis'
|
||||
| 'upstash'
|
||||
| 'kvrocks'
|
||||
| undefined) || 'localstorage';
|
||||
|
||||
// 生成签名
|
||||
async function generateSignature(
|
||||
data: string,
|
||||
secret: string
|
||||
): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const keyData = encoder.encode(secret);
|
||||
const messageData = encoder.encode(data);
|
||||
|
||||
// 导入密钥
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyData,
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
// 生成签名
|
||||
const signature = await crypto.subtle.sign('HMAC', key, messageData);
|
||||
|
||||
// 转换为十六进制字符串
|
||||
return Array.from(new Uint8Array(signature))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
// 生成认证Cookie(带签名)
|
||||
async function generateAuthCookie(
|
||||
username?: string,
|
||||
password?: string,
|
||||
role?: 'owner' | 'admin' | 'user',
|
||||
includePassword = false
|
||||
): Promise<string> {
|
||||
const authData: any = { role: role || 'user' };
|
||||
|
||||
// 只在需要时包含 password
|
||||
if (includePassword && password) {
|
||||
authData.password = password;
|
||||
}
|
||||
|
||||
if (username && process.env.PASSWORD) {
|
||||
authData.username = username;
|
||||
// 使用密码作为密钥对用户名进行签名
|
||||
const signature = await generateSignature(username, process.env.PASSWORD);
|
||||
authData.signature = signature;
|
||||
authData.timestamp = Date.now(); // 添加时间戳防重放攻击
|
||||
}
|
||||
|
||||
return encodeURIComponent(JSON.stringify(authData));
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// 本地 / localStorage 模式——仅校验固定密码
|
||||
if (STORAGE_TYPE === 'localstorage') {
|
||||
const envPassword = process.env.PASSWORD;
|
||||
|
||||
// 未配置 PASSWORD 时直接放行
|
||||
if (!envPassword) {
|
||||
const response = NextResponse.json({ ok: true });
|
||||
|
||||
// 清除可能存在的认证cookie
|
||||
response.cookies.set('auth', '', {
|
||||
path: '/',
|
||||
expires: new Date(0),
|
||||
sameSite: 'lax', // 改为 lax 以支持 PWA
|
||||
httpOnly: false, // PWA 需要客户端可访问
|
||||
secure: false, // 根据协议自动设置
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
const { password } = await req.json();
|
||||
if (typeof password !== 'string') {
|
||||
return NextResponse.json({ error: '密码不能为空' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (password !== envPassword) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: '密码错误' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证成功,设置认证cookie
|
||||
const response = NextResponse.json({ ok: true });
|
||||
const cookieValue = await generateAuthCookie(
|
||||
undefined,
|
||||
password,
|
||||
'user',
|
||||
true
|
||||
); // localstorage 模式包含 password
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + 7); // 7天过期
|
||||
|
||||
response.cookies.set('auth', cookieValue, {
|
||||
path: '/',
|
||||
expires,
|
||||
sameSite: 'lax', // 改为 lax 以支持 PWA
|
||||
httpOnly: false, // PWA 需要客户端可访问
|
||||
secure: false, // 根据协议自动设置
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// 数据库 / redis 模式——校验用户名并尝试连接数据库
|
||||
const { username, password } = await req.json();
|
||||
|
||||
if (!username || typeof username !== 'string') {
|
||||
return NextResponse.json({ error: '用户名不能为空' }, { status: 400 });
|
||||
}
|
||||
if (!password || typeof password !== 'string') {
|
||||
return NextResponse.json({ error: '密码不能为空' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 可能是站长,直接读环境变量
|
||||
if (
|
||||
username === process.env.USERNAME &&
|
||||
password === process.env.PASSWORD
|
||||
) {
|
||||
// 验证成功,设置认证cookie
|
||||
const response = NextResponse.json({ ok: true });
|
||||
const cookieValue = await generateAuthCookie(
|
||||
username,
|
||||
password,
|
||||
'owner',
|
||||
false
|
||||
); // 数据库模式不包含 password
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + 7); // 7天过期
|
||||
|
||||
response.cookies.set('auth', cookieValue, {
|
||||
path: '/',
|
||||
expires,
|
||||
sameSite: 'lax', // 改为 lax 以支持 PWA
|
||||
httpOnly: false, // PWA 需要客户端可访问
|
||||
secure: false, // 根据协议自动设置
|
||||
});
|
||||
|
||||
return response;
|
||||
} else if (username === process.env.USERNAME) {
|
||||
return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const user = config.UserConfig.Users.find((u) => u.username === username);
|
||||
if (user && user.banned) {
|
||||
return NextResponse.json({ error: '用户被封禁' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 校验用户密码
|
||||
try {
|
||||
const pass = await db.verifyUser(username, password);
|
||||
if (!pass) {
|
||||
return NextResponse.json(
|
||||
{ error: '用户名或密码错误' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证成功,设置认证cookie
|
||||
const response = NextResponse.json({ ok: true });
|
||||
const cookieValue = await generateAuthCookie(
|
||||
username,
|
||||
password,
|
||||
user?.role || 'user',
|
||||
false
|
||||
); // 数据库模式不包含 password
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + 7); // 7天过期
|
||||
|
||||
response.cookies.set('auth', cookieValue, {
|
||||
path: '/',
|
||||
expires,
|
||||
sameSite: 'lax', // 改为 lax 以支持 PWA
|
||||
httpOnly: false, // PWA 需要客户端可访问
|
||||
secure: false, // 根据协议自动设置
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (err) {
|
||||
console.error('数据库验证失败', err);
|
||||
return NextResponse.json({ error: '数据库错误' }, { status: 500 });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('登录接口异常', error);
|
||||
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST() {
|
||||
const response = NextResponse.json({ ok: true });
|
||||
|
||||
// 清除认证cookie
|
||||
response.cookies.set('auth', '', {
|
||||
path: '/',
|
||||
expires: new Date(0),
|
||||
sameSite: 'lax', // 改为 lax 以支持 PWA
|
||||
httpOnly: false, // PWA 需要客户端可访问
|
||||
secure: false, // 根据协议自动设置
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import { PlayRecord } from '@/lib/types';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// 从 cookie 获取用户信息
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const records = await db.getAllPlayRecords(authInfo.username);
|
||||
return NextResponse.json(records, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('获取播放记录失败', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// 从 cookie 获取用户信息
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { key, record }: { key: string; record: PlayRecord } = body;
|
||||
|
||||
if (!key || !record) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing key or record' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证播放记录数据
|
||||
if (!record.title || !record.source_name || record.index < 1) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid record data' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 从key中解析source和id
|
||||
const [source, id] = key.split('+');
|
||||
if (!source || !id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid key format' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const finalRecord = {
|
||||
...record,
|
||||
save_time: record.save_time ?? Date.now(),
|
||||
} as PlayRecord;
|
||||
|
||||
await db.savePlayRecord(authInfo.username, source, id, finalRecord);
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('保存播放记录失败', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
// 从 cookie 获取用户信息
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const username = authInfo.username;
|
||||
const { searchParams } = new URL(request.url);
|
||||
const key = searchParams.get('key');
|
||||
|
||||
if (key) {
|
||||
// 如果提供了 key,删除单条播放记录
|
||||
const [source, id] = key.split('+');
|
||||
if (!source || !id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid key format' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await db.deletePlayRecord(username, source, id);
|
||||
} else {
|
||||
// 未提供 key,则清空全部播放记录
|
||||
// 目前 DbManager 没有对应方法,这里直接遍历删除
|
||||
const all = await db.getAllPlayRecords(username);
|
||||
await Promise.all(
|
||||
Object.keys(all).map(async (k) => {
|
||||
const [s, i] = k.split('+');
|
||||
if (s && i) await db.deletePlayRecord(username, s, i);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('删除播放记录失败', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getConfig } from "@/lib/config";
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const url = searchParams.get('url');
|
||||
const source = searchParams.get('moontv-source');
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: 'Missing url' }, { status: 400 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const liveSource = config.LiveConfig?.find((s: any) => s.key === source);
|
||||
if (!liveSource) {
|
||||
return NextResponse.json({ error: 'Source not found' }, { status: 404 });
|
||||
}
|
||||
const ua = liveSource.ua || 'AptvPlayer/1.4.10';
|
||||
|
||||
try {
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
console.log(decodedUrl);
|
||||
const response = await fetch(decodedUrl, {
|
||||
headers: {
|
||||
'User-Agent': ua,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ error: 'Failed to fetch key' }, { status: 500 });
|
||||
}
|
||||
const keyData = await response.arrayBuffer();
|
||||
return new Response(keyData, {
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Cache-Control': 'public, max-age=3600'
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Failed to fetch key' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const imageUrl = searchParams.get('url');
|
||||
const source = searchParams.get('moontv-source');
|
||||
|
||||
if (!imageUrl) {
|
||||
return NextResponse.json({ error: 'Missing image URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const liveSource = config.LiveConfig?.find((s: any) => s.key === source);
|
||||
const ua = liveSource?.ua || 'AptvPlayer/1.4.10';
|
||||
|
||||
try {
|
||||
const decodedUrl = decodeURIComponent(imageUrl);
|
||||
const imageResponse = await fetch(decodedUrl, {
|
||||
cache: 'no-cache',
|
||||
redirect: 'follow',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'User-Agent': ua,
|
||||
},
|
||||
});
|
||||
|
||||
if (!imageResponse.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: imageResponse.statusText },
|
||||
{ status: imageResponse.status }
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = imageResponse.headers.get('content-type');
|
||||
|
||||
if (!imageResponse.body) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Image response has no body' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// 创建响应头
|
||||
const headers = new Headers();
|
||||
if (contentType) {
|
||||
headers.set('Content-Type', contentType);
|
||||
}
|
||||
|
||||
// 设置缓存头
|
||||
headers.set('Cache-Control', 'public, max-age=86400, s-maxage=86400'); // 缓存一天
|
||||
|
||||
// 直接返回图片流
|
||||
return new Response(imageResponse.body, {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Error fetching image' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getConfig } from "@/lib/config";
|
||||
import { getBaseUrl, resolveUrl } from "@/lib/live";
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const url = searchParams.get('url');
|
||||
const allowCORS = searchParams.get('allowCORS') === 'true';
|
||||
const source = searchParams.get('moontv-source');
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: 'Missing url' }, { status: 400 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const liveSource = config.LiveConfig?.find((s: any) => s.key === source);
|
||||
if (!liveSource) {
|
||||
return NextResponse.json({ error: 'Source not found' }, { status: 404 });
|
||||
}
|
||||
const ua = liveSource.ua || 'AptvPlayer/1.4.10';
|
||||
|
||||
let response: Response | null = null;
|
||||
let responseUsed = false;
|
||||
|
||||
try {
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
|
||||
response = await fetch(decodedUrl, {
|
||||
cache: 'no-cache',
|
||||
redirect: 'follow',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'User-Agent': ua,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ error: 'Failed to fetch m3u8' }, { status: 500 });
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('Content-Type') || '';
|
||||
// rewrite m3u8
|
||||
if (contentType.toLowerCase().includes('mpegurl') || contentType.toLowerCase().includes('octet-stream')) {
|
||||
// 获取最终的响应URL(处理重定向后的URL)
|
||||
const finalUrl = response.url;
|
||||
const m3u8Content = await response.text();
|
||||
responseUsed = true; // 标记 response 已被使用
|
||||
|
||||
// 使用最终的响应URL作为baseUrl,而不是原始的请求URL
|
||||
const baseUrl = getBaseUrl(finalUrl);
|
||||
|
||||
// 重写 M3U8 内容
|
||||
const modifiedContent = rewriteM3U8Content(m3u8Content, baseUrl, request, allowCORS);
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', contentType);
|
||||
headers.set('Access-Control-Allow-Origin', '*');
|
||||
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
|
||||
headers.set('Cache-Control', 'no-cache');
|
||||
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
|
||||
return new Response(modifiedContent, { headers });
|
||||
}
|
||||
// just proxy
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', response.headers.get('Content-Type') || 'application/vnd.apple.mpegurl');
|
||||
headers.set('Access-Control-Allow-Origin', '*');
|
||||
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
|
||||
headers.set('Cache-Control', 'no-cache');
|
||||
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
|
||||
|
||||
// 直接返回视频流
|
||||
return new Response(response.body, {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Failed to fetch m3u8' }, { status: 500 });
|
||||
} finally {
|
||||
// 确保 response 被正确关闭以释放资源
|
||||
if (response && !responseUsed) {
|
||||
try {
|
||||
response.body?.cancel();
|
||||
} catch (error) {
|
||||
// 忽略关闭时的错误
|
||||
console.warn('Failed to close response body:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rewriteM3U8Content(content: string, baseUrl: string, req: Request, allowCORS: boolean) {
|
||||
// 从 referer 头提取协议信息
|
||||
const referer = req.headers.get('referer');
|
||||
let protocol = 'http';
|
||||
if (referer) {
|
||||
try {
|
||||
const refererUrl = new URL(referer);
|
||||
protocol = refererUrl.protocol.replace(':', '');
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const host = req.headers.get('host');
|
||||
const proxyBase = `${protocol}://${host}/api/proxy`;
|
||||
|
||||
const lines = content.split('\n');
|
||||
const rewrittenLines: string[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let line = lines[i].trim();
|
||||
|
||||
// 处理 TS 片段 URL 和其他媒体文件
|
||||
if (line && !line.startsWith('#')) {
|
||||
const resolvedUrl = resolveUrl(baseUrl, line);
|
||||
const proxyUrl = allowCORS ? resolvedUrl : `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}`;
|
||||
rewrittenLines.push(proxyUrl);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 处理 EXT-X-MAP 标签中的 URI
|
||||
if (line.startsWith('#EXT-X-MAP:')) {
|
||||
line = rewriteMapUri(line, baseUrl, proxyBase);
|
||||
}
|
||||
|
||||
// 处理 EXT-X-KEY 标签中的 URI
|
||||
if (line.startsWith('#EXT-X-KEY:')) {
|
||||
line = rewriteKeyUri(line, baseUrl, proxyBase);
|
||||
}
|
||||
|
||||
// 处理嵌套的 M3U8 文件 (EXT-X-STREAM-INF)
|
||||
if (line.startsWith('#EXT-X-STREAM-INF:')) {
|
||||
rewrittenLines.push(line);
|
||||
// 下一行通常是 M3U8 URL
|
||||
if (i + 1 < lines.length) {
|
||||
i++;
|
||||
const nextLine = lines[i].trim();
|
||||
if (nextLine && !nextLine.startsWith('#')) {
|
||||
const resolvedUrl = resolveUrl(baseUrl, nextLine);
|
||||
const proxyUrl = `${proxyBase}/m3u8?url=${encodeURIComponent(resolvedUrl)}`;
|
||||
rewrittenLines.push(proxyUrl);
|
||||
} else {
|
||||
rewrittenLines.push(nextLine);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
rewrittenLines.push(line);
|
||||
}
|
||||
|
||||
return rewrittenLines.join('\n');
|
||||
}
|
||||
|
||||
function rewriteMapUri(line: string, baseUrl: string, proxyBase: string) {
|
||||
const uriMatch = line.match(/URI="([^"]+)"/);
|
||||
if (uriMatch) {
|
||||
const originalUri = uriMatch[1];
|
||||
const resolvedUrl = resolveUrl(baseUrl, originalUri);
|
||||
const proxyUrl = `${proxyBase}/segment?url=${encodeURIComponent(resolvedUrl)}`;
|
||||
return line.replace(uriMatch[0], `URI="${proxyUrl}"`);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function rewriteKeyUri(line: string, baseUrl: string, proxyBase: string) {
|
||||
const uriMatch = line.match(/URI="([^"]+)"/);
|
||||
if (uriMatch) {
|
||||
const originalUri = uriMatch[1];
|
||||
const resolvedUrl = resolveUrl(baseUrl, originalUri);
|
||||
const proxyUrl = `${proxyBase}/key?url=${encodeURIComponent(resolvedUrl)}`;
|
||||
return line.replace(uriMatch[0], `URI="${proxyUrl}"`);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getConfig } from "@/lib/config";
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const url = searchParams.get('url');
|
||||
const source = searchParams.get('moontv-source');
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: 'Missing url' }, { status: 400 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const liveSource = config.LiveConfig?.find((s: any) => s.key === source);
|
||||
if (!liveSource) {
|
||||
return NextResponse.json({ error: 'Source not found' }, { status: 404 });
|
||||
}
|
||||
const ua = liveSource.ua || 'AptvPlayer/1.4.10';
|
||||
|
||||
let response: Response | null = null;
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
||||
|
||||
try {
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
response = await fetch(decodedUrl, {
|
||||
headers: {
|
||||
'User-Agent': ua,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ error: 'Failed to fetch segment' }, { status: 500 });
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', 'video/mp2t');
|
||||
headers.set('Access-Control-Allow-Origin', '*');
|
||||
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
headers.set('Access-Control-Allow-Headers', 'Content-Type, Range, Origin, Accept');
|
||||
headers.set('Accept-Ranges', 'bytes');
|
||||
headers.set('Access-Control-Expose-Headers', 'Content-Length, Content-Range');
|
||||
const contentLength = response.headers.get('content-length');
|
||||
if (contentLength) {
|
||||
headers.set('Content-Length', contentLength);
|
||||
}
|
||||
|
||||
// 使用流式传输,避免占用内存
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
if (!response?.body) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
reader = response.body.getReader();
|
||||
const isCancelled = false;
|
||||
|
||||
function pump() {
|
||||
if (isCancelled || !reader) {
|
||||
return;
|
||||
}
|
||||
|
||||
reader.read().then(({ done, value }) => {
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (done) {
|
||||
controller.close();
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
controller.enqueue(value);
|
||||
pump();
|
||||
}).catch((error) => {
|
||||
if (!isCancelled) {
|
||||
controller.error(error);
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (reader) {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch (e) {
|
||||
// reader 可能已经被释放,忽略错误
|
||||
}
|
||||
reader = null;
|
||||
}
|
||||
}
|
||||
|
||||
pump();
|
||||
},
|
||||
cancel() {
|
||||
// 当流被取消时,确保释放所有资源
|
||||
if (reader) {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch (e) {
|
||||
// reader 可能已经被释放,忽略错误
|
||||
}
|
||||
reader = null;
|
||||
}
|
||||
|
||||
if (response?.body) {
|
||||
try {
|
||||
response.body.cancel();
|
||||
} catch (e) {
|
||||
// 忽略取消时的错误
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(stream, { headers });
|
||||
} catch (error) {
|
||||
// 确保在错误情况下也释放资源
|
||||
if (reader) {
|
||||
try {
|
||||
(reader as ReadableStreamDefaultReader<Uint8Array>).releaseLock();
|
||||
} catch (e) {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
if (response?.body) {
|
||||
try {
|
||||
response.body.cancel();
|
||||
} catch (e) {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Failed to fetch segment' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config';
|
||||
import { searchFromApi } from '@/lib/downstream';
|
||||
import { yellowWords } from '@/lib/yellow';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
// OrionTV 兼容接口
|
||||
export async function GET(request: NextRequest) {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get('q');
|
||||
const resourceId = searchParams.get('resourceId');
|
||||
|
||||
if (!query || !resourceId) {
|
||||
const cacheTime = await getCacheTime();
|
||||
return NextResponse.json(
|
||||
{ result: null, error: '缺少必要参数: q 或 resourceId' },
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
||||
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Netlify-Vary': 'query',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const apiSites = await getAvailableApiSites(authInfo.username);
|
||||
|
||||
try {
|
||||
// 根据 resourceId 查找对应的 API 站点
|
||||
const targetSite = apiSites.find((site) => site.key === resourceId);
|
||||
if (!targetSite) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `未找到指定的视频源: ${resourceId}`,
|
||||
result: null,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const results = await searchFromApi(targetSite, query);
|
||||
let result = results.filter((r) => r.title === query);
|
||||
if (!config.SiteConfig.DisableYellowFilter) {
|
||||
result = result.filter((result) => {
|
||||
const typeName = result.type_name || '';
|
||||
return !yellowWords.some((word: string) => typeName.includes(word));
|
||||
});
|
||||
}
|
||||
const cacheTime = await getCacheTime();
|
||||
|
||||
if (result.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '未找到结果',
|
||||
result: null,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ results: result },
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
||||
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Netlify-Vary': 'query',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '搜索失败',
|
||||
result: null,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAvailableApiSites } from '@/lib/config';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
// OrionTV 兼容接口
|
||||
export async function GET(request: NextRequest) {
|
||||
console.log('request', request.url);
|
||||
try {
|
||||
const apiSites = await getAvailableApiSites();
|
||||
|
||||
return NextResponse.json(apiSites);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: '获取资源失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config';
|
||||
import { searchFromApi } from '@/lib/downstream';
|
||||
import { yellowWords } from '@/lib/yellow';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get('q');
|
||||
|
||||
if (!query) {
|
||||
const cacheTime = await getCacheTime();
|
||||
return NextResponse.json(
|
||||
{ results: [] },
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
||||
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Netlify-Vary': 'query',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const apiSites = await getAvailableApiSites(authInfo.username);
|
||||
|
||||
// 添加超时控制和错误处理,避免慢接口拖累整体响应
|
||||
const searchPromises = apiSites.map((site) =>
|
||||
Promise.race([
|
||||
searchFromApi(site, query),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`${site.name} timeout`)), 20000)
|
||||
),
|
||||
]).catch((err) => {
|
||||
console.warn(`搜索失败 ${site.name}:`, err.message);
|
||||
return []; // 返回空数组而不是抛出错误
|
||||
})
|
||||
);
|
||||
|
||||
try {
|
||||
const results = await Promise.allSettled(searchPromises);
|
||||
const successResults = results
|
||||
.filter((result) => result.status === 'fulfilled')
|
||||
.map((result) => (result as PromiseFulfilledResult<any>).value);
|
||||
let flattenedResults = successResults.flat();
|
||||
if (!config.SiteConfig.DisableYellowFilter) {
|
||||
flattenedResults = flattenedResults.filter((result) => {
|
||||
const typeName = result.type_name || '';
|
||||
return !yellowWords.some((word: string) => typeName.includes(word));
|
||||
});
|
||||
}
|
||||
const cacheTime = await getCacheTime();
|
||||
|
||||
if (flattenedResults.length === 0) {
|
||||
// no cache if empty
|
||||
return NextResponse.json({ results: [] }, { status: 200 });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ results: flattenedResults },
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
||||
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Netlify-Vary': 'query',
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: '搜索失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { AdminConfig } from '@/lib/admin.types';
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getAvailableApiSites, getConfig } from '@/lib/config';
|
||||
import { searchFromApi } from '@/lib/downstream';
|
||||
import { yellowWords } from '@/lib/yellow';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// 从 cookie 获取用户信息
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get('q')?.trim();
|
||||
|
||||
if (!query) {
|
||||
return NextResponse.json({ suggestions: [] });
|
||||
}
|
||||
|
||||
// 生成建议
|
||||
const suggestions = await generateSuggestions(config, query, authInfo.username);
|
||||
|
||||
// 从配置中获取缓存时间,如果没有配置则使用默认值300秒(5分钟)
|
||||
const cacheTime = config.SiteConfig.SiteInterfaceCacheTime || 300;
|
||||
|
||||
return NextResponse.json(
|
||||
{ suggestions },
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`,
|
||||
'CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`,
|
||||
'Netlify-Vary': 'query',
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('获取搜索建议失败', error);
|
||||
return NextResponse.json({ error: '获取搜索建议失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function generateSuggestions(config: AdminConfig, query: string, username: string): Promise<
|
||||
Array<{
|
||||
text: string;
|
||||
type: 'exact' | 'related' | 'suggestion';
|
||||
score: number;
|
||||
}>
|
||||
> {
|
||||
const queryLower = query.toLowerCase();
|
||||
|
||||
const apiSites = await getAvailableApiSites(username);
|
||||
let realKeywords: string[] = [];
|
||||
|
||||
if (apiSites.length > 0) {
|
||||
// 取第一个可用的数据源进行搜索
|
||||
const firstSite = apiSites[0];
|
||||
const results = await searchFromApi(firstSite, query);
|
||||
|
||||
realKeywords = Array.from(
|
||||
new Set(
|
||||
results
|
||||
.filter((r: any) => config.SiteConfig.DisableYellowFilter || !yellowWords.some((word: string) => (r.type_name || '').includes(word)))
|
||||
.map((r: any) => r.title)
|
||||
.filter(Boolean)
|
||||
.flatMap((title: string) => title.split(/[ -::·、-]/))
|
||||
.filter(
|
||||
(w: string) => w.length > 1 && w.toLowerCase().includes(queryLower)
|
||||
)
|
||||
)
|
||||
).slice(0, 8);
|
||||
}
|
||||
|
||||
// 根据关键词与查询的匹配程度计算分数,并动态确定类型
|
||||
const realSuggestions = realKeywords.map((word) => {
|
||||
const wordLower = word.toLowerCase();
|
||||
const queryWords = queryLower.split(/[ -::·、-]/);
|
||||
|
||||
// 计算匹配分数:完全匹配得分更高
|
||||
let score = 1.0;
|
||||
if (wordLower === queryLower) {
|
||||
score = 2.0; // 完全匹配
|
||||
} else if (
|
||||
wordLower.startsWith(queryLower) ||
|
||||
wordLower.endsWith(queryLower)
|
||||
) {
|
||||
score = 1.8; // 前缀或后缀匹配
|
||||
} else if (queryWords.some((qw) => wordLower.includes(qw))) {
|
||||
score = 1.5; // 包含查询词
|
||||
}
|
||||
|
||||
// 根据匹配程度确定类型
|
||||
let type: 'exact' | 'related' | 'suggestion' = 'related';
|
||||
if (score >= 2.0) {
|
||||
type = 'exact';
|
||||
} else if (score >= 1.5) {
|
||||
type = 'related';
|
||||
} else {
|
||||
type = 'suggestion';
|
||||
}
|
||||
|
||||
return {
|
||||
text: word,
|
||||
type,
|
||||
score,
|
||||
};
|
||||
});
|
||||
|
||||
// 按分数降序排列,相同分数按类型优先级排列
|
||||
const sortedSuggestions = realSuggestions.sort((a, b) => {
|
||||
if (a.score !== b.score) {
|
||||
return b.score - a.score; // 分数高的在前
|
||||
}
|
||||
// 分数相同时,按类型优先级:exact > related > suggestion
|
||||
const typePriority = { exact: 3, related: 2, suggestion: 1 };
|
||||
return typePriority[b.type] - typePriority[a.type];
|
||||
});
|
||||
|
||||
return sortedSuggestions;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getAvailableApiSites, getConfig } from '@/lib/config';
|
||||
import { searchFromApi } from '@/lib/downstream';
|
||||
import { yellowWords } from '@/lib/yellow';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get('q');
|
||||
|
||||
if (!query) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: '搜索关键词不能为空' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const apiSites = await getAvailableApiSites(authInfo.username);
|
||||
|
||||
// 共享状态
|
||||
let streamClosed = false;
|
||||
|
||||
// 创建可读流
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
// 辅助函数:安全地向控制器写入数据
|
||||
const safeEnqueue = (data: Uint8Array) => {
|
||||
try {
|
||||
if (streamClosed || (!controller.desiredSize && controller.desiredSize !== 0)) {
|
||||
// 流已标记为关闭或控制器已关闭
|
||||
return false;
|
||||
}
|
||||
controller.enqueue(data);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// 控制器已关闭或出现其他错误
|
||||
console.warn('Failed to enqueue data:', error);
|
||||
streamClosed = true;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 发送开始事件
|
||||
const startEvent = `data: ${JSON.stringify({
|
||||
type: 'start',
|
||||
query,
|
||||
totalSources: apiSites.length,
|
||||
timestamp: Date.now()
|
||||
})}\n\n`;
|
||||
|
||||
if (!safeEnqueue(encoder.encode(startEvent))) {
|
||||
return; // 连接已关闭,提前退出
|
||||
}
|
||||
|
||||
// 记录已完成的源数量
|
||||
let completedSources = 0;
|
||||
const allResults: any[] = [];
|
||||
|
||||
// 为每个源创建搜索 Promise
|
||||
const searchPromises = apiSites.map(async (site) => {
|
||||
try {
|
||||
// 添加超时控制
|
||||
const searchPromise = Promise.race([
|
||||
searchFromApi(site, query),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`${site.name} timeout`)), 20000)
|
||||
),
|
||||
]);
|
||||
|
||||
const results = await searchPromise as any[];
|
||||
|
||||
// 过滤黄色内容
|
||||
let filteredResults = results;
|
||||
if (!config.SiteConfig.DisableYellowFilter) {
|
||||
filteredResults = results.filter((result) => {
|
||||
const typeName = result.type_name || '';
|
||||
return !yellowWords.some((word: string) => typeName.includes(word));
|
||||
});
|
||||
}
|
||||
|
||||
// 发送该源的搜索结果
|
||||
completedSources++;
|
||||
|
||||
if (!streamClosed) {
|
||||
const sourceEvent = `data: ${JSON.stringify({
|
||||
type: 'source_result',
|
||||
source: site.key,
|
||||
sourceName: site.name,
|
||||
results: filteredResults,
|
||||
timestamp: Date.now()
|
||||
})}\n\n`;
|
||||
|
||||
if (!safeEnqueue(encoder.encode(sourceEvent))) {
|
||||
streamClosed = true;
|
||||
return; // 连接已关闭,停止处理
|
||||
}
|
||||
}
|
||||
|
||||
if (filteredResults.length > 0) {
|
||||
allResults.push(...filteredResults);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.warn(`搜索失败 ${site.name}:`, error);
|
||||
|
||||
// 发送源错误事件
|
||||
completedSources++;
|
||||
|
||||
if (!streamClosed) {
|
||||
const errorEvent = `data: ${JSON.stringify({
|
||||
type: 'source_error',
|
||||
source: site.key,
|
||||
sourceName: site.name,
|
||||
error: error instanceof Error ? error.message : '搜索失败',
|
||||
timestamp: Date.now()
|
||||
})}\n\n`;
|
||||
|
||||
if (!safeEnqueue(encoder.encode(errorEvent))) {
|
||||
streamClosed = true;
|
||||
return; // 连接已关闭,停止处理
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否所有源都已完成
|
||||
if (completedSources === apiSites.length) {
|
||||
if (!streamClosed) {
|
||||
// 发送最终完成事件
|
||||
const completeEvent = `data: ${JSON.stringify({
|
||||
type: 'complete',
|
||||
totalResults: allResults.length,
|
||||
completedSources,
|
||||
timestamp: Date.now()
|
||||
})}\n\n`;
|
||||
|
||||
if (safeEnqueue(encoder.encode(completeEvent))) {
|
||||
// 只有在成功发送完成事件后才关闭流
|
||||
try {
|
||||
controller.close();
|
||||
} catch (error) {
|
||||
console.warn('Failed to close controller:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 等待所有搜索完成
|
||||
await Promise.allSettled(searchPromises);
|
||||
},
|
||||
|
||||
cancel() {
|
||||
// 客户端断开连接时,标记流已关闭
|
||||
streamClosed = true;
|
||||
console.log('Client disconnected, cancelling search stream');
|
||||
},
|
||||
});
|
||||
|
||||
// 返回流式响应
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
// 最大保存条数(与客户端保持一致)
|
||||
const HISTORY_LIMIT = 20;
|
||||
|
||||
/**
|
||||
* GET /api/searchhistory
|
||||
* 返回 string[]
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// 从 cookie 获取用户信息
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const history = await db.getSearchHistory(authInfo.username);
|
||||
return NextResponse.json(history, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('获取搜索历史失败', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/searchhistory
|
||||
* body: { keyword: string }
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// 从 cookie 获取用户信息
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const keyword: string = body.keyword?.trim();
|
||||
|
||||
if (!keyword) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Keyword is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await db.addSearchHistory(authInfo.username, keyword);
|
||||
|
||||
// 再次获取最新列表,确保客户端与服务端同步
|
||||
const history = await db.getSearchHistory(authInfo.username);
|
||||
return NextResponse.json(history.slice(0, HISTORY_LIMIT), { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('添加搜索历史失败', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/searchhistory?keyword=<kw>
|
||||
*
|
||||
* 1. 不带 keyword -> 清空全部搜索历史
|
||||
* 2. 带 keyword=<kw> -> 删除单条关键字
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
// 从 cookie 获取用户信息
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const kw = searchParams.get('keyword')?.trim();
|
||||
|
||||
await db.deleteSearchHistory(authInfo.username, kw || undefined);
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('删除搜索历史失败', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { CURRENT_VERSION } from '@/lib/version'
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
console.log('server-config called: ', request.url);
|
||||
|
||||
const config = await getConfig();
|
||||
const result = {
|
||||
SiteName: config.SiteConfig.SiteName,
|
||||
StorageType: process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage',
|
||||
Version: CURRENT_VERSION,
|
||||
};
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import { SkipConfig } from '@/lib/types';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = config.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const source = searchParams.get('source');
|
||||
const id = searchParams.get('id');
|
||||
|
||||
if (source && id) {
|
||||
// 获取单个配置
|
||||
const config = await db.getSkipConfig(authInfo.username, source, id);
|
||||
return NextResponse.json(config);
|
||||
} else {
|
||||
// 获取所有配置
|
||||
const configs = await db.getAllSkipConfigs(authInfo.username);
|
||||
return NextResponse.json(configs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取跳过片头片尾配置失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '获取跳过片头片尾配置失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
const adminConfig = await getConfig();
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = adminConfig.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { key, config } = body;
|
||||
|
||||
if (!key || !config) {
|
||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 解析key为source和id
|
||||
const [source, id] = key.split('+');
|
||||
if (!source || !id) {
|
||||
return NextResponse.json({ error: '无效的key格式' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 验证配置格式
|
||||
const skipConfig: SkipConfig = {
|
||||
enable: Boolean(config.enable),
|
||||
intro_time: Number(config.intro_time) || 0,
|
||||
outro_time: Number(config.outro_time) || 0,
|
||||
};
|
||||
|
||||
await db.setSkipConfig(authInfo.username, source, id, skipConfig);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('保存跳过片头片尾配置失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '保存跳过片头片尾配置失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
const adminConfig = await getConfig();
|
||||
if (authInfo.username !== process.env.ADMIN_USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const user = adminConfig.UserConfig.Users.find(
|
||||
(u) => u.username === authInfo.username
|
||||
);
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (user.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const key = searchParams.get('key');
|
||||
|
||||
if (!key) {
|
||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 解析key为source和id
|
||||
const [source, id] = key.split('+');
|
||||
if (!source || !id) {
|
||||
return NextResponse.json({ error: '无效的key格式' }, { status: 400 });
|
||||
}
|
||||
|
||||
await db.deleteSkipConfig(authInfo.username, source, id);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('删除跳过片头片尾配置失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '删除跳过片头片尾配置失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,822 @@
|
||||
/* eslint-disable no-console,react-hooks/exhaustive-deps,@typescript-eslint/no-explicit-any */
|
||||
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Suspense } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { GetBangumiCalendarData } from '@/lib/bangumi.client';
|
||||
import {
|
||||
getDoubanCategories,
|
||||
getDoubanList,
|
||||
getDoubanRecommends,
|
||||
} from '@/lib/douban.client';
|
||||
import { DoubanItem, DoubanResult } from '@/lib/types';
|
||||
|
||||
import DoubanCardSkeleton from '@/components/DoubanCardSkeleton';
|
||||
import DoubanCustomSelector from '@/components/DoubanCustomSelector';
|
||||
import DoubanSelector from '@/components/DoubanSelector';
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
import VideoCard from '@/components/VideoCard';
|
||||
|
||||
function DoubanPageClient() {
|
||||
const searchParams = useSearchParams();
|
||||
const [doubanData, setDoubanData] = useState<DoubanItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [selectorsReady, setSelectorsReady] = useState(false);
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const loadingRef = useRef<HTMLDivElement>(null);
|
||||
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 用于存储最新参数值的 refs
|
||||
const currentParamsRef = useRef({
|
||||
type: '',
|
||||
primarySelection: '',
|
||||
secondarySelection: '',
|
||||
multiLevelSelection: {} as Record<string, string>,
|
||||
selectedWeekday: '',
|
||||
currentPage: 0,
|
||||
});
|
||||
|
||||
const type = searchParams.get('type') || 'movie';
|
||||
|
||||
// 获取 runtimeConfig 中的自定义分类数据
|
||||
const [customCategories, setCustomCategories] = useState<
|
||||
Array<{ name: string; type: 'movie' | 'tv'; query: string }>
|
||||
>([]);
|
||||
|
||||
// 选择器状态 - 完全独立,不依赖URL参数
|
||||
const [primarySelection, setPrimarySelection] = useState<string>(() => {
|
||||
if (type === 'movie') return '热门';
|
||||
if (type === 'tv' || type === 'show') return '最近热门';
|
||||
if (type === 'anime') return '每日放送';
|
||||
return '';
|
||||
});
|
||||
const [secondarySelection, setSecondarySelection] = useState<string>(() => {
|
||||
if (type === 'movie') return '全部';
|
||||
if (type === 'tv') return 'tv';
|
||||
if (type === 'show') return 'show';
|
||||
return '全部';
|
||||
});
|
||||
|
||||
// MultiLevelSelector 状态
|
||||
const [multiLevelValues, setMultiLevelValues] = useState<
|
||||
Record<string, string>
|
||||
>({
|
||||
type: 'all',
|
||||
region: 'all',
|
||||
year: 'all',
|
||||
platform: 'all',
|
||||
label: 'all',
|
||||
sort: 'T',
|
||||
});
|
||||
|
||||
// 星期选择器状态
|
||||
const [selectedWeekday, setSelectedWeekday] = useState<string>('');
|
||||
|
||||
// 获取自定义分类数据
|
||||
useEffect(() => {
|
||||
const runtimeConfig = (window as any).RUNTIME_CONFIG;
|
||||
if (runtimeConfig?.CUSTOM_CATEGORIES?.length > 0) {
|
||||
setCustomCategories(runtimeConfig.CUSTOM_CATEGORIES);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 同步最新参数值到 ref
|
||||
useEffect(() => {
|
||||
currentParamsRef.current = {
|
||||
type,
|
||||
primarySelection,
|
||||
secondarySelection,
|
||||
multiLevelSelection: multiLevelValues,
|
||||
selectedWeekday,
|
||||
currentPage,
|
||||
};
|
||||
}, [
|
||||
type,
|
||||
primarySelection,
|
||||
secondarySelection,
|
||||
multiLevelValues,
|
||||
selectedWeekday,
|
||||
currentPage,
|
||||
]);
|
||||
|
||||
// 初始化时标记选择器为准备好状态
|
||||
useEffect(() => {
|
||||
// 短暂延迟确保初始状态设置完成
|
||||
const timer = setTimeout(() => {
|
||||
setSelectorsReady(true);
|
||||
}, 50);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []); // 只在组件挂载时执行一次
|
||||
|
||||
// type变化时立即重置selectorsReady(最高优先级)
|
||||
useEffect(() => {
|
||||
setSelectorsReady(false);
|
||||
setLoading(true); // 立即显示loading状态
|
||||
}, [type]);
|
||||
|
||||
// 当type变化时重置选择器状态
|
||||
useEffect(() => {
|
||||
if (type === 'custom' && customCategories.length > 0) {
|
||||
// 自定义分类模式:优先选择 movie,如果没有 movie 则选择 tv
|
||||
const types = Array.from(
|
||||
new Set(customCategories.map((cat) => cat.type))
|
||||
);
|
||||
if (types.length > 0) {
|
||||
// 优先选择 movie,如果没有 movie 则选择 tv
|
||||
let selectedType = types[0]; // 默认选择第一个
|
||||
if (types.includes('movie')) {
|
||||
selectedType = 'movie';
|
||||
} else {
|
||||
selectedType = 'tv';
|
||||
}
|
||||
setPrimarySelection(selectedType);
|
||||
|
||||
// 设置选中类型的第一个分类的 query 作为二级选择
|
||||
const firstCategory = customCategories.find(
|
||||
(cat) => cat.type === selectedType
|
||||
);
|
||||
if (firstCategory) {
|
||||
setSecondarySelection(firstCategory.query);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 原有逻辑
|
||||
if (type === 'movie') {
|
||||
setPrimarySelection('热门');
|
||||
setSecondarySelection('全部');
|
||||
} else if (type === 'tv') {
|
||||
setPrimarySelection('最近热门');
|
||||
setSecondarySelection('tv');
|
||||
} else if (type === 'show') {
|
||||
setPrimarySelection('最近热门');
|
||||
setSecondarySelection('show');
|
||||
} else if (type === 'anime') {
|
||||
setPrimarySelection('每日放送');
|
||||
setSecondarySelection('全部');
|
||||
} else {
|
||||
setPrimarySelection('');
|
||||
setSecondarySelection('全部');
|
||||
}
|
||||
}
|
||||
|
||||
// 清空 MultiLevelSelector 状态
|
||||
setMultiLevelValues({
|
||||
type: 'all',
|
||||
region: 'all',
|
||||
year: 'all',
|
||||
platform: 'all',
|
||||
label: 'all',
|
||||
sort: 'T',
|
||||
});
|
||||
|
||||
// 使用短暂延迟确保状态更新完成后标记选择器准备好
|
||||
const timer = setTimeout(() => {
|
||||
setSelectorsReady(true);
|
||||
}, 50);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [type, customCategories]);
|
||||
|
||||
// 生成骨架屏数据
|
||||
const skeletonData = Array.from({ length: 25 }, (_, index) => index);
|
||||
|
||||
// 参数快照比较函数
|
||||
const isSnapshotEqual = useCallback(
|
||||
(
|
||||
snapshot1: {
|
||||
type: string;
|
||||
primarySelection: string;
|
||||
secondarySelection: string;
|
||||
multiLevelSelection: Record<string, string>;
|
||||
selectedWeekday: string;
|
||||
currentPage: number;
|
||||
},
|
||||
snapshot2: {
|
||||
type: string;
|
||||
primarySelection: string;
|
||||
secondarySelection: string;
|
||||
multiLevelSelection: Record<string, string>;
|
||||
selectedWeekday: string;
|
||||
currentPage: number;
|
||||
}
|
||||
) => {
|
||||
return (
|
||||
snapshot1.type === snapshot2.type &&
|
||||
snapshot1.primarySelection === snapshot2.primarySelection &&
|
||||
snapshot1.secondarySelection === snapshot2.secondarySelection &&
|
||||
snapshot1.selectedWeekday === snapshot2.selectedWeekday &&
|
||||
snapshot1.currentPage === snapshot2.currentPage &&
|
||||
JSON.stringify(snapshot1.multiLevelSelection) ===
|
||||
JSON.stringify(snapshot2.multiLevelSelection)
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 生成API请求参数的辅助函数
|
||||
const getRequestParams = useCallback(
|
||||
(pageStart: number) => {
|
||||
// 当type为tv或show时,kind统一为'tv',category使用type本身
|
||||
if (type === 'tv' || type === 'show') {
|
||||
return {
|
||||
kind: 'tv' as const,
|
||||
category: type,
|
||||
type: secondarySelection,
|
||||
pageLimit: 25,
|
||||
pageStart,
|
||||
};
|
||||
}
|
||||
|
||||
// 电影类型保持原逻辑
|
||||
return {
|
||||
kind: type as 'tv' | 'movie',
|
||||
category: primarySelection,
|
||||
type: secondarySelection,
|
||||
pageLimit: 25,
|
||||
pageStart,
|
||||
};
|
||||
},
|
||||
[type, primarySelection, secondarySelection]
|
||||
);
|
||||
|
||||
// 防抖的数据加载函数
|
||||
const loadInitialData = useCallback(async () => {
|
||||
// 创建当前参数的快照
|
||||
const requestSnapshot = {
|
||||
type,
|
||||
primarySelection,
|
||||
secondarySelection,
|
||||
multiLevelSelection: multiLevelValues,
|
||||
selectedWeekday,
|
||||
currentPage: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
// 确保在加载初始数据时重置页面状态
|
||||
setDoubanData([]);
|
||||
setCurrentPage(0);
|
||||
setHasMore(true);
|
||||
setIsLoadingMore(false);
|
||||
|
||||
let data: DoubanResult;
|
||||
|
||||
if (type === 'custom') {
|
||||
// 自定义分类模式:根据选中的一级和二级选项获取对应的分类
|
||||
const selectedCategory = customCategories.find(
|
||||
(cat) =>
|
||||
cat.type === primarySelection && cat.query === secondarySelection
|
||||
);
|
||||
|
||||
if (selectedCategory) {
|
||||
data = await getDoubanList({
|
||||
tag: selectedCategory.query,
|
||||
type: selectedCategory.type,
|
||||
pageLimit: 25,
|
||||
pageStart: 0,
|
||||
});
|
||||
} else {
|
||||
throw new Error('没有找到对应的分类');
|
||||
}
|
||||
} else if (type === 'anime' && primarySelection === '每日放送') {
|
||||
const calendarData = await GetBangumiCalendarData();
|
||||
const weekdayData = calendarData.find(
|
||||
(item) => item.weekday.en === selectedWeekday
|
||||
);
|
||||
if (weekdayData) {
|
||||
data = {
|
||||
code: 200,
|
||||
message: 'success',
|
||||
list: weekdayData.items.map((item) => ({
|
||||
id: item.id?.toString() || '',
|
||||
title: item.name_cn || item.name,
|
||||
poster:
|
||||
item.images.large ||
|
||||
item.images.common ||
|
||||
item.images.medium ||
|
||||
item.images.small ||
|
||||
item.images.grid,
|
||||
rate: item.rating?.score?.toFixed(1) || '',
|
||||
year: item.air_date?.split('-')?.[0] || '',
|
||||
})),
|
||||
};
|
||||
} else {
|
||||
throw new Error('没有找到对应的日期');
|
||||
}
|
||||
} else if (type === 'anime') {
|
||||
data = await getDoubanRecommends({
|
||||
kind: primarySelection === '番剧' ? 'tv' : 'movie',
|
||||
pageLimit: 25,
|
||||
pageStart: 0,
|
||||
category: '动画',
|
||||
format: primarySelection === '番剧' ? '电视剧' : '',
|
||||
region: multiLevelValues.region
|
||||
? (multiLevelValues.region as string)
|
||||
: '',
|
||||
year: multiLevelValues.year ? (multiLevelValues.year as string) : '',
|
||||
platform: multiLevelValues.platform
|
||||
? (multiLevelValues.platform as string)
|
||||
: '',
|
||||
sort: multiLevelValues.sort ? (multiLevelValues.sort as string) : '',
|
||||
label: multiLevelValues.label
|
||||
? (multiLevelValues.label as string)
|
||||
: '',
|
||||
});
|
||||
} else if (primarySelection === '全部') {
|
||||
data = await getDoubanRecommends({
|
||||
kind: type === 'show' ? 'tv' : (type as 'tv' | 'movie'),
|
||||
pageLimit: 25,
|
||||
pageStart: 0, // 初始数据加载始终从第一页开始
|
||||
category: multiLevelValues.type
|
||||
? (multiLevelValues.type as string)
|
||||
: '',
|
||||
format: type === 'show' ? '综艺' : type === 'tv' ? '电视剧' : '',
|
||||
region: multiLevelValues.region
|
||||
? (multiLevelValues.region as string)
|
||||
: '',
|
||||
year: multiLevelValues.year ? (multiLevelValues.year as string) : '',
|
||||
platform: multiLevelValues.platform
|
||||
? (multiLevelValues.platform as string)
|
||||
: '',
|
||||
sort: multiLevelValues.sort ? (multiLevelValues.sort as string) : '',
|
||||
label: multiLevelValues.label
|
||||
? (multiLevelValues.label as string)
|
||||
: '',
|
||||
});
|
||||
} else {
|
||||
data = await getDoubanCategories(getRequestParams(0));
|
||||
}
|
||||
|
||||
if (data.code === 200) {
|
||||
// 检查参数是否仍然一致,如果一致才设置数据
|
||||
// 使用 ref 获取最新的当前值
|
||||
const currentSnapshot = { ...currentParamsRef.current };
|
||||
|
||||
if (isSnapshotEqual(requestSnapshot, currentSnapshot)) {
|
||||
setDoubanData(data.list);
|
||||
setHasMore(data.list.length !== 0);
|
||||
setLoading(false);
|
||||
} else {
|
||||
console.log('参数不一致,不执行任何操作,避免设置过期数据');
|
||||
}
|
||||
// 如果参数不一致,不执行任何操作,避免设置过期数据
|
||||
} else {
|
||||
throw new Error(data.message || '获取数据失败');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setLoading(false); // 发生错误时总是停止loading状态
|
||||
}
|
||||
}, [
|
||||
type,
|
||||
primarySelection,
|
||||
secondarySelection,
|
||||
multiLevelValues,
|
||||
selectedWeekday,
|
||||
getRequestParams,
|
||||
customCategories,
|
||||
]);
|
||||
|
||||
// 只在选择器准备好后才加载数据
|
||||
useEffect(() => {
|
||||
// 只有在选择器准备好时才开始加载
|
||||
if (!selectorsReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 清除之前的防抖定时器
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
|
||||
// 使用防抖机制加载数据,避免连续状态更新触发多次请求
|
||||
debounceTimeoutRef.current = setTimeout(() => {
|
||||
loadInitialData();
|
||||
}, 100); // 100ms 防抖延迟
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
selectorsReady,
|
||||
type,
|
||||
primarySelection,
|
||||
secondarySelection,
|
||||
multiLevelValues,
|
||||
selectedWeekday,
|
||||
loadInitialData,
|
||||
]);
|
||||
|
||||
// 单独处理 currentPage 变化(加载更多)
|
||||
useEffect(() => {
|
||||
if (currentPage > 0) {
|
||||
const fetchMoreData = async () => {
|
||||
// 创建当前参数的快照
|
||||
const requestSnapshot = {
|
||||
type,
|
||||
primarySelection,
|
||||
secondarySelection,
|
||||
multiLevelSelection: multiLevelValues,
|
||||
selectedWeekday,
|
||||
currentPage,
|
||||
};
|
||||
|
||||
try {
|
||||
setIsLoadingMore(true);
|
||||
|
||||
let data: DoubanResult;
|
||||
if (type === 'custom') {
|
||||
// 自定义分类模式:根据选中的一级和二级选项获取对应的分类
|
||||
const selectedCategory = customCategories.find(
|
||||
(cat) =>
|
||||
cat.type === primarySelection &&
|
||||
cat.query === secondarySelection
|
||||
);
|
||||
|
||||
if (selectedCategory) {
|
||||
data = await getDoubanList({
|
||||
tag: selectedCategory.query,
|
||||
type: selectedCategory.type,
|
||||
pageLimit: 25,
|
||||
pageStart: currentPage * 25,
|
||||
});
|
||||
} else {
|
||||
throw new Error('没有找到对应的分类');
|
||||
}
|
||||
} else if (type === 'anime' && primarySelection === '每日放送') {
|
||||
// 每日放送模式下,不进行数据请求,返回空数据
|
||||
data = {
|
||||
code: 200,
|
||||
message: 'success',
|
||||
list: [],
|
||||
};
|
||||
} else if (type === 'anime') {
|
||||
data = await getDoubanRecommends({
|
||||
kind: primarySelection === '番剧' ? 'tv' : 'movie',
|
||||
pageLimit: 25,
|
||||
pageStart: currentPage * 25,
|
||||
category: '动画',
|
||||
format: primarySelection === '番剧' ? '电视剧' : '',
|
||||
region: multiLevelValues.region
|
||||
? (multiLevelValues.region as string)
|
||||
: '',
|
||||
year: multiLevelValues.year
|
||||
? (multiLevelValues.year as string)
|
||||
: '',
|
||||
platform: multiLevelValues.platform
|
||||
? (multiLevelValues.platform as string)
|
||||
: '',
|
||||
sort: multiLevelValues.sort
|
||||
? (multiLevelValues.sort as string)
|
||||
: '',
|
||||
label: multiLevelValues.label
|
||||
? (multiLevelValues.label as string)
|
||||
: '',
|
||||
});
|
||||
} else if (primarySelection === '全部') {
|
||||
data = await getDoubanRecommends({
|
||||
kind: type === 'show' ? 'tv' : (type as 'tv' | 'movie'),
|
||||
pageLimit: 25,
|
||||
pageStart: currentPage * 25,
|
||||
category: multiLevelValues.type
|
||||
? (multiLevelValues.type as string)
|
||||
: '',
|
||||
format: type === 'show' ? '综艺' : type === 'tv' ? '电视剧' : '',
|
||||
region: multiLevelValues.region
|
||||
? (multiLevelValues.region as string)
|
||||
: '',
|
||||
year: multiLevelValues.year
|
||||
? (multiLevelValues.year as string)
|
||||
: '',
|
||||
platform: multiLevelValues.platform
|
||||
? (multiLevelValues.platform as string)
|
||||
: '',
|
||||
sort: multiLevelValues.sort
|
||||
? (multiLevelValues.sort as string)
|
||||
: '',
|
||||
label: multiLevelValues.label
|
||||
? (multiLevelValues.label as string)
|
||||
: '',
|
||||
});
|
||||
} else {
|
||||
data = await getDoubanCategories(
|
||||
getRequestParams(currentPage * 25)
|
||||
);
|
||||
}
|
||||
|
||||
if (data.code === 200) {
|
||||
// 检查参数是否仍然一致,如果一致才设置数据
|
||||
// 使用 ref 获取最新的当前值
|
||||
const currentSnapshot = { ...currentParamsRef.current };
|
||||
|
||||
if (isSnapshotEqual(requestSnapshot, currentSnapshot)) {
|
||||
setDoubanData((prev) => [...prev, ...data.list]);
|
||||
setHasMore(data.list.length !== 0);
|
||||
} else {
|
||||
console.log('参数不一致,不执行任何操作,避免设置过期数据');
|
||||
}
|
||||
} else {
|
||||
throw new Error(data.message || '获取数据失败');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMoreData();
|
||||
}
|
||||
}, [
|
||||
currentPage,
|
||||
type,
|
||||
primarySelection,
|
||||
secondarySelection,
|
||||
customCategories,
|
||||
multiLevelValues,
|
||||
selectedWeekday,
|
||||
]);
|
||||
|
||||
// 设置滚动监听
|
||||
useEffect(() => {
|
||||
// 如果没有更多数据或正在加载,则不设置监听
|
||||
if (!hasMore || isLoadingMore || loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保 loadingRef 存在
|
||||
if (!loadingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore && !isLoadingMore) {
|
||||
setCurrentPage((prev) => prev + 1);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
observer.observe(loadingRef.current);
|
||||
observerRef.current = observer;
|
||||
|
||||
return () => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
}
|
||||
};
|
||||
}, [hasMore, isLoadingMore, loading]);
|
||||
|
||||
// 处理选择器变化
|
||||
const handlePrimaryChange = useCallback(
|
||||
(value: string) => {
|
||||
// 只有当值真正改变时才设置loading状态
|
||||
if (value !== primarySelection) {
|
||||
setLoading(true);
|
||||
// 立即重置页面状态,防止基于旧状态的请求
|
||||
setCurrentPage(0);
|
||||
setDoubanData([]);
|
||||
setHasMore(true);
|
||||
setIsLoadingMore(false);
|
||||
|
||||
// 清空 MultiLevelSelector 状态
|
||||
setMultiLevelValues({
|
||||
type: 'all',
|
||||
region: 'all',
|
||||
year: 'all',
|
||||
platform: 'all',
|
||||
label: 'all',
|
||||
sort: 'T',
|
||||
});
|
||||
|
||||
// 如果是自定义分类模式,同时更新一级和二级选择器
|
||||
if (type === 'custom' && customCategories.length > 0) {
|
||||
const firstCategory = customCategories.find(
|
||||
(cat) => cat.type === value
|
||||
);
|
||||
if (firstCategory) {
|
||||
// 批量更新状态,避免多次触发数据加载
|
||||
setPrimarySelection(value);
|
||||
setSecondarySelection(firstCategory.query);
|
||||
} else {
|
||||
setPrimarySelection(value);
|
||||
}
|
||||
} else {
|
||||
// 电视剧和综艺切换到"最近热门"时,重置二级分类为第一个选项
|
||||
if ((type === 'tv' || type === 'show') && value === '最近热门') {
|
||||
setPrimarySelection(value);
|
||||
if (type === 'tv') {
|
||||
setSecondarySelection('tv');
|
||||
} else if (type === 'show') {
|
||||
setSecondarySelection('show');
|
||||
}
|
||||
} else {
|
||||
setPrimarySelection(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[primarySelection, type, customCategories]
|
||||
);
|
||||
|
||||
const handleSecondaryChange = useCallback(
|
||||
(value: string) => {
|
||||
// 只有当值真正改变时才设置loading状态
|
||||
if (value !== secondarySelection) {
|
||||
setLoading(true);
|
||||
// 立即重置页面状态,防止基于旧状态的请求
|
||||
setCurrentPage(0);
|
||||
setDoubanData([]);
|
||||
setHasMore(true);
|
||||
setIsLoadingMore(false);
|
||||
setSecondarySelection(value);
|
||||
}
|
||||
},
|
||||
[secondarySelection]
|
||||
);
|
||||
|
||||
const handleMultiLevelChange = useCallback(
|
||||
(values: Record<string, string>) => {
|
||||
// 比较两个对象是否相同,忽略顺序
|
||||
const isEqual = (
|
||||
obj1: Record<string, string>,
|
||||
obj2: Record<string, string>
|
||||
) => {
|
||||
const keys1 = Object.keys(obj1).sort();
|
||||
const keys2 = Object.keys(obj2).sort();
|
||||
|
||||
if (keys1.length !== keys2.length) return false;
|
||||
|
||||
return keys1.every((key) => obj1[key] === obj2[key]);
|
||||
};
|
||||
|
||||
// 如果相同,则不设置loading状态
|
||||
if (isEqual(values, multiLevelValues)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
// 立即重置页面状态,防止基于旧状态的请求
|
||||
setCurrentPage(0);
|
||||
setDoubanData([]);
|
||||
setHasMore(true);
|
||||
setIsLoadingMore(false);
|
||||
setMultiLevelValues(values);
|
||||
},
|
||||
[multiLevelValues]
|
||||
);
|
||||
|
||||
const handleWeekdayChange = useCallback((weekday: string) => {
|
||||
setSelectedWeekday(weekday);
|
||||
}, []);
|
||||
|
||||
const getPageTitle = () => {
|
||||
// 根据 type 生成标题
|
||||
return type === 'movie'
|
||||
? '电影'
|
||||
: type === 'tv'
|
||||
? '电视剧'
|
||||
: type === 'anime'
|
||||
? '动漫'
|
||||
: type === 'show'
|
||||
? '综艺'
|
||||
: '自定义';
|
||||
};
|
||||
|
||||
const getPageDescription = () => {
|
||||
if (type === 'anime' && primarySelection === '每日放送') {
|
||||
return '来自 Bangumi 番组计划的精选内容';
|
||||
}
|
||||
return '来自豆瓣的精选内容';
|
||||
};
|
||||
|
||||
const getActivePath = () => {
|
||||
const params = new URLSearchParams();
|
||||
if (type) params.set('type', type);
|
||||
|
||||
const queryString = params.toString();
|
||||
const activePath = `/douban${queryString ? `?${queryString}` : ''}`;
|
||||
return activePath;
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout activePath={getActivePath()}>
|
||||
<div className='px-4 sm:px-10 py-4 sm:py-8 overflow-visible'>
|
||||
{/* 页面标题和选择器 */}
|
||||
<div className='mb-6 sm:mb-8 space-y-4 sm:space-y-6'>
|
||||
{/* 页面标题 */}
|
||||
<div>
|
||||
<h1 className='text-2xl sm:text-3xl font-bold text-gray-800 mb-1 sm:mb-2 dark:text-gray-200'>
|
||||
{getPageTitle()}
|
||||
</h1>
|
||||
<p className='text-sm sm:text-base text-gray-600 dark:text-gray-400'>
|
||||
{getPageDescription()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 选择器组件 */}
|
||||
{type !== 'custom' ? (
|
||||
<div className='bg-white/60 dark:bg-gray-800/40 rounded-2xl p-4 sm:p-6 border border-gray-200/30 dark:border-gray-700/30 backdrop-blur-sm'>
|
||||
<DoubanSelector
|
||||
type={type as 'movie' | 'tv' | 'show' | 'anime'}
|
||||
primarySelection={primarySelection}
|
||||
secondarySelection={secondarySelection}
|
||||
onPrimaryChange={handlePrimaryChange}
|
||||
onSecondaryChange={handleSecondaryChange}
|
||||
onMultiLevelChange={handleMultiLevelChange}
|
||||
onWeekdayChange={handleWeekdayChange}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className='bg-white/60 dark:bg-gray-800/40 rounded-2xl p-4 sm:p-6 border border-gray-200/30 dark:border-gray-700/30 backdrop-blur-sm'>
|
||||
<DoubanCustomSelector
|
||||
customCategories={customCategories}
|
||||
primarySelection={primarySelection}
|
||||
secondarySelection={secondarySelection}
|
||||
onPrimaryChange={handlePrimaryChange}
|
||||
onSecondaryChange={handleSecondaryChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 内容展示区域 */}
|
||||
<div className='max-w-[95%] mx-auto mt-8 overflow-visible'>
|
||||
{/* 内容网格 */}
|
||||
<div className='justify-start grid grid-cols-3 gap-x-2 gap-y-12 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,minmax(160px,1fr))] sm:gap-x-8 sm:gap-y-20'>
|
||||
{loading || !selectorsReady
|
||||
? // 显示骨架屏
|
||||
skeletonData.map((index) => <DoubanCardSkeleton key={index} />)
|
||||
: // 显示实际数据
|
||||
doubanData.map((item, index) => (
|
||||
<div key={`${item.title}-${index}`} className='w-full'>
|
||||
<VideoCard
|
||||
from='douban'
|
||||
title={item.title}
|
||||
poster={item.poster}
|
||||
douban_id={Number(item.id)}
|
||||
rate={item.rate}
|
||||
year={item.year}
|
||||
type={type === 'movie' ? 'movie' : ''} // 电影类型严格控制,tv 不控
|
||||
isBangumi={
|
||||
type === 'anime' && primarySelection === '每日放送'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 加载更多指示器 */}
|
||||
{hasMore && !loading && (
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el && el.offsetParent !== null) {
|
||||
(
|
||||
loadingRef as React.MutableRefObject<HTMLDivElement | null>
|
||||
).current = el;
|
||||
}
|
||||
}}
|
||||
className='flex justify-center mt-12 py-8'
|
||||
>
|
||||
{isLoadingMore && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='animate-spin rounded-full h-6 w-6 border-b-2 border-green-500'></div>
|
||||
<span className='text-gray-600'>加载中...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 没有更多数据提示 */}
|
||||
{!hasMore && doubanData.length > 0 && (
|
||||
<div className='text-center text-gray-500 py-8'>已加载全部内容</div>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!loading && doubanData.length === 0 && (
|
||||
<div className='text-center text-gray-500 py-8'>暂无相关内容</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DoubanPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<DoubanPageClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer utilities {
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari and Opera */
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
/* 阻止 iOS Safari 拉动回弹 */
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
}
|
||||
|
||||
html:not(.dark) body {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
#e6f3fb 0%,
|
||||
#eaf3f7 18%,
|
||||
#f7f7f3 38%,
|
||||
#e9ecef 60%,
|
||||
#dbe3ea 80%,
|
||||
#d3dde6 100%
|
||||
);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(31, 41, 55, 0.1);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(75, 85, 99, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(107, 114, 128, 0.5);
|
||||
}
|
||||
|
||||
/* 视频卡片悬停效果 */
|
||||
.video-card-hover {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.video-card-hover:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* 渐变遮罩 */
|
||||
.gradient-overlay {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
rgba(0, 0, 0, 0.8) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* 隐藏移动端(<768px)垂直滚动条 */
|
||||
@media (max-width: 767px) {
|
||||
html,
|
||||
body {
|
||||
-ms-overflow-style: none; /* IE & Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar,
|
||||
body::-webkit-scrollbar {
|
||||
display: none; /* Chrome Safari */
|
||||
}
|
||||
}
|
||||
|
||||
/* 隐藏所有滚动条(兼容 WebKit、Firefox、IE/Edge) */
|
||||
* {
|
||||
-ms-overflow-style: none; /* IE & Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari, Opera */
|
||||
}
|
||||
|
||||
/* View Transitions API 动画 */
|
||||
@keyframes slide-from-top {
|
||||
from {
|
||||
clip-path: polygon(0 0, 100% 0, 100% 0, 0 0);
|
||||
}
|
||||
to {
|
||||
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-from-bottom {
|
||||
from {
|
||||
clip-path: polygon(0 100%, 100% 100%, 100% 100%, 0 100%);
|
||||
}
|
||||
to {
|
||||
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
|
||||
}
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation-duration: 0.8s;
|
||||
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
/*
|
||||
切换时,旧的视图不应该有动画,它应该在下面,等待被新的视图覆盖。
|
||||
这可以防止在动画完成前,页面底部提前变色。
|
||||
*/
|
||||
::view-transition-old(root) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* 从浅色到深色:新内容(深色)从顶部滑入 */
|
||||
html.dark::view-transition-new(root) {
|
||||
animation-name: slide-from-top;
|
||||
}
|
||||
|
||||
/* 从深色到浅色:新内容(浅色)从底部滑入 */
|
||||
html:not(.dark)::view-transition-new(root) {
|
||||
animation-name: slide-from-bottom;
|
||||
}
|
||||
|
||||
/* 强制播放器内部的 video 元素高度为 100%,并保持内容完整显示 */
|
||||
div[data-media-provider] video {
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.art-poster {
|
||||
background-size: contain !important; /* 使图片完整展示 */
|
||||
background-position: center center !important; /* 居中显示 */
|
||||
background-repeat: no-repeat !important; /* 防止重复 */
|
||||
background-color: #000 !important; /* 其余区域填充为黑色 */
|
||||
}
|
||||
|
||||
/* 隐藏移动端竖屏时的 pip 按钮 */
|
||||
@media (max-width: 768px) {
|
||||
.art-control-pip {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.art-control-fullscreenWeb {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.art-control-volume {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
|
||||
import './globals.css';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
|
||||
import { GlobalErrorIndicator } from '../components/GlobalErrorIndicator';
|
||||
import { SiteProvider } from '../components/SiteProvider';
|
||||
import { ThemeProvider } from '../components/ThemeProvider';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// 动态生成 metadata,支持配置更新后的标题变化
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
const config = await getConfig();
|
||||
let siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV';
|
||||
if (storageType !== 'localstorage') {
|
||||
siteName = config.SiteConfig.SiteName;
|
||||
}
|
||||
|
||||
return {
|
||||
title: siteName,
|
||||
description: '影视聚合',
|
||||
manifest: '/manifest.json',
|
||||
};
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
viewportFit: 'cover',
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
|
||||
let siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV';
|
||||
let announcement =
|
||||
process.env.ANNOUNCEMENT ||
|
||||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。';
|
||||
|
||||
let doubanProxyType = process.env.NEXT_PUBLIC_DOUBAN_PROXY_TYPE || 'cmliussss-cdn-tencent';
|
||||
let doubanProxy = process.env.NEXT_PUBLIC_DOUBAN_PROXY || '';
|
||||
let doubanImageProxyType =
|
||||
process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE || 'cmliussss-cdn-tencent';
|
||||
let doubanImageProxy = process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '';
|
||||
let disableYellowFilter =
|
||||
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true';
|
||||
let fluidSearch = process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false';
|
||||
let customCategories = [] as {
|
||||
name: string;
|
||||
type: 'movie' | 'tv';
|
||||
query: string;
|
||||
}[];
|
||||
if (storageType !== 'localstorage') {
|
||||
const config = await getConfig();
|
||||
siteName = config.SiteConfig.SiteName;
|
||||
announcement = config.SiteConfig.Announcement;
|
||||
|
||||
doubanProxyType = config.SiteConfig.DoubanProxyType;
|
||||
doubanProxy = config.SiteConfig.DoubanProxy;
|
||||
doubanImageProxyType = config.SiteConfig.DoubanImageProxyType;
|
||||
doubanImageProxy = config.SiteConfig.DoubanImageProxy;
|
||||
disableYellowFilter = config.SiteConfig.DisableYellowFilter;
|
||||
customCategories = config.CustomCategories.filter(
|
||||
(category) => !category.disabled
|
||||
).map((category) => ({
|
||||
name: category.name || '',
|
||||
type: category.type,
|
||||
query: category.query,
|
||||
}));
|
||||
fluidSearch = config.SiteConfig.FluidSearch;
|
||||
}
|
||||
|
||||
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
|
||||
const runtimeConfig = {
|
||||
STORAGE_TYPE: process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage',
|
||||
DOUBAN_PROXY_TYPE: doubanProxyType,
|
||||
DOUBAN_PROXY: doubanProxy,
|
||||
DOUBAN_IMAGE_PROXY_TYPE: doubanImageProxyType,
|
||||
DOUBAN_IMAGE_PROXY: doubanImageProxy,
|
||||
DISABLE_YELLOW_FILTER: disableYellowFilter,
|
||||
CUSTOM_CATEGORIES: customCategories,
|
||||
FLUID_SEARCH: fluidSearch,
|
||||
};
|
||||
|
||||
return (
|
||||
<html lang='zh-CN' suppressHydrationWarning>
|
||||
<head>
|
||||
<meta
|
||||
name='viewport'
|
||||
content='width=device-width, initial-scale=1.0, viewport-fit=cover'
|
||||
/>
|
||||
<link rel='apple-touch-icon' href='/icons/icon-192x192.png' />
|
||||
{/* 将配置序列化后直接写入脚本,浏览器端可通过 window.RUNTIME_CONFIG 获取 */}
|
||||
{/* eslint-disable-next-line @next/next/no-sync-scripts */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `window.RUNTIME_CONFIG = ${JSON.stringify(runtimeConfig)};`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={`${inter.className} min-h-screen bg-white text-gray-900 dark:bg-black dark:text-gray-200`}
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute='class'
|
||||
defaultTheme='system'
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<SiteProvider siteName={siteName} announcement={announcement}>
|
||||
{children}
|
||||
<GlobalErrorIndicator />
|
||||
</SiteProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,195 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
'use client';
|
||||
|
||||
import { AlertCircle, CheckCircle } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
|
||||
import { CURRENT_VERSION } from '@/lib/version';
|
||||
import { checkForUpdates, UpdateStatus } from '@/lib/version_check';
|
||||
|
||||
import { useSite } from '@/components/SiteProvider';
|
||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||
|
||||
// 版本显示组件
|
||||
function VersionDisplay() {
|
||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null);
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const checkUpdate = async () => {
|
||||
try {
|
||||
const status = await checkForUpdates();
|
||||
setUpdateStatus(status);
|
||||
} catch (_) {
|
||||
// do nothing
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkUpdate();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() =>
|
||||
window.open('https://github.com/MoonTechLab/LunaTV', '_blank')
|
||||
}
|
||||
className='absolute bottom-4 left-1/2 transform -translate-x-1/2 flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 transition-colors cursor-pointer'
|
||||
>
|
||||
<span className='font-mono'>v{CURRENT_VERSION}</span>
|
||||
{!isChecking && updateStatus !== UpdateStatus.FETCH_FAILED && (
|
||||
<div
|
||||
className={`flex items-center gap-1.5 ${updateStatus === UpdateStatus.HAS_UPDATE
|
||||
? 'text-yellow-600 dark:text-yellow-400'
|
||||
: updateStatus === UpdateStatus.NO_UPDATE
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{updateStatus === UpdateStatus.HAS_UPDATE && (
|
||||
<>
|
||||
<AlertCircle className='w-3.5 h-3.5' />
|
||||
<span className='font-semibold text-xs'>有新版本</span>
|
||||
</>
|
||||
)}
|
||||
{updateStatus === UpdateStatus.NO_UPDATE && (
|
||||
<>
|
||||
<CheckCircle className='w-3.5 h-3.5' />
|
||||
<span className='font-semibold text-xs'>已是最新</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginPageClient() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [password, setPassword] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [shouldAskUsername, setShouldAskUsername] = useState(false);
|
||||
|
||||
const { siteName } = useSite();
|
||||
|
||||
// 在客户端挂载后设置配置
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const storageType = (window as any).RUNTIME_CONFIG?.STORAGE_TYPE;
|
||||
setShouldAskUsername(storageType && storageType !== 'localstorage');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!password || (shouldAskUsername && !username)) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
password,
|
||||
...(shouldAskUsername ? { username } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const redirect = searchParams.get('redirect') || '/';
|
||||
router.replace(redirect);
|
||||
} else if (res.status === 401) {
|
||||
setError('密码错误');
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setError(data.error ?? '服务器错误');
|
||||
}
|
||||
} catch (error) {
|
||||
setError('网络错误,请稍后重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className='relative min-h-screen flex items-center justify-center px-4 overflow-hidden'>
|
||||
<div className='absolute top-4 right-4'>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<div className='relative z-10 w-full max-w-md rounded-3xl bg-gradient-to-b from-white/90 via-white/70 to-white/40 dark:from-zinc-900/90 dark:via-zinc-900/70 dark:to-zinc-900/40 backdrop-blur-xl shadow-2xl p-10 dark:border dark:border-zinc-800'>
|
||||
<h1 className='text-green-600 tracking-tight text-center text-3xl font-extrabold mb-8 bg-clip-text drop-shadow-sm'>
|
||||
{siteName}
|
||||
</h1>
|
||||
<form onSubmit={handleSubmit} className='space-y-8'>
|
||||
{shouldAskUsername && (
|
||||
<div>
|
||||
<label htmlFor='username' className='sr-only'>
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
id='username'
|
||||
type='text'
|
||||
autoComplete='username'
|
||||
className='block w-full rounded-lg border-0 py-3 px-4 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-white/60 dark:ring-white/20 placeholder:text-gray-500 dark:placeholder:text-gray-400 focus:ring-2 focus:ring-green-500 focus:outline-none sm:text-base bg-white/60 dark:bg-zinc-800/60 backdrop-blur'
|
||||
placeholder='输入用户名'
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor='password' className='sr-only'>
|
||||
密码
|
||||
</label>
|
||||
<input
|
||||
id='password'
|
||||
type='password'
|
||||
autoComplete='current-password'
|
||||
className='block w-full rounded-lg border-0 py-3 px-4 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-white/60 dark:ring-white/20 placeholder:text-gray-500 dark:placeholder:text-gray-400 focus:ring-2 focus:ring-green-500 focus:outline-none sm:text-base bg-white/60 dark:bg-zinc-800/60 backdrop-blur'
|
||||
placeholder='输入访问密码'
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className='text-sm text-red-600 dark:text-red-400'>{error}</p>
|
||||
)}
|
||||
|
||||
{/* 登录按钮 */}
|
||||
<button
|
||||
type='submit'
|
||||
disabled={
|
||||
!password || loading || (shouldAskUsername && !username)
|
||||
}
|
||||
className='inline-flex w-full justify-center rounded-lg bg-green-600 py-3 text-base font-semibold text-white shadow-lg transition-all duration-200 hover:from-green-600 hover:to-blue-600 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* 版本信息显示 */}
|
||||
<VersionDisplay />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<LoginPageClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps, no-console */
|
||||
|
||||
'use client';
|
||||
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
BangumiCalendarData,
|
||||
GetBangumiCalendarData,
|
||||
} from '@/lib/bangumi.client';
|
||||
// 客户端收藏 API
|
||||
import {
|
||||
clearAllFavorites,
|
||||
getAllFavorites,
|
||||
getAllPlayRecords,
|
||||
subscribeToDataUpdates,
|
||||
} from '@/lib/db.client';
|
||||
import { getDoubanCategories } from '@/lib/douban.client';
|
||||
import { DoubanItem } from '@/lib/types';
|
||||
|
||||
import CapsuleSwitch from '@/components/CapsuleSwitch';
|
||||
import ContinueWatching from '@/components/ContinueWatching';
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
import ScrollableRow from '@/components/ScrollableRow';
|
||||
import { useSite } from '@/components/SiteProvider';
|
||||
import VideoCard from '@/components/VideoCard';
|
||||
|
||||
function HomeClient() {
|
||||
const [activeTab, setActiveTab] = useState<'home' | 'favorites'>('home');
|
||||
const [hotMovies, setHotMovies] = useState<DoubanItem[]>([]);
|
||||
const [hotTvShows, setHotTvShows] = useState<DoubanItem[]>([]);
|
||||
const [hotVarietyShows, setHotVarietyShows] = useState<DoubanItem[]>([]);
|
||||
const [bangumiCalendarData, setBangumiCalendarData] = useState<
|
||||
BangumiCalendarData[]
|
||||
>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { announcement } = useSite();
|
||||
|
||||
const [showAnnouncement, setShowAnnouncement] = useState(false);
|
||||
|
||||
// 检查公告弹窗状态
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && announcement) {
|
||||
const hasSeenAnnouncement = localStorage.getItem('hasSeenAnnouncement');
|
||||
if (hasSeenAnnouncement !== announcement) {
|
||||
setShowAnnouncement(true);
|
||||
} else {
|
||||
setShowAnnouncement(Boolean(!hasSeenAnnouncement && announcement));
|
||||
}
|
||||
}
|
||||
}, [announcement]);
|
||||
|
||||
// 收藏夹数据
|
||||
type FavoriteItem = {
|
||||
id: string;
|
||||
source: string;
|
||||
title: string;
|
||||
poster: string;
|
||||
episodes: number;
|
||||
source_name: string;
|
||||
currentEpisode?: number;
|
||||
search_title?: string;
|
||||
origin?: 'vod' | 'live';
|
||||
};
|
||||
|
||||
const [favoriteItems, setFavoriteItems] = useState<FavoriteItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRecommendData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 并行获取热门电影、热门剧集和热门综艺
|
||||
const [moviesData, tvShowsData, varietyShowsData, bangumiCalendarData] =
|
||||
await Promise.all([
|
||||
getDoubanCategories({
|
||||
kind: 'movie',
|
||||
category: '热门',
|
||||
type: '全部',
|
||||
}),
|
||||
getDoubanCategories({ kind: 'tv', category: 'tv', type: 'tv' }),
|
||||
getDoubanCategories({ kind: 'tv', category: 'show', type: 'show' }),
|
||||
GetBangumiCalendarData(),
|
||||
]);
|
||||
|
||||
if (moviesData.code === 200) {
|
||||
setHotMovies(moviesData.list);
|
||||
}
|
||||
|
||||
if (tvShowsData.code === 200) {
|
||||
setHotTvShows(tvShowsData.list);
|
||||
}
|
||||
|
||||
if (varietyShowsData.code === 200) {
|
||||
setHotVarietyShows(varietyShowsData.list);
|
||||
}
|
||||
|
||||
setBangumiCalendarData(bangumiCalendarData);
|
||||
} catch (error) {
|
||||
console.error('获取推荐数据失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchRecommendData();
|
||||
}, []);
|
||||
|
||||
// 处理收藏数据更新的函数
|
||||
const updateFavoriteItems = async (allFavorites: Record<string, any>) => {
|
||||
const allPlayRecords = await getAllPlayRecords();
|
||||
|
||||
// 根据保存时间排序(从近到远)
|
||||
const sorted = Object.entries(allFavorites)
|
||||
.sort(([, a], [, b]) => b.save_time - a.save_time)
|
||||
.map(([key, fav]) => {
|
||||
const plusIndex = key.indexOf('+');
|
||||
const source = key.slice(0, plusIndex);
|
||||
const id = key.slice(plusIndex + 1);
|
||||
|
||||
// 查找对应的播放记录,获取当前集数
|
||||
const playRecord = allPlayRecords[key];
|
||||
const currentEpisode = playRecord?.index;
|
||||
|
||||
return {
|
||||
id,
|
||||
source,
|
||||
title: fav.title,
|
||||
year: fav.year,
|
||||
poster: fav.cover,
|
||||
episodes: fav.total_episodes,
|
||||
source_name: fav.source_name,
|
||||
currentEpisode,
|
||||
search_title: fav?.search_title,
|
||||
origin: fav?.origin,
|
||||
} as FavoriteItem;
|
||||
});
|
||||
setFavoriteItems(sorted);
|
||||
};
|
||||
|
||||
// 当切换到收藏夹时加载收藏数据
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'favorites') return;
|
||||
|
||||
const loadFavorites = async () => {
|
||||
const allFavorites = await getAllFavorites();
|
||||
await updateFavoriteItems(allFavorites);
|
||||
};
|
||||
|
||||
loadFavorites();
|
||||
|
||||
// 监听收藏更新事件
|
||||
const unsubscribe = subscribeToDataUpdates(
|
||||
'favoritesUpdated',
|
||||
(newFavorites: Record<string, any>) => {
|
||||
updateFavoriteItems(newFavorites);
|
||||
}
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
}, [activeTab]);
|
||||
|
||||
const handleCloseAnnouncement = (announcement: string) => {
|
||||
setShowAnnouncement(false);
|
||||
localStorage.setItem('hasSeenAnnouncement', announcement); // 记录已查看弹窗
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className='px-2 sm:px-10 py-4 sm:py-8 overflow-visible'>
|
||||
{/* 顶部 Tab 切换 */}
|
||||
<div className='mb-8 flex justify-center'>
|
||||
<CapsuleSwitch
|
||||
options={[
|
||||
{ label: '首页', value: 'home' },
|
||||
{ label: '收藏夹', value: 'favorites' },
|
||||
]}
|
||||
active={activeTab}
|
||||
onChange={(value) => setActiveTab(value as 'home' | 'favorites')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='max-w-[95%] mx-auto'>
|
||||
{activeTab === 'favorites' ? (
|
||||
// 收藏夹视图
|
||||
<section className='mb-8'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
我的收藏
|
||||
</h2>
|
||||
{favoriteItems.length > 0 && (
|
||||
<button
|
||||
className='text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
onClick={async () => {
|
||||
await clearAllFavorites();
|
||||
setFavoriteItems([]);
|
||||
}}
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className='justify-start grid grid-cols-3 gap-x-2 gap-y-14 sm:gap-y-20 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8'>
|
||||
{favoriteItems.map((item) => (
|
||||
<div key={item.id + item.source} className='w-full'>
|
||||
<VideoCard
|
||||
query={item.search_title}
|
||||
{...item}
|
||||
from='favorite'
|
||||
type={item.episodes > 1 ? 'tv' : ''}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{favoriteItems.length === 0 && (
|
||||
<div className='col-span-full text-center text-gray-500 py-8 dark:text-gray-400'>
|
||||
暂无收藏内容
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
// 首页视图
|
||||
<>
|
||||
{/* 继续观看 */}
|
||||
<ContinueWatching />
|
||||
|
||||
{/* 热门电影 */}
|
||||
<section className='mb-8'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
热门电影
|
||||
</h2>
|
||||
<Link
|
||||
href='/douban?type=movie'
|
||||
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
>
|
||||
查看更多
|
||||
<ChevronRight className='w-4 h-4 ml-1' />
|
||||
</Link>
|
||||
</div>
|
||||
<ScrollableRow>
|
||||
{loading
|
||||
? // 加载状态显示灰色占位数据
|
||||
Array.from({ length: 8 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
|
||||
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
|
||||
</div>
|
||||
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
|
||||
</div>
|
||||
))
|
||||
: // 显示真实数据
|
||||
hotMovies.map((movie, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<VideoCard
|
||||
from='douban'
|
||||
title={movie.title}
|
||||
poster={movie.poster}
|
||||
douban_id={Number(movie.id)}
|
||||
rate={movie.rate}
|
||||
year={movie.year}
|
||||
type='movie'
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
|
||||
{/* 热门剧集 */}
|
||||
<section className='mb-8'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
热门剧集
|
||||
</h2>
|
||||
<Link
|
||||
href='/douban?type=tv'
|
||||
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
>
|
||||
查看更多
|
||||
<ChevronRight className='w-4 h-4 ml-1' />
|
||||
</Link>
|
||||
</div>
|
||||
<ScrollableRow>
|
||||
{loading
|
||||
? // 加载状态显示灰色占位数据
|
||||
Array.from({ length: 8 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
|
||||
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
|
||||
</div>
|
||||
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
|
||||
</div>
|
||||
))
|
||||
: // 显示真实数据
|
||||
hotTvShows.map((show, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<VideoCard
|
||||
from='douban'
|
||||
title={show.title}
|
||||
poster={show.poster}
|
||||
douban_id={Number(show.id)}
|
||||
rate={show.rate}
|
||||
year={show.year}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
|
||||
{/* 每日新番放送 */}
|
||||
<section className='mb-8'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
新番放送
|
||||
</h2>
|
||||
<Link
|
||||
href='/douban?type=anime'
|
||||
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
>
|
||||
查看更多
|
||||
<ChevronRight className='w-4 h-4 ml-1' />
|
||||
</Link>
|
||||
</div>
|
||||
<ScrollableRow>
|
||||
{loading
|
||||
? // 加载状态显示灰色占位数据
|
||||
Array.from({ length: 8 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
|
||||
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
|
||||
</div>
|
||||
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
|
||||
</div>
|
||||
))
|
||||
: // 展示当前日期的番剧
|
||||
(() => {
|
||||
// 获取当前日期对应的星期
|
||||
const today = new Date();
|
||||
const weekdays = [
|
||||
'Sun',
|
||||
'Mon',
|
||||
'Tue',
|
||||
'Wed',
|
||||
'Thu',
|
||||
'Fri',
|
||||
'Sat',
|
||||
];
|
||||
const currentWeekday = weekdays[today.getDay()];
|
||||
|
||||
// 找到当前星期对应的番剧数据
|
||||
const todayAnimes =
|
||||
bangumiCalendarData.find(
|
||||
(item) => item.weekday.en === currentWeekday
|
||||
)?.items || [];
|
||||
|
||||
return todayAnimes.map((anime, index) => (
|
||||
<div
|
||||
key={`${anime.id}-${index}`}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<VideoCard
|
||||
from='douban'
|
||||
title={anime.name_cn || anime.name}
|
||||
poster={
|
||||
anime.images.large ||
|
||||
anime.images.common ||
|
||||
anime.images.medium ||
|
||||
anime.images.small ||
|
||||
anime.images.grid
|
||||
}
|
||||
douban_id={anime.id}
|
||||
rate={anime.rating?.score?.toFixed(1) || ''}
|
||||
year={anime.air_date?.split('-')?.[0] || ''}
|
||||
isBangumi={true}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
|
||||
{/* 热门综艺 */}
|
||||
<section className='mb-8'>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
热门综艺
|
||||
</h2>
|
||||
<Link
|
||||
href='/douban?type=show'
|
||||
className='flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
>
|
||||
查看更多
|
||||
<ChevronRight className='w-4 h-4 ml-1' />
|
||||
</Link>
|
||||
</div>
|
||||
<ScrollableRow>
|
||||
{loading
|
||||
? // 加载状态显示灰色占位数据
|
||||
Array.from({ length: 8 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
|
||||
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
|
||||
</div>
|
||||
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
|
||||
</div>
|
||||
))
|
||||
: // 显示真实数据
|
||||
hotVarietyShows.map((show, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<VideoCard
|
||||
from='douban'
|
||||
title={show.title}
|
||||
poster={show.poster}
|
||||
douban_id={Number(show.id)}
|
||||
rate={show.rate}
|
||||
year={show.year}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{announcement && showAnnouncement && (
|
||||
<div
|
||||
className={`fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm dark:bg-black/70 p-4 transition-opacity duration-300 ${showAnnouncement ? '' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
onTouchStart={(e) => {
|
||||
// 如果点击的是背景区域,阻止触摸事件冒泡,防止背景滚动
|
||||
if (e.target === e.currentTarget) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onTouchMove={(e) => {
|
||||
// 如果触摸的是背景区域,阻止触摸移动,防止背景滚动
|
||||
if (e.target === e.currentTarget) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
// 如果触摸的是背景区域,阻止触摸结束事件,防止背景滚动
|
||||
if (e.target === e.currentTarget) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
touchAction: 'none', // 禁用所有触摸操作
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className='w-full max-w-md rounded-xl bg-white p-6 shadow-xl dark:bg-gray-900 transform transition-all duration-300 hover:shadow-2xl'
|
||||
onTouchMove={(e) => {
|
||||
// 允许公告内容区域正常滚动,阻止事件冒泡到外层
|
||||
e.stopPropagation();
|
||||
}}
|
||||
style={{
|
||||
touchAction: 'auto', // 允许内容区域的正常触摸操作
|
||||
}}
|
||||
>
|
||||
<div className='flex justify-between items-start mb-4'>
|
||||
<h3 className='text-2xl font-bold tracking-tight text-gray-800 dark:text-white border-b border-green-500 pb-1'>
|
||||
提示
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => handleCloseAnnouncement(announcement)}
|
||||
className='text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-white transition-colors'
|
||||
aria-label='关闭'
|
||||
></button>
|
||||
</div>
|
||||
<div className='mb-6'>
|
||||
<div className='relative overflow-hidden rounded-lg mb-4 bg-green-50 dark:bg-green-900/20'>
|
||||
<div className='absolute inset-y-0 left-0 w-1.5 bg-green-500 dark:bg-green-400'></div>
|
||||
<p className='ml-4 text-gray-600 dark:text-gray-300 leading-relaxed'>
|
||||
{announcement}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCloseAnnouncement(announcement)}
|
||||
className='w-full rounded-lg bg-gradient-to-r from-green-600 to-green-700 px-4 py-3 text-white font-medium shadow-md hover:shadow-lg hover:from-green-700 hover:to-green-800 dark:from-green-600 dark:to-green-700 dark:hover:from-green-700 dark:hover:to-green-800 transition-all duration-300 transform hover:-translate-y-0.5'
|
||||
>
|
||||
我知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<Suspense>
|
||||
<HomeClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,899 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps, @typescript-eslint/no-explicit-any,@typescript-eslint/no-non-null-assertion,no-empty */
|
||||
'use client';
|
||||
|
||||
import { ChevronUp, Search, X } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import React, { startTransition, Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
addSearchHistory,
|
||||
clearSearchHistory,
|
||||
deleteSearchHistory,
|
||||
getSearchHistory,
|
||||
subscribeToDataUpdates,
|
||||
} from '@/lib/db.client';
|
||||
import { SearchResult } from '@/lib/types';
|
||||
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
import SearchResultFilter, { SearchFilterCategory } from '@/components/SearchResultFilter';
|
||||
import SearchSuggestions from '@/components/SearchSuggestions';
|
||||
import VideoCard, { VideoCardHandle } from '@/components/VideoCard';
|
||||
|
||||
function SearchPageClient() {
|
||||
// 搜索历史
|
||||
const [searchHistory, setSearchHistory] = useState<string[]>([]);
|
||||
// 返回顶部按钮显示状态
|
||||
const [showBackToTop, setShowBackToTop] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const currentQueryRef = useRef<string>('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const [totalSources, setTotalSources] = useState(0);
|
||||
const [completedSources, setCompletedSources] = useState(0);
|
||||
const pendingResultsRef = useRef<SearchResult[]>([]);
|
||||
const flushTimerRef = useRef<number | null>(null);
|
||||
const [useFluidSearch, setUseFluidSearch] = useState(true);
|
||||
// 聚合卡片 refs 与聚合统计缓存
|
||||
const groupRefs = useRef<Map<string, React.RefObject<VideoCardHandle>>>(new Map());
|
||||
const groupStatsRef = useRef<Map<string, { douban_id?: number; episodes?: number; source_names: string[] }>>(new Map());
|
||||
|
||||
const getGroupRef = (key: string) => {
|
||||
let ref = groupRefs.current.get(key);
|
||||
if (!ref) {
|
||||
ref = React.createRef<VideoCardHandle>();
|
||||
groupRefs.current.set(key, ref);
|
||||
}
|
||||
return ref;
|
||||
};
|
||||
|
||||
const computeGroupStats = (group: SearchResult[]) => {
|
||||
const episodes = (() => {
|
||||
const countMap = new Map<number, number>();
|
||||
group.forEach((g) => {
|
||||
const len = g.episodes?.length || 0;
|
||||
if (len > 0) countMap.set(len, (countMap.get(len) || 0) + 1);
|
||||
});
|
||||
let max = 0;
|
||||
let res = 0;
|
||||
countMap.forEach((v, k) => {
|
||||
if (v > max) { max = v; res = k; }
|
||||
});
|
||||
return res;
|
||||
})();
|
||||
const source_names = Array.from(new Set(group.map((g) => g.source_name).filter(Boolean))) as string[];
|
||||
|
||||
const douban_id = (() => {
|
||||
const countMap = new Map<number, number>();
|
||||
group.forEach((g) => {
|
||||
if (g.douban_id && g.douban_id > 0) {
|
||||
countMap.set(g.douban_id, (countMap.get(g.douban_id) || 0) + 1);
|
||||
}
|
||||
});
|
||||
let max = 0;
|
||||
let res: number | undefined;
|
||||
countMap.forEach((v, k) => {
|
||||
if (v > max) { max = v; res = k; }
|
||||
});
|
||||
return res;
|
||||
})();
|
||||
|
||||
return { episodes, source_names, douban_id };
|
||||
};
|
||||
// 过滤器:非聚合与聚合
|
||||
const [filterAll, setFilterAll] = useState<{ source: string; title: string; year: string; yearOrder: 'none' | 'asc' | 'desc' }>({
|
||||
source: 'all',
|
||||
title: 'all',
|
||||
year: 'all',
|
||||
yearOrder: 'none',
|
||||
});
|
||||
const [filterAgg, setFilterAgg] = useState<{ source: string; title: string; year: string; yearOrder: 'none' | 'asc' | 'desc' }>({
|
||||
source: 'all',
|
||||
title: 'all',
|
||||
year: 'all',
|
||||
yearOrder: 'none',
|
||||
});
|
||||
|
||||
// 获取默认聚合设置:只读取用户本地设置,默认为 true
|
||||
const getDefaultAggregate = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const userSetting = localStorage.getItem('defaultAggregateSearch');
|
||||
if (userSetting !== null) {
|
||||
return JSON.parse(userSetting);
|
||||
}
|
||||
}
|
||||
return true; // 默认启用聚合
|
||||
};
|
||||
|
||||
const [viewMode, setViewMode] = useState<'agg' | 'all'>(() => {
|
||||
return getDefaultAggregate() ? 'agg' : 'all';
|
||||
});
|
||||
|
||||
// 在“无排序”场景用于每个源批次的预排序:完全匹配标题优先,其次年份倒序,未知年份最后
|
||||
const sortBatchForNoOrder = (items: SearchResult[]) => {
|
||||
const q = currentQueryRef.current.trim();
|
||||
return items.slice().sort((a, b) => {
|
||||
const aExact = (a.title || '').trim() === q;
|
||||
const bExact = (b.title || '').trim() === q;
|
||||
if (aExact && !bExact) return -1;
|
||||
if (!aExact && bExact) return 1;
|
||||
|
||||
const aNum = Number.parseInt(a.year as any, 10);
|
||||
const bNum = Number.parseInt(b.year as any, 10);
|
||||
const aValid = !Number.isNaN(aNum);
|
||||
const bValid = !Number.isNaN(bNum);
|
||||
if (aValid && !bValid) return -1;
|
||||
if (!aValid && bValid) return 1;
|
||||
if (aValid && bValid) return bNum - aNum; // 年份倒序
|
||||
return 0;
|
||||
});
|
||||
};
|
||||
|
||||
// 简化的年份排序:unknown/空值始终在最后
|
||||
const compareYear = (aYear: string, bYear: string, order: 'none' | 'asc' | 'desc') => {
|
||||
// 如果是无排序状态,返回0(保持原顺序)
|
||||
if (order === 'none') return 0;
|
||||
|
||||
// 处理空值和unknown
|
||||
const aIsEmpty = !aYear || aYear === 'unknown';
|
||||
const bIsEmpty = !bYear || bYear === 'unknown';
|
||||
|
||||
if (aIsEmpty && bIsEmpty) return 0;
|
||||
if (aIsEmpty) return 1; // a 在后
|
||||
if (bIsEmpty) return -1; // b 在后
|
||||
|
||||
// 都是有效年份,按数字比较
|
||||
const aNum = parseInt(aYear, 10);
|
||||
const bNum = parseInt(bYear, 10);
|
||||
|
||||
return order === 'asc' ? aNum - bNum : bNum - aNum;
|
||||
};
|
||||
|
||||
// 聚合后的结果(按标题和年份分组)
|
||||
const aggregatedResults = useMemo(() => {
|
||||
const map = new Map<string, SearchResult[]>();
|
||||
const keyOrder: string[] = []; // 记录键出现的顺序
|
||||
|
||||
searchResults.forEach((item) => {
|
||||
// 使用 title + year + type 作为键,year 必然存在,但依然兜底 'unknown'
|
||||
const key = `${item.title.replaceAll(' ', '')}-${item.year || 'unknown'
|
||||
}-${item.episodes.length === 1 ? 'movie' : 'tv'}`;
|
||||
const arr = map.get(key) || [];
|
||||
|
||||
// 如果是新的键,记录其顺序
|
||||
if (arr.length === 0) {
|
||||
keyOrder.push(key);
|
||||
}
|
||||
|
||||
arr.push(item);
|
||||
map.set(key, arr);
|
||||
});
|
||||
|
||||
// 按出现顺序返回聚合结果
|
||||
return keyOrder.map(key => [key, map.get(key)!] as [string, SearchResult[]]);
|
||||
}, [searchResults]);
|
||||
|
||||
// 当聚合结果变化时,如果某个聚合已存在,则调用其卡片 ref 的 set 方法增量更新
|
||||
useEffect(() => {
|
||||
aggregatedResults.forEach(([mapKey, group]) => {
|
||||
const stats = computeGroupStats(group);
|
||||
const prev = groupStatsRef.current.get(mapKey);
|
||||
if (!prev) {
|
||||
// 第一次出现,记录初始值,不调用 ref(由初始 props 渲染)
|
||||
groupStatsRef.current.set(mapKey, stats);
|
||||
return;
|
||||
}
|
||||
// 对比变化并调用对应的 set 方法
|
||||
const ref = groupRefs.current.get(mapKey);
|
||||
if (ref && ref.current) {
|
||||
if (prev.episodes !== stats.episodes) {
|
||||
ref.current.setEpisodes(stats.episodes);
|
||||
}
|
||||
const prevNames = (prev.source_names || []).join('|');
|
||||
const nextNames = (stats.source_names || []).join('|');
|
||||
if (prevNames !== nextNames) {
|
||||
ref.current.setSourceNames(stats.source_names);
|
||||
}
|
||||
if (prev.douban_id !== stats.douban_id) {
|
||||
ref.current.setDoubanId(stats.douban_id);
|
||||
}
|
||||
groupStatsRef.current.set(mapKey, stats);
|
||||
}
|
||||
});
|
||||
}, [aggregatedResults]);
|
||||
|
||||
// 构建筛选选项
|
||||
const filterOptions = useMemo(() => {
|
||||
const sourcesSet = new Map<string, string>();
|
||||
const titlesSet = new Set<string>();
|
||||
const yearsSet = new Set<string>();
|
||||
|
||||
searchResults.forEach((item) => {
|
||||
if (item.source && item.source_name) {
|
||||
sourcesSet.set(item.source, item.source_name);
|
||||
}
|
||||
if (item.title) titlesSet.add(item.title);
|
||||
if (item.year) yearsSet.add(item.year);
|
||||
});
|
||||
|
||||
const sourceOptions: { label: string; value: string }[] = [
|
||||
{ label: '全部来源', value: 'all' },
|
||||
...Array.from(sourcesSet.entries())
|
||||
.sort((a, b) => a[1].localeCompare(b[1]))
|
||||
.map(([value, label]) => ({ label, value })),
|
||||
];
|
||||
|
||||
const titleOptions: { label: string; value: string }[] = [
|
||||
{ label: '全部标题', value: 'all' },
|
||||
...Array.from(titlesSet.values())
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((t) => ({ label: t, value: t })),
|
||||
];
|
||||
|
||||
// 年份: 将 unknown 放末尾
|
||||
const years = Array.from(yearsSet.values());
|
||||
const knownYears = years.filter((y) => y !== 'unknown').sort((a, b) => parseInt(b) - parseInt(a));
|
||||
const hasUnknown = years.includes('unknown');
|
||||
const yearOptions: { label: string; value: string }[] = [
|
||||
{ label: '全部年份', value: 'all' },
|
||||
...knownYears.map((y) => ({ label: y, value: y })),
|
||||
...(hasUnknown ? [{ label: '未知', value: 'unknown' }] : []),
|
||||
];
|
||||
|
||||
const categoriesAll: SearchFilterCategory[] = [
|
||||
{ key: 'source', label: '来源', options: sourceOptions },
|
||||
{ key: 'title', label: '标题', options: titleOptions },
|
||||
{ key: 'year', label: '年份', options: yearOptions },
|
||||
];
|
||||
|
||||
const categoriesAgg: SearchFilterCategory[] = [
|
||||
{ key: 'source', label: '来源', options: sourceOptions },
|
||||
{ key: 'title', label: '标题', options: titleOptions },
|
||||
{ key: 'year', label: '年份', options: yearOptions },
|
||||
];
|
||||
|
||||
return { categoriesAll, categoriesAgg };
|
||||
}, [searchResults]);
|
||||
|
||||
// 非聚合:应用筛选与排序
|
||||
const filteredAllResults = useMemo(() => {
|
||||
const { source, title, year, yearOrder } = filterAll;
|
||||
const filtered = searchResults.filter((item) => {
|
||||
if (source !== 'all' && item.source !== source) return false;
|
||||
if (title !== 'all' && item.title !== title) return false;
|
||||
if (year !== 'all' && item.year !== year) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// 如果是无排序状态,直接返回过滤后的原始顺序
|
||||
if (yearOrder === 'none') {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
// 简化排序:1. 年份排序,2. 年份相同时精确匹配在前,3. 标题排序
|
||||
return filtered.sort((a, b) => {
|
||||
// 首先按年份排序
|
||||
const yearComp = compareYear(a.year, b.year, yearOrder);
|
||||
if (yearComp !== 0) return yearComp;
|
||||
|
||||
// 年份相同时,精确匹配在前
|
||||
const aExactMatch = a.title === searchQuery.trim();
|
||||
const bExactMatch = b.title === searchQuery.trim();
|
||||
if (aExactMatch && !bExactMatch) return -1;
|
||||
if (!aExactMatch && bExactMatch) return 1;
|
||||
|
||||
// 最后按标题排序,正序时字母序,倒序时反字母序
|
||||
return yearOrder === 'asc' ?
|
||||
a.title.localeCompare(b.title) :
|
||||
b.title.localeCompare(a.title);
|
||||
});
|
||||
}, [searchResults, filterAll, searchQuery]);
|
||||
|
||||
// 聚合:应用筛选与排序
|
||||
const filteredAggResults = useMemo(() => {
|
||||
const { source, title, year, yearOrder } = filterAgg as any;
|
||||
const filtered = aggregatedResults.filter(([_, group]) => {
|
||||
const gTitle = group[0]?.title ?? '';
|
||||
const gYear = group[0]?.year ?? 'unknown';
|
||||
const hasSource = source === 'all' ? true : group.some((item) => item.source === source);
|
||||
if (!hasSource) return false;
|
||||
if (title !== 'all' && gTitle !== title) return false;
|
||||
if (year !== 'all' && gYear !== year) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// 如果是无排序状态,保持按关键字+年份+类型出现的原始顺序
|
||||
if (yearOrder === 'none') {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
// 简化排序:1. 年份排序,2. 年份相同时精确匹配在前,3. 标题排序
|
||||
return filtered.sort((a, b) => {
|
||||
// 首先按年份排序
|
||||
const aYear = a[1][0].year;
|
||||
const bYear = b[1][0].year;
|
||||
const yearComp = compareYear(aYear, bYear, yearOrder);
|
||||
if (yearComp !== 0) return yearComp;
|
||||
|
||||
// 年份相同时,精确匹配在前
|
||||
const aExactMatch = a[1][0].title === searchQuery.trim();
|
||||
const bExactMatch = b[1][0].title === searchQuery.trim();
|
||||
if (aExactMatch && !bExactMatch) return -1;
|
||||
if (!aExactMatch && bExactMatch) return 1;
|
||||
|
||||
// 最后按标题排序,正序时字母序,倒序时反字母序
|
||||
const aTitle = a[1][0].title;
|
||||
const bTitle = b[1][0].title;
|
||||
return yearOrder === 'asc' ?
|
||||
aTitle.localeCompare(bTitle) :
|
||||
bTitle.localeCompare(aTitle);
|
||||
});
|
||||
}, [aggregatedResults, filterAgg, searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
// 无搜索参数时聚焦搜索框
|
||||
!searchParams.get('q') && document.getElementById('searchInput')?.focus();
|
||||
|
||||
// 初始加载搜索历史
|
||||
getSearchHistory().then(setSearchHistory);
|
||||
|
||||
// 读取流式搜索设置
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedFluidSearch = localStorage.getItem('fluidSearch');
|
||||
const defaultFluidSearch =
|
||||
(window as any).RUNTIME_CONFIG?.FLUID_SEARCH !== false;
|
||||
if (savedFluidSearch !== null) {
|
||||
setUseFluidSearch(JSON.parse(savedFluidSearch));
|
||||
} else if (defaultFluidSearch !== undefined) {
|
||||
setUseFluidSearch(defaultFluidSearch);
|
||||
}
|
||||
}
|
||||
|
||||
// 监听搜索历史更新事件
|
||||
const unsubscribe = subscribeToDataUpdates(
|
||||
'searchHistoryUpdated',
|
||||
(newHistory: string[]) => {
|
||||
setSearchHistory(newHistory);
|
||||
}
|
||||
);
|
||||
|
||||
// 获取滚动位置的函数 - 专门针对 body 滚动
|
||||
const getScrollTop = () => {
|
||||
return document.body.scrollTop || 0;
|
||||
};
|
||||
|
||||
// 使用 requestAnimationFrame 持续检测滚动位置
|
||||
let isRunning = false;
|
||||
const checkScrollPosition = () => {
|
||||
if (!isRunning) return;
|
||||
|
||||
const scrollTop = getScrollTop();
|
||||
const shouldShow = scrollTop > 300;
|
||||
setShowBackToTop(shouldShow);
|
||||
|
||||
requestAnimationFrame(checkScrollPosition);
|
||||
};
|
||||
|
||||
// 启动持续检测
|
||||
isRunning = true;
|
||||
checkScrollPosition();
|
||||
|
||||
// 监听 body 元素的滚动事件
|
||||
const handleScroll = () => {
|
||||
const scrollTop = getScrollTop();
|
||||
setShowBackToTop(scrollTop > 300);
|
||||
};
|
||||
|
||||
document.body.addEventListener('scroll', handleScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
isRunning = false; // 停止 requestAnimationFrame 循环
|
||||
|
||||
// 移除 body 滚动事件监听器
|
||||
document.body.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 当搜索参数变化时更新搜索状态
|
||||
const query = searchParams.get('q') || '';
|
||||
currentQueryRef.current = query.trim();
|
||||
|
||||
if (query) {
|
||||
setSearchQuery(query);
|
||||
// 新搜索:关闭旧连接并清空结果
|
||||
if (eventSourceRef.current) {
|
||||
try { eventSourceRef.current.close(); } catch { }
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
setSearchResults([]);
|
||||
setTotalSources(0);
|
||||
setCompletedSources(0);
|
||||
// 清理缓冲
|
||||
pendingResultsRef.current = [];
|
||||
if (flushTimerRef.current) {
|
||||
clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setShowResults(true);
|
||||
|
||||
const trimmed = query.trim();
|
||||
|
||||
// 每次搜索时重新读取设置,确保使用最新的配置
|
||||
let currentFluidSearch = useFluidSearch;
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedFluidSearch = localStorage.getItem('fluidSearch');
|
||||
if (savedFluidSearch !== null) {
|
||||
currentFluidSearch = JSON.parse(savedFluidSearch);
|
||||
} else {
|
||||
const defaultFluidSearch = (window as any).RUNTIME_CONFIG?.FLUID_SEARCH !== false;
|
||||
currentFluidSearch = defaultFluidSearch;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果读取的配置与当前状态不同,更新状态
|
||||
if (currentFluidSearch !== useFluidSearch) {
|
||||
setUseFluidSearch(currentFluidSearch);
|
||||
}
|
||||
|
||||
if (currentFluidSearch) {
|
||||
// 流式搜索:打开新的流式连接
|
||||
const es = new EventSource(`/api/search/ws?q=${encodeURIComponent(trimmed)}`);
|
||||
eventSourceRef.current = es;
|
||||
|
||||
es.onmessage = (event) => {
|
||||
if (!event.data) return;
|
||||
try {
|
||||
const payload = JSON.parse(event.data);
|
||||
if (currentQueryRef.current !== trimmed) return;
|
||||
switch (payload.type) {
|
||||
case 'start':
|
||||
setTotalSources(payload.totalSources || 0);
|
||||
setCompletedSources(0);
|
||||
break;
|
||||
case 'source_result': {
|
||||
setCompletedSources((prev) => prev + 1);
|
||||
if (Array.isArray(payload.results) && payload.results.length > 0) {
|
||||
// 缓冲新增结果,节流刷入,避免频繁重渲染导致闪烁
|
||||
const activeYearOrder = (viewMode === 'agg' ? (filterAgg.yearOrder) : (filterAll.yearOrder));
|
||||
const incoming: SearchResult[] =
|
||||
activeYearOrder === 'none'
|
||||
? sortBatchForNoOrder(payload.results as SearchResult[])
|
||||
: (payload.results as SearchResult[]);
|
||||
pendingResultsRef.current.push(...incoming);
|
||||
if (!flushTimerRef.current) {
|
||||
flushTimerRef.current = window.setTimeout(() => {
|
||||
const toAppend = pendingResultsRef.current;
|
||||
pendingResultsRef.current = [];
|
||||
startTransition(() => {
|
||||
setSearchResults((prev) => prev.concat(toAppend));
|
||||
});
|
||||
flushTimerRef.current = null;
|
||||
}, 80);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'source_error':
|
||||
setCompletedSources((prev) => prev + 1);
|
||||
break;
|
||||
case 'complete':
|
||||
setCompletedSources(payload.completedSources || totalSources);
|
||||
// 完成前确保将缓冲写入
|
||||
if (pendingResultsRef.current.length > 0) {
|
||||
const toAppend = pendingResultsRef.current;
|
||||
pendingResultsRef.current = [];
|
||||
if (flushTimerRef.current) {
|
||||
clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
startTransition(() => {
|
||||
setSearchResults((prev) => prev.concat(toAppend));
|
||||
});
|
||||
}
|
||||
setIsLoading(false);
|
||||
try { es.close(); } catch { }
|
||||
if (eventSourceRef.current === es) {
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch { }
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
setIsLoading(false);
|
||||
// 错误时也清空缓冲
|
||||
if (pendingResultsRef.current.length > 0) {
|
||||
const toAppend = pendingResultsRef.current;
|
||||
pendingResultsRef.current = [];
|
||||
if (flushTimerRef.current) {
|
||||
clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
startTransition(() => {
|
||||
setSearchResults((prev) => prev.concat(toAppend));
|
||||
});
|
||||
}
|
||||
try { es.close(); } catch { }
|
||||
if (eventSourceRef.current === es) {
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// 传统搜索:使用普通接口
|
||||
fetch(`/api/search?q=${encodeURIComponent(trimmed)}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (currentQueryRef.current !== trimmed) return;
|
||||
|
||||
if (data.results && Array.isArray(data.results)) {
|
||||
const activeYearOrder = (viewMode === 'agg' ? (filterAgg.yearOrder) : (filterAll.yearOrder));
|
||||
const results: SearchResult[] =
|
||||
activeYearOrder === 'none'
|
||||
? sortBatchForNoOrder(data.results as SearchResult[])
|
||||
: (data.results as SearchResult[]);
|
||||
|
||||
setSearchResults(results);
|
||||
setTotalSources(1);
|
||||
setCompletedSources(1);
|
||||
}
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
setShowSuggestions(false);
|
||||
|
||||
// 保存到搜索历史 (事件监听会自动更新界面)
|
||||
addSearchHistory(query);
|
||||
} else {
|
||||
setShowResults(false);
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// 组件卸载时,关闭可能存在的连接
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (eventSourceRef.current) {
|
||||
try { eventSourceRef.current.close(); } catch { }
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
if (flushTimerRef.current) {
|
||||
clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
pendingResultsRef.current = [];
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 输入框内容变化时触发,显示搜索建议
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setSearchQuery(value);
|
||||
|
||||
if (value.trim()) {
|
||||
setShowSuggestions(true);
|
||||
} else {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 搜索框聚焦时触发,显示搜索建议
|
||||
const handleInputFocus = () => {
|
||||
if (searchQuery.trim()) {
|
||||
setShowSuggestions(true);
|
||||
}
|
||||
};
|
||||
|
||||
// 搜索表单提交时触发,处理搜索逻辑
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = searchQuery.trim().replace(/\s+/g, ' ');
|
||||
if (!trimmed) return;
|
||||
|
||||
// 回显搜索框
|
||||
setSearchQuery(trimmed);
|
||||
setIsLoading(true);
|
||||
setShowResults(true);
|
||||
setShowSuggestions(false);
|
||||
|
||||
router.push(`/search?q=${encodeURIComponent(trimmed)}`);
|
||||
// 其余由 searchParams 变化的 effect 处理
|
||||
};
|
||||
|
||||
const handleSuggestionSelect = (suggestion: string) => {
|
||||
setSearchQuery(suggestion);
|
||||
setShowSuggestions(false);
|
||||
|
||||
// 自动执行搜索
|
||||
setIsLoading(true);
|
||||
setShowResults(true);
|
||||
|
||||
router.push(`/search?q=${encodeURIComponent(suggestion)}`);
|
||||
// 其余由 searchParams 变化的 effect 处理
|
||||
};
|
||||
|
||||
// 返回顶部功能
|
||||
const scrollToTop = () => {
|
||||
try {
|
||||
// 根据调试结果,真正的滚动容器是 document.body
|
||||
document.body.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
} catch (error) {
|
||||
// 如果平滑滚动完全失败,使用立即滚动
|
||||
document.body.scrollTop = 0;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout activePath='/search'>
|
||||
<div className='px-4 sm:px-10 py-4 sm:py-8 overflow-visible mb-10'>
|
||||
{/* 搜索框 */}
|
||||
<div className='mb-8'>
|
||||
<form onSubmit={handleSearch} className='max-w-2xl mx-auto'>
|
||||
<div className='relative'>
|
||||
<Search className='absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400 dark:text-gray-500' />
|
||||
<input
|
||||
id='searchInput'
|
||||
type='text'
|
||||
value={searchQuery}
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleInputFocus}
|
||||
placeholder='搜索电影、电视剧...'
|
||||
autoComplete="off"
|
||||
className='w-full h-12 rounded-lg bg-gray-50/80 py-3 pl-10 pr-12 text-sm text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-400 focus:bg-white border border-gray-200/50 shadow-sm dark:bg-gray-800 dark:text-gray-300 dark:placeholder-gray-500 dark:focus:bg-gray-700 dark:border-gray-700'
|
||||
/>
|
||||
|
||||
{/* 清除按钮 */}
|
||||
{searchQuery && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setShowSuggestions(false);
|
||||
document.getElementById('searchInput')?.focus();
|
||||
}}
|
||||
className='absolute right-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors dark:text-gray-500 dark:hover:text-gray-300'
|
||||
aria-label='清除搜索内容'
|
||||
>
|
||||
<X className='h-5 w-5' />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 搜索建议 */}
|
||||
<SearchSuggestions
|
||||
query={searchQuery}
|
||||
isVisible={showSuggestions}
|
||||
onSelect={handleSuggestionSelect}
|
||||
onClose={() => setShowSuggestions(false)}
|
||||
onEnterKey={() => {
|
||||
// 当用户按回车键时,使用搜索框的实际内容进行搜索
|
||||
const trimmed = searchQuery.trim().replace(/\s+/g, ' ');
|
||||
if (!trimmed) return;
|
||||
|
||||
// 回显搜索框
|
||||
setSearchQuery(trimmed);
|
||||
setIsLoading(true);
|
||||
setShowResults(true);
|
||||
setShowSuggestions(false);
|
||||
|
||||
router.push(`/search?q=${encodeURIComponent(trimmed)}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* 搜索结果或搜索历史 */}
|
||||
<div className='max-w-[95%] mx-auto mt-12 overflow-visible'>
|
||||
{showResults ? (
|
||||
<section className='mb-12'>
|
||||
{/* 标题 */}
|
||||
<div className='mb-4'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
搜索结果
|
||||
{totalSources > 0 && useFluidSearch && (
|
||||
<span className='ml-2 text-sm font-normal text-gray-500 dark:text-gray-400'>
|
||||
{completedSources}/{totalSources}
|
||||
</span>
|
||||
)}
|
||||
{isLoading && useFluidSearch && (
|
||||
<span className='ml-2 inline-block align-middle'>
|
||||
<span className='inline-block h-3 w-3 border-2 border-gray-300 border-t-green-500 rounded-full animate-spin'></span>
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
{/* 筛选器 + 聚合开关 同行 */}
|
||||
<div className='mb-8 flex items-center justify-between gap-3'>
|
||||
<div className='flex-1 min-w-0'>
|
||||
{viewMode === 'agg' ? (
|
||||
<SearchResultFilter
|
||||
categories={filterOptions.categoriesAgg}
|
||||
values={filterAgg}
|
||||
onChange={(v) => setFilterAgg(v as any)}
|
||||
/>
|
||||
) : (
|
||||
<SearchResultFilter
|
||||
categories={filterOptions.categoriesAll}
|
||||
values={filterAll}
|
||||
onChange={(v) => setFilterAll(v as any)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* 聚合开关 */}
|
||||
<label className='flex items-center gap-2 cursor-pointer select-none shrink-0'>
|
||||
<span className='text-xs sm:text-sm text-gray-700 dark:text-gray-300'>聚合</span>
|
||||
<div className='relative'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='sr-only peer'
|
||||
checked={viewMode === 'agg'}
|
||||
onChange={() => setViewMode(viewMode === 'agg' ? 'all' : 'agg')}
|
||||
/>
|
||||
<div className='w-9 h-5 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
|
||||
<div className='absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-4'></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
{searchResults.length === 0 ? (
|
||||
isLoading ? (
|
||||
<div className='flex justify-center items-center h-40'>
|
||||
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-green-500'></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-center text-gray-500 py-8 dark:text-gray-400'>
|
||||
未找到相关结果
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
key={`search-results-${viewMode}`}
|
||||
className='justify-start grid grid-cols-3 gap-x-2 gap-y-14 sm:gap-y-20 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8'
|
||||
>
|
||||
{viewMode === 'agg'
|
||||
? filteredAggResults.map(([mapKey, group]) => {
|
||||
const title = group[0]?.title || '';
|
||||
const poster = group[0]?.poster || '';
|
||||
const year = group[0]?.year || 'unknown';
|
||||
const { episodes, source_names, douban_id } = computeGroupStats(group);
|
||||
const type = episodes === 1 ? 'movie' : 'tv';
|
||||
|
||||
// 如果该聚合第一次出现,写入初始统计
|
||||
if (!groupStatsRef.current.has(mapKey)) {
|
||||
groupStatsRef.current.set(mapKey, { episodes, source_names, douban_id });
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`agg-${mapKey}`} className='w-full'>
|
||||
<VideoCard
|
||||
ref={getGroupRef(mapKey)}
|
||||
from='search'
|
||||
isAggregate={true}
|
||||
title={title}
|
||||
poster={poster}
|
||||
year={year}
|
||||
episodes={episodes}
|
||||
source_names={source_names}
|
||||
douban_id={douban_id}
|
||||
query={
|
||||
searchQuery.trim() !== title
|
||||
? searchQuery.trim()
|
||||
: ''
|
||||
}
|
||||
type={type}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: filteredAllResults.map((item) => (
|
||||
<div
|
||||
key={`all-${item.source}-${item.id}`}
|
||||
className='w-full'
|
||||
>
|
||||
<VideoCard
|
||||
id={item.id}
|
||||
title={item.title}
|
||||
poster={item.poster}
|
||||
episodes={item.episodes.length}
|
||||
source={item.source}
|
||||
source_name={item.source_name}
|
||||
douban_id={item.douban_id}
|
||||
query={
|
||||
searchQuery.trim() !== item.title
|
||||
? searchQuery.trim()
|
||||
: ''
|
||||
}
|
||||
year={item.year}
|
||||
from='search'
|
||||
type={item.episodes.length > 1 ? 'tv' : 'movie'}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : searchHistory.length > 0 ? (
|
||||
// 搜索历史
|
||||
<section className='mb-12'>
|
||||
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left dark:text-gray-200'>
|
||||
搜索历史
|
||||
{searchHistory.length > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
clearSearchHistory(); // 事件监听会自动更新界面
|
||||
}}
|
||||
className='ml-3 text-sm text-gray-500 hover:text-red-500 transition-colors dark:text-gray-400 dark:hover:text-red-500'
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
)}
|
||||
</h2>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{searchHistory.map((item) => (
|
||||
<div key={item} className='relative group'>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery(item);
|
||||
router.push(
|
||||
`/search?q=${encodeURIComponent(item.trim())}`
|
||||
);
|
||||
}}
|
||||
className='px-4 py-2 bg-gray-500/10 hover:bg-gray-300 rounded-full text-sm text-gray-700 transition-colors duration-200 dark:bg-gray-700/50 dark:hover:bg-gray-600 dark:text-gray-300'
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
{/* 删除按钮 */}
|
||||
<button
|
||||
aria-label='删除搜索历史'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
deleteSearchHistory(item); // 事件监听会自动更新界面
|
||||
}}
|
||||
className='absolute -top-1 -right-1 w-4 h-4 opacity-0 group-hover:opacity-100 bg-gray-400 hover:bg-red-500 text-white rounded-full flex items-center justify-center text-[10px] transition-colors'
|
||||
>
|
||||
<X className='w-3 h-3' />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 返回顶部悬浮按钮 */}
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
className={`fixed bottom-20 md:bottom-6 right-6 z-[500] w-12 h-12 bg-green-500/90 hover:bg-green-500 text-white rounded-full shadow-lg backdrop-blur-sm transition-all duration-300 ease-in-out flex items-center justify-center group ${showBackToTop
|
||||
? 'opacity-100 translate-y-0 pointer-events-auto'
|
||||
: 'opacity-0 translate-y-4 pointer-events-none'
|
||||
}`}
|
||||
aria-label='返回顶部'
|
||||
>
|
||||
<ChevronUp className='w-6 h-6 transition-transform group-hover:scale-110' />
|
||||
</button>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SearchPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<SearchPageClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '安全警告 - MoonTV',
|
||||
description: '站点安全配置警告',
|
||||
};
|
||||
|
||||
export default function WarningPage() {
|
||||
return (
|
||||
<div className='min-h-screen bg-gradient-to-br from-red-50 to-orange-50 flex items-center justify-center p-4'>
|
||||
<div className='max-w-2xl w-full bg-white rounded-2xl shadow-2xl p-4 sm:p-8 border border-red-200'>
|
||||
{/* 警告图标 */}
|
||||
<div className='flex justify-center mb-4 sm:mb-6'>
|
||||
<div className='w-16 h-16 sm:w-20 sm:h-20 bg-red-100 rounded-full flex items-center justify-center'>
|
||||
<svg
|
||||
className='w-10 h-10 sm:w-12 sm:h-12 text-red-600'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 标题 */}
|
||||
<div className='text-center mb-6 sm:mb-8'>
|
||||
<h1 className='text-2xl sm:text-3xl font-bold text-gray-900 mb-2'>
|
||||
安全合规配置警告
|
||||
</h1>
|
||||
<div className='w-12 sm:w-16 h-1 bg-red-500 mx-auto rounded-full'></div>
|
||||
</div>
|
||||
|
||||
{/* 警告内容 */}
|
||||
<div className='space-y-4 sm:space-y-6 text-gray-700'>
|
||||
<div className='bg-red-50 border-l-4 border-red-500 p-3 sm:p-4 rounded-r-lg'>
|
||||
<p className='text-base sm:text-lg font-semibold text-red-800 mb-2'>
|
||||
⚠️ 安全风险提示
|
||||
</p>
|
||||
<p className='text-sm sm:text-base text-red-700'>
|
||||
检测到您的站点未配置访问控制,存在潜在的安全风险和法律合规问题。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
<h2 className='text-lg sm:text-xl font-semibold text-gray-900'>
|
||||
主要风险
|
||||
</h2>
|
||||
<ul className='space-y-2 sm:space-y-3 text-sm sm:text-base text-gray-600'>
|
||||
<li className='flex items-start'>
|
||||
<span className='text-red-500 mr-2 mt-0.5'>•</span>
|
||||
<span>未经授权的访问可能导致内容被恶意传播</span>
|
||||
</li>
|
||||
<li className='flex items-start'>
|
||||
<span className='text-red-500 mr-2 mt-0.5'>•</span>
|
||||
<span>服务器资源可能被滥用,影响正常服务</span>
|
||||
</li>
|
||||
<li className='flex items-start'>
|
||||
<span className='text-red-500 mr-2 mt-0.5'>•</span>
|
||||
<span>可能收到相关权利方的法律通知</span>
|
||||
</li>
|
||||
<li className='flex items-start'>
|
||||
<span className='text-red-500 mr-2 mt-0.5'>•</span>
|
||||
<span>服务提供商可能因合规问题终止服务</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className='bg-yellow-50 border border-yellow-200 rounded-lg p-3 sm:p-4'>
|
||||
<h3 className='text-base sm:text-lg font-semibold text-yellow-800 mb-2'>
|
||||
🔒 安全配置建议
|
||||
</h3>
|
||||
<p className='text-sm sm:text-base text-yellow-700'>
|
||||
请立即配置{' '}
|
||||
<code className='bg-yellow-100 px-1.5 py-0.5 rounded text-xs sm:text-sm font-mono'>
|
||||
PASSWORD
|
||||
</code>{' '}
|
||||
环境变量以启用访问控制。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部装饰 */}
|
||||
<div className='mt-6 sm:mt-8 pt-4 sm:pt-6 border-t border-gray-200'>
|
||||
<div className='text-center text-xs sm:text-sm text-gray-500'>
|
||||
<p>为确保系统安全性和合规性,请及时完成安全配置</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
export function BackButton() {
|
||||
return (
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className='w-10 h-10 p-2 rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors'
|
||||
aria-label='Back'
|
||||
>
|
||||
<ArrowLeft className='w-full h-full' />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface CapsuleSwitchProps {
|
||||
options: { label: string; value: string }[];
|
||||
active: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CapsuleSwitch: React.FC<CapsuleSwitchProps> = ({
|
||||
options,
|
||||
active,
|
||||
onChange,
|
||||
className,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const [indicatorStyle, setIndicatorStyle] = useState<{
|
||||
left: number;
|
||||
width: number;
|
||||
}>({ left: 0, width: 0 });
|
||||
|
||||
const activeIndex = options.findIndex((opt) => opt.value === active);
|
||||
|
||||
// 更新指示器位置
|
||||
const updateIndicatorPosition = () => {
|
||||
if (
|
||||
activeIndex >= 0 &&
|
||||
buttonRefs.current[activeIndex] &&
|
||||
containerRef.current
|
||||
) {
|
||||
const button = buttonRefs.current[activeIndex];
|
||||
const container = containerRef.current;
|
||||
if (button && container) {
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
if (buttonRect.width > 0) {
|
||||
setIndicatorStyle({
|
||||
left: buttonRect.left - containerRect.left,
|
||||
width: buttonRect.width,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 组件挂载时立即计算初始位置
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(updateIndicatorPosition, 0);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, []);
|
||||
|
||||
// 监听选中项变化
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(updateIndicatorPosition, 0);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [activeIndex]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`relative inline-flex bg-gray-300/80 rounded-full p-1 dark:bg-gray-700 ${
|
||||
className || ''
|
||||
}`}
|
||||
>
|
||||
{/* 滑动的白色背景指示器 */}
|
||||
{indicatorStyle.width > 0 && (
|
||||
<div
|
||||
className='absolute top-1 bottom-1 bg-white dark:bg-gray-500 rounded-full shadow-sm transition-all duration-300 ease-out'
|
||||
style={{
|
||||
left: `${indicatorStyle.left}px`,
|
||||
width: `${indicatorStyle.width}px`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{options.map((opt, index) => {
|
||||
const isActive = active === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
ref={(el) => {
|
||||
buttonRefs.current[index] = el;
|
||||
}}
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={`relative z-10 w-16 px-3 py-1 text-xs sm:w-20 sm:py-2 sm:text-sm rounded-full font-medium transition-all duration-200 cursor-pointer ${
|
||||
isActive
|
||||
? 'text-gray-900 dark:text-gray-100'
|
||||
: 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CapsuleSwitch;
|
||||
@@ -0,0 +1,154 @@
|
||||
/* eslint-disable no-console */
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { PlayRecord } from '@/lib/db.client';
|
||||
import {
|
||||
clearAllPlayRecords,
|
||||
getAllPlayRecords,
|
||||
subscribeToDataUpdates,
|
||||
} from '@/lib/db.client';
|
||||
|
||||
import ScrollableRow from '@/components/ScrollableRow';
|
||||
import VideoCard from '@/components/VideoCard';
|
||||
|
||||
interface ContinueWatchingProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ContinueWatching({ className }: ContinueWatchingProps) {
|
||||
const [playRecords, setPlayRecords] = useState<
|
||||
(PlayRecord & { key: string })[]
|
||||
>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// 处理播放记录数据更新的函数
|
||||
const updatePlayRecords = (allRecords: Record<string, PlayRecord>) => {
|
||||
// 将记录转换为数组并根据 save_time 由近到远排序
|
||||
const recordsArray = Object.entries(allRecords).map(([key, record]) => ({
|
||||
...record,
|
||||
key,
|
||||
}));
|
||||
|
||||
// 按 save_time 降序排序(最新的在前面)
|
||||
const sortedRecords = recordsArray.sort(
|
||||
(a, b) => b.save_time - a.save_time
|
||||
);
|
||||
|
||||
setPlayRecords(sortedRecords);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPlayRecords = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 从缓存或API获取所有播放记录
|
||||
const allRecords = await getAllPlayRecords();
|
||||
updatePlayRecords(allRecords);
|
||||
} catch (error) {
|
||||
console.error('获取播放记录失败:', error);
|
||||
setPlayRecords([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPlayRecords();
|
||||
|
||||
// 监听播放记录更新事件
|
||||
const unsubscribe = subscribeToDataUpdates(
|
||||
'playRecordsUpdated',
|
||||
(newRecords: Record<string, PlayRecord>) => {
|
||||
updatePlayRecords(newRecords);
|
||||
}
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// 如果没有播放记录,则不渲染组件
|
||||
if (!loading && playRecords.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 计算播放进度百分比
|
||||
const getProgress = (record: PlayRecord) => {
|
||||
if (record.total_time === 0) return 0;
|
||||
return (record.play_time / record.total_time) * 100;
|
||||
};
|
||||
|
||||
// 从 key 中解析 source 和 id
|
||||
const parseKey = (key: string) => {
|
||||
const [source, id] = key.split('+');
|
||||
return { source, id };
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={`mb-8 ${className || ''}`}>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
继续观看
|
||||
</h2>
|
||||
{!loading && playRecords.length > 0 && (
|
||||
<button
|
||||
className='text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
onClick={async () => {
|
||||
await clearAllPlayRecords();
|
||||
setPlayRecords([]);
|
||||
}}
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ScrollableRow>
|
||||
{loading
|
||||
? // 加载状态显示灰色占位数据
|
||||
Array.from({ length: 6 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
|
||||
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
|
||||
</div>
|
||||
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
|
||||
<div className='mt-1 h-3 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
|
||||
</div>
|
||||
))
|
||||
: // 显示真实数据
|
||||
playRecords.map((record) => {
|
||||
const { source, id } = parseKey(record.key);
|
||||
return (
|
||||
<div
|
||||
key={record.key}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<VideoCard
|
||||
id={id}
|
||||
title={record.title}
|
||||
poster={record.cover}
|
||||
year={record.year}
|
||||
source={source}
|
||||
source_name={record.source_name}
|
||||
progress={getProgress(record)}
|
||||
episodes={record.total_episodes}
|
||||
currentEpisode={record.index}
|
||||
query={record.search_title}
|
||||
from='playrecord'
|
||||
onDelete={() =>
|
||||
setPlayRecords((prev) =>
|
||||
prev.filter((r) => r.key !== record.key)
|
||||
)
|
||||
}
|
||||
type={record.total_episodes > 1 ? 'tv' : ''}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
'use client';
|
||||
|
||||
import { AlertCircle, AlertTriangle, CheckCircle, Download, FileCheck, Lock, Upload } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface DataMigrationProps {
|
||||
onRefreshConfig?: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface AlertModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
type: 'success' | 'error' | 'warning';
|
||||
title: string;
|
||||
message?: string;
|
||||
html?: string;
|
||||
confirmText?: string;
|
||||
onConfirm?: () => void;
|
||||
showConfirm?: boolean;
|
||||
timer?: number;
|
||||
}
|
||||
|
||||
const AlertModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
html,
|
||||
confirmText = '确定',
|
||||
onConfirm,
|
||||
showConfirm = false,
|
||||
timer
|
||||
}: AlertModalProps) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
// 控制动画状态
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setIsVisible(true);
|
||||
if (timer) {
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, timer);
|
||||
}
|
||||
} else {
|
||||
setIsVisible(false);
|
||||
}
|
||||
}, [isOpen, timer, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const getIcon = () => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return <CheckCircle className="w-12 h-12 text-green-500" />;
|
||||
case 'error':
|
||||
return <AlertCircle className="w-12 h-12 text-red-500" />;
|
||||
case 'warning':
|
||||
return <AlertTriangle className="w-12 h-12 text-yellow-500" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getBgColor = () => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800';
|
||||
case 'error':
|
||||
return 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800';
|
||||
case 'warning':
|
||||
return 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800';
|
||||
default:
|
||||
return 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800';
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className={`fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4 transition-opacity duration-200 ${isVisible ? 'opacity-100' : 'opacity-0'}`} onClick={onClose}>
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full border ${getBgColor()} transition-all duration-200 ${isVisible ? 'scale-100' : 'scale-95'}`} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="p-6 text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
{getIcon()}
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{message && (
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{html && (
|
||||
<div
|
||||
className="text-left text-gray-600 dark:text-gray-400 mb-4"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center space-x-3">
|
||||
{showConfirm && onConfirm ? (
|
||||
<>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onConfirm();
|
||||
onClose();
|
||||
}}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
const DataMigration = ({ onRefreshConfig }: DataMigrationProps) => {
|
||||
const [exportPassword, setExportPassword] = useState('');
|
||||
const [importPassword, setImportPassword] = useState('');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [alertModal, setAlertModal] = useState<{
|
||||
isOpen: boolean;
|
||||
type: 'success' | 'error' | 'warning';
|
||||
title: string;
|
||||
message?: string;
|
||||
html?: string;
|
||||
confirmText?: string;
|
||||
onConfirm?: () => void;
|
||||
showConfirm?: boolean;
|
||||
timer?: number;
|
||||
}>({
|
||||
isOpen: false,
|
||||
type: 'success',
|
||||
title: '',
|
||||
});
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const showAlert = (config: Omit<typeof alertModal, 'isOpen'>) => {
|
||||
setAlertModal({ ...config, isOpen: true });
|
||||
};
|
||||
|
||||
const hideAlert = () => {
|
||||
setAlertModal(prev => ({ ...prev, isOpen: false }));
|
||||
};
|
||||
|
||||
// 导出数据
|
||||
const handleExport = async () => {
|
||||
if (!exportPassword.trim()) {
|
||||
showAlert({
|
||||
type: 'error',
|
||||
title: '错误',
|
||||
message: '请输入加密密码',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsExporting(true);
|
||||
|
||||
const response = await fetch('/api/admin/data_migration/export', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
password: exportPassword,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `导出失败: ${response.status}`);
|
||||
}
|
||||
|
||||
// 获取文件名
|
||||
const contentDisposition = response.headers.get('content-disposition');
|
||||
const filenameMatch = contentDisposition?.match(/filename="(.+)"/);
|
||||
const filename = filenameMatch?.[1] || 'moontv-backup.dat';
|
||||
|
||||
// 下载文件
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.style.display = 'none';
|
||||
a.style.position = 'fixed';
|
||||
a.style.top = '0';
|
||||
a.style.left = '0';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
showAlert({
|
||||
type: 'success',
|
||||
title: '导出成功',
|
||||
message: '数据已成功导出,请妥善保管备份文件和密码',
|
||||
timer: 3000,
|
||||
});
|
||||
|
||||
setExportPassword('');
|
||||
} catch (error) {
|
||||
showAlert({
|
||||
type: 'error',
|
||||
title: '导出失败',
|
||||
message: error instanceof Error ? error.message : '导出过程中发生错误',
|
||||
});
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 文件选择处理
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
// 导入数据
|
||||
const handleImport = async () => {
|
||||
if (!selectedFile) {
|
||||
showAlert({
|
||||
type: 'error',
|
||||
title: '错误',
|
||||
message: '请选择备份文件',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!importPassword.trim()) {
|
||||
showAlert({
|
||||
type: 'error',
|
||||
title: '错误',
|
||||
message: '请输入解密密码',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsImporting(true);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
formData.append('password', importPassword);
|
||||
|
||||
const response = await fetch('/api/admin/data_migration/import', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || `导入失败: ${response.status}`);
|
||||
}
|
||||
|
||||
showAlert({
|
||||
type: 'success',
|
||||
title: '导入成功',
|
||||
html: `
|
||||
<div class="text-left">
|
||||
<p><strong>导入完成!</strong></p>
|
||||
<p class="mt-2">导入的用户数量: ${result.importedUsers}</p>
|
||||
<p>备份时间: ${new Date(result.timestamp).toLocaleString('zh-CN')}</p>
|
||||
<p>服务器版本: ${result.serverVersion || '未知版本'}</p>
|
||||
<p class="mt-3 text-orange-600">请刷新页面以查看最新数据。</p>
|
||||
</div>
|
||||
`,
|
||||
confirmText: '刷新页面',
|
||||
showConfirm: true,
|
||||
onConfirm: async () => {
|
||||
// 清理状态
|
||||
setSelectedFile(null);
|
||||
setImportPassword('');
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
|
||||
// 刷新配置
|
||||
if (onRefreshConfig) {
|
||||
await onRefreshConfig();
|
||||
}
|
||||
|
||||
// 刷新页面
|
||||
window.location.reload();
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
showAlert({
|
||||
type: 'error',
|
||||
title: '导入失败',
|
||||
message: error instanceof Error ? error.message : '导入过程中发生错误',
|
||||
});
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
{/* 简洁警告提示 */}
|
||||
<div className="flex items-center gap-3 p-4 border border-amber-200 dark:border-amber-700 rounded-lg bg-amber-50/30 dark:bg-amber-900/5">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0" />
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
数据迁移操作请谨慎,确保已备份重要数据
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 主要操作区域 - 响应式布局 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 数据导出 */}
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-6 bg-white dark:bg-gray-800 hover:shadow-sm transition-shadow flex flex-col">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-50 dark:bg-blue-900/20 flex items-center justify-center">
|
||||
<Download className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100">数据导出</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">创建加密备份文件</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="space-y-4">
|
||||
{/* 密码输入 */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<Lock className="w-4 h-4" />
|
||||
加密密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={exportPassword}
|
||||
onChange={(e) => setExportPassword(e.target.value)}
|
||||
placeholder="设置强密码保护备份文件"
|
||||
className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
disabled={isExporting}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
导入时需要使用相同密码
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 备份内容列表 */}
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1">
|
||||
<p className="font-medium text-gray-700 dark:text-gray-300 mb-2">备份内容:</p>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<div>• 管理配置</div>
|
||||
<div>• 用户数据</div>
|
||||
<div>• 播放记录</div>
|
||||
<div>• 收藏夹</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 导出按钮 */}
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={isExporting || !exportPassword.trim()}
|
||||
className={`w-full px-4 py-2.5 rounded-lg font-medium transition-colors mt-10 ${isExporting || !exportPassword.trim()
|
||||
? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed text-gray-500 dark:text-gray-400'
|
||||
: 'bg-blue-600 hover:bg-blue-700 text-white'
|
||||
}`}
|
||||
>
|
||||
{isExporting ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
导出中...
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Download className="w-4 h-4" />
|
||||
导出数据
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 数据导入 */}
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-6 bg-white dark:bg-gray-800 hover:shadow-sm transition-shadow flex flex-col">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-8 h-8 rounded-lg bg-red-50 dark:bg-red-900/20 flex items-center justify-center">
|
||||
<Upload className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100">数据导入</h3>
|
||||
<p className="text-sm text-red-600 dark:text-red-400">⚠️ 将清空现有数据</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="space-y-4">
|
||||
{/* 文件选择 */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<FileCheck className="w-4 h-4" />
|
||||
备份文件
|
||||
{selectedFile && (
|
||||
<span className="ml-auto text-xs text-green-600 dark:text-green-400 font-normal">
|
||||
{selectedFile.name} ({(selectedFile.size / 1024).toFixed(1)} KB)
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".dat"
|
||||
onChange={handleFileSelect}
|
||||
className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-red-500 focus:border-red-500 file:mr-3 file:py-1.5 file:px-3 file:rounded file:border-0 file:text-sm file:font-medium file:bg-gray-50 dark:file:bg-gray-600 file:text-gray-700 dark:file:text-gray-300 hover:file:bg-gray-100 dark:hover:file:bg-gray-500 transition-colors"
|
||||
disabled={isImporting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 密码输入 */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<Lock className="w-4 h-4" />
|
||||
解密密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={importPassword}
|
||||
onChange={(e) => setImportPassword(e.target.value)}
|
||||
placeholder="输入导出时的加密密码"
|
||||
className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-colors"
|
||||
disabled={isImporting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 导入按钮 */}
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={isImporting || !selectedFile || !importPassword.trim()}
|
||||
className={`w-full px-4 py-2.5 rounded-lg font-medium transition-colors mt-10 ${isImporting || !selectedFile || !importPassword.trim()
|
||||
? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed text-gray-500 dark:text-gray-400'
|
||||
: 'bg-red-600 hover:bg-red-700 text-white'
|
||||
}`}
|
||||
>
|
||||
{isImporting ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
导入中...
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Upload className="w-4 h-4" />
|
||||
导入数据
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 弹窗组件 */}
|
||||
<AlertModal
|
||||
isOpen={alertModal.isOpen}
|
||||
onClose={hideAlert}
|
||||
type={alertModal.type}
|
||||
title={alertModal.title}
|
||||
message={alertModal.message}
|
||||
html={alertModal.html}
|
||||
confirmText={alertModal.confirmText}
|
||||
onConfirm={alertModal.onConfirm}
|
||||
showConfirm={alertModal.showConfirm}
|
||||
timer={alertModal.timer}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataMigration;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ImagePlaceholder } from '@/components/ImagePlaceholder';
|
||||
|
||||
const DoubanCardSkeleton = () => {
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<div className='group relative w-full rounded-lg bg-transparent shadow-none flex flex-col'>
|
||||
{/* 图片占位符 - 骨架屏效果 */}
|
||||
<ImagePlaceholder aspectRatio='aspect-[2/3]' />
|
||||
|
||||
{/* 信息层骨架 */}
|
||||
<div className='absolute top-[calc(100%+0.5rem)] left-0 right-0'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<div className='h-4 w-24 sm:w-32 bg-gray-200 rounded animate-pulse mb-2'></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DoubanCardSkeleton;
|
||||
@@ -0,0 +1,318 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface CustomCategory {
|
||||
name: string;
|
||||
type: 'movie' | 'tv';
|
||||
query: string;
|
||||
}
|
||||
|
||||
interface DoubanCustomSelectorProps {
|
||||
customCategories: CustomCategory[];
|
||||
primarySelection?: string;
|
||||
secondarySelection?: string;
|
||||
onPrimaryChange: (value: string) => void;
|
||||
onSecondaryChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const DoubanCustomSelector: React.FC<DoubanCustomSelectorProps> = ({
|
||||
customCategories,
|
||||
primarySelection,
|
||||
secondarySelection,
|
||||
onPrimaryChange,
|
||||
onSecondaryChange,
|
||||
}) => {
|
||||
// 为不同的选择器创建独立的refs和状态
|
||||
const primaryContainerRef = useRef<HTMLDivElement>(null);
|
||||
const primaryButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const [primaryIndicatorStyle, setPrimaryIndicatorStyle] = useState<{
|
||||
left: number;
|
||||
width: number;
|
||||
}>({ left: 0, width: 0 });
|
||||
|
||||
const secondaryContainerRef = useRef<HTMLDivElement>(null);
|
||||
const secondaryButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const [secondaryIndicatorStyle, setSecondaryIndicatorStyle] = useState<{
|
||||
left: number;
|
||||
width: number;
|
||||
}>({ left: 0, width: 0 });
|
||||
|
||||
// 二级选择器滚动容器的ref
|
||||
const secondaryScrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 根据 customCategories 生成一级选择器选项(按 type 分组,电影优先)
|
||||
const primaryOptions = React.useMemo(() => {
|
||||
const types = Array.from(new Set(customCategories.map((cat) => cat.type)));
|
||||
// 确保电影类型排在前面
|
||||
const sortedTypes = types.sort((a, b) => {
|
||||
if (a === 'movie' && b !== 'movie') return -1;
|
||||
if (a !== 'movie' && b === 'movie') return 1;
|
||||
return 0;
|
||||
});
|
||||
return sortedTypes.map((type) => ({
|
||||
label: type === 'movie' ? '电影' : '剧集',
|
||||
value: type,
|
||||
}));
|
||||
}, [customCategories]);
|
||||
|
||||
// 根据选中的一级选项生成二级选择器选项
|
||||
const secondaryOptions = React.useMemo(() => {
|
||||
if (!primarySelection) return [];
|
||||
return customCategories
|
||||
.filter((cat) => cat.type === primarySelection)
|
||||
.map((cat) => ({
|
||||
label: cat.name || cat.query,
|
||||
value: cat.query,
|
||||
}));
|
||||
}, [customCategories, primarySelection]);
|
||||
|
||||
// 处理二级选择器的鼠标滚轮事件(原生 DOM 事件)
|
||||
const handleSecondaryWheel = React.useCallback((e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const container = secondaryScrollContainerRef.current;
|
||||
if (container) {
|
||||
const scrollAmount = e.deltaY * 2;
|
||||
container.scrollLeft += scrollAmount;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 添加二级选择器的鼠标滚轮事件监听器
|
||||
useEffect(() => {
|
||||
const scrollContainer = secondaryScrollContainerRef.current;
|
||||
const capsuleContainer = secondaryContainerRef.current;
|
||||
|
||||
if (scrollContainer && capsuleContainer) {
|
||||
// 同时监听滚动容器和胶囊容器的滚轮事件
|
||||
scrollContainer.addEventListener('wheel', handleSecondaryWheel, {
|
||||
passive: false,
|
||||
});
|
||||
capsuleContainer.addEventListener('wheel', handleSecondaryWheel, {
|
||||
passive: false,
|
||||
});
|
||||
|
||||
return () => {
|
||||
scrollContainer.removeEventListener('wheel', handleSecondaryWheel);
|
||||
capsuleContainer.removeEventListener('wheel', handleSecondaryWheel);
|
||||
};
|
||||
}
|
||||
}, [handleSecondaryWheel]);
|
||||
|
||||
// 当二级选项变化时重新添加事件监听器
|
||||
useEffect(() => {
|
||||
const scrollContainer = secondaryScrollContainerRef.current;
|
||||
const capsuleContainer = secondaryContainerRef.current;
|
||||
|
||||
if (scrollContainer && capsuleContainer && secondaryOptions.length > 0) {
|
||||
// 重新添加事件监听器
|
||||
scrollContainer.addEventListener('wheel', handleSecondaryWheel, {
|
||||
passive: false,
|
||||
});
|
||||
capsuleContainer.addEventListener('wheel', handleSecondaryWheel, {
|
||||
passive: false,
|
||||
});
|
||||
|
||||
return () => {
|
||||
scrollContainer.removeEventListener('wheel', handleSecondaryWheel);
|
||||
capsuleContainer.removeEventListener('wheel', handleSecondaryWheel);
|
||||
};
|
||||
}
|
||||
}, [handleSecondaryWheel, secondaryOptions]);
|
||||
|
||||
// 更新指示器位置的通用函数
|
||||
const updateIndicatorPosition = (
|
||||
activeIndex: number,
|
||||
containerRef: React.RefObject<HTMLDivElement>,
|
||||
buttonRefs: React.MutableRefObject<(HTMLButtonElement | null)[]>,
|
||||
setIndicatorStyle: React.Dispatch<
|
||||
React.SetStateAction<{ left: number; width: number }>
|
||||
>
|
||||
) => {
|
||||
if (
|
||||
activeIndex >= 0 &&
|
||||
buttonRefs.current[activeIndex] &&
|
||||
containerRef.current
|
||||
) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
const button = buttonRefs.current[activeIndex];
|
||||
const container = containerRef.current;
|
||||
if (button && container) {
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
if (buttonRect.width > 0) {
|
||||
setIndicatorStyle({
|
||||
left: buttonRect.left - containerRect.left,
|
||||
width: buttonRect.width,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
|
||||
// 组件挂载时立即计算初始位置
|
||||
useEffect(() => {
|
||||
// 主选择器初始位置
|
||||
if (primaryOptions.length > 0) {
|
||||
const activeIndex = primaryOptions.findIndex(
|
||||
(opt) => opt.value === (primarySelection || primaryOptions[0].value)
|
||||
);
|
||||
updateIndicatorPosition(
|
||||
activeIndex,
|
||||
primaryContainerRef,
|
||||
primaryButtonRefs,
|
||||
setPrimaryIndicatorStyle
|
||||
);
|
||||
}
|
||||
|
||||
// 副选择器初始位置
|
||||
if (secondaryOptions.length > 0) {
|
||||
const activeIndex = secondaryOptions.findIndex(
|
||||
(opt) => opt.value === (secondarySelection || secondaryOptions[0].value)
|
||||
);
|
||||
updateIndicatorPosition(
|
||||
activeIndex,
|
||||
secondaryContainerRef,
|
||||
secondaryButtonRefs,
|
||||
setSecondaryIndicatorStyle
|
||||
);
|
||||
}
|
||||
}, [primaryOptions, secondaryOptions]); // 当选项变化时重新计算
|
||||
|
||||
// 监听主选择器变化
|
||||
useEffect(() => {
|
||||
if (primaryOptions.length > 0) {
|
||||
const activeIndex = primaryOptions.findIndex(
|
||||
(opt) => opt.value === primarySelection
|
||||
);
|
||||
const cleanup = updateIndicatorPosition(
|
||||
activeIndex,
|
||||
primaryContainerRef,
|
||||
primaryButtonRefs,
|
||||
setPrimaryIndicatorStyle
|
||||
);
|
||||
return cleanup;
|
||||
}
|
||||
}, [primarySelection, primaryOptions]);
|
||||
|
||||
// 监听副选择器变化
|
||||
useEffect(() => {
|
||||
if (secondaryOptions.length > 0) {
|
||||
const activeIndex = secondaryOptions.findIndex(
|
||||
(opt) => opt.value === secondarySelection
|
||||
);
|
||||
const cleanup = updateIndicatorPosition(
|
||||
activeIndex,
|
||||
secondaryContainerRef,
|
||||
secondaryButtonRefs,
|
||||
setSecondaryIndicatorStyle
|
||||
);
|
||||
return cleanup;
|
||||
}
|
||||
}, [secondarySelection, secondaryOptions]);
|
||||
|
||||
// 渲染胶囊式选择器
|
||||
const renderCapsuleSelector = (
|
||||
options: { label: string; value: string }[],
|
||||
activeValue: string | undefined,
|
||||
onChange: (value: string) => void,
|
||||
isPrimary = false
|
||||
) => {
|
||||
const containerRef = isPrimary
|
||||
? primaryContainerRef
|
||||
: secondaryContainerRef;
|
||||
const buttonRefs = isPrimary ? primaryButtonRefs : secondaryButtonRefs;
|
||||
const indicatorStyle = isPrimary
|
||||
? primaryIndicatorStyle
|
||||
: secondaryIndicatorStyle;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className='relative inline-flex bg-gray-200/60 rounded-full p-0.5 sm:p-1 dark:bg-gray-700/60 backdrop-blur-sm'
|
||||
>
|
||||
{/* 滑动的白色背景指示器 */}
|
||||
{indicatorStyle.width > 0 && (
|
||||
<div
|
||||
className='absolute top-0.5 bottom-0.5 sm:top-1 sm:bottom-1 bg-white dark:bg-gray-500 rounded-full shadow-sm transition-all duration-300 ease-out'
|
||||
style={{
|
||||
left: `${indicatorStyle.left}px`,
|
||||
width: `${indicatorStyle.width}px`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{options.map((option, index) => {
|
||||
const isActive = activeValue === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
ref={(el) => {
|
||||
buttonRefs.current[index] = el;
|
||||
}}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={`relative z-10 px-2 py-1 sm:px-4 sm:py-2 text-xs sm:text-sm font-medium rounded-full transition-all duration-200 whitespace-nowrap ${
|
||||
isActive
|
||||
? 'text-gray-900 dark:text-gray-100 cursor-default'
|
||||
: 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 如果没有自定义分类,则不渲染任何内容
|
||||
if (!customCategories || customCategories.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-4 sm:space-y-6'>
|
||||
{/* 两级选择器包装 */}
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
{/* 一级选择器 */}
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
|
||||
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
|
||||
类型
|
||||
</span>
|
||||
<div className='overflow-x-auto'>
|
||||
{renderCapsuleSelector(
|
||||
primaryOptions,
|
||||
primarySelection || primaryOptions[0]?.value,
|
||||
onPrimaryChange,
|
||||
true
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 二级选择器 */}
|
||||
{secondaryOptions.length > 0 && (
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
|
||||
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
|
||||
片单
|
||||
</span>
|
||||
<div ref={secondaryScrollContainerRef} className='overflow-x-auto'>
|
||||
{renderCapsuleSelector(
|
||||
secondaryOptions,
|
||||
secondarySelection || secondaryOptions[0]?.value,
|
||||
onSecondaryChange,
|
||||
false
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DoubanCustomSelector;
|
||||
@@ -0,0 +1,567 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import MultiLevelSelector from './MultiLevelSelector';
|
||||
import WeekdaySelector from './WeekdaySelector';
|
||||
|
||||
interface SelectorOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface DoubanSelectorProps {
|
||||
type: 'movie' | 'tv' | 'show' | 'anime';
|
||||
primarySelection?: string;
|
||||
secondarySelection?: string;
|
||||
onPrimaryChange: (value: string) => void;
|
||||
onSecondaryChange: (value: string) => void;
|
||||
onMultiLevelChange?: (values: Record<string, string>) => void;
|
||||
onWeekdayChange: (weekday: string) => void;
|
||||
}
|
||||
|
||||
const DoubanSelector: React.FC<DoubanSelectorProps> = ({
|
||||
type,
|
||||
primarySelection,
|
||||
secondarySelection,
|
||||
onPrimaryChange,
|
||||
onSecondaryChange,
|
||||
onMultiLevelChange,
|
||||
onWeekdayChange,
|
||||
}) => {
|
||||
// 为不同的选择器创建独立的refs和状态
|
||||
const primaryContainerRef = useRef<HTMLDivElement>(null);
|
||||
const primaryButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const [primaryIndicatorStyle, setPrimaryIndicatorStyle] = useState<{
|
||||
left: number;
|
||||
width: number;
|
||||
}>({ left: 0, width: 0 });
|
||||
|
||||
const secondaryContainerRef = useRef<HTMLDivElement>(null);
|
||||
const secondaryButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const [secondaryIndicatorStyle, setSecondaryIndicatorStyle] = useState<{
|
||||
left: number;
|
||||
width: number;
|
||||
}>({ left: 0, width: 0 });
|
||||
|
||||
// 电影的一级选择器选项
|
||||
const moviePrimaryOptions: SelectorOption[] = [
|
||||
{ label: '全部', value: '全部' },
|
||||
{ label: '热门电影', value: '热门' },
|
||||
{ label: '最新电影', value: '最新' },
|
||||
{ label: '豆瓣高分', value: '豆瓣高分' },
|
||||
{ label: '冷门佳片', value: '冷门佳片' },
|
||||
];
|
||||
|
||||
// 电影的二级选择器选项
|
||||
const movieSecondaryOptions: SelectorOption[] = [
|
||||
{ label: '全部', value: '全部' },
|
||||
{ label: '华语', value: '华语' },
|
||||
{ label: '欧美', value: '欧美' },
|
||||
{ label: '韩国', value: '韩国' },
|
||||
{ label: '日本', value: '日本' },
|
||||
];
|
||||
|
||||
// 电视剧一级选择器选项
|
||||
const tvPrimaryOptions: SelectorOption[] = [
|
||||
{ label: '全部', value: '全部' },
|
||||
{ label: '最近热门', value: '最近热门' },
|
||||
];
|
||||
|
||||
// 电视剧二级选择器选项
|
||||
const tvSecondaryOptions: SelectorOption[] = [
|
||||
{ label: '全部', value: 'tv' },
|
||||
{ label: '国产', value: 'tv_domestic' },
|
||||
{ label: '欧美', value: 'tv_american' },
|
||||
{ label: '日本', value: 'tv_japanese' },
|
||||
{ label: '韩国', value: 'tv_korean' },
|
||||
{ label: '动漫', value: 'tv_animation' },
|
||||
{ label: '纪录片', value: 'tv_documentary' },
|
||||
];
|
||||
|
||||
// 综艺一级选择器选项
|
||||
const showPrimaryOptions: SelectorOption[] = [
|
||||
{ label: '全部', value: '全部' },
|
||||
{ label: '最近热门', value: '最近热门' },
|
||||
];
|
||||
|
||||
// 综艺二级选择器选项
|
||||
const showSecondaryOptions: SelectorOption[] = [
|
||||
{ label: '全部', value: 'show' },
|
||||
{ label: '国内', value: 'show_domestic' },
|
||||
{ label: '国外', value: 'show_foreign' },
|
||||
];
|
||||
|
||||
// 动漫一级选择器选项
|
||||
const animePrimaryOptions: SelectorOption[] = [
|
||||
{ label: '每日放送', value: '每日放送' },
|
||||
{ label: '番剧', value: '番剧' },
|
||||
{ label: '剧场版', value: '剧场版' },
|
||||
];
|
||||
|
||||
// 处理多级选择器变化
|
||||
const handleMultiLevelChange = (values: Record<string, string>) => {
|
||||
onMultiLevelChange?.(values);
|
||||
};
|
||||
|
||||
// 更新指示器位置的通用函数
|
||||
const updateIndicatorPosition = (
|
||||
activeIndex: number,
|
||||
containerRef: React.RefObject<HTMLDivElement>,
|
||||
buttonRefs: React.MutableRefObject<(HTMLButtonElement | null)[]>,
|
||||
setIndicatorStyle: React.Dispatch<
|
||||
React.SetStateAction<{ left: number; width: number }>
|
||||
>
|
||||
) => {
|
||||
if (
|
||||
activeIndex >= 0 &&
|
||||
buttonRefs.current[activeIndex] &&
|
||||
containerRef.current
|
||||
) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
const button = buttonRefs.current[activeIndex];
|
||||
const container = containerRef.current;
|
||||
if (button && container) {
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
if (buttonRect.width > 0) {
|
||||
setIndicatorStyle({
|
||||
left: buttonRect.left - containerRect.left,
|
||||
width: buttonRect.width,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
|
||||
// 组件挂载时立即计算初始位置
|
||||
useEffect(() => {
|
||||
// 主选择器初始位置
|
||||
if (type === 'movie') {
|
||||
const activeIndex = moviePrimaryOptions.findIndex(
|
||||
(opt) =>
|
||||
opt.value === (primarySelection || moviePrimaryOptions[0].value)
|
||||
);
|
||||
updateIndicatorPosition(
|
||||
activeIndex,
|
||||
primaryContainerRef,
|
||||
primaryButtonRefs,
|
||||
setPrimaryIndicatorStyle
|
||||
);
|
||||
} else if (type === 'tv') {
|
||||
const activeIndex = tvPrimaryOptions.findIndex(
|
||||
(opt) => opt.value === (primarySelection || tvPrimaryOptions[1].value)
|
||||
);
|
||||
updateIndicatorPosition(
|
||||
activeIndex,
|
||||
primaryContainerRef,
|
||||
primaryButtonRefs,
|
||||
setPrimaryIndicatorStyle
|
||||
);
|
||||
} else if (type === 'anime') {
|
||||
const activeIndex = animePrimaryOptions.findIndex(
|
||||
(opt) =>
|
||||
opt.value === (primarySelection || animePrimaryOptions[0].value)
|
||||
);
|
||||
updateIndicatorPosition(
|
||||
activeIndex,
|
||||
primaryContainerRef,
|
||||
primaryButtonRefs,
|
||||
setPrimaryIndicatorStyle
|
||||
);
|
||||
} else if (type === 'show') {
|
||||
const activeIndex = showPrimaryOptions.findIndex(
|
||||
(opt) => opt.value === (primarySelection || showPrimaryOptions[1].value)
|
||||
);
|
||||
updateIndicatorPosition(
|
||||
activeIndex,
|
||||
primaryContainerRef,
|
||||
primaryButtonRefs,
|
||||
setPrimaryIndicatorStyle
|
||||
);
|
||||
}
|
||||
|
||||
// 副选择器初始位置
|
||||
let secondaryActiveIndex = -1;
|
||||
if (type === 'movie') {
|
||||
secondaryActiveIndex = movieSecondaryOptions.findIndex(
|
||||
(opt) =>
|
||||
opt.value === (secondarySelection || movieSecondaryOptions[0].value)
|
||||
);
|
||||
} else if (type === 'tv') {
|
||||
secondaryActiveIndex = tvSecondaryOptions.findIndex(
|
||||
(opt) =>
|
||||
opt.value === (secondarySelection || tvSecondaryOptions[0].value)
|
||||
);
|
||||
} else if (type === 'show') {
|
||||
secondaryActiveIndex = showSecondaryOptions.findIndex(
|
||||
(opt) =>
|
||||
opt.value === (secondarySelection || showSecondaryOptions[0].value)
|
||||
);
|
||||
}
|
||||
|
||||
if (secondaryActiveIndex >= 0) {
|
||||
updateIndicatorPosition(
|
||||
secondaryActiveIndex,
|
||||
secondaryContainerRef,
|
||||
secondaryButtonRefs,
|
||||
setSecondaryIndicatorStyle
|
||||
);
|
||||
}
|
||||
}, [type]); // 只在type变化时重新计算
|
||||
|
||||
// 监听主选择器变化
|
||||
useEffect(() => {
|
||||
if (type === 'movie') {
|
||||
const activeIndex = moviePrimaryOptions.findIndex(
|
||||
(opt) => opt.value === primarySelection
|
||||
);
|
||||
const cleanup = updateIndicatorPosition(
|
||||
activeIndex,
|
||||
primaryContainerRef,
|
||||
primaryButtonRefs,
|
||||
setPrimaryIndicatorStyle
|
||||
);
|
||||
return cleanup;
|
||||
} else if (type === 'tv') {
|
||||
const activeIndex = tvPrimaryOptions.findIndex(
|
||||
(opt) => opt.value === primarySelection
|
||||
);
|
||||
const cleanup = updateIndicatorPosition(
|
||||
activeIndex,
|
||||
primaryContainerRef,
|
||||
primaryButtonRefs,
|
||||
setPrimaryIndicatorStyle
|
||||
);
|
||||
return cleanup;
|
||||
} else if (type === 'anime') {
|
||||
const activeIndex = animePrimaryOptions.findIndex(
|
||||
(opt) => opt.value === primarySelection
|
||||
);
|
||||
const cleanup = updateIndicatorPosition(
|
||||
activeIndex,
|
||||
primaryContainerRef,
|
||||
primaryButtonRefs,
|
||||
setPrimaryIndicatorStyle
|
||||
);
|
||||
return cleanup;
|
||||
} else if (type === 'show') {
|
||||
const activeIndex = showPrimaryOptions.findIndex(
|
||||
(opt) => opt.value === primarySelection
|
||||
);
|
||||
const cleanup = updateIndicatorPosition(
|
||||
activeIndex,
|
||||
primaryContainerRef,
|
||||
primaryButtonRefs,
|
||||
setPrimaryIndicatorStyle
|
||||
);
|
||||
return cleanup;
|
||||
}
|
||||
}, [primarySelection]);
|
||||
|
||||
// 监听副选择器变化
|
||||
useEffect(() => {
|
||||
let activeIndex = -1;
|
||||
let options: SelectorOption[] = [];
|
||||
|
||||
if (type === 'movie') {
|
||||
activeIndex = movieSecondaryOptions.findIndex(
|
||||
(opt) => opt.value === secondarySelection
|
||||
);
|
||||
options = movieSecondaryOptions;
|
||||
} else if (type === 'tv') {
|
||||
activeIndex = tvSecondaryOptions.findIndex(
|
||||
(opt) => opt.value === secondarySelection
|
||||
);
|
||||
options = tvSecondaryOptions;
|
||||
} else if (type === 'show') {
|
||||
activeIndex = showSecondaryOptions.findIndex(
|
||||
(opt) => opt.value === secondarySelection
|
||||
);
|
||||
options = showSecondaryOptions;
|
||||
}
|
||||
|
||||
if (options.length > 0) {
|
||||
const cleanup = updateIndicatorPosition(
|
||||
activeIndex,
|
||||
secondaryContainerRef,
|
||||
secondaryButtonRefs,
|
||||
setSecondaryIndicatorStyle
|
||||
);
|
||||
return cleanup;
|
||||
}
|
||||
}, [secondarySelection]);
|
||||
|
||||
// 渲染胶囊式选择器
|
||||
const renderCapsuleSelector = (
|
||||
options: SelectorOption[],
|
||||
activeValue: string | undefined,
|
||||
onChange: (value: string) => void,
|
||||
isPrimary = false
|
||||
) => {
|
||||
const containerRef = isPrimary
|
||||
? primaryContainerRef
|
||||
: secondaryContainerRef;
|
||||
const buttonRefs = isPrimary ? primaryButtonRefs : secondaryButtonRefs;
|
||||
const indicatorStyle = isPrimary
|
||||
? primaryIndicatorStyle
|
||||
: secondaryIndicatorStyle;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className='relative inline-flex bg-gray-200/60 rounded-full p-0.5 sm:p-1 dark:bg-gray-700/60 backdrop-blur-sm'
|
||||
>
|
||||
{/* 滑动的白色背景指示器 */}
|
||||
{indicatorStyle.width > 0 && (
|
||||
<div
|
||||
className='absolute top-0.5 bottom-0.5 sm:top-1 sm:bottom-1 bg-white dark:bg-gray-500 rounded-full shadow-sm transition-all duration-300 ease-out'
|
||||
style={{
|
||||
left: `${indicatorStyle.left}px`,
|
||||
width: `${indicatorStyle.width}px`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{options.map((option, index) => {
|
||||
const isActive = activeValue === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
ref={(el) => {
|
||||
buttonRefs.current[index] = el;
|
||||
}}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={`relative z-10 px-2 py-1 sm:px-4 sm:py-2 text-xs sm:text-sm font-medium rounded-full transition-all duration-200 whitespace-nowrap ${
|
||||
isActive
|
||||
? 'text-gray-900 dark:text-gray-100 cursor-default'
|
||||
: 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='space-y-4 sm:space-y-6'>
|
||||
{/* 电影类型 - 显示两级选择器 */}
|
||||
{type === 'movie' && (
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
{/* 一级选择器 */}
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
|
||||
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
|
||||
分类
|
||||
</span>
|
||||
<div className='overflow-x-auto'>
|
||||
{renderCapsuleSelector(
|
||||
moviePrimaryOptions,
|
||||
primarySelection || moviePrimaryOptions[0].value,
|
||||
onPrimaryChange,
|
||||
true
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 二级选择器 - 只在非"全部"时显示 */}
|
||||
{primarySelection !== '全部' ? (
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
|
||||
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
|
||||
地区
|
||||
</span>
|
||||
<div className='overflow-x-auto'>
|
||||
{renderCapsuleSelector(
|
||||
movieSecondaryOptions,
|
||||
secondarySelection || movieSecondaryOptions[0].value,
|
||||
onSecondaryChange,
|
||||
false
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* 多级选择器 - 只在选中"全部"时显示 */
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
|
||||
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
|
||||
筛选
|
||||
</span>
|
||||
<div className='overflow-x-auto'>
|
||||
<MultiLevelSelector
|
||||
key={`${type}-${primarySelection}`}
|
||||
onChange={handleMultiLevelChange}
|
||||
contentType={type}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 电视剧类型 - 显示两级选择器 */}
|
||||
{type === 'tv' && (
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
{/* 一级选择器 */}
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
|
||||
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
|
||||
分类
|
||||
</span>
|
||||
<div className='overflow-x-auto'>
|
||||
{renderCapsuleSelector(
|
||||
tvPrimaryOptions,
|
||||
primarySelection || tvPrimaryOptions[1].value,
|
||||
onPrimaryChange,
|
||||
true
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 二级选择器 - 只在选中"最近热门"时显示,选中"全部"时显示多级选择器 */}
|
||||
{(primarySelection || tvPrimaryOptions[1].value) === '最近热门' ? (
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
|
||||
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
|
||||
类型
|
||||
</span>
|
||||
<div className='overflow-x-auto'>
|
||||
{renderCapsuleSelector(
|
||||
tvSecondaryOptions,
|
||||
secondarySelection || tvSecondaryOptions[0].value,
|
||||
onSecondaryChange,
|
||||
false
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (primarySelection || tvPrimaryOptions[1].value) === '全部' ? (
|
||||
/* 多级选择器 - 只在选中"全部"时显示 */
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
|
||||
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
|
||||
筛选
|
||||
</span>
|
||||
<div className='overflow-x-auto'>
|
||||
<MultiLevelSelector
|
||||
key={`${type}-${primarySelection}`}
|
||||
onChange={handleMultiLevelChange}
|
||||
contentType={type}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 动漫类型 - 显示一级选择器和多级选择器 */}
|
||||
{type === 'anime' && (
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
|
||||
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
|
||||
分类
|
||||
</span>
|
||||
<div className='overflow-x-auto'>
|
||||
{renderCapsuleSelector(
|
||||
animePrimaryOptions,
|
||||
primarySelection || animePrimaryOptions[0].value,
|
||||
onPrimaryChange,
|
||||
true
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 筛选部分 - 根据一级选择器显示不同内容 */}
|
||||
{(primarySelection || animePrimaryOptions[0].value) === '每日放送' ? (
|
||||
// 每日放送分类下显示星期选择器
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
|
||||
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
|
||||
星期
|
||||
</span>
|
||||
<div className='overflow-x-auto'>
|
||||
<WeekdaySelector onWeekdayChange={onWeekdayChange} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 其他分类下显示原有的筛选功能
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
|
||||
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
|
||||
筛选
|
||||
</span>
|
||||
<div className='overflow-x-auto'>
|
||||
{(primarySelection || animePrimaryOptions[0].value) ===
|
||||
'番剧' ? (
|
||||
<MultiLevelSelector
|
||||
key={`anime-tv-${primarySelection}`}
|
||||
onChange={handleMultiLevelChange}
|
||||
contentType='anime-tv'
|
||||
/>
|
||||
) : (
|
||||
<MultiLevelSelector
|
||||
key={`anime-movie-${primarySelection}`}
|
||||
onChange={handleMultiLevelChange}
|
||||
contentType='anime-movie'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 综艺类型 - 显示两级选择器 */}
|
||||
{type === 'show' && (
|
||||
<div className='space-y-3 sm:space-y-4'>
|
||||
{/* 一级选择器 */}
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
|
||||
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
|
||||
分类
|
||||
</span>
|
||||
<div className='overflow-x-auto'>
|
||||
{renderCapsuleSelector(
|
||||
showPrimaryOptions,
|
||||
primarySelection || showPrimaryOptions[1].value,
|
||||
onPrimaryChange,
|
||||
true
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 二级选择器 - 只在选中"最近热门"时显示,选中"全部"时显示多级选择器 */}
|
||||
{(primarySelection || showPrimaryOptions[1].value) === '最近热门' ? (
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
|
||||
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
|
||||
类型
|
||||
</span>
|
||||
<div className='overflow-x-auto'>
|
||||
{renderCapsuleSelector(
|
||||
showSecondaryOptions,
|
||||
secondarySelection || showSecondaryOptions[0].value,
|
||||
onSecondaryChange,
|
||||
false
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (primarySelection || showPrimaryOptions[1].value) === '全部' ? (
|
||||
/* 多级选择器 - 只在选中"全部"时显示 */
|
||||
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
|
||||
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
|
||||
筛选
|
||||
</span>
|
||||
<div className='overflow-x-auto'>
|
||||
<MultiLevelSelector
|
||||
key={`${type}-${primarySelection}`}
|
||||
onChange={handleMultiLevelChange}
|
||||
contentType={type}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DoubanSelector;
|
||||
@@ -0,0 +1,293 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
|
||||
import { Clock, Target, Tv } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { formatTimeToHHMM, parseCustomTimeFormat } from '@/lib/time';
|
||||
|
||||
interface EpgProgram {
|
||||
start: string;
|
||||
end: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface EpgScrollableRowProps {
|
||||
programs: EpgProgram[];
|
||||
currentTime?: Date;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export default function EpgScrollableRow({
|
||||
programs,
|
||||
currentTime = new Date(),
|
||||
isLoading = false,
|
||||
}: EpgScrollableRowProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [currentPlayingIndex, setCurrentPlayingIndex] = useState<number>(-1);
|
||||
|
||||
// 处理滚轮事件,实现横向滚动
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (isHovered && containerRef.current) {
|
||||
e.preventDefault(); // 阻止默认的竖向滚动
|
||||
|
||||
const container = containerRef.current;
|
||||
const scrollAmount = e.deltaY * 4; // 增加滚动速度
|
||||
|
||||
// 根据滚轮方向进行横向滚动
|
||||
container.scrollBy({
|
||||
left: scrollAmount,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 阻止页面竖向滚动
|
||||
const preventPageScroll = (e: WheelEvent) => {
|
||||
if (isHovered) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
// 自动滚动到正在播放的节目
|
||||
const scrollToCurrentProgram = () => {
|
||||
if (containerRef.current) {
|
||||
const currentProgramIndex = programs.findIndex(program => isCurrentlyPlaying(program));
|
||||
if (currentProgramIndex !== -1) {
|
||||
const programElement = containerRef.current.children[currentProgramIndex] as HTMLElement;
|
||||
if (programElement) {
|
||||
const container = containerRef.current;
|
||||
const programLeft = programElement.offsetLeft;
|
||||
const containerWidth = container.clientWidth;
|
||||
const programWidth = programElement.offsetWidth;
|
||||
|
||||
// 计算滚动位置,使正在播放的节目居中显示
|
||||
const scrollLeft = programLeft - (containerWidth / 2) + (programWidth / 2);
|
||||
|
||||
container.scrollTo({
|
||||
left: Math.max(0, scrollLeft),
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isHovered) {
|
||||
// 鼠标悬停时阻止页面滚动
|
||||
document.addEventListener('wheel', preventPageScroll, { passive: false });
|
||||
document.addEventListener('wheel', handleWheel, { passive: false });
|
||||
} else {
|
||||
// 鼠标离开时恢复页面滚动
|
||||
document.removeEventListener('wheel', preventPageScroll);
|
||||
document.removeEventListener('wheel', handleWheel);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('wheel', preventPageScroll);
|
||||
document.removeEventListener('wheel', handleWheel);
|
||||
};
|
||||
}, [isHovered]);
|
||||
|
||||
// 组件加载后自动滚动到正在播放的节目
|
||||
useEffect(() => {
|
||||
// 延迟执行,确保DOM完全渲染
|
||||
const timer = setTimeout(() => {
|
||||
// 初始化当前正在播放的节目索引
|
||||
const initialPlayingIndex = programs.findIndex(program => isCurrentlyPlaying(program));
|
||||
setCurrentPlayingIndex(initialPlayingIndex);
|
||||
scrollToCurrentProgram();
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [programs, currentTime]);
|
||||
|
||||
// 定时刷新正在播放状态
|
||||
useEffect(() => {
|
||||
// 每分钟刷新一次正在播放状态
|
||||
const interval = setInterval(() => {
|
||||
// 更新当前正在播放的节目索引
|
||||
const newPlayingIndex = programs.findIndex(program => {
|
||||
try {
|
||||
const start = parseCustomTimeFormat(program.start);
|
||||
const end = parseCustomTimeFormat(program.end);
|
||||
return currentTime >= start && currentTime < end;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (newPlayingIndex !== currentPlayingIndex) {
|
||||
setCurrentPlayingIndex(newPlayingIndex);
|
||||
// 如果正在播放的节目发生变化,自动滚动到新位置
|
||||
scrollToCurrentProgram();
|
||||
}
|
||||
}, 60000); // 60秒 = 1分钟
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [programs, currentTime, currentPlayingIndex]);
|
||||
|
||||
// 格式化时间显示
|
||||
const formatTime = (timeString: string) => {
|
||||
return formatTimeToHHMM(timeString);
|
||||
};
|
||||
|
||||
// 判断节目是否正在播放
|
||||
const isCurrentlyPlaying = (program: EpgProgram) => {
|
||||
try {
|
||||
const start = parseCustomTimeFormat(program.start);
|
||||
const end = parseCustomTimeFormat(program.end);
|
||||
return currentTime >= start && currentTime < end;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 加载中状态
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="pt-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="text-xs sm:text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-2">
|
||||
<Clock className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
今日节目单
|
||||
</h4>
|
||||
<div className="w-16 sm:w-20"></div>
|
||||
</div>
|
||||
<div className="min-h-[100px] sm:min-h-[120px] flex items-center justify-center">
|
||||
<div className="flex items-center gap-3 sm:gap-4 text-gray-500 dark:text-gray-400">
|
||||
<div className="w-5 h-5 sm:w-6 sm:h-6 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin"></div>
|
||||
<span className="text-sm sm:text-base">加载节目单...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 无节目单状态
|
||||
if (!programs || programs.length === 0) {
|
||||
return (
|
||||
<div className="pt-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="text-xs sm:text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-2">
|
||||
<Clock className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
今日节目单
|
||||
</h4>
|
||||
<div className="w-16 sm:w-20"></div>
|
||||
</div>
|
||||
<div className="min-h-[100px] sm:min-h-[120px] flex items-center justify-center">
|
||||
<div className="flex items-center gap-2 sm:gap-3 text-gray-400 dark:text-gray-500">
|
||||
<Tv className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
<span className="text-sm sm:text-base">暂无节目单数据</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-4 mt-2">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="text-xs sm:text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-2">
|
||||
<Clock className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
今日节目单
|
||||
</h4>
|
||||
{currentPlayingIndex !== -1 && (
|
||||
<button
|
||||
onClick={scrollToCurrentProgram}
|
||||
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-2.5 py-1.5 sm:py-2 text-xs font-medium text-gray-600 dark:text-gray-400 hover:text-green-600 dark:hover:text-green-400 bg-gray-300/50 dark:bg-gray-800 hover:bg-green-50 dark:hover:bg-green-900/20 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-green-300 dark:hover:border-green-700 transition-all duration-200"
|
||||
title="滚动到当前播放位置"
|
||||
>
|
||||
<Target className="w-2.5 h-2.5 sm:w-3 sm:h-3" />
|
||||
<span className="hidden sm:inline">当前播放</span>
|
||||
<span className="sm:hidden">当前</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='relative'
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className='flex overflow-x-auto scrollbar-hide py-2 pb-4 px-2 sm:px-4 min-h-[100px] sm:min-h-[120px]'
|
||||
>
|
||||
{programs.map((program, index) => {
|
||||
// 使用 currentPlayingIndex 来判断播放状态,确保样式能正确更新
|
||||
const isPlaying = index === currentPlayingIndex;
|
||||
const isFinishedProgram = index < currentPlayingIndex;
|
||||
const isUpcomingProgram = index > currentPlayingIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex-shrink-0 w-36 sm:w-48 p-2 sm:p-3 rounded-lg border transition-all duration-200 flex flex-col min-h-[100px] sm:min-h-[120px] ${isPlaying
|
||||
? 'bg-green-500/10 dark:bg-green-500/20 border-green-500/30'
|
||||
: isFinishedProgram
|
||||
? 'bg-gray-300/50 dark:bg-gray-800 border-gray-300 dark:border-gray-700'
|
||||
: isUpcomingProgram
|
||||
? 'bg-blue-500/10 dark:bg-blue-500/20 border-blue-500/30'
|
||||
: 'bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{/* 时间显示在顶部 */}
|
||||
<div className="flex items-center justify-between mb-2 sm:mb-3 flex-shrink-0">
|
||||
<span className={`text-xs font-medium ${isPlaying
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: isFinishedProgram
|
||||
? 'text-gray-500 dark:text-gray-400'
|
||||
: isUpcomingProgram
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-300'
|
||||
}`}>
|
||||
{formatTime(program.start)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{formatTime(program.end)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 标题在中间,占据剩余空间 */}
|
||||
<div
|
||||
className={`text-xs sm:text-sm font-medium flex-1 ${isPlaying
|
||||
? 'text-green-900 dark:text-green-100'
|
||||
: isFinishedProgram
|
||||
? 'text-gray-600 dark:text-gray-400'
|
||||
: isUpcomingProgram
|
||||
? 'text-blue-900 dark:text-blue-100'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
style={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
lineHeight: '1.4',
|
||||
maxHeight: '2.8em'
|
||||
}}
|
||||
title={program.title}
|
||||
>
|
||||
{program.title}
|
||||
</div>
|
||||
|
||||
{/* 正在播放状态在底部 */}
|
||||
{isPlaying && (
|
||||
<div className="mt-auto pt-1 sm:pt-2 flex items-center gap-1 sm:gap-1.5 flex-shrink-0">
|
||||
<div className="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-xs text-green-600 dark:text-green-400 font-medium">
|
||||
正在播放
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { SearchResult } from '@/lib/types';
|
||||
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
|
||||
|
||||
// 定义视频信息类型
|
||||
interface VideoInfo {
|
||||
quality: string;
|
||||
loadSpeed: string;
|
||||
pingTime: number;
|
||||
hasError?: boolean; // 添加错误状态标识
|
||||
}
|
||||
|
||||
interface EpisodeSelectorProps {
|
||||
/** 总集数 */
|
||||
totalEpisodes: number;
|
||||
/** 剧集标题 */
|
||||
episodes_titles: string[];
|
||||
/** 每页显示多少集,默认 50 */
|
||||
episodesPerPage?: number;
|
||||
/** 当前选中的集数(1 开始) */
|
||||
value?: number;
|
||||
/** 用户点击选集后的回调 */
|
||||
onChange?: (episodeNumber: number) => void;
|
||||
/** 换源相关 */
|
||||
onSourceChange?: (source: string, id: string, title: string) => void;
|
||||
currentSource?: string;
|
||||
currentId?: string;
|
||||
videoTitle?: string;
|
||||
videoYear?: string;
|
||||
availableSources?: SearchResult[];
|
||||
sourceSearchLoading?: boolean;
|
||||
sourceSearchError?: string | null;
|
||||
/** 预计算的测速结果,避免重复测速 */
|
||||
precomputedVideoInfo?: Map<string, VideoInfo>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 选集组件,支持分页、自动滚动聚焦当前分页标签,以及换源功能。
|
||||
*/
|
||||
const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
totalEpisodes,
|
||||
episodes_titles,
|
||||
episodesPerPage = 50,
|
||||
value = 1,
|
||||
onChange,
|
||||
onSourceChange,
|
||||
currentSource,
|
||||
currentId,
|
||||
videoTitle,
|
||||
availableSources = [],
|
||||
sourceSearchLoading = false,
|
||||
sourceSearchError = null,
|
||||
precomputedVideoInfo,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const pageCount = Math.ceil(totalEpisodes / episodesPerPage);
|
||||
|
||||
// 存储每个源的视频信息
|
||||
const [videoInfoMap, setVideoInfoMap] = useState<Map<string, VideoInfo>>(
|
||||
new Map()
|
||||
);
|
||||
const [attemptedSources, setAttemptedSources] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
|
||||
// 使用 ref 来避免闭包问题
|
||||
const attemptedSourcesRef = useRef<Set<string>>(new Set());
|
||||
const videoInfoMapRef = useRef<Map<string, VideoInfo>>(new Map());
|
||||
|
||||
// 同步状态到 ref
|
||||
useEffect(() => {
|
||||
attemptedSourcesRef.current = attemptedSources;
|
||||
}, [attemptedSources]);
|
||||
|
||||
useEffect(() => {
|
||||
videoInfoMapRef.current = videoInfoMap;
|
||||
}, [videoInfoMap]);
|
||||
|
||||
// 主要的 tab 状态:'episodes' 或 'sources'
|
||||
// 当只有一集时默认展示 "换源",并隐藏 "选集" 标签
|
||||
const [activeTab, setActiveTab] = useState<'episodes' | 'sources'>(
|
||||
totalEpisodes > 1 ? 'episodes' : 'sources'
|
||||
);
|
||||
|
||||
// 当前分页索引(0 开始)
|
||||
const initialPage = Math.floor((value - 1) / episodesPerPage);
|
||||
const [currentPage, setCurrentPage] = useState<number>(initialPage);
|
||||
|
||||
// 是否倒序显示
|
||||
const [descending, setDescending] = useState<boolean>(false);
|
||||
|
||||
// 根据 descending 状态计算实际显示的分页索引
|
||||
const displayPage = useMemo(() => {
|
||||
if (descending) {
|
||||
return pageCount - 1 - currentPage;
|
||||
}
|
||||
return currentPage;
|
||||
}, [currentPage, descending, pageCount]);
|
||||
|
||||
// 获取视频信息的函数 - 移除 attemptedSources 依赖避免不必要的重新创建
|
||||
const getVideoInfo = useCallback(async (source: SearchResult) => {
|
||||
const sourceKey = `${source.source}-${source.id}`;
|
||||
|
||||
// 使用 ref 获取最新的状态,避免闭包问题
|
||||
if (attemptedSourcesRef.current.has(sourceKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取第一集的URL
|
||||
if (!source.episodes || source.episodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
const episodeUrl =
|
||||
source.episodes.length > 1 ? source.episodes[1] : source.episodes[0];
|
||||
|
||||
// 标记为已尝试
|
||||
setAttemptedSources((prev) => new Set(prev).add(sourceKey));
|
||||
|
||||
try {
|
||||
const info = await getVideoResolutionFromM3u8(episodeUrl);
|
||||
setVideoInfoMap((prev) => new Map(prev).set(sourceKey, info));
|
||||
} catch (error) {
|
||||
// 失败时保存错误状态
|
||||
setVideoInfoMap((prev) =>
|
||||
new Map(prev).set(sourceKey, {
|
||||
quality: '错误',
|
||||
loadSpeed: '未知',
|
||||
pingTime: 0,
|
||||
hasError: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 当有预计算结果时,先合并到videoInfoMap中
|
||||
useEffect(() => {
|
||||
if (precomputedVideoInfo && precomputedVideoInfo.size > 0) {
|
||||
// 原子性地更新两个状态,避免时序问题
|
||||
setVideoInfoMap((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
precomputedVideoInfo.forEach((value, key) => {
|
||||
newMap.set(key, value);
|
||||
});
|
||||
return newMap;
|
||||
});
|
||||
|
||||
setAttemptedSources((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
precomputedVideoInfo.forEach((info, key) => {
|
||||
if (!info.hasError) {
|
||||
newSet.add(key);
|
||||
}
|
||||
});
|
||||
return newSet;
|
||||
});
|
||||
|
||||
// 同步更新 ref,确保 getVideoInfo 能立即看到更新
|
||||
precomputedVideoInfo.forEach((info, key) => {
|
||||
if (!info.hasError) {
|
||||
attemptedSourcesRef.current.add(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [precomputedVideoInfo]);
|
||||
|
||||
// 读取本地"优选和测速"开关,默认开启
|
||||
const [optimizationEnabled] = useState<boolean>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('enableOptimization');
|
||||
if (saved !== null) {
|
||||
try {
|
||||
return JSON.parse(saved);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// 当切换到换源tab并且有源数据时,异步获取视频信息 - 移除 attemptedSources 依赖避免循环触发
|
||||
useEffect(() => {
|
||||
const fetchVideoInfosInBatches = async () => {
|
||||
if (
|
||||
!optimizationEnabled || // 若关闭测速则直接退出
|
||||
activeTab !== 'sources' ||
|
||||
availableSources.length === 0
|
||||
)
|
||||
return;
|
||||
|
||||
// 筛选出尚未测速的播放源
|
||||
const pendingSources = availableSources.filter((source) => {
|
||||
const sourceKey = `${source.source}-${source.id}`;
|
||||
return !attemptedSourcesRef.current.has(sourceKey);
|
||||
});
|
||||
|
||||
if (pendingSources.length === 0) return;
|
||||
|
||||
const batchSize = Math.ceil(pendingSources.length / 2);
|
||||
|
||||
for (let start = 0; start < pendingSources.length; start += batchSize) {
|
||||
const batch = pendingSources.slice(start, start + batchSize);
|
||||
await Promise.all(batch.map(getVideoInfo));
|
||||
}
|
||||
};
|
||||
|
||||
fetchVideoInfosInBatches();
|
||||
// 依赖项保持与之前一致
|
||||
}, [activeTab, availableSources, getVideoInfo, optimizationEnabled]);
|
||||
|
||||
// 升序分页标签
|
||||
const categoriesAsc = useMemo(() => {
|
||||
return Array.from({ length: pageCount }, (_, i) => {
|
||||
const start = i * episodesPerPage + 1;
|
||||
const end = Math.min(start + episodesPerPage - 1, totalEpisodes);
|
||||
return { start, end };
|
||||
});
|
||||
}, [pageCount, episodesPerPage, totalEpisodes]);
|
||||
|
||||
// 根据 descending 状态决定分页标签的排序和内容
|
||||
const categories = useMemo(() => {
|
||||
if (descending) {
|
||||
// 倒序时,label 也倒序显示
|
||||
return [...categoriesAsc]
|
||||
.reverse()
|
||||
.map(({ start, end }) => `${end}-${start}`);
|
||||
}
|
||||
return categoriesAsc.map(({ start, end }) => `${start}-${end}`);
|
||||
}, [categoriesAsc, descending]);
|
||||
|
||||
const categoryContainerRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
// 添加鼠标悬停状态管理
|
||||
const [isCategoryHovered, setIsCategoryHovered] = useState(false);
|
||||
|
||||
// 阻止页面竖向滚动
|
||||
const preventPageScroll = useCallback((e: WheelEvent) => {
|
||||
if (isCategoryHovered) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}, [isCategoryHovered]);
|
||||
|
||||
// 处理滚轮事件,实现横向滚动
|
||||
const handleWheel = useCallback((e: WheelEvent) => {
|
||||
if (isCategoryHovered && categoryContainerRef.current) {
|
||||
e.preventDefault(); // 阻止默认的竖向滚动
|
||||
|
||||
const container = categoryContainerRef.current;
|
||||
const scrollAmount = e.deltaY * 2; // 调整滚动速度
|
||||
|
||||
// 根据滚轮方向进行横向滚动
|
||||
container.scrollBy({
|
||||
left: scrollAmount,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}, [isCategoryHovered]);
|
||||
|
||||
// 添加全局wheel事件监听器
|
||||
useEffect(() => {
|
||||
if (isCategoryHovered) {
|
||||
// 鼠标悬停时阻止页面滚动
|
||||
document.addEventListener('wheel', preventPageScroll, { passive: false });
|
||||
document.addEventListener('wheel', handleWheel, { passive: false });
|
||||
} else {
|
||||
// 鼠标离开时恢复页面滚动
|
||||
document.removeEventListener('wheel', preventPageScroll);
|
||||
document.removeEventListener('wheel', handleWheel);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('wheel', preventPageScroll);
|
||||
document.removeEventListener('wheel', handleWheel);
|
||||
};
|
||||
}, [isCategoryHovered, preventPageScroll, handleWheel]);
|
||||
|
||||
// 当分页切换时,将激活的分页标签滚动到视口中间
|
||||
useEffect(() => {
|
||||
const btn = buttonRefs.current[displayPage];
|
||||
const container = categoryContainerRef.current;
|
||||
if (btn && container) {
|
||||
// 手动计算滚动位置,只滚动分页标签容器
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const btnRect = btn.getBoundingClientRect();
|
||||
const scrollLeft = container.scrollLeft;
|
||||
|
||||
// 计算按钮相对于容器的位置
|
||||
const btnLeft = btnRect.left - containerRect.left + scrollLeft;
|
||||
const btnWidth = btnRect.width;
|
||||
const containerWidth = containerRect.width;
|
||||
|
||||
// 计算目标滚动位置,使按钮居中
|
||||
const targetScrollLeft = btnLeft - (containerWidth - btnWidth) / 2;
|
||||
|
||||
// 平滑滚动到目标位置
|
||||
container.scrollTo({
|
||||
left: targetScrollLeft,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}, [displayPage, pageCount]);
|
||||
|
||||
// 处理换源tab点击,只在点击时才搜索
|
||||
const handleSourceTabClick = () => {
|
||||
setActiveTab('sources');
|
||||
};
|
||||
|
||||
const handleCategoryClick = useCallback(
|
||||
(index: number) => {
|
||||
if (descending) {
|
||||
// 在倒序时,需要将显示索引转换为实际索引
|
||||
setCurrentPage(pageCount - 1 - index);
|
||||
} else {
|
||||
setCurrentPage(index);
|
||||
}
|
||||
},
|
||||
[descending, pageCount]
|
||||
);
|
||||
|
||||
const handleEpisodeClick = useCallback(
|
||||
(episodeNumber: number) => {
|
||||
onChange?.(episodeNumber);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const handleSourceClick = useCallback(
|
||||
(source: SearchResult) => {
|
||||
onSourceChange?.(source.source, source.id, source.title);
|
||||
},
|
||||
[onSourceChange]
|
||||
);
|
||||
|
||||
const currentStart = currentPage * episodesPerPage + 1;
|
||||
const currentEnd = Math.min(
|
||||
currentStart + episodesPerPage - 1,
|
||||
totalEpisodes
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='md:ml-2 px-4 py-0 h-full rounded-xl bg-black/10 dark:bg-white/5 flex flex-col border border-white/0 dark:border-white/30 overflow-hidden'>
|
||||
{/* 主要的 Tab 切换 - 无缝融入设计 */}
|
||||
<div className='flex mb-1 -mx-6 flex-shrink-0'>
|
||||
{totalEpisodes > 1 && (
|
||||
<div
|
||||
onClick={() => setActiveTab('episodes')}
|
||||
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium
|
||||
${activeTab === 'episodes'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-gray-700 hover:text-green-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-green-400 hover:bg-black/3 dark:hover:bg-white/3'
|
||||
}
|
||||
`.trim()}
|
||||
>
|
||||
选集
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
onClick={handleSourceTabClick}
|
||||
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium
|
||||
${activeTab === 'sources'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-gray-700 hover:text-green-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-green-400 hover:bg-black/3 dark:hover:bg-white/3'
|
||||
}
|
||||
`.trim()}
|
||||
>
|
||||
换源
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 选集 Tab 内容 */}
|
||||
{activeTab === 'episodes' && (
|
||||
<>
|
||||
{/* 分类标签 */}
|
||||
<div className='flex items-center gap-4 mb-4 border-b border-gray-300 dark:border-gray-700 -mx-6 px-6 flex-shrink-0'>
|
||||
<div
|
||||
className='flex-1 overflow-x-auto'
|
||||
ref={categoryContainerRef}
|
||||
onMouseEnter={() => setIsCategoryHovered(true)}
|
||||
onMouseLeave={() => setIsCategoryHovered(false)}
|
||||
>
|
||||
<div className='flex gap-2 min-w-max'>
|
||||
{categories.map((label, idx) => {
|
||||
const isActive = idx === displayPage;
|
||||
return (
|
||||
<button
|
||||
key={label}
|
||||
ref={(el) => {
|
||||
buttonRefs.current[idx] = el;
|
||||
}}
|
||||
onClick={() => handleCategoryClick(idx)}
|
||||
className={`w-20 relative py-2 text-sm font-medium transition-colors whitespace-nowrap flex-shrink-0 text-center
|
||||
${isActive
|
||||
? 'text-green-500 dark:text-green-400'
|
||||
: 'text-gray-700 hover:text-green-600 dark:text-gray-300 dark:hover:text-green-400'
|
||||
}
|
||||
`.trim()}
|
||||
>
|
||||
{label}
|
||||
{isActive && (
|
||||
<div className='absolute bottom-0 left-0 right-0 h-0.5 bg-green-500 dark:bg-green-400' />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* 向上/向下按钮 */}
|
||||
<button
|
||||
className='flex-shrink-0 w-8 h-8 rounded-md flex items-center justify-center text-gray-700 hover:text-green-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:text-green-400 dark:hover:bg-white/20 transition-colors transform translate-y-[-4px]'
|
||||
onClick={() => {
|
||||
// 切换集数排序(正序/倒序)
|
||||
setDescending((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className='w-4 h-4'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='2'
|
||||
d='M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 集数网格 */}
|
||||
<div className='flex flex-wrap gap-3 overflow-y-auto flex-1 content-start pb-4'>
|
||||
{(() => {
|
||||
const len = currentEnd - currentStart + 1;
|
||||
const episodes = Array.from({ length: len }, (_, i) =>
|
||||
descending ? currentEnd - i : currentStart + i
|
||||
);
|
||||
return episodes;
|
||||
})().map((episodeNumber) => {
|
||||
const isActive = episodeNumber === value;
|
||||
return (
|
||||
<button
|
||||
key={episodeNumber}
|
||||
onClick={() => handleEpisodeClick(episodeNumber - 1)}
|
||||
className={`h-10 min-w-10 px-3 py-2 flex items-center justify-center text-sm font-medium rounded-md transition-all duration-200 whitespace-nowrap font-mono
|
||||
${isActive
|
||||
? 'bg-green-500 text-white shadow-lg shadow-green-500/25 dark:bg-green-600'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 hover:scale-105 dark:bg-white/10 dark:text-gray-300 dark:hover:bg-white/20'
|
||||
}`.trim()}
|
||||
>
|
||||
{(() => {
|
||||
const title = episodes_titles?.[episodeNumber - 1];
|
||||
if (!title) {
|
||||
return episodeNumber;
|
||||
}
|
||||
// 如果匹配"第X集"、"第X话"、"X集"、"X话"格式,提取中间的数字
|
||||
const match = title.match(/(?:第)?(\d+)(?:集|话)/);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
return title;
|
||||
})()}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 换源 Tab 内容 */}
|
||||
{activeTab === 'sources' && (
|
||||
<div className='flex flex-col h-full mt-4'>
|
||||
{sourceSearchLoading && (
|
||||
<div className='flex items-center justify-center py-8'>
|
||||
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-green-500'></div>
|
||||
<span className='ml-2 text-sm text-gray-600 dark:text-gray-300'>
|
||||
搜索中...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sourceSearchError && (
|
||||
<div className='flex items-center justify-center py-8'>
|
||||
<div className='text-center'>
|
||||
<div className='text-red-500 text-2xl mb-2'>⚠️</div>
|
||||
<p className='text-sm text-red-600 dark:text-red-400'>
|
||||
{sourceSearchError}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!sourceSearchLoading &&
|
||||
!sourceSearchError &&
|
||||
availableSources.length === 0 && (
|
||||
<div className='flex items-center justify-center py-8'>
|
||||
<div className='text-center'>
|
||||
<div className='text-gray-400 text-2xl mb-2'>📺</div>
|
||||
<p className='text-sm text-gray-600 dark:text-gray-300'>
|
||||
暂无可用的换源
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!sourceSearchLoading &&
|
||||
!sourceSearchError &&
|
||||
availableSources.length > 0 && (
|
||||
<div className='flex-1 overflow-y-auto space-y-2 pb-20'>
|
||||
{availableSources
|
||||
.sort((a, b) => {
|
||||
const aIsCurrent =
|
||||
a.source?.toString() === currentSource?.toString() &&
|
||||
a.id?.toString() === currentId?.toString();
|
||||
const bIsCurrent =
|
||||
b.source?.toString() === currentSource?.toString() &&
|
||||
b.id?.toString() === currentId?.toString();
|
||||
if (aIsCurrent && !bIsCurrent) return -1;
|
||||
if (!aIsCurrent && bIsCurrent) return 1;
|
||||
return 0;
|
||||
})
|
||||
.map((source, index) => {
|
||||
const isCurrentSource =
|
||||
source.source?.toString() === currentSource?.toString() &&
|
||||
source.id?.toString() === currentId?.toString();
|
||||
return (
|
||||
<div
|
||||
key={`${source.source}-${source.id}`}
|
||||
onClick={() =>
|
||||
!isCurrentSource && handleSourceClick(source)
|
||||
}
|
||||
className={`flex items-start gap-3 px-2 py-3 rounded-lg transition-all select-none duration-200 relative
|
||||
${isCurrentSource
|
||||
? 'bg-green-500/10 dark:bg-green-500/20 border-green-500/30 border'
|
||||
: 'hover:bg-gray-200/50 dark:hover:bg-white/10 hover:scale-[1.02] cursor-pointer'
|
||||
}`.trim()}
|
||||
>
|
||||
{/* 封面 */}
|
||||
<div className='flex-shrink-0 w-12 h-20 bg-gray-300 dark:bg-gray-600 rounded overflow-hidden'>
|
||||
{source.episodes && source.episodes.length > 0 && (
|
||||
<img
|
||||
src={processImageUrl(source.poster)}
|
||||
alt={source.title}
|
||||
className='w-full h-full object-cover'
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息区域 */}
|
||||
<div className='flex-1 min-w-0 flex flex-col justify-between h-20'>
|
||||
{/* 标题和分辨率 - 顶部 */}
|
||||
<div className='flex items-start justify-between gap-3 h-6'>
|
||||
<div className='flex-1 min-w-0 relative group/title'>
|
||||
<h3 className='font-medium text-base truncate text-gray-900 dark:text-gray-100 leading-none'>
|
||||
{source.title}
|
||||
</h3>
|
||||
{/* 标题级别的 tooltip - 第一个元素不显示 */}
|
||||
{index !== 0 && (
|
||||
<div className='absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-gray-800 text-white text-xs rounded-md shadow-lg opacity-0 invisible group-hover/title:opacity-100 group-hover/title:visible transition-all duration-200 ease-out delay-100 whitespace-nowrap z-[500] pointer-events-none'>
|
||||
{source.title}
|
||||
<div className='absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800'></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
const sourceKey = `${source.source}-${source.id}`;
|
||||
const videoInfo = videoInfoMap.get(sourceKey);
|
||||
|
||||
if (videoInfo && videoInfo.quality !== '未知') {
|
||||
if (videoInfo.hasError) {
|
||||
return (
|
||||
<div className='bg-gray-500/10 dark:bg-gray-400/20 text-red-600 dark:text-red-400 px-1.5 py-0 rounded text-xs flex-shrink-0 min-w-[50px] text-center'>
|
||||
检测失败
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// 根据分辨率设置不同颜色:2K、4K为紫色,1080p、720p为绿色,其他为黄色
|
||||
const isUltraHigh = ['4K', '2K'].includes(
|
||||
videoInfo.quality
|
||||
);
|
||||
const isHigh = ['1080p', '720p'].includes(
|
||||
videoInfo.quality
|
||||
);
|
||||
const textColorClasses = isUltraHigh
|
||||
? 'text-purple-600 dark:text-purple-400'
|
||||
: isHigh
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-yellow-600 dark:text-yellow-400';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-gray-500/10 dark:bg-gray-400/20 ${textColorClasses} px-1.5 py-0 rounded text-xs flex-shrink-0 min-w-[50px] text-center`}
|
||||
>
|
||||
{videoInfo.quality}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* 源名称和集数信息 - 垂直居中 */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-xs px-2 py-1 border border-gray-500/60 rounded text-gray-700 dark:text-gray-300'>
|
||||
{source.source_name}
|
||||
</span>
|
||||
{source.episodes.length > 1 && (
|
||||
<span className='text-xs text-gray-500 dark:text-gray-400 font-medium'>
|
||||
{source.episodes.length} 集
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 网络信息 - 底部 */}
|
||||
<div className='flex items-end h-6'>
|
||||
{(() => {
|
||||
const sourceKey = `${source.source}-${source.id}`;
|
||||
const videoInfo = videoInfoMap.get(sourceKey);
|
||||
if (videoInfo) {
|
||||
if (!videoInfo.hasError) {
|
||||
return (
|
||||
<div className='flex items-end gap-3 text-xs'>
|
||||
<div className='text-green-600 dark:text-green-400 font-medium text-xs'>
|
||||
{videoInfo.loadSpeed}
|
||||
</div>
|
||||
<div className='text-orange-600 dark:text-orange-400 font-medium text-xs'>
|
||||
{videoInfo.pingTime}ms
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className='text-red-500/90 dark:text-red-400 font-medium text-xs'>
|
||||
无测速数据
|
||||
</div>
|
||||
); // 占位div
|
||||
}
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className='flex-shrink-0 mt-auto pt-2 border-t border-gray-400 dark:border-gray-700'>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (videoTitle) {
|
||||
router.push(
|
||||
`/search?q=${encodeURIComponent(videoTitle)}`
|
||||
);
|
||||
}
|
||||
}}
|
||||
className='w-full text-center text-xs text-gray-500 dark:text-gray-400 hover:text-green-500 dark:hover:text-green-400 transition-colors py-2'
|
||||
>
|
||||
影片匹配有误?点击去搜索
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EpisodeSelector;
|
||||
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface ErrorInfo {
|
||||
id: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export function GlobalErrorIndicator() {
|
||||
const [currentError, setCurrentError] = useState<ErrorInfo | null>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isReplacing, setIsReplacing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 监听自定义错误事件
|
||||
const handleError = (event: CustomEvent) => {
|
||||
const { message } = event.detail;
|
||||
const newError: ErrorInfo = {
|
||||
id: Date.now().toString(),
|
||||
message,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// 如果已有错误,开始替换动画
|
||||
if (currentError) {
|
||||
setCurrentError(newError);
|
||||
setIsReplacing(true);
|
||||
|
||||
// 动画完成后恢复正常
|
||||
setTimeout(() => {
|
||||
setIsReplacing(false);
|
||||
}, 200);
|
||||
} else {
|
||||
// 第一次显示错误
|
||||
setCurrentError(newError);
|
||||
}
|
||||
|
||||
setIsVisible(true);
|
||||
};
|
||||
|
||||
// 监听错误事件
|
||||
window.addEventListener('globalError', handleError as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('globalError', handleError as EventListener);
|
||||
};
|
||||
}, [currentError]);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsVisible(false);
|
||||
setCurrentError(null);
|
||||
setIsReplacing(false);
|
||||
};
|
||||
|
||||
if (!isVisible || !currentError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='fixed top-4 right-4 z-[2000]'>
|
||||
{/* 错误卡片 */}
|
||||
<div
|
||||
className={`bg-red-500 text-white px-4 py-3 rounded-lg shadow-lg flex items-center justify-between min-w-[300px] max-w-[400px] transition-all duration-300 ${
|
||||
isReplacing ? 'scale-105 bg-red-400' : 'scale-100 bg-red-500'
|
||||
} animate-fade-in`}
|
||||
>
|
||||
<span className='text-sm font-medium flex-1 mr-3'>
|
||||
{currentError.message}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className='text-white hover:text-red-100 transition-colors flex-shrink-0'
|
||||
aria-label='关闭错误提示'
|
||||
>
|
||||
<svg
|
||||
className='w-5 h-5'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M6 18L18 6M6 6l12 12'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 全局错误触发函数
|
||||
export function triggerGlobalError(message: string) {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('globalError', {
|
||||
detail: { message },
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// 图片占位符组件 - 实现骨架屏效果(支持暗色模式)
|
||||
const ImagePlaceholder = ({ aspectRatio }: { aspectRatio: string }) => (
|
||||
<div
|
||||
className={`w-full ${aspectRatio} rounded-lg`}
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(90deg, var(--skeleton-color) 25%, var(--skeleton-highlight) 50%, var(--skeleton-color) 75%)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'shine 1.5s infinite',
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes shine {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
/* 亮色模式变量 */
|
||||
:root {
|
||||
--skeleton-color: #f0f0f0;
|
||||
--skeleton-highlight: #e0e0e0;
|
||||
}
|
||||
|
||||
/* 暗色模式变量 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--skeleton-color: #2d2d2d;
|
||||
--skeleton-highlight: #3d3d3d;
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
--skeleton-color: #2d2d2d;
|
||||
--skeleton-highlight: #3d3d3d;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
export { ImagePlaceholder };
|
||||
@@ -0,0 +1,349 @@
|
||||
import { Radio, X } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
interface ActionItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
onClick: (e?: React.MouseEvent) => void | Promise<void>;
|
||||
color?: 'default' | 'danger' | 'primary';
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface MobileActionSheetProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
actions: ActionItem[];
|
||||
poster?: string;
|
||||
sources?: string[]; // 播放源信息
|
||||
isAggregate?: boolean; // 是否为聚合内容
|
||||
sourceName?: string; // 播放源名称
|
||||
currentEpisode?: number; // 当前集数
|
||||
totalEpisodes?: number; // 总集数
|
||||
origin?: 'vod' | 'live';
|
||||
}
|
||||
|
||||
const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
actions,
|
||||
poster,
|
||||
sources,
|
||||
isAggregate,
|
||||
sourceName,
|
||||
currentEpisode,
|
||||
totalEpisodes,
|
||||
origin = 'vod',
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
// 控制动画状态
|
||||
useEffect(() => {
|
||||
let animationId: number;
|
||||
let timer: NodeJS.Timeout;
|
||||
|
||||
if (isOpen) {
|
||||
setIsVisible(true);
|
||||
// 使用双重 requestAnimationFrame 确保DOM完全渲染
|
||||
animationId = requestAnimationFrame(() => {
|
||||
animationId = requestAnimationFrame(() => {
|
||||
setIsAnimating(true);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
setIsAnimating(false);
|
||||
// 等待动画完成后隐藏组件
|
||||
timer = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (animationId) {
|
||||
cancelAnimationFrame(animationId);
|
||||
}
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// 阻止背景滚动
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
// 保存当前滚动位置
|
||||
const scrollY = window.scrollY;
|
||||
const scrollX = window.scrollX;
|
||||
const body = document.body;
|
||||
const html = document.documentElement;
|
||||
|
||||
// 获取滚动条宽度
|
||||
const scrollBarWidth = window.innerWidth - html.clientWidth;
|
||||
|
||||
// 保存原始样式
|
||||
const originalBodyStyle = {
|
||||
position: body.style.position,
|
||||
top: body.style.top,
|
||||
left: body.style.left,
|
||||
right: body.style.right,
|
||||
width: body.style.width,
|
||||
paddingRight: body.style.paddingRight,
|
||||
overflow: body.style.overflow,
|
||||
};
|
||||
|
||||
// 设置body样式来阻止滚动,但保持原位置
|
||||
body.style.position = 'fixed';
|
||||
body.style.top = `-${scrollY}px`;
|
||||
body.style.left = `-${scrollX}px`;
|
||||
body.style.right = '0';
|
||||
body.style.width = '100%';
|
||||
body.style.overflow = 'hidden';
|
||||
body.style.paddingRight = `${scrollBarWidth}px`;
|
||||
|
||||
return () => {
|
||||
// 恢复所有原始样式
|
||||
body.style.position = originalBodyStyle.position;
|
||||
body.style.top = originalBodyStyle.top;
|
||||
body.style.left = originalBodyStyle.left;
|
||||
body.style.right = originalBodyStyle.right;
|
||||
body.style.width = originalBodyStyle.width;
|
||||
body.style.paddingRight = originalBodyStyle.paddingRight;
|
||||
body.style.overflow = originalBodyStyle.overflow;
|
||||
|
||||
// 使用 requestAnimationFrame 确保样式恢复后再滚动
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo(scrollX, scrollY);
|
||||
});
|
||||
};
|
||||
}
|
||||
}, [isVisible]);
|
||||
|
||||
// ESC键关闭
|
||||
useEffect(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isVisible) {
|
||||
document.addEventListener('keydown', handleEsc);
|
||||
return () => document.removeEventListener('keydown', handleEsc);
|
||||
}
|
||||
}, [isVisible, onClose]);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
const getActionColor = (color: ActionItem['color']) => {
|
||||
switch (color) {
|
||||
case 'danger':
|
||||
return 'text-red-600 dark:text-red-400';
|
||||
case 'primary':
|
||||
return 'text-green-600 dark:text-green-400';
|
||||
default:
|
||||
return 'text-gray-700 dark:text-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
const getActionHoverColor = (color: ActionItem['color']) => {
|
||||
switch (color) {
|
||||
case 'danger':
|
||||
return 'hover:bg-red-50/50 dark:hover:bg-red-900/10';
|
||||
case 'primary':
|
||||
return 'hover:bg-green-50/50 dark:hover:bg-green-900/10';
|
||||
default:
|
||||
return 'hover:bg-gray-50/50 dark:hover:bg-gray-800/20';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-end justify-center"
|
||||
onTouchMove={(e) => {
|
||||
// 阻止最外层容器的触摸移动,防止背景滚动
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
style={{
|
||||
touchAction: 'none', // 禁用所有触摸操作
|
||||
}}
|
||||
>
|
||||
{/* 背景遮罩 */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/50 transition-opacity duration-200 ease-out ${isAnimating ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
onClick={onClose}
|
||||
onTouchMove={(e) => {
|
||||
// 只阻止滚动,允许其他触摸事件(包括点击)
|
||||
e.preventDefault();
|
||||
}}
|
||||
onWheel={(e) => {
|
||||
// 阻止滚轮滚动
|
||||
e.preventDefault();
|
||||
}}
|
||||
style={{
|
||||
backdropFilter: 'blur(4px)',
|
||||
willChange: 'opacity',
|
||||
touchAction: 'none', // 禁用所有触摸操作
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 操作表单 */}
|
||||
<div
|
||||
className="relative w-full max-w-lg mx-4 mb-4 bg-white dark:bg-gray-900 rounded-2xl shadow-2xl transition-all duration-200 ease-out"
|
||||
onTouchMove={(e) => {
|
||||
// 允许操作表单内部滚动,阻止事件冒泡到外层
|
||||
e.stopPropagation();
|
||||
}}
|
||||
style={{
|
||||
marginBottom: 'calc(1rem + env(safe-area-inset-bottom))',
|
||||
willChange: 'transform, opacity',
|
||||
backfaceVisibility: 'hidden', // 避免闪烁
|
||||
transform: isAnimating
|
||||
? 'translateY(0) translateZ(0)'
|
||||
: 'translateY(100%) translateZ(0)', // 组合变换保持滑入效果和硬件加速
|
||||
opacity: isAnimating ? 1 : 0,
|
||||
touchAction: 'auto', // 允许操作表单内的正常触摸操作
|
||||
}}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-800">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
{poster && (
|
||||
<div className="relative w-12 h-16 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 flex-shrink-0">
|
||||
<Image
|
||||
src={poster}
|
||||
alt={title}
|
||||
fill
|
||||
className={origin === 'live' ? 'object-contain' : 'object-cover'}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||
{title}
|
||||
</h3>
|
||||
{sourceName && (
|
||||
<span className="flex-shrink-0 text-xs px-2 py-1 border border-gray-300 dark:border-gray-600 rounded text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800">
|
||||
{origin === 'live' && (
|
||||
<Radio size={12} className="inline-block text-gray-500 dark:text-gray-400 mr-1.5" />
|
||||
)}
|
||||
{sourceName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
选择操作
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150"
|
||||
>
|
||||
<X size={20} className="text-gray-500 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 操作列表 */}
|
||||
<div className="px-4 py-2">
|
||||
{actions.map((action, index) => (
|
||||
<div key={action.id}>
|
||||
<button
|
||||
onClick={() => {
|
||||
action.onClick();
|
||||
onClose();
|
||||
}}
|
||||
disabled={action.disabled}
|
||||
className={`
|
||||
w-full flex items-center gap-4 py-4 px-2 transition-all duration-150 ease-out
|
||||
${action.disabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: `${getActionHoverColor(action.color)} active:scale-[0.98]`
|
||||
}
|
||||
`}
|
||||
style={{ willChange: 'transform, background-color' }}
|
||||
>
|
||||
{/* 图标 - 使用线条风格 */}
|
||||
<div className="w-6 h-6 flex items-center justify-center flex-shrink-0">
|
||||
<span className={`transition-colors duration-150 ${action.disabled
|
||||
? 'text-gray-400 dark:text-gray-600'
|
||||
: getActionColor(action.color)
|
||||
}`}>
|
||||
{action.icon}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 文字 */}
|
||||
<span className={`
|
||||
text-left font-medium text-base flex-1
|
||||
${action.disabled
|
||||
? 'text-gray-400 dark:text-gray-600'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}
|
||||
`}>
|
||||
{action.label}
|
||||
</span>
|
||||
|
||||
{/* 播放进度 - 只在播放按钮且有播放记录时显示 */}
|
||||
{action.id === 'play' && currentEpisode && totalEpisodes && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 font-medium">
|
||||
{currentEpisode}/{totalEpisodes}
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
||||
</button>
|
||||
|
||||
{/* 分割线 - 最后一项不显示 */}
|
||||
{index < actions.length - 1 && (
|
||||
<div className="border-b border-gray-100 dark:border-gray-800 ml-10"></div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 播放源信息展示区域 */}
|
||||
{isAggregate && sources && sources.length > 0 && (
|
||||
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-800">
|
||||
{/* 标题区域 */}
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
|
||||
可用播放源
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
共 {sources.length} 个播放源
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 播放源列表 */}
|
||||
<div className="max-h-32 overflow-y-auto">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{sources.map((source, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-2 py-2 px-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50/30 dark:bg-gray-800/30"
|
||||
>
|
||||
<div className="w-1 h-1 bg-gray-400 dark:bg-gray-500 rounded-full flex-shrink-0" />
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400 truncate">
|
||||
{source}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileActionSheet;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user