Update
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
name: Build & Push Docker image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
actions: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: linux/amd64
|
||||||
|
os: ubuntu-latest
|
||||||
|
- platform: linux/arm64
|
||||||
|
os: ubuntu-24.04-arm
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Prepare platform name
|
||||||
|
run: |
|
||||||
|
echo "PLATFORM_NAME=${{ matrix.platform }}" | sed 's|/|-|g' >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Checkout source code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
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/${{ steps.lowercase.outputs.owner }}/moontv
|
||||||
|
tags: |
|
||||||
|
type=ref,event=pr
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
|
- name: Build and push by digest
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
outputs: type=image,name=ghcr.io/${{ steps.lowercase.outputs.owner }}/moontv,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
|
||||||
|
|
||||||
|
- name: Export digest
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/digests
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digests-${{ env.PLATFORM_NAME }}
|
||||||
|
path: /tmp/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
merge:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- build
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
steps:
|
||||||
|
- name: Download digests
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: /tmp/digests
|
||||||
|
pattern: digests-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
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/${{ steps.lowercase.outputs.owner }}/moontv
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=pr
|
||||||
|
type=sha
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
|
- 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/${{ steps.lowercase.outputs.owner }}/moontv@sha256:%s ' *)
|
||||||
|
|
||||||
|
- name: Inspect image
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools inspect ghcr.io/${{ steps.lowercase.outputs.owner }}/moontv:${{ steps.meta.outputs.version }}
|
||||||
|
|
||||||
|
cleanup:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- merge
|
||||||
|
if: always() && github.event_name != 'pull_request'
|
||||||
|
steps:
|
||||||
|
- name: Delete workflow runs
|
||||||
|
uses: Mattraks/delete-workflow-runs@main
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
repository: ${{ github.repository }}
|
||||||
|
retain_days: 0
|
||||||
|
keep_minimum_runs: 2
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
name: Upstream Sync
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 */6 * * *" # run every 6 hours
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
actions: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync_latest_from_upstream:
|
||||||
|
name: Sync latest commits from upstream repo
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: ${{ github.event.repository.fork }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# Step 1: run a standard checkout action
|
||||||
|
- name: Checkout target repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Step 2: run the sync action
|
||||||
|
- name: Sync upstream changes
|
||||||
|
id: sync
|
||||||
|
uses: aormsby/Fork-Sync-With-Upstream-action@v3.4.1
|
||||||
|
with:
|
||||||
|
upstream_sync_repo: senshinya/MoonTV
|
||||||
|
upstream_sync_branch: main
|
||||||
|
target_sync_branch: main
|
||||||
|
target_repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Sync check
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
echo "[Error] Due to a change in the workflow file of the upstream repository, GitHub has automatically suspended the scheduled automatic update. You need to manually sync your fork."
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Delete workflow runs
|
||||||
|
uses: Mattraks/delete-workflow-runs@main
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
repository: ${{ github.repository }}
|
||||||
|
retain_days: 0
|
||||||
|
keep_minimum_runs: 2
|
||||||
@@ -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,11 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
. "$(dirname "$0")/_/husky.sh"
|
||||||
|
|
||||||
|
# 生成版本号
|
||||||
|
pnpm gen:version
|
||||||
|
|
||||||
|
# 自动添加修改的版本文件
|
||||||
|
git add src/lib/version.ts
|
||||||
|
git add VERSION.txt
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"Region CSS": {
|
||||||
|
"prefix": "regc",
|
||||||
|
"body": [
|
||||||
|
"/* #region /**=========== ${1} =========== */",
|
||||||
|
"$0",
|
||||||
|
"/* #endregion /**======== ${1} =========== */"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
// Tailwind CSS Intellisense
|
||||||
|
"bradlc.vscode-tailwindcss",
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"aaron-bond.better-comments"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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}')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
username TEXT PRIMARY KEY,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS play_records (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
source_name TEXT NOT NULL,
|
||||||
|
cover TEXT NOT NULL,
|
||||||
|
year TEXT NOT NULL,
|
||||||
|
index_episode INTEGER NOT NULL,
|
||||||
|
total_episodes INTEGER NOT NULL,
|
||||||
|
play_time INTEGER NOT NULL,
|
||||||
|
total_time INTEGER NOT NULL,
|
||||||
|
save_time INTEGER NOT NULL,
|
||||||
|
search_title TEXT,
|
||||||
|
UNIQUE(username, key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS favorites (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
source_name TEXT NOT NULL,
|
||||||
|
cover TEXT NOT NULL,
|
||||||
|
year TEXT NOT NULL,
|
||||||
|
total_episodes INTEGER NOT NULL,
|
||||||
|
save_time INTEGER NOT NULL,
|
||||||
|
UNIQUE(username, key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS search_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
keyword TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||||
|
UNIQUE(username, keyword)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_config (
|
||||||
|
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||||
|
config TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 基本索引
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_play_records_username ON play_records(username);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_favorites_username ON favorites(username);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_search_history_username ON search_history(username);
|
||||||
|
|
||||||
|
-- 复合索引优化查询性能
|
||||||
|
-- 播放记录:用户名+键值的复合索引,用于快速查找特定记录
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_play_records_username_key ON play_records(username, key);
|
||||||
|
-- 播放记录:用户名+保存时间的复合索引,用于按时间排序的查询
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_play_records_username_save_time ON play_records(username, save_time DESC);
|
||||||
|
|
||||||
|
-- 收藏:用户名+键值的复合索引,用于快速查找特定收藏
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_favorites_username_key ON favorites(username, key);
|
||||||
|
-- 收藏:用户名+保存时间的复合索引,用于按时间排序的查询
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_favorites_username_save_time ON favorites(username, save_time DESC);
|
||||||
|
|
||||||
|
-- 搜索历史:用户名+关键词的复合索引,用于快速查找/删除特定搜索记录
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_search_history_username_keyword ON search_history(username, keyword);
|
||||||
|
-- 搜索历史:用户名+创建时间的复合索引,用于按时间排序的查询
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_search_history_username_created_at ON search_history(username, created_at DESC);
|
||||||
|
|
||||||
|
-- 搜索历史清理查询的优化索引
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_search_history_username_id_created_at ON search_history(username, id, created_at DESC);
|
||||||
|
```
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# ---- 第 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,
|
||||||
|
# 确保 Next.js 在编译时即选择 Node Runtime 而不是 Edge Runtime
|
||||||
|
RUN find ./src -type f -name "route.ts" -print0 \
|
||||||
|
| xargs -0 sed -i "s/export const runtime = 'edge';/export const runtime = 'nodejs';/g"
|
||||||
|
ENV DOCKER_ENV=true
|
||||||
|
|
||||||
|
# For Docker builds, force dynamic rendering to read runtime environment variables.
|
||||||
|
RUN sed -i "/const inter = Inter({ subsets: \['latin'] });/a export const dynamic = 'force-dynamic';" src/app/layout.tsx
|
||||||
|
|
||||||
|
# 生成生产构建
|
||||||
|
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
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/config.json ./config.json
|
||||||
|
|
||||||
|
# 切换到非特权用户
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# 使用自定义启动脚本,先预加载配置再启动服务器
|
||||||
|
CMD ["node", "start.js"]
|
||||||
@@ -1,201 +1,21 @@
|
|||||||
Apache License
|
MIT License
|
||||||
Version 2.0, January 2004
|
|
||||||
http://www.apache.org/licenses/
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
Copyright (c) 2025 senshinya
|
||||||
|
|
||||||
1. Definitions.
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
"License" shall mean the terms and conditions for use, reproduction,
|
The above copyright notice and this permission notice shall be included in all
|
||||||
and distribution as defined by Sections 1 through 9 of this document.
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
the copyright owner that is granting the License.
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
"Legal Entity" shall mean the union of the acting entity and all
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
other entities that control, are controlled by, or are under common
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
control with that entity. For the purposes of this definition,
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
"control" means (i) the power, direct or indirect, to cause the
|
SOFTWARE.
|
||||||
direction or management of such entity, whether by contract or
|
|
||||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
||||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
||||||
|
|
||||||
"You" (or "Your") shall mean an individual or Legal Entity
|
|
||||||
exercising permissions granted by this License.
|
|
||||||
|
|
||||||
"Source" form shall mean the preferred form for making modifications,
|
|
||||||
including but not limited to software source code, documentation
|
|
||||||
source, and configuration files.
|
|
||||||
|
|
||||||
"Object" form shall mean any form resulting from mechanical
|
|
||||||
transformation or translation of a Source form, including but
|
|
||||||
not limited to compiled object code, generated documentation,
|
|
||||||
and conversions to other media types.
|
|
||||||
|
|
||||||
"Work" shall mean the work of authorship, whether in Source or
|
|
||||||
Object form, made available under the License, as indicated by a
|
|
||||||
copyright notice that is included in or attached to the work
|
|
||||||
(an example is provided in the Appendix below).
|
|
||||||
|
|
||||||
"Derivative Works" shall mean any work, whether in Source or Object
|
|
||||||
form, that is based on (or derived from) the Work and for which the
|
|
||||||
editorial revisions, annotations, elaborations, or other modifications
|
|
||||||
represent, as a whole, an original work of authorship. For the purposes
|
|
||||||
of this License, Derivative Works shall not include works that remain
|
|
||||||
separable from, or merely link (or bind by name) to the interfaces of,
|
|
||||||
the Work and Derivative Works thereof.
|
|
||||||
|
|
||||||
"Contribution" shall mean any work of authorship, including
|
|
||||||
the original version of the Work and any modifications or additions
|
|
||||||
to that Work or Derivative Works thereof, that is intentionally
|
|
||||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
||||||
or by an individual or Legal Entity authorized to submit on behalf of
|
|
||||||
the copyright owner. For the purposes of this definition, "submitted"
|
|
||||||
means any form of electronic, verbal, or written communication sent
|
|
||||||
to the Licensor or its representatives, including but not limited to
|
|
||||||
communication on electronic mailing lists, source code control systems,
|
|
||||||
and issue tracking systems that are managed by, or on behalf of, the
|
|
||||||
Licensor for the purpose of discussing and improving the Work, but
|
|
||||||
excluding communication that is conspicuously marked or otherwise
|
|
||||||
designated in writing by the copyright owner as "Not a Contribution."
|
|
||||||
|
|
||||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
||||||
on behalf of whom a Contribution has been received by Licensor and
|
|
||||||
subsequently incorporated within the Work.
|
|
||||||
|
|
||||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
copyright license to reproduce, prepare Derivative Works of,
|
|
||||||
publicly display, publicly perform, sublicense, and distribute the
|
|
||||||
Work and such Derivative Works in Source or Object form.
|
|
||||||
|
|
||||||
3. Grant of Patent License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
(except as stated in this section) patent license to make, have made,
|
|
||||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
||||||
where such license applies only to those patent claims licensable
|
|
||||||
by such Contributor that are necessarily infringed by their
|
|
||||||
Contribution(s) alone or by combination of their Contribution(s)
|
|
||||||
with the Work to which such Contribution(s) was submitted. If You
|
|
||||||
institute patent litigation against any entity (including a
|
|
||||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
||||||
or a Contribution incorporated within the Work constitutes direct
|
|
||||||
or contributory patent infringement, then any patent licenses
|
|
||||||
granted to You under this License for that Work shall terminate
|
|
||||||
as of the date such litigation is filed.
|
|
||||||
|
|
||||||
4. Redistribution. You may reproduce and distribute copies of the
|
|
||||||
Work or Derivative Works thereof in any medium, with or without
|
|
||||||
modifications, and in Source or Object form, provided that You
|
|
||||||
meet the following conditions:
|
|
||||||
|
|
||||||
(a) You must give any other recipients of the Work or
|
|
||||||
Derivative Works a copy of this License; and
|
|
||||||
|
|
||||||
(b) You must cause any modified files to carry prominent notices
|
|
||||||
stating that You changed the files; and
|
|
||||||
|
|
||||||
(c) You must retain, in the Source form of any Derivative Works
|
|
||||||
that You distribute, all copyright, patent, trademark, and
|
|
||||||
attribution notices from the Source form of the Work,
|
|
||||||
excluding those notices that do not pertain to any part of
|
|
||||||
the Derivative Works; and
|
|
||||||
|
|
||||||
(d) If the Work includes a "NOTICE" text file as part of its
|
|
||||||
distribution, then any Derivative Works that You distribute must
|
|
||||||
include a readable copy of the attribution notices contained
|
|
||||||
within such NOTICE file, excluding those notices that do not
|
|
||||||
pertain to any part of the Derivative Works, in at least one
|
|
||||||
of the following places: within a NOTICE text file distributed
|
|
||||||
as part of the Derivative Works; within the Source form or
|
|
||||||
documentation, if provided along with the Derivative Works; or,
|
|
||||||
within a display generated by the Derivative Works, if and
|
|
||||||
wherever such third-party notices normally appear. The contents
|
|
||||||
of the NOTICE file are for informational purposes only and
|
|
||||||
do not modify the License. You may add Your own attribution
|
|
||||||
notices within Derivative Works that You distribute, alongside
|
|
||||||
or as an addendum to the NOTICE text from the Work, provided
|
|
||||||
that such additional attribution notices cannot be construed
|
|
||||||
as modifying the License.
|
|
||||||
|
|
||||||
You may add Your own copyright statement to Your modifications and
|
|
||||||
may provide additional or different license terms and conditions
|
|
||||||
for use, reproduction, or distribution of Your modifications, or
|
|
||||||
for any such Derivative Works as a whole, provided Your use,
|
|
||||||
reproduction, and distribution of the Work otherwise complies with
|
|
||||||
the conditions stated in this License.
|
|
||||||
|
|
||||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
||||||
any Contribution intentionally submitted for inclusion in the Work
|
|
||||||
by You to the Licensor shall be under the terms and conditions of
|
|
||||||
this License, without any additional terms or conditions.
|
|
||||||
Notwithstanding the above, nothing herein shall supersede or modify
|
|
||||||
the terms of any separate license agreement you may have executed
|
|
||||||
with Licensor regarding such Contributions.
|
|
||||||
|
|
||||||
6. Trademarks. This License does not grant permission to use the trade
|
|
||||||
names, trademarks, service marks, or product names of the Licensor,
|
|
||||||
except as required for reasonable and customary use in describing the
|
|
||||||
origin of the Work and reproducing the content of the NOTICE file.
|
|
||||||
|
|
||||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
||||||
agreed to in writing, Licensor provides the Work (and each
|
|
||||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
||||||
implied, including, without limitation, any warranties or conditions
|
|
||||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
||||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
||||||
appropriateness of using or redistributing the Work and assume any
|
|
||||||
risks associated with Your exercise of permissions under this License.
|
|
||||||
|
|
||||||
8. Limitation of Liability. In no event and under no legal theory,
|
|
||||||
whether in tort (including negligence), contract, or otherwise,
|
|
||||||
unless required by applicable law (such as deliberate and grossly
|
|
||||||
negligent acts) or agreed to in writing, shall any Contributor be
|
|
||||||
liable to You for damages, including any direct, indirect, special,
|
|
||||||
incidental, or consequential damages of any character arising as a
|
|
||||||
result of this License or out of the use or inability to use the
|
|
||||||
Work (including but not limited to damages for loss of goodwill,
|
|
||||||
work stoppage, computer failure or malfunction, or any and all
|
|
||||||
other commercial damages or losses), even if such Contributor
|
|
||||||
has been advised of the possibility of such damages.
|
|
||||||
|
|
||||||
9. Accepting Warranty or Additional Liability. While redistributing
|
|
||||||
the Work or Derivative Works thereof, You may choose to offer,
|
|
||||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
||||||
or other liability obligations and/or rights consistent with this
|
|
||||||
License. However, in accepting such obligations, You may act only
|
|
||||||
on Your own behalf and on Your sole responsibility, not on behalf
|
|
||||||
of any other Contributor, and only if You agree to indemnify,
|
|
||||||
defend, and hold each Contributor harmless for any liability
|
|
||||||
incurred by, or claims asserted against, such Contributor by reason
|
|
||||||
of your accepting any such warranty or additional liability.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
APPENDIX: How to apply the Apache License to your work.
|
|
||||||
|
|
||||||
To apply the Apache License to your work, attach the following
|
|
||||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
||||||
replaced with your own identifying information. (Don't include
|
|
||||||
the brackets!) The text should be enclosed in the appropriate
|
|
||||||
comment syntax for the file format. We also recommend that a
|
|
||||||
file or class name and description of purpose be included on the
|
|
||||||
same "printed page" as the copyright notice for easier
|
|
||||||
identification within third-party archives.
|
|
||||||
|
|
||||||
Copyright [yyyy] [name of copyright owner]
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
# MoonTV
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="public/logo.png" alt="LibreTV Logo" width="120">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
> 🎬 **MoonTV** 是一个开箱即用的、跨平台的影视聚合播放器。它基于 **Next.js 14** + **Tailwind CSS** + **TypeScript** 构建,支持多资源搜索、在线播放、收藏同步、播放记录、本地/云端存储,让你可以随时随地畅享海量免费影视内容。
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ 功能特性
|
||||||
|
|
||||||
|
- 🔍 **多源聚合搜索**:内置数十个免费资源站点,一次搜索立刻返回全源结果。
|
||||||
|
- 📄 **丰富详情页**:支持剧集列表、演员、年份、简介等完整信息展示。
|
||||||
|
- ▶️ **流畅在线播放**:集成 HLS.js & ArtPlayer。
|
||||||
|
- ❤️ **收藏 + 继续观看**:支持 Redis/D1 存储,多端同步进度。
|
||||||
|
- 📱 **PWA**:离线缓存、安装到桌面/主屏,移动端原生体验。
|
||||||
|
- 🌗 **响应式布局**:桌面侧边栏 + 移动底部导航,自适应各种屏幕尺寸。
|
||||||
|
- 🚀 **极简部署**:一条 Docker 命令即可将完整服务跑起来,或免费部署到 Vercel 和 Cloudflare。
|
||||||
|
- 👿 **智能去广告**:自动跳过视频中的切片广告(实验性)
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>点击查看项目截图</summary>
|
||||||
|
<img src="public/screenshot1.png" alt="项目截图" style="max-width:600px">
|
||||||
|
<img src="public/screenshot2.png" alt="项目截图" style="max-width:600px">
|
||||||
|
<img src="public/screenshot3.png" alt="项目截图" style="max-width:600px">
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## 🗺 目录
|
||||||
|
|
||||||
|
- [技术栈](#技术栈)
|
||||||
|
- [部署](#部署)
|
||||||
|
- [Docker Compose 最佳实践](#Docker-Compose-最佳实践)
|
||||||
|
- [环境变量](#环境变量)
|
||||||
|
- [配置说明](#配置说明)
|
||||||
|
- [管理员配置](#管理员配置)
|
||||||
|
- [AndroidTV 使用](#AndroidTV-使用)
|
||||||
|
- [Roadmap](#roadmap)
|
||||||
|
- [安全与隐私提醒](#安全与隐私提醒)
|
||||||
|
- [License](#license)
|
||||||
|
- [致谢](#致谢)
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 分类 | 主要依赖 |
|
||||||
|
| --------- | ----------------------------------------------------------------------------------------------------- |
|
||||||
|
| 前端框架 | [Next.js 14](https://nextjs.org/) · App Router |
|
||||||
|
| UI & 样式 | [Tailwind CSS 3](https://tailwindcss.com/) |
|
||||||
|
| 语言 | TypeScript 4 |
|
||||||
|
| 播放器 | [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) · [HLS.js](https://github.com/video-dev/hls.js/) |
|
||||||
|
| 代码质量 | ESLint · Prettier · Jest |
|
||||||
|
| 部署 | Docker · Vercel · CloudFlare pages |
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
本项目**支持 Vercel、Docker 和 Cloudflare** 部署。
|
||||||
|
|
||||||
|
存储支持矩阵
|
||||||
|
|
||||||
|
| | Docker | Vercel | Cloudflare |
|
||||||
|
| :-----------: | :----: | :----: | :--------: |
|
||||||
|
| localstorage | ✅ | ✅ | ✅ |
|
||||||
|
| 原生 redis | ✅ | | |
|
||||||
|
| Cloudflare D1 | | | ✅ |
|
||||||
|
| Upstash Redis | ☑️ | ✅ | ☑️ |
|
||||||
|
|
||||||
|
✅:经测试支持
|
||||||
|
|
||||||
|
☑️:理论上支持,未测试
|
||||||
|
|
||||||
|
除 localstorage 方式外,其他方式都支持多账户、记录同步和管理页面
|
||||||
|
|
||||||
|
### Vercel 部署
|
||||||
|
|
||||||
|
#### 普通部署(localstorage)
|
||||||
|
|
||||||
|
1. **Fork** 本仓库到你的 GitHub 账户。
|
||||||
|
2. 登陆 [Vercel](https://vercel.com/),点击 **Add New → Project**,选择 Fork 后的仓库。
|
||||||
|
3. 设置 PASSWORD 环境变量。
|
||||||
|
4. 保持默认设置完成首次部署。
|
||||||
|
5. 如需自定义 `config.json`,请直接修改 Fork 后仓库中该文件。
|
||||||
|
6. 每次 Push 到 `main` 分支将自动触发重新构建。
|
||||||
|
|
||||||
|
部署完成后即可通过分配的域名访问,也可以绑定自定义域名。
|
||||||
|
|
||||||
|
#### Upstash Redis 支持
|
||||||
|
|
||||||
|
0. 完成普通部署并成功访问。
|
||||||
|
1. 在 [upstash](https://upstash.com/) 注册账号并新建一个 Redis 实例,名称任意。
|
||||||
|
2. 复制新数据库的 **HTTPS ENDPOINT 和 TOKEN**
|
||||||
|
3. 返回你的 Vercel 项目,新增环境变量 **UPSTASH_URL 和 UPSTASH_TOKEN**,值为第二步复制的 endpoint 和 token
|
||||||
|
4. 设置环境变量 NEXT_PUBLIC_STORAGE_TYPE,值为 **upstash**;设置 USERNAME 和 PASSWORD 作为站长账号
|
||||||
|
5. 重试部署
|
||||||
|
|
||||||
|
### Cloudflare 部署
|
||||||
|
|
||||||
|
**Cloudflare Pages 的环境变量尽量设置为密钥而非文本**
|
||||||
|
|
||||||
|
#### 普通部署(localstorage)
|
||||||
|
|
||||||
|
1. **Fork** 本仓库到你的 GitHub 账户。
|
||||||
|
2. 登陆 [Cloudflare](https://cloudflare.com),点击 **计算(Workers)-> Workers 和 Pages**,点击创建
|
||||||
|
3. 选择 Pages,导入现有的 Git 存储库,选择 Fork 后的仓库
|
||||||
|
4. 构建命令填写 **pnpm install --frozen-lockfile && pnpm run pages:build**,预设框架为无,构建输出目录为 `.vercel/output/static`
|
||||||
|
5. 保持默认设置完成首次部署。进入设置,将兼容性标志设置为 `nodejs_compat`
|
||||||
|
6. 首次部署完成后进入设置,新增 PASSWORD 密钥(变量和机密下),而后重试部署。
|
||||||
|
7. 如需自定义 `config.json`,请直接修改 Fork 后仓库中该文件。
|
||||||
|
8. 每次 Push 到 `main` 分支将自动触发重新构建。
|
||||||
|
|
||||||
|
#### D1 支持
|
||||||
|
|
||||||
|
0. 完成普通部署并成功访问
|
||||||
|
1. 点击 **存储和数据库 -> D1 SQL 数据库**,创建一个新的数据库,名称随意
|
||||||
|
2. 进入刚创建的数据库,点击左上角的 Explore Data,将[D1 初始化](D1初始化.md) 中的内容粘贴到 Query 窗口后点击 **Run All**,等待运行完成
|
||||||
|
3. 返回你的 pages 项目,进入 **设置 -> 绑定**,添加绑定 D1 数据库,选择你刚创建的数据库,变量名称填 **DB**
|
||||||
|
4. 设置环境变量 NEXT_PUBLIC_STORAGE_TYPE,值为 **d1**;设置 USERNAME 和 PASSWORD 作为站长账号
|
||||||
|
5. 重试部署
|
||||||
|
|
||||||
|
### Docker 部署
|
||||||
|
|
||||||
|
#### 1. 直接运行(最简单)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 拉取预构建镜像
|
||||||
|
docker pull ghcr.io/senshinya/moontv:latest
|
||||||
|
|
||||||
|
# 运行容器
|
||||||
|
# -d: 后台运行 -p: 映射端口 3000 -> 3000
|
||||||
|
docker run -d --name moontv -p 3000:3000 --env PASSWORD=your_password ghcr.io/senshinya/moontv:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 `http://服务器 IP:3000` 即可。(需自行到服务器控制台放通 `3000` 端口)
|
||||||
|
|
||||||
|
## Docker Compose 最佳实践
|
||||||
|
|
||||||
|
若你使用 docker compose 部署,以下是一些 compose 示例
|
||||||
|
|
||||||
|
### local storage 版本
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
moontv:
|
||||||
|
image: ghcr.io/senshinya/moontv:latest
|
||||||
|
container_name: moontv
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '3000:3000'
|
||||||
|
environment:
|
||||||
|
- PASSWORD=your_password
|
||||||
|
# 如需自定义配置,可挂载文件
|
||||||
|
# volumes:
|
||||||
|
# - ./config.json:/app/config.json:ro
|
||||||
|
```
|
||||||
|
|
||||||
|
### Redis 版本(推荐,多账户数据隔离,跨设备同步)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
moontv-core:
|
||||||
|
image: ghcr.io/senshinya/moontv:latest
|
||||||
|
container_name: moontv
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '3000:3000'
|
||||||
|
environment:
|
||||||
|
- USERNAME=admin
|
||||||
|
- PASSWORD=admin_password
|
||||||
|
- NEXT_PUBLIC_STORAGE_TYPE=redis
|
||||||
|
- REDIS_URL=redis://moontv-redis:6379
|
||||||
|
- NEXT_PUBLIC_ENABLE_REGISTER=true
|
||||||
|
networks:
|
||||||
|
- moontv-network
|
||||||
|
depends_on:
|
||||||
|
- moontv-redis
|
||||||
|
# 如需自定义配置,可挂载文件
|
||||||
|
# volumes:
|
||||||
|
# - ./config.json:/app/config.json:ro
|
||||||
|
moontv-redis:
|
||||||
|
image: redis
|
||||||
|
container_name: moontv-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- moontv-network
|
||||||
|
# 如需持久化
|
||||||
|
# volumes:
|
||||||
|
# - ./data:/data
|
||||||
|
networks:
|
||||||
|
moontv-network:
|
||||||
|
driver: bridge
|
||||||
|
```
|
||||||
|
|
||||||
|
## 自动同步最近更改
|
||||||
|
|
||||||
|
建议在 fork 的仓库中启用本仓库自带的 GitHub Actions 自动同步功能(见 `.github/workflows/sync.yml`)。
|
||||||
|
|
||||||
|
如需手动同步主仓库更新,也可以使用 GitHub 官方的 [Sync fork](https://docs.github.com/cn/github/collaborating-with-issues-and-pull-requests/syncing-a-fork) 功能。
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
| 变量 | 说明 | 可选值 | 默认值 |
|
||||||
|
| --------------------------- | ----------------------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| USERNAME | redis 部署时的管理员账号 | 任意字符串 | (空) |
|
||||||
|
| PASSWORD | 默认部署时为唯一访问密码,redis 部署时为管理员密码 | 任意字符串 | (空) |
|
||||||
|
| SITE_NAME | 站点名称 | 任意字符串 | MoonTV |
|
||||||
|
| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 |
|
||||||
|
| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | localstorage、redis、d1、upstash | localstorage |
|
||||||
|
| REDIS_URL | redis 连接 url,若 NEXT_PUBLIC_STORAGE_TYPE 为 redis 则必填 | 连接 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_IMAGE_PROXY | 默认的浏览器端图片代理 | url prefix | (空) |
|
||||||
|
| NEXT_PUBLIC_DOUBAN_PROXY | 默认的浏览器端豆瓣数据代理 | url prefix | (空) |
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
所有可自定义项集中在根目录的 `config.json` 中:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cache_time": 7200,
|
||||||
|
"api_site": {
|
||||||
|
"dyttzy": {
|
||||||
|
"api": "http://caiji.dyttzyapi.com/api.php/provide/vod",
|
||||||
|
"name": "电影天堂资源",
|
||||||
|
"detail": "http://caiji.dyttzyapi.com"
|
||||||
|
}
|
||||||
|
// ...更多站点
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `cache_time`:接口缓存时间(秒)。
|
||||||
|
- `api_site`:你可以增删或替换任何资源站,字段说明:
|
||||||
|
- `key`:唯一标识,保持小写字母/数字。
|
||||||
|
- `api`:资源站提供的 `vod` JSON API 根地址。
|
||||||
|
- `name`:在人机界面中展示的名称。
|
||||||
|
- `detail`:(可选)部分无法通过 API 获取剧集详情的站点,需要提供网页详情根 URL,用于爬取。
|
||||||
|
|
||||||
|
MoonTV 支持标准的苹果 CMS V10 API 格式。
|
||||||
|
|
||||||
|
修改后 **无需重新构建**,服务会在启动时读取一次。
|
||||||
|
|
||||||
|
## 管理员配置
|
||||||
|
|
||||||
|
**该特性目前仅支持通过非 localstorage 存储的部署方式使用**
|
||||||
|
|
||||||
|
支持在运行时动态变更服务配置
|
||||||
|
|
||||||
|
设置环境变量 USERNAME 和 PASSWORD 即为站长用户,站长可设置用户为管理员
|
||||||
|
|
||||||
|
站长或管理员访问 `/admin` 即可进行管理员配置
|
||||||
|
|
||||||
|
## AndroidTV 使用
|
||||||
|
|
||||||
|
目前该项目可以配合 [OrionTV](https://github.com/zimplexing/OrionTV) 在 Android TV 上使用,可以直接作为 OrionTV 后端
|
||||||
|
|
||||||
|
暂时收藏夹与播放记录和网页端隔离,后续会支持同步用户数据
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- [x] 深色模式
|
||||||
|
- [x] 持久化存储
|
||||||
|
- [x] 多账户
|
||||||
|
|
||||||
|
## 安全与隐私提醒
|
||||||
|
|
||||||
|
### 强烈建议设置密码保护
|
||||||
|
|
||||||
|
为了您的安全和避免潜在的法律风险,我们**强烈建议**在部署时设置密码保护:
|
||||||
|
|
||||||
|
- **避免公开访问**:不设置密码的实例任何人都可以访问,可能被恶意利用
|
||||||
|
- **防范版权风险**:公开的视频搜索服务可能面临版权方的投诉举报
|
||||||
|
- **保护个人隐私**:设置密码可以限制访问范围,保护您的使用记录
|
||||||
|
|
||||||
|
### 部署建议
|
||||||
|
|
||||||
|
1. **设置环境变量 `PASSWORD`**:为您的实例设置一个强密码
|
||||||
|
2. **仅供个人使用**:请勿将您的实例链接公开分享或传播
|
||||||
|
3. **遵守当地法律**:请确保您的使用行为符合当地法律法规
|
||||||
|
|
||||||
|
### 重要声明
|
||||||
|
|
||||||
|
- 本项目仅供学习和个人使用
|
||||||
|
- 请勿将部署的实例用于商业用途或公开服务
|
||||||
|
- 如因公开分享导致的任何法律问题,用户需自行承担责任
|
||||||
|
- 项目开发者不对用户的使用行为承担任何法律责任
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE) © 2025 MoonTV & Contributors
|
||||||
|
|
||||||
|
## 致谢
|
||||||
|
|
||||||
|
- [ts-nextjs-tailwind-starter](https://github.com/theodorusclarence/ts-nextjs-tailwind-starter) — 项目最初基于该脚手架。
|
||||||
|
- [LibreTV](https://github.com/LibreSpark/LibreTV) — 由此启发,站在巨人的肩膀上。
|
||||||
|
- [ArtPlayer](https://github.com/zhw2590582/ArtPlayer) — 提供强大的网页视频播放器。
|
||||||
|
- [HLS.js](https://github.com/video-dev/hls.js) — 实现 HLS 流媒体在浏览器中的播放支持。
|
||||||
|
- 感谢所有提供免费影视接口的站点。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
20250928125318
|
||||||
@@ -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,89 @@
|
|||||||
|
{
|
||||||
|
"cache_time": 7200,
|
||||||
|
"api_site": {
|
||||||
|
"dyttzy": {
|
||||||
|
"api": "http://caiji.dyttzyapi.com/api.php/provide/vod",
|
||||||
|
"name": "电影天堂资源",
|
||||||
|
"detail": "http://caiji.dyttzyapi.com"
|
||||||
|
},
|
||||||
|
"heimuer": {
|
||||||
|
"api": "https://json.heimuer.xyz/api.php/provide/vod",
|
||||||
|
"name": "黑木耳",
|
||||||
|
"detail": "https://heimuer.tv"
|
||||||
|
},
|
||||||
|
"ruyi": {
|
||||||
|
"api": "https://cj.rycjapi.com/api.php/provide/vod",
|
||||||
|
"name": "如意资源"
|
||||||
|
},
|
||||||
|
"bfzy": {
|
||||||
|
"api": "https://bfzyapi.com/api.php/provide/vod",
|
||||||
|
"name": "暴风资源"
|
||||||
|
},
|
||||||
|
"tyyszy": {
|
||||||
|
"api": "https://tyyszy.com/api.php/provide/vod",
|
||||||
|
"name": "天涯资源"
|
||||||
|
},
|
||||||
|
"ffzy": {
|
||||||
|
"api": "http://ffzy5.tv/api.php/provide/vod",
|
||||||
|
"name": "非凡影视",
|
||||||
|
"detail": "http://ffzy5.tv"
|
||||||
|
},
|
||||||
|
"zy360": {
|
||||||
|
"api": "https://360zy.com/api.php/provide/vod",
|
||||||
|
"name": "360资源"
|
||||||
|
},
|
||||||
|
"maotaizy": {
|
||||||
|
"api": "https://caiji.maotaizy.cc/api.php/provide/vod",
|
||||||
|
"name": "茅台资源"
|
||||||
|
},
|
||||||
|
"wolong": {
|
||||||
|
"api": "https://wolongzyw.com/api.php/provide/vod",
|
||||||
|
"name": "卧龙资源"
|
||||||
|
},
|
||||||
|
"jisu": {
|
||||||
|
"api": "https://jszyapi.com/api.php/provide/vod",
|
||||||
|
"name": "极速资源",
|
||||||
|
"detail": "https://jszyapi.com"
|
||||||
|
},
|
||||||
|
"dbzy": {
|
||||||
|
"api": "https://dbzy.tv/api.php/provide/vod",
|
||||||
|
"name": "豆瓣资源"
|
||||||
|
},
|
||||||
|
"mozhua": {
|
||||||
|
"api": "https://mozhuazy.com/api.php/provide/vod",
|
||||||
|
"name": "魔爪资源"
|
||||||
|
},
|
||||||
|
"mdzy": {
|
||||||
|
"api": "https://www.mdzyapi.com/api.php/provide/vod",
|
||||||
|
"name": "魔都资源"
|
||||||
|
},
|
||||||
|
"zuid": {
|
||||||
|
"api": "https://api.zuidapi.com/api.php/provide/vod",
|
||||||
|
"name": "最大资源"
|
||||||
|
},
|
||||||
|
"yinghua": {
|
||||||
|
"api": "https://m3u8.apiyhzy.com/api.php/provide/vod",
|
||||||
|
"name": "樱花资源"
|
||||||
|
},
|
||||||
|
"wujin": {
|
||||||
|
"api": "https://api.wujinapi.me/api.php/provide/vod",
|
||||||
|
"name": "无尽资源"
|
||||||
|
},
|
||||||
|
"wwzy": {
|
||||||
|
"api": "https://wwzy.tv/api.php/provide/vod",
|
||||||
|
"name": "旺旺短剧"
|
||||||
|
},
|
||||||
|
"ikun": {
|
||||||
|
"api": "https://ikunzyapi.com/api.php/provide/vod",
|
||||||
|
"name": "iKun资源"
|
||||||
|
},
|
||||||
|
"lzi": {
|
||||||
|
"api": "https://cj.lziapi.com/api.php/provide/vod",
|
||||||
|
"name": "量子资源站"
|
||||||
|
},
|
||||||
|
"xiaomaomi": {
|
||||||
|
"api": "https://zy.xmm.hk/api.php/provide/vod",
|
||||||
|
"name": "小猫咪资源"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,74 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||||
|
const nextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
eslint: {
|
||||||
|
dirs: ['src'],
|
||||||
|
},
|
||||||
|
|
||||||
|
reactStrictMode: false,
|
||||||
|
swcMinify: true,
|
||||||
|
|
||||||
|
// 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,92 @@
|
|||||||
|
{
|
||||||
|
"name": "moontv",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "pnpm gen:runtime && pnpm gen:manifest && next dev -H 0.0.0.0",
|
||||||
|
"build": "pnpm gen:runtime && 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:runtime": "node scripts/convert-config.js",
|
||||||
|
"gen:manifest": "node scripts/generate-manifest.js",
|
||||||
|
"gen:version": "node scripts/generate-version.js",
|
||||||
|
"postbuild": "echo 'Build completed - sitemap generation disabled'",
|
||||||
|
"prepare": "husky install",
|
||||||
|
"pages:build": "pnpm gen:runtime && pnpm gen:manifest && next build && npx @cloudflare/next-on-pages --experimental-minify"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@cloudflare/next-on-pages": "^1.13.12",
|
||||||
|
"@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",
|
||||||
|
"@upstash/redis": "^1.25.0",
|
||||||
|
"@vidstack/react": "^1.12.13",
|
||||||
|
"artplayer": "^5.2.3",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"framer-motion": "^12.18.1",
|
||||||
|
"hls.js": "^1.6.6",
|
||||||
|
"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",
|
||||||
|
"sweetalert2": "^11.11.0",
|
||||||
|
"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/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"
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"**/*.{js,jsx,ts,tsx}": [
|
||||||
|
"eslint --max-warnings=0",
|
||||||
|
"prettier -w"
|
||||||
|
],
|
||||||
|
"**/*.{json,css,scss,md,webmanifest}": [
|
||||||
|
"prettier -w"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.12.4+sha512.5ea8b0deed94ed68691c9bad4c955492705c5eeb8a87ef86bc62c74a26b037b08ff9570f108b2e4dbd1dd1a9186fea925e527f141c648e85af45631074680184"
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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>`;
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 296 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,3 @@
|
|||||||
|
# 禁止所有搜索引擎爬取
|
||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
|
After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 6.1 MiB |
|
After Width: | Height: | Size: 7.0 MiB |
|
After Width: | Height: | Size: 5.0 MiB |
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/* eslint-disable */
|
||||||
|
// AUTO-GENERATED SCRIPT: Converts config.json to TypeScript definition.
|
||||||
|
// Usage: node scripts/convert-config.js
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Resolve project root (one level up from scripts folder)
|
||||||
|
const projectRoot = path.resolve(__dirname, '..');
|
||||||
|
|
||||||
|
// Paths
|
||||||
|
const configPath = path.join(projectRoot, 'config.json');
|
||||||
|
const libDir = path.join(projectRoot, 'src', 'lib');
|
||||||
|
const oldRuntimePath = path.join(libDir, 'runtime.ts');
|
||||||
|
const newRuntimePath = path.join(libDir, 'runtime.ts');
|
||||||
|
|
||||||
|
// Delete the old runtime.ts file if it exists
|
||||||
|
if (fs.existsSync(oldRuntimePath)) {
|
||||||
|
fs.unlinkSync(oldRuntimePath);
|
||||||
|
console.log('旧的 runtime.ts 已删除');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and parse config.json
|
||||||
|
let rawConfig;
|
||||||
|
try {
|
||||||
|
rawConfig = fs.readFileSync(configPath, 'utf8');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`无法读取 ${configPath}:`, err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let config;
|
||||||
|
try {
|
||||||
|
config = JSON.parse(rawConfig);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('config.json 不是有效的 JSON:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare TypeScript file content
|
||||||
|
const tsContent =
|
||||||
|
`// 该文件由 scripts/convert-config.js 自动生成,请勿手动修改\n` +
|
||||||
|
`/* eslint-disable */\n\n` +
|
||||||
|
`export const config = ${JSON.stringify(config, null, 2)} as const;\n\n` +
|
||||||
|
`export type RuntimeConfig = typeof config;\n\n` +
|
||||||
|
`export default config;\n`;
|
||||||
|
|
||||||
|
// Ensure lib directory exists
|
||||||
|
if (!fs.existsSync(libDir)) {
|
||||||
|
fs.mkdirSync(libDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to runtime.ts
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(newRuntimePath, tsContent, 'utf8');
|
||||||
|
console.log('已生成 src/lib/runtime.ts');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('写入 runtime.ts 失败:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/* eslint-disable */
|
||||||
|
// 根据 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.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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// 获取当前时间并格式化为 YYYYMMDDHHMMSS 格式
|
||||||
|
function generateVersion() {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(now.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(now.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||||
|
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||||||
|
|
||||||
|
const version = `${year}${month}${day}${hours}${minutes}${seconds}`;
|
||||||
|
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成版本号
|
||||||
|
const currentVersion = generateVersion();
|
||||||
|
|
||||||
|
// 读取现有的 version.ts 文件
|
||||||
|
const versionFilePath = path.join(__dirname, '..', 'src', 'lib', 'version.ts');
|
||||||
|
let fileContent = fs.readFileSync(versionFilePath, 'utf8');
|
||||||
|
|
||||||
|
// 使用正则表达式替换 CURRENT_VERSION 的值
|
||||||
|
const updatedContent = fileContent.replace(
|
||||||
|
/const CURRENT_VERSION = '.*?'/,
|
||||||
|
`const CURRENT_VERSION = '${currentVersion}'`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 写入更新后的内容
|
||||||
|
fs.writeFileSync(versionFilePath, updatedContent, 'utf8');
|
||||||
|
|
||||||
|
// 将版本号写入根目录下的 VERSION.txt 文件
|
||||||
|
const versionTxtPath = path.join(__dirname, '..', 'VERSION.txt');
|
||||||
|
fs.writeFileSync(versionTxtPath, currentVersion, 'utf8');
|
||||||
|
|
||||||
|
console.log(`版本号已更新为: ${currentVersion}`);
|
||||||
|
console.log(`文件已更新: ${versionFilePath}`);
|
||||||
|
console.log(`VERSION.txt 已更新: ${versionTxtPath}`);
|
||||||
@@ -0,0 +1,1417 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
PointerSensor,
|
||||||
|
TouchSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
restrictToParentElement,
|
||||||
|
restrictToVerticalAxis,
|
||||||
|
} from '@dnd-kit/modifiers';
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import { ChevronDown, ChevronUp, Settings, Users, Video } from 'lucide-react';
|
||||||
|
import { GripVertical } from 'lucide-react';
|
||||||
|
import { Suspense, useCallback, useEffect, useState } from 'react';
|
||||||
|
import Swal from 'sweetalert2';
|
||||||
|
|
||||||
|
import { AdminConfig, AdminConfigResult } from '@/lib/admin.types';
|
||||||
|
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
||||||
|
|
||||||
|
import PageLayout from '@/components/PageLayout';
|
||||||
|
|
||||||
|
// 统一弹窗方法(必须在首次使用前定义)
|
||||||
|
const showError = (message: string) =>
|
||||||
|
Swal.fire({ icon: 'error', title: '错误', text: message });
|
||||||
|
|
||||||
|
const showSuccess = (message: string) =>
|
||||||
|
Swal.fire({
|
||||||
|
icon: 'success',
|
||||||
|
title: '成功',
|
||||||
|
text: message,
|
||||||
|
timer: 2000,
|
||||||
|
showConfirmButton: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 新增站点配置类型
|
||||||
|
interface SiteConfig {
|
||||||
|
SiteName: string;
|
||||||
|
Announcement: string;
|
||||||
|
SearchDownstreamMaxPage: number;
|
||||||
|
SiteInterfaceCacheTime: number;
|
||||||
|
ImageProxy: string;
|
||||||
|
DoubanProxy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 视频源数据类型
|
||||||
|
interface DataSource {
|
||||||
|
name: string;
|
||||||
|
key: string;
|
||||||
|
api: string;
|
||||||
|
detail?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
from: 'config' | 'custom';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 可折叠标签组件
|
||||||
|
interface CollapsibleTabProps {
|
||||||
|
title: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CollapsibleTab = ({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
isExpanded,
|
||||||
|
onToggle,
|
||||||
|
children,
|
||||||
|
}: CollapsibleTabProps) => {
|
||||||
|
return (
|
||||||
|
<div className='rounded-xl shadow-sm mb-4 overflow-hidden bg-white/80 backdrop-blur-md dark:bg-gray-800/50 dark:ring-1 dark:ring-gray-700'>
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className='w-full px-6 py-4 flex items-center justify-between bg-gray-50/70 dark:bg-gray-800/60 hover:bg-gray-100/80 dark:hover:bg-gray-700/60 transition-colors'
|
||||||
|
>
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
|
{icon}
|
||||||
|
<h3 className='text-lg font-medium text-gray-900 dark:text-gray-100'>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className='text-gray-500 dark:text-gray-400'>
|
||||||
|
{isExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && <div className='px-6 py-4'>{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 用户配置组件
|
||||||
|
interface UserConfigProps {
|
||||||
|
config: AdminConfig | null;
|
||||||
|
role: 'owner' | 'admin' | null;
|
||||||
|
refreshConfig: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
|
||||||
|
const [userSettings, setUserSettings] = useState({
|
||||||
|
enableRegistration: false,
|
||||||
|
});
|
||||||
|
const [showAddUserForm, setShowAddUserForm] = useState(false);
|
||||||
|
const [showChangePasswordForm, setShowChangePasswordForm] = useState(false);
|
||||||
|
const [newUser, setNewUser] = useState({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
});
|
||||||
|
const [changePasswordUser, setChangePasswordUser] = useState({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 当前登录用户名
|
||||||
|
const currentUsername = getAuthInfoFromBrowserCookie()?.username || null;
|
||||||
|
|
||||||
|
// 检测存储类型是否为 d1
|
||||||
|
const isD1Storage =
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE === 'd1';
|
||||||
|
const isUpstashStorage =
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE === 'upstash';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config?.UserConfig) {
|
||||||
|
setUserSettings({
|
||||||
|
enableRegistration: config.UserConfig.AllowRegister,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
// 切换允许注册设置
|
||||||
|
const toggleAllowRegister = async (value: boolean) => {
|
||||||
|
try {
|
||||||
|
// 先更新本地 UI
|
||||||
|
setUserSettings((prev) => ({ ...prev, enableRegistration: value }));
|
||||||
|
|
||||||
|
const res = await fetch('/api/admin/user', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'setAllowRegister',
|
||||||
|
allowRegister: value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || `操作失败: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshConfig();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err instanceof Error ? err.message : '操作失败');
|
||||||
|
// revert toggle UI
|
||||||
|
setUserSettings((prev) => ({ ...prev, enableRegistration: !value }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBanUser = async (uname: string) => {
|
||||||
|
await handleUserAction('ban', uname);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnbanUser = async (uname: string) => {
|
||||||
|
await handleUserAction('unban', uname);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetAdmin = async (uname: string) => {
|
||||||
|
await handleUserAction('setAdmin', uname);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveAdmin = async (uname: string) => {
|
||||||
|
await handleUserAction('cancelAdmin', uname);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddUser = async () => {
|
||||||
|
if (!newUser.username || !newUser.password) return;
|
||||||
|
await handleUserAction('add', newUser.username, newUser.password);
|
||||||
|
setNewUser({ username: '', password: '' });
|
||||||
|
setShowAddUserForm(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangePassword = async () => {
|
||||||
|
if (!changePasswordUser.username || !changePasswordUser.password) return;
|
||||||
|
await handleUserAction(
|
||||||
|
'changePassword',
|
||||||
|
changePasswordUser.username,
|
||||||
|
changePasswordUser.password
|
||||||
|
);
|
||||||
|
setChangePasswordUser({ username: '', password: '' });
|
||||||
|
setShowChangePasswordForm(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShowChangePasswordForm = (username: string) => {
|
||||||
|
setChangePasswordUser({ username, password: '' });
|
||||||
|
setShowChangePasswordForm(true);
|
||||||
|
setShowAddUserForm(false); // 关闭添加用户表单
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteUser = async (username: string) => {
|
||||||
|
const { isConfirmed } = await Swal.fire({
|
||||||
|
title: '确认删除用户',
|
||||||
|
text: `删除用户 ${username} 将同时删除其搜索历史、播放记录和收藏夹,此操作不可恢复!`,
|
||||||
|
icon: 'warning',
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonText: '确认删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
confirmButtonColor: '#dc2626',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isConfirmed) return;
|
||||||
|
|
||||||
|
await handleUserAction('deleteUser', username);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 通用请求函数
|
||||||
|
const handleUserAction = async (
|
||||||
|
action:
|
||||||
|
| 'add'
|
||||||
|
| 'ban'
|
||||||
|
| 'unban'
|
||||||
|
| 'setAdmin'
|
||||||
|
| 'cancelAdmin'
|
||||||
|
| 'changePassword'
|
||||||
|
| 'deleteUser',
|
||||||
|
targetUsername: string,
|
||||||
|
targetPassword?: string
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/user', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
targetUsername,
|
||||||
|
...(targetPassword ? { targetPassword } : {}),
|
||||||
|
action,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || `操作失败: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 成功后刷新配置(无需整页刷新)
|
||||||
|
await refreshConfig();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err instanceof Error ? err.message : '操作失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return (
|
||||||
|
<div className='text-center text-gray-500 dark:text-gray-400'>
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
{/* 用户统计 */}
|
||||||
|
<div>
|
||||||
|
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300 mb-3'>
|
||||||
|
用户统计
|
||||||
|
</h4>
|
||||||
|
<div className='p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800'>
|
||||||
|
<div className='text-2xl font-bold text-green-800 dark:text-green-300'>
|
||||||
|
{config.UserConfig.Users.length}
|
||||||
|
</div>
|
||||||
|
<div className='text-sm text-green-600 dark:text-green-400'>
|
||||||
|
总用户数
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 注册设置 */}
|
||||||
|
<div>
|
||||||
|
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300 mb-3'>
|
||||||
|
注册设置
|
||||||
|
</h4>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<label
|
||||||
|
className={`text-gray-700 dark:text-gray-300 ${
|
||||||
|
isD1Storage || isUpstashStorage ? 'opacity-50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
允许新用户注册
|
||||||
|
{isD1Storage && (
|
||||||
|
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
(D1 环境下请通过环境变量修改)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isUpstashStorage && (
|
||||||
|
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
(Upstash 环境下请通过环境变量修改)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
!isD1Storage &&
|
||||||
|
!isUpstashStorage &&
|
||||||
|
toggleAllowRegister(!userSettings.enableRegistration)
|
||||||
|
}
|
||||||
|
disabled={isD1Storage || isUpstashStorage}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
|
||||||
|
userSettings.enableRegistration
|
||||||
|
? 'bg-green-600'
|
||||||
|
: 'bg-gray-200 dark:bg-gray-700'
|
||||||
|
} ${
|
||||||
|
isD1Storage || isUpstashStorage
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
userSettings.enableRegistration
|
||||||
|
? 'translate-x-6'
|
||||||
|
: 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 用户列表 */}
|
||||||
|
<div>
|
||||||
|
<div className='flex items-center justify-between mb-3'>
|
||||||
|
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
|
用户列表
|
||||||
|
</h4>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowAddUserForm(!showAddUserForm);
|
||||||
|
if (showChangePasswordForm) {
|
||||||
|
setShowChangePasswordForm(false);
|
||||||
|
setChangePasswordUser({ username: '', password: '' });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className='px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-sm rounded-lg transition-colors'
|
||||||
|
>
|
||||||
|
{showAddUserForm ? '取消' : '添加用户'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 添加用户表单 */}
|
||||||
|
{showAddUserForm && (
|
||||||
|
<div className='mb-4 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700'>
|
||||||
|
<div className='flex flex-col sm:flex-row gap-4 sm:gap-3'>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='用户名'
|
||||||
|
value={newUser.username}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewUser((prev) => ({ ...prev, username: e.target.value }))
|
||||||
|
}
|
||||||
|
className='flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type='password'
|
||||||
|
placeholder='密码'
|
||||||
|
value={newUser.password}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewUser((prev) => ({ ...prev, password: e.target.value }))
|
||||||
|
}
|
||||||
|
className='flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleAddUser}
|
||||||
|
disabled={!newUser.username || !newUser.password}
|
||||||
|
className='w-full sm:w-auto px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white rounded-lg transition-colors'
|
||||||
|
>
|
||||||
|
添加
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 修改密码表单 */}
|
||||||
|
{showChangePasswordForm && (
|
||||||
|
<div className='mb-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-700'>
|
||||||
|
<h5 className='text-sm font-medium text-blue-800 dark:text-blue-300 mb-3'>
|
||||||
|
修改用户密码
|
||||||
|
</h5>
|
||||||
|
<div className='flex flex-col sm:flex-row gap-4 sm:gap-3'>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='用户名'
|
||||||
|
value={changePasswordUser.username}
|
||||||
|
disabled
|
||||||
|
className='flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-gray-100 cursor-not-allowed'
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type='password'
|
||||||
|
placeholder='新密码'
|
||||||
|
value={changePasswordUser.password}
|
||||||
|
onChange={(e) =>
|
||||||
|
setChangePasswordUser((prev) => ({
|
||||||
|
...prev,
|
||||||
|
password: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className='flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent'
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleChangePassword}
|
||||||
|
disabled={!changePasswordUser.password}
|
||||||
|
className='w-full sm:w-auto px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white rounded-lg transition-colors'
|
||||||
|
>
|
||||||
|
修改密码
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowChangePasswordForm(false);
|
||||||
|
setChangePasswordUser({ username: '', password: '' });
|
||||||
|
}}
|
||||||
|
className='w-full sm:w-auto px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors'
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 用户列表 */}
|
||||||
|
<div className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[28rem] overflow-y-auto overflow-x-auto'>
|
||||||
|
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
|
||||||
|
<thead className='bg-gray-50 dark:bg-gray-900'>
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
scope='col'
|
||||||
|
className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
|
||||||
|
>
|
||||||
|
用户名
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope='col'
|
||||||
|
className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
|
||||||
|
>
|
||||||
|
角色
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope='col'
|
||||||
|
className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
|
||||||
|
>
|
||||||
|
状态
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope='col'
|
||||||
|
className='px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
|
||||||
|
>
|
||||||
|
操作
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
{/* 按规则排序用户:自己 -> 站长(若非自己) -> 管理员 -> 其他 */}
|
||||||
|
{(() => {
|
||||||
|
const sortedUsers = [...config.UserConfig.Users].sort((a, b) => {
|
||||||
|
type UserInfo = (typeof config.UserConfig.Users)[number];
|
||||||
|
const priority = (u: UserInfo) => {
|
||||||
|
if (u.username === currentUsername) return 0;
|
||||||
|
if (u.role === 'owner') return 1;
|
||||||
|
if (u.role === 'admin') return 2;
|
||||||
|
return 3;
|
||||||
|
};
|
||||||
|
return priority(a) - priority(b);
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<tbody className='divide-y divide-gray-200 dark:divide-gray-700'>
|
||||||
|
{sortedUsers.map((user) => {
|
||||||
|
// 修改密码权限:站长可修改管理员和普通用户密码,管理员可修改普通用户和自己的密码,但任何人都不能修改站长密码
|
||||||
|
const canChangePassword =
|
||||||
|
user.role !== 'owner' && // 不能修改站长密码
|
||||||
|
(role === 'owner' || // 站长可以修改管理员和普通用户密码
|
||||||
|
(role === 'admin' &&
|
||||||
|
(user.role === 'user' ||
|
||||||
|
user.username === currentUsername))); // 管理员可以修改普通用户和自己的密码
|
||||||
|
|
||||||
|
// 删除用户权限:站长可删除除自己外的所有用户,管理员仅可删除普通用户
|
||||||
|
const canDeleteUser =
|
||||||
|
user.username !== currentUsername &&
|
||||||
|
(role === 'owner' || // 站长可以删除除自己外的所有用户
|
||||||
|
(role === 'admin' && user.role === 'user')); // 管理员仅可删除普通用户
|
||||||
|
|
||||||
|
// 其他操作权限:不能操作自己,站长可操作所有用户,管理员可操作普通用户
|
||||||
|
const canOperate =
|
||||||
|
user.username !== currentUsername &&
|
||||||
|
(role === 'owner' ||
|
||||||
|
(role === 'admin' && user.role === 'user'));
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={user.username}
|
||||||
|
className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors'
|
||||||
|
>
|
||||||
|
<td className='px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100'>
|
||||||
|
{user.username}
|
||||||
|
</td>
|
||||||
|
<td className='px-6 py-4 whitespace-nowrap'>
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 text-xs rounded-full ${
|
||||||
|
user.role === 'owner'
|
||||||
|
? 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300'
|
||||||
|
: user.role === 'admin'
|
||||||
|
? 'bg-purple-100 dark:bg-purple-900/20 text-purple-800 dark:text-purple-300'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{user.role === 'owner'
|
||||||
|
? '站长'
|
||||||
|
: user.role === 'admin'
|
||||||
|
? '管理员'
|
||||||
|
: '普通用户'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className='px-6 py-4 whitespace-nowrap'>
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 text-xs rounded-full ${
|
||||||
|
!user.banned
|
||||||
|
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
|
||||||
|
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{!user.banned ? '正常' : '已封禁'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
|
||||||
|
{/* 修改密码按钮 */}
|
||||||
|
{canChangePassword && (
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
handleShowChangePasswordForm(user.username)
|
||||||
|
}
|
||||||
|
className='inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 hover:bg-blue-200 dark:bg-blue-900/40 dark:hover:bg-blue-900/60 dark:text-blue-200 transition-colors'
|
||||||
|
>
|
||||||
|
修改密码
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canOperate && (
|
||||||
|
<>
|
||||||
|
{/* 其他操作按钮 */}
|
||||||
|
{user.role === 'user' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleSetAdmin(user.username)}
|
||||||
|
className='inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 hover:bg-purple-200 dark:bg-purple-900/40 dark:hover:bg-purple-900/60 dark:text-purple-200 transition-colors'
|
||||||
|
>
|
||||||
|
设为管理
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{user.role === 'admin' && (
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
handleRemoveAdmin(user.username)
|
||||||
|
}
|
||||||
|
className='inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-700/40 dark:hover:bg-gray-700/60 dark:text-gray-200 transition-colors'
|
||||||
|
>
|
||||||
|
取消管理
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{user.role !== 'owner' &&
|
||||||
|
(!user.banned ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleBanUser(user.username)}
|
||||||
|
className='inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-red-100 text-red-800 hover:bg-red-200 dark:bg-red-900/40 dark:hover:bg-red-900/60 dark:text-red-300 transition-colors'
|
||||||
|
>
|
||||||
|
封禁
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
handleUnbanUser(user.username)
|
||||||
|
}
|
||||||
|
className='inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-green-100 text-green-800 hover:bg-green-200 dark:bg-green-900/40 dark:hover:bg-green-900/60 dark:text-green-300 transition-colors'
|
||||||
|
>
|
||||||
|
解封
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* 删除用户按钮 - 放在最后,使用更明显的红色样式 */}
|
||||||
|
{canDeleteUser && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteUser(user.username)}
|
||||||
|
className='inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-red-600 text-white hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 transition-colors'
|
||||||
|
>
|
||||||
|
删除用户
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 视频源配置组件
|
||||||
|
const VideoSourceConfig = ({
|
||||||
|
config,
|
||||||
|
refreshConfig,
|
||||||
|
}: {
|
||||||
|
config: AdminConfig | null;
|
||||||
|
refreshConfig: () => Promise<void>;
|
||||||
|
}) => {
|
||||||
|
const [sources, setSources] = useState<DataSource[]>([]);
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
|
const [orderChanged, setOrderChanged] = useState(false);
|
||||||
|
const [newSource, setNewSource] = useState<DataSource>({
|
||||||
|
name: '',
|
||||||
|
key: '',
|
||||||
|
api: '',
|
||||||
|
detail: '',
|
||||||
|
disabled: false,
|
||||||
|
from: 'config',
|
||||||
|
});
|
||||||
|
|
||||||
|
// dnd-kit 传感器
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 5, // 轻微位移即可触发
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(TouchSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
delay: 150, // 长按 150ms 后触发,避免与滚动冲突
|
||||||
|
tolerance: 5,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
useEffect(() => {
|
||||||
|
if (config?.SourceConfig) {
|
||||||
|
setSources(config.SourceConfig);
|
||||||
|
// 进入时重置 orderChanged
|
||||||
|
setOrderChanged(false);
|
||||||
|
}
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
// 通用 API 请求
|
||||||
|
const callSourceApi = async (body: Record<string, any>) => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/admin/source', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ...body }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || `操作失败: ${resp.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 成功后刷新配置
|
||||||
|
await refreshConfig();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err instanceof Error ? err.message : '操作失败');
|
||||||
|
throw err; // 向上抛出方便调用处判断
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleEnable = (key: string) => {
|
||||||
|
const target = sources.find((s) => s.key === key);
|
||||||
|
if (!target) return;
|
||||||
|
const action = target.disabled ? 'enable' : 'disable';
|
||||||
|
callSourceApi({ action, key }).catch(() => {
|
||||||
|
console.error('操作失败', action, key);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (key: string) => {
|
||||||
|
callSourceApi({ action: 'delete', key }).catch(() => {
|
||||||
|
console.error('操作失败', 'delete', key);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddSource = () => {
|
||||||
|
if (!newSource.name || !newSource.key || !newSource.api) return;
|
||||||
|
callSourceApi({
|
||||||
|
action: 'add',
|
||||||
|
key: newSource.key,
|
||||||
|
name: newSource.name,
|
||||||
|
api: newSource.api,
|
||||||
|
detail: newSource.detail,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
setNewSource({
|
||||||
|
name: '',
|
||||||
|
key: '',
|
||||||
|
api: '',
|
||||||
|
detail: '',
|
||||||
|
disabled: false,
|
||||||
|
from: 'custom',
|
||||||
|
});
|
||||||
|
setShowAddForm(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
console.error('操作失败', 'add', newSource);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = (event: any) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
const oldIndex = sources.findIndex((s) => s.key === active.id);
|
||||||
|
const newIndex = sources.findIndex((s) => s.key === over.id);
|
||||||
|
setSources((prev) => arrayMove(prev, oldIndex, newIndex));
|
||||||
|
setOrderChanged(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveOrder = () => {
|
||||||
|
const order = sources.map((s) => s.key);
|
||||||
|
callSourceApi({ action: 'sort', order })
|
||||||
|
.then(() => {
|
||||||
|
setOrderChanged(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
console.error('操作失败', 'sort', order);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 可拖拽行封装 (dnd-kit)
|
||||||
|
const DraggableRow = ({ source }: { source: DataSource }) => {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||||
|
useSortable({ id: source.key });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
} as React.CSSProperties;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className='hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors select-none'
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
className='px-2 py-4 cursor-grab text-gray-400'
|
||||||
|
style={{ touchAction: 'none' }}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<GripVertical size={16} />
|
||||||
|
</td>
|
||||||
|
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
|
||||||
|
{source.name}
|
||||||
|
</td>
|
||||||
|
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100'>
|
||||||
|
{source.key}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 max-w-[12rem] truncate'
|
||||||
|
title={source.api}
|
||||||
|
>
|
||||||
|
{source.api}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className='px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 max-w-[8rem] truncate'
|
||||||
|
title={source.detail || '-'}
|
||||||
|
>
|
||||||
|
{source.detail || '-'}
|
||||||
|
</td>
|
||||||
|
<td className='px-6 py-4 whitespace-nowrap max-w-[1rem]'>
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 text-xs rounded-full ${
|
||||||
|
!source.disabled
|
||||||
|
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
|
||||||
|
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{!source.disabled ? '启用中' : '已禁用'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleEnable(source.key)}
|
||||||
|
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${
|
||||||
|
!source.disabled
|
||||||
|
? 'bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-900/60'
|
||||||
|
: 'bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-900/60'
|
||||||
|
} transition-colors`}
|
||||||
|
>
|
||||||
|
{!source.disabled ? '禁用' : '启用'}
|
||||||
|
</button>
|
||||||
|
{source.from !== 'config' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(source.key)}
|
||||||
|
className='inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 hover:bg-gray-200 dark:bg-gray-700/40 dark:hover:bg-gray-700/60 dark:text-gray-200 transition-colors'
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return (
|
||||||
|
<div className='text-center text-gray-500 dark:text-gray-400'>
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
{/* 添加视频源表单 */}
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
|
视频源列表
|
||||||
|
</h4>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddForm(!showAddForm)}
|
||||||
|
className='px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-sm rounded-lg transition-colors'
|
||||||
|
>
|
||||||
|
{showAddForm ? '取消' : '添加视频源'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAddForm && (
|
||||||
|
<div className='p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 space-y-4'>
|
||||||
|
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='名称'
|
||||||
|
value={newSource.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewSource((prev) => ({ ...prev, name: e.target.value }))
|
||||||
|
}
|
||||||
|
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='Key'
|
||||||
|
value={newSource.key}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewSource((prev) => ({ ...prev, key: e.target.value }))
|
||||||
|
}
|
||||||
|
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='API 地址'
|
||||||
|
value={newSource.api}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewSource((prev) => ({ ...prev, api: e.target.value }))
|
||||||
|
}
|
||||||
|
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='Detail 地址(选填)'
|
||||||
|
value={newSource.detail}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewSource((prev) => ({ ...prev, detail: e.target.value }))
|
||||||
|
}
|
||||||
|
className='px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='flex justify-end'>
|
||||||
|
<button
|
||||||
|
onClick={handleAddSource}
|
||||||
|
disabled={!newSource.name || !newSource.key || !newSource.api}
|
||||||
|
className='w-full sm:w-auto px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white rounded-lg transition-colors'
|
||||||
|
>
|
||||||
|
添加
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 视频源表格 */}
|
||||||
|
<div className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[28rem] overflow-y-auto overflow-x-auto'>
|
||||||
|
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
|
||||||
|
<thead className='bg-gray-50 dark:bg-gray-900'>
|
||||||
|
<tr>
|
||||||
|
<th className='w-8' />
|
||||||
|
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
||||||
|
名称
|
||||||
|
</th>
|
||||||
|
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
||||||
|
Key
|
||||||
|
</th>
|
||||||
|
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
||||||
|
API 地址
|
||||||
|
</th>
|
||||||
|
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
||||||
|
Detail 地址
|
||||||
|
</th>
|
||||||
|
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
||||||
|
状态
|
||||||
|
</th>
|
||||||
|
<th className='px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
||||||
|
操作
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
autoScroll={false}
|
||||||
|
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={sources.map((s) => s.key)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<tbody className='divide-y divide-gray-200 dark:divide-gray-700'>
|
||||||
|
{sources.map((source) => (
|
||||||
|
<DraggableRow key={source.key} source={source} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 保存排序按钮 */}
|
||||||
|
{orderChanged && (
|
||||||
|
<div className='flex justify-end'>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveOrder}
|
||||||
|
className='px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors'
|
||||||
|
>
|
||||||
|
保存排序
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 新增站点配置组件
|
||||||
|
const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
|
||||||
|
const [siteSettings, setSiteSettings] = useState<SiteConfig>({
|
||||||
|
SiteName: '',
|
||||||
|
Announcement: '',
|
||||||
|
SearchDownstreamMaxPage: 1,
|
||||||
|
SiteInterfaceCacheTime: 7200,
|
||||||
|
ImageProxy: '',
|
||||||
|
DoubanProxy: '',
|
||||||
|
});
|
||||||
|
// 保存状态
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// 检测存储类型是否为 d1 或 upstash
|
||||||
|
const isD1Storage =
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE === 'd1';
|
||||||
|
const isUpstashStorage =
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE === 'upstash';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config?.SiteConfig) {
|
||||||
|
setSiteSettings({
|
||||||
|
...config.SiteConfig,
|
||||||
|
ImageProxy: config.SiteConfig.ImageProxy || '',
|
||||||
|
DoubanProxy: config.SiteConfig.DoubanProxy || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
// 保存站点配置
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
const resp = await fetch('/api/admin/site', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ...siteSettings }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || `保存失败: ${resp.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
showSuccess('保存成功, 请刷新页面');
|
||||||
|
} catch (err) {
|
||||||
|
showError(err instanceof Error ? err.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return (
|
||||||
|
<div className='text-center text-gray-500 dark:text-gray-400'>
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
{/* 站点名称 */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
|
||||||
|
isD1Storage || isUpstashStorage ? 'opacity-50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
站点名称
|
||||||
|
{isD1Storage && (
|
||||||
|
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
(D1 环境下请通过环境变量修改)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isUpstashStorage && (
|
||||||
|
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
(Upstash 环境下请通过环境变量修改)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
value={siteSettings.SiteName}
|
||||||
|
onChange={(e) =>
|
||||||
|
!isD1Storage &&
|
||||||
|
!isUpstashStorage &&
|
||||||
|
setSiteSettings((prev) => ({ ...prev, SiteName: e.target.value }))
|
||||||
|
}
|
||||||
|
disabled={isD1Storage || isUpstashStorage}
|
||||||
|
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent ${
|
||||||
|
isD1Storage || isUpstashStorage
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 站点公告 */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
|
||||||
|
isD1Storage || isUpstashStorage ? 'opacity-50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
站点公告
|
||||||
|
{isD1Storage && (
|
||||||
|
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
(D1 环境下请通过环境变量修改)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isUpstashStorage && (
|
||||||
|
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
(Upstash 环境下请通过环境变量修改)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={siteSettings.Announcement}
|
||||||
|
onChange={(e) =>
|
||||||
|
!isD1Storage &&
|
||||||
|
!isUpstashStorage &&
|
||||||
|
setSiteSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
Announcement: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={isD1Storage || isUpstashStorage}
|
||||||
|
rows={3}
|
||||||
|
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent ${
|
||||||
|
isD1Storage || isUpstashStorage
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 搜索接口可拉取最大页数 */}
|
||||||
|
<div>
|
||||||
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
|
搜索接口可拉取最大页数
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='number'
|
||||||
|
min={1}
|
||||||
|
value={siteSettings.SearchDownstreamMaxPage}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSiteSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
SearchDownstreamMaxPage: Number(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 站点接口缓存时间 */}
|
||||||
|
<div>
|
||||||
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
|
站点接口缓存时间(秒)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='number'
|
||||||
|
min={1}
|
||||||
|
value={siteSettings.SiteInterfaceCacheTime}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSiteSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
SiteInterfaceCacheTime: Number(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 图片代理 */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
|
||||||
|
isD1Storage || isUpstashStorage ? 'opacity-50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
图片代理前缀
|
||||||
|
{isD1Storage && (
|
||||||
|
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
(D1 环境下请通过环境变量修改)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isUpstashStorage && (
|
||||||
|
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
(Upstash 环境下请通过环境变量修改)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='例如: https://imageproxy.example.com/?url='
|
||||||
|
value={siteSettings.ImageProxy}
|
||||||
|
onChange={(e) =>
|
||||||
|
!isD1Storage &&
|
||||||
|
!isUpstashStorage &&
|
||||||
|
setSiteSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
ImageProxy: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={isD1Storage || isUpstashStorage}
|
||||||
|
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent ${
|
||||||
|
isD1Storage || isUpstashStorage
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
用于代理图片访问,解决跨域或访问限制问题。留空则不使用代理。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 豆瓣代理设置 */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className={`block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 ${
|
||||||
|
isD1Storage || isUpstashStorage ? 'opacity-50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
豆瓣代理地址
|
||||||
|
{isD1Storage && (
|
||||||
|
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
(D1 环境下请通过环境变量修改)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isUpstashStorage && (
|
||||||
|
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
(Upstash 环境下请通过环境变量修改)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='例如: https://proxy.example.com/fetch?url='
|
||||||
|
value={siteSettings.DoubanProxy}
|
||||||
|
onChange={(e) =>
|
||||||
|
!isD1Storage &&
|
||||||
|
!isUpstashStorage &&
|
||||||
|
setSiteSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
DoubanProxy: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={isD1Storage || isUpstashStorage}
|
||||||
|
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent ${
|
||||||
|
isD1Storage || isUpstashStorage
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
用于代理豆瓣数据访问,解决跨域或访问限制问题。留空则使用服务端API。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className='flex justify-end'>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || isD1Storage || isUpstashStorage}
|
||||||
|
className={`px-4 py-2 ${
|
||||||
|
saving || isD1Storage || isUpstashStorage
|
||||||
|
? 'bg-gray-400 cursor-not-allowed'
|
||||||
|
: 'bg-green-600 hover:bg-green-700'
|
||||||
|
} text-white rounded-lg transition-colors`}
|
||||||
|
>
|
||||||
|
{saving ? '保存中…' : '保存'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function AdminPageClient() {
|
||||||
|
const [config, setConfig] = useState<AdminConfig | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [role, setRole] = useState<'owner' | 'admin' | null>(null);
|
||||||
|
const [expandedTabs, setExpandedTabs] = useState<{ [key: string]: boolean }>({
|
||||||
|
userConfig: false,
|
||||||
|
videoSource: false,
|
||||||
|
siteConfig: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取管理员配置
|
||||||
|
// showLoading 用于控制是否在请求期间显示整体加载骨架。
|
||||||
|
const fetchConfig = useCallback(async (showLoading = false) => {
|
||||||
|
try {
|
||||||
|
if (showLoading) {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/admin/config`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = (await response.json()) as any;
|
||||||
|
throw new Error(`获取配置失败: ${data.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as AdminConfigResult;
|
||||||
|
setConfig(data.Config);
|
||||||
|
setRole(data.Role);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : '获取配置失败';
|
||||||
|
showError(msg);
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
if (showLoading) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 首次加载时显示骨架
|
||||||
|
fetchConfig(true);
|
||||||
|
}, [fetchConfig]);
|
||||||
|
|
||||||
|
// 切换标签展开状态
|
||||||
|
const toggleTab = (tabKey: string) => {
|
||||||
|
setExpandedTabs((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[tabKey]: !prev[tabKey],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 新增: 重置配置处理函数
|
||||||
|
const handleResetConfig = async () => {
|
||||||
|
const { isConfirmed } = await Swal.fire({
|
||||||
|
title: '确认重置配置',
|
||||||
|
text: '此操作将重置用户封禁和管理员设置、自定义视频源,站点配置将重置为默认值,是否继续?',
|
||||||
|
icon: 'warning',
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonText: '确认',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
});
|
||||||
|
if (!isConfirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/admin/reset`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`重置失败: ${response.status}`);
|
||||||
|
}
|
||||||
|
showSuccess('重置成功,请刷新页面!');
|
||||||
|
} catch (err) {
|
||||||
|
showError(err instanceof Error ? err.message : '重置失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<PageLayout activePath='/admin'>
|
||||||
|
<div className='px-2 sm:px-10 py-4 sm:py-8'>
|
||||||
|
<div className='max-w-[95%] mx-auto'>
|
||||||
|
<h1 className='text-2xl font-bold text-gray-900 dark:text-gray-100 mb-8'>
|
||||||
|
管理员设置
|
||||||
|
</h1>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className='h-20 bg-gray-200 dark:bg-gray-700 rounded-lg animate-pulse'
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
// 错误已通过 SweetAlert2 展示,此处直接返回空
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout activePath='/admin'>
|
||||||
|
<div className='px-2 sm:px-10 py-4 sm:py-8'>
|
||||||
|
<div className='max-w-[95%] mx-auto'>
|
||||||
|
{/* 标题 + 重置配置按钮 */}
|
||||||
|
<div className='flex items-center gap-2 mb-8'>
|
||||||
|
<h1 className='text-2xl font-bold text-gray-900 dark:text-gray-100'>
|
||||||
|
管理员设置
|
||||||
|
</h1>
|
||||||
|
{config && role === 'owner' && (
|
||||||
|
<button
|
||||||
|
onClick={handleResetConfig}
|
||||||
|
className='px-3 py-1 bg-red-600 hover:bg-red-700 text-white text-xs rounded-md transition-colors'
|
||||||
|
>
|
||||||
|
重置配置
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 站点配置标签 */}
|
||||||
|
<CollapsibleTab
|
||||||
|
title='站点配置'
|
||||||
|
icon={
|
||||||
|
<Settings
|
||||||
|
size={20}
|
||||||
|
className='text-gray-600 dark:text-gray-400'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
isExpanded={expandedTabs.siteConfig}
|
||||||
|
onToggle={() => toggleTab('siteConfig')}
|
||||||
|
>
|
||||||
|
<SiteConfigComponent config={config} />
|
||||||
|
</CollapsibleTab>
|
||||||
|
|
||||||
|
<div className='space-y-4'>
|
||||||
|
{/* 用户配置标签 */}
|
||||||
|
<CollapsibleTab
|
||||||
|
title='用户配置'
|
||||||
|
icon={
|
||||||
|
<Users size={20} className='text-gray-600 dark:text-gray-400' />
|
||||||
|
}
|
||||||
|
isExpanded={expandedTabs.userConfig}
|
||||||
|
onToggle={() => toggleTab('userConfig')}
|
||||||
|
>
|
||||||
|
<UserConfig
|
||||||
|
config={config}
|
||||||
|
role={role}
|
||||||
|
refreshConfig={fetchConfig}
|
||||||
|
/>
|
||||||
|
</CollapsibleTab>
|
||||||
|
|
||||||
|
{/* 视频源配置标签 */}
|
||||||
|
<CollapsibleTab
|
||||||
|
title='视频源配置'
|
||||||
|
icon={
|
||||||
|
<Video size={20} className='text-gray-600 dark:text-gray-400' />
|
||||||
|
}
|
||||||
|
isExpanded={expandedTabs.videoSource}
|
||||||
|
onToggle={() => toggleTab('videoSource')}
|
||||||
|
>
|
||||||
|
<VideoSourceConfig config={config} refreshConfig={fetchConfig} />
|
||||||
|
</CollapsibleTab>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<AdminPageClient />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 = 'edge';
|
||||||
|
|
||||||
|
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') {
|
||||||
|
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,51 @@
|
|||||||
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { resetConfig } from '@/lib/config';
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
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,106 @@
|
|||||||
|
/* 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 { getStorage } from '@/lib/db';
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
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,
|
||||||
|
ImageProxy,
|
||||||
|
DoubanProxy,
|
||||||
|
} = body as {
|
||||||
|
SiteName: string;
|
||||||
|
Announcement: string;
|
||||||
|
SearchDownstreamMaxPage: number;
|
||||||
|
SiteInterfaceCacheTime: number;
|
||||||
|
ImageProxy: string;
|
||||||
|
DoubanProxy: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 参数校验
|
||||||
|
if (
|
||||||
|
typeof SiteName !== 'string' ||
|
||||||
|
typeof Announcement !== 'string' ||
|
||||||
|
typeof SearchDownstreamMaxPage !== 'number' ||
|
||||||
|
typeof SiteInterfaceCacheTime !== 'number' ||
|
||||||
|
typeof ImageProxy !== 'string' ||
|
||||||
|
typeof DoubanProxy !== 'string'
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminConfig = await getConfig();
|
||||||
|
const storage = getStorage();
|
||||||
|
|
||||||
|
// 权限校验
|
||||||
|
if (username !== process.env.USERNAME) {
|
||||||
|
// 管理员
|
||||||
|
const user = adminConfig.UserConfig.Users.find(
|
||||||
|
(u) => u.username === username
|
||||||
|
);
|
||||||
|
if (!user || user.role !== 'admin') {
|
||||||
|
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新缓存中的站点设置
|
||||||
|
adminConfig.SiteConfig = {
|
||||||
|
SiteName,
|
||||||
|
Announcement,
|
||||||
|
SearchDownstreamMaxPage,
|
||||||
|
SiteInterfaceCacheTime,
|
||||||
|
ImageProxy,
|
||||||
|
DoubanProxy,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 写入数据库
|
||||||
|
if (storage && typeof (storage as any).setAdminConfig === 'function') {
|
||||||
|
await (storage as any).setAdminConfig(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,169 @@
|
|||||||
|
/* 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 { getStorage } from '@/lib/db';
|
||||||
|
import { IStorage } from '@/lib/types';
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
// 支持的操作类型
|
||||||
|
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();
|
||||||
|
const storage: IStorage | null = getStorage();
|
||||||
|
|
||||||
|
// 权限与身份校验
|
||||||
|
if (username !== process.env.USERNAME) {
|
||||||
|
const userEntry = adminConfig.UserConfig.Users.find(
|
||||||
|
(u) => u.username === username
|
||||||
|
);
|
||||||
|
if (!userEntry || userEntry.role !== 'admin') {
|
||||||
|
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);
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 持久化到存储
|
||||||
|
if (storage && typeof (storage as any).setAdminConfig === 'function') {
|
||||||
|
await (storage as any).setAdminConfig(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,337 @@
|
|||||||
|
/* 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 { getStorage } from '@/lib/db';
|
||||||
|
import { IStorage } from '@/lib/types';
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
// 支持的操作类型
|
||||||
|
const ACTIONS = [
|
||||||
|
'add',
|
||||||
|
'ban',
|
||||||
|
'unban',
|
||||||
|
'setAdmin',
|
||||||
|
'cancelAdmin',
|
||||||
|
'setAllowRegister',
|
||||||
|
'changePassword',
|
||||||
|
'deleteUser',
|
||||||
|
] 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, // 目标用户密码(仅在添加用户时需要)
|
||||||
|
allowRegister,
|
||||||
|
action,
|
||||||
|
} = body as {
|
||||||
|
targetUsername?: string;
|
||||||
|
targetPassword?: string;
|
||||||
|
allowRegister?: boolean;
|
||||||
|
action?: (typeof ACTIONS)[number];
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!action || !ACTIONS.includes(action)) {
|
||||||
|
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action !== 'setAllowRegister' && !targetUsername) {
|
||||||
|
return NextResponse.json({ error: '缺少目标用户名' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
action !== 'setAllowRegister' &&
|
||||||
|
action !== 'changePassword' &&
|
||||||
|
action !== 'deleteUser' &&
|
||||||
|
username === targetUsername
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '无法对自己进行此操作' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取配置与存储
|
||||||
|
const adminConfig = await getConfig();
|
||||||
|
const storage: IStorage | null = getStorage();
|
||||||
|
|
||||||
|
// 判定操作者角色
|
||||||
|
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') {
|
||||||
|
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||||
|
}
|
||||||
|
operatorRole = 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找目标用户条目
|
||||||
|
let targetEntry = adminConfig.UserConfig.Users.find(
|
||||||
|
(u) => u.username === targetUsername
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
targetEntry &&
|
||||||
|
targetEntry.role === 'owner' &&
|
||||||
|
action !== 'changePassword'
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ error: '无法操作站长' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 权限校验逻辑
|
||||||
|
const isTargetAdmin = targetEntry?.role === 'admin';
|
||||||
|
|
||||||
|
if (action === 'setAllowRegister') {
|
||||||
|
if (typeof allowRegister !== 'boolean') {
|
||||||
|
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||||
|
}
|
||||||
|
adminConfig.UserConfig.AllowRegister = allowRegister;
|
||||||
|
// 保存后直接返回成功(走后面的统一保存逻辑)
|
||||||
|
} else {
|
||||||
|
switch (action) {
|
||||||
|
case 'add': {
|
||||||
|
if (targetEntry) {
|
||||||
|
return NextResponse.json({ error: '用户已存在' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!targetPassword) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '缺少目标用户密码' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!storage || typeof storage.registerUser !== 'function') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '存储未配置用户注册' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await storage.registerUser(targetUsername!, targetPassword);
|
||||||
|
// 更新配置
|
||||||
|
adminConfig.UserConfig.Users.push({
|
||||||
|
username: targetUsername!,
|
||||||
|
role: 'user',
|
||||||
|
});
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!storage || typeof storage.changePassword !== 'function') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '存储未配置密码修改功能' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!storage || typeof storage.deleteUser !== 'function') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '存储未配置用户删除功能' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.deleteUser(targetUsername!);
|
||||||
|
|
||||||
|
// 从配置中移除用户
|
||||||
|
const userIndex = adminConfig.UserConfig.Users.findIndex(
|
||||||
|
(u) => u.username === targetUsername
|
||||||
|
);
|
||||||
|
if (userIndex > -1) {
|
||||||
|
adminConfig.UserConfig.Users.splice(userIndex, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将更新后的配置写入数据库
|
||||||
|
if (storage && typeof (storage as any).setAdminConfig === 'function') {
|
||||||
|
await (storage as any).setAdminConfig(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,72 @@
|
|||||||
|
/* eslint-disable no-console*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { getStorage } from '@/lib/db';
|
||||||
|
import { IStorage } from '@/lib/types';
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取存储实例
|
||||||
|
const storage: IStorage | null = getStorage();
|
||||||
|
if (!storage || typeof storage.changePassword !== 'function') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '存储服务不支持修改密码' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改密码
|
||||||
|
await storage.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,189 @@
|
|||||||
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { fetchVideoDetail } from '@/lib/fetchVideoDetail';
|
||||||
|
import { SearchResult } from '@/lib/types';
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
console.log(request.url);
|
||||||
|
try {
|
||||||
|
console.log('Cron job triggered:', new Date().toISOString());
|
||||||
|
|
||||||
|
refreshRecordAndFavorites();
|
||||||
|
|
||||||
|
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 refreshRecordAndFavorites() {
|
||||||
|
if (
|
||||||
|
(process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage') === 'localstorage'
|
||||||
|
) {
|
||||||
|
console.log('跳过刷新:当前使用 localstorage 存储模式');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
const favorites = await db.getAllFavorites(user);
|
||||||
|
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,45 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAvailableApiSites, getCacheTime } from '@/lib/config';
|
||||||
|
import { getDetailFromApi } from '@/lib/downstream';
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
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();
|
||||||
|
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}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getCacheTime } from '@/lib/config';
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDoubanData(
|
||||||
|
url: string
|
||||||
|
): Promise<DoubanCategoryApiResponse> {
|
||||||
|
// 添加超时控制
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
|
||||||
|
|
||||||
|
// 设置请求选项,包括信号和头部
|
||||||
|
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: 'application/json, text/plain, */*',
|
||||||
|
Origin: 'https://movie.douban.com',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 尝试直接访问豆瓣API
|
||||||
|
const response = await fetch(url, fetchOptions);
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
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(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}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '获取豆瓣数据失败', details: (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getCacheTime } from '@/lib/config';
|
||||||
|
import { DoubanItem, DoubanResult } from '@/lib/types';
|
||||||
|
|
||||||
|
interface DoubanApiResponse {
|
||||||
|
subjects: Array<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
cover: string;
|
||||||
|
rate: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDoubanData(url: string): Promise<DoubanApiResponse> {
|
||||||
|
// 添加超时控制
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
|
||||||
|
|
||||||
|
// 设置请求选项,包括信号和头部
|
||||||
|
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: 'application/json, text/plain, */*',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 尝试直接访问豆瓣API
|
||||||
|
const response = await fetch(url, fetchOptions);
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
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(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}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} 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}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: '获取豆瓣 Top250 数据失败',
|
||||||
|
details: (error as Error).message,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { Favorite } from '@/lib/types';
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 { 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 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 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,61 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
|
||||||
|
// 直接返回图片流
|
||||||
|
return new Response(imageResponse.body, {
|
||||||
|
status: 200,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Error fetching image' },
|
||||||
|
{ 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 = 'edge';
|
||||||
|
|
||||||
|
// 读取存储类型环境变量,默认 localstorage
|
||||||
|
const STORAGE_TYPE =
|
||||||
|
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
|
||||||
|
| 'localstorage'
|
||||||
|
| 'redis'
|
||||||
|
| 'd1'
|
||||||
|
| 'upstash'
|
||||||
|
| 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 = 'edge';
|
||||||
|
|
||||||
|
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,125 @@
|
|||||||
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { PlayRecord } from '@/lib/types';
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// 从 cookie 获取用户信息
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo || !authInfo.username) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { 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 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 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,130 @@
|
|||||||
|
/* 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 = 'edge';
|
||||||
|
|
||||||
|
// 读取存储类型环境变量,默认 localstorage
|
||||||
|
const STORAGE_TYPE =
|
||||||
|
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
|
||||||
|
| 'localstorage'
|
||||||
|
| 'redis'
|
||||||
|
| 'd1'
|
||||||
|
| 'upstash'
|
||||||
|
| 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): Promise<string> {
|
||||||
|
const authData: any = {
|
||||||
|
role: 'user',
|
||||||
|
username,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用process.env.PASSWORD作为签名密钥,而不是用户密码
|
||||||
|
const signingKey = process.env.PASSWORD || '';
|
||||||
|
const signature = await generateSignature(username, signingKey);
|
||||||
|
authData.signature = signature;
|
||||||
|
|
||||||
|
return encodeURIComponent(JSON.stringify(authData));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
// localstorage 模式下不支持注册
|
||||||
|
if (STORAGE_TYPE === 'localstorage') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '当前模式不支持注册' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
// 校验是否开放注册
|
||||||
|
if (!config.UserConfig.AllowRegister) {
|
||||||
|
return NextResponse.json({ error: '当前未开放注册' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return NextResponse.json({ error: '用户已存在' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 检查用户是否已存在
|
||||||
|
const exist = await db.checkUserExist(username);
|
||||||
|
if (exist) {
|
||||||
|
return NextResponse.json({ error: '用户已存在' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.registerUser(username, password);
|
||||||
|
|
||||||
|
// 添加到配置中并保存
|
||||||
|
config.UserConfig.Users.push({
|
||||||
|
username,
|
||||||
|
role: 'user',
|
||||||
|
});
|
||||||
|
await db.saveAdminConfig(config);
|
||||||
|
|
||||||
|
// 注册成功,设置认证cookie
|
||||||
|
const response = NextResponse.json({ ok: true });
|
||||||
|
const cookieValue = await generateAuthCookie(username);
|
||||||
|
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,76 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAvailableApiSites, getCacheTime } from '@/lib/config';
|
||||||
|
import { searchFromApi } from '@/lib/downstream';
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
// OrionTV 兼容接口
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
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}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiSites = await getAvailableApiSites();
|
||||||
|
|
||||||
|
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);
|
||||||
|
const result = results.filter((r) => r.title === query);
|
||||||
|
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}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: '搜索失败',
|
||||||
|
result: null,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAvailableApiSites, getCacheTime } from '@/lib/config';
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
// OrionTV 兼容接口
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const apiSites = await getAvailableApiSites();
|
||||||
|
const cacheTime = await getCacheTime();
|
||||||
|
|
||||||
|
return NextResponse.json(apiSites, {
|
||||||
|
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}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: '获取资源失败' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAvailableApiSites, getCacheTime } from '@/lib/config';
|
||||||
|
import { searchFromApi } from '@/lib/downstream';
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
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}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiSites = await getAvailableApiSites();
|
||||||
|
const searchPromises = apiSites.map((site) => searchFromApi(site, query));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await Promise.all(searchPromises);
|
||||||
|
const flattenedResults = results.flat();
|
||||||
|
const cacheTime = await getCacheTime();
|
||||||
|
|
||||||
|
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}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: '搜索失败' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
// 最大保存条数(与客户端保持一致)
|
||||||
|
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 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 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 { 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,18 @@
|
|||||||
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
|
||||||
|
export const runtime = 'edge';
|
||||||
|
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
return NextResponse.json(result);
|
||||||
|
}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
/* eslint-disable no-console,react-hooks/exhaustive-deps */
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { getDoubanCategories } from '@/lib/douban.client';
|
||||||
|
import { DoubanItem } from '@/lib/types';
|
||||||
|
|
||||||
|
import DoubanCardSkeleton from '@/components/DoubanCardSkeleton';
|
||||||
|
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);
|
||||||
|
|
||||||
|
const type = searchParams.get('type') || 'movie';
|
||||||
|
|
||||||
|
// 选择器状态 - 完全独立,不依赖URL参数
|
||||||
|
const [primarySelection, setPrimarySelection] = useState<string>(() => {
|
||||||
|
return type === 'movie' ? '热门' : '';
|
||||||
|
});
|
||||||
|
const [secondarySelection, setSecondarySelection] = useState<string>(() => {
|
||||||
|
if (type === 'movie') return '全部';
|
||||||
|
if (type === 'tv') return 'tv';
|
||||||
|
if (type === 'show') return 'show';
|
||||||
|
return '全部';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 初始化时标记选择器为准备好状态
|
||||||
|
useEffect(() => {
|
||||||
|
// 短暂延迟确保初始状态设置完成
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setSelectorsReady(true);
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []); // 只在组件挂载时执行一次
|
||||||
|
|
||||||
|
// type变化时立即重置selectorsReady(最高优先级)
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectorsReady(false);
|
||||||
|
setLoading(true); // 立即显示loading状态
|
||||||
|
}, [type]);
|
||||||
|
|
||||||
|
// 当type变化时重置选择器状态
|
||||||
|
useEffect(() => {
|
||||||
|
// 批量更新选择器状态
|
||||||
|
if (type === 'movie') {
|
||||||
|
setPrimarySelection('热门');
|
||||||
|
setSecondarySelection('全部');
|
||||||
|
} else if (type === 'tv') {
|
||||||
|
setPrimarySelection('');
|
||||||
|
setSecondarySelection('tv');
|
||||||
|
} else if (type === 'show') {
|
||||||
|
setPrimarySelection('');
|
||||||
|
setSecondarySelection('show');
|
||||||
|
} else {
|
||||||
|
setPrimarySelection('');
|
||||||
|
setSecondarySelection('全部');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用短暂延迟确保状态更新完成后标记选择器准备好
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setSelectorsReady(true);
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [type]);
|
||||||
|
|
||||||
|
// 生成骨架屏数据
|
||||||
|
const skeletonData = Array.from({ length: 25 }, (_, index) => index);
|
||||||
|
|
||||||
|
// 生成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 () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await getDoubanCategories(getRequestParams(0));
|
||||||
|
|
||||||
|
if (data.code === 200) {
|
||||||
|
setDoubanData(data.list);
|
||||||
|
setHasMore(data.list.length === 25);
|
||||||
|
setLoading(false);
|
||||||
|
} else {
|
||||||
|
throw new Error(data.message || '获取数据失败');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}, [type, primarySelection, secondarySelection, getRequestParams]);
|
||||||
|
|
||||||
|
// 只在选择器准备好后才加载数据
|
||||||
|
useEffect(() => {
|
||||||
|
// 只有在选择器准备好时才开始加载
|
||||||
|
if (!selectorsReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置页面状态
|
||||||
|
setDoubanData([]);
|
||||||
|
setCurrentPage(0);
|
||||||
|
setHasMore(true);
|
||||||
|
setIsLoadingMore(false);
|
||||||
|
|
||||||
|
// 清除之前的防抖定时器
|
||||||
|
if (debounceTimeoutRef.current) {
|
||||||
|
clearTimeout(debounceTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用防抖机制加载数据,避免连续状态更新触发多次请求
|
||||||
|
debounceTimeoutRef.current = setTimeout(() => {
|
||||||
|
loadInitialData();
|
||||||
|
}, 100); // 100ms 防抖延迟
|
||||||
|
|
||||||
|
// 清理函数
|
||||||
|
return () => {
|
||||||
|
if (debounceTimeoutRef.current) {
|
||||||
|
clearTimeout(debounceTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
selectorsReady,
|
||||||
|
type,
|
||||||
|
primarySelection,
|
||||||
|
secondarySelection,
|
||||||
|
loadInitialData,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 单独处理 currentPage 变化(加载更多)
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentPage > 0) {
|
||||||
|
const fetchMoreData = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoadingMore(true);
|
||||||
|
|
||||||
|
const data = await getDoubanCategories(
|
||||||
|
getRequestParams(currentPage * 25)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.code === 200) {
|
||||||
|
setDoubanData((prev) => [...prev, ...data.list]);
|
||||||
|
setHasMore(data.list.length === 25);
|
||||||
|
} else {
|
||||||
|
throw new Error(data.message || '获取数据失败');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingMore(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchMoreData();
|
||||||
|
}
|
||||||
|
}, [currentPage, type, primarySelection, secondarySelection]);
|
||||||
|
|
||||||
|
// 设置滚动监听
|
||||||
|
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);
|
||||||
|
setPrimarySelection(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[primarySelection]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSecondaryChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
// 只有当值真正改变时才设置loading状态
|
||||||
|
if (value !== secondarySelection) {
|
||||||
|
setLoading(true);
|
||||||
|
setSecondarySelection(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[secondarySelection]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getPageTitle = () => {
|
||||||
|
// 根据 type 生成标题
|
||||||
|
return type === 'movie' ? '电影' : type === 'tv' ? '电视剧' : '综艺';
|
||||||
|
};
|
||||||
|
|
||||||
|
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'>
|
||||||
|
来自豆瓣的精选内容
|
||||||
|
</p>
|
||||||
|
</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'>
|
||||||
|
<DoubanSelector
|
||||||
|
type={type as 'movie' | 'tv' | 'show'}
|
||||||
|
primarySelection={primarySelection}
|
||||||
|
secondarySelection={secondarySelection}
|
||||||
|
onPrimaryChange={handlePrimaryChange}
|
||||||
|
onSecondaryChange={handleSecondaryChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内容展示区域 */}
|
||||||
|
<div className='max-w-[95%] mx-auto mt-8 overflow-visible'>
|
||||||
|
{/* 内容网格 */}
|
||||||
|
<div className='grid grid-cols-3 gap-x-2 gap-y-12 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fit,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={item.id}
|
||||||
|
rate={item.rate}
|
||||||
|
year={item.year}
|
||||||
|
type={type === 'movie' ? 'movie' : ''} // 电影类型严格控制,tv 不控
|
||||||
|
/>
|
||||||
|
</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,915 @@
|
|||||||
|
/* 加载骨架屏淡紫色主题 */
|
||||||
|
.animate-pulse {
|
||||||
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: .5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 页面进入动画 */
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in-up {
|
||||||
|
animation: fadeInUp 0.6s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* KatelyaTV Logo 彩虹渐变动画 */
|
||||||
|
.katelya-logo {
|
||||||
|
background: linear-gradient(
|
||||||
|
45deg,
|
||||||
|
#ff6b6b,
|
||||||
|
#4ecdc4,
|
||||||
|
#45b7d1,
|
||||||
|
#96ceb4,
|
||||||
|
#ffc645,
|
||||||
|
#fd79a8,
|
||||||
|
#6c5ce7,
|
||||||
|
#a29bfe
|
||||||
|
);
|
||||||
|
background-size: 400% 400%;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
animation: rainbow-flow 4s ease-in-out infinite, logo-glow 2s ease-in-out infinite alternate;
|
||||||
|
position: relative;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rainbow-flow {
|
||||||
|
0%, 100% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-glow {
|
||||||
|
0% {
|
||||||
|
filter: drop-shadow(0 0 5px rgba(108, 92, 231, 0.4));
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
filter: drop-shadow(0 0 20px rgba(108, 92, 231, 0.8)) drop-shadow(0 0 30px rgba(255, 107, 107, 0.4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主内容区大型 KatelyaTV Logo 容器 */
|
||||||
|
.main-logo-container {
|
||||||
|
padding: 3rem 0 4rem 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 背景光效 */
|
||||||
|
.logo-background-glow {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 600px;
|
||||||
|
height: 400px;
|
||||||
|
background: radial-gradient(
|
||||||
|
ellipse,
|
||||||
|
rgba(147, 112, 219, 0.15) 0%,
|
||||||
|
rgba(255, 107, 107, 0.1) 30%,
|
||||||
|
rgba(76, 205, 196, 0.08) 60%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
animation: glow-pulse 4s ease-in-out infinite, glow-rotate 20s linear infinite;
|
||||||
|
border-radius: 50%;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glow-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0.6;
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, -50%) scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glow-rotate {
|
||||||
|
0% {
|
||||||
|
transform: translate(-50%, -50%) rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate(-50%, -50%) rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主内容区大型 Logo */
|
||||||
|
.main-katelya-logo {
|
||||||
|
font-size: 4rem;
|
||||||
|
font-weight: 900;
|
||||||
|
background: linear-gradient(
|
||||||
|
45deg,
|
||||||
|
#ff6b6b,
|
||||||
|
#4ecdc4,
|
||||||
|
#45b7d1,
|
||||||
|
#96ceb4,
|
||||||
|
#ffc645,
|
||||||
|
#fd79a8,
|
||||||
|
#6c5ce7,
|
||||||
|
#a29bfe,
|
||||||
|
#ff6b6b
|
||||||
|
);
|
||||||
|
background-size: 600% 600%;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
animation: rainbow-flow-main 6s ease-in-out infinite, logo-float 3s ease-in-out infinite, logo-glow-main 4s ease-in-out infinite;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-shadow: 0 10px 30px rgba(147, 112, 219, 0.3);
|
||||||
|
cursor: default;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rainbow-flow-main {
|
||||||
|
0%, 100% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
background-position: 100% 0%;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background-position: 100% 100%;
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
background-position: 0% 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-float {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateY(0px) scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-8px) scale(1.02);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-glow-main {
|
||||||
|
0%, 100% {
|
||||||
|
filter: drop-shadow(0 0 20px rgba(147, 112, 219, 0.4)) drop-shadow(0 0 40px rgba(255, 107, 107, 0.2));
|
||||||
|
}
|
||||||
|
33% {
|
||||||
|
filter: drop-shadow(0 0 30px rgba(76, 205, 196, 0.4)) drop-shadow(0 0 50px rgba(69, 183, 209, 0.3));
|
||||||
|
}
|
||||||
|
66% {
|
||||||
|
filter: drop-shadow(0 0 25px rgba(255, 198, 69, 0.4)) drop-shadow(0 0 45px rgba(253, 121, 168, 0.3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主 Logo 副标题 */
|
||||||
|
.main-logo-subtitle {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(147, 112, 219, 0.8);
|
||||||
|
animation: subtitle-shimmer 5s ease-in-out infinite;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .main-logo-subtitle {
|
||||||
|
color: rgba(186, 85, 211, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes subtitle-shimmer {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0.7;
|
||||||
|
transform: translateY(0px);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logo 装饰性粒子效果 */
|
||||||
|
.logo-particles {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0.6;
|
||||||
|
animation: particle-float 8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle-1 {
|
||||||
|
top: 20%;
|
||||||
|
left: 15%;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: linear-gradient(45deg, #ff6b6b, #fd79a8);
|
||||||
|
animation-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle-2 {
|
||||||
|
top: 70%;
|
||||||
|
right: 20%;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background: linear-gradient(45deg, #4ecdc4, #45b7d1);
|
||||||
|
animation-delay: -2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle-3 {
|
||||||
|
bottom: 30%;
|
||||||
|
left: 25%;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background: linear-gradient(45deg, #96ceb4, #ffc645);
|
||||||
|
animation-delay: -4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle-4 {
|
||||||
|
top: 40%;
|
||||||
|
right: 30%;
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
background: linear-gradient(45deg, #6c5ce7, #a29bfe);
|
||||||
|
animation-delay: -1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle-5 {
|
||||||
|
top: 60%;
|
||||||
|
left: 10%;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
background: linear-gradient(45deg, #fd79a8, #ffc645);
|
||||||
|
animation-delay: -3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle-6 {
|
||||||
|
bottom: 15%;
|
||||||
|
right: 15%;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
background: linear-gradient(45deg, #a29bfe, #ff6b6b);
|
||||||
|
animation-delay: -5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes particle-float {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateY(0px) translateX(0px) rotate(0deg);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: translateY(-20px) translateX(10px) rotate(90deg);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-10px) translateX(-15px) rotate(180deg);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: translateY(-25px) translateX(5px) rotate(270deg);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端主内容区 Logo 适配 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.main-logo-container {
|
||||||
|
padding: 2rem 0 3rem 0;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-katelya-logo {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-logo-subtitle {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-background-glow {
|
||||||
|
width: 400px;
|
||||||
|
height: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle {
|
||||||
|
transform: scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.main-katelya-logo {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-logo-subtitle {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-background-glow {
|
||||||
|
width: 300px;
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部 KatelyaTV Logo 容器 */
|
||||||
|
.bottom-logo-container {
|
||||||
|
padding: 2rem 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-logo-container::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(147, 112, 219, 0.1),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
animation: shimmer-sweep 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer-sweep {
|
||||||
|
0% {
|
||||||
|
left: -100%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-logo {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
background: linear-gradient(
|
||||||
|
45deg,
|
||||||
|
#ff6b6b,
|
||||||
|
#4ecdc4,
|
||||||
|
#45b7d1,
|
||||||
|
#96ceb4,
|
||||||
|
#ffc645,
|
||||||
|
#fd79a8,
|
||||||
|
#6c5ce7,
|
||||||
|
#a29bfe,
|
||||||
|
#ff6b6b
|
||||||
|
);
|
||||||
|
background-size: 400% 400%;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
animation: rainbow-flow 3s ease-in-out infinite, pulse-scale 2s ease-in-out infinite;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-shadow: 0 0 30px rgba(147, 112, 219, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-scale {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端底部 Logo 调整 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.bottom-logo {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-logo-container {
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按钮悬停效果增强 */
|
||||||
|
.btn-purple {
|
||||||
|
background: linear-gradient(135deg, #9370db, #ba55d3);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-purple::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||||
|
transition: left 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-purple:hover::before {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-purple:hover {
|
||||||
|
background: linear-gradient(135deg, #8a2be2, #9370db);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 25px rgba(147, 112, 219, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 导航项悬停动画 */
|
||||||
|
.nav-item {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, transparent, #9370db, transparent);
|
||||||
|
transition: left 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover::before {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卡片悬停增强效果 */
|
||||||
|
.card-hover {
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hover::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(45deg, transparent, rgba(147, 112, 219, 0.1), transparent);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hover:hover::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hover:hover {
|
||||||
|
transform: translateY(-8px) scale(1.02);
|
||||||
|
box-shadow: 0 20px 40px rgba(147, 112, 219, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 浮动几何图形 */
|
||||||
|
.floating-shapes {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0.1;
|
||||||
|
animation: float-rotate 20s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape:nth-child(1) {
|
||||||
|
top: 10%;
|
||||||
|
left: 10%;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
background: linear-gradient(45deg, #ff6b6b, #4ecdc4);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape:nth-child(2) {
|
||||||
|
top: 70%;
|
||||||
|
right: 15%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: linear-gradient(45deg, #6c5ce7, #a29bfe);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
animation-delay: -5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape:nth-child(3) {
|
||||||
|
bottom: 20%;
|
||||||
|
left: 20%;
|
||||||
|
width: 80px;
|
||||||
|
height: 20px;
|
||||||
|
background: linear-gradient(45deg, #ffc645, #fd79a8);
|
||||||
|
border-radius: 10px;
|
||||||
|
animation-delay: -10s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape:nth-child(4) {
|
||||||
|
top: 30%;
|
||||||
|
right: 30%;
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
background: linear-gradient(45deg, #96ceb4, #45b7d1);
|
||||||
|
clip-path: polygon(50% 0%, 0% 100%, 100% 100%);
|
||||||
|
animation-delay: -15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float-rotate {
|
||||||
|
0% {
|
||||||
|
transform: translateY(0px) rotate(0deg);
|
||||||
|
opacity: 0.1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(-20px) rotate(360deg);
|
||||||
|
opacity: 0.1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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));
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 动态背景特效 - 增强版 */
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: -2;
|
||||||
|
pointer-events: none;
|
||||||
|
background: linear-gradient(45deg, #e6e6fa, #dda0dd, #c8a2c8, #f0e6ff, #e6e6fa, #d8bfd8, #e6e6fa);
|
||||||
|
background-size: 400% 400%;
|
||||||
|
animation: gradientFlow 12s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 流光背景动画 */
|
||||||
|
@keyframes gradientFlow {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
33% {
|
||||||
|
background-position: 100% 0%;
|
||||||
|
}
|
||||||
|
66% {
|
||||||
|
background-position: 0% 100%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗色模式背景 */
|
||||||
|
html.dark body::before {
|
||||||
|
background: linear-gradient(45deg, #2a0845, #4a0e4e, #1a0a2e, #16213e, #2a0845, #3d1f69, #2a0845);
|
||||||
|
background-size: 400% 400%;
|
||||||
|
animation: gradientFlow 12s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 增强的浮动装饰元素 */
|
||||||
|
body::after {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(3px 3px at 20px 30px, rgba(147, 112, 219, 0.5), transparent),
|
||||||
|
radial-gradient(2px 2px at 40px 70px, rgba(186, 85, 211, 0.4), transparent),
|
||||||
|
radial-gradient(1px 1px at 90px 40px, rgba(221, 160, 221, 0.5), transparent),
|
||||||
|
radial-gradient(2px 2px at 130px 80px, rgba(147, 112, 219, 0.4), transparent),
|
||||||
|
radial-gradient(3px 3px at 160px 30px, rgba(138, 43, 226, 0.5), transparent),
|
||||||
|
radial-gradient(1px 1px at 200px 90px, rgba(219, 112, 147, 0.4), transparent),
|
||||||
|
radial-gradient(2px 2px at 250px 50px, rgba(147, 112, 219, 0.5), transparent),
|
||||||
|
radial-gradient(4px 4px at 300px 120px, rgba(255, 107, 107, 0.3), transparent),
|
||||||
|
radial-gradient(2px 2px at 350px 40px, rgba(76, 205, 196, 0.4), transparent);
|
||||||
|
background-repeat: repeat;
|
||||||
|
background-size: 350px 250px;
|
||||||
|
animation: sparkle 25s linear infinite, float 18s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sparkle {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateY(0px) translateX(0px) rotate(0deg);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: translateY(-15px) translateX(10px) rotate(90deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-8px) translateX(-8px) rotate(180deg);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: translateY(-20px) translateX(5px) rotate(270deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 自定义滚动条样式 - 增强版 */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: rgba(147, 112, 219, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: linear-gradient(180deg, rgba(147, 112, 219, 0.3), rgba(186, 85, 211, 0.4));
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: linear-gradient(180deg, rgba(147, 112, 219, 0.6), rgba(186, 85, 211, 0.7));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 视频卡片悬停效果 */
|
||||||
|
.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%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 优化的圆角容器样式 - 主内容区 - 修改透明度为50% */
|
||||||
|
.rounded-container {
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
/* 将透明度从0.85调整为0.5 (50%) */
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
backdrop-filter: blur(25px);
|
||||||
|
border: 1px solid rgba(147, 112, 219, 0.2);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 40px rgba(147, 112, 219, 0.12),
|
||||||
|
0 1px 0 rgba(255, 255, 255, 0.9) inset,
|
||||||
|
0 0 0 1px rgba(147, 112, 219, 0.05) inset;
|
||||||
|
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
position: relative;
|
||||||
|
/* 确保容器占据完整的分配空间 */
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-container::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(147, 112, 219, 0.5), rgba(255, 107, 107, 0.3), rgba(147, 112, 219, 0.5), transparent);
|
||||||
|
animation: shimmerTop 4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmerTop {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .rounded-container {
|
||||||
|
/* 暗色模式下也将透明度调整为0.5 (50%) */
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
border: 1px solid rgba(147, 112, 219, 0.3);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 40px rgba(147, 112, 219, 0.25),
|
||||||
|
0 1px 0 rgba(147, 112, 219, 0.15) inset,
|
||||||
|
0 0 0 1px rgba(147, 112, 219, 0.1) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .rounded-container::before {
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(147, 112, 219, 0.7), rgba(255, 107, 107, 0.4), rgba(147, 112, 219, 0.7), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 悬停效果 */
|
||||||
|
.rounded-container:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow:
|
||||||
|
0 15px 50px rgba(147, 112, 219, 0.2),
|
||||||
|
0 1px 0 rgba(255, 255, 255, 0.95) inset,
|
||||||
|
0 0 0 1px rgba(147, 112, 219, 0.1) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .rounded-container:hover {
|
||||||
|
box-shadow:
|
||||||
|
0 15px 50px rgba(147, 112, 219, 0.35),
|
||||||
|
0 1px 0 rgba(147, 112, 219, 0.25) inset,
|
||||||
|
0 0 0 1px rgba(147, 112, 219, 0.2) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式容器边距 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.rounded-container {
|
||||||
|
border-radius: 16px;
|
||||||
|
/* 移动端时恢复全宽 */
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 隐藏移动端(<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,110 @@
|
|||||||
|
import type { Metadata, Viewport } from 'next';
|
||||||
|
import { Inter } from 'next/font/google';
|
||||||
|
|
||||||
|
import './globals.css';
|
||||||
|
import 'sweetalert2/dist/sweetalert2.min.css';
|
||||||
|
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
|
||||||
|
import { SiteProvider } from '../components/SiteProvider';
|
||||||
|
import { ThemeProvider } from '../components/ThemeProvider';
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ['latin'] });
|
||||||
|
|
||||||
|
// 动态生成 metadata,支持配置更新后的标题变化
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
let siteName = process.env.SITE_NAME || 'KatelyaTV';
|
||||||
|
if (
|
||||||
|
process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'd1' &&
|
||||||
|
process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'upstash'
|
||||||
|
) {
|
||||||
|
const config = await getConfig();
|
||||||
|
siteName = config.SiteConfig.SiteName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: siteName,
|
||||||
|
description: '影视聚合',
|
||||||
|
manifest: '/manifest.json',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: '#000000',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 浮动几何形状组件
|
||||||
|
const FloatingShapes = () => {
|
||||||
|
return (
|
||||||
|
<div className="floating-shapes">
|
||||||
|
<div className="shape"></div>
|
||||||
|
<div className="shape"></div>
|
||||||
|
<div className="shape"></div>
|
||||||
|
<div className="shape"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
let siteName = process.env.SITE_NAME || 'KatelyaTV';
|
||||||
|
let announcement =
|
||||||
|
process.env.ANNOUNCEMENT ||
|
||||||
|
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。Link Me TG:@katelya77';
|
||||||
|
let enableRegister = process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true';
|
||||||
|
let imageProxy = process.env.NEXT_PUBLIC_IMAGE_PROXY || '';
|
||||||
|
let doubanProxy = process.env.NEXT_PUBLIC_DOUBAN_PROXY || '';
|
||||||
|
if (
|
||||||
|
process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'd1' &&
|
||||||
|
process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'upstash'
|
||||||
|
) {
|
||||||
|
const config = await getConfig();
|
||||||
|
siteName = config.SiteConfig.SiteName;
|
||||||
|
announcement = config.SiteConfig.Announcement;
|
||||||
|
enableRegister = config.UserConfig.AllowRegister;
|
||||||
|
imageProxy = config.SiteConfig.ImageProxy;
|
||||||
|
doubanProxy = config.SiteConfig.DoubanProxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
|
||||||
|
const runtimeConfig = {
|
||||||
|
STORAGE_TYPE: process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage',
|
||||||
|
ENABLE_REGISTER: enableRegister,
|
||||||
|
IMAGE_PROXY: imageProxy,
|
||||||
|
DOUBAN_PROXY: doubanProxy,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang='zh-CN' suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
{/* 将配置序列化后直接写入脚本,浏览器端可通过 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`}
|
||||||
|
>
|
||||||
|
{/* 浮动几何形状装饰 */}
|
||||||
|
<FloatingShapes />
|
||||||
|
|
||||||
|
<ThemeProvider
|
||||||
|
attribute='class'
|
||||||
|
defaultTheme='system'
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
<SiteProvider siteName={siteName} announcement={announcement}>
|
||||||
|
{children}
|
||||||
|
</SiteProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
/* 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 { checkForUpdates, CURRENT_VERSION, UpdateStatus } from '@/lib/version';
|
||||||
|
|
||||||
|
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/senshinya/MoonTV', '_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-purple-600 dark:text-purple-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 [enableRegister, setEnableRegister] = useState(false);
|
||||||
|
const { siteName } = useSite();
|
||||||
|
|
||||||
|
// 在客户端挂载后设置配置
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const storageType = (window as any).RUNTIME_CONFIG?.STORAGE_TYPE;
|
||||||
|
setShouldAskUsername(storageType && storageType !== 'localstorage');
|
||||||
|
setEnableRegister(
|
||||||
|
Boolean((window as any).RUNTIME_CONFIG?.ENABLE_REGISTER)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理注册逻辑
|
||||||
|
const handleRegister = async () => {
|
||||||
|
setError(null);
|
||||||
|
if (!password || !username) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await fetch('/api/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const redirect = searchParams.get('redirect') || '/';
|
||||||
|
router.replace(redirect);
|
||||||
|
} 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'>
|
||||||
|
{/* 渐变酷炫Logo */}
|
||||||
|
<h1 className='relative tracking-tight text-center text-3xl font-extrabold mb-8 drop-shadow-lg'>
|
||||||
|
<span className='bg-gradient-to-r from-purple-400 via-pink-500 to-purple-600 dark:from-purple-300 dark:via-pink-400 dark:to-purple-500 bg-clip-text text-transparent animate-pulse'>
|
||||||
|
{siteName}
|
||||||
|
</span>
|
||||||
|
{/* 添加发光效果 */}
|
||||||
|
<span className='absolute inset-0 bg-gradient-to-r from-purple-400 via-pink-500 to-purple-600 dark:from-purple-300 dark:via-pink-400 dark:to-purple-500 bg-clip-text text-transparent blur-sm opacity-50 animate-pulse'>
|
||||||
|
{siteName}
|
||||||
|
</span>
|
||||||
|
</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-purple-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-purple-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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 登录 / 注册按钮 */}
|
||||||
|
{shouldAskUsername && enableRegister ? (
|
||||||
|
<div className='flex gap-4'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={handleRegister}
|
||||||
|
disabled={!password || !username || loading}
|
||||||
|
className='flex-1 inline-flex justify-center rounded-lg bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 py-3 text-base font-semibold text-white shadow-lg transition-all duration-200 disabled:cursor-not-allowed disabled:opacity-50'
|
||||||
|
>
|
||||||
|
{loading ? '注册中...' : '注册'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type='submit'
|
||||||
|
disabled={
|
||||||
|
!password || loading || (shouldAskUsername && !username)
|
||||||
|
}
|
||||||
|
className='flex-1 inline-flex justify-center rounded-lg bg-gradient-to-r from-purple-600 to-purple-500 hover:from-purple-700 hover:to-purple-600 py-3 text-base font-semibold text-white shadow-lg transition-all duration-200 disabled:cursor-not-allowed disabled:opacity-50'
|
||||||
|
>
|
||||||
|
{loading ? '登录中...' : '登录'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type='submit'
|
||||||
|
disabled={
|
||||||
|
!password || loading || (shouldAskUsername && !username)
|
||||||
|
}
|
||||||
|
className='inline-flex w-full justify-center rounded-lg bg-gradient-to-r from-purple-600 to-purple-500 hover:from-purple-700 hover:to-purple-600 py-3 text-base font-semibold text-white shadow-lg transition-all duration-200 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,474 @@
|
|||||||
|
/* 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';
|
||||||
|
|
||||||
|
// 客户端收藏 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';
|
||||||
|
|
||||||
|
// 主内容区大型 KatelyaTV Logo 组件
|
||||||
|
const MainKatelyaLogo = () => {
|
||||||
|
return (
|
||||||
|
<div className="main-logo-container">
|
||||||
|
{/* 背景光效 */}
|
||||||
|
<div className="logo-background-glow"></div>
|
||||||
|
|
||||||
|
{/* 主 Logo */}
|
||||||
|
<div className="main-katelya-logo">
|
||||||
|
KatelyaTV
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 副标题 */}
|
||||||
|
<div className="mt-3 text-center">
|
||||||
|
<div className="main-logo-subtitle">
|
||||||
|
极致影视体验,尽在指尖
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 装饰性粒子效果 */}
|
||||||
|
<div className="logo-particles">
|
||||||
|
<div className="particle particle-1"></div>
|
||||||
|
<div className="particle particle-2"></div>
|
||||||
|
<div className="particle particle-3"></div>
|
||||||
|
<div className="particle particle-4"></div>
|
||||||
|
<div className="particle particle-5"></div>
|
||||||
|
<div className="particle particle-6"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// KatelyaTV 底部 Logo 组件
|
||||||
|
const BottomKatelyaLogo = () => {
|
||||||
|
return (
|
||||||
|
<div className="bottom-logo-container">
|
||||||
|
{/* 浮动几何形状装饰 */}
|
||||||
|
<div className="floating-shapes">
|
||||||
|
<div className="shape"></div>
|
||||||
|
<div className="shape"></div>
|
||||||
|
<div className="shape"></div>
|
||||||
|
<div className="shape"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="bottom-logo">
|
||||||
|
KatelyaTV
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm text-gray-500 dark:text-gray-400 opacity-75">
|
||||||
|
Powered by MoonTV Core
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 [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;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [favoriteItems, setFavoriteItems] = useState<FavoriteItem[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchDoubanData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// 并行获取热门电影、热门剧集和热门综艺
|
||||||
|
const [moviesData, tvShowsData, varietyShowsData] = await Promise.all([
|
||||||
|
getDoubanCategories({
|
||||||
|
kind: 'movie',
|
||||||
|
category: '热门',
|
||||||
|
type: '全部',
|
||||||
|
}),
|
||||||
|
getDoubanCategories({ kind: 'tv', category: 'tv', type: 'tv' }),
|
||||||
|
getDoubanCategories({ kind: 'tv', category: 'show', type: 'show' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (moviesData.code === 200) {
|
||||||
|
setHotMovies(moviesData.list);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tvShowsData.code === 200) {
|
||||||
|
setHotTvShows(tvShowsData.list);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (varietyShowsData.code === 200) {
|
||||||
|
setHotVarietyShows(varietyShowsData.list);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取豆瓣数据失败:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchDoubanData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 处理收藏数据更新的函数
|
||||||
|
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,
|
||||||
|
} 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-4 sm:px-8 lg:px-12 py-4 sm:py-8 overflow-visible'>
|
||||||
|
{/* 主内容区大型 KatelyaTV Logo - 仅在首页显示 */}
|
||||||
|
{activeTab === 'home' && <MainKatelyaLogo />}
|
||||||
|
|
||||||
|
{/* 顶部 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='w-full max-w-none 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-purple-700 dark:text-gray-400 dark:hover:text-purple-300 transition-colors'
|
||||||
|
onClick={async () => {
|
||||||
|
await clearAllFavorites();
|
||||||
|
setFavoriteItems([]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* 优化收藏夹网格布局,确保在新的居中布局下完美对齐 */}
|
||||||
|
<div className='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-6 lg:gap-x-8 justify-items-center'>
|
||||||
|
{favoriteItems.map((item) => (
|
||||||
|
<div key={item.id + item.source} className='w-full max-w-44'>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 收藏夹页面底部 Logo */}
|
||||||
|
<BottomKatelyaLogo />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// 首页视图
|
||||||
|
<>
|
||||||
|
{/* 继续观看 */}
|
||||||
|
<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-purple-700 dark:text-gray-400 dark:hover:text-purple-300 transition-colors'
|
||||||
|
>
|
||||||
|
查看更多
|
||||||
|
<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-purple-200 animate-pulse dark:bg-purple-800'>
|
||||||
|
<div className='absolute inset-0 bg-purple-300 dark:bg-purple-700'></div>
|
||||||
|
</div>
|
||||||
|
<div className='mt-2 h-4 bg-purple-200 rounded animate-pulse dark:bg-purple-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={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-purple-700 dark:text-gray-400 dark:hover:text-purple-300 transition-colors'
|
||||||
|
>
|
||||||
|
查看更多
|
||||||
|
<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-purple-200 animate-pulse dark:bg-purple-800'>
|
||||||
|
<div className='absolute inset-0 bg-purple-300 dark:bg-purple-700'></div>
|
||||||
|
</div>
|
||||||
|
<div className='mt-2 h-4 bg-purple-200 rounded animate-pulse dark:bg-purple-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={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=show'
|
||||||
|
className='flex items-center text-sm text-gray-500 hover:text-purple-700 dark:text-gray-400 dark:hover:text-purple-300 transition-colors'
|
||||||
|
>
|
||||||
|
查看更多
|
||||||
|
<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-purple-200 animate-pulse dark:bg-purple-800'>
|
||||||
|
<div className='absolute inset-0 bg-purple-300 dark:bg-purple-700'></div>
|
||||||
|
</div>
|
||||||
|
<div className='mt-2 h-4 bg-purple-200 rounded animate-pulse dark:bg-purple-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={show.id}
|
||||||
|
rate={show.rate}
|
||||||
|
year={show.year}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</ScrollableRow>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 首页底部 Logo */}
|
||||||
|
<BottomKatelyaLogo />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<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'>
|
||||||
|
<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-purple-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-purple-50 dark:bg-purple-900/20'>
|
||||||
|
<div className='absolute inset-y-0 left-0 w-1.5 bg-purple-500 dark:bg-purple-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-purple-600 to-purple-700 px-4 py-3 text-white font-medium shadow-md hover:shadow-lg hover:from-purple-700 hover:to-purple-800 dark:from-purple-600 dark:to-purple-700 dark:hover:from-purple-700 dark:hover:to-purple-800 transition-all duration-300 transform hover:-translate-y-0.5'
|
||||||
|
>
|
||||||
|
我知道了
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<HomeClient />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,1702 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps, no-console, @next/next/no-img-element */
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import Artplayer from 'artplayer';
|
||||||
|
import Hls from 'hls.js';
|
||||||
|
import { Heart } from 'lucide-react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
deleteFavorite,
|
||||||
|
deletePlayRecord,
|
||||||
|
generateStorageKey,
|
||||||
|
getAllPlayRecords,
|
||||||
|
isFavorited,
|
||||||
|
saveFavorite,
|
||||||
|
savePlayRecord,
|
||||||
|
subscribeToDataUpdates,
|
||||||
|
} from '@/lib/db.client';
|
||||||
|
import { SearchResult } from '@/lib/types';
|
||||||
|
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
|
||||||
|
|
||||||
|
import EpisodeSelector from '@/components/EpisodeSelector';
|
||||||
|
import PageLayout from '@/components/PageLayout';
|
||||||
|
|
||||||
|
// 扩展 HTMLVideoElement 类型以支持 hls 属性
|
||||||
|
declare global {
|
||||||
|
interface HTMLVideoElement {
|
||||||
|
hls?: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayPageClient() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 状态变量(State)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingStage, setLoadingStage] = useState<
|
||||||
|
'searching' | 'preferring' | 'fetching' | 'ready'
|
||||||
|
>('searching');
|
||||||
|
const [loadingMessage, setLoadingMessage] = useState('正在搜索播放源...');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [detail, setDetail] = useState<SearchResult | null>(null);
|
||||||
|
|
||||||
|
// 收藏状态
|
||||||
|
const [favorited, setFavorited] = useState(false);
|
||||||
|
|
||||||
|
// 去广告开关(从 localStorage 继承,默认 true)
|
||||||
|
const [blockAdEnabled, setBlockAdEnabled] = useState<boolean>(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const v = localStorage.getItem('enable_blockad');
|
||||||
|
if (v !== null) return v === 'true';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const blockAdEnabledRef = useRef(blockAdEnabled);
|
||||||
|
useEffect(() => {
|
||||||
|
blockAdEnabledRef.current = blockAdEnabled;
|
||||||
|
}, [blockAdEnabled]);
|
||||||
|
|
||||||
|
// 视频基本信息
|
||||||
|
const [videoTitle, setVideoTitle] = useState(searchParams.get('title') || '');
|
||||||
|
const [videoYear, setVideoYear] = useState(searchParams.get('year') || '');
|
||||||
|
const [videoCover, setVideoCover] = useState('');
|
||||||
|
// 当前源和ID
|
||||||
|
const [currentSource, setCurrentSource] = useState(
|
||||||
|
searchParams.get('source') || ''
|
||||||
|
);
|
||||||
|
const [currentId, setCurrentId] = useState(searchParams.get('id') || '');
|
||||||
|
|
||||||
|
// 搜索所需信息
|
||||||
|
const [searchTitle] = useState(searchParams.get('stitle') || '');
|
||||||
|
const [searchType] = useState(searchParams.get('stype') || '');
|
||||||
|
|
||||||
|
// 是否需要优选
|
||||||
|
const [needPrefer, setNeedPrefer] = useState(
|
||||||
|
searchParams.get('prefer') === 'true'
|
||||||
|
);
|
||||||
|
const needPreferRef = useRef(needPrefer);
|
||||||
|
useEffect(() => {
|
||||||
|
needPreferRef.current = needPrefer;
|
||||||
|
}, [needPrefer]);
|
||||||
|
// 集数相关
|
||||||
|
const [currentEpisodeIndex, setCurrentEpisodeIndex] = useState(0);
|
||||||
|
|
||||||
|
const currentSourceRef = useRef(currentSource);
|
||||||
|
const currentIdRef = useRef(currentId);
|
||||||
|
const videoTitleRef = useRef(videoTitle);
|
||||||
|
const videoYearRef = useRef(videoYear);
|
||||||
|
const detailRef = useRef<SearchResult | null>(detail);
|
||||||
|
const currentEpisodeIndexRef = useRef(currentEpisodeIndex);
|
||||||
|
|
||||||
|
// 同步最新值到 refs
|
||||||
|
useEffect(() => {
|
||||||
|
currentSourceRef.current = currentSource;
|
||||||
|
currentIdRef.current = currentId;
|
||||||
|
detailRef.current = detail;
|
||||||
|
currentEpisodeIndexRef.current = currentEpisodeIndex;
|
||||||
|
videoTitleRef.current = videoTitle;
|
||||||
|
videoYearRef.current = videoYear;
|
||||||
|
}, [
|
||||||
|
currentSource,
|
||||||
|
currentId,
|
||||||
|
detail,
|
||||||
|
currentEpisodeIndex,
|
||||||
|
videoTitle,
|
||||||
|
videoYear,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 视频播放地址
|
||||||
|
const [videoUrl, setVideoUrl] = useState('');
|
||||||
|
|
||||||
|
// 总集数
|
||||||
|
const totalEpisodes = detail?.episodes?.length || 0;
|
||||||
|
|
||||||
|
// 用于记录是否需要在播放器 ready 后跳转到指定进度
|
||||||
|
const resumeTimeRef = useRef<number | null>(null);
|
||||||
|
// 上次使用的音量,默认 0.7
|
||||||
|
const lastVolumeRef = useRef<number>(0.7);
|
||||||
|
|
||||||
|
// 换源相关状态
|
||||||
|
const [availableSources, setAvailableSources] = useState<SearchResult[]>([]);
|
||||||
|
const [sourceSearchLoading, setSourceSearchLoading] = useState(false);
|
||||||
|
const [sourceSearchError, setSourceSearchError] = useState<string | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
// 优选和测速开关
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保存优选时的测速结果,避免EpisodeSelector重复测速
|
||||||
|
const [precomputedVideoInfo, setPrecomputedVideoInfo] = useState<
|
||||||
|
Map<string, { quality: string; loadSpeed: string; pingTime: number }>
|
||||||
|
>(new Map());
|
||||||
|
|
||||||
|
// 折叠状态(仅在 lg 及以上屏幕有效)
|
||||||
|
const [isEpisodeSelectorCollapsed, setIsEpisodeSelectorCollapsed] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
|
// 换源加载状态
|
||||||
|
const [isVideoLoading, setIsVideoLoading] = useState(true);
|
||||||
|
const [videoLoadingStage, setVideoLoadingStage] = useState<
|
||||||
|
'initing' | 'sourceChanging'
|
||||||
|
>('initing');
|
||||||
|
|
||||||
|
// 播放进度保存相关
|
||||||
|
const saveIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const lastSaveTimeRef = useRef<number>(0);
|
||||||
|
|
||||||
|
const artPlayerRef = useRef<any>(null);
|
||||||
|
const artRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 工具函数(Utils)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// 播放源优选函数
|
||||||
|
const preferBestSource = async (
|
||||||
|
sources: SearchResult[]
|
||||||
|
): Promise<SearchResult> => {
|
||||||
|
if (sources.length === 1) return sources[0];
|
||||||
|
|
||||||
|
// 将播放源均分为两批,并发测速各批,避免一次性过多请求
|
||||||
|
const batchSize = Math.ceil(sources.length / 2);
|
||||||
|
const allResults: Array<{
|
||||||
|
source: SearchResult;
|
||||||
|
testResult: { quality: string; loadSpeed: string; pingTime: number };
|
||||||
|
} | null> = [];
|
||||||
|
|
||||||
|
for (let start = 0; start < sources.length; start += batchSize) {
|
||||||
|
const batchSources = sources.slice(start, start + batchSize);
|
||||||
|
const batchResults = await Promise.all(
|
||||||
|
batchSources.map(async (source) => {
|
||||||
|
try {
|
||||||
|
// 检查是否有第一集的播放地址
|
||||||
|
if (!source.episodes || source.episodes.length === 0) {
|
||||||
|
console.warn(`播放源 ${source.source_name} 没有可用的播放地址`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const episodeUrl =
|
||||||
|
source.episodes.length > 1
|
||||||
|
? source.episodes[1]
|
||||||
|
: source.episodes[0];
|
||||||
|
const testResult = await getVideoResolutionFromM3u8(episodeUrl);
|
||||||
|
|
||||||
|
return {
|
||||||
|
source,
|
||||||
|
testResult,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
allResults.push(...batchResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待所有测速完成,包含成功和失败的结果
|
||||||
|
// 保存所有测速结果到 precomputedVideoInfo,供 EpisodeSelector 使用(包含错误结果)
|
||||||
|
const newVideoInfoMap = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
quality: string;
|
||||||
|
loadSpeed: string;
|
||||||
|
pingTime: number;
|
||||||
|
hasError?: boolean;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
allResults.forEach((result, index) => {
|
||||||
|
const source = sources[index];
|
||||||
|
const sourceKey = `${source.source}-${source.id}`;
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
// 成功的结果
|
||||||
|
newVideoInfoMap.set(sourceKey, result.testResult);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 过滤出成功的结果用于优选计算
|
||||||
|
const successfulResults = allResults.filter(Boolean) as Array<{
|
||||||
|
source: SearchResult;
|
||||||
|
testResult: { quality: string; loadSpeed: string; pingTime: number };
|
||||||
|
}>;
|
||||||
|
|
||||||
|
setPrecomputedVideoInfo(newVideoInfoMap);
|
||||||
|
|
||||||
|
if (successfulResults.length === 0) {
|
||||||
|
console.warn('所有播放源测速都失败,使用第一个播放源');
|
||||||
|
return sources[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找出所有有效速度的最大值,用于线性映射
|
||||||
|
const validSpeeds = successfulResults
|
||||||
|
.map((result) => {
|
||||||
|
const speedStr = result.testResult.loadSpeed;
|
||||||
|
if (speedStr === '未知' || speedStr === '测量中...') return 0;
|
||||||
|
|
||||||
|
const match = speedStr.match(/^([\d.]+)\s*(KB\/s|MB\/s)$/);
|
||||||
|
if (!match) return 0;
|
||||||
|
|
||||||
|
const value = parseFloat(match[1]);
|
||||||
|
const unit = match[2];
|
||||||
|
return unit === 'MB/s' ? value * 1024 : value; // 统一转换为 KB/s
|
||||||
|
})
|
||||||
|
.filter((speed) => speed > 0);
|
||||||
|
|
||||||
|
const maxSpeed = validSpeeds.length > 0 ? Math.max(...validSpeeds) : 1024; // 默认1MB/s作为基准
|
||||||
|
|
||||||
|
// 找出所有有效延迟的最小值和最大值,用于线性映射
|
||||||
|
const validPings = successfulResults
|
||||||
|
.map((result) => result.testResult.pingTime)
|
||||||
|
.filter((ping) => ping > 0);
|
||||||
|
|
||||||
|
const minPing = validPings.length > 0 ? Math.min(...validPings) : 50;
|
||||||
|
const maxPing = validPings.length > 0 ? Math.max(...validPings) : 1000;
|
||||||
|
|
||||||
|
// 计算每个结果的评分
|
||||||
|
const resultsWithScore = successfulResults.map((result) => ({
|
||||||
|
...result,
|
||||||
|
score: calculateSourceScore(
|
||||||
|
result.testResult,
|
||||||
|
maxSpeed,
|
||||||
|
minPing,
|
||||||
|
maxPing
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 按综合评分排序,选择最佳播放源
|
||||||
|
resultsWithScore.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
console.log('播放源评分排序结果:');
|
||||||
|
resultsWithScore.forEach((result, index) => {
|
||||||
|
console.log(
|
||||||
|
`${index + 1}. ${
|
||||||
|
result.source.source_name
|
||||||
|
} - 评分: ${result.score.toFixed(2)} (${result.testResult.quality}, ${
|
||||||
|
result.testResult.loadSpeed
|
||||||
|
}, ${result.testResult.pingTime}ms)`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return resultsWithScore[0].source;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 计算播放源综合评分
|
||||||
|
const calculateSourceScore = (
|
||||||
|
testResult: {
|
||||||
|
quality: string;
|
||||||
|
loadSpeed: string;
|
||||||
|
pingTime: number;
|
||||||
|
},
|
||||||
|
maxSpeed: number,
|
||||||
|
minPing: number,
|
||||||
|
maxPing: number
|
||||||
|
): number => {
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
// 分辨率评分 (40% 权重)
|
||||||
|
const qualityScore = (() => {
|
||||||
|
switch (testResult.quality) {
|
||||||
|
case '4K':
|
||||||
|
return 100;
|
||||||
|
case '2K':
|
||||||
|
return 85;
|
||||||
|
case '1080p':
|
||||||
|
return 75;
|
||||||
|
case '720p':
|
||||||
|
return 60;
|
||||||
|
case '480p':
|
||||||
|
return 40;
|
||||||
|
case 'SD':
|
||||||
|
return 20;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
score += qualityScore * 0.4;
|
||||||
|
|
||||||
|
// 下载速度评分 (40% 权重) - 基于最大速度线性映射
|
||||||
|
const speedScore = (() => {
|
||||||
|
const speedStr = testResult.loadSpeed;
|
||||||
|
if (speedStr === '未知' || speedStr === '测量中...') return 30;
|
||||||
|
|
||||||
|
// 解析速度值
|
||||||
|
const match = speedStr.match(/^([\d.]+)\s*(KB\/s|MB\/s)$/);
|
||||||
|
if (!match) return 30;
|
||||||
|
|
||||||
|
const value = parseFloat(match[1]);
|
||||||
|
const unit = match[2];
|
||||||
|
const speedKBps = unit === 'MB/s' ? value * 1024 : value;
|
||||||
|
|
||||||
|
// 基于最大速度线性映射,最高100分
|
||||||
|
const speedRatio = speedKBps / maxSpeed;
|
||||||
|
return Math.min(100, Math.max(0, speedRatio * 100));
|
||||||
|
})();
|
||||||
|
score += speedScore * 0.4;
|
||||||
|
|
||||||
|
// 网络延迟评分 (20% 权重) - 基于延迟范围线性映射
|
||||||
|
const pingScore = (() => {
|
||||||
|
const ping = testResult.pingTime;
|
||||||
|
if (ping <= 0) return 0; // 无效延迟给默认分
|
||||||
|
|
||||||
|
// 如果所有延迟都相同,给满分
|
||||||
|
if (maxPing === minPing) return 100;
|
||||||
|
|
||||||
|
// 线性映射:最低延迟=100分,最高延迟=0分
|
||||||
|
const pingRatio = (maxPing - ping) / (maxPing - minPing);
|
||||||
|
return Math.min(100, Math.max(0, pingRatio * 100));
|
||||||
|
})();
|
||||||
|
score += pingScore * 0.2;
|
||||||
|
|
||||||
|
return Math.round(score * 100) / 100; // 保留两位小数
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新视频地址
|
||||||
|
const updateVideoUrl = (
|
||||||
|
detailData: SearchResult | null,
|
||||||
|
episodeIndex: number
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
!detailData ||
|
||||||
|
!detailData.episodes ||
|
||||||
|
episodeIndex >= detailData.episodes.length
|
||||||
|
) {
|
||||||
|
setVideoUrl('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newUrl = detailData?.episodes[episodeIndex] || '';
|
||||||
|
if (newUrl !== videoUrl) {
|
||||||
|
setVideoUrl(newUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureVideoSource = (video: HTMLVideoElement | null, url: string) => {
|
||||||
|
if (!video || !url) return;
|
||||||
|
const sources = Array.from(video.getElementsByTagName('source'));
|
||||||
|
const existed = sources.some((s) => s.src === url);
|
||||||
|
if (!existed) {
|
||||||
|
// 移除旧的 source,保持唯一
|
||||||
|
sources.forEach((s) => s.remove());
|
||||||
|
const sourceEl = document.createElement('source');
|
||||||
|
sourceEl.src = url;
|
||||||
|
video.appendChild(sourceEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 始终允许远程播放(AirPlay / Cast)
|
||||||
|
video.disableRemotePlayback = false;
|
||||||
|
// 如果曾经有禁用属性,移除之
|
||||||
|
if (video.hasAttribute('disableRemotePlayback')) {
|
||||||
|
video.removeAttribute('disableRemotePlayback');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 去广告相关函数
|
||||||
|
function filterAdsFromM3U8(m3u8Content: string): string {
|
||||||
|
if (!m3u8Content) return '';
|
||||||
|
|
||||||
|
// 按行分割M3U8内容
|
||||||
|
const lines = m3u8Content.split('\n');
|
||||||
|
const filteredLines = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
|
||||||
|
// 只过滤#EXT-X-DISCONTINUITY标识
|
||||||
|
if (!line.includes('#EXT-X-DISCONTINUITY')) {
|
||||||
|
filteredLines.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredLines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
class CustomHlsJsLoader extends Hls.DefaultConfig.loader {
|
||||||
|
constructor(config: any) {
|
||||||
|
super(config);
|
||||||
|
const load = this.load.bind(this);
|
||||||
|
this.load = function (context: any, config: any, callbacks: any) {
|
||||||
|
// 拦截manifest和level请求
|
||||||
|
if (
|
||||||
|
(context as any).type === 'manifest' ||
|
||||||
|
(context as any).type === 'level'
|
||||||
|
) {
|
||||||
|
const onSuccess = callbacks.onSuccess;
|
||||||
|
callbacks.onSuccess = function (
|
||||||
|
response: any,
|
||||||
|
stats: any,
|
||||||
|
context: any
|
||||||
|
) {
|
||||||
|
// 如果是m3u8文件,处理内容以移除广告分段
|
||||||
|
if (response.data && typeof response.data === 'string') {
|
||||||
|
// 过滤掉广告段 - 实现更精确的广告过滤逻辑
|
||||||
|
response.data = filterAdsFromM3U8(response.data);
|
||||||
|
}
|
||||||
|
return onSuccess(response, stats, context, null);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 执行原始load方法
|
||||||
|
load(context, config, callbacks);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当集数索引变化时自动更新视频地址
|
||||||
|
useEffect(() => {
|
||||||
|
updateVideoUrl(detail, currentEpisodeIndex);
|
||||||
|
}, [detail, currentEpisodeIndex]);
|
||||||
|
|
||||||
|
// 进入页面时直接获取全部源信息
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSourceDetail = async (
|
||||||
|
source: string,
|
||||||
|
id: string
|
||||||
|
): Promise<SearchResult[]> => {
|
||||||
|
try {
|
||||||
|
const detailResponse = await fetch(
|
||||||
|
`/api/detail?source=${source}&id=${id}`
|
||||||
|
);
|
||||||
|
if (!detailResponse.ok) {
|
||||||
|
throw new Error('获取视频详情失败');
|
||||||
|
}
|
||||||
|
const detailData = (await detailResponse.json()) as SearchResult;
|
||||||
|
setAvailableSources([detailData]);
|
||||||
|
return [detailData];
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取视频详情失败:', err);
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
setSourceSearchLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const fetchSourcesData = async (query: string): Promise<SearchResult[]> => {
|
||||||
|
// 根据搜索词获取全部源信息
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/search?q=${encodeURIComponent(query.trim())}`
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('搜索失败');
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// 处理搜索结果,根据规则过滤
|
||||||
|
const results = data.results.filter(
|
||||||
|
(result: SearchResult) =>
|
||||||
|
result.title.replaceAll(' ', '').toLowerCase() ===
|
||||||
|
videoTitleRef.current.replaceAll(' ', '').toLowerCase() &&
|
||||||
|
(videoYearRef.current
|
||||||
|
? result.year.toLowerCase() === videoYearRef.current.toLowerCase()
|
||||||
|
: true) &&
|
||||||
|
(searchType
|
||||||
|
? (searchType === 'tv' && result.episodes.length > 1) ||
|
||||||
|
(searchType === 'movie' && result.episodes.length === 1)
|
||||||
|
: true)
|
||||||
|
);
|
||||||
|
setAvailableSources(results);
|
||||||
|
return results;
|
||||||
|
} catch (err) {
|
||||||
|
setSourceSearchError(err instanceof Error ? err.message : '搜索失败');
|
||||||
|
setAvailableSources([]);
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
setSourceSearchLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const initAll = async () => {
|
||||||
|
if (!currentSource && !currentId && !videoTitle && !searchTitle) {
|
||||||
|
setError('缺少必要参数');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setLoadingStage(currentSource && currentId ? 'fetching' : 'searching');
|
||||||
|
setLoadingMessage(
|
||||||
|
currentSource && currentId
|
||||||
|
? '🎬 正在获取视频详情...'
|
||||||
|
: '🔍 正在搜索播放源...'
|
||||||
|
);
|
||||||
|
|
||||||
|
let sourcesInfo = await fetchSourcesData(searchTitle || videoTitle);
|
||||||
|
if (
|
||||||
|
currentSource &&
|
||||||
|
currentId &&
|
||||||
|
!sourcesInfo.some(
|
||||||
|
(source) => source.source === currentSource && source.id === currentId
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
sourcesInfo = await fetchSourceDetail(currentSource, currentId);
|
||||||
|
}
|
||||||
|
if (sourcesInfo.length === 0) {
|
||||||
|
setError('未找到匹配结果');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let detailData: SearchResult = sourcesInfo[0];
|
||||||
|
// 指定源和id且无需优选
|
||||||
|
if (currentSource && currentId && !needPreferRef.current) {
|
||||||
|
const target = sourcesInfo.find(
|
||||||
|
(source) => source.source === currentSource && source.id === currentId
|
||||||
|
);
|
||||||
|
if (target) {
|
||||||
|
detailData = target;
|
||||||
|
} else {
|
||||||
|
setError('未找到匹配结果');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未指定源和 id 或需要优选,且开启优选开关
|
||||||
|
if (
|
||||||
|
(!currentSource || !currentId || needPreferRef.current) &&
|
||||||
|
optimizationEnabled
|
||||||
|
) {
|
||||||
|
setLoadingStage('preferring');
|
||||||
|
setLoadingMessage('⚡ 正在优选最佳播放源...');
|
||||||
|
|
||||||
|
detailData = await preferBestSource(sourcesInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(detailData.source, detailData.id);
|
||||||
|
|
||||||
|
setNeedPrefer(false);
|
||||||
|
setCurrentSource(detailData.source);
|
||||||
|
setCurrentId(detailData.id);
|
||||||
|
setVideoYear(detailData.year);
|
||||||
|
setVideoTitle(detailData.title || videoTitleRef.current);
|
||||||
|
setVideoCover(detailData.poster);
|
||||||
|
setDetail(detailData);
|
||||||
|
if (currentEpisodeIndex >= detailData.episodes.length) {
|
||||||
|
setCurrentEpisodeIndex(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 规范URL参数
|
||||||
|
const newUrl = new URL(window.location.href);
|
||||||
|
newUrl.searchParams.set('source', detailData.source);
|
||||||
|
newUrl.searchParams.set('id', detailData.id);
|
||||||
|
newUrl.searchParams.set('year', detailData.year);
|
||||||
|
newUrl.searchParams.set('title', detailData.title);
|
||||||
|
newUrl.searchParams.delete('prefer');
|
||||||
|
window.history.replaceState({}, '', newUrl.toString());
|
||||||
|
|
||||||
|
setLoadingStage('ready');
|
||||||
|
setLoadingMessage('✨ 准备就绪,即将开始播放...');
|
||||||
|
|
||||||
|
// 短暂延迟让用户看到完成状态
|
||||||
|
setTimeout(() => {
|
||||||
|
setLoading(false);
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
initAll();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 播放记录处理
|
||||||
|
useEffect(() => {
|
||||||
|
// 仅在初次挂载时检查播放记录
|
||||||
|
const initFromHistory = async () => {
|
||||||
|
if (!currentSource || !currentId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allRecords = await getAllPlayRecords();
|
||||||
|
const key = generateStorageKey(currentSource, currentId);
|
||||||
|
const record = allRecords[key];
|
||||||
|
|
||||||
|
if (record) {
|
||||||
|
const targetIndex = record.index - 1;
|
||||||
|
const targetTime = record.play_time;
|
||||||
|
|
||||||
|
// 更新当前选集索引
|
||||||
|
if (targetIndex !== currentEpisodeIndex) {
|
||||||
|
setCurrentEpisodeIndex(targetIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存待恢复的播放进度,待播放器就绪后跳转
|
||||||
|
resumeTimeRef.current = targetTime;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('读取播放记录失败:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initFromHistory();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 处理换源
|
||||||
|
const handleSourceChange = async (
|
||||||
|
newSource: string,
|
||||||
|
newId: string,
|
||||||
|
newTitle: string
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
// 显示换源加载状态
|
||||||
|
setVideoLoadingStage('sourceChanging');
|
||||||
|
setIsVideoLoading(true);
|
||||||
|
|
||||||
|
// 记录当前播放进度(仅在同一集数切换时恢复)
|
||||||
|
const currentPlayTime = artPlayerRef.current?.currentTime || 0;
|
||||||
|
console.log('换源前当前播放时间:', currentPlayTime);
|
||||||
|
|
||||||
|
// 清除前一个历史记录
|
||||||
|
if (currentSourceRef.current && currentIdRef.current) {
|
||||||
|
try {
|
||||||
|
await deletePlayRecord(
|
||||||
|
currentSourceRef.current,
|
||||||
|
currentIdRef.current
|
||||||
|
);
|
||||||
|
console.log('已清除前一个播放记录');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('清除播放记录失败:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newDetail = availableSources.find(
|
||||||
|
(source) => source.source === newSource && source.id === newId
|
||||||
|
);
|
||||||
|
if (!newDetail) {
|
||||||
|
setError('未找到匹配结果');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试跳转到当前正在播放的集数
|
||||||
|
let targetIndex = currentEpisodeIndex;
|
||||||
|
|
||||||
|
// 如果当前集数超出新源的范围,则跳转到第一集
|
||||||
|
if (!newDetail.episodes || targetIndex >= newDetail.episodes.length) {
|
||||||
|
targetIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果仍然是同一集数且播放进度有效,则在播放器就绪后恢复到原始进度
|
||||||
|
if (targetIndex !== currentEpisodeIndex) {
|
||||||
|
resumeTimeRef.current = 0;
|
||||||
|
} else if (
|
||||||
|
(!resumeTimeRef.current || resumeTimeRef.current === 0) &&
|
||||||
|
currentPlayTime > 1
|
||||||
|
) {
|
||||||
|
resumeTimeRef.current = currentPlayTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新URL参数(不刷新页面)
|
||||||
|
const newUrl = new URL(window.location.href);
|
||||||
|
newUrl.searchParams.set('source', newSource);
|
||||||
|
newUrl.searchParams.set('id', newId);
|
||||||
|
newUrl.searchParams.set('year', newDetail.year);
|
||||||
|
window.history.replaceState({}, '', newUrl.toString());
|
||||||
|
|
||||||
|
setVideoTitle(newDetail.title || newTitle);
|
||||||
|
setVideoYear(newDetail.year);
|
||||||
|
setVideoCover(newDetail.poster);
|
||||||
|
setCurrentSource(newSource);
|
||||||
|
setCurrentId(newId);
|
||||||
|
setDetail(newDetail);
|
||||||
|
setCurrentEpisodeIndex(targetIndex);
|
||||||
|
} catch (err) {
|
||||||
|
// 隐藏换源加载状态
|
||||||
|
setIsVideoLoading(false);
|
||||||
|
setError(err instanceof Error ? err.message : '换源失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener('keydown', handleKeyboardShortcuts);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyboardShortcuts);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 集数切换
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 处理集数切换
|
||||||
|
const handleEpisodeChange = (episodeNumber: number) => {
|
||||||
|
if (episodeNumber >= 0 && episodeNumber < totalEpisodes) {
|
||||||
|
// 在更换集数前保存当前播放进度
|
||||||
|
if (artPlayerRef.current && artPlayerRef.current.paused) {
|
||||||
|
saveCurrentPlayProgress();
|
||||||
|
}
|
||||||
|
setCurrentEpisodeIndex(episodeNumber);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePreviousEpisode = () => {
|
||||||
|
const d = detailRef.current;
|
||||||
|
const idx = currentEpisodeIndexRef.current;
|
||||||
|
if (d && d.episodes && idx > 0) {
|
||||||
|
if (artPlayerRef.current && !artPlayerRef.current.paused) {
|
||||||
|
saveCurrentPlayProgress();
|
||||||
|
}
|
||||||
|
setCurrentEpisodeIndex(idx - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNextEpisode = () => {
|
||||||
|
const d = detailRef.current;
|
||||||
|
const idx = currentEpisodeIndexRef.current;
|
||||||
|
if (d && d.episodes && idx < d.episodes.length - 1) {
|
||||||
|
if (artPlayerRef.current && !artPlayerRef.current.paused) {
|
||||||
|
saveCurrentPlayProgress();
|
||||||
|
}
|
||||||
|
setCurrentEpisodeIndex(idx + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 键盘快捷键
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 处理全局快捷键
|
||||||
|
const handleKeyboardShortcuts = (e: KeyboardEvent) => {
|
||||||
|
// 忽略输入框中的按键事件
|
||||||
|
if (
|
||||||
|
(e.target as HTMLElement).tagName === 'INPUT' ||
|
||||||
|
(e.target as HTMLElement).tagName === 'TEXTAREA'
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Alt + 左箭头 = 上一集
|
||||||
|
if (e.altKey && e.key === 'ArrowLeft') {
|
||||||
|
if (detailRef.current && currentEpisodeIndexRef.current > 0) {
|
||||||
|
handlePreviousEpisode();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alt + 右箭头 = 下一集
|
||||||
|
if (e.altKey && e.key === 'ArrowRight') {
|
||||||
|
const d = detailRef.current;
|
||||||
|
const idx = currentEpisodeIndexRef.current;
|
||||||
|
if (d && idx < d.episodes.length - 1) {
|
||||||
|
handleNextEpisode();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 左箭头 = 快退
|
||||||
|
if (!e.altKey && e.key === 'ArrowLeft') {
|
||||||
|
if (artPlayerRef.current && artPlayerRef.current.currentTime > 5) {
|
||||||
|
artPlayerRef.current.currentTime -= 10;
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右箭头 = 快进
|
||||||
|
if (!e.altKey && e.key === 'ArrowRight') {
|
||||||
|
if (
|
||||||
|
artPlayerRef.current &&
|
||||||
|
artPlayerRef.current.currentTime < artPlayerRef.current.duration - 5
|
||||||
|
) {
|
||||||
|
artPlayerRef.current.currentTime += 10;
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上箭头 = 音量+
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
if (artPlayerRef.current && artPlayerRef.current.volume < 1) {
|
||||||
|
artPlayerRef.current.volume =
|
||||||
|
Math.round((artPlayerRef.current.volume + 0.1) * 10) / 10;
|
||||||
|
artPlayerRef.current.notice.show = `音量: ${Math.round(
|
||||||
|
artPlayerRef.current.volume * 100
|
||||||
|
)}`;
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下箭头 = 音量-
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
if (artPlayerRef.current && artPlayerRef.current.volume > 0) {
|
||||||
|
artPlayerRef.current.volume =
|
||||||
|
Math.round((artPlayerRef.current.volume - 0.1) * 10) / 10;
|
||||||
|
artPlayerRef.current.notice.show = `音量: ${Math.round(
|
||||||
|
artPlayerRef.current.volume * 100
|
||||||
|
)}`;
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空格 = 播放/暂停
|
||||||
|
if (e.key === ' ') {
|
||||||
|
if (artPlayerRef.current) {
|
||||||
|
artPlayerRef.current.toggle();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// f 键 = 切换全屏
|
||||||
|
if (e.key === 'f' || e.key === 'F') {
|
||||||
|
if (artPlayerRef.current) {
|
||||||
|
artPlayerRef.current.fullscreen = !artPlayerRef.current.fullscreen;
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 播放记录相关
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 保存播放进度
|
||||||
|
const saveCurrentPlayProgress = async () => {
|
||||||
|
if (
|
||||||
|
!artPlayerRef.current ||
|
||||||
|
!currentSourceRef.current ||
|
||||||
|
!currentIdRef.current ||
|
||||||
|
!videoTitleRef.current ||
|
||||||
|
!detailRef.current?.source_name
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const player = artPlayerRef.current;
|
||||||
|
const currentTime = player.currentTime || 0;
|
||||||
|
const duration = player.duration || 0;
|
||||||
|
|
||||||
|
// 如果播放时间太短(少于5秒)或者视频时长无效,不保存
|
||||||
|
if (currentTime < 1 || !duration) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await savePlayRecord(currentSourceRef.current, currentIdRef.current, {
|
||||||
|
title: videoTitleRef.current,
|
||||||
|
source_name: detailRef.current?.source_name || '',
|
||||||
|
year: detailRef.current?.year,
|
||||||
|
cover: detailRef.current?.poster || '',
|
||||||
|
index: currentEpisodeIndexRef.current + 1, // 转换为1基索引
|
||||||
|
total_episodes: detailRef.current?.episodes.length || 1,
|
||||||
|
play_time: Math.floor(currentTime),
|
||||||
|
total_time: Math.floor(duration),
|
||||||
|
save_time: Date.now(),
|
||||||
|
search_title: searchTitle,
|
||||||
|
});
|
||||||
|
|
||||||
|
lastSaveTimeRef.current = Date.now();
|
||||||
|
console.log('播放进度已保存:', {
|
||||||
|
title: videoTitleRef.current,
|
||||||
|
episode: currentEpisodeIndexRef.current + 1,
|
||||||
|
year: detailRef.current?.year,
|
||||||
|
progress: `${Math.floor(currentTime)}/${Math.floor(duration)}`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('保存播放进度失败:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 页面即将卸载时保存播放进度
|
||||||
|
const handleBeforeUnload = () => {
|
||||||
|
saveCurrentPlayProgress();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 页面可见性变化时保存播放进度
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === 'hidden') {
|
||||||
|
saveCurrentPlayProgress();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加事件监听器
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// 清理事件监听器
|
||||||
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
};
|
||||||
|
}, [currentEpisodeIndex, detail, artPlayerRef.current]);
|
||||||
|
|
||||||
|
// 清理定时器
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (saveIntervalRef.current) {
|
||||||
|
clearInterval(saveIntervalRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 收藏相关
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 每当 source 或 id 变化时检查收藏状态
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentSource || !currentId) return;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const fav = await isFavorited(currentSource, currentId);
|
||||||
|
setFavorited(fav);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('检查收藏状态失败:', err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [currentSource, currentId]);
|
||||||
|
|
||||||
|
// 监听收藏数据更新事件
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentSource || !currentId) return;
|
||||||
|
|
||||||
|
const unsubscribe = subscribeToDataUpdates(
|
||||||
|
'favoritesUpdated',
|
||||||
|
(favorites: Record<string, any>) => {
|
||||||
|
const key = generateStorageKey(currentSource, currentId);
|
||||||
|
const isFav = !!favorites[key];
|
||||||
|
setFavorited(isFav);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, [currentSource, currentId]);
|
||||||
|
|
||||||
|
// 切换收藏
|
||||||
|
const handleToggleFavorite = async () => {
|
||||||
|
if (
|
||||||
|
!videoTitleRef.current ||
|
||||||
|
!detailRef.current ||
|
||||||
|
!currentSourceRef.current ||
|
||||||
|
!currentIdRef.current
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (favorited) {
|
||||||
|
// 如果已收藏,删除收藏
|
||||||
|
await deleteFavorite(currentSourceRef.current, currentIdRef.current);
|
||||||
|
setFavorited(false);
|
||||||
|
} else {
|
||||||
|
// 如果未收藏,添加收藏
|
||||||
|
await saveFavorite(currentSourceRef.current, currentIdRef.current, {
|
||||||
|
title: videoTitleRef.current,
|
||||||
|
source_name: detailRef.current?.source_name || '',
|
||||||
|
year: detailRef.current?.year,
|
||||||
|
cover: detailRef.current?.poster || '',
|
||||||
|
total_episodes: detailRef.current?.episodes.length || 1,
|
||||||
|
save_time: Date.now(),
|
||||||
|
search_title: searchTitle,
|
||||||
|
});
|
||||||
|
setFavorited(true);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('切换收藏失败:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!Artplayer ||
|
||||||
|
!Hls ||
|
||||||
|
!videoUrl ||
|
||||||
|
loading ||
|
||||||
|
currentEpisodeIndex === null ||
|
||||||
|
!artRef.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保选集索引有效
|
||||||
|
if (
|
||||||
|
!detail ||
|
||||||
|
!detail.episodes ||
|
||||||
|
currentEpisodeIndex >= detail.episodes.length ||
|
||||||
|
currentEpisodeIndex < 0
|
||||||
|
) {
|
||||||
|
setError(`选集索引无效,当前共 ${totalEpisodes} 集`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!videoUrl) {
|
||||||
|
setError('视频地址无效');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(videoUrl);
|
||||||
|
|
||||||
|
// 检测是否为WebKit浏览器
|
||||||
|
const isWebkit =
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
typeof (window as any).webkitConvertPointFromNodeToPage === 'function';
|
||||||
|
|
||||||
|
// 非WebKit浏览器且播放器已存在,使用switch方法切换
|
||||||
|
if (!isWebkit && artPlayerRef.current) {
|
||||||
|
artPlayerRef.current.switch = videoUrl;
|
||||||
|
artPlayerRef.current.title = `${videoTitle} - 第${
|
||||||
|
currentEpisodeIndex + 1
|
||||||
|
}集`;
|
||||||
|
artPlayerRef.current.poster = videoCover;
|
||||||
|
if (artPlayerRef.current?.video) {
|
||||||
|
ensureVideoSource(
|
||||||
|
artPlayerRef.current.video as HTMLVideoElement,
|
||||||
|
videoUrl
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebKit浏览器或首次创建:销毁之前的播放器实例并创建新的
|
||||||
|
if (artPlayerRef.current) {
|
||||||
|
if (artPlayerRef.current.video && artPlayerRef.current.video.hls) {
|
||||||
|
artPlayerRef.current.video.hls.destroy();
|
||||||
|
}
|
||||||
|
// 销毁播放器实例
|
||||||
|
artPlayerRef.current.destroy();
|
||||||
|
artPlayerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 创建新的播放器实例
|
||||||
|
Artplayer.PLAYBACK_RATE = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
|
||||||
|
Artplayer.USE_RAF = true;
|
||||||
|
|
||||||
|
artPlayerRef.current = new Artplayer({
|
||||||
|
container: artRef.current,
|
||||||
|
url: videoUrl,
|
||||||
|
poster: videoCover,
|
||||||
|
volume: 0.7,
|
||||||
|
isLive: false,
|
||||||
|
muted: false,
|
||||||
|
autoplay: true,
|
||||||
|
pip: true,
|
||||||
|
autoSize: false,
|
||||||
|
autoMini: false,
|
||||||
|
screenshot: false,
|
||||||
|
setting: true,
|
||||||
|
loop: false,
|
||||||
|
flip: false,
|
||||||
|
playbackRate: true,
|
||||||
|
aspectRatio: false,
|
||||||
|
fullscreen: true,
|
||||||
|
fullscreenWeb: true,
|
||||||
|
subtitleOffset: false,
|
||||||
|
miniProgressBar: false,
|
||||||
|
mutex: true,
|
||||||
|
playsInline: true,
|
||||||
|
autoPlayback: false,
|
||||||
|
airplay: true,
|
||||||
|
theme: '#22c55e',
|
||||||
|
lang: 'zh-cn',
|
||||||
|
hotkey: false,
|
||||||
|
fastForward: true,
|
||||||
|
autoOrientation: true,
|
||||||
|
lock: true,
|
||||||
|
moreVideoAttr: {
|
||||||
|
crossOrigin: 'anonymous',
|
||||||
|
},
|
||||||
|
// HLS 支持配置
|
||||||
|
customType: {
|
||||||
|
m3u8: function (video: HTMLVideoElement, url: string) {
|
||||||
|
if (!Hls) {
|
||||||
|
console.error('HLS.js 未加载');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (video.hls) {
|
||||||
|
video.hls.destroy();
|
||||||
|
}
|
||||||
|
const hls = new Hls({
|
||||||
|
debug: false, // 关闭日志
|
||||||
|
enableWorker: true, // WebWorker 解码,降低主线程压力
|
||||||
|
lowLatencyMode: true, // 开启低延迟 LL-HLS
|
||||||
|
|
||||||
|
/* 缓冲/内存相关 */
|
||||||
|
maxBufferLength: 30, // 前向缓冲最大 30s,过大容易导致高延迟
|
||||||
|
backBufferLength: 30, // 仅保留 30s 已播放内容,避免内存占用
|
||||||
|
maxBufferSize: 60 * 1000 * 1000, // 约 60MB,超出后触发清理
|
||||||
|
|
||||||
|
/* 自定义loader */
|
||||||
|
loader: blockAdEnabledRef.current
|
||||||
|
? CustomHlsJsLoader
|
||||||
|
: Hls.DefaultConfig.loader,
|
||||||
|
});
|
||||||
|
|
||||||
|
hls.loadSource(url);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
video.hls = hls;
|
||||||
|
|
||||||
|
ensureVideoSource(video, url);
|
||||||
|
|
||||||
|
hls.on(Hls.Events.ERROR, function (event: any, data: any) {
|
||||||
|
console.error('HLS Error:', event, data);
|
||||||
|
if (data.fatal) {
|
||||||
|
switch (data.type) {
|
||||||
|
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||||
|
console.log('网络错误,尝试恢复...');
|
||||||
|
hls.startLoad();
|
||||||
|
break;
|
||||||
|
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||||
|
console.log('媒体错误,尝试恢复...');
|
||||||
|
hls.recoverMediaError();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log('无法恢复的错误');
|
||||||
|
hls.destroy();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
icons: {
|
||||||
|
loading:
|
||||||
|
'<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MCIgaGVpZ2h0PSI1MCIgdmlld0JveD0iMCAwIDUwIDUwIj48cGF0aCBkPSJNMjUuMjUxIDYuNDYxYy0xMC4zMTggMC0xOC42ODMgOC4zNjUtMTguNjgzIDE4LjY4M2g0LjA2OGMwLTguMDcgNi41NDUtMTQuNjE1IDE0LjYxNS0xNC42MTVWNi40NjF6IiBmaWxsPSIjMDA5Njg4Ij48YW5pbWF0ZVRyYW5zZm9ybSBhdHRyaWJ1dGVOYW1lPSJ0cmFuc2Zvcm0iIGF0dHJpYnV0ZVR5cGU9IlhNTCIgZHVyPSIxcyIgZnJvbT0iMCAyNSAyNSIgcmVwZWF0Q291bnQ9ImluZGVmaW5pdGUiIHRvPSIzNjAgMjUgMjUiIHR5cGU9InJvdGF0ZSIvPjwvcGF0aD48L3N2Zz4=">',
|
||||||
|
},
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
html: '去广告',
|
||||||
|
icon: '<text x="50%" y="50%" font-size="20" font-weight="bold" text-anchor="middle" dominant-baseline="middle" fill="#ffffff">AD</text>',
|
||||||
|
tooltip: blockAdEnabled ? '已开启' : '已关闭',
|
||||||
|
onClick() {
|
||||||
|
const newVal = !blockAdEnabled;
|
||||||
|
try {
|
||||||
|
localStorage.setItem('enable_blockad', String(newVal));
|
||||||
|
if (artPlayerRef.current) {
|
||||||
|
resumeTimeRef.current = artPlayerRef.current.currentTime;
|
||||||
|
if (
|
||||||
|
artPlayerRef.current.video &&
|
||||||
|
artPlayerRef.current.video.hls
|
||||||
|
) {
|
||||||
|
artPlayerRef.current.video.hls.destroy();
|
||||||
|
}
|
||||||
|
artPlayerRef.current.destroy();
|
||||||
|
artPlayerRef.current = null;
|
||||||
|
}
|
||||||
|
setBlockAdEnabled(newVal);
|
||||||
|
} catch (_) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return newVal ? '当前开启' : '当前关闭';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// 控制栏配置
|
||||||
|
controls: [
|
||||||
|
{
|
||||||
|
position: 'left',
|
||||||
|
index: 13,
|
||||||
|
html: '<i class="art-icon flex"><svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" fill="currentColor"/></svg></i>',
|
||||||
|
tooltip: '播放下一集',
|
||||||
|
click: function () {
|
||||||
|
handleNextEpisode();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听播放器事件
|
||||||
|
artPlayerRef.current.on('ready', () => {
|
||||||
|
setError(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
artPlayerRef.current.on('video:volumechange', () => {
|
||||||
|
lastVolumeRef.current = artPlayerRef.current.volume;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听视频可播放事件,这时恢复播放进度更可靠
|
||||||
|
artPlayerRef.current.on('video:canplay', () => {
|
||||||
|
// 若存在需要恢复的播放进度,则跳转
|
||||||
|
if (resumeTimeRef.current && resumeTimeRef.current > 0) {
|
||||||
|
try {
|
||||||
|
const duration = artPlayerRef.current.duration || 0;
|
||||||
|
let target = resumeTimeRef.current;
|
||||||
|
if (duration && target >= duration - 2) {
|
||||||
|
target = Math.max(0, duration - 5);
|
||||||
|
}
|
||||||
|
artPlayerRef.current.currentTime = target;
|
||||||
|
console.log('成功恢复播放进度到:', resumeTimeRef.current);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('恢复播放进度失败:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resumeTimeRef.current = null;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (
|
||||||
|
Math.abs(artPlayerRef.current.volume - lastVolumeRef.current) > 0.01
|
||||||
|
) {
|
||||||
|
artPlayerRef.current.volume = lastVolumeRef.current;
|
||||||
|
}
|
||||||
|
artPlayerRef.current.notice.show = '';
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// 隐藏换源加载状态
|
||||||
|
setIsVideoLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
artPlayerRef.current.on('error', (err: any) => {
|
||||||
|
console.error('播放器错误:', err);
|
||||||
|
if (artPlayerRef.current.currentTime > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听视频播放结束事件,自动播放下一集
|
||||||
|
artPlayerRef.current.on('video:ended', () => {
|
||||||
|
const d = detailRef.current;
|
||||||
|
const idx = currentEpisodeIndexRef.current;
|
||||||
|
if (d && d.episodes && idx < d.episodes.length - 1) {
|
||||||
|
setTimeout(() => {
|
||||||
|
setCurrentEpisodeIndex(idx + 1);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
artPlayerRef.current.on('video:timeupdate', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
let interval = 5000;
|
||||||
|
if (process.env.NEXT_PUBLIC_STORAGE_TYPE === 'd1') {
|
||||||
|
interval = 10000;
|
||||||
|
}
|
||||||
|
if (process.env.NEXT_PUBLIC_STORAGE_TYPE === 'upstash') {
|
||||||
|
interval = 20000;
|
||||||
|
}
|
||||||
|
if (now - lastSaveTimeRef.current > interval) {
|
||||||
|
saveCurrentPlayProgress();
|
||||||
|
lastSaveTimeRef.current = now;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
artPlayerRef.current.on('pause', () => {
|
||||||
|
saveCurrentPlayProgress();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (artPlayerRef.current?.video) {
|
||||||
|
ensureVideoSource(
|
||||||
|
artPlayerRef.current.video as HTMLVideoElement,
|
||||||
|
videoUrl
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('创建播放器失败:', err);
|
||||||
|
setError('播放器初始化失败');
|
||||||
|
}
|
||||||
|
}, [Artplayer, Hls, videoUrl, loading, blockAdEnabled]);
|
||||||
|
|
||||||
|
// 当组件卸载时清理定时器
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (saveIntervalRef.current) {
|
||||||
|
clearInterval(saveIntervalRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<PageLayout activePath='/play'>
|
||||||
|
<div className='flex items-center justify-center min-h-screen bg-transparent'>
|
||||||
|
<div className='text-center max-w-md mx-auto px-6'>
|
||||||
|
{/* 动画影院图标 */}
|
||||||
|
<div className='relative mb-8'>
|
||||||
|
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-green-500 to-emerald-600 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
|
||||||
|
<div className='text-white text-4xl'>
|
||||||
|
{loadingStage === 'searching' && '🔍'}
|
||||||
|
{loadingStage === 'preferring' && '⚡'}
|
||||||
|
{loadingStage === 'fetching' && '🎬'}
|
||||||
|
{loadingStage === 'ready' && '✨'}
|
||||||
|
</div>
|
||||||
|
{/* 旋转光环 */}
|
||||||
|
<div className='absolute -inset-2 bg-gradient-to-r from-green-500 to-emerald-600 rounded-2xl opacity-20 animate-spin'></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 浮动粒子效果 */}
|
||||||
|
<div className='absolute top-0 left-0 w-full h-full pointer-events-none'>
|
||||||
|
<div className='absolute top-2 left-2 w-2 h-2 bg-green-400 rounded-full animate-bounce'></div>
|
||||||
|
<div
|
||||||
|
className='absolute top-4 right-4 w-1.5 h-1.5 bg-emerald-400 rounded-full animate-bounce'
|
||||||
|
style={{ animationDelay: '0.5s' }}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
className='absolute bottom-3 left-6 w-1 h-1 bg-lime-400 rounded-full animate-bounce'
|
||||||
|
style={{ animationDelay: '1s' }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 进度指示器 */}
|
||||||
|
<div className='mb-6 w-80 mx-auto'>
|
||||||
|
<div className='flex justify-center space-x-2 mb-4'>
|
||||||
|
<div
|
||||||
|
className={`w-3 h-3 rounded-full transition-all duration-500 ${
|
||||||
|
loadingStage === 'searching' || loadingStage === 'fetching'
|
||||||
|
? 'bg-green-500 scale-125'
|
||||||
|
: loadingStage === 'preferring' ||
|
||||||
|
loadingStage === 'ready'
|
||||||
|
? 'bg-green-500'
|
||||||
|
: 'bg-gray-300'
|
||||||
|
}`}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
className={`w-3 h-3 rounded-full transition-all duration-500 ${
|
||||||
|
loadingStage === 'preferring'
|
||||||
|
? 'bg-green-500 scale-125'
|
||||||
|
: loadingStage === 'ready'
|
||||||
|
? 'bg-green-500'
|
||||||
|
: 'bg-gray-300'
|
||||||
|
}`}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
className={`w-3 h-3 rounded-full transition-all duration-500 ${
|
||||||
|
loadingStage === 'ready'
|
||||||
|
? 'bg-green-500 scale-125'
|
||||||
|
: 'bg-gray-300'
|
||||||
|
}`}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 进度条 */}
|
||||||
|
<div className='w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden'>
|
||||||
|
<div
|
||||||
|
className='h-full bg-gradient-to-r from-green-500 to-emerald-600 rounded-full transition-all duration-1000 ease-out'
|
||||||
|
style={{
|
||||||
|
width:
|
||||||
|
loadingStage === 'searching' ||
|
||||||
|
loadingStage === 'fetching'
|
||||||
|
? '33%'
|
||||||
|
: loadingStage === 'preferring'
|
||||||
|
? '66%'
|
||||||
|
: '100%',
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 加载消息 */}
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<p className='text-xl font-semibold text-gray-800 dark:text-gray-200 animate-pulse'>
|
||||||
|
{loadingMessage}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<PageLayout activePath='/play'>
|
||||||
|
<div className='flex items-center justify-center min-h-screen bg-transparent'>
|
||||||
|
<div className='text-center max-w-md mx-auto px-6'>
|
||||||
|
{/* 错误图标 */}
|
||||||
|
<div className='relative mb-8'>
|
||||||
|
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-red-500 to-orange-500 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
|
||||||
|
<div className='text-white text-4xl'>😵</div>
|
||||||
|
{/* 脉冲效果 */}
|
||||||
|
<div className='absolute -inset-2 bg-gradient-to-r from-red-500 to-orange-500 rounded-2xl opacity-20 animate-pulse'></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 浮动错误粒子 */}
|
||||||
|
<div className='absolute top-0 left-0 w-full h-full pointer-events-none'>
|
||||||
|
<div className='absolute top-2 left-2 w-2 h-2 bg-red-400 rounded-full animate-bounce'></div>
|
||||||
|
<div
|
||||||
|
className='absolute top-4 right-4 w-1.5 h-1.5 bg-orange-400 rounded-full animate-bounce'
|
||||||
|
style={{ animationDelay: '0.5s' }}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
className='absolute bottom-3 left-6 w-1 h-1 bg-yellow-400 rounded-full animate-bounce'
|
||||||
|
style={{ animationDelay: '1s' }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 错误信息 */}
|
||||||
|
<div className='space-y-4 mb-8'>
|
||||||
|
<h2 className='text-2xl font-bold text-gray-800 dark:text-gray-200'>
|
||||||
|
哎呀,出现了一些问题
|
||||||
|
</h2>
|
||||||
|
<div className='bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4'>
|
||||||
|
<p className='text-red-600 dark:text-red-400 font-medium'>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className='text-sm text-gray-500 dark:text-gray-400'>
|
||||||
|
请检查网络连接或尝试刷新页面
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className='space-y-3'>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
videoTitle
|
||||||
|
? router.push(`/search?q=${encodeURIComponent(videoTitle)}`)
|
||||||
|
: router.back()
|
||||||
|
}
|
||||||
|
className='w-full px-6 py-3 bg-gradient-to-r from-green-500 to-emerald-600 text-white rounded-xl font-medium hover:from-green-600 hover:to-emerald-700 transform hover:scale-105 transition-all duration-200 shadow-lg hover:shadow-xl'
|
||||||
|
>
|
||||||
|
{videoTitle ? '🔍 返回搜索' : '← 返回上页'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className='w-full px-6 py-3 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-xl font-medium hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors duration-200'
|
||||||
|
>
|
||||||
|
🔄 重新尝试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout activePath='/play'>
|
||||||
|
<div className='flex flex-col gap-3 py-4 px-5 lg:px-[3rem] 2xl:px-20'>
|
||||||
|
{/* 第一行:影片标题 */}
|
||||||
|
<div className='py-1'>
|
||||||
|
<h1 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
||||||
|
{videoTitle || '影片标题'}
|
||||||
|
{totalEpisodes > 1 && (
|
||||||
|
<span className='text-gray-500 dark:text-gray-400'>
|
||||||
|
{` > 第 ${currentEpisodeIndex + 1} 集`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
{/* 第二行:播放器和选集 */}
|
||||||
|
<div className='space-y-2'>
|
||||||
|
{/* 折叠控制 - 仅在 lg 及以上屏幕显示 */}
|
||||||
|
<div className='hidden lg:flex justify-end'>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setIsEpisodeSelectorCollapsed(!isEpisodeSelectorCollapsed)
|
||||||
|
}
|
||||||
|
className='group relative flex items-center space-x-1.5 px-3 py-1.5 rounded-full bg-white/80 hover:bg-white dark:bg-gray-800/80 dark:hover:bg-gray-800 backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50 shadow-sm hover:shadow-md transition-all duration-200'
|
||||||
|
title={
|
||||||
|
isEpisodeSelectorCollapsed ? '显示选集面板' : '隐藏选集面板'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`w-3.5 h-3.5 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${
|
||||||
|
isEpisodeSelectorCollapsed ? 'rotate-180' : 'rotate-0'
|
||||||
|
}`}
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
strokeWidth='2'
|
||||||
|
d='M9 5l7 7-7 7'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className='text-xs font-medium text-gray-600 dark:text-gray-300'>
|
||||||
|
{isEpisodeSelectorCollapsed ? '显示' : '隐藏'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* 精致的状态指示点 */}
|
||||||
|
<div
|
||||||
|
className={`absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full transition-all duration-200 ${
|
||||||
|
isEpisodeSelectorCollapsed
|
||||||
|
? 'bg-orange-400 animate-pulse'
|
||||||
|
: 'bg-green-400'
|
||||||
|
}`}
|
||||||
|
></div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`grid gap-4 lg:h-[500px] xl:h-[650px] 2xl:h-[750px] transition-all duration-300 ease-in-out ${
|
||||||
|
isEpisodeSelectorCollapsed
|
||||||
|
? 'grid-cols-1'
|
||||||
|
: 'grid-cols-1 md:grid-cols-4'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* 播放器 */}
|
||||||
|
<div
|
||||||
|
className={`h-full transition-all duration-300 ease-in-out rounded-xl border border-white/0 dark:border-white/30 ${
|
||||||
|
isEpisodeSelectorCollapsed ? 'col-span-1' : 'md:col-span-3'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className='relative w-full h-[300px] lg:h-full'>
|
||||||
|
<div
|
||||||
|
ref={artRef}
|
||||||
|
className='bg-black w-full h-full rounded-xl overflow-hidden shadow-lg'
|
||||||
|
></div>
|
||||||
|
|
||||||
|
{/* 换源加载蒙层 */}
|
||||||
|
{isVideoLoading && (
|
||||||
|
<div className='absolute inset-0 bg-black/85 backdrop-blur-sm rounded-xl flex items-center justify-center z-[500] transition-all duration-300'>
|
||||||
|
<div className='text-center max-w-md mx-auto px-6'>
|
||||||
|
{/* 动画影院图标 */}
|
||||||
|
<div className='relative mb-8'>
|
||||||
|
<div className='relative mx-auto w-24 h-24 bg-gradient-to-r from-green-500 to-emerald-600 rounded-2xl shadow-2xl flex items-center justify-center transform hover:scale-105 transition-transform duration-300'>
|
||||||
|
<div className='text-white text-4xl'>🎬</div>
|
||||||
|
{/* 旋转光环 */}
|
||||||
|
<div className='absolute -inset-2 bg-gradient-to-r from-green-500 to-emerald-600 rounded-2xl opacity-20 animate-spin'></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 浮动粒子效果 */}
|
||||||
|
<div className='absolute top-0 left-0 w-full h-full pointer-events-none'>
|
||||||
|
<div className='absolute top-2 left-2 w-2 h-2 bg-green-400 rounded-full animate-bounce'></div>
|
||||||
|
<div
|
||||||
|
className='absolute top-4 right-4 w-1.5 h-1.5 bg-emerald-400 rounded-full animate-bounce'
|
||||||
|
style={{ animationDelay: '0.5s' }}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
className='absolute bottom-3 left-6 w-1 h-1 bg-lime-400 rounded-full animate-bounce'
|
||||||
|
style={{ animationDelay: '1s' }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 换源消息 */}
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<p className='text-xl font-semibold text-white animate-pulse'>
|
||||||
|
{videoLoadingStage === 'sourceChanging'
|
||||||
|
? '🔄 切换播放源...'
|
||||||
|
: '🔄 视频加载中...'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 选集和换源 - 在移动端始终显示,在 lg 及以上可折叠 */}
|
||||||
|
<div
|
||||||
|
className={`h-[300px] lg:h-full md:overflow-hidden transition-all duration-300 ease-in-out ${
|
||||||
|
isEpisodeSelectorCollapsed
|
||||||
|
? 'md:col-span-1 lg:hidden lg:opacity-0 lg:scale-95'
|
||||||
|
: 'md:col-span-1 lg:opacity-100 lg:scale-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<EpisodeSelector
|
||||||
|
totalEpisodes={totalEpisodes}
|
||||||
|
value={currentEpisodeIndex + 1}
|
||||||
|
onChange={handleEpisodeChange}
|
||||||
|
onSourceChange={handleSourceChange}
|
||||||
|
currentSource={currentSource}
|
||||||
|
currentId={currentId}
|
||||||
|
videoTitle={searchTitle || videoTitle}
|
||||||
|
availableSources={availableSources}
|
||||||
|
sourceSearchLoading={sourceSearchLoading}
|
||||||
|
sourceSearchError={sourceSearchError}
|
||||||
|
precomputedVideoInfo={precomputedVideoInfo}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 详情展示 */}
|
||||||
|
<div className='grid grid-cols-1 md:grid-cols-4 gap-4'>
|
||||||
|
{/* 文字区 */}
|
||||||
|
<div className='md:col-span-3'>
|
||||||
|
<div className='p-6 flex flex-col min-h-0'>
|
||||||
|
{/* 标题 */}
|
||||||
|
<h1 className='text-3xl font-bold mb-2 tracking-wide flex items-center flex-shrink-0 text-center md:text-left w-full'>
|
||||||
|
{videoTitle || '影片标题'}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleToggleFavorite();
|
||||||
|
}}
|
||||||
|
className='ml-3 flex-shrink-0 hover:opacity-80 transition-opacity'
|
||||||
|
>
|
||||||
|
<FavoriteIcon filled={favorited} />
|
||||||
|
</button>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* 关键信息行 */}
|
||||||
|
<div className='flex flex-wrap items-center gap-3 text-base mb-4 opacity-80 flex-shrink-0'>
|
||||||
|
{detail?.class && (
|
||||||
|
<span className='text-green-600 font-semibold'>
|
||||||
|
{detail.class}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(detail?.year || videoYear) && (
|
||||||
|
<span>{detail?.year || videoYear}</span>
|
||||||
|
)}
|
||||||
|
{detail?.source_name && (
|
||||||
|
<span className='border border-gray-500/60 px-2 py-[1px] rounded'>
|
||||||
|
{detail.source_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{detail?.type_name && <span>{detail.type_name}</span>}
|
||||||
|
</div>
|
||||||
|
{/* 剧情简介 */}
|
||||||
|
{detail?.desc && (
|
||||||
|
<div
|
||||||
|
className='mt-0 text-base leading-relaxed opacity-90 overflow-y-auto pr-2 flex-1 min-h-0 scrollbar-hide'
|
||||||
|
style={{ whiteSpace: 'pre-line' }}
|
||||||
|
>
|
||||||
|
{detail.desc}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 封面展示 */}
|
||||||
|
<div className='hidden md:block md:col-span-1 md:order-first'>
|
||||||
|
<div className='pl-0 py-4 pr-6'>
|
||||||
|
<div className='bg-gray-300 dark:bg-gray-700 aspect-[2/3] flex items-center justify-center rounded-xl overflow-hidden'>
|
||||||
|
{videoCover ? (
|
||||||
|
<img
|
||||||
|
src={processImageUrl(videoCover)}
|
||||||
|
alt={videoTitle}
|
||||||
|
className='w-full h-full object-cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className='text-gray-600 dark:text-gray-400'>
|
||||||
|
封面图片
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// FavoriteIcon 组件
|
||||||
|
const FavoriteIcon = ({ filled }: { filled: boolean }) => {
|
||||||
|
if (filled) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className='h-7 w-7'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d='M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z'
|
||||||
|
fill='#ef4444' /* Tailwind red-500 */
|
||||||
|
stroke='#ef4444'
|
||||||
|
strokeWidth='2'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Heart className='h-7 w-7 stroke-[1] text-gray-600 dark:text-gray-300' />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PlayPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
|
<PlayPageClient />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
/* eslint-disable react-hooks/exhaustive-deps, @typescript-eslint/no-explicit-any */
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ChevronUp, Search, X } from 'lucide-react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { Suspense, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
addSearchHistory,
|
||||||
|
clearSearchHistory,
|
||||||
|
deleteSearchHistory,
|
||||||
|
getSearchHistory,
|
||||||
|
subscribeToDataUpdates,
|
||||||
|
} from '@/lib/db.client';
|
||||||
|
import { SearchResult } from '@/lib/types';
|
||||||
|
|
||||||
|
import PageLayout from '@/components/PageLayout';
|
||||||
|
import VideoCard from '@/components/VideoCard';
|
||||||
|
|
||||||
|
function SearchPageClient() {
|
||||||
|
// 搜索历史
|
||||||
|
const [searchHistory, setSearchHistory] = useState<string[]>([]);
|
||||||
|
// 返回顶部按钮显示状态
|
||||||
|
const [showBackToTop, setShowBackToTop] = useState(false);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [showResults, setShowResults] = useState(false);
|
||||||
|
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||||
|
|
||||||
|
// 获取默认聚合设置:只读取用户本地设置,默认为 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 aggregatedResults = useMemo(() => {
|
||||||
|
const map = new Map<string, SearchResult[]>();
|
||||||
|
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) || [];
|
||||||
|
arr.push(item);
|
||||||
|
map.set(key, arr);
|
||||||
|
});
|
||||||
|
return Array.from(map.entries()).sort((a, b) => {
|
||||||
|
// 优先排序:标题与搜索词完全一致的排在前面
|
||||||
|
const aExactMatch = a[1][0].title
|
||||||
|
.replaceAll(' ', '')
|
||||||
|
.includes(searchQuery.trim().replaceAll(' ', ''));
|
||||||
|
const bExactMatch = b[1][0].title
|
||||||
|
.replaceAll(' ', '')
|
||||||
|
.includes(searchQuery.trim().replaceAll(' ', ''));
|
||||||
|
|
||||||
|
if (aExactMatch && !bExactMatch) return -1;
|
||||||
|
if (!aExactMatch && bExactMatch) return 1;
|
||||||
|
|
||||||
|
// 年份排序
|
||||||
|
if (a[1][0].year === b[1][0].year) {
|
||||||
|
return a[0].localeCompare(b[0]);
|
||||||
|
} else {
|
||||||
|
// 处理 unknown 的情况
|
||||||
|
const aYear = a[1][0].year;
|
||||||
|
const bYear = b[1][0].year;
|
||||||
|
|
||||||
|
if (aYear === 'unknown' && bYear === 'unknown') {
|
||||||
|
return 0;
|
||||||
|
} else if (aYear === 'unknown') {
|
||||||
|
return 1; // a 排在后面
|
||||||
|
} else if (bYear === 'unknown') {
|
||||||
|
return -1; // b 排在后面
|
||||||
|
} else {
|
||||||
|
// 都是数字年份,按数字大小排序(大的在前面)
|
||||||
|
return aYear > bYear ? -1 : 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [searchResults]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 无搜索参数时聚焦搜索框
|
||||||
|
!searchParams.get('q') && document.getElementById('searchInput')?.focus();
|
||||||
|
|
||||||
|
// 初始加载搜索历史
|
||||||
|
getSearchHistory().then(setSearchHistory);
|
||||||
|
|
||||||
|
// 监听搜索历史更新事件
|
||||||
|
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');
|
||||||
|
if (query) {
|
||||||
|
setSearchQuery(query);
|
||||||
|
fetchSearchResults(query);
|
||||||
|
|
||||||
|
// 保存到搜索历史 (事件监听会自动更新界面)
|
||||||
|
addSearchHistory(query);
|
||||||
|
} else {
|
||||||
|
setShowResults(false);
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const fetchSearchResults = async (query: string) => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/search?q=${encodeURIComponent(query.trim())}`
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
setSearchResults(
|
||||||
|
data.results.sort((a: SearchResult, b: SearchResult) => {
|
||||||
|
// 优先排序:标题与搜索词完全一致的排在前面
|
||||||
|
const aExactMatch = a.title === query.trim();
|
||||||
|
const bExactMatch = b.title === query.trim();
|
||||||
|
|
||||||
|
if (aExactMatch && !bExactMatch) return -1;
|
||||||
|
if (!aExactMatch && bExactMatch) return 1;
|
||||||
|
|
||||||
|
// 如果都匹配或都不匹配,则按原来的逻辑排序
|
||||||
|
if (a.year === b.year) {
|
||||||
|
return a.title.localeCompare(b.title);
|
||||||
|
} else {
|
||||||
|
// 处理 unknown 的情况
|
||||||
|
if (a.year === 'unknown' && b.year === 'unknown') {
|
||||||
|
return 0;
|
||||||
|
} else if (a.year === 'unknown') {
|
||||||
|
return 1; // a 排在后面
|
||||||
|
} else if (b.year === 'unknown') {
|
||||||
|
return -1; // b 排在后面
|
||||||
|
} else {
|
||||||
|
// 都是数字年份,按数字大小排序(大的在前面)
|
||||||
|
return parseInt(a.year) > parseInt(b.year) ? -1 : 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
setShowResults(true);
|
||||||
|
} catch (error) {
|
||||||
|
setSearchResults([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const trimmed = searchQuery.trim().replace(/\s+/g, ' ');
|
||||||
|
if (!trimmed) return;
|
||||||
|
|
||||||
|
// 回显搜索框
|
||||||
|
setSearchQuery(trimmed);
|
||||||
|
setIsLoading(true);
|
||||||
|
setShowResults(true);
|
||||||
|
|
||||||
|
router.push(`/search?q=${encodeURIComponent(trimmed)}`);
|
||||||
|
// 直接发请求
|
||||||
|
fetchSearchResults(trimmed);
|
||||||
|
|
||||||
|
// 保存到搜索历史 (事件监听会自动更新界面)
|
||||||
|
addSearchHistory(trimmed);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 返回顶部功能
|
||||||
|
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={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder='搜索电影、电视剧...'
|
||||||
|
className='w-full h-12 rounded-lg bg-gray-50/80 py-3 pl-10 pr-4 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'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 搜索结果或搜索历史 */}
|
||||||
|
<div className='max-w-[95%] mx-auto mt-12 overflow-visible'>
|
||||||
|
{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>
|
||||||
|
) : showResults ? (
|
||||||
|
<section className='mb-12'>
|
||||||
|
{/* 标题 + 聚合开关 */}
|
||||||
|
<div className='mb-8 flex items-center justify-between'>
|
||||||
|
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||||
|
搜索结果
|
||||||
|
</h2>
|
||||||
|
{/* 聚合开关 */}
|
||||||
|
<label className='flex items-center gap-2 cursor-pointer select-none'>
|
||||||
|
<span className='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>
|
||||||
|
<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'
|
||||||
|
? aggregatedResults.map(([mapKey, group]) => {
|
||||||
|
return (
|
||||||
|
<div key={`agg-${mapKey}`} className='w-full'>
|
||||||
|
<VideoCard
|
||||||
|
from='search'
|
||||||
|
items={group}
|
||||||
|
query={
|
||||||
|
searchQuery.trim() !== group[0].title
|
||||||
|
? searchQuery.trim()
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: searchResults.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?.toString()}
|
||||||
|
query={
|
||||||
|
searchQuery.trim() !== item.title
|
||||||
|
? searchQuery.trim()
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
year={item.year}
|
||||||
|
from='search'
|
||||||
|
type={item.episodes.length > 1 ? 'tv' : 'movie'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{searchResults.length === 0 && (
|
||||||
|
<div className='col-span-full text-center text-gray-500 py-8 dark:text-gray-400'>
|
||||||
|
未找到相关结果
|
||||||
|
</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,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,330 @@
|
|||||||
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
interface SelectorOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DoubanSelectorProps {
|
||||||
|
type: 'movie' | 'tv' | 'show';
|
||||||
|
primarySelection?: string;
|
||||||
|
secondarySelection?: string;
|
||||||
|
onPrimaryChange: (value: string) => void;
|
||||||
|
onSecondaryChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DoubanSelector: React.FC<DoubanSelectorProps> = ({
|
||||||
|
type,
|
||||||
|
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 });
|
||||||
|
|
||||||
|
// 电影的一级选择器选项
|
||||||
|
const moviePrimaryOptions: SelectorOption[] = [
|
||||||
|
{ label: '热门电影', value: '热门' },
|
||||||
|
{ label: '最新电影', value: '最新' },
|
||||||
|
{ label: '豆瓣高分', value: '豆瓣高分' },
|
||||||
|
{ label: '冷门佳片', value: '冷门佳片' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 电影的二级选择器选项
|
||||||
|
const movieSecondaryOptions: SelectorOption[] = [
|
||||||
|
{ label: '全部', value: '全部' },
|
||||||
|
{ label: '华语', value: '华语' },
|
||||||
|
{ label: '欧美', value: '欧美' },
|
||||||
|
{ label: '韩国', value: '韩国' },
|
||||||
|
{ label: '日本', value: '日本' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 电视剧选择器选项
|
||||||
|
const tvOptions: 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 showOptions: SelectorOption[] = [
|
||||||
|
{ label: '全部', value: 'show' },
|
||||||
|
{ label: '国内', value: 'show_domestic' },
|
||||||
|
{ label: '国外', value: 'show_foreign' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 更新指示器位置的通用函数
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 副选择器初始位置
|
||||||
|
let secondaryActiveIndex = -1;
|
||||||
|
if (type === 'movie') {
|
||||||
|
secondaryActiveIndex = movieSecondaryOptions.findIndex(
|
||||||
|
(opt) =>
|
||||||
|
opt.value === (secondarySelection || movieSecondaryOptions[0].value)
|
||||||
|
);
|
||||||
|
} else if (type === 'tv') {
|
||||||
|
secondaryActiveIndex = tvOptions.findIndex(
|
||||||
|
(opt) => opt.value === (secondarySelection || tvOptions[0].value)
|
||||||
|
);
|
||||||
|
} else if (type === 'show') {
|
||||||
|
secondaryActiveIndex = showOptions.findIndex(
|
||||||
|
(opt) => opt.value === (secondarySelection || showOptions[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;
|
||||||
|
}
|
||||||
|
}, [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 = tvOptions.findIndex(
|
||||||
|
(opt) => opt.value === secondarySelection
|
||||||
|
);
|
||||||
|
options = tvOptions;
|
||||||
|
} else if (type === 'show') {
|
||||||
|
activeIndex = showOptions.findIndex(
|
||||||
|
(opt) => opt.value === secondarySelection
|
||||||
|
);
|
||||||
|
options = showOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* 二级选择器 */}
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 电视剧类型 - 只显示一级选择器 */}
|
||||||
|
{type === 'tv' && (
|
||||||
|
<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(
|
||||||
|
tvOptions,
|
||||||
|
secondarySelection || tvOptions[0].value,
|
||||||
|
onSecondaryChange,
|
||||||
|
false
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 综艺类型 - 只显示一级选择器 */}
|
||||||
|
{type === 'show' && (
|
||||||
|
<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(
|
||||||
|
showOptions,
|
||||||
|
secondarySelection || showOptions[0].value,
|
||||||
|
onSecondaryChange,
|
||||||
|
false
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DoubanSelector;
|
||||||
@@ -0,0 +1,602 @@
|
|||||||
|
/* 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;
|
||||||
|
/** 每页显示多少集,默认 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,
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 获取视频信息的函数 - 移除 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]);
|
||||||
|
|
||||||
|
// 分页标签始终保持升序
|
||||||
|
const categories = categoriesAsc;
|
||||||
|
|
||||||
|
const categoryContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||||
|
|
||||||
|
// 当分页切换时,将激活的分页标签滚动到视口中间
|
||||||
|
useEffect(() => {
|
||||||
|
const btn = buttonRefs.current[currentPage];
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [currentPage, pageCount]);
|
||||||
|
|
||||||
|
// 处理换源tab点击,只在点击时才搜索
|
||||||
|
const handleSourceTabClick = () => {
|
||||||
|
setActiveTab('sources');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCategoryClick = useCallback((index: number) => {
|
||||||
|
setCurrentPage(index);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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}>
|
||||||
|
<div className='flex gap-2 min-w-max'>
|
||||||
|
{categories.map((label, idx) => {
|
||||||
|
const isActive = idx === currentPage;
|
||||||
|
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='grid grid-cols-[repeat(auto-fill,minmax(40px,1fr))] auto-rows-[40px] gap-x-3 gap-y-3 overflow-y-auto h-full 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 flex items-center justify-center text-sm font-medium rounded-md transition-all duration-200
|
||||||
|
${
|
||||||
|
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()}
|
||||||
|
>
|
||||||
|
{episodeNumber}
|
||||||
|
</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,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,109 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Clover, Film, Home, Search, Tv } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
interface MobileBottomNavProps {
|
||||||
|
/**
|
||||||
|
* 主动指定当前激活的路径。当未提供时,自动使用 usePathname() 获取的路径。
|
||||||
|
*/
|
||||||
|
activePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
// 当前激活路径:优先使用传入的 activePath,否则回退到浏览器地址
|
||||||
|
const currentActive = activePath ?? pathname;
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ icon: Home, label: '首页', href: '/' },
|
||||||
|
{ icon: Search, label: '搜索', href: '/search' },
|
||||||
|
{
|
||||||
|
icon: Film,
|
||||||
|
label: '电影',
|
||||||
|
href: '/douban?type=movie',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Tv,
|
||||||
|
label: '剧集',
|
||||||
|
href: '/douban?type=tv',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Clover,
|
||||||
|
label: '综艺',
|
||||||
|
href: '/douban?type=show',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const isActive = (href: string) => {
|
||||||
|
const typeMatch = href.match(/type=([^&]+)/)?.[1];
|
||||||
|
|
||||||
|
// 解码URL以进行正确的比较
|
||||||
|
const decodedActive = decodeURIComponent(currentActive);
|
||||||
|
const decodedItemHref = decodeURIComponent(href);
|
||||||
|
|
||||||
|
return (
|
||||||
|
decodedActive === decodedItemHref ||
|
||||||
|
(decodedActive.startsWith('/douban') &&
|
||||||
|
decodedActive.includes(`type=${typeMatch}`))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
className='md:hidden fixed left-0 right-0 z-[600] bg-white/90 backdrop-blur-xl border-t border-purple-200/50 overflow-hidden dark:bg-gray-900/80 dark:border-purple-700/50 shadow-lg'
|
||||||
|
style={{
|
||||||
|
/* 紧贴视口底部,同时在内部留出安全区高度 */
|
||||||
|
bottom: 0,
|
||||||
|
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 顶部装饰线 */}
|
||||||
|
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-transparent via-purple-500/50 to-transparent"></div>
|
||||||
|
|
||||||
|
<ul className='flex items-center'>
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const active = isActive(item.href);
|
||||||
|
return (
|
||||||
|
<li key={item.href} className='flex-shrink-0 w-1/5'>
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
className={`flex flex-col items-center justify-center w-full h-14 gap-1 text-xs transition-all duration-200 relative ${
|
||||||
|
active
|
||||||
|
? 'transform -translate-y-1'
|
||||||
|
: 'hover:transform hover:-translate-y-0.5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* 激活状态的背景光晕 */}
|
||||||
|
{active && (
|
||||||
|
<div className="absolute inset-0 bg-purple-500/10 rounded-lg mx-2 my-1 border border-purple-300/20"></div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<item.icon
|
||||||
|
className={`h-6 w-6 transition-all duration-200 ${
|
||||||
|
active
|
||||||
|
? 'text-purple-600 dark:text-purple-400 scale-110'
|
||||||
|
: 'text-gray-500 dark:text-gray-400 hover:text-purple-500 dark:hover:text-purple-300'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`transition-all duration-200 font-medium ${
|
||||||
|
active
|
||||||
|
? 'text-purple-600 dark:text-purple-400'
|
||||||
|
: 'text-gray-600 dark:text-gray-300 hover:text-purple-500 dark:hover:text-purple-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MobileBottomNav;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { BackButton } from './BackButton';
|
||||||
|
import { useSite } from './SiteProvider';
|
||||||
|
import { ThemeToggle } from './ThemeToggle';
|
||||||
|
import { UserMenu } from './UserMenu';
|
||||||
|
|
||||||
|
interface MobileHeaderProps {
|
||||||
|
showBackButton?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
|
||||||
|
const { siteName } = useSite();
|
||||||
|
return (
|
||||||
|
<header className='md:hidden relative w-full bg-white/70 backdrop-blur-xl border-b border-purple-200/50 shadow-sm dark:bg-gray-900/70 dark:border-purple-700/50'>
|
||||||
|
<div className='h-12 flex items-center justify-between px-4'>
|
||||||
|
{/* 左侧:返回按钮和设置按钮 */}
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
{showBackButton && <BackButton />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧按钮 */}
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<ThemeToggle />
|
||||||
|
<UserMenu />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 中间:Logo(绝对居中)- 应用彩虹渐变效果 */}
|
||||||
|
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'>
|
||||||
|
<Link
|
||||||
|
href='/'
|
||||||
|
className='text-2xl font-bold katelya-logo tracking-tight hover:opacity-80 transition-opacity'
|
||||||
|
>
|
||||||
|
{siteName}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MobileHeader;
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { Clover, Film, Home, Search, Tv } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { BackButton } from './BackButton';
|
||||||
|
import MobileBottomNav from './MobileBottomNav';
|
||||||
|
import MobileHeader from './MobileHeader';
|
||||||
|
import { useSite } from './SiteProvider';
|
||||||
|
import { ThemeToggle } from './ThemeToggle';
|
||||||
|
import { UserMenu } from './UserMenu';
|
||||||
|
|
||||||
|
interface PageLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
activePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内联顶部导航栏组件
|
||||||
|
const TopNavbar = ({ activePath = '/' }: { activePath?: string }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { siteName } = useSite();
|
||||||
|
|
||||||
|
const [active, setActive] = useState(activePath);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 优先使用传入的 activePath
|
||||||
|
if (activePath) {
|
||||||
|
setActive(activePath);
|
||||||
|
} else {
|
||||||
|
// 否则使用当前路径
|
||||||
|
const getCurrentFullPath = () => {
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
return queryString ? `${pathname}?${queryString}` : pathname;
|
||||||
|
};
|
||||||
|
const fullPath = getCurrentFullPath();
|
||||||
|
setActive(fullPath);
|
||||||
|
}
|
||||||
|
}, [activePath, pathname, searchParams]);
|
||||||
|
|
||||||
|
const handleSearchClick = useCallback(() => {
|
||||||
|
router.push('/search');
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{
|
||||||
|
icon: Home,
|
||||||
|
label: '首页',
|
||||||
|
href: '/',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Search,
|
||||||
|
label: '搜索',
|
||||||
|
href: '/search',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Film,
|
||||||
|
label: '电影',
|
||||||
|
href: '/douban?type=movie',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Tv,
|
||||||
|
label: '剧集',
|
||||||
|
href: '/douban?type=tv',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Clover,
|
||||||
|
label: '综艺',
|
||||||
|
href: '/douban?type=show',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className='w-full bg-white/40 backdrop-blur-xl border-b border-purple-200/50 shadow-lg dark:bg-gray-900/70 dark:border-purple-700/50 sticky top-0 z-50'>
|
||||||
|
<div className='w-full px-8 lg:px-12 xl:px-16'>
|
||||||
|
<div className='flex items-center justify-between h-16'>
|
||||||
|
{/* Logo区域 - 调整为更靠左 */}
|
||||||
|
<div className='flex-shrink-0 -ml-2'>
|
||||||
|
<Link
|
||||||
|
href='/'
|
||||||
|
className='flex items-center select-none hover:opacity-80 transition-opacity duration-200'
|
||||||
|
>
|
||||||
|
<span className='text-2xl font-bold katelya-logo tracking-tight'>
|
||||||
|
{siteName}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 导航菜单 */}
|
||||||
|
<div className='hidden md:block'>
|
||||||
|
<div className='ml-10 flex items-baseline space-x-4'>
|
||||||
|
{menuItems.map((item) => {
|
||||||
|
// 检查当前路径是否匹配这个菜单项
|
||||||
|
const typeMatch = item.href.match(/type=([^&]+)/)?.[1];
|
||||||
|
const tagMatch = item.href.match(/tag=([^&]+)/)?.[1];
|
||||||
|
|
||||||
|
// 解码URL以进行正确的比较
|
||||||
|
const decodedActive = decodeURIComponent(active);
|
||||||
|
const decodedItemHref = decodeURIComponent(item.href);
|
||||||
|
|
||||||
|
const isActive =
|
||||||
|
decodedActive === decodedItemHref ||
|
||||||
|
(decodedActive.startsWith('/douban') &&
|
||||||
|
decodedActive.includes(`type=${typeMatch}`) &&
|
||||||
|
tagMatch &&
|
||||||
|
decodedActive.includes(`tag=${tagMatch}`));
|
||||||
|
|
||||||
|
const Icon = item.icon;
|
||||||
|
|
||||||
|
if (item.href === '/search') {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.label}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSearchClick();
|
||||||
|
setActive('/search');
|
||||||
|
}}
|
||||||
|
data-active={isActive}
|
||||||
|
className={`group flex items-center rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-200 ${
|
||||||
|
isActive
|
||||||
|
? 'bg-purple-500/20 text-purple-700 dark:bg-purple-500/10 dark:text-purple-400'
|
||||||
|
: 'text-gray-700 hover:bg-purple-100/30 hover:text-purple-600 dark:text-gray-300 dark:hover:text-purple-400 dark:hover:bg-purple-500/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className='h-4 w-4 mr-2' />
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.label}
|
||||||
|
href={item.href}
|
||||||
|
onClick={() => setActive(item.href)}
|
||||||
|
data-active={isActive}
|
||||||
|
className={`group flex items-center rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-200 ${
|
||||||
|
isActive
|
||||||
|
? 'bg-purple-500/20 text-purple-700 dark:bg-purple-500/10 dark:text-purple-400'
|
||||||
|
: 'text-gray-700 hover:bg-purple-100/30 hover:text-purple-600 dark:text-gray-300 dark:hover:text-purple-400 dark:hover:bg-purple-500/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className='h-4 w-4 mr-2' />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧按钮 - 调整为更靠右,增加间距实现对称效果 */}
|
||||||
|
<div className='flex items-center gap-3 -mr-2'>
|
||||||
|
<ThemeToggle />
|
||||||
|
<UserMenu />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => {
|
||||||
|
return (
|
||||||
|
<div className='w-full min-h-screen'>
|
||||||
|
{/* 移动端头部 */}
|
||||||
|
<MobileHeader showBackButton={['/play'].includes(activePath)} />
|
||||||
|
|
||||||
|
{/* 桌面端顶部导航栏 */}
|
||||||
|
<div className='hidden md:block'>
|
||||||
|
<TopNavbar activePath={activePath} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 主要布局容器 */}
|
||||||
|
<div className='w-full min-h-screen md:min-h-auto'>
|
||||||
|
{/* 主内容区域 */}
|
||||||
|
<div className='relative min-w-0 flex-1 transition-all duration-300'>
|
||||||
|
{/* 桌面端左上角返回按钮 */}
|
||||||
|
{['/play'].includes(activePath) && (
|
||||||
|
<div className='absolute top-3 left-1 z-20 hidden md:flex'>
|
||||||
|
<BackButton />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 主内容容器 - 修改布局实现完全居中:左右各留白1/6,主内容区占2/3 */}
|
||||||
|
<main className='flex-1 md:min-h-0 mb-14 md:mb-0 md:p-6 lg:p-8'>
|
||||||
|
{/* 使用flex布局实现三等分 */}
|
||||||
|
<div className='flex w-full min-h-screen md:min-h-[calc(100vh-10rem)]'>
|
||||||
|
{/* 左侧留白区域 - 占1/6 */}
|
||||||
|
<div className='hidden md:block flex-shrink-0' style={{ width: '16.67%' }}></div>
|
||||||
|
|
||||||
|
{/* 主内容区 - 占2/3 */}
|
||||||
|
<div className='flex-1 md:flex-none rounded-container w-full' style={{ width: '66.67%' }}>
|
||||||
|
<div
|
||||||
|
className='p-4 md:p-8 lg:p-10'
|
||||||
|
style={{
|
||||||
|
paddingBottom: 'calc(3.5rem + env(safe-area-inset-bottom))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧留白区域 - 占1/6 */}
|
||||||
|
<div className='hidden md:block flex-shrink-0' style={{ width: '16.67%' }}></div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 移动端底部导航 */}
|
||||||
|
<div className='md:hidden'>
|
||||||
|
<MobileBottomNav activePath={activePath} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PageLayout;
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
interface ScrollableRowProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
scrollDistance?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScrollableRow({
|
||||||
|
children,
|
||||||
|
scrollDistance = 1000,
|
||||||
|
}: ScrollableRowProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [showLeftScroll, setShowLeftScroll] = useState(false);
|
||||||
|
const [showRightScroll, setShowRightScroll] = useState(false);
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
|
const checkScroll = () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
const { scrollWidth, clientWidth, scrollLeft } = containerRef.current;
|
||||||
|
|
||||||
|
// 计算是否需要左右滚动按钮
|
||||||
|
const threshold = 1; // 容差值,避免浮点误差
|
||||||
|
const canScrollRight =
|
||||||
|
scrollWidth - (scrollLeft + clientWidth) > threshold;
|
||||||
|
const canScrollLeft = scrollLeft > threshold;
|
||||||
|
|
||||||
|
setShowRightScroll(canScrollRight);
|
||||||
|
setShowLeftScroll(canScrollLeft);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 多次延迟检查,确保内容已完全渲染
|
||||||
|
checkScroll();
|
||||||
|
|
||||||
|
// 监听窗口大小变化
|
||||||
|
window.addEventListener('resize', checkScroll);
|
||||||
|
|
||||||
|
// 创建一个 ResizeObserver 来监听容器大小变化
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
// 延迟执行检查
|
||||||
|
checkScroll();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (containerRef.current) {
|
||||||
|
resizeObserver.observe(containerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', checkScroll);
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, [children]); // 依赖 children,当子组件变化时重新检查
|
||||||
|
|
||||||
|
// 添加一个额外的效果来监听子组件的变化
|
||||||
|
useEffect(() => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
// 监听 DOM 变化
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
setTimeout(checkScroll, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(containerRef.current, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['style', 'class'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleScrollRightClick = () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
containerRef.current.scrollBy({
|
||||||
|
left: scrollDistance,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScrollLeftClick = () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
containerRef.current.scrollBy({
|
||||||
|
left: -scrollDistance,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className='relative'
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setIsHovered(true);
|
||||||
|
// 当鼠标进入时重新检查一次
|
||||||
|
checkScroll();
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className='flex space-x-6 overflow-x-auto scrollbar-hide py-1 sm:py-2 pb-12 sm:pb-14 px-4 sm:px-6'
|
||||||
|
onScroll={checkScroll}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{showLeftScroll && (
|
||||||
|
<div
|
||||||
|
className={`hidden sm:flex absolute left-0 top-0 bottom-0 w-16 items-center justify-center z-[600] transition-opacity duration-200 ${
|
||||||
|
isHovered ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
pointerEvents: 'none', // 允许点击穿透
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className='absolute inset-0 flex items-center justify-center'
|
||||||
|
style={{
|
||||||
|
top: '40%',
|
||||||
|
bottom: '60%',
|
||||||
|
left: '-4.5rem',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={handleScrollLeftClick}
|
||||||
|
className='w-12 h-12 bg-white/95 rounded-full shadow-lg flex items-center justify-center hover:bg-white border border-gray-200 transition-transform hover:scale-105 dark:bg-gray-800/90 dark:hover:bg-gray-700 dark:border-gray-600'
|
||||||
|
>
|
||||||
|
<ChevronLeft className='w-6 h-6 text-gray-600 dark:text-gray-300' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showRightScroll && (
|
||||||
|
<div
|
||||||
|
className={`hidden sm:flex absolute right-0 top-0 bottom-0 w-16 items-center justify-center z-[600] transition-opacity duration-200 ${
|
||||||
|
isHovered ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
pointerEvents: 'none', // 允许点击穿透
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className='absolute inset-0 flex items-center justify-center'
|
||||||
|
style={{
|
||||||
|
top: '40%',
|
||||||
|
bottom: '60%',
|
||||||
|
right: '-4.5rem',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={handleScrollRightClick}
|
||||||
|
className='w-12 h-12 bg-white/95 rounded-full shadow-lg flex items-center justify-center hover:bg-white border border-gray-200 transition-transform hover:scale-105 dark:bg-gray-800/90 dark:hover:bg-gray-700 dark:border-gray-600'
|
||||||
|
>
|
||||||
|
<ChevronRight className='w-6 h-6 text-gray-600 dark:text-gray-300' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Clover, Film, Home, Menu, Search, Tv } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import { useSite } from './SiteProvider';
|
||||||
|
|
||||||
|
interface SidebarContextType {
|
||||||
|
isCollapsed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarContext = createContext<SidebarContextType>({
|
||||||
|
isCollapsed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useSidebar = () => useContext(SidebarContext);
|
||||||
|
|
||||||
|
// Logo 组件 - 应用彩虹渐变效果
|
||||||
|
const Logo = () => {
|
||||||
|
const { siteName } = useSite();
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href='/'
|
||||||
|
className='flex items-center justify-center h-16 select-none hover:opacity-80 transition-opacity duration-200'
|
||||||
|
>
|
||||||
|
<span className='text-2xl font-bold katelya-logo tracking-tight'>
|
||||||
|
{siteName}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
onToggle?: (collapsed: boolean) => void;
|
||||||
|
activePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在浏览器环境下通过全局变量缓存折叠状态,避免组件重新挂载时出现初始值闪烁
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__sidebarCollapsed?: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
// 若同一次 SPA 会话中已经读取过折叠状态,则直接复用,避免闪烁
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
|
||||||
|
if (
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
typeof window.__sidebarCollapsed === 'boolean'
|
||||||
|
) {
|
||||||
|
return window.__sidebarCollapsed;
|
||||||
|
}
|
||||||
|
return false; // 默认展开
|
||||||
|
});
|
||||||
|
|
||||||
|
// 首次挂载时读取 localStorage,以便刷新后仍保持上次的折叠状态
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const saved = localStorage.getItem('sidebarCollapsed');
|
||||||
|
if (saved !== null) {
|
||||||
|
const val = JSON.parse(saved);
|
||||||
|
setIsCollapsed(val);
|
||||||
|
window.__sidebarCollapsed = val;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 当折叠状态变化时,同步到 <html> data 属性,供首屏 CSS 使用
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
if (isCollapsed) {
|
||||||
|
document.documentElement.dataset.sidebarCollapsed = 'true';
|
||||||
|
} else {
|
||||||
|
delete document.documentElement.dataset.sidebarCollapsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isCollapsed]);
|
||||||
|
|
||||||
|
const [active, setActive] = useState(activePath);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 优先使用传入的 activePath
|
||||||
|
if (activePath) {
|
||||||
|
setActive(activePath);
|
||||||
|
} else {
|
||||||
|
// 否则使用当前路径
|
||||||
|
const getCurrentFullPath = () => {
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
return queryString ? `${pathname}?${queryString}` : pathname;
|
||||||
|
};
|
||||||
|
const fullPath = getCurrentFullPath();
|
||||||
|
setActive(fullPath);
|
||||||
|
}
|
||||||
|
}, [activePath, pathname, searchParams]);
|
||||||
|
|
||||||
|
const handleToggle = useCallback(() => {
|
||||||
|
const newState = !isCollapsed;
|
||||||
|
setIsCollapsed(newState);
|
||||||
|
localStorage.setItem('sidebarCollapsed', JSON.stringify(newState));
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.__sidebarCollapsed = newState;
|
||||||
|
}
|
||||||
|
onToggle?.(newState);
|
||||||
|
}, [isCollapsed, onToggle]);
|
||||||
|
|
||||||
|
const handleSearchClick = useCallback(() => {
|
||||||
|
router.push('/search');
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const contextValue = {
|
||||||
|
isCollapsed,
|
||||||
|
};
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{
|
||||||
|
icon: Film,
|
||||||
|
label: '电影',
|
||||||
|
href: '/douban?type=movie',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Tv,
|
||||||
|
label: '剧集',
|
||||||
|
href: '/douban?type=tv',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Clover,
|
||||||
|
label: '综艺',
|
||||||
|
href: '/douban?type=show',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarContext.Provider value={contextValue}>
|
||||||
|
{/* 在移动端隐藏侧边栏 */}
|
||||||
|
<div className='hidden md:flex'>
|
||||||
|
<aside
|
||||||
|
data-sidebar
|
||||||
|
className={`fixed top-0 left-0 h-screen bg-white/40 backdrop-blur-xl transition-all duration-300 border-r border-purple-200/50 z-10 shadow-lg dark:bg-gray-900/70 dark:border-purple-700/50 ${
|
||||||
|
isCollapsed ? 'w-16' : 'w-64'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
backdropFilter: 'blur(20px)',
|
||||||
|
WebkitBackdropFilter: 'blur(20px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className='flex h-full flex-col'>
|
||||||
|
{/* 顶部 Logo 区域 */}
|
||||||
|
<div className='relative h-16'>
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 flex items-center justify-center transition-opacity duration-200 ${
|
||||||
|
isCollapsed ? 'opacity-0' : 'opacity-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className='w-[calc(100%-4rem)] flex justify-center'>
|
||||||
|
{!isCollapsed && <Logo />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleToggle}
|
||||||
|
className={`absolute top-1/2 -translate-y-1/2 flex items-center justify-center w-8 h-8 rounded-lg text-gray-500 hover:text-gray-700 hover:bg-gray-100/50 transition-colors duration-200 z-10 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:bg-gray-700/50 ${
|
||||||
|
isCollapsed ? 'left-1/2 -translate-x-1/2' : 'right-2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Menu className='h-4 w-4' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 首页和搜索导航 */}
|
||||||
|
<nav className='px-2 mt-4 space-y-1'>
|
||||||
|
<Link
|
||||||
|
href='/'
|
||||||
|
onClick={() => setActive('/')}
|
||||||
|
data-active={active === '/'}
|
||||||
|
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-purple-100/30 hover:text-purple-600 data-[active=true]:bg-purple-500/20 data-[active=true]:text-purple-700 font-medium transition-colors duration-200 min-h-[40px] dark:text-gray-300 dark:hover:text-purple-400 dark:data-[active=true]:bg-purple-500/10 dark:data-[active=true]:text-purple-400 ${
|
||||||
|
isCollapsed ? 'w-full max-w-none mx-0' : 'mx-0'
|
||||||
|
} gap-3 justify-start`}
|
||||||
|
>
|
||||||
|
<div className='w-4 h-4 flex items-center justify-center'>
|
||||||
|
<Home className='h-4 w-4 text-gray-500 group-hover:text-purple-600 data-[active=true]:text-purple-700 dark:text-gray-400 dark:group-hover:text-purple-400 dark:data-[active=true]:text-purple-400' />
|
||||||
|
</div>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'>
|
||||||
|
首页
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href='/search'
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSearchClick();
|
||||||
|
setActive('/search');
|
||||||
|
}}
|
||||||
|
data-active={active === '/search'}
|
||||||
|
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-gray-700 hover:bg-purple-100/30 hover:text-purple-600 data-[active=true]:bg-purple-500/20 data-[active=true]:text-purple-700 font-medium transition-colors duration-200 min-h-[40px] dark:text-gray-300 dark:hover:text-purple-400 dark:data-[active=true]:bg-purple-500/10 dark:data-[active=true]:text-purple-400 ${
|
||||||
|
isCollapsed ? 'w-full max-w-none mx-0' : 'mx-0'
|
||||||
|
} gap-3 justify-start`}
|
||||||
|
>
|
||||||
|
<div className='w-4 h-4 flex items-center justify-center'>
|
||||||
|
<Search className='h-4 w-4 text-gray-500 group-hover:text-purple-600 data-[active=true]:text-purple-700 dark:text-gray-400 dark:group-hover:text-purple-400 dark:data-[active=true]:text-purple-400' />
|
||||||
|
</div>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'>
|
||||||
|
搜索
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* 菜单项 */}
|
||||||
|
<div className='flex-1 overflow-y-auto px-2 pt-4'>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
{menuItems.map((item) => {
|
||||||
|
// 检查当前路径是否匹配这个菜单项
|
||||||
|
const typeMatch = item.href.match(/type=([^&]+)/)?.[1];
|
||||||
|
const tagMatch = item.href.match(/tag=([^&]+)/)?.[1];
|
||||||
|
|
||||||
|
// 解码URL以进行正确的比较
|
||||||
|
const decodedActive = decodeURIComponent(active);
|
||||||
|
const decodedItemHref = decodeURIComponent(item.href);
|
||||||
|
|
||||||
|
const isActive =
|
||||||
|
decodedActive === decodedItemHref ||
|
||||||
|
(decodedActive.startsWith('/douban') &&
|
||||||
|
decodedActive.includes(`type=${typeMatch}`) &&
|
||||||
|
tagMatch &&
|
||||||
|
decodedActive.includes(`tag=${tagMatch}`));
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.label}
|
||||||
|
href={item.href}
|
||||||
|
onClick={() => setActive(item.href)}
|
||||||
|
data-active={isActive}
|
||||||
|
className={`group flex items-center rounded-lg px-2 py-2 pl-4 text-sm text-gray-700 hover:bg-purple-100/30 hover:text-purple-600 data-[active=true]:bg-purple-500/20 data-[active=true]:text-purple-700 transition-colors duration-200 min-h-[40px] dark:text-gray-300 dark:hover:text-purple-400 dark:data-[active=true]:bg-purple-500/10 dark:data-[active=true]:text-purple-400 ${
|
||||||
|
isCollapsed ? 'w-full max-w-none mx-0' : 'mx-0'
|
||||||
|
} gap-3 justify-start`}
|
||||||
|
>
|
||||||
|
<div className='w-4 h-4 flex items-center justify-center'>
|
||||||
|
<Icon className='h-4 w-4 text-gray-500 group-hover:text-purple-600 data-[active=true]:text-purple-700 dark:text-gray-400 dark:group-hover:text-purple-400 dark:data-[active=true]:text-purple-400' />
|
||||||
|
</div>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<span className='whitespace-nowrap transition-opacity duration-200 opacity-100'>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div
|
||||||
|
className={`transition-all duration-300 sidebar-offset ${
|
||||||
|
isCollapsed ? 'w-16' : 'w-64'
|
||||||
|
}`}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</SidebarContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Sidebar;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createContext, ReactNode, useContext } from 'react';
|
||||||
|
|
||||||
|
const SiteContext = createContext<{ siteName: string; announcement?: string }>({
|
||||||
|
// 默认值
|
||||||
|
siteName: 'MoonTV',
|
||||||
|
announcement:
|
||||||
|
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useSite = () => useContext(SiteContext);
|
||||||
|
|
||||||
|
export function SiteProvider({
|
||||||
|
children,
|
||||||
|
siteName,
|
||||||
|
announcement,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
siteName: string;
|
||||||
|
announcement?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SiteContext.Provider value={{ siteName, announcement }}>
|
||||||
|
{children}
|
||||||
|
</SiteContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { ThemeProviderProps } from 'next-themes';
|
||||||
|
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||||
|
return (
|
||||||
|
<NextThemesProvider
|
||||||
|
attribute='class'
|
||||||
|
defaultTheme='system'
|
||||||
|
enableSystem
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</NextThemesProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any,react-hooks/exhaustive-deps */
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Moon, Sun } from 'lucide-react';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const { setTheme, resolvedTheme } = useTheme();
|
||||||
|
|
||||||
|
const setThemeColor = (theme?: string) => {
|
||||||
|
const meta = document.querySelector('meta[name="theme-color"]');
|
||||||
|
if (!meta) {
|
||||||
|
const meta = document.createElement('meta');
|
||||||
|
meta.name = 'theme-color';
|
||||||
|
meta.content = theme === 'dark' ? '#0c111c' : '#f9fbfe';
|
||||||
|
document.head.appendChild(meta);
|
||||||
|
} else {
|
||||||
|
meta.setAttribute('content', theme === 'dark' ? '#0c111c' : '#f9fbfe');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
setThemeColor(resolvedTheme);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
// 渲染一个占位符以避免布局偏移
|
||||||
|
return <div className='w-10 h-10' />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
// 检查浏览器是否支持 View Transitions API
|
||||||
|
const targetTheme = resolvedTheme === 'dark' ? 'light' : 'dark';
|
||||||
|
setThemeColor(targetTheme);
|
||||||
|
if (!(document as any).startViewTransition) {
|
||||||
|
setTheme(targetTheme);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
(document as any).startViewTransition(() => {
|
||||||
|
setTheme(targetTheme);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
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='Toggle theme'
|
||||||
|
>
|
||||||
|
{resolvedTheme === 'dark' ? (
|
||||||
|
<Sun className='w-full h-full' />
|
||||||
|
) : (
|
||||||
|
<Moon className='w-full h-full' />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,749 @@
|
|||||||
|
/* eslint-disable no-console,@typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { KeyRound, LogOut, Settings, Shield, User, X } from 'lucide-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
||||||
|
import { checkForUpdates, CURRENT_VERSION, UpdateStatus } from '@/lib/version';
|
||||||
|
|
||||||
|
interface AuthInfo {
|
||||||
|
username?: string;
|
||||||
|
role?: 'owner' | 'admin' | 'user';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UserMenu: React.FC = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
|
const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false);
|
||||||
|
const [authInfo, setAuthInfo] = useState<AuthInfo | null>(null);
|
||||||
|
const [storageType, setStorageType] = useState<string>('localstorage');
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
// 设置相关状态
|
||||||
|
const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true);
|
||||||
|
const [doubanProxyUrl, setDoubanProxyUrl] = useState('');
|
||||||
|
const [imageProxyUrl, setImageProxyUrl] = useState('');
|
||||||
|
const [enableOptimization, setEnableOptimization] = useState(true);
|
||||||
|
const [enableImageProxy, setEnableImageProxy] = useState(false);
|
||||||
|
const [enableDoubanProxy, setEnableDoubanProxy] = useState(false);
|
||||||
|
|
||||||
|
// 修改密码相关状态
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [passwordLoading, setPasswordLoading] = useState(false);
|
||||||
|
const [passwordError, setPasswordError] = useState('');
|
||||||
|
|
||||||
|
// 版本检查相关状态
|
||||||
|
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null);
|
||||||
|
const [isChecking, setIsChecking] = useState(true);
|
||||||
|
|
||||||
|
// 确保组件已挂载
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 获取认证信息和存储类型
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const auth = getAuthInfoFromBrowserCookie();
|
||||||
|
setAuthInfo(auth);
|
||||||
|
|
||||||
|
const type =
|
||||||
|
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE || 'localstorage';
|
||||||
|
setStorageType(type);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 从 localStorage 读取设置
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const savedAggregateSearch = localStorage.getItem(
|
||||||
|
'defaultAggregateSearch'
|
||||||
|
);
|
||||||
|
if (savedAggregateSearch !== null) {
|
||||||
|
setDefaultAggregateSearch(JSON.parse(savedAggregateSearch));
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedEnableDoubanProxy = localStorage.getItem('enableDoubanProxy');
|
||||||
|
const defaultDoubanProxy =
|
||||||
|
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY || '';
|
||||||
|
if (savedEnableDoubanProxy !== null) {
|
||||||
|
setEnableDoubanProxy(JSON.parse(savedEnableDoubanProxy));
|
||||||
|
} else if (defaultDoubanProxy) {
|
||||||
|
setEnableDoubanProxy(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedDoubanProxyUrl = localStorage.getItem('doubanProxyUrl');
|
||||||
|
if (savedDoubanProxyUrl !== null) {
|
||||||
|
setDoubanProxyUrl(savedDoubanProxyUrl);
|
||||||
|
} else if (defaultDoubanProxy) {
|
||||||
|
setDoubanProxyUrl(defaultDoubanProxy);
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedEnableImageProxy = localStorage.getItem('enableImageProxy');
|
||||||
|
const defaultImageProxy =
|
||||||
|
(window as any).RUNTIME_CONFIG?.IMAGE_PROXY || '';
|
||||||
|
if (savedEnableImageProxy !== null) {
|
||||||
|
setEnableImageProxy(JSON.parse(savedEnableImageProxy));
|
||||||
|
} else if (defaultImageProxy) {
|
||||||
|
setEnableImageProxy(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedImageProxyUrl = localStorage.getItem('imageProxyUrl');
|
||||||
|
if (savedImageProxyUrl !== null) {
|
||||||
|
setImageProxyUrl(savedImageProxyUrl);
|
||||||
|
} else if (defaultImageProxy) {
|
||||||
|
setImageProxyUrl(defaultImageProxy);
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedEnableOptimization =
|
||||||
|
localStorage.getItem('enableOptimization');
|
||||||
|
if (savedEnableOptimization !== null) {
|
||||||
|
setEnableOptimization(JSON.parse(savedEnableOptimization));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 版本检查
|
||||||
|
useEffect(() => {
|
||||||
|
const checkUpdate = async () => {
|
||||||
|
try {
|
||||||
|
const status = await checkForUpdates();
|
||||||
|
setUpdateStatus(status);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('版本检查失败:', error);
|
||||||
|
} finally {
|
||||||
|
setIsChecking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkUpdate();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMenuClick = () => {
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseMenu = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('注销请求失败:', error);
|
||||||
|
}
|
||||||
|
window.location.href = '/';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdminPanel = () => {
|
||||||
|
router.push('/admin');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangePassword = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setIsChangePasswordOpen(true);
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
setPasswordError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseChangePassword = () => {
|
||||||
|
setIsChangePasswordOpen(false);
|
||||||
|
setNewPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
setPasswordError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitChangePassword = async () => {
|
||||||
|
setPasswordError('');
|
||||||
|
|
||||||
|
// 验证密码
|
||||||
|
if (!newPassword) {
|
||||||
|
setPasswordError('新密码不得为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setPasswordError('两次输入的密码不一致');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPasswordLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
newPassword,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setPasswordError(data.error || '修改密码失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改成功,关闭弹窗并登出
|
||||||
|
setIsChangePasswordOpen(false);
|
||||||
|
await handleLogout();
|
||||||
|
} catch (error) {
|
||||||
|
setPasswordError('网络错误,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
setPasswordLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSettings = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setIsSettingsOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseSettings = () => {
|
||||||
|
setIsSettingsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 设置相关的处理函数
|
||||||
|
const handleAggregateToggle = (value: boolean) => {
|
||||||
|
setDefaultAggregateSearch(value);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('defaultAggregateSearch', JSON.stringify(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDoubanProxyUrlChange = (value: string) => {
|
||||||
|
setDoubanProxyUrl(value);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('doubanProxyUrl', value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageProxyUrlChange = (value: string) => {
|
||||||
|
setImageProxyUrl(value);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('imageProxyUrl', value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOptimizationToggle = (value: boolean) => {
|
||||||
|
setEnableOptimization(value);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('enableOptimization', JSON.stringify(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageProxyToggle = (value: boolean) => {
|
||||||
|
setEnableImageProxy(value);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('enableImageProxy', JSON.stringify(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDoubanProxyToggle = (value: boolean) => {
|
||||||
|
setEnableDoubanProxy(value);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('enableDoubanProxy', JSON.stringify(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetSettings = () => {
|
||||||
|
const defaultImageProxy = (window as any).RUNTIME_CONFIG?.IMAGE_PROXY || '';
|
||||||
|
const defaultDoubanProxy =
|
||||||
|
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY || '';
|
||||||
|
|
||||||
|
setDefaultAggregateSearch(true);
|
||||||
|
setEnableOptimization(true);
|
||||||
|
setDoubanProxyUrl(defaultDoubanProxy);
|
||||||
|
setEnableDoubanProxy(!!defaultDoubanProxy);
|
||||||
|
setEnableImageProxy(!!defaultImageProxy);
|
||||||
|
setImageProxyUrl(defaultImageProxy);
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('defaultAggregateSearch', JSON.stringify(true));
|
||||||
|
localStorage.setItem('enableOptimization', JSON.stringify(true));
|
||||||
|
localStorage.setItem('doubanProxyUrl', defaultDoubanProxy);
|
||||||
|
localStorage.setItem(
|
||||||
|
'enableDoubanProxy',
|
||||||
|
JSON.stringify(!!defaultDoubanProxy)
|
||||||
|
);
|
||||||
|
localStorage.setItem(
|
||||||
|
'enableImageProxy',
|
||||||
|
JSON.stringify(!!defaultImageProxy)
|
||||||
|
);
|
||||||
|
localStorage.setItem('imageProxyUrl', defaultImageProxy);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查是否显示管理面板按钮
|
||||||
|
const showAdminPanel =
|
||||||
|
authInfo?.role === 'owner' || authInfo?.role === 'admin';
|
||||||
|
|
||||||
|
// 检查是否显示修改密码按钮
|
||||||
|
const showChangePassword =
|
||||||
|
authInfo?.role !== 'owner' && storageType !== 'localstorage';
|
||||||
|
|
||||||
|
// 角色中文映射
|
||||||
|
const getRoleText = (role?: string) => {
|
||||||
|
switch (role) {
|
||||||
|
case 'owner':
|
||||||
|
return '站长';
|
||||||
|
case 'admin':
|
||||||
|
return '管理员';
|
||||||
|
case 'user':
|
||||||
|
return '用户';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 菜单面板内容
|
||||||
|
const menuPanel = (
|
||||||
|
<>
|
||||||
|
{/* 背景遮罩 - 普通菜单无需模糊 */}
|
||||||
|
<div
|
||||||
|
className='fixed inset-0 bg-transparent z-[1000]'
|
||||||
|
onClick={handleCloseMenu}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 菜单面板 */}
|
||||||
|
<div className='fixed top-14 right-4 w-56 bg-white dark:bg-gray-900 rounded-lg shadow-xl z-[1001] border border-gray-200/50 dark:border-gray-700/50 overflow-hidden select-none'>
|
||||||
|
{/* 用户信息区域 */}
|
||||||
|
<div className='px-3 py-2.5 border-b border-gray-200 dark:border-gray-700 bg-gradient-to-r from-gray-50 to-gray-100/50 dark:from-gray-800 dark:to-gray-800/50'>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<span className='text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
||||||
|
当前用户
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
(authInfo?.role || 'user') === 'owner'
|
||||||
|
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
|
||||||
|
: (authInfo?.role || 'user') === 'admin'
|
||||||
|
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
||||||
|
: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{getRoleText(authInfo?.role || 'user')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='font-semibold text-gray-900 dark:text-gray-100 text-sm truncate'>
|
||||||
|
{authInfo?.username || 'default'}
|
||||||
|
</div>
|
||||||
|
<div className='text-[10px] text-gray-400 dark:text-gray-500'>
|
||||||
|
数据存储:
|
||||||
|
{storageType === 'localstorage' ? '本地' : storageType}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 菜单项 */}
|
||||||
|
<div className='py-1'>
|
||||||
|
{/* 设置按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={handleSettings}
|
||||||
|
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm'
|
||||||
|
>
|
||||||
|
<Settings className='w-4 h-4 text-gray-500 dark:text-gray-400' />
|
||||||
|
<span className='font-medium'>设置</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 管理面板按钮 */}
|
||||||
|
{showAdminPanel && (
|
||||||
|
<button
|
||||||
|
onClick={handleAdminPanel}
|
||||||
|
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm'
|
||||||
|
>
|
||||||
|
<Shield className='w-4 h-4 text-gray-500 dark:text-gray-400' />
|
||||||
|
<span className='font-medium'>管理面板</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 修改密码按钮 */}
|
||||||
|
{showChangePassword && (
|
||||||
|
<button
|
||||||
|
onClick={handleChangePassword}
|
||||||
|
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm'
|
||||||
|
>
|
||||||
|
<KeyRound className='w-4 h-4 text-gray-500 dark:text-gray-400' />
|
||||||
|
<span className='font-medium'>修改密码</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 分割线 */}
|
||||||
|
<div className='my-1 border-t border-gray-200 dark:border-gray-700'></div>
|
||||||
|
|
||||||
|
{/* 登出按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className='w-full px-3 py-2 text-left flex items-center gap-2.5 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors text-sm'
|
||||||
|
>
|
||||||
|
<LogOut className='w-4 h-4' />
|
||||||
|
<span className='font-medium'>登出</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 分割线 */}
|
||||||
|
<div className='my-1 border-t border-gray-200 dark:border-gray-700'></div>
|
||||||
|
|
||||||
|
{/* 版本信息 */}
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
window.open('https://github.com/senshinya/MoonTV', '_blank')
|
||||||
|
}
|
||||||
|
className='w-full px-3 py-2 text-center flex items-center justify-center text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors text-xs'
|
||||||
|
>
|
||||||
|
<div className='flex items-center gap-1'>
|
||||||
|
<span className='font-mono'>v{CURRENT_VERSION}</span>
|
||||||
|
{!isChecking &&
|
||||||
|
updateStatus &&
|
||||||
|
updateStatus !== UpdateStatus.FETCH_FAILED && (
|
||||||
|
<div
|
||||||
|
className={`w-2 h-2 rounded-full -translate-y-2 ${
|
||||||
|
updateStatus === UpdateStatus.HAS_UPDATE
|
||||||
|
? 'bg-yellow-500'
|
||||||
|
: updateStatus === UpdateStatus.NO_UPDATE
|
||||||
|
? 'bg-green-400'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 设置面板内容
|
||||||
|
const settingsPanel = (
|
||||||
|
<>
|
||||||
|
{/* 背景遮罩 */}
|
||||||
|
<div
|
||||||
|
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
|
||||||
|
onClick={handleCloseSettings}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 设置面板 */}
|
||||||
|
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] p-6'>
|
||||||
|
{/* 标题栏 */}
|
||||||
|
<div className='flex items-center justify-between mb-6'>
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
|
<h3 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||||
|
本地设置
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={handleResetSettings}
|
||||||
|
className='px-2 py-1 text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 border border-red-200 hover:border-red-300 dark:border-red-800 dark:hover:border-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors'
|
||||||
|
title='重置为默认设置'
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCloseSettings}
|
||||||
|
className='w-8 h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
|
||||||
|
aria-label='Close'
|
||||||
|
>
|
||||||
|
<X className='w-full h-full' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 设置项 */}
|
||||||
|
<div className='space-y-6'>
|
||||||
|
{/* 默认聚合搜索结果 */}
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div>
|
||||||
|
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
|
默认聚合搜索结果
|
||||||
|
</h4>
|
||||||
|
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||||
|
搜索时默认按标题和年份聚合显示结果
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className='flex items-center cursor-pointer'>
|
||||||
|
<div className='relative'>
|
||||||
|
<input
|
||||||
|
type='checkbox'
|
||||||
|
className='sr-only peer'
|
||||||
|
checked={defaultAggregateSearch}
|
||||||
|
onChange={(e) => handleAggregateToggle(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<div className='w-11 h-6 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-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 优选和测速 */}
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div>
|
||||||
|
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
|
启用优选和测速
|
||||||
|
</h4>
|
||||||
|
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||||
|
如出现播放器劫持问题可关闭
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className='flex items-center cursor-pointer'>
|
||||||
|
<div className='relative'>
|
||||||
|
<input
|
||||||
|
type='checkbox'
|
||||||
|
className='sr-only peer'
|
||||||
|
checked={enableOptimization}
|
||||||
|
onChange={(e) => handleOptimizationToggle(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<div className='w-11 h-6 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-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分割线 */}
|
||||||
|
<div className='border-t border-gray-200 dark:border-gray-700'></div>
|
||||||
|
|
||||||
|
{/* 豆瓣代理开关 */}
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div>
|
||||||
|
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
|
启用豆瓣代理
|
||||||
|
</h4>
|
||||||
|
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||||
|
启用后,豆瓣数据将通过代理服务器获取
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className='flex items-center cursor-pointer'>
|
||||||
|
<div className='relative'>
|
||||||
|
<input
|
||||||
|
type='checkbox'
|
||||||
|
className='sr-only peer'
|
||||||
|
checked={enableDoubanProxy}
|
||||||
|
onChange={(e) => handleDoubanProxyToggle(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<div className='w-11 h-6 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-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 豆瓣代理地址设置 */}
|
||||||
|
<div className='space-y-3'>
|
||||||
|
<div>
|
||||||
|
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
|
豆瓣代理地址
|
||||||
|
</h4>
|
||||||
|
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||||
|
仅在启用豆瓣代理时生效,留空则使用服务器 API
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
className={`w-full px-3 py-2 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors ${
|
||||||
|
enableDoubanProxy
|
||||||
|
? 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 text-gray-400 dark:text-gray-500 placeholder-gray-400 dark:placeholder-gray-600 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
placeholder='例如: https://proxy.example.com/fetch?url='
|
||||||
|
value={doubanProxyUrl}
|
||||||
|
onChange={(e) => handleDoubanProxyUrlChange(e.target.value)}
|
||||||
|
disabled={!enableDoubanProxy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分割线 */}
|
||||||
|
<div className='border-t border-gray-200 dark:border-gray-700'></div>
|
||||||
|
|
||||||
|
{/* 图片代理开关 */}
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div>
|
||||||
|
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
|
启用图片代理
|
||||||
|
</h4>
|
||||||
|
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||||
|
启用后,所有图片加载将通过代理服务器
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className='flex items-center cursor-pointer'>
|
||||||
|
<div className='relative'>
|
||||||
|
<input
|
||||||
|
type='checkbox'
|
||||||
|
className='sr-only peer'
|
||||||
|
checked={enableImageProxy}
|
||||||
|
onChange={(e) => handleImageProxyToggle(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<div className='w-11 h-6 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-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 图片代理地址设置 */}
|
||||||
|
<div className='space-y-3'>
|
||||||
|
<div>
|
||||||
|
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
|
图片代理地址
|
||||||
|
</h4>
|
||||||
|
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||||
|
仅在启用图片代理时生效
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
className={`w-full px-3 py-2 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors ${
|
||||||
|
enableImageProxy
|
||||||
|
? 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 text-gray-400 dark:text-gray-500 placeholder-gray-400 dark:placeholder-gray-600 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
placeholder='例如: https://imageproxy.example.com/?url='
|
||||||
|
value={imageProxyUrl}
|
||||||
|
onChange={(e) => handleImageProxyUrlChange(e.target.value)}
|
||||||
|
disabled={!enableImageProxy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部说明 */}
|
||||||
|
<div className='mt-6 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||||
|
<p className='text-xs text-gray-500 dark:text-gray-400 text-center'>
|
||||||
|
这些设置保存在本地浏览器中
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 修改密码面板内容
|
||||||
|
const changePasswordPanel = (
|
||||||
|
<>
|
||||||
|
{/* 背景遮罩 */}
|
||||||
|
<div
|
||||||
|
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
|
||||||
|
onClick={handleCloseChangePassword}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 修改密码面板 */}
|
||||||
|
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] p-6'>
|
||||||
|
{/* 标题栏 */}
|
||||||
|
<div className='flex items-center justify-between mb-6'>
|
||||||
|
<h3 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||||
|
修改密码
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={handleCloseChangePassword}
|
||||||
|
className='w-8 h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
|
||||||
|
aria-label='Close'
|
||||||
|
>
|
||||||
|
<X className='w-full h-full' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 表单 */}
|
||||||
|
<div className='space-y-4'>
|
||||||
|
{/* 新密码输入 */}
|
||||||
|
<div>
|
||||||
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
|
新密码
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='password'
|
||||||
|
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400'
|
||||||
|
placeholder='请输入新密码'
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
disabled={passwordLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 确认密码输入 */}
|
||||||
|
<div>
|
||||||
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
|
确认密码
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='password'
|
||||||
|
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400'
|
||||||
|
placeholder='请再次输入新密码'
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
disabled={passwordLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 错误信息 */}
|
||||||
|
{passwordError && (
|
||||||
|
<div className='text-red-500 text-sm bg-red-50 dark:bg-red-900/20 p-3 rounded-md border border-red-200 dark:border-red-800'>
|
||||||
|
{passwordError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className='flex gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||||
|
<button
|
||||||
|
onClick={handleCloseChangePassword}
|
||||||
|
className='flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors'
|
||||||
|
disabled={passwordLoading}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmitChangePassword}
|
||||||
|
className='flex-1 px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 dark:bg-green-700 dark:hover:bg-green-600 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
|
||||||
|
disabled={passwordLoading || !newPassword || !confirmPassword}
|
||||||
|
>
|
||||||
|
{passwordLoading ? '修改中...' : '确认修改'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部说明 */}
|
||||||
|
<div className='mt-4 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||||
|
<p className='text-xs text-gray-500 dark:text-gray-400 text-center'>
|
||||||
|
修改密码后需要重新登录
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='relative'>
|
||||||
|
<button
|
||||||
|
onClick={handleMenuClick}
|
||||||
|
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='User Menu'
|
||||||
|
>
|
||||||
|
<User className='w-full h-full' />
|
||||||
|
</button>
|
||||||
|
{updateStatus === UpdateStatus.HAS_UPDATE && (
|
||||||
|
<div className='absolute top-[2px] right-[2px] w-2 h-2 bg-yellow-500 rounded-full'></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 使用 Portal 将菜单面板渲染到 document.body */}
|
||||||
|
{isOpen && mounted && createPortal(menuPanel, document.body)}
|
||||||
|
|
||||||
|
{/* 使用 Portal 将设置面板渲染到 document.body */}
|
||||||
|
{isSettingsOpen && mounted && createPortal(settingsPanel, document.body)}
|
||||||
|
|
||||||
|
{/* 使用 Portal 将修改密码面板渲染到 document.body */}
|
||||||
|
{isChangePasswordOpen &&
|
||||||
|
mounted &&
|
||||||
|
createPortal(changePasswordPanel, document.body)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
import { CheckCircle, Heart, Link, PlayCircleIcon } from 'lucide-react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
deleteFavorite,
|
||||||
|
deletePlayRecord,
|
||||||
|
generateStorageKey,
|
||||||
|
isFavorited,
|
||||||
|
saveFavorite,
|
||||||
|
subscribeToDataUpdates,
|
||||||
|
} from '@/lib/db.client';
|
||||||
|
import { SearchResult } from '@/lib/types';
|
||||||
|
import { processImageUrl } from '@/lib/utils';
|
||||||
|
|
||||||
|
import { ImagePlaceholder } from '@/components/ImagePlaceholder';
|
||||||
|
|
||||||
|
interface VideoCardProps {
|
||||||
|
id?: string;
|
||||||
|
source?: string;
|
||||||
|
title?: string;
|
||||||
|
query?: string;
|
||||||
|
poster?: string;
|
||||||
|
episodes?: number;
|
||||||
|
source_name?: string;
|
||||||
|
progress?: number;
|
||||||
|
year?: string;
|
||||||
|
from: 'playrecord' | 'favorite' | 'search' | 'douban';
|
||||||
|
currentEpisode?: number;
|
||||||
|
douban_id?: string;
|
||||||
|
onDelete?: () => void;
|
||||||
|
rate?: string;
|
||||||
|
items?: SearchResult[];
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VideoCard({
|
||||||
|
id,
|
||||||
|
title = '',
|
||||||
|
query = '',
|
||||||
|
poster = '',
|
||||||
|
episodes,
|
||||||
|
source,
|
||||||
|
source_name,
|
||||||
|
progress = 0,
|
||||||
|
year,
|
||||||
|
from,
|
||||||
|
currentEpisode,
|
||||||
|
douban_id,
|
||||||
|
onDelete,
|
||||||
|
rate,
|
||||||
|
items,
|
||||||
|
type = '',
|
||||||
|
}: VideoCardProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [favorited, setFavorited] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const isAggregate = from === 'search' && !!items?.length;
|
||||||
|
|
||||||
|
const aggregateData = useMemo(() => {
|
||||||
|
if (!isAggregate || !items) return null;
|
||||||
|
const countMap = new Map<string | number, number>();
|
||||||
|
const episodeCountMap = new Map<number, number>();
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (item.douban_id && item.douban_id !== 0) {
|
||||||
|
countMap.set(item.douban_id, (countMap.get(item.douban_id) || 0) + 1);
|
||||||
|
}
|
||||||
|
const len = item.episodes?.length || 0;
|
||||||
|
if (len > 0) {
|
||||||
|
episodeCountMap.set(len, (episodeCountMap.get(len) || 0) + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const getMostFrequent = <T extends string | number>(
|
||||||
|
map: Map<T, number>
|
||||||
|
) => {
|
||||||
|
let maxCount = 0;
|
||||||
|
let result: T | undefined;
|
||||||
|
map.forEach((cnt, key) => {
|
||||||
|
if (cnt > maxCount) {
|
||||||
|
maxCount = cnt;
|
||||||
|
result = key;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
first: items[0],
|
||||||
|
mostFrequentDoubanId: getMostFrequent(countMap),
|
||||||
|
mostFrequentEpisodes: getMostFrequent(episodeCountMap) || 0,
|
||||||
|
};
|
||||||
|
}, [isAggregate, items]);
|
||||||
|
|
||||||
|
const actualTitle = aggregateData?.first.title ?? title;
|
||||||
|
const actualPoster = aggregateData?.first.poster ?? poster;
|
||||||
|
const actualSource = aggregateData?.first.source ?? source;
|
||||||
|
const actualId = aggregateData?.first.id ?? id;
|
||||||
|
const actualDoubanId = String(
|
||||||
|
aggregateData?.mostFrequentDoubanId ?? douban_id
|
||||||
|
);
|
||||||
|
const actualEpisodes = aggregateData?.mostFrequentEpisodes ?? episodes;
|
||||||
|
const actualYear = aggregateData?.first.year ?? year;
|
||||||
|
const actualQuery = query || '';
|
||||||
|
const actualSearchType = isAggregate
|
||||||
|
? aggregateData?.first.episodes?.length === 1
|
||||||
|
? 'movie'
|
||||||
|
: 'tv'
|
||||||
|
: type;
|
||||||
|
|
||||||
|
// 获取收藏状态
|
||||||
|
useEffect(() => {
|
||||||
|
if (from === 'douban' || !actualSource || !actualId) return;
|
||||||
|
|
||||||
|
const fetchFavoriteStatus = async () => {
|
||||||
|
try {
|
||||||
|
const fav = await isFavorited(actualSource, actualId);
|
||||||
|
setFavorited(fav);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error('检查收藏状态失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchFavoriteStatus();
|
||||||
|
|
||||||
|
// 监听收藏状态更新事件
|
||||||
|
const storageKey = generateStorageKey(actualSource, actualId);
|
||||||
|
const unsubscribe = subscribeToDataUpdates(
|
||||||
|
'favoritesUpdated',
|
||||||
|
(newFavorites: Record<string, any>) => {
|
||||||
|
// 检查当前项目是否在新的收藏列表中
|
||||||
|
const isNowFavorited = !!newFavorites[storageKey];
|
||||||
|
setFavorited(isNowFavorited);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, [from, actualSource, actualId]);
|
||||||
|
|
||||||
|
const handleToggleFavorite = useCallback(
|
||||||
|
async (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (from === 'douban' || !actualSource || !actualId) return;
|
||||||
|
try {
|
||||||
|
if (favorited) {
|
||||||
|
// 如果已收藏,删除收藏
|
||||||
|
await deleteFavorite(actualSource, actualId);
|
||||||
|
setFavorited(false);
|
||||||
|
} else {
|
||||||
|
// 如果未收藏,添加收藏
|
||||||
|
await saveFavorite(actualSource, actualId, {
|
||||||
|
title: actualTitle,
|
||||||
|
source_name: source_name || '',
|
||||||
|
year: actualYear || '',
|
||||||
|
cover: actualPoster,
|
||||||
|
total_episodes: actualEpisodes ?? 1,
|
||||||
|
save_time: Date.now(),
|
||||||
|
});
|
||||||
|
setFavorited(true);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error('切换收藏状态失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
from,
|
||||||
|
actualSource,
|
||||||
|
actualId,
|
||||||
|
actualTitle,
|
||||||
|
source_name,
|
||||||
|
actualYear,
|
||||||
|
actualPoster,
|
||||||
|
actualEpisodes,
|
||||||
|
favorited,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteRecord = useCallback(
|
||||||
|
async (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (from !== 'playrecord' || !actualSource || !actualId) return;
|
||||||
|
try {
|
||||||
|
await deletePlayRecord(actualSource, actualId);
|
||||||
|
onDelete?.();
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error('删除播放记录失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[from, actualSource, actualId, onDelete]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
if (from === 'douban') {
|
||||||
|
router.push(
|
||||||
|
`/play?title=${encodeURIComponent(actualTitle.trim())}${
|
||||||
|
actualYear ? `&year=${actualYear}` : ''
|
||||||
|
}${actualSearchType ? `&stype=${actualSearchType}` : ''}`
|
||||||
|
);
|
||||||
|
} else if (actualSource && actualId) {
|
||||||
|
router.push(
|
||||||
|
`/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent(
|
||||||
|
actualTitle
|
||||||
|
)}${actualYear ? `&year=${actualYear}` : ''}${
|
||||||
|
isAggregate ? '&prefer=true' : ''
|
||||||
|
}${
|
||||||
|
actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''
|
||||||
|
}${actualSearchType ? `&stype=${actualSearchType}` : ''}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
from,
|
||||||
|
actualSource,
|
||||||
|
actualId,
|
||||||
|
router,
|
||||||
|
actualTitle,
|
||||||
|
actualYear,
|
||||||
|
isAggregate,
|
||||||
|
actualQuery,
|
||||||
|
actualSearchType,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const config = useMemo(() => {
|
||||||
|
const configs = {
|
||||||
|
playrecord: {
|
||||||
|
showSourceName: true,
|
||||||
|
showProgress: true,
|
||||||
|
showPlayButton: true,
|
||||||
|
showHeart: true,
|
||||||
|
showCheckCircle: true,
|
||||||
|
showDoubanLink: false,
|
||||||
|
showRating: false,
|
||||||
|
},
|
||||||
|
favorite: {
|
||||||
|
showSourceName: true,
|
||||||
|
showProgress: false,
|
||||||
|
showPlayButton: true,
|
||||||
|
showHeart: true,
|
||||||
|
showCheckCircle: false,
|
||||||
|
showDoubanLink: false,
|
||||||
|
showRating: false,
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
showSourceName: true,
|
||||||
|
showProgress: false,
|
||||||
|
showPlayButton: true,
|
||||||
|
showHeart: !isAggregate,
|
||||||
|
showCheckCircle: false,
|
||||||
|
showDoubanLink: !!actualDoubanId,
|
||||||
|
showRating: false,
|
||||||
|
},
|
||||||
|
douban: {
|
||||||
|
showSourceName: false,
|
||||||
|
showProgress: false,
|
||||||
|
showPlayButton: true,
|
||||||
|
showHeart: false,
|
||||||
|
showCheckCircle: false,
|
||||||
|
showDoubanLink: true,
|
||||||
|
showRating: !!rate,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return configs[from] || configs.search;
|
||||||
|
}, [from, isAggregate, actualDoubanId, rate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className='group relative w-full rounded-lg bg-transparent cursor-pointer transition-all duration-300 ease-in-out hover:scale-[1.05] hover:z-[500]'
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
{/* 海报容器 */}
|
||||||
|
<div className='relative aspect-[2/3] overflow-hidden rounded-lg'>
|
||||||
|
{/* 骨架屏 */}
|
||||||
|
{!isLoading && <ImagePlaceholder aspectRatio='aspect-[2/3]' />}
|
||||||
|
{/* 图片 */}
|
||||||
|
<Image
|
||||||
|
src={processImageUrl(actualPoster)}
|
||||||
|
alt={actualTitle}
|
||||||
|
fill
|
||||||
|
className='object-cover'
|
||||||
|
referrerPolicy='no-referrer'
|
||||||
|
onLoadingComplete={() => setIsLoading(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 悬浮遮罩 */}
|
||||||
|
<div className='absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-0 transition-opacity duration-300 ease-in-out group-hover:opacity-100' />
|
||||||
|
|
||||||
|
{/* 播放按钮 */}
|
||||||
|
{config.showPlayButton && (
|
||||||
|
<div className='absolute inset-0 flex items-center justify-center opacity-0 transition-all duration-300 ease-in-out delay-75 group-hover:opacity-100 group-hover:scale-100'>
|
||||||
|
<PlayCircleIcon
|
||||||
|
size={50}
|
||||||
|
strokeWidth={0.8}
|
||||||
|
className='text-white fill-transparent transition-all duration-300 ease-out hover:fill-green-500 hover:scale-[1.1]'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
{(config.showHeart || config.showCheckCircle) && (
|
||||||
|
<div className='absolute bottom-3 right-3 flex gap-3 opacity-0 translate-y-2 transition-all duration-300 ease-in-out group-hover:opacity-100 group-hover:translate-y-0'>
|
||||||
|
{config.showCheckCircle && (
|
||||||
|
<CheckCircle
|
||||||
|
onClick={handleDeleteRecord}
|
||||||
|
size={20}
|
||||||
|
className='text-white transition-all duration-300 ease-out hover:stroke-green-500 hover:scale-[1.1]'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{config.showHeart && (
|
||||||
|
<Heart
|
||||||
|
onClick={handleToggleFavorite}
|
||||||
|
size={20}
|
||||||
|
className={`transition-all duration-300 ease-out ${
|
||||||
|
favorited
|
||||||
|
? 'fill-red-600 stroke-red-600'
|
||||||
|
: 'fill-transparent stroke-white hover:stroke-red-400'
|
||||||
|
} hover:scale-[1.1]`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 徽章 */}
|
||||||
|
{config.showRating && rate && (
|
||||||
|
<div className='absolute top-2 right-2 bg-pink-500 text-white text-xs font-bold w-7 h-7 rounded-full flex items-center justify-center shadow-md transition-all duration-300 ease-out group-hover:scale-110'>
|
||||||
|
{rate}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{actualEpisodes && actualEpisodes > 1 && (
|
||||||
|
<div className='absolute top-2 right-2 bg-green-500 text-white text-xs font-semibold px-2 py-1 rounded-md shadow-md transition-all duration-300 ease-out group-hover:scale-110'>
|
||||||
|
{currentEpisode
|
||||||
|
? `${currentEpisode}/${actualEpisodes}`
|
||||||
|
: actualEpisodes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 豆瓣链接 */}
|
||||||
|
{config.showDoubanLink && actualDoubanId && (
|
||||||
|
<a
|
||||||
|
href={`https://movie.douban.com/subject/${actualDoubanId}`}
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className='absolute top-2 left-2 opacity-0 -translate-x-2 transition-all duration-300 ease-in-out delay-100 group-hover:opacity-100 group-hover:translate-x-0'
|
||||||
|
>
|
||||||
|
<div className='bg-green-500 text-white text-xs font-bold w-7 h-7 rounded-full flex items-center justify-center shadow-md hover:bg-green-600 hover:scale-[1.1] transition-all duration-300 ease-out'>
|
||||||
|
<Link size={16} />
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 进度条 */}
|
||||||
|
{config.showProgress && progress !== undefined && (
|
||||||
|
<div className='mt-1 h-1 w-full bg-gray-200 rounded-full overflow-hidden'>
|
||||||
|
<div
|
||||||
|
className='h-full bg-green-500 transition-all duration-500 ease-out'
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 标题与来源 */}
|
||||||
|
<div className='mt-2 text-center'>
|
||||||
|
<div className='relative'>
|
||||||
|
<span className='block text-sm font-semibold truncate text-gray-900 dark:text-gray-100 transition-colors duration-300 ease-in-out group-hover:text-green-600 dark:group-hover:text-green-400 peer'>
|
||||||
|
{actualTitle}
|
||||||
|
</span>
|
||||||
|
{/* 自定义 tooltip */}
|
||||||
|
<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 peer-hover:opacity-100 peer-hover:visible transition-all duration-200 ease-out delay-100 whitespace-nowrap pointer-events-none'>
|
||||||
|
{actualTitle}
|
||||||
|
<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>
|
||||||
|
{config.showSourceName && source_name && (
|
||||||
|
<span className='block text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||||
|
<span className='inline-block border rounded px-2 py-0.5 border-gray-500/60 dark:border-gray-400/60 transition-all duration-300 ease-in-out group-hover:border-green-500/60 group-hover:text-green-600 dark:group-hover:text-green-400'>
|
||||||
|
{source_name}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export interface AdminConfig {
|
||||||
|
SiteConfig: {
|
||||||
|
SiteName: string;
|
||||||
|
Announcement: string;
|
||||||
|
SearchDownstreamMaxPage: number;
|
||||||
|
SiteInterfaceCacheTime: number;
|
||||||
|
ImageProxy: string;
|
||||||
|
DoubanProxy: string;
|
||||||
|
};
|
||||||
|
UserConfig: {
|
||||||
|
AllowRegister: boolean;
|
||||||
|
Users: {
|
||||||
|
username: string;
|
||||||
|
role: 'user' | 'admin' | 'owner';
|
||||||
|
banned?: boolean;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
SourceConfig: {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
api: string;
|
||||||
|
detail?: string;
|
||||||
|
from: 'config' | 'custom';
|
||||||
|
disabled?: boolean;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminConfigResult {
|
||||||
|
Role: 'owner' | 'admin';
|
||||||
|
Config: AdminConfig;
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
// 从cookie获取认证信息 (服务端使用)
|
||||||
|
export function getAuthInfoFromCookie(request: NextRequest): {
|
||||||
|
password?: string;
|
||||||
|
username?: string;
|
||||||
|
signature?: string;
|
||||||
|
timestamp?: number;
|
||||||
|
} | null {
|
||||||
|
const authCookie = request.cookies.get('auth');
|
||||||
|
|
||||||
|
if (!authCookie) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = decodeURIComponent(authCookie.value);
|
||||||
|
const authData = JSON.parse(decoded);
|
||||||
|
return authData;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从cookie获取认证信息 (客户端使用)
|
||||||
|
export function getAuthInfoFromBrowserCookie(): {
|
||||||
|
password?: string;
|
||||||
|
username?: string;
|
||||||
|
signature?: string;
|
||||||
|
timestamp?: number;
|
||||||
|
role?: 'owner' | 'admin' | 'user';
|
||||||
|
} | null {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 解析 document.cookie
|
||||||
|
const cookies = document.cookie.split(';').reduce((acc, cookie) => {
|
||||||
|
const trimmed = cookie.trim();
|
||||||
|
const firstEqualIndex = trimmed.indexOf('=');
|
||||||
|
|
||||||
|
if (firstEqualIndex > 0) {
|
||||||
|
const key = trimmed.substring(0, firstEqualIndex);
|
||||||
|
const value = trimmed.substring(firstEqualIndex + 1);
|
||||||
|
if (key && value) {
|
||||||
|
acc[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, string>);
|
||||||
|
|
||||||
|
const authCookie = cookies['auth'];
|
||||||
|
if (!authCookie) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理可能的双重编码
|
||||||
|
let decoded = decodeURIComponent(authCookie);
|
||||||
|
|
||||||
|
// 如果解码后仍然包含 %,说明是双重编码,需要再次解码
|
||||||
|
if (decoded.includes('%')) {
|
||||||
|
decoded = decodeURIComponent(decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authData = JSON.parse(decoded);
|
||||||
|
return authData;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any, no-console, @typescript-eslint/no-non-null-assertion */
|
||||||
|
|
||||||
|
import { getStorage } from '@/lib/db';
|
||||||
|
|
||||||
|
import { AdminConfig } from './admin.types';
|
||||||
|
import runtimeConfig from './runtime';
|
||||||
|
|
||||||
|
export interface ApiSite {
|
||||||
|
key: string;
|
||||||
|
api: string;
|
||||||
|
name: string;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigFileStruct {
|
||||||
|
cache_time?: number;
|
||||||
|
api_site: {
|
||||||
|
[key: string]: ApiSite;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const API_CONFIG = {
|
||||||
|
search: {
|
||||||
|
path: '?ac=videolist&wd=',
|
||||||
|
pagePath: '?ac=videolist&wd={query}&pg={page}',
|
||||||
|
headers: {
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
detail: {
|
||||||
|
path: '?ac=videolist&ids=',
|
||||||
|
headers: {
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 在模块加载时根据环境决定配置来源
|
||||||
|
let fileConfig: ConfigFileStruct;
|
||||||
|
let cachedConfig: AdminConfig;
|
||||||
|
|
||||||
|
async function initConfig() {
|
||||||
|
if (cachedConfig) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.DOCKER_ENV === 'true') {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
||||||
|
const _require = eval('require') as NodeRequire;
|
||||||
|
const fs = _require('fs') as typeof import('fs');
|
||||||
|
const path = _require('path') as typeof import('path');
|
||||||
|
|
||||||
|
const configPath = path.join(process.cwd(), 'config.json');
|
||||||
|
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||||
|
fileConfig = JSON.parse(raw) as ConfigFileStruct;
|
||||||
|
console.log('load dynamic config success');
|
||||||
|
} else {
|
||||||
|
// 默认使用编译时生成的配置
|
||||||
|
fileConfig = runtimeConfig as unknown as ConfigFileStruct;
|
||||||
|
}
|
||||||
|
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||||
|
if (storageType !== 'localstorage') {
|
||||||
|
// 数据库存储,读取并补全管理员配置
|
||||||
|
const storage = getStorage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 尝试从数据库获取管理员配置
|
||||||
|
let adminConfig: AdminConfig | null = null;
|
||||||
|
if (storage && typeof (storage as any).getAdminConfig === 'function') {
|
||||||
|
adminConfig = await (storage as any).getAdminConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有用户名,用于补全 Users
|
||||||
|
let userNames: string[] = [];
|
||||||
|
if (storage && typeof (storage as any).getAllUsers === 'function') {
|
||||||
|
try {
|
||||||
|
userNames = await (storage as any).getAllUsers();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取用户列表失败:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从文件中获取源信息,用于补全源
|
||||||
|
const apiSiteEntries = Object.entries(fileConfig.api_site);
|
||||||
|
|
||||||
|
if (adminConfig) {
|
||||||
|
// 补全 SourceConfig
|
||||||
|
const existed = new Set(
|
||||||
|
(adminConfig.SourceConfig || []).map((s) => s.key)
|
||||||
|
);
|
||||||
|
apiSiteEntries.forEach(([key, site]) => {
|
||||||
|
if (!existed.has(key)) {
|
||||||
|
adminConfig!.SourceConfig.push({
|
||||||
|
key,
|
||||||
|
name: site.name,
|
||||||
|
api: site.api,
|
||||||
|
detail: site.detail,
|
||||||
|
from: 'config',
|
||||||
|
disabled: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 检查现有源是否在 fileConfig.api_site 中,如果不在则标记为 custom
|
||||||
|
const apiSiteKeys = new Set(apiSiteEntries.map(([key]) => key));
|
||||||
|
adminConfig.SourceConfig.forEach((source) => {
|
||||||
|
if (!apiSiteKeys.has(source.key)) {
|
||||||
|
source.from = 'custom';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const existedUsers = new Set(
|
||||||
|
(adminConfig.UserConfig.Users || []).map((u) => u.username)
|
||||||
|
);
|
||||||
|
userNames.forEach((uname) => {
|
||||||
|
if (!existedUsers.has(uname)) {
|
||||||
|
adminConfig!.UserConfig.Users.push({
|
||||||
|
username: uname,
|
||||||
|
role: 'user',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 站长
|
||||||
|
const ownerUser = process.env.USERNAME;
|
||||||
|
if (ownerUser) {
|
||||||
|
adminConfig!.UserConfig.Users = adminConfig!.UserConfig.Users.filter(
|
||||||
|
(u) => u.username !== ownerUser
|
||||||
|
);
|
||||||
|
adminConfig!.UserConfig.Users.unshift({
|
||||||
|
username: ownerUser,
|
||||||
|
role: 'owner',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 数据库中没有配置,创建新的管理员配置
|
||||||
|
let allUsers = userNames.map((uname) => ({
|
||||||
|
username: uname,
|
||||||
|
role: 'user',
|
||||||
|
}));
|
||||||
|
const ownerUser = process.env.USERNAME;
|
||||||
|
if (ownerUser) {
|
||||||
|
allUsers = allUsers.filter((u) => u.username !== ownerUser);
|
||||||
|
allUsers.unshift({
|
||||||
|
username: ownerUser,
|
||||||
|
role: 'owner',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
adminConfig = {
|
||||||
|
SiteConfig: {
|
||||||
|
SiteName: process.env.SITE_NAME || 'MoonTV',
|
||||||
|
Announcement:
|
||||||
|
process.env.ANNOUNCEMENT ||
|
||||||
|
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
|
||||||
|
SearchDownstreamMaxPage:
|
||||||
|
Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
|
||||||
|
SiteInterfaceCacheTime: fileConfig.cache_time || 7200,
|
||||||
|
ImageProxy: process.env.NEXT_PUBLIC_IMAGE_PROXY || '',
|
||||||
|
DoubanProxy: process.env.NEXT_PUBLIC_DOUBAN_PROXY || '',
|
||||||
|
},
|
||||||
|
UserConfig: {
|
||||||
|
AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true',
|
||||||
|
Users: allUsers as any,
|
||||||
|
},
|
||||||
|
SourceConfig: apiSiteEntries.map(([key, site]) => ({
|
||||||
|
key,
|
||||||
|
name: site.name,
|
||||||
|
api: site.api,
|
||||||
|
detail: site.detail,
|
||||||
|
from: 'config',
|
||||||
|
disabled: false,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写回数据库(更新/创建)
|
||||||
|
if (storage && typeof (storage as any).setAdminConfig === 'function') {
|
||||||
|
await (storage as any).setAdminConfig(adminConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新缓存
|
||||||
|
cachedConfig = adminConfig;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载管理员配置失败:', err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 本地存储直接使用文件配置
|
||||||
|
cachedConfig = {
|
||||||
|
SiteConfig: {
|
||||||
|
SiteName: process.env.SITE_NAME || 'MoonTV',
|
||||||
|
Announcement:
|
||||||
|
process.env.ANNOUNCEMENT ||
|
||||||
|
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
|
||||||
|
SearchDownstreamMaxPage:
|
||||||
|
Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
|
||||||
|
SiteInterfaceCacheTime: fileConfig.cache_time || 7200,
|
||||||
|
ImageProxy: process.env.NEXT_PUBLIC_IMAGE_PROXY || '',
|
||||||
|
DoubanProxy: process.env.NEXT_PUBLIC_DOUBAN_PROXY || '',
|
||||||
|
},
|
||||||
|
UserConfig: {
|
||||||
|
AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true',
|
||||||
|
Users: [],
|
||||||
|
},
|
||||||
|
SourceConfig: Object.entries(fileConfig.api_site).map(([key, site]) => ({
|
||||||
|
key,
|
||||||
|
name: site.name,
|
||||||
|
api: site.api,
|
||||||
|
detail: site.detail,
|
||||||
|
from: 'config',
|
||||||
|
disabled: false,
|
||||||
|
})),
|
||||||
|
} as AdminConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConfig(): Promise<AdminConfig> {
|
||||||
|
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||||
|
if (process.env.DOCKER_ENV === 'true' || storageType === 'localstorage') {
|
||||||
|
await initConfig();
|
||||||
|
return cachedConfig;
|
||||||
|
}
|
||||||
|
// 非 docker 环境且 DB 存储,直接读 db 配置
|
||||||
|
const storage = getStorage();
|
||||||
|
let adminConfig: AdminConfig | null = null;
|
||||||
|
if (storage && typeof (storage as any).getAdminConfig === 'function') {
|
||||||
|
adminConfig = await (storage as any).getAdminConfig();
|
||||||
|
}
|
||||||
|
if (adminConfig) {
|
||||||
|
// 合并一些环境变量配置
|
||||||
|
adminConfig.SiteConfig.SiteName = process.env.SITE_NAME || 'MoonTV';
|
||||||
|
adminConfig.SiteConfig.Announcement =
|
||||||
|
process.env.ANNOUNCEMENT ||
|
||||||
|
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。';
|
||||||
|
adminConfig.UserConfig.AllowRegister =
|
||||||
|
process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true';
|
||||||
|
adminConfig.SiteConfig.ImageProxy =
|
||||||
|
process.env.NEXT_PUBLIC_IMAGE_PROXY || '';
|
||||||
|
adminConfig.SiteConfig.DoubanProxy =
|
||||||
|
process.env.NEXT_PUBLIC_DOUBAN_PROXY || '';
|
||||||
|
|
||||||
|
// 合并文件中的源信息
|
||||||
|
fileConfig = runtimeConfig as unknown as ConfigFileStruct;
|
||||||
|
const apiSiteEntries = Object.entries(fileConfig.api_site);
|
||||||
|
const existed = new Set((adminConfig.SourceConfig || []).map((s) => s.key));
|
||||||
|
apiSiteEntries.forEach(([key, site]) => {
|
||||||
|
if (!existed.has(key)) {
|
||||||
|
adminConfig!.SourceConfig.push({
|
||||||
|
key,
|
||||||
|
name: site.name,
|
||||||
|
api: site.api,
|
||||||
|
detail: site.detail,
|
||||||
|
from: 'config',
|
||||||
|
disabled: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 检查现有源是否在 fileConfig.api_site 中,如果不在则标记为 custom
|
||||||
|
const apiSiteKeys = new Set(apiSiteEntries.map(([key]) => key));
|
||||||
|
adminConfig.SourceConfig.forEach((source) => {
|
||||||
|
if (!apiSiteKeys.has(source.key)) {
|
||||||
|
source.from = 'custom';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const ownerUser = process.env.USERNAME || '';
|
||||||
|
// 检查配置中的站长用户是否和 USERNAME 匹配,如果不匹配则降级为普通用户
|
||||||
|
let containOwner = false;
|
||||||
|
adminConfig.UserConfig.Users.forEach((user) => {
|
||||||
|
if (user.username !== ownerUser && user.role === 'owner') {
|
||||||
|
user.role = 'user';
|
||||||
|
}
|
||||||
|
if (user.username === ownerUser) {
|
||||||
|
containOwner = true;
|
||||||
|
user.role = 'owner';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果不在则添加
|
||||||
|
if (!containOwner) {
|
||||||
|
adminConfig.UserConfig.Users.unshift({
|
||||||
|
username: ownerUser,
|
||||||
|
role: 'owner',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cachedConfig = adminConfig;
|
||||||
|
} else {
|
||||||
|
// DB 无配置,执行一次初始化
|
||||||
|
await initConfig();
|
||||||
|
}
|
||||||
|
return cachedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetConfig() {
|
||||||
|
const storage = getStorage();
|
||||||
|
// 获取所有用户名,用于补全 Users
|
||||||
|
let userNames: string[] = [];
|
||||||
|
if (storage && typeof (storage as any).getAllUsers === 'function') {
|
||||||
|
try {
|
||||||
|
userNames = await (storage as any).getAllUsers();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取用户列表失败:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.DOCKER_ENV === 'true') {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
||||||
|
const _require = eval('require') as NodeRequire;
|
||||||
|
const fs = _require('fs') as typeof import('fs');
|
||||||
|
const path = _require('path') as typeof import('path');
|
||||||
|
|
||||||
|
const configPath = path.join(process.cwd(), 'config.json');
|
||||||
|
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||||
|
fileConfig = JSON.parse(raw) as ConfigFileStruct;
|
||||||
|
console.log('load dynamic config success');
|
||||||
|
} else {
|
||||||
|
// 默认使用编译时生成的配置
|
||||||
|
fileConfig = runtimeConfig as unknown as ConfigFileStruct;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从文件中获取源信息,用于补全源
|
||||||
|
const apiSiteEntries = Object.entries(fileConfig.api_site);
|
||||||
|
let allUsers = userNames.map((uname) => ({
|
||||||
|
username: uname,
|
||||||
|
role: 'user',
|
||||||
|
}));
|
||||||
|
const ownerUser = process.env.USERNAME;
|
||||||
|
if (ownerUser) {
|
||||||
|
allUsers = allUsers.filter((u) => u.username !== ownerUser);
|
||||||
|
allUsers.unshift({
|
||||||
|
username: ownerUser,
|
||||||
|
role: 'owner',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const adminConfig = {
|
||||||
|
SiteConfig: {
|
||||||
|
SiteName: process.env.SITE_NAME || 'MoonTV',
|
||||||
|
Announcement:
|
||||||
|
process.env.ANNOUNCEMENT ||
|
||||||
|
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。',
|
||||||
|
SearchDownstreamMaxPage:
|
||||||
|
Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
|
||||||
|
SiteInterfaceCacheTime: fileConfig.cache_time || 7200,
|
||||||
|
ImageProxy: process.env.NEXT_PUBLIC_IMAGE_PROXY || '',
|
||||||
|
DoubanProxy: process.env.NEXT_PUBLIC_DOUBAN_PROXY || '',
|
||||||
|
},
|
||||||
|
UserConfig: {
|
||||||
|
AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true',
|
||||||
|
Users: allUsers as any,
|
||||||
|
},
|
||||||
|
SourceConfig: apiSiteEntries.map(([key, site]) => ({
|
||||||
|
key,
|
||||||
|
name: site.name,
|
||||||
|
api: site.api,
|
||||||
|
detail: site.detail,
|
||||||
|
from: 'config',
|
||||||
|
disabled: false,
|
||||||
|
})),
|
||||||
|
} as AdminConfig;
|
||||||
|
|
||||||
|
if (storage && typeof (storage as any).setAdminConfig === 'function') {
|
||||||
|
await (storage as any).setAdminConfig(adminConfig);
|
||||||
|
}
|
||||||
|
if (cachedConfig == null) {
|
||||||
|
// serverless 环境,直接使用 adminConfig
|
||||||
|
cachedConfig = adminConfig;
|
||||||
|
}
|
||||||
|
cachedConfig.SiteConfig = adminConfig.SiteConfig;
|
||||||
|
cachedConfig.UserConfig = adminConfig.UserConfig;
|
||||||
|
cachedConfig.SourceConfig = adminConfig.SourceConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCacheTime(): Promise<number> {
|
||||||
|
const config = await getConfig();
|
||||||
|
return config.SiteConfig.SiteInterfaceCacheTime || 7200;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAvailableApiSites(): Promise<ApiSite[]> {
|
||||||
|
const config = await getConfig();
|
||||||
|
return config.SourceConfig.filter((s) => !s.disabled).map((s) => ({
|
||||||
|
key: s.key,
|
||||||
|
name: s.name,
|
||||||
|
api: s.api,
|
||||||
|
detail: s.detail,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
||||||
|
|
||||||
|
import { AdminConfig } from './admin.types';
|
||||||
|
import { Favorite, IStorage, PlayRecord } from './types';
|
||||||
|
|
||||||
|
// 搜索历史最大条数
|
||||||
|
const SEARCH_HISTORY_LIMIT = 20;
|
||||||
|
|
||||||
|
// D1 数据库接口
|
||||||
|
interface D1Database {
|
||||||
|
prepare(sql: string): D1PreparedStatement;
|
||||||
|
exec(sql: string): Promise<D1ExecResult>;
|
||||||
|
batch(statements: D1PreparedStatement[]): Promise<D1Result[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface D1PreparedStatement {
|
||||||
|
bind(...values: any[]): D1PreparedStatement;
|
||||||
|
first<T = any>(colName?: string): Promise<T | null>;
|
||||||
|
run(): Promise<D1Result>;
|
||||||
|
all<T = any>(): Promise<D1Result<T>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface D1Result<T = any> {
|
||||||
|
results: T[];
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
meta: {
|
||||||
|
changed_db: boolean;
|
||||||
|
changes: number;
|
||||||
|
last_row_id: number;
|
||||||
|
duration: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface D1ExecResult {
|
||||||
|
count: number;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取全局D1数据库实例
|
||||||
|
function getD1Database(): D1Database {
|
||||||
|
return (process.env as any).DB as D1Database;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class D1Storage implements IStorage {
|
||||||
|
private db: D1Database | null = null;
|
||||||
|
|
||||||
|
private async getDatabase(): Promise<D1Database> {
|
||||||
|
if (!this.db) {
|
||||||
|
this.db = getD1Database();
|
||||||
|
}
|
||||||
|
return this.db;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 播放记录相关
|
||||||
|
async getPlayRecord(
|
||||||
|
userName: string,
|
||||||
|
key: string
|
||||||
|
): Promise<PlayRecord | null> {
|
||||||
|
try {
|
||||||
|
const db = await this.getDatabase();
|
||||||
|
const result = await db
|
||||||
|
.prepare('SELECT * FROM play_records WHERE username = ? AND key = ?')
|
||||||
|
.bind(userName, key)
|
||||||
|
.first<any>();
|
||||||
|
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: result.title,
|
||||||
|
source_name: result.source_name,
|
||||||
|
cover: result.cover,
|
||||||
|
year: result.year,
|
||||||
|
index: result.index_episode,
|
||||||
|
total_episodes: result.total_episodes,
|
||||||
|
play_time: result.play_time,
|
||||||
|
total_time: result.total_time,
|
||||||
|
save_time: result.save_time,
|
||||||
|
search_title: result.search_title || undefined,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to get play record:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPlayRecord(
|
||||||
|
userName: string,
|
||||||
|
key: string,
|
||||||
|
record: PlayRecord
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const db = await this.getDatabase();
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
INSERT OR REPLACE INTO play_records
|
||||||
|
(username, key, title, source_name, cover, year, index_episode, total_episodes, play_time, total_time, save_time, search_title)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
userName,
|
||||||
|
key,
|
||||||
|
record.title,
|
||||||
|
record.source_name,
|
||||||
|
record.cover,
|
||||||
|
record.year,
|
||||||
|
record.index,
|
||||||
|
record.total_episodes,
|
||||||
|
record.play_time,
|
||||||
|
record.total_time,
|
||||||
|
record.save_time,
|
||||||
|
record.search_title || null
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to set play record:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllPlayRecords(
|
||||||
|
userName: string
|
||||||
|
): Promise<Record<string, PlayRecord>> {
|
||||||
|
try {
|
||||||
|
const db = await this.getDatabase();
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
'SELECT * FROM play_records WHERE username = ? ORDER BY save_time DESC'
|
||||||
|
)
|
||||||
|
.bind(userName)
|
||||||
|
.all<any>();
|
||||||
|
|
||||||
|
const records: Record<string, PlayRecord> = {};
|
||||||
|
|
||||||
|
result.results.forEach((row: any) => {
|
||||||
|
records[row.key] = {
|
||||||
|
title: row.title,
|
||||||
|
source_name: row.source_name,
|
||||||
|
cover: row.cover,
|
||||||
|
year: row.year,
|
||||||
|
index: row.index_episode,
|
||||||
|
total_episodes: row.total_episodes,
|
||||||
|
play_time: row.play_time,
|
||||||
|
total_time: row.total_time,
|
||||||
|
save_time: row.save_time,
|
||||||
|
search_title: row.search_title || undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return records;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to get all play records:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePlayRecord(userName: string, key: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const db = await this.getDatabase();
|
||||||
|
await db
|
||||||
|
.prepare('DELETE FROM play_records WHERE username = ? AND key = ?')
|
||||||
|
.bind(userName, key)
|
||||||
|
.run();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete play record:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收藏相关
|
||||||
|
async getFavorite(userName: string, key: string): Promise<Favorite | null> {
|
||||||
|
try {
|
||||||
|
const db = await this.getDatabase();
|
||||||
|
const result = await db
|
||||||
|
.prepare('SELECT * FROM favorites WHERE username = ? AND key = ?')
|
||||||
|
.bind(userName, key)
|
||||||
|
.first<any>();
|
||||||
|
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: result.title,
|
||||||
|
source_name: result.source_name,
|
||||||
|
cover: result.cover,
|
||||||
|
year: result.year,
|
||||||
|
total_episodes: result.total_episodes,
|
||||||
|
save_time: result.save_time,
|
||||||
|
search_title: result.search_title,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to get favorite:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setFavorite(
|
||||||
|
userName: string,
|
||||||
|
key: string,
|
||||||
|
favorite: Favorite
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const db = await this.getDatabase();
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
INSERT OR REPLACE INTO favorites
|
||||||
|
(username, key, title, source_name, cover, year, total_episodes, save_time)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
userName,
|
||||||
|
key,
|
||||||
|
favorite.title,
|
||||||
|
favorite.source_name,
|
||||||
|
favorite.cover,
|
||||||
|
favorite.year,
|
||||||
|
favorite.total_episodes,
|
||||||
|
favorite.save_time
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to set favorite:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllFavorites(userName: string): Promise<Record<string, Favorite>> {
|
||||||
|
try {
|
||||||
|
const db = await this.getDatabase();
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
'SELECT * FROM favorites WHERE username = ? ORDER BY save_time DESC'
|
||||||
|
)
|
||||||
|
.bind(userName)
|
||||||
|
.all<any>();
|
||||||
|
|
||||||
|
const favorites: Record<string, Favorite> = {};
|
||||||
|
|
||||||
|
result.results.forEach((row: any) => {
|
||||||
|
favorites[row.key] = {
|
||||||
|
title: row.title,
|
||||||
|
source_name: row.source_name,
|
||||||
|
cover: row.cover,
|
||||||
|
year: row.year,
|
||||||
|
total_episodes: row.total_episodes,
|
||||||
|
save_time: row.save_time,
|
||||||
|
search_title: row.search_title,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return favorites;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to get all favorites:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFavorite(userName: string, key: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const db = await this.getDatabase();
|
||||||
|
await db
|
||||||
|
.prepare('DELETE FROM favorites WHERE username = ? AND key = ?')
|
||||||
|
.bind(userName, key)
|
||||||
|
.run();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete favorite:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户相关
|
||||||
|
async registerUser(userName: string, password: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const db = await this.getDatabase();
|
||||||
|
await db
|
||||||
|
.prepare('INSERT INTO users (username, password) VALUES (?, ?)')
|
||||||
|
.bind(userName, password)
|
||||||
|
.run();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to register user:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyUser(userName: string, password: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const db = await this.getDatabase();
|
||||||
|
const result = await db
|
||||||
|
.prepare('SELECT password FROM users WHERE username = ?')
|
||||||
|
.bind(userName)
|
||||||
|
.first<{ password: string }>();
|
||||||
|
|
||||||
|
return result?.password === password;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to verify user:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkUserExist(userName: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const db = await this.getDatabase();
|
||||||
|
const result = await db
|
||||||
|
.prepare('SELECT 1 FROM users WHERE username = ?')
|
||||||
|
.bind(userName)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
return result !== null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to check user existence:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async changePassword(userName: string, newPassword: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const db = await this.getDatabase();
|
||||||
|
await db
|
||||||
|
.prepare('UPDATE users SET password = ? WHERE username = ?')
|
||||||
|
.bind(newPassword, userName)
|
||||||
|
.run();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to change password:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUser(userName: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const db = await this.getDatabase();
|
||||||
|
const statements = [
|
||||||
|
db.prepare('DELETE FROM users WHERE username = ?').bind(userName),
|
||||||
|
db
|
||||||
|
.prepare('DELETE FROM play_records WHERE username = ?')
|
||||||
|
.bind(userName),
|
||||||
|
db.prepare('DELETE FROM favorites WHERE username = ?').bind(userName),
|
||||||
|
db
|
||||||
|
.prepare('DELETE FROM search_history WHERE username = ?')
|
||||||
|
.bind(userName),
|
||||||
|
];
|
||||||
|
|
||||||
|
await db.batch(statements);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete user:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索历史相关
|
||||||
|
async getSearchHistory(userName: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const db = await this.getDatabase();
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
'SELECT keyword FROM search_history WHERE username = ? ORDER BY created_at DESC LIMIT ?'
|
||||||
|
)
|
||||||
|
.bind(userName, SEARCH_HISTORY_LIMIT)
|
||||||
|
.all<{ keyword: string }>();
|
||||||
|
|
||||||
|
return result.results.map((row) => row.keyword);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to get search history:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addSearchHistory(userName: string, keyword: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const db = await this.getDatabase();
|
||||||
|
// 先删除可能存在的重复记录
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'DELETE FROM search_history WHERE username = ? AND keyword = ?'
|
||||||
|
)
|
||||||
|
.bind(userName, keyword)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// 添加新记录
|
||||||
|
await db
|
||||||
|
.prepare('INSERT INTO search_history (username, keyword) VALUES (?, ?)')
|
||||||
|
.bind(userName, keyword)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// 保持历史记录条数限制
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
DELETE FROM search_history
|
||||||
|
WHERE username = ? AND id NOT IN (
|
||||||
|
SELECT id FROM search_history
|
||||||
|
WHERE username = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
)
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.bind(userName, userName, SEARCH_HISTORY_LIMIT)
|
||||||
|
.run();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to add search history:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSearchHistory(userName: string, keyword?: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const db = await this.getDatabase();
|
||||||
|
if (keyword) {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'DELETE FROM search_history WHERE username = ? AND keyword = ?'
|
||||||
|
)
|
||||||
|
.bind(userName, keyword)
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
await db
|
||||||
|
.prepare('DELETE FROM search_history WHERE username = ?')
|
||||||
|
.bind(userName)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete search history:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户列表
|
||||||
|
async getAllUsers(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const db = await this.getDatabase();
|
||||||
|
const result = await db
|
||||||
|
.prepare('SELECT username FROM users ORDER BY created_at ASC')
|
||||||
|
.all<{ username: string }>();
|
||||||
|
|
||||||
|
return result.results.map((row) => row.username);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to get all users:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理员配置相关
|
||||||
|
async getAdminConfig(): Promise<AdminConfig | null> {
|
||||||
|
try {
|
||||||
|
const db = await this.getDatabase();
|
||||||
|
const result = await db
|
||||||
|
.prepare('SELECT config FROM admin_config WHERE id = 1')
|
||||||
|
.first<{ config: string }>();
|
||||||
|
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
return JSON.parse(result.config) as AdminConfig;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to get admin config:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setAdminConfig(config: AdminConfig): Promise<void> {
|
||||||
|
try {
|
||||||
|
const db = await this.getDatabase();
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'INSERT OR REPLACE INTO admin_config (id, config) VALUES (1, ?)'
|
||||||
|
)
|
||||||
|
.bind(JSON.stringify(config))
|
||||||
|
.run();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to set admin config:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,1244 @@
|
|||||||
|
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-function */
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅在浏览器端使用的数据库工具,目前基于 localStorage 实现。
|
||||||
|
* 之所以单独拆分文件,是为了避免在客户端 bundle 中引入 `fs`, `path` 等 Node.js 内置模块,
|
||||||
|
* 从而解决诸如 "Module not found: Can't resolve 'fs'" 的问题。
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* 1. 获取全部播放记录(getAllPlayRecords)。
|
||||||
|
* 2. 保存播放记录(savePlayRecord)。
|
||||||
|
* 3. 数据库存储模式下的混合缓存策略,提升用户体验。
|
||||||
|
*
|
||||||
|
* 如后续需要在客户端读取收藏等其它数据,可按同样方式在此文件中补充实现。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getAuthInfoFromBrowserCookie } from './auth';
|
||||||
|
|
||||||
|
// ---- 类型 ----
|
||||||
|
export interface PlayRecord {
|
||||||
|
title: string;
|
||||||
|
source_name: string;
|
||||||
|
year: string;
|
||||||
|
cover: string;
|
||||||
|
index: number; // 第几集
|
||||||
|
total_episodes: number; // 总集数
|
||||||
|
play_time: number; // 播放进度(秒)
|
||||||
|
total_time: number; // 总进度(秒)
|
||||||
|
save_time: number; // 记录保存时间(时间戳)
|
||||||
|
search_title?: string; // 搜索时使用的标题
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 收藏类型 ----
|
||||||
|
export interface Favorite {
|
||||||
|
title: string;
|
||||||
|
source_name: string;
|
||||||
|
year: string;
|
||||||
|
cover: string;
|
||||||
|
total_episodes: number;
|
||||||
|
save_time: number;
|
||||||
|
search_title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 缓存数据结构 ----
|
||||||
|
interface CacheData<T> {
|
||||||
|
data: T;
|
||||||
|
timestamp: number;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserCacheStore {
|
||||||
|
playRecords?: CacheData<Record<string, PlayRecord>>;
|
||||||
|
favorites?: CacheData<Record<string, Favorite>>;
|
||||||
|
searchHistory?: CacheData<string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 常量 ----
|
||||||
|
const PLAY_RECORDS_KEY = 'moontv_play_records';
|
||||||
|
const FAVORITES_KEY = 'moontv_favorites';
|
||||||
|
const SEARCH_HISTORY_KEY = 'moontv_search_history';
|
||||||
|
|
||||||
|
// 缓存相关常量
|
||||||
|
const CACHE_PREFIX = 'moontv_cache_';
|
||||||
|
const CACHE_VERSION = '1.0.0';
|
||||||
|
const CACHE_EXPIRE_TIME = 60 * 60 * 1000; // 一小时缓存过期
|
||||||
|
|
||||||
|
// ---- 环境变量 ----
|
||||||
|
const STORAGE_TYPE = (() => {
|
||||||
|
const raw =
|
||||||
|
(typeof window !== 'undefined' &&
|
||||||
|
(window as any).RUNTIME_CONFIG?.STORAGE_TYPE) ||
|
||||||
|
(process.env.STORAGE_TYPE as
|
||||||
|
| 'localstorage'
|
||||||
|
| 'redis'
|
||||||
|
| 'd1'
|
||||||
|
| 'upstash'
|
||||||
|
| undefined) ||
|
||||||
|
'localstorage';
|
||||||
|
return raw;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ---------------- 搜索历史相关常量 ----------------
|
||||||
|
// 搜索历史最大保存条数
|
||||||
|
const SEARCH_HISTORY_LIMIT = 20;
|
||||||
|
|
||||||
|
// ---- 缓存管理器 ----
|
||||||
|
class HybridCacheManager {
|
||||||
|
private static instance: HybridCacheManager;
|
||||||
|
|
||||||
|
static getInstance(): HybridCacheManager {
|
||||||
|
if (!HybridCacheManager.instance) {
|
||||||
|
HybridCacheManager.instance = new HybridCacheManager();
|
||||||
|
}
|
||||||
|
return HybridCacheManager.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前用户名
|
||||||
|
*/
|
||||||
|
private getCurrentUsername(): string | null {
|
||||||
|
const authInfo = getAuthInfoFromBrowserCookie();
|
||||||
|
return authInfo?.username || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成用户专属的缓存key
|
||||||
|
*/
|
||||||
|
private getUserCacheKey(username: string): string {
|
||||||
|
return `${CACHE_PREFIX}${username}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户缓存数据
|
||||||
|
*/
|
||||||
|
private getUserCache(username: string): UserCacheStore {
|
||||||
|
if (typeof window === 'undefined') return {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cacheKey = this.getUserCacheKey(username);
|
||||||
|
const cached = localStorage.getItem(cacheKey);
|
||||||
|
return cached ? JSON.parse(cached) : {};
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('获取用户缓存失败:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存用户缓存数据
|
||||||
|
*/
|
||||||
|
private saveUserCache(username: string, cache: UserCacheStore): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cacheKey = this.getUserCacheKey(username);
|
||||||
|
localStorage.setItem(cacheKey, JSON.stringify(cache));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('保存用户缓存失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查缓存是否有效
|
||||||
|
*/
|
||||||
|
private isCacheValid<T>(cache: CacheData<T>): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
return (
|
||||||
|
cache.version === CACHE_VERSION &&
|
||||||
|
now - cache.timestamp < CACHE_EXPIRE_TIME
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建缓存数据
|
||||||
|
*/
|
||||||
|
private createCacheData<T>(data: T): CacheData<T> {
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
version: CACHE_VERSION,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取缓存的播放记录
|
||||||
|
*/
|
||||||
|
getCachedPlayRecords(): Record<string, PlayRecord> | null {
|
||||||
|
const username = this.getCurrentUsername();
|
||||||
|
if (!username) return null;
|
||||||
|
|
||||||
|
const userCache = this.getUserCache(username);
|
||||||
|
const cached = userCache.playRecords;
|
||||||
|
|
||||||
|
if (cached && this.isCacheValid(cached)) {
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存播放记录
|
||||||
|
*/
|
||||||
|
cachePlayRecords(data: Record<string, PlayRecord>): void {
|
||||||
|
const username = this.getCurrentUsername();
|
||||||
|
if (!username) return;
|
||||||
|
|
||||||
|
const userCache = this.getUserCache(username);
|
||||||
|
userCache.playRecords = this.createCacheData(data);
|
||||||
|
this.saveUserCache(username, userCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取缓存的收藏
|
||||||
|
*/
|
||||||
|
getCachedFavorites(): Record<string, Favorite> | null {
|
||||||
|
const username = this.getCurrentUsername();
|
||||||
|
if (!username) return null;
|
||||||
|
|
||||||
|
const userCache = this.getUserCache(username);
|
||||||
|
const cached = userCache.favorites;
|
||||||
|
|
||||||
|
if (cached && this.isCacheValid(cached)) {
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存收藏
|
||||||
|
*/
|
||||||
|
cacheFavorites(data: Record<string, Favorite>): void {
|
||||||
|
const username = this.getCurrentUsername();
|
||||||
|
if (!username) return;
|
||||||
|
|
||||||
|
const userCache = this.getUserCache(username);
|
||||||
|
userCache.favorites = this.createCacheData(data);
|
||||||
|
this.saveUserCache(username, userCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取缓存的搜索历史
|
||||||
|
*/
|
||||||
|
getCachedSearchHistory(): string[] | null {
|
||||||
|
const username = this.getCurrentUsername();
|
||||||
|
if (!username) return null;
|
||||||
|
|
||||||
|
const userCache = this.getUserCache(username);
|
||||||
|
const cached = userCache.searchHistory;
|
||||||
|
|
||||||
|
if (cached && this.isCacheValid(cached)) {
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 缓存搜索历史
|
||||||
|
*/
|
||||||
|
cacheSearchHistory(data: string[]): void {
|
||||||
|
const username = this.getCurrentUsername();
|
||||||
|
if (!username) return;
|
||||||
|
|
||||||
|
const userCache = this.getUserCache(username);
|
||||||
|
userCache.searchHistory = this.createCacheData(data);
|
||||||
|
this.saveUserCache(username, userCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除指定用户的所有缓存
|
||||||
|
*/
|
||||||
|
clearUserCache(username?: string): void {
|
||||||
|
const targetUsername = username || this.getCurrentUsername();
|
||||||
|
if (!targetUsername) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cacheKey = this.getUserCacheKey(targetUsername);
|
||||||
|
localStorage.removeItem(cacheKey);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('清除用户缓存失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除所有过期缓存
|
||||||
|
*/
|
||||||
|
clearExpiredCaches(): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const keysToRemove: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key?.startsWith(CACHE_PREFIX)) {
|
||||||
|
try {
|
||||||
|
const cache = JSON.parse(localStorage.getItem(key) || '{}');
|
||||||
|
// 检查是否有任何缓存数据过期
|
||||||
|
let hasValidData = false;
|
||||||
|
for (const [, cacheData] of Object.entries(cache)) {
|
||||||
|
if (cacheData && this.isCacheValid(cacheData as CacheData<any>)) {
|
||||||
|
hasValidData = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasValidData) {
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 解析失败的缓存也删除
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('清除过期缓存失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取缓存管理器实例
|
||||||
|
const cacheManager = HybridCacheManager.getInstance();
|
||||||
|
|
||||||
|
// ---- 错误处理辅助函数 ----
|
||||||
|
/**
|
||||||
|
* 数据库操作失败时的通用错误处理
|
||||||
|
* 立即从数据库刷新对应类型的缓存以保持数据一致性
|
||||||
|
*/
|
||||||
|
async function handleDatabaseOperationFailure(
|
||||||
|
dataType: 'playRecords' | 'favorites' | 'searchHistory',
|
||||||
|
error: any
|
||||||
|
): Promise<void> {
|
||||||
|
console.error(`数据库操作失败 (${dataType}):`, error);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let freshData: any;
|
||||||
|
let eventName: string;
|
||||||
|
|
||||||
|
switch (dataType) {
|
||||||
|
case 'playRecords':
|
||||||
|
freshData = await fetchFromApi<Record<string, PlayRecord>>(
|
||||||
|
`/api/playrecords`
|
||||||
|
);
|
||||||
|
cacheManager.cachePlayRecords(freshData);
|
||||||
|
eventName = 'playRecordsUpdated';
|
||||||
|
break;
|
||||||
|
case 'favorites':
|
||||||
|
freshData = await fetchFromApi<Record<string, Favorite>>(
|
||||||
|
`/api/favorites`
|
||||||
|
);
|
||||||
|
cacheManager.cacheFavorites(freshData);
|
||||||
|
eventName = 'favoritesUpdated';
|
||||||
|
break;
|
||||||
|
case 'searchHistory':
|
||||||
|
freshData = await fetchFromApi<string[]>(`/api/searchhistory`);
|
||||||
|
cacheManager.cacheSearchHistory(freshData);
|
||||||
|
eventName = 'searchHistoryUpdated';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发更新事件通知组件
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(eventName, {
|
||||||
|
detail: freshData,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (refreshErr) {
|
||||||
|
console.error(`刷新${dataType}缓存失败:`, refreshErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面加载时清理过期缓存
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
setTimeout(() => cacheManager.clearExpiredCaches(), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 工具函数 ----
|
||||||
|
async function fetchFromApi<T>(path: string): Promise<T> {
|
||||||
|
const res = await fetch(path);
|
||||||
|
if (!res.ok) throw new Error(`请求 ${path} 失败: ${res.status}`);
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成存储key
|
||||||
|
*/
|
||||||
|
export function generateStorageKey(source: string, id: string): string {
|
||||||
|
return `${source}+${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- API ----
|
||||||
|
/**
|
||||||
|
* 读取全部播放记录。
|
||||||
|
* D1 存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
|
||||||
|
* 在服务端渲染阶段 (window === undefined) 时返回空对象,避免报错。
|
||||||
|
*/
|
||||||
|
export async function getAllPlayRecords(): Promise<Record<string, PlayRecord>> {
|
||||||
|
// 服务器端渲染阶段直接返回空,交由客户端 useEffect 再行请求
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据库存储模式:使用混合缓存策略(包括 redis、d1、upstash)
|
||||||
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
|
// 优先从缓存获取数据
|
||||||
|
const cachedData = cacheManager.getCachedPlayRecords();
|
||||||
|
|
||||||
|
if (cachedData) {
|
||||||
|
// 返回缓存数据,同时后台异步更新
|
||||||
|
fetchFromApi<Record<string, PlayRecord>>(`/api/playrecords`)
|
||||||
|
.then((freshData) => {
|
||||||
|
// 只有数据真正不同时才更新缓存
|
||||||
|
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
|
||||||
|
cacheManager.cachePlayRecords(freshData);
|
||||||
|
// 触发数据更新事件,供组件监听
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('playRecordsUpdated', {
|
||||||
|
detail: freshData,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn('后台同步播放记录失败:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return cachedData;
|
||||||
|
} else {
|
||||||
|
// 缓存为空,直接从 API 获取并缓存
|
||||||
|
try {
|
||||||
|
const freshData = await fetchFromApi<Record<string, PlayRecord>>(
|
||||||
|
`/api/playrecords`
|
||||||
|
);
|
||||||
|
cacheManager.cachePlayRecords(freshData);
|
||||||
|
return freshData;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取播放记录失败:', err);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// localstorage 模式
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(PLAY_RECORDS_KEY);
|
||||||
|
if (!raw) return {};
|
||||||
|
return JSON.parse(raw) as Record<string, PlayRecord>;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('读取播放记录失败:', err);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存播放记录。
|
||||||
|
* 数据库存储模式下使用乐观更新:先更新缓存(立即生效),再异步同步到数据库。
|
||||||
|
*/
|
||||||
|
export async function savePlayRecord(
|
||||||
|
source: string,
|
||||||
|
id: string,
|
||||||
|
record: PlayRecord
|
||||||
|
): Promise<void> {
|
||||||
|
const key = generateStorageKey(source, id);
|
||||||
|
|
||||||
|
// 数据库存储模式:乐观更新策略(包括 redis、d1、upstash)
|
||||||
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
|
// 立即更新缓存
|
||||||
|
const cachedRecords = cacheManager.getCachedPlayRecords() || {};
|
||||||
|
cachedRecords[key] = record;
|
||||||
|
cacheManager.cachePlayRecords(cachedRecords);
|
||||||
|
|
||||||
|
// 触发立即更新事件
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('playRecordsUpdated', {
|
||||||
|
detail: cachedRecords,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 异步同步到数据库
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/playrecords', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ key, record }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`保存播放记录失败: ${res.status}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await handleDatabaseOperationFailure('playRecords', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// localstorage 模式
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
console.warn('无法在服务端保存播放记录到 localStorage');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allRecords = await getAllPlayRecords();
|
||||||
|
allRecords[key] = record;
|
||||||
|
localStorage.setItem(PLAY_RECORDS_KEY, JSON.stringify(allRecords));
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('playRecordsUpdated', {
|
||||||
|
detail: allRecords,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('保存播放记录失败:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除播放记录。
|
||||||
|
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||||
|
*/
|
||||||
|
export async function deletePlayRecord(
|
||||||
|
source: string,
|
||||||
|
id: string
|
||||||
|
): Promise<void> {
|
||||||
|
const key = generateStorageKey(source, id);
|
||||||
|
|
||||||
|
// 数据库存储模式:乐观更新策略(包括 redis、d1、upstash)
|
||||||
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
|
// 立即更新缓存
|
||||||
|
const cachedRecords = cacheManager.getCachedPlayRecords() || {};
|
||||||
|
delete cachedRecords[key];
|
||||||
|
cacheManager.cachePlayRecords(cachedRecords);
|
||||||
|
|
||||||
|
// 触发立即更新事件
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('playRecordsUpdated', {
|
||||||
|
detail: cachedRecords,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 异步同步到数据库
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/playrecords?key=${encodeURIComponent(key)}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`删除播放记录失败: ${res.status}`);
|
||||||
|
} catch (err) {
|
||||||
|
await handleDatabaseOperationFailure('playRecords', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// localstorage 模式
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
console.warn('无法在服务端删除播放记录到 localStorage');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allRecords = await getAllPlayRecords();
|
||||||
|
delete allRecords[key];
|
||||||
|
localStorage.setItem(PLAY_RECORDS_KEY, JSON.stringify(allRecords));
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('playRecordsUpdated', {
|
||||||
|
detail: allRecords,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('删除播放记录失败:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 搜索历史相关 API ---------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取搜索历史。
|
||||||
|
* 数据库存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
|
||||||
|
*/
|
||||||
|
export async function getSearchHistory(): Promise<string[]> {
|
||||||
|
// 服务器端渲染阶段直接返回空
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据库存储模式:使用混合缓存策略(包括 redis、d1、upstash)
|
||||||
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
|
// 优先从缓存获取数据
|
||||||
|
const cachedData = cacheManager.getCachedSearchHistory();
|
||||||
|
|
||||||
|
if (cachedData) {
|
||||||
|
// 返回缓存数据,同时后台异步更新
|
||||||
|
fetchFromApi<string[]>(`/api/searchhistory`)
|
||||||
|
.then((freshData) => {
|
||||||
|
// 只有数据真正不同时才更新缓存
|
||||||
|
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
|
||||||
|
cacheManager.cacheSearchHistory(freshData);
|
||||||
|
// 触发数据更新事件
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('searchHistoryUpdated', {
|
||||||
|
detail: freshData,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn('后台同步搜索历史失败:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return cachedData;
|
||||||
|
} else {
|
||||||
|
// 缓存为空,直接从 API 获取并缓存
|
||||||
|
try {
|
||||||
|
const freshData = await fetchFromApi<string[]>(`/api/searchhistory`);
|
||||||
|
cacheManager.cacheSearchHistory(freshData);
|
||||||
|
return freshData;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取搜索历史失败:', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// localStorage 模式
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(SEARCH_HISTORY_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const arr = JSON.parse(raw) as string[];
|
||||||
|
// 仅返回字符串数组
|
||||||
|
return Array.isArray(arr) ? arr : [];
|
||||||
|
} catch (err) {
|
||||||
|
console.error('读取搜索历史失败:', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将关键字添加到搜索历史。
|
||||||
|
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||||
|
*/
|
||||||
|
export async function addSearchHistory(keyword: string): Promise<void> {
|
||||||
|
const trimmed = keyword.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
|
||||||
|
// 数据库存储模式:乐观更新策略(包括 redis、d1、upstash)
|
||||||
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
|
// 立即更新缓存
|
||||||
|
const cachedHistory = cacheManager.getCachedSearchHistory() || [];
|
||||||
|
const newHistory = [trimmed, ...cachedHistory.filter((k) => k !== trimmed)];
|
||||||
|
// 限制长度
|
||||||
|
if (newHistory.length > SEARCH_HISTORY_LIMIT) {
|
||||||
|
newHistory.length = SEARCH_HISTORY_LIMIT;
|
||||||
|
}
|
||||||
|
cacheManager.cacheSearchHistory(newHistory);
|
||||||
|
|
||||||
|
// 触发立即更新事件
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('searchHistoryUpdated', {
|
||||||
|
detail: newHistory,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 异步同步到数据库
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/searchhistory', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ keyword: trimmed }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`保存搜索历史失败: ${res.status}`);
|
||||||
|
} catch (err) {
|
||||||
|
await handleDatabaseOperationFailure('searchHistory', err);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// localStorage 模式
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const history = await getSearchHistory();
|
||||||
|
const newHistory = [trimmed, ...history.filter((k) => k !== trimmed)];
|
||||||
|
// 限制长度
|
||||||
|
if (newHistory.length > SEARCH_HISTORY_LIMIT) {
|
||||||
|
newHistory.length = SEARCH_HISTORY_LIMIT;
|
||||||
|
}
|
||||||
|
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(newHistory));
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('searchHistoryUpdated', {
|
||||||
|
detail: newHistory,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('保存搜索历史失败:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空搜索历史。
|
||||||
|
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||||
|
*/
|
||||||
|
export async function clearSearchHistory(): Promise<void> {
|
||||||
|
// 数据库存储模式:乐观更新策略(包括 redis、d1、upstash)
|
||||||
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
|
// 立即更新缓存
|
||||||
|
cacheManager.cacheSearchHistory([]);
|
||||||
|
|
||||||
|
// 触发立即更新事件
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('searchHistoryUpdated', {
|
||||||
|
detail: [],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 异步同步到数据库
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/searchhistory`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`清空搜索历史失败: ${res.status}`);
|
||||||
|
} catch (err) {
|
||||||
|
await handleDatabaseOperationFailure('searchHistory', err);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// localStorage 模式
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
localStorage.removeItem(SEARCH_HISTORY_KEY);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('searchHistoryUpdated', {
|
||||||
|
detail: [],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除单条搜索历史。
|
||||||
|
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||||
|
*/
|
||||||
|
export async function deleteSearchHistory(keyword: string): Promise<void> {
|
||||||
|
const trimmed = keyword.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
|
||||||
|
// 数据库存储模式:乐观更新策略(包括 redis、d1、upstash)
|
||||||
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
|
// 立即更新缓存
|
||||||
|
const cachedHistory = cacheManager.getCachedSearchHistory() || [];
|
||||||
|
const newHistory = cachedHistory.filter((k) => k !== trimmed);
|
||||||
|
cacheManager.cacheSearchHistory(newHistory);
|
||||||
|
|
||||||
|
// 触发立即更新事件
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('searchHistoryUpdated', {
|
||||||
|
detail: newHistory,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 异步同步到数据库
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/searchhistory?keyword=${encodeURIComponent(trimmed)}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`删除搜索历史失败: ${res.status}`);
|
||||||
|
} catch (err) {
|
||||||
|
await handleDatabaseOperationFailure('searchHistory', err);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// localStorage 模式
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const history = await getSearchHistory();
|
||||||
|
const newHistory = history.filter((k) => k !== trimmed);
|
||||||
|
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(newHistory));
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('searchHistoryUpdated', {
|
||||||
|
detail: newHistory,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('删除搜索历史失败:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 收藏相关 API ----------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取全部收藏。
|
||||||
|
* 数据库存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
|
||||||
|
*/
|
||||||
|
export async function getAllFavorites(): Promise<Record<string, Favorite>> {
|
||||||
|
// 服务器端渲染阶段直接返回空
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据库存储模式:使用混合缓存策略(包括 redis、d1、upstash)
|
||||||
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
|
// 优先从缓存获取数据
|
||||||
|
const cachedData = cacheManager.getCachedFavorites();
|
||||||
|
|
||||||
|
if (cachedData) {
|
||||||
|
// 返回缓存数据,同时后台异步更新
|
||||||
|
fetchFromApi<Record<string, Favorite>>(`/api/favorites`)
|
||||||
|
.then((freshData) => {
|
||||||
|
// 只有数据真正不同时才更新缓存
|
||||||
|
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
|
||||||
|
cacheManager.cacheFavorites(freshData);
|
||||||
|
// 触发数据更新事件
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('favoritesUpdated', {
|
||||||
|
detail: freshData,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn('后台同步收藏失败:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return cachedData;
|
||||||
|
} else {
|
||||||
|
// 缓存为空,直接从 API 获取并缓存
|
||||||
|
try {
|
||||||
|
const freshData = await fetchFromApi<Record<string, Favorite>>(
|
||||||
|
`/api/favorites`
|
||||||
|
);
|
||||||
|
cacheManager.cacheFavorites(freshData);
|
||||||
|
return freshData;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取收藏失败:', err);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// localStorage 模式
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(FAVORITES_KEY);
|
||||||
|
if (!raw) return {};
|
||||||
|
return JSON.parse(raw) as Record<string, Favorite>;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('读取收藏失败:', err);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存收藏。
|
||||||
|
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||||
|
*/
|
||||||
|
export async function saveFavorite(
|
||||||
|
source: string,
|
||||||
|
id: string,
|
||||||
|
favorite: Favorite
|
||||||
|
): Promise<void> {
|
||||||
|
const key = generateStorageKey(source, id);
|
||||||
|
|
||||||
|
// 数据库存储模式:乐观更新策略(包括 redis、d1、upstash)
|
||||||
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
|
// 立即更新缓存
|
||||||
|
const cachedFavorites = cacheManager.getCachedFavorites() || {};
|
||||||
|
cachedFavorites[key] = favorite;
|
||||||
|
cacheManager.cacheFavorites(cachedFavorites);
|
||||||
|
|
||||||
|
// 触发立即更新事件
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('favoritesUpdated', {
|
||||||
|
detail: cachedFavorites,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 异步同步到数据库
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/favorites', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ key, favorite }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`保存收藏失败: ${res.status}`);
|
||||||
|
} catch (err) {
|
||||||
|
await handleDatabaseOperationFailure('favorites', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// localStorage 模式
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
console.warn('无法在服务端保存收藏到 localStorage');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allFavorites = await getAllFavorites();
|
||||||
|
allFavorites[key] = favorite;
|
||||||
|
localStorage.setItem(FAVORITES_KEY, JSON.stringify(allFavorites));
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('favoritesUpdated', {
|
||||||
|
detail: allFavorites,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('保存收藏失败:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除收藏。
|
||||||
|
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||||
|
*/
|
||||||
|
export async function deleteFavorite(
|
||||||
|
source: string,
|
||||||
|
id: string
|
||||||
|
): Promise<void> {
|
||||||
|
const key = generateStorageKey(source, id);
|
||||||
|
|
||||||
|
// 数据库存储模式:乐观更新策略(包括 redis、d1、upstash)
|
||||||
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
|
// 立即更新缓存
|
||||||
|
const cachedFavorites = cacheManager.getCachedFavorites() || {};
|
||||||
|
delete cachedFavorites[key];
|
||||||
|
cacheManager.cacheFavorites(cachedFavorites);
|
||||||
|
|
||||||
|
// 触发立即更新事件
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('favoritesUpdated', {
|
||||||
|
detail: cachedFavorites,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 异步同步到数据库
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/favorites?key=${encodeURIComponent(key)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`删除收藏失败: ${res.status}`);
|
||||||
|
} catch (err) {
|
||||||
|
await handleDatabaseOperationFailure('favorites', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// localStorage 模式
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
console.warn('无法在服务端删除收藏到 localStorage');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allFavorites = await getAllFavorites();
|
||||||
|
delete allFavorites[key];
|
||||||
|
localStorage.setItem(FAVORITES_KEY, JSON.stringify(allFavorites));
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('favoritesUpdated', {
|
||||||
|
detail: allFavorites,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('删除收藏失败:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否已收藏。
|
||||||
|
* 数据库存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
|
||||||
|
*/
|
||||||
|
export async function isFavorited(
|
||||||
|
source: string,
|
||||||
|
id: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const key = generateStorageKey(source, id);
|
||||||
|
|
||||||
|
// 数据库存储模式:使用混合缓存策略(包括 redis、d1、upstash)
|
||||||
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
|
const cachedFavorites = cacheManager.getCachedFavorites();
|
||||||
|
|
||||||
|
if (cachedFavorites) {
|
||||||
|
// 返回缓存数据,同时后台异步更新
|
||||||
|
fetchFromApi<Record<string, Favorite>>(`/api/favorites`)
|
||||||
|
.then((freshData) => {
|
||||||
|
// 只有数据真正不同时才更新缓存
|
||||||
|
if (JSON.stringify(cachedFavorites) !== JSON.stringify(freshData)) {
|
||||||
|
cacheManager.cacheFavorites(freshData);
|
||||||
|
// 触发数据更新事件
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('favoritesUpdated', {
|
||||||
|
detail: freshData,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn('后台同步收藏失败:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return !!cachedFavorites[key];
|
||||||
|
} else {
|
||||||
|
// 缓存为空,直接从 API 获取并缓存
|
||||||
|
try {
|
||||||
|
const freshData = await fetchFromApi<Record<string, Favorite>>(
|
||||||
|
`/api/favorites`
|
||||||
|
);
|
||||||
|
cacheManager.cacheFavorites(freshData);
|
||||||
|
return !!freshData[key];
|
||||||
|
} catch (err) {
|
||||||
|
console.error('检查收藏状态失败:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// localStorage 模式
|
||||||
|
const allFavorites = await getAllFavorites();
|
||||||
|
return !!allFavorites[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空全部播放记录
|
||||||
|
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||||
|
*/
|
||||||
|
export async function clearAllPlayRecords(): Promise<void> {
|
||||||
|
// 数据库存储模式:乐观更新策略(包括 redis、d1、upstash)
|
||||||
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
|
// 立即更新缓存
|
||||||
|
cacheManager.cachePlayRecords({});
|
||||||
|
|
||||||
|
// 触发立即更新事件
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('playRecordsUpdated', {
|
||||||
|
detail: {},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 异步同步到数据库
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/playrecords`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`清空播放记录失败: ${res.status}`);
|
||||||
|
} catch (err) {
|
||||||
|
await handleDatabaseOperationFailure('playRecords', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// localStorage 模式
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
localStorage.removeItem(PLAY_RECORDS_KEY);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('playRecordsUpdated', {
|
||||||
|
detail: {},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空全部收藏
|
||||||
|
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||||
|
*/
|
||||||
|
export async function clearAllFavorites(): Promise<void> {
|
||||||
|
// 数据库存储模式:乐观更新策略(包括 redis、d1、upstash)
|
||||||
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
|
// 立即更新缓存
|
||||||
|
cacheManager.cacheFavorites({});
|
||||||
|
|
||||||
|
// 触发立即更新事件
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('favoritesUpdated', {
|
||||||
|
detail: {},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 异步同步到数据库
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/favorites`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`清空收藏失败: ${res.status}`);
|
||||||
|
} catch (err) {
|
||||||
|
await handleDatabaseOperationFailure('favorites', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// localStorage 模式
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
localStorage.removeItem(FAVORITES_KEY);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('favoritesUpdated', {
|
||||||
|
detail: {},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 混合缓存辅助函数 ----------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除当前用户的所有缓存数据
|
||||||
|
* 用于用户登出时清理缓存
|
||||||
|
*/
|
||||||
|
export function clearUserCache(): void {
|
||||||
|
if (STORAGE_TYPE !== 'localstorage') {
|
||||||
|
cacheManager.clearUserCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动刷新所有缓存数据
|
||||||
|
* 强制从服务器重新获取数据并更新缓存
|
||||||
|
*/
|
||||||
|
export async function refreshAllCache(): Promise<void> {
|
||||||
|
if (STORAGE_TYPE === 'localstorage') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 并行刷新所有数据
|
||||||
|
const [playRecords, favorites, searchHistory] = await Promise.allSettled([
|
||||||
|
fetchFromApi<Record<string, PlayRecord>>(`/api/playrecords`),
|
||||||
|
fetchFromApi<Record<string, Favorite>>(`/api/favorites`),
|
||||||
|
fetchFromApi<string[]>(`/api/searchhistory`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (playRecords.status === 'fulfilled') {
|
||||||
|
cacheManager.cachePlayRecords(playRecords.value);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('playRecordsUpdated', {
|
||||||
|
detail: playRecords.value,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (favorites.status === 'fulfilled') {
|
||||||
|
cacheManager.cacheFavorites(favorites.value);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('favoritesUpdated', {
|
||||||
|
detail: favorites.value,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchHistory.status === 'fulfilled') {
|
||||||
|
cacheManager.cacheSearchHistory(searchHistory.value);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('searchHistoryUpdated', {
|
||||||
|
detail: searchHistory.value,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('刷新缓存失败:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取缓存状态信息
|
||||||
|
* 用于调试和监控缓存健康状态
|
||||||
|
*/
|
||||||
|
export function getCacheStatus(): {
|
||||||
|
hasPlayRecords: boolean;
|
||||||
|
hasFavorites: boolean;
|
||||||
|
hasSearchHistory: boolean;
|
||||||
|
username: string | null;
|
||||||
|
} {
|
||||||
|
if (STORAGE_TYPE === 'localstorage') {
|
||||||
|
return {
|
||||||
|
hasPlayRecords: false,
|
||||||
|
hasFavorites: false,
|
||||||
|
hasSearchHistory: false,
|
||||||
|
username: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const authInfo = getAuthInfoFromBrowserCookie();
|
||||||
|
return {
|
||||||
|
hasPlayRecords: !!cacheManager.getCachedPlayRecords(),
|
||||||
|
hasFavorites: !!cacheManager.getCachedFavorites(),
|
||||||
|
hasSearchHistory: !!cacheManager.getCachedSearchHistory(),
|
||||||
|
username: authInfo?.username || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- React Hook 辅助类型 ----------------
|
||||||
|
|
||||||
|
export type CacheUpdateEvent =
|
||||||
|
| 'playRecordsUpdated'
|
||||||
|
| 'favoritesUpdated'
|
||||||
|
| 'searchHistoryUpdated';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用于 React 组件监听数据更新的事件监听器
|
||||||
|
* 使用方法:
|
||||||
|
*
|
||||||
|
* useEffect(() => {
|
||||||
|
* const unsubscribe = subscribeToDataUpdates('playRecordsUpdated', (data) => {
|
||||||
|
* setPlayRecords(data);
|
||||||
|
* });
|
||||||
|
* return unsubscribe;
|
||||||
|
* }, []);
|
||||||
|
*/
|
||||||
|
export function subscribeToDataUpdates<T>(
|
||||||
|
eventType: CacheUpdateEvent,
|
||||||
|
callback: (data: T) => void
|
||||||
|
): () => void {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdate = (event: CustomEvent) => {
|
||||||
|
callback(event.detail);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener(eventType, handleUpdate as EventListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(eventType, handleUpdate as EventListener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预加载所有用户数据到缓存
|
||||||
|
* 适合在应用启动时调用,提升后续访问速度
|
||||||
|
*/
|
||||||
|
export async function preloadUserData(): Promise<void> {
|
||||||
|
if (STORAGE_TYPE === 'localstorage') return;
|
||||||
|
|
||||||
|
// 检查是否已有有效缓存,避免重复请求
|
||||||
|
const status = getCacheStatus();
|
||||||
|
if (status.hasPlayRecords && status.hasFavorites && status.hasSearchHistory) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后台静默预加载,不阻塞界面
|
||||||
|
refreshAllCache().catch((err) => {
|
||||||
|
console.warn('预加载用户数据失败:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
||||||
|
|
||||||
|
import { AdminConfig } from './admin.types';
|
||||||
|
import { D1Storage } from './d1.db';
|
||||||
|
import { RedisStorage } from './redis.db';
|
||||||
|
import { Favorite, IStorage, PlayRecord } from './types';
|
||||||
|
import { UpstashRedisStorage } from './upstash.db';
|
||||||
|
|
||||||
|
// storage type 常量: 'localstorage' | 'redis' | 'd1' | 'upstash',默认 'localstorage'
|
||||||
|
const STORAGE_TYPE =
|
||||||
|
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
|
||||||
|
| 'localstorage'
|
||||||
|
| 'redis'
|
||||||
|
| 'd1'
|
||||||
|
| 'upstash'
|
||||||
|
| undefined) || 'localstorage';
|
||||||
|
|
||||||
|
// 创建存储实例
|
||||||
|
function createStorage(): IStorage {
|
||||||
|
switch (STORAGE_TYPE) {
|
||||||
|
case 'redis':
|
||||||
|
return new RedisStorage();
|
||||||
|
case 'upstash':
|
||||||
|
return new UpstashRedisStorage();
|
||||||
|
case 'd1':
|
||||||
|
return new D1Storage();
|
||||||
|
case 'localstorage':
|
||||||
|
default:
|
||||||
|
// 默认返回内存实现,保证本地开发可用
|
||||||
|
return null as unknown as IStorage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单例存储实例
|
||||||
|
let storageInstance: IStorage | null = null;
|
||||||
|
|
||||||
|
export function getStorage(): IStorage {
|
||||||
|
if (!storageInstance) {
|
||||||
|
storageInstance = createStorage();
|
||||||
|
}
|
||||||
|
return storageInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具函数:生成存储key
|
||||||
|
export function generateStorageKey(source: string, id: string): string {
|
||||||
|
return `${source}+${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出便捷方法
|
||||||
|
export class DbManager {
|
||||||
|
private storage: IStorage;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.storage = getStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 播放记录相关方法
|
||||||
|
async getPlayRecord(
|
||||||
|
userName: string,
|
||||||
|
source: string,
|
||||||
|
id: string
|
||||||
|
): Promise<PlayRecord | null> {
|
||||||
|
const key = generateStorageKey(source, id);
|
||||||
|
return this.storage.getPlayRecord(userName, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async savePlayRecord(
|
||||||
|
userName: string,
|
||||||
|
source: string,
|
||||||
|
id: string,
|
||||||
|
record: PlayRecord
|
||||||
|
): Promise<void> {
|
||||||
|
const key = generateStorageKey(source, id);
|
||||||
|
await this.storage.setPlayRecord(userName, key, record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllPlayRecords(userName: string): Promise<{
|
||||||
|
[key: string]: PlayRecord;
|
||||||
|
}> {
|
||||||
|
return this.storage.getAllPlayRecords(userName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePlayRecord(
|
||||||
|
userName: string,
|
||||||
|
source: string,
|
||||||
|
id: string
|
||||||
|
): Promise<void> {
|
||||||
|
const key = generateStorageKey(source, id);
|
||||||
|
await this.storage.deletePlayRecord(userName, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收藏相关方法
|
||||||
|
async getFavorite(
|
||||||
|
userName: string,
|
||||||
|
source: string,
|
||||||
|
id: string
|
||||||
|
): Promise<Favorite | null> {
|
||||||
|
const key = generateStorageKey(source, id);
|
||||||
|
return this.storage.getFavorite(userName, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveFavorite(
|
||||||
|
userName: string,
|
||||||
|
source: string,
|
||||||
|
id: string,
|
||||||
|
favorite: Favorite
|
||||||
|
): Promise<void> {
|
||||||
|
const key = generateStorageKey(source, id);
|
||||||
|
await this.storage.setFavorite(userName, key, favorite);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllFavorites(
|
||||||
|
userName: string
|
||||||
|
): Promise<{ [key: string]: Favorite }> {
|
||||||
|
return this.storage.getAllFavorites(userName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFavorite(
|
||||||
|
userName: string,
|
||||||
|
source: string,
|
||||||
|
id: string
|
||||||
|
): Promise<void> {
|
||||||
|
const key = generateStorageKey(source, id);
|
||||||
|
await this.storage.deleteFavorite(userName, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async isFavorited(
|
||||||
|
userName: string,
|
||||||
|
source: string,
|
||||||
|
id: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const favorite = await this.getFavorite(userName, source, id);
|
||||||
|
return favorite !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 用户相关 ----------
|
||||||
|
async registerUser(userName: string, password: string): Promise<void> {
|
||||||
|
await this.storage.registerUser(userName, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyUser(userName: string, password: string): Promise<boolean> {
|
||||||
|
return this.storage.verifyUser(userName, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户是否已存在
|
||||||
|
async checkUserExist(userName: string): Promise<boolean> {
|
||||||
|
return this.storage.checkUserExist(userName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 搜索历史 ----------
|
||||||
|
async getSearchHistory(userName: string): Promise<string[]> {
|
||||||
|
return this.storage.getSearchHistory(userName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addSearchHistory(userName: string, keyword: string): Promise<void> {
|
||||||
|
await this.storage.addSearchHistory(userName, keyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSearchHistory(userName: string, keyword?: string): Promise<void> {
|
||||||
|
await this.storage.deleteSearchHistory(userName, keyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取全部用户名
|
||||||
|
async getAllUsers(): Promise<string[]> {
|
||||||
|
if (typeof (this.storage as any).getAllUsers === 'function') {
|
||||||
|
return (this.storage as any).getAllUsers();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 管理员配置 ----------
|
||||||
|
async getAdminConfig(): Promise<AdminConfig | null> {
|
||||||
|
if (typeof (this.storage as any).getAdminConfig === 'function') {
|
||||||
|
return (this.storage as any).getAdminConfig();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveAdminConfig(config: AdminConfig): Promise<void> {
|
||||||
|
if (typeof (this.storage as any).setAdminConfig === 'function') {
|
||||||
|
await (this.storage as any).setAdminConfig(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出默认实例
|
||||||
|
export const db = new DbManager();
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { DoubanItem, DoubanResult } from './types';
|
||||||
|
import { getDoubanProxyUrl } from './utils';
|
||||||
|
|
||||||
|
interface DoubanCategoriesParams {
|
||||||
|
kind: 'tv' | 'movie';
|
||||||
|
category: string;
|
||||||
|
type: string;
|
||||||
|
pageLimit?: number;
|
||||||
|
pageStart?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DoubanCategoryApiResponse {
|
||||||
|
total: number;
|
||||||
|
items: Array<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
card_subtitle: string;
|
||||||
|
pic: {
|
||||||
|
large: string;
|
||||||
|
normal: string;
|
||||||
|
};
|
||||||
|
rating: {
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 带超时的 fetch 请求
|
||||||
|
*/
|
||||||
|
async function fetchWithTimeout(
|
||||||
|
url: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
): Promise<Response> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
|
||||||
|
|
||||||
|
// 检查是否使用代理
|
||||||
|
const proxyUrl = getDoubanProxyUrl();
|
||||||
|
const finalUrl = proxyUrl ? `${proxyUrl}${encodeURIComponent(url)}` : url;
|
||||||
|
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
...options,
|
||||||
|
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: 'application/json, text/plain, */*',
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(finalUrl, fetchOptions);
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否应该使用客户端获取豆瓣数据
|
||||||
|
*/
|
||||||
|
export function shouldUseDoubanClient(): boolean {
|
||||||
|
return getDoubanProxyUrl() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 浏览器端豆瓣分类数据获取函数
|
||||||
|
*/
|
||||||
|
export async function fetchDoubanCategories(
|
||||||
|
params: DoubanCategoriesParams
|
||||||
|
): Promise<DoubanResult> {
|
||||||
|
const { kind, category, type, pageLimit = 20, pageStart = 0 } = params;
|
||||||
|
|
||||||
|
// 验证参数
|
||||||
|
if (!['tv', 'movie'].includes(kind)) {
|
||||||
|
throw new Error('kind 参数必须是 tv 或 movie');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!category || !type) {
|
||||||
|
throw new Error('category 和 type 参数不能为空');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageLimit < 1 || pageLimit > 100) {
|
||||||
|
throw new Error('pageLimit 必须在 1-100 之间');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageStart < 0) {
|
||||||
|
throw new Error('pageStart 不能小于 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = `https://m.douban.com/rexxar/api/v2/subject/recent_hot/${kind}?start=${pageStart}&limit=${pageLimit}&category=${category}&type=${type}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchWithTimeout(target);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const doubanData: DoubanCategoryApiResponse = await response.json();
|
||||||
|
|
||||||
|
// 转换数据格式
|
||||||
|
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] || '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: '获取成功',
|
||||||
|
list: list,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一的豆瓣分类数据获取函数,根据代理设置选择使用服务端 API 或客户端代理获取
|
||||||
|
*/
|
||||||
|
export async function getDoubanCategories(
|
||||||
|
params: DoubanCategoriesParams
|
||||||
|
): Promise<DoubanResult> {
|
||||||
|
if (shouldUseDoubanClient()) {
|
||||||
|
// 使用客户端代理获取(当设置了代理 URL 时)
|
||||||
|
return fetchDoubanCategories(params);
|
||||||
|
} else {
|
||||||
|
// 使用服务端 API(当没有设置代理 URL 时)
|
||||||
|
const { kind, category, type, pageLimit = 20, pageStart = 0 } = params;
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/douban/categories?kind=${kind}&category=${category}&type=${type}&limit=${pageLimit}&start=${pageStart}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('获取豆瓣分类数据失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
import { API_CONFIG, ApiSite, getConfig } from '@/lib/config';
|
||||||
|
import { SearchResult } from '@/lib/types';
|
||||||
|
import { cleanHtmlTags } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ApiSearchItem {
|
||||||
|
vod_id: string;
|
||||||
|
vod_name: string;
|
||||||
|
vod_pic: string;
|
||||||
|
vod_remarks?: string;
|
||||||
|
vod_play_url?: string;
|
||||||
|
vod_class?: string;
|
||||||
|
vod_year?: string;
|
||||||
|
vod_content?: string;
|
||||||
|
vod_douban_id?: number;
|
||||||
|
type_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchFromApi(
|
||||||
|
apiSite: ApiSite,
|
||||||
|
query: string
|
||||||
|
): Promise<SearchResult[]> {
|
||||||
|
try {
|
||||||
|
const apiBaseUrl = apiSite.api;
|
||||||
|
const apiUrl =
|
||||||
|
apiBaseUrl + API_CONFIG.search.path + encodeURIComponent(query);
|
||||||
|
const apiName = apiSite.name;
|
||||||
|
|
||||||
|
// 添加超时处理
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 8000);
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
headers: API_CONFIG.search.headers,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (
|
||||||
|
!data ||
|
||||||
|
!data.list ||
|
||||||
|
!Array.isArray(data.list) ||
|
||||||
|
data.list.length === 0
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// 处理第一页结果
|
||||||
|
const results = data.list.map((item: ApiSearchItem) => {
|
||||||
|
let episodes: string[] = [];
|
||||||
|
|
||||||
|
// 使用正则表达式从 vod_play_url 提取 m3u8 链接
|
||||||
|
if (item.vod_play_url) {
|
||||||
|
const m3u8Regex = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g;
|
||||||
|
// 先用 $$$ 分割
|
||||||
|
const vod_play_url_array = item.vod_play_url.split('$$$');
|
||||||
|
// 对每个分片做匹配,取匹配到最多的作为结果
|
||||||
|
vod_play_url_array.forEach((url: string) => {
|
||||||
|
const matches = url.match(m3u8Regex) || [];
|
||||||
|
if (matches.length > episodes.length) {
|
||||||
|
episodes = matches;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
episodes = Array.from(new Set(episodes)).map((link: string) => {
|
||||||
|
link = link.substring(1); // 去掉开头的 $
|
||||||
|
const parenIndex = link.indexOf('(');
|
||||||
|
return parenIndex > 0 ? link.substring(0, parenIndex) : link;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.vod_id.toString(),
|
||||||
|
title: item.vod_name.trim().replace(/\s+/g, ' '),
|
||||||
|
poster: item.vod_pic,
|
||||||
|
episodes,
|
||||||
|
source: apiSite.key,
|
||||||
|
source_name: apiName,
|
||||||
|
class: item.vod_class,
|
||||||
|
year: item.vod_year
|
||||||
|
? item.vod_year.match(/\d{4}/)?.[0] || ''
|
||||||
|
: 'unknown',
|
||||||
|
desc: cleanHtmlTags(item.vod_content || ''),
|
||||||
|
type_name: item.type_name,
|
||||||
|
douban_id: item.vod_douban_id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
const MAX_SEARCH_PAGES: number = config.SiteConfig.SearchDownstreamMaxPage;
|
||||||
|
|
||||||
|
// 获取总页数
|
||||||
|
const pageCount = data.pagecount || 1;
|
||||||
|
// 确定需要获取的额外页数
|
||||||
|
const pagesToFetch = Math.min(pageCount - 1, MAX_SEARCH_PAGES - 1);
|
||||||
|
|
||||||
|
// 如果有额外页数,获取更多页的结果
|
||||||
|
if (pagesToFetch > 0) {
|
||||||
|
const additionalPagePromises = [];
|
||||||
|
|
||||||
|
for (let page = 2; page <= pagesToFetch + 1; page++) {
|
||||||
|
const pageUrl =
|
||||||
|
apiBaseUrl +
|
||||||
|
API_CONFIG.search.pagePath
|
||||||
|
.replace('{query}', encodeURIComponent(query))
|
||||||
|
.replace('{page}', page.toString());
|
||||||
|
|
||||||
|
const pagePromise = (async () => {
|
||||||
|
try {
|
||||||
|
const pageController = new AbortController();
|
||||||
|
const pageTimeoutId = setTimeout(
|
||||||
|
() => pageController.abort(),
|
||||||
|
8000
|
||||||
|
);
|
||||||
|
|
||||||
|
const pageResponse = await fetch(pageUrl, {
|
||||||
|
headers: API_CONFIG.search.headers,
|
||||||
|
signal: pageController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(pageTimeoutId);
|
||||||
|
|
||||||
|
if (!pageResponse.ok) return [];
|
||||||
|
|
||||||
|
const pageData = await pageResponse.json();
|
||||||
|
|
||||||
|
if (!pageData || !pageData.list || !Array.isArray(pageData.list))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
return pageData.list.map((item: ApiSearchItem) => {
|
||||||
|
let episodes: string[] = [];
|
||||||
|
|
||||||
|
// 使用正则表达式从 vod_play_url 提取 m3u8 链接
|
||||||
|
if (item.vod_play_url) {
|
||||||
|
const m3u8Regex = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g;
|
||||||
|
episodes = item.vod_play_url.match(m3u8Regex) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
episodes = Array.from(new Set(episodes)).map((link: string) => {
|
||||||
|
link = link.substring(1); // 去掉开头的 $
|
||||||
|
const parenIndex = link.indexOf('(');
|
||||||
|
return parenIndex > 0 ? link.substring(0, parenIndex) : link;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.vod_id.toString(),
|
||||||
|
title: item.vod_name.trim().replace(/\s+/g, ' '),
|
||||||
|
poster: item.vod_pic,
|
||||||
|
episodes,
|
||||||
|
source: apiSite.key,
|
||||||
|
source_name: apiName,
|
||||||
|
class: item.vod_class,
|
||||||
|
year: item.vod_year
|
||||||
|
? item.vod_year.match(/\d{4}/)?.[0] || ''
|
||||||
|
: 'unknown',
|
||||||
|
desc: cleanHtmlTags(item.vod_content || ''),
|
||||||
|
type_name: item.type_name,
|
||||||
|
douban_id: item.vod_douban_id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
additionalPagePromises.push(pagePromise);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待所有额外页的结果
|
||||||
|
const additionalResults = await Promise.all(additionalPagePromises);
|
||||||
|
|
||||||
|
// 合并所有页的结果
|
||||||
|
additionalResults.forEach((pageResults) => {
|
||||||
|
if (pageResults.length > 0) {
|
||||||
|
results.push(...pageResults);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 匹配 m3u8 链接的正则
|
||||||
|
const M3U8_PATTERN = /(https?:\/\/[^"'\s]+?\.m3u8)/g;
|
||||||
|
|
||||||
|
export async function getDetailFromApi(
|
||||||
|
apiSite: ApiSite,
|
||||||
|
id: string
|
||||||
|
): Promise<SearchResult> {
|
||||||
|
if (apiSite.detail) {
|
||||||
|
return handleSpecialSourceDetail(id, apiSite);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailUrl = `${apiSite.api}${API_CONFIG.detail.path}${id}`;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||||
|
|
||||||
|
const response = await fetch(detailUrl, {
|
||||||
|
headers: API_CONFIG.detail.headers,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`详情请求失败: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (
|
||||||
|
!data ||
|
||||||
|
!data.list ||
|
||||||
|
!Array.isArray(data.list) ||
|
||||||
|
data.list.length === 0
|
||||||
|
) {
|
||||||
|
throw new Error('获取到的详情内容无效');
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoDetail = data.list[0];
|
||||||
|
let episodes: string[] = [];
|
||||||
|
|
||||||
|
// 处理播放源拆分
|
||||||
|
if (videoDetail.vod_play_url) {
|
||||||
|
const playSources = videoDetail.vod_play_url.split('$$$');
|
||||||
|
if (playSources.length > 0) {
|
||||||
|
const mainSource = playSources[0];
|
||||||
|
const episodeList = mainSource.split('#');
|
||||||
|
episodes = episodeList
|
||||||
|
.map((ep: string) => {
|
||||||
|
const parts = ep.split('$');
|
||||||
|
return parts.length > 1 ? parts[1] : '';
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
(url: string) =>
|
||||||
|
url && (url.startsWith('http://') || url.startsWith('https://'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果播放源为空,则尝试从内容中解析 m3u8
|
||||||
|
if (episodes.length === 0 && videoDetail.vod_content) {
|
||||||
|
const matches = videoDetail.vod_content.match(M3U8_PATTERN) || [];
|
||||||
|
episodes = matches.map((link: string) => link.replace(/^\$/, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id.toString(),
|
||||||
|
title: videoDetail.vod_name,
|
||||||
|
poster: videoDetail.vod_pic,
|
||||||
|
episodes,
|
||||||
|
source: apiSite.key,
|
||||||
|
source_name: apiSite.name,
|
||||||
|
class: videoDetail.vod_class,
|
||||||
|
year: videoDetail.vod_year
|
||||||
|
? videoDetail.vod_year.match(/\d{4}/)?.[0] || ''
|
||||||
|
: 'unknown',
|
||||||
|
desc: cleanHtmlTags(videoDetail.vod_content),
|
||||||
|
type_name: videoDetail.type_name,
|
||||||
|
douban_id: videoDetail.vod_douban_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSpecialSourceDetail(
|
||||||
|
id: string,
|
||||||
|
apiSite: ApiSite
|
||||||
|
): Promise<SearchResult> {
|
||||||
|
const detailUrl = `${apiSite.detail}/index.php/vod/detail/id/${id}.html`;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||||
|
|
||||||
|
const response = await fetch(detailUrl, {
|
||||||
|
headers: API_CONFIG.detail.headers,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`详情页请求失败: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await response.text();
|
||||||
|
let matches: string[] = [];
|
||||||
|
|
||||||
|
if (apiSite.key === 'ffzy') {
|
||||||
|
const ffzyPattern =
|
||||||
|
/\$(https?:\/\/[^"'\s]+?\/\d{8}\/\d+_[a-f0-9]+\/index\.m3u8)/g;
|
||||||
|
matches = html.match(ffzyPattern) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.length === 0) {
|
||||||
|
const generalPattern = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g;
|
||||||
|
matches = html.match(generalPattern) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 去重并清理链接前缀
|
||||||
|
matches = Array.from(new Set(matches)).map((link: string) => {
|
||||||
|
link = link.substring(1); // 去掉开头的 $
|
||||||
|
const parenIndex = link.indexOf('(');
|
||||||
|
return parenIndex > 0 ? link.substring(0, parenIndex) : link;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 提取标题
|
||||||
|
const titleMatch = html.match(/<h1[^>]*>([^<]+)<\/h1>/);
|
||||||
|
const titleText = titleMatch ? titleMatch[1].trim() : '';
|
||||||
|
|
||||||
|
// 提取描述
|
||||||
|
const descMatch = html.match(
|
||||||
|
/<div[^>]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/
|
||||||
|
);
|
||||||
|
const descText = descMatch ? cleanHtmlTags(descMatch[1]) : '';
|
||||||
|
|
||||||
|
// 提取封面
|
||||||
|
const coverMatch = html.match(/(https?:\/\/[^"'\s]+?\.jpg)/g);
|
||||||
|
const coverUrl = coverMatch ? coverMatch[0].trim() : '';
|
||||||
|
|
||||||
|
// 提取年份
|
||||||
|
const yearMatch = html.match(/>(\d{4})</);
|
||||||
|
const yearText = yearMatch ? yearMatch[1] : 'unknown';
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: titleText,
|
||||||
|
poster: coverUrl,
|
||||||
|
episodes: matches,
|
||||||
|
source: apiSite.key,
|
||||||
|
source_name: apiSite.name,
|
||||||
|
class: '',
|
||||||
|
year: yearText,
|
||||||
|
desc: descText,
|
||||||
|
type_name: '',
|
||||||
|
douban_id: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { getAvailableApiSites } from '@/lib/config';
|
||||||
|
import { SearchResult } from '@/lib/types';
|
||||||
|
|
||||||
|
import { getDetailFromApi, searchFromApi } from './downstream';
|
||||||
|
|
||||||
|
interface FetchVideoDetailOptions {
|
||||||
|
source: string;
|
||||||
|
id: string;
|
||||||
|
fallbackTitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 source 与 id 获取视频详情。
|
||||||
|
* 1. 若传入 fallbackTitle,则先调用 /api/search 搜索精确匹配。
|
||||||
|
* 2. 若搜索未命中或未提供 fallbackTitle,则直接调用 /api/detail。
|
||||||
|
*/
|
||||||
|
export async function fetchVideoDetail({
|
||||||
|
source,
|
||||||
|
id,
|
||||||
|
fallbackTitle = '',
|
||||||
|
}: FetchVideoDetailOptions): Promise<SearchResult> {
|
||||||
|
// 优先通过搜索接口查找精确匹配
|
||||||
|
const apiSites = await getAvailableApiSites();
|
||||||
|
const apiSite = apiSites.find((site) => site.key === source);
|
||||||
|
if (!apiSite) {
|
||||||
|
throw new Error('无效的API来源');
|
||||||
|
}
|
||||||
|
if (fallbackTitle) {
|
||||||
|
try {
|
||||||
|
const searchData = await searchFromApi(apiSite, fallbackTitle.trim());
|
||||||
|
const exactMatch = searchData.find(
|
||||||
|
(item: SearchResult) =>
|
||||||
|
item.source.toString() === source.toString() &&
|
||||||
|
item.id.toString() === id.toString()
|
||||||
|
);
|
||||||
|
if (exactMatch) {
|
||||||
|
return exactMatch;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用 /api/detail 接口
|
||||||
|
const detail = await getDetailFromApi(apiSite, id);
|
||||||
|
if (!detail) {
|
||||||
|
throw new Error('获取视频详情失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
||||||
|
|
||||||
|
import { createClient, RedisClientType } from 'redis';
|
||||||
|
|
||||||
|
import { AdminConfig } from './admin.types';
|
||||||
|
import { Favorite, IStorage, PlayRecord } from './types';
|
||||||
|
|
||||||
|
// 搜索历史最大条数
|
||||||
|
const SEARCH_HISTORY_LIMIT = 20;
|
||||||
|
|
||||||
|
// 数据类型转换辅助函数
|
||||||
|
function ensureString(value: any): string {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureStringArray(value: any[]): string[] {
|
||||||
|
return value.map((item) => String(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加Redis操作重试包装器
|
||||||
|
async function withRetry<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
maxRetries = 3
|
||||||
|
): Promise<T> {
|
||||||
|
for (let i = 0; i < maxRetries; i++) {
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (err: any) {
|
||||||
|
const isLastAttempt = i === maxRetries - 1;
|
||||||
|
const isConnectionError =
|
||||||
|
err.message?.includes('Connection') ||
|
||||||
|
err.message?.includes('ECONNREFUSED') ||
|
||||||
|
err.message?.includes('ENOTFOUND') ||
|
||||||
|
err.code === 'ECONNRESET' ||
|
||||||
|
err.code === 'EPIPE';
|
||||||
|
|
||||||
|
if (isConnectionError && !isLastAttempt) {
|
||||||
|
console.log(
|
||||||
|
`Redis operation failed, retrying... (${i + 1}/${maxRetries})`
|
||||||
|
);
|
||||||
|
console.error('Error:', err.message);
|
||||||
|
|
||||||
|
// 等待一段时间后重试
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000 * (i + 1)));
|
||||||
|
|
||||||
|
// 尝试重新连接
|
||||||
|
try {
|
||||||
|
const client = getRedisClient();
|
||||||
|
if (!client.isOpen) {
|
||||||
|
await client.connect();
|
||||||
|
}
|
||||||
|
} catch (reconnectErr) {
|
||||||
|
console.error('Failed to reconnect:', reconnectErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Max retries exceeded');
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RedisStorage implements IStorage {
|
||||||
|
private client: RedisClientType;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.client = getRedisClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 播放记录 ----------
|
||||||
|
private prKey(user: string, key: string) {
|
||||||
|
return `u:${user}:pr:${key}`; // u:username:pr:source+id
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPlayRecord(
|
||||||
|
userName: string,
|
||||||
|
key: string
|
||||||
|
): Promise<PlayRecord | null> {
|
||||||
|
const val = await withRetry(() =>
|
||||||
|
this.client.get(this.prKey(userName, key))
|
||||||
|
);
|
||||||
|
return val ? (JSON.parse(val) as PlayRecord) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPlayRecord(
|
||||||
|
userName: string,
|
||||||
|
key: string,
|
||||||
|
record: PlayRecord
|
||||||
|
): Promise<void> {
|
||||||
|
await withRetry(() =>
|
||||||
|
this.client.set(this.prKey(userName, key), JSON.stringify(record))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllPlayRecords(
|
||||||
|
userName: string
|
||||||
|
): Promise<Record<string, PlayRecord>> {
|
||||||
|
const pattern = `u:${userName}:pr:*`;
|
||||||
|
const keys: string[] = await withRetry(() => this.client.keys(pattern));
|
||||||
|
if (keys.length === 0) return {};
|
||||||
|
const values = await withRetry(() => this.client.mGet(keys));
|
||||||
|
const result: Record<string, PlayRecord> = {};
|
||||||
|
keys.forEach((fullKey: string, idx: number) => {
|
||||||
|
const raw = values[idx];
|
||||||
|
if (raw) {
|
||||||
|
const rec = JSON.parse(raw) as PlayRecord;
|
||||||
|
// 截取 source+id 部分
|
||||||
|
const keyPart = ensureString(fullKey.replace(`u:${userName}:pr:`, ''));
|
||||||
|
result[keyPart] = rec;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePlayRecord(userName: string, key: string): Promise<void> {
|
||||||
|
await withRetry(() => this.client.del(this.prKey(userName, key)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 收藏 ----------
|
||||||
|
private favKey(user: string, key: string) {
|
||||||
|
return `u:${user}:fav:${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFavorite(userName: string, key: string): Promise<Favorite | null> {
|
||||||
|
const val = await withRetry(() =>
|
||||||
|
this.client.get(this.favKey(userName, key))
|
||||||
|
);
|
||||||
|
return val ? (JSON.parse(val) as Favorite) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setFavorite(
|
||||||
|
userName: string,
|
||||||
|
key: string,
|
||||||
|
favorite: Favorite
|
||||||
|
): Promise<void> {
|
||||||
|
await withRetry(() =>
|
||||||
|
this.client.set(this.favKey(userName, key), JSON.stringify(favorite))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllFavorites(userName: string): Promise<Record<string, Favorite>> {
|
||||||
|
const pattern = `u:${userName}:fav:*`;
|
||||||
|
const keys: string[] = await withRetry(() => this.client.keys(pattern));
|
||||||
|
if (keys.length === 0) return {};
|
||||||
|
const values = await withRetry(() => this.client.mGet(keys));
|
||||||
|
const result: Record<string, Favorite> = {};
|
||||||
|
keys.forEach((fullKey: string, idx: number) => {
|
||||||
|
const raw = values[idx];
|
||||||
|
if (raw) {
|
||||||
|
const fav = JSON.parse(raw) as Favorite;
|
||||||
|
const keyPart = ensureString(fullKey.replace(`u:${userName}:fav:`, ''));
|
||||||
|
result[keyPart] = fav;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFavorite(userName: string, key: string): Promise<void> {
|
||||||
|
await withRetry(() => this.client.del(this.favKey(userName, key)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 用户注册 / 登录 ----------
|
||||||
|
private userPwdKey(user: string) {
|
||||||
|
return `u:${user}:pwd`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async registerUser(userName: string, password: string): Promise<void> {
|
||||||
|
// 简单存储明文密码,生产环境应加密
|
||||||
|
await withRetry(() => this.client.set(this.userPwdKey(userName), password));
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyUser(userName: string, password: string): Promise<boolean> {
|
||||||
|
const stored = await withRetry(() =>
|
||||||
|
this.client.get(this.userPwdKey(userName))
|
||||||
|
);
|
||||||
|
if (stored === null) return false;
|
||||||
|
// 确保比较时都是字符串类型
|
||||||
|
return ensureString(stored) === password;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户是否存在
|
||||||
|
async checkUserExist(userName: string): Promise<boolean> {
|
||||||
|
// 使用 EXISTS 判断 key 是否存在
|
||||||
|
const exists = await withRetry(() =>
|
||||||
|
this.client.exists(this.userPwdKey(userName))
|
||||||
|
);
|
||||||
|
return exists === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改用户密码
|
||||||
|
async changePassword(userName: string, newPassword: string): Promise<void> {
|
||||||
|
// 简单存储明文密码,生产环境应加密
|
||||||
|
await withRetry(() =>
|
||||||
|
this.client.set(this.userPwdKey(userName), newPassword)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除用户及其所有数据
|
||||||
|
async deleteUser(userName: string): Promise<void> {
|
||||||
|
// 删除用户密码
|
||||||
|
await withRetry(() => this.client.del(this.userPwdKey(userName)));
|
||||||
|
|
||||||
|
// 删除搜索历史
|
||||||
|
await withRetry(() => this.client.del(this.shKey(userName)));
|
||||||
|
|
||||||
|
// 删除播放记录
|
||||||
|
const playRecordPattern = `u:${userName}:pr:*`;
|
||||||
|
const playRecordKeys = await withRetry(() =>
|
||||||
|
this.client.keys(playRecordPattern)
|
||||||
|
);
|
||||||
|
if (playRecordKeys.length > 0) {
|
||||||
|
await withRetry(() => this.client.del(playRecordKeys));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除收藏夹
|
||||||
|
const favoritePattern = `u:${userName}:fav:*`;
|
||||||
|
const favoriteKeys = await withRetry(() =>
|
||||||
|
this.client.keys(favoritePattern)
|
||||||
|
);
|
||||||
|
if (favoriteKeys.length > 0) {
|
||||||
|
await withRetry(() => this.client.del(favoriteKeys));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 搜索历史 ----------
|
||||||
|
private shKey(user: string) {
|
||||||
|
return `u:${user}:sh`; // u:username:sh
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSearchHistory(userName: string): Promise<string[]> {
|
||||||
|
const result = await withRetry(() =>
|
||||||
|
this.client.lRange(this.shKey(userName), 0, -1)
|
||||||
|
);
|
||||||
|
// 确保返回的都是字符串类型
|
||||||
|
return ensureStringArray(result as any[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addSearchHistory(userName: string, keyword: string): Promise<void> {
|
||||||
|
const key = this.shKey(userName);
|
||||||
|
// 先去重
|
||||||
|
await withRetry(() => this.client.lRem(key, 0, ensureString(keyword)));
|
||||||
|
// 插入到最前
|
||||||
|
await withRetry(() => this.client.lPush(key, ensureString(keyword)));
|
||||||
|
// 限制最大长度
|
||||||
|
await withRetry(() => this.client.lTrim(key, 0, SEARCH_HISTORY_LIMIT - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSearchHistory(userName: string, keyword?: string): Promise<void> {
|
||||||
|
const key = this.shKey(userName);
|
||||||
|
if (keyword) {
|
||||||
|
await withRetry(() => this.client.lRem(key, 0, ensureString(keyword)));
|
||||||
|
} else {
|
||||||
|
await withRetry(() => this.client.del(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 获取全部用户 ----------
|
||||||
|
async getAllUsers(): Promise<string[]> {
|
||||||
|
const keys = await withRetry(() => this.client.keys('u:*:pwd'));
|
||||||
|
return keys
|
||||||
|
.map((k) => {
|
||||||
|
const match = k.match(/^u:(.+?):pwd$/);
|
||||||
|
return match ? ensureString(match[1]) : undefined;
|
||||||
|
})
|
||||||
|
.filter((u): u is string => typeof u === 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 管理员配置 ----------
|
||||||
|
private adminConfigKey() {
|
||||||
|
return 'admin:config';
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAdminConfig(): Promise<AdminConfig | null> {
|
||||||
|
const val = await withRetry(() => this.client.get(this.adminConfigKey()));
|
||||||
|
return val ? (JSON.parse(val) as AdminConfig) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setAdminConfig(config: AdminConfig): Promise<void> {
|
||||||
|
await withRetry(() =>
|
||||||
|
this.client.set(this.adminConfigKey(), JSON.stringify(config))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单例 Redis 客户端
|
||||||
|
function getRedisClient(): RedisClientType {
|
||||||
|
const globalKey = Symbol.for('__MOONTV_REDIS_CLIENT__');
|
||||||
|
let client: RedisClientType | undefined = (global as any)[globalKey];
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
const url = process.env.REDIS_URL;
|
||||||
|
if (!url) {
|
||||||
|
throw new Error('REDIS_URL env variable not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建客户端,配置重连策略
|
||||||
|
client = createClient({
|
||||||
|
url,
|
||||||
|
socket: {
|
||||||
|
// 重连策略:指数退避,最大30秒
|
||||||
|
reconnectStrategy: (retries: number) => {
|
||||||
|
console.log(`Redis reconnection attempt ${retries + 1}`);
|
||||||
|
if (retries > 10) {
|
||||||
|
console.error('Redis max reconnection attempts exceeded');
|
||||||
|
return false; // 停止重连
|
||||||
|
}
|
||||||
|
return Math.min(1000 * Math.pow(2, retries), 30000); // 指数退避,最大30秒
|
||||||
|
},
|
||||||
|
connectTimeout: 10000, // 10秒连接超时
|
||||||
|
// 设置no delay,减少延迟
|
||||||
|
noDelay: true,
|
||||||
|
},
|
||||||
|
// 添加其他配置
|
||||||
|
pingInterval: 30000, // 30秒ping一次,保持连接活跃
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加错误事件监听
|
||||||
|
client.on('error', (err) => {
|
||||||
|
console.error('Redis client error:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('connect', () => {
|
||||||
|
console.log('Redis connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('reconnecting', () => {
|
||||||
|
console.log('Redis reconnecting...');
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('ready', () => {
|
||||||
|
console.log('Redis ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 初始连接,带重试机制
|
||||||
|
const connectWithRetry = async () => {
|
||||||
|
try {
|
||||||
|
await client!.connect();
|
||||||
|
console.log('Redis connected successfully');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Redis initial connection failed:', err);
|
||||||
|
console.log('Will retry in 5 seconds...');
|
||||||
|
setTimeout(connectWithRetry, 5000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
connectWithRetry();
|
||||||
|
|
||||||
|
(global as any)[globalKey] = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { AdminConfig } from './admin.types';
|
||||||
|
|
||||||
|
// 播放记录数据结构
|
||||||
|
export interface PlayRecord {
|
||||||
|
title: string;
|
||||||
|
source_name: string;
|
||||||
|
cover: string;
|
||||||
|
year: string;
|
||||||
|
index: number; // 第几集
|
||||||
|
total_episodes: number; // 总集数
|
||||||
|
play_time: number; // 播放进度(秒)
|
||||||
|
total_time: number; // 总进度(秒)
|
||||||
|
save_time: number; // 记录保存时间(时间戳)
|
||||||
|
search_title: string; // 搜索时使用的标题
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收藏数据结构
|
||||||
|
export interface Favorite {
|
||||||
|
source_name: string;
|
||||||
|
total_episodes: number; // 总集数
|
||||||
|
title: string;
|
||||||
|
year: string;
|
||||||
|
cover: string;
|
||||||
|
save_time: number; // 记录保存时间(时间戳)
|
||||||
|
search_title: string; // 搜索时使用的标题
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存储接口
|
||||||
|
export interface IStorage {
|
||||||
|
// 播放记录相关
|
||||||
|
getPlayRecord(userName: string, key: string): Promise<PlayRecord | null>;
|
||||||
|
setPlayRecord(
|
||||||
|
userName: string,
|
||||||
|
key: string,
|
||||||
|
record: PlayRecord
|
||||||
|
): Promise<void>;
|
||||||
|
getAllPlayRecords(userName: string): Promise<{ [key: string]: PlayRecord }>;
|
||||||
|
deletePlayRecord(userName: string, key: string): Promise<void>;
|
||||||
|
|
||||||
|
// 收藏相关
|
||||||
|
getFavorite(userName: string, key: string): Promise<Favorite | null>;
|
||||||
|
setFavorite(userName: string, key: string, favorite: Favorite): Promise<void>;
|
||||||
|
getAllFavorites(userName: string): Promise<{ [key: string]: Favorite }>;
|
||||||
|
deleteFavorite(userName: string, key: string): Promise<void>;
|
||||||
|
|
||||||
|
// 用户相关
|
||||||
|
registerUser(userName: string, password: string): Promise<void>;
|
||||||
|
verifyUser(userName: string, password: string): Promise<boolean>;
|
||||||
|
// 检查用户是否存在(无需密码)
|
||||||
|
checkUserExist(userName: string): Promise<boolean>;
|
||||||
|
// 修改用户密码
|
||||||
|
changePassword(userName: string, newPassword: string): Promise<void>;
|
||||||
|
// 删除用户(包括密码、搜索历史、播放记录、收藏夹)
|
||||||
|
deleteUser(userName: string): Promise<void>;
|
||||||
|
|
||||||
|
// 搜索历史相关
|
||||||
|
getSearchHistory(userName: string): Promise<string[]>;
|
||||||
|
addSearchHistory(userName: string, keyword: string): Promise<void>;
|
||||||
|
deleteSearchHistory(userName: string, keyword?: string): Promise<void>;
|
||||||
|
|
||||||
|
// 用户列表
|
||||||
|
getAllUsers(): Promise<string[]>;
|
||||||
|
|
||||||
|
// 管理员配置相关
|
||||||
|
getAdminConfig(): Promise<AdminConfig | null>;
|
||||||
|
setAdminConfig(config: AdminConfig): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索结果数据结构
|
||||||
|
export interface SearchResult {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
poster: string;
|
||||||
|
episodes: string[];
|
||||||
|
source: string;
|
||||||
|
source_name: string;
|
||||||
|
class?: string;
|
||||||
|
year: string;
|
||||||
|
desc?: string;
|
||||||
|
type_name?: string;
|
||||||
|
douban_id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 豆瓣数据结构
|
||||||
|
export interface DoubanItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
poster: string;
|
||||||
|
rate: string;
|
||||||
|
year: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DoubanResult {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
list: DoubanItem[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
||||||
|
|
||||||
|
import { Redis } from '@upstash/redis';
|
||||||
|
|
||||||
|
import { AdminConfig } from './admin.types';
|
||||||
|
import { Favorite, IStorage, PlayRecord } from './types';
|
||||||
|
|
||||||
|
// 搜索历史最大条数
|
||||||
|
const SEARCH_HISTORY_LIMIT = 20;
|
||||||
|
|
||||||
|
// 数据类型转换辅助函数
|
||||||
|
function ensureString(value: any): string {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureStringArray(value: any[]): string[] {
|
||||||
|
return value.map((item) => String(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加Upstash Redis操作重试包装器
|
||||||
|
async function withRetry<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
maxRetries = 3
|
||||||
|
): Promise<T> {
|
||||||
|
for (let i = 0; i < maxRetries; i++) {
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (err: any) {
|
||||||
|
const isLastAttempt = i === maxRetries - 1;
|
||||||
|
const isConnectionError =
|
||||||
|
err.message?.includes('Connection') ||
|
||||||
|
err.message?.includes('ECONNREFUSED') ||
|
||||||
|
err.message?.includes('ENOTFOUND') ||
|
||||||
|
err.code === 'ECONNRESET' ||
|
||||||
|
err.code === 'EPIPE' ||
|
||||||
|
err.name === 'UpstashError';
|
||||||
|
|
||||||
|
if (isConnectionError && !isLastAttempt) {
|
||||||
|
console.log(
|
||||||
|
`Upstash Redis operation failed, retrying... (${i + 1}/${maxRetries})`
|
||||||
|
);
|
||||||
|
console.error('Error:', err.message);
|
||||||
|
|
||||||
|
// 等待一段时间后重试
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000 * (i + 1)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Max retries exceeded');
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpstashRedisStorage implements IStorage {
|
||||||
|
private client: Redis;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.client = getUpstashRedisClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 播放记录 ----------
|
||||||
|
private prKey(user: string, key: string) {
|
||||||
|
return `u:${user}:pr:${key}`; // u:username:pr:source+id
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPlayRecord(
|
||||||
|
userName: string,
|
||||||
|
key: string
|
||||||
|
): Promise<PlayRecord | null> {
|
||||||
|
const val = await withRetry(() =>
|
||||||
|
this.client.get(this.prKey(userName, key))
|
||||||
|
);
|
||||||
|
return val ? (val as PlayRecord) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPlayRecord(
|
||||||
|
userName: string,
|
||||||
|
key: string,
|
||||||
|
record: PlayRecord
|
||||||
|
): Promise<void> {
|
||||||
|
await withRetry(() => this.client.set(this.prKey(userName, key), record));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllPlayRecords(
|
||||||
|
userName: string
|
||||||
|
): Promise<Record<string, PlayRecord>> {
|
||||||
|
const pattern = `u:${userName}:pr:*`;
|
||||||
|
const keys: string[] = await withRetry(() => this.client.keys(pattern));
|
||||||
|
if (keys.length === 0) return {};
|
||||||
|
|
||||||
|
const result: Record<string, PlayRecord> = {};
|
||||||
|
for (const fullKey of keys) {
|
||||||
|
const value = await withRetry(() => this.client.get(fullKey));
|
||||||
|
if (value) {
|
||||||
|
// 截取 source+id 部分
|
||||||
|
const keyPart = ensureString(fullKey.replace(`u:${userName}:pr:`, ''));
|
||||||
|
result[keyPart] = value as PlayRecord;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePlayRecord(userName: string, key: string): Promise<void> {
|
||||||
|
await withRetry(() => this.client.del(this.prKey(userName, key)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 收藏 ----------
|
||||||
|
private favKey(user: string, key: string) {
|
||||||
|
return `u:${user}:fav:${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFavorite(userName: string, key: string): Promise<Favorite | null> {
|
||||||
|
const val = await withRetry(() =>
|
||||||
|
this.client.get(this.favKey(userName, key))
|
||||||
|
);
|
||||||
|
return val ? (val as Favorite) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setFavorite(
|
||||||
|
userName: string,
|
||||||
|
key: string,
|
||||||
|
favorite: Favorite
|
||||||
|
): Promise<void> {
|
||||||
|
await withRetry(() =>
|
||||||
|
this.client.set(this.favKey(userName, key), favorite)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllFavorites(userName: string): Promise<Record<string, Favorite>> {
|
||||||
|
const pattern = `u:${userName}:fav:*`;
|
||||||
|
const keys: string[] = await withRetry(() => this.client.keys(pattern));
|
||||||
|
if (keys.length === 0) return {};
|
||||||
|
|
||||||
|
const result: Record<string, Favorite> = {};
|
||||||
|
for (const fullKey of keys) {
|
||||||
|
const value = await withRetry(() => this.client.get(fullKey));
|
||||||
|
if (value) {
|
||||||
|
const keyPart = ensureString(fullKey.replace(`u:${userName}:fav:`, ''));
|
||||||
|
result[keyPart] = value as Favorite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFavorite(userName: string, key: string): Promise<void> {
|
||||||
|
await withRetry(() => this.client.del(this.favKey(userName, key)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 用户注册 / 登录 ----------
|
||||||
|
private userPwdKey(user: string) {
|
||||||
|
return `u:${user}:pwd`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async registerUser(userName: string, password: string): Promise<void> {
|
||||||
|
// 简单存储明文密码,生产环境应加密
|
||||||
|
await withRetry(() => this.client.set(this.userPwdKey(userName), password));
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyUser(userName: string, password: string): Promise<boolean> {
|
||||||
|
const stored = await withRetry(() =>
|
||||||
|
this.client.get(this.userPwdKey(userName))
|
||||||
|
);
|
||||||
|
if (stored === null) return false;
|
||||||
|
// 确保比较时都是字符串类型
|
||||||
|
return ensureString(stored) === password;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户是否存在
|
||||||
|
async checkUserExist(userName: string): Promise<boolean> {
|
||||||
|
// 使用 EXISTS 判断 key 是否存在
|
||||||
|
const exists = await withRetry(() =>
|
||||||
|
this.client.exists(this.userPwdKey(userName))
|
||||||
|
);
|
||||||
|
return exists === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改用户密码
|
||||||
|
async changePassword(userName: string, newPassword: string): Promise<void> {
|
||||||
|
// 简单存储明文密码,生产环境应加密
|
||||||
|
await withRetry(() =>
|
||||||
|
this.client.set(this.userPwdKey(userName), newPassword)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除用户及其所有数据
|
||||||
|
async deleteUser(userName: string): Promise<void> {
|
||||||
|
// 删除用户密码
|
||||||
|
await withRetry(() => this.client.del(this.userPwdKey(userName)));
|
||||||
|
|
||||||
|
// 删除搜索历史
|
||||||
|
await withRetry(() => this.client.del(this.shKey(userName)));
|
||||||
|
|
||||||
|
// 删除播放记录
|
||||||
|
const playRecordPattern = `u:${userName}:pr:*`;
|
||||||
|
const playRecordKeys = await withRetry(() =>
|
||||||
|
this.client.keys(playRecordPattern)
|
||||||
|
);
|
||||||
|
if (playRecordKeys.length > 0) {
|
||||||
|
await withRetry(() => this.client.del(...playRecordKeys));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除收藏夹
|
||||||
|
const favoritePattern = `u:${userName}:fav:*`;
|
||||||
|
const favoriteKeys = await withRetry(() =>
|
||||||
|
this.client.keys(favoritePattern)
|
||||||
|
);
|
||||||
|
if (favoriteKeys.length > 0) {
|
||||||
|
await withRetry(() => this.client.del(...favoriteKeys));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 搜索历史 ----------
|
||||||
|
private shKey(user: string) {
|
||||||
|
return `u:${user}:sh`; // u:username:sh
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSearchHistory(userName: string): Promise<string[]> {
|
||||||
|
const result = await withRetry(() =>
|
||||||
|
this.client.lrange(this.shKey(userName), 0, -1)
|
||||||
|
);
|
||||||
|
// 确保返回的都是字符串类型
|
||||||
|
return ensureStringArray(result as any[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addSearchHistory(userName: string, keyword: string): Promise<void> {
|
||||||
|
const key = this.shKey(userName);
|
||||||
|
// 先去重
|
||||||
|
await withRetry(() => this.client.lrem(key, 0, ensureString(keyword)));
|
||||||
|
// 插入到最前
|
||||||
|
await withRetry(() => this.client.lpush(key, ensureString(keyword)));
|
||||||
|
// 限制最大长度
|
||||||
|
await withRetry(() => this.client.ltrim(key, 0, SEARCH_HISTORY_LIMIT - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSearchHistory(userName: string, keyword?: string): Promise<void> {
|
||||||
|
const key = this.shKey(userName);
|
||||||
|
if (keyword) {
|
||||||
|
await withRetry(() => this.client.lrem(key, 0, ensureString(keyword)));
|
||||||
|
} else {
|
||||||
|
await withRetry(() => this.client.del(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 获取全部用户 ----------
|
||||||
|
async getAllUsers(): Promise<string[]> {
|
||||||
|
const keys = await withRetry(() => this.client.keys('u:*:pwd'));
|
||||||
|
return keys
|
||||||
|
.map((k) => {
|
||||||
|
const match = k.match(/^u:(.+?):pwd$/);
|
||||||
|
return match ? ensureString(match[1]) : undefined;
|
||||||
|
})
|
||||||
|
.filter((u): u is string => typeof u === 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 管理员配置 ----------
|
||||||
|
private adminConfigKey() {
|
||||||
|
return 'admin:config';
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAdminConfig(): Promise<AdminConfig | null> {
|
||||||
|
const val = await withRetry(() => this.client.get(this.adminConfigKey()));
|
||||||
|
return val ? (val as AdminConfig) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setAdminConfig(config: AdminConfig): Promise<void> {
|
||||||
|
await withRetry(() => this.client.set(this.adminConfigKey(), config));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单例 Upstash Redis 客户端
|
||||||
|
function getUpstashRedisClient(): Redis {
|
||||||
|
const globalKey = Symbol.for('__MOONTV_UPSTASH_REDIS_CLIENT__');
|
||||||
|
let client: Redis | undefined = (global as any)[globalKey];
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
const upstashUrl = process.env.UPSTASH_URL;
|
||||||
|
const upstashToken = process.env.UPSTASH_TOKEN;
|
||||||
|
|
||||||
|
if (!upstashUrl || !upstashToken) {
|
||||||
|
throw new Error(
|
||||||
|
'UPSTASH_URL and UPSTASH_TOKEN env variables must be set'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 Upstash Redis 客户端
|
||||||
|
client = new Redis({
|
||||||
|
url: upstashUrl,
|
||||||
|
token: upstashToken,
|
||||||
|
// 可选配置
|
||||||
|
retry: {
|
||||||
|
retries: 3,
|
||||||
|
backoff: (retryCount: number) =>
|
||||||
|
Math.min(1000 * Math.pow(2, retryCount), 30000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Upstash Redis client created successfully');
|
||||||
|
|
||||||
|
(global as any)[globalKey] = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||||
|
|
||||||
|
import Hls from 'hls.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取图片代理 URL 设置
|
||||||
|
*/
|
||||||
|
export function getImageProxyUrl(): string | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
|
||||||
|
// 本地未开启图片代理,则不使用代理
|
||||||
|
const enableImageProxy = localStorage.getItem('enableImageProxy');
|
||||||
|
if (enableImageProxy !== null) {
|
||||||
|
if (!JSON.parse(enableImageProxy) as boolean) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const localImageProxy = localStorage.getItem('imageProxyUrl');
|
||||||
|
if (localImageProxy != null) {
|
||||||
|
return localImageProxy.trim() ? localImageProxy.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果未设置,则使用全局对象
|
||||||
|
const serverImageProxy = (window as any).RUNTIME_CONFIG?.IMAGE_PROXY;
|
||||||
|
return serverImageProxy && serverImageProxy.trim()
|
||||||
|
? serverImageProxy.trim()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理图片 URL,如果设置了图片代理则使用代理
|
||||||
|
*/
|
||||||
|
export function processImageUrl(originalUrl: string): string {
|
||||||
|
if (!originalUrl) return originalUrl;
|
||||||
|
|
||||||
|
const proxyUrl = getImageProxyUrl();
|
||||||
|
if (!proxyUrl) return originalUrl;
|
||||||
|
|
||||||
|
return `${proxyUrl}${encodeURIComponent(originalUrl)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取豆瓣代理 URL 设置
|
||||||
|
*/
|
||||||
|
export function getDoubanProxyUrl(): string | null {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
|
||||||
|
// 本地未开启豆瓣代理,则不使用代理
|
||||||
|
const enableDoubanProxy = localStorage.getItem('enableDoubanProxy');
|
||||||
|
if (enableDoubanProxy !== null) {
|
||||||
|
if (!JSON.parse(enableDoubanProxy) as boolean) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const localDoubanProxy = localStorage.getItem('doubanProxyUrl');
|
||||||
|
if (localDoubanProxy != null) {
|
||||||
|
return localDoubanProxy.trim() ? localDoubanProxy.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果未设置,则使用全局对象
|
||||||
|
const serverDoubanProxy = (window as any).RUNTIME_CONFIG?.DOUBAN_PROXY;
|
||||||
|
return serverDoubanProxy && serverDoubanProxy.trim()
|
||||||
|
? serverDoubanProxy.trim()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理豆瓣 URL,如果设置了豆瓣代理则使用代理
|
||||||
|
*/
|
||||||
|
export function processDoubanUrl(originalUrl: string): string {
|
||||||
|
if (!originalUrl) return originalUrl;
|
||||||
|
|
||||||
|
const proxyUrl = getDoubanProxyUrl();
|
||||||
|
if (!proxyUrl) return originalUrl;
|
||||||
|
|
||||||
|
return `${proxyUrl}${encodeURIComponent(originalUrl)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanHtmlTags(text: string): string {
|
||||||
|
if (!text) return '';
|
||||||
|
return text
|
||||||
|
.replace(/<[^>]+>/g, '\n') // 将 HTML 标签替换为换行
|
||||||
|
.replace(/\n+/g, '\n') // 将多个连续换行合并为一个
|
||||||
|
.replace(/[ \t]+/g, ' ') // 将多个连续空格和制表符合并为一个空格,但保留换行符
|
||||||
|
.replace(/^\n+|\n+$/g, '') // 去掉首尾换行
|
||||||
|
.replace(/ /g, ' ') // 将 替换为空格
|
||||||
|
.trim(); // 去掉首尾空格
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从m3u8地址获取视频质量等级和网络信息
|
||||||
|
* @param m3u8Url m3u8播放列表的URL
|
||||||
|
* @returns Promise<{quality: string, loadSpeed: string, pingTime: number}> 视频质量等级和网络信息
|
||||||
|
*/
|
||||||
|
export async function getVideoResolutionFromM3u8(m3u8Url: string): Promise<{
|
||||||
|
quality: string; // 如720p、1080p等
|
||||||
|
loadSpeed: string; // 自动转换为KB/s或MB/s
|
||||||
|
pingTime: number; // 网络延迟(毫秒)
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
// 直接使用m3u8 URL作为视频源,避免CORS问题
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.muted = true;
|
||||||
|
video.preload = 'metadata';
|
||||||
|
|
||||||
|
// 测量网络延迟(ping时间) - 使用m3u8 URL而不是ts文件
|
||||||
|
const pingStart = performance.now();
|
||||||
|
let pingTime = 0;
|
||||||
|
|
||||||
|
// 测量ping时间(使用m3u8 URL)
|
||||||
|
fetch(m3u8Url, { method: 'HEAD', mode: 'no-cors' })
|
||||||
|
.then(() => {
|
||||||
|
pingTime = performance.now() - pingStart;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
pingTime = performance.now() - pingStart; // 记录到失败为止的时间
|
||||||
|
});
|
||||||
|
|
||||||
|
// 固定使用hls.js加载
|
||||||
|
const hls = new Hls();
|
||||||
|
|
||||||
|
// 设置超时处理
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
hls.destroy();
|
||||||
|
video.remove();
|
||||||
|
reject(new Error('Timeout loading video metadata'));
|
||||||
|
}, 4000);
|
||||||
|
|
||||||
|
video.onerror = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
hls.destroy();
|
||||||
|
video.remove();
|
||||||
|
reject(new Error('Failed to load video metadata'));
|
||||||
|
};
|
||||||
|
|
||||||
|
let actualLoadSpeed = '未知';
|
||||||
|
let hasSpeedCalculated = false;
|
||||||
|
let hasMetadataLoaded = false;
|
||||||
|
|
||||||
|
let fragmentStartTime = 0;
|
||||||
|
|
||||||
|
// 检查是否可以返回结果
|
||||||
|
const checkAndResolve = () => {
|
||||||
|
if (
|
||||||
|
hasMetadataLoaded &&
|
||||||
|
(hasSpeedCalculated || actualLoadSpeed !== '未知')
|
||||||
|
) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
const width = video.videoWidth;
|
||||||
|
if (width && width > 0) {
|
||||||
|
hls.destroy();
|
||||||
|
video.remove();
|
||||||
|
|
||||||
|
// 根据视频宽度判断视频质量等级,使用经典分辨率的宽度作为分割点
|
||||||
|
const quality =
|
||||||
|
width >= 3840
|
||||||
|
? '4K' // 4K: 3840x2160
|
||||||
|
: width >= 2560
|
||||||
|
? '2K' // 2K: 2560x1440
|
||||||
|
: width >= 1920
|
||||||
|
? '1080p' // 1080p: 1920x1080
|
||||||
|
: width >= 1280
|
||||||
|
? '720p' // 720p: 1280x720
|
||||||
|
: width >= 854
|
||||||
|
? '480p'
|
||||||
|
: 'SD'; // 480p: 854x480
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
quality,
|
||||||
|
loadSpeed: actualLoadSpeed,
|
||||||
|
pingTime: Math.round(pingTime),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// webkit 无法获取尺寸,直接返回
|
||||||
|
resolve({
|
||||||
|
quality: '未知',
|
||||||
|
loadSpeed: actualLoadSpeed,
|
||||||
|
pingTime: Math.round(pingTime),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听片段加载开始
|
||||||
|
hls.on(Hls.Events.FRAG_LOADING, () => {
|
||||||
|
fragmentStartTime = performance.now();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听片段加载完成,只需首个分片即可计算速度
|
||||||
|
hls.on(Hls.Events.FRAG_LOADED, (event: any, data: any) => {
|
||||||
|
if (
|
||||||
|
fragmentStartTime > 0 &&
|
||||||
|
data &&
|
||||||
|
data.payload &&
|
||||||
|
!hasSpeedCalculated
|
||||||
|
) {
|
||||||
|
const loadTime = performance.now() - fragmentStartTime;
|
||||||
|
const size = data.payload.byteLength || 0;
|
||||||
|
|
||||||
|
if (loadTime > 0 && size > 0) {
|
||||||
|
const speedKBps = size / 1024 / (loadTime / 1000);
|
||||||
|
|
||||||
|
// 立即计算速度,无需等待更多分片
|
||||||
|
const avgSpeedKBps = speedKBps;
|
||||||
|
|
||||||
|
if (avgSpeedKBps >= 1024) {
|
||||||
|
actualLoadSpeed = `${(avgSpeedKBps / 1024).toFixed(1)} MB/s`;
|
||||||
|
} else {
|
||||||
|
actualLoadSpeed = `${avgSpeedKBps.toFixed(1)} KB/s`;
|
||||||
|
}
|
||||||
|
hasSpeedCalculated = true;
|
||||||
|
checkAndResolve(); // 尝试返回结果
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
hls.loadSource(m3u8Url);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
|
||||||
|
// 监听hls.js错误
|
||||||
|
hls.on(Hls.Events.ERROR, (event: any, data: any) => {
|
||||||
|
console.error('HLS错误:', data);
|
||||||
|
if (data.fatal) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
hls.destroy();
|
||||||
|
video.remove();
|
||||||
|
reject(new Error(`HLS播放失败: ${data.type}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听视频元数据加载完成
|
||||||
|
video.onloadedmetadata = () => {
|
||||||
|
hasMetadataLoaded = true;
|
||||||
|
checkAndResolve(); // 尝试返回结果
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Error getting video resolution: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
const CURRENT_VERSION = '20250928125318';
|
||||||
|
|
||||||
|
// 版本检查结果枚举
|
||||||
|
export enum UpdateStatus {
|
||||||
|
HAS_UPDATE = 'has_update', // 有新版本
|
||||||
|
NO_UPDATE = 'no_update', // 无新版本
|
||||||
|
FETCH_FAILED = 'fetch_failed', // 获取失败
|
||||||
|
}
|
||||||
|
|
||||||
|
// 远程版本检查URL配置
|
||||||
|
const VERSION_CHECK_URLS = [
|
||||||
|
'https://ghfast.top/raw.githubusercontent.com/senshinya/MoonTV/main/VERSION.txt',
|
||||||
|
'https://raw.githubusercontent.com/senshinya/MoonTV/main/VERSION.txt',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否有新版本可用
|
||||||
|
* @returns Promise<UpdateStatus> - 返回版本检查状态
|
||||||
|
*/
|
||||||
|
export async function checkForUpdates(): Promise<UpdateStatus> {
|
||||||
|
try {
|
||||||
|
// 尝试从主要URL获取版本信息
|
||||||
|
const primaryVersion = await fetchVersionFromUrl(VERSION_CHECK_URLS[0]);
|
||||||
|
if (primaryVersion) {
|
||||||
|
return compareVersions(primaryVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果主要URL失败,尝试备用URL
|
||||||
|
const backupVersion = await fetchVersionFromUrl(VERSION_CHECK_URLS[1]);
|
||||||
|
if (backupVersion) {
|
||||||
|
return compareVersions(backupVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果两个URL都失败,返回获取失败状态
|
||||||
|
return UpdateStatus.FETCH_FAILED;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('版本检查失败:', error);
|
||||||
|
return UpdateStatus.FETCH_FAILED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从指定URL获取版本信息
|
||||||
|
* @param url - 版本信息URL
|
||||||
|
* @returns Promise<string | null> - 版本字符串或null
|
||||||
|
*/
|
||||||
|
async function fetchVersionFromUrl(url: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5秒超时
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = await response.text();
|
||||||
|
return version.trim();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`从 ${url} 获取版本信息失败:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 比较版本号
|
||||||
|
* @param remoteVersion - 远程版本号
|
||||||
|
* @returns UpdateStatus - 返回版本比较结果
|
||||||
|
*/
|
||||||
|
function compareVersions(remoteVersion: string): UpdateStatus {
|
||||||
|
try {
|
||||||
|
// 将版本号转换为数字进行比较
|
||||||
|
const current = parseInt(CURRENT_VERSION, 10);
|
||||||
|
const remote = parseInt(remoteVersion, 10);
|
||||||
|
|
||||||
|
return remote > current ? UpdateStatus.HAS_UPDATE : UpdateStatus.NO_UPDATE;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('版本比较失败:', error);
|
||||||
|
return UpdateStatus.FETCH_FAILED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出当前版本号供其他地方使用
|
||||||
|
export { CURRENT_VERSION };
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
// 跳过不需要认证的路径
|
||||||
|
if (shouldSkipAuth(pathname)) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||||
|
|
||||||
|
if (!process.env.PASSWORD) {
|
||||||
|
// 如果没有设置密码,重定向到警告页面
|
||||||
|
const warningUrl = new URL('/warning', request.url);
|
||||||
|
return NextResponse.redirect(warningUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从cookie获取认证信息
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
|
||||||
|
if (!authInfo) {
|
||||||
|
return handleAuthFailure(request, pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
// localstorage模式:在middleware中完成验证
|
||||||
|
if (storageType === 'localstorage') {
|
||||||
|
if (!authInfo.password || authInfo.password !== process.env.PASSWORD) {
|
||||||
|
return handleAuthFailure(request, pathname);
|
||||||
|
}
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他模式:只验证签名
|
||||||
|
// 检查是否有用户名(非localStorage模式下密码不存储在cookie中)
|
||||||
|
if (!authInfo.username || !authInfo.signature) {
|
||||||
|
return handleAuthFailure(request, pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证签名(如果存在)
|
||||||
|
if (authInfo.signature) {
|
||||||
|
const isValidSignature = await verifySignature(
|
||||||
|
authInfo.username,
|
||||||
|
authInfo.signature,
|
||||||
|
process.env.PASSWORD || ''
|
||||||
|
);
|
||||||
|
|
||||||
|
// 签名验证通过即可
|
||||||
|
if (isValidSignature) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 签名验证失败或不存在签名
|
||||||
|
return handleAuthFailure(request, pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证签名
|
||||||
|
async function verifySignature(
|
||||||
|
data: string,
|
||||||
|
signature: string,
|
||||||
|
secret: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const keyData = encoder.encode(secret);
|
||||||
|
const messageData = encoder.encode(data);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 导入密钥
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
keyData,
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['verify']
|
||||||
|
);
|
||||||
|
|
||||||
|
// 将十六进制字符串转换为Uint8Array
|
||||||
|
const signatureBuffer = new Uint8Array(
|
||||||
|
signature.match(/.{1,2}/g)?.map((byte) => parseInt(byte, 16)) || []
|
||||||
|
);
|
||||||
|
|
||||||
|
// 验证签名
|
||||||
|
return await crypto.subtle.verify(
|
||||||
|
'HMAC',
|
||||||
|
key,
|
||||||
|
signatureBuffer,
|
||||||
|
messageData
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('签名验证失败:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理认证失败的情况
|
||||||
|
function handleAuthFailure(
|
||||||
|
request: NextRequest,
|
||||||
|
pathname: string
|
||||||
|
): NextResponse {
|
||||||
|
// 如果是 API 路由,返回 401 状态码
|
||||||
|
if (pathname.startsWith('/api')) {
|
||||||
|
return new NextResponse('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则重定向到登录页面
|
||||||
|
const loginUrl = new URL('/login', request.url);
|
||||||
|
// 保留完整的URL,包括查询参数
|
||||||
|
const fullUrl = `${pathname}${request.nextUrl.search}`;
|
||||||
|
loginUrl.searchParams.set('redirect', fullUrl);
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断是否需要跳过认证的路径
|
||||||
|
function shouldSkipAuth(pathname: string): boolean {
|
||||||
|
const skipPaths = [
|
||||||
|
'/_next',
|
||||||
|
'/favicon.ico',
|
||||||
|
'/robots.txt',
|
||||||
|
'/manifest.json',
|
||||||
|
'/icons/',
|
||||||
|
'/logo.png',
|
||||||
|
'/screenshot.png',
|
||||||
|
];
|
||||||
|
|
||||||
|
return skipPaths.some((path) => pathname.startsWith(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置middleware匹配规则
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
'/((?!_next/static|_next/image|favicon.ico|login|warning|api/login|api/register|api/logout|api/cron|api/server-config).*)',
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,550 @@
|
|||||||
|
/* //!STARTERCONF Remove this file after copying your desired color, this is a large file you should remove it. */
|
||||||
|
|
||||||
|
.slate {
|
||||||
|
--tw-color-primary-50: 248 250 252;
|
||||||
|
--tw-color-primary-100: 241 245 249;
|
||||||
|
--tw-color-primary-200: 226 232 240;
|
||||||
|
--tw-color-primary-300: 203 213 225;
|
||||||
|
--tw-color-primary-400: 148 163 184;
|
||||||
|
--tw-color-primary-500: 100 116 139;
|
||||||
|
--tw-color-primary-600: 71 85 105;
|
||||||
|
--tw-color-primary-700: 51 65 85;
|
||||||
|
--tw-color-primary-800: 30 41 59;
|
||||||
|
--tw-color-primary-900: 15 23 42;
|
||||||
|
--tw-color-primary-950: 2 6 23;
|
||||||
|
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #f8fafc */
|
||||||
|
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #f1f5f9 */
|
||||||
|
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #e2e8f0 */
|
||||||
|
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #cbd5e1 */
|
||||||
|
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #94a3b8 */
|
||||||
|
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #64748b */
|
||||||
|
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #475569 */
|
||||||
|
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #334155 */
|
||||||
|
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #1e293b */
|
||||||
|
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #0f172a */
|
||||||
|
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #020617 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.gray {
|
||||||
|
--tw-color-primary-50: 249 250 251;
|
||||||
|
--tw-color-primary-100: 243 244 246;
|
||||||
|
--tw-color-primary-200: 229 231 235;
|
||||||
|
--tw-color-primary-300: 209 213 219;
|
||||||
|
--tw-color-primary-400: 156 163 175;
|
||||||
|
--tw-color-primary-500: 107 114 128;
|
||||||
|
--tw-color-primary-600: 75 85 99;
|
||||||
|
--tw-color-primary-700: 55 65 81;
|
||||||
|
--tw-color-primary-800: 31 41 55;
|
||||||
|
--tw-color-primary-900: 17 24 39;
|
||||||
|
--tw-color-primary-950: 3 7 18;
|
||||||
|
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #f9fafb */
|
||||||
|
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #f3f4f6 */
|
||||||
|
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #e5e7eb */
|
||||||
|
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #d1d5db */
|
||||||
|
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #9ca3af */
|
||||||
|
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #6b7280 */
|
||||||
|
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #4b5563 */
|
||||||
|
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #374151 */
|
||||||
|
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #1f2937 */
|
||||||
|
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #111827 */
|
||||||
|
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #030712 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.zinc {
|
||||||
|
--tw-color-primary-50: 250 250 250;
|
||||||
|
--tw-color-primary-100: 244 244 245;
|
||||||
|
--tw-color-primary-200: 228 228 231;
|
||||||
|
--tw-color-primary-300: 212 212 216;
|
||||||
|
--tw-color-primary-400: 161 161 170;
|
||||||
|
--tw-color-primary-500: 113 113 122;
|
||||||
|
--tw-color-primary-600: 82 82 91;
|
||||||
|
--tw-color-primary-700: 63 63 70;
|
||||||
|
--tw-color-primary-800: 39 39 42;
|
||||||
|
--tw-color-primary-900: 24 24 27;
|
||||||
|
--tw-color-primary-950: 9 9 11;
|
||||||
|
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #fafafa */
|
||||||
|
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #f4f4f5 */
|
||||||
|
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #e4e4e7 */
|
||||||
|
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #d4d4d8 */
|
||||||
|
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #a1a1aa */
|
||||||
|
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #71717a */
|
||||||
|
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #52525b */
|
||||||
|
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #3f3f46 */
|
||||||
|
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #27272a */
|
||||||
|
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #18181b */
|
||||||
|
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #09090b */
|
||||||
|
}
|
||||||
|
|
||||||
|
.neutral {
|
||||||
|
--tw-color-primary-50: 250 250 250;
|
||||||
|
--tw-color-primary-100: 245 245 245;
|
||||||
|
--tw-color-primary-200: 229 229 229;
|
||||||
|
--tw-color-primary-300: 212 212 212;
|
||||||
|
--tw-color-primary-400: 163 163 163;
|
||||||
|
--tw-color-primary-500: 115 115 115;
|
||||||
|
--tw-color-primary-600: 82 82 82;
|
||||||
|
--tw-color-primary-700: 64 64 64;
|
||||||
|
--tw-color-primary-800: 38 38 38;
|
||||||
|
--tw-color-primary-900: 23 23 23;
|
||||||
|
--tw-color-primary-950: 10 10 10;
|
||||||
|
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #fafafa */
|
||||||
|
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #f5f5f5 */
|
||||||
|
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #e5e5e5 */
|
||||||
|
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #d4d4d4 */
|
||||||
|
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #a3a3a3 */
|
||||||
|
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #737373 */
|
||||||
|
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #525252 */
|
||||||
|
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #404040 */
|
||||||
|
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #262626 */
|
||||||
|
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #171717 */
|
||||||
|
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #0a0a0a */
|
||||||
|
}
|
||||||
|
|
||||||
|
.stone {
|
||||||
|
--tw-color-primary-50: 250 250 249;
|
||||||
|
--tw-color-primary-100: 245 245 244;
|
||||||
|
--tw-color-primary-200: 231 229 228;
|
||||||
|
--tw-color-primary-300: 214 211 209;
|
||||||
|
--tw-color-primary-400: 168 162 158;
|
||||||
|
--tw-color-primary-500: 120 113 108;
|
||||||
|
--tw-color-primary-600: 87 83 78;
|
||||||
|
--tw-color-primary-700: 68 64 60;
|
||||||
|
--tw-color-primary-800: 41 37 36;
|
||||||
|
--tw-color-primary-900: 28 25 23;
|
||||||
|
--tw-color-primary-950: 12 10 9;
|
||||||
|
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #fafaf9 */
|
||||||
|
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #f5f5f4 */
|
||||||
|
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #e7e5e4 */
|
||||||
|
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #d6d3d1 */
|
||||||
|
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #a8a29e */
|
||||||
|
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #78716c */
|
||||||
|
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #57534e */
|
||||||
|
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #44403c */
|
||||||
|
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #292524 */
|
||||||
|
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #1c1917 */
|
||||||
|
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #0c0a09 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.red {
|
||||||
|
--tw-color-primary-50: 254 242 242;
|
||||||
|
--tw-color-primary-100: 254 226 226;
|
||||||
|
--tw-color-primary-200: 254 202 202;
|
||||||
|
--tw-color-primary-300: 252 165 165;
|
||||||
|
--tw-color-primary-400: 248 113 113;
|
||||||
|
--tw-color-primary-500: 239 68 68;
|
||||||
|
--tw-color-primary-600: 220 38 38;
|
||||||
|
--tw-color-primary-700: 185 28 28;
|
||||||
|
--tw-color-primary-800: 153 27 27;
|
||||||
|
--tw-color-primary-900: 127 29 29;
|
||||||
|
--tw-color-primary-950: 69 10 10;
|
||||||
|
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #fef2f2 */
|
||||||
|
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #fee2e2 */
|
||||||
|
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #fecaca */
|
||||||
|
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #fca5a5 */
|
||||||
|
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #f87171 */
|
||||||
|
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #ef4444 */
|
||||||
|
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #dc2626 */
|
||||||
|
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #b91c1c */
|
||||||
|
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #991b1b */
|
||||||
|
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #7f1d1d */
|
||||||
|
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #450a0a */
|
||||||
|
}
|
||||||
|
|
||||||
|
.orange {
|
||||||
|
--tw-color-primary-50: 255 247 237;
|
||||||
|
--tw-color-primary-100: 255 237 213;
|
||||||
|
--tw-color-primary-200: 254 215 170;
|
||||||
|
--tw-color-primary-300: 253 186 116;
|
||||||
|
--tw-color-primary-400: 251 146 60;
|
||||||
|
--tw-color-primary-500: 249 115 22;
|
||||||
|
--tw-color-primary-600: 234 88 12;
|
||||||
|
--tw-color-primary-700: 194 65 12;
|
||||||
|
--tw-color-primary-800: 154 52 18;
|
||||||
|
--tw-color-primary-900: 124 45 18;
|
||||||
|
--tw-color-primary-950: 67 20 7;
|
||||||
|
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #fff7ed */
|
||||||
|
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #ffedd5 */
|
||||||
|
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #fed7aa */
|
||||||
|
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #fdba74 */
|
||||||
|
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #fb923c */
|
||||||
|
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #f97316 */
|
||||||
|
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #ea580c */
|
||||||
|
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #c2410c */
|
||||||
|
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #9a3412 */
|
||||||
|
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #7c2d12 */
|
||||||
|
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #431407 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.amber {
|
||||||
|
--tw-color-primary-50: 255 251 235;
|
||||||
|
--tw-color-primary-100: 254 243 199;
|
||||||
|
--tw-color-primary-200: 253 230 138;
|
||||||
|
--tw-color-primary-300: 252 211 77;
|
||||||
|
--tw-color-primary-400: 251 191 36;
|
||||||
|
--tw-color-primary-500: 245 158 11;
|
||||||
|
--tw-color-primary-600: 217 119 6;
|
||||||
|
--tw-color-primary-700: 180 83 9;
|
||||||
|
--tw-color-primary-800: 146 64 14;
|
||||||
|
--tw-color-primary-900: 120 53 15;
|
||||||
|
--tw-color-primary-950: 69 26 3;
|
||||||
|
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #fffbeb */
|
||||||
|
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #fef3c7 */
|
||||||
|
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #fde68a */
|
||||||
|
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #fcd34d */
|
||||||
|
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #fbbf24 */
|
||||||
|
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #f59e0b */
|
||||||
|
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #d97706 */
|
||||||
|
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #b45309 */
|
||||||
|
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #92400e */
|
||||||
|
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #78350f */
|
||||||
|
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #451a03 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.yellow {
|
||||||
|
--tw-color-primary-50: 254 252 232;
|
||||||
|
--tw-color-primary-100: 254 249 195;
|
||||||
|
--tw-color-primary-200: 254 240 138;
|
||||||
|
--tw-color-primary-300: 253 224 71;
|
||||||
|
--tw-color-primary-400: 250 204 21;
|
||||||
|
--tw-color-primary-500: 234 179 8;
|
||||||
|
--tw-color-primary-600: 202 138 4;
|
||||||
|
--tw-color-primary-700: 161 98 7;
|
||||||
|
--tw-color-primary-800: 133 77 14;
|
||||||
|
--tw-color-primary-900: 113 63 18;
|
||||||
|
--tw-color-primary-950: 66 32 6;
|
||||||
|
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #fefce8 */
|
||||||
|
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #fef9c3 */
|
||||||
|
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #fef08a */
|
||||||
|
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #fde047 */
|
||||||
|
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #facc15 */
|
||||||
|
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #eab308 */
|
||||||
|
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #ca8a04 */
|
||||||
|
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #a16207 */
|
||||||
|
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #854d0e */
|
||||||
|
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #713f12 */
|
||||||
|
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #422006 */
|
||||||
|
}
|
||||||
|
.lime {
|
||||||
|
--tw-color-primary-50: 247 254 231;
|
||||||
|
--tw-color-primary-100: 236 252 203;
|
||||||
|
--tw-color-primary-200: 217 249 157;
|
||||||
|
--tw-color-primary-300: 190 242 100;
|
||||||
|
--tw-color-primary-400: 163 230 53;
|
||||||
|
--tw-color-primary-500: 132 204 22;
|
||||||
|
--tw-color-primary-600: 101 163 13;
|
||||||
|
--tw-color-primary-700: 77 124 15;
|
||||||
|
--tw-color-primary-800: 63 98 18;
|
||||||
|
--tw-color-primary-900: 54 83 20;
|
||||||
|
--tw-color-primary-950: 26 46 5;
|
||||||
|
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #f7fee7 */
|
||||||
|
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #ecfccb */
|
||||||
|
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #d9f99d */
|
||||||
|
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #bef264 */
|
||||||
|
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #a3e635 */
|
||||||
|
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #84cc16 */
|
||||||
|
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #65a30d */
|
||||||
|
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #4d7c0f */
|
||||||
|
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #3f6212 */
|
||||||
|
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #365314 */
|
||||||
|
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #1a2e05 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.green {
|
||||||
|
--tw-color-primary-50: 240 253 244;
|
||||||
|
--tw-color-primary-100: 220 252 231;
|
||||||
|
--tw-color-primary-200: 187 247 208;
|
||||||
|
--tw-color-primary-300: 134 239 172;
|
||||||
|
--tw-color-primary-400: 74 222 128;
|
||||||
|
--tw-color-primary-500: 34 197 94;
|
||||||
|
--tw-color-primary-600: 22 163 74;
|
||||||
|
--tw-color-primary-700: 21 128 61;
|
||||||
|
--tw-color-primary-800: 22 101 52;
|
||||||
|
--tw-color-primary-900: 20 83 45;
|
||||||
|
--tw-color-primary-950: 5 46 22;
|
||||||
|
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #f0fdf4 */
|
||||||
|
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #dcfce7 */
|
||||||
|
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #bbf7d0 */
|
||||||
|
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #86efac */
|
||||||
|
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #4ade80 */
|
||||||
|
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #22c55e */
|
||||||
|
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #16a34a */
|
||||||
|
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #15803d */
|
||||||
|
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #166534 */
|
||||||
|
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #14532d */
|
||||||
|
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #052e16 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.emerald {
|
||||||
|
--tw-color-primary-50: 236 253 245;
|
||||||
|
--tw-color-primary-100: 209 250 229;
|
||||||
|
--tw-color-primary-200: 167 243 208;
|
||||||
|
--tw-color-primary-300: 110 231 183;
|
||||||
|
--tw-color-primary-400: 52 211 153;
|
||||||
|
--tw-color-primary-500: 16 185 129;
|
||||||
|
--tw-color-primary-600: 5 150 105;
|
||||||
|
--tw-color-primary-700: 4 120 87;
|
||||||
|
--tw-color-primary-800: 6 95 70;
|
||||||
|
--tw-color-primary-900: 6 78 59;
|
||||||
|
--tw-color-primary-950: 2 44 34;
|
||||||
|
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #ecfdf5 */
|
||||||
|
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #d1fae5 */
|
||||||
|
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #a7f3d0 */
|
||||||
|
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #6ee7b7 */
|
||||||
|
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #34d399 */
|
||||||
|
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #10b981 */
|
||||||
|
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #059669 */
|
||||||
|
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #047857 */
|
||||||
|
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #065f46 */
|
||||||
|
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #064e3b */
|
||||||
|
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #022c22 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.teal {
|
||||||
|
--tw-color-primary-50: 240 253 250;
|
||||||
|
--tw-color-primary-100: 204 251 241;
|
||||||
|
--tw-color-primary-200: 153 246 228;
|
||||||
|
--tw-color-primary-300: 94 234 212;
|
||||||
|
--tw-color-primary-400: 45 212 191;
|
||||||
|
--tw-color-primary-500: 20 184 166;
|
||||||
|
--tw-color-primary-600: 13 148 136;
|
||||||
|
--tw-color-primary-700: 15 118 110;
|
||||||
|
--tw-color-primary-800: 17 94 89;
|
||||||
|
--tw-color-primary-900: 19 78 74;
|
||||||
|
--tw-color-primary-950: 4 47 46;
|
||||||
|
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #f0fdfa */
|
||||||
|
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #ccfbf1 */
|
||||||
|
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #99f6e4 */
|
||||||
|
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #5eead4 */
|
||||||
|
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #2dd4bf */
|
||||||
|
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #14b8a6 */
|
||||||
|
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #0d9488 */
|
||||||
|
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #0f766e */
|
||||||
|
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #115e59 */
|
||||||
|
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #134e4a */
|
||||||
|
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #042f2e */
|
||||||
|
}
|
||||||
|
|
||||||
|
.cyan {
|
||||||
|
--tw-color-primary-50: 236 254 255;
|
||||||
|
--tw-color-primary-100: 207 250 254;
|
||||||
|
--tw-color-primary-200: 165 243 252;
|
||||||
|
--tw-color-primary-300: 103 232 249;
|
||||||
|
--tw-color-primary-400: 34 211 238;
|
||||||
|
--tw-color-primary-500: 6 182 212;
|
||||||
|
--tw-color-primary-600: 8 145 178;
|
||||||
|
--tw-color-primary-700: 14 116 144;
|
||||||
|
--tw-color-primary-800: 21 94 117;
|
||||||
|
--tw-color-primary-900: 22 78 99;
|
||||||
|
--tw-color-primary-950: 8 51 68;
|
||||||
|
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #ecfeff */
|
||||||
|
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #cffafe */
|
||||||
|
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #a5f3fc */
|
||||||
|
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #67e8f9 */
|
||||||
|
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #22d3ee */
|
||||||
|
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #06b6d4 */
|
||||||
|
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #0891b2 */
|
||||||
|
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #0e7490 */
|
||||||
|
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #155e75 */
|
||||||
|
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #164e63 */
|
||||||
|
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #083344 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.sky {
|
||||||
|
--tw-color-primary-50: 240 249 255;
|
||||||
|
--tw-color-primary-100: 224 242 254;
|
||||||
|
--tw-color-primary-200: 186 230 253;
|
||||||
|
--tw-color-primary-300: 125 211 252;
|
||||||
|
--tw-color-primary-400: 56 189 248;
|
||||||
|
--tw-color-primary-500: 14 165 233;
|
||||||
|
--tw-color-primary-600: 2 132 199;
|
||||||
|
--tw-color-primary-700: 3 105 161;
|
||||||
|
--tw-color-primary-800: 7 89 133;
|
||||||
|
--tw-color-primary-900: 12 74 110;
|
||||||
|
--tw-color-primary-950: 8 47 73;
|
||||||
|
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #f0f9ff */
|
||||||
|
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #e0f2fe */
|
||||||
|
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #bae6fd */
|
||||||
|
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #7dd3fc */
|
||||||
|
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #38bdf8 */
|
||||||
|
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #0ea5e9 */
|
||||||
|
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #0284c7 */
|
||||||
|
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #0369a1 */
|
||||||
|
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #075985 */
|
||||||
|
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #0c4a6e */
|
||||||
|
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #082f49 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.blue {
|
||||||
|
--tw-color-primary-50: 239 246 255;
|
||||||
|
--tw-color-primary-100: 219 234 254;
|
||||||
|
--tw-color-primary-200: 191 219 254;
|
||||||
|
--tw-color-primary-300: 147 197 253;
|
||||||
|
--tw-color-primary-400: 96 165 250;
|
||||||
|
--tw-color-primary-500: 59 130 246;
|
||||||
|
--tw-color-primary-600: 37 99 235;
|
||||||
|
--tw-color-primary-700: 29 78 216;
|
||||||
|
--tw-color-primary-800: 30 64 175;
|
||||||
|
--tw-color-primary-900: 30 58 138;
|
||||||
|
--tw-color-primary-950: 23 37 84;
|
||||||
|
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #eff6ff */
|
||||||
|
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #dbeafe */
|
||||||
|
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #bfdbfe */
|
||||||
|
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #93c5fd */
|
||||||
|
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #60a5fa */
|
||||||
|
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #3b82f6 */
|
||||||
|
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #2563eb */
|
||||||
|
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #1d4ed8 */
|
||||||
|
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #1e40af */
|
||||||
|
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #1e3a8a */
|
||||||
|
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #172554 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.indigo {
|
||||||
|
--tw-color-primary-50: 238 242 255;
|
||||||
|
--tw-color-primary-100: 224 231 255;
|
||||||
|
--tw-color-primary-200: 199 210 254;
|
||||||
|
--tw-color-primary-300: 165 180 252;
|
||||||
|
--tw-color-primary-400: 129 140 248;
|
||||||
|
--tw-color-primary-500: 99 102 241;
|
||||||
|
--tw-color-primary-600: 79 70 229;
|
||||||
|
--tw-color-primary-700: 67 56 202;
|
||||||
|
--tw-color-primary-800: 55 48 163;
|
||||||
|
--tw-color-primary-900: 49 46 129;
|
||||||
|
--tw-color-primary-950: 30 27 75;
|
||||||
|
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #eef2ff */
|
||||||
|
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #e0e7ff */
|
||||||
|
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #c7d2fe */
|
||||||
|
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #a5b4fc */
|
||||||
|
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #818cf8 */
|
||||||
|
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #6366f1 */
|
||||||
|
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #4f46e5 */
|
||||||
|
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #4338ca */
|
||||||
|
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #3730a3 */
|
||||||
|
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #312e81 */
|
||||||
|
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #1e1b4b */
|
||||||
|
}
|
||||||
|
|
||||||
|
.violet {
|
||||||
|
--tw-color-primary-50: 245 243 255;
|
||||||
|
--tw-color-primary-100: 237 233 254;
|
||||||
|
--tw-color-primary-200: 221 214 254;
|
||||||
|
--tw-color-primary-300: 196 181 253;
|
||||||
|
--tw-color-primary-400: 167 139 250;
|
||||||
|
--tw-color-primary-500: 139 92 246;
|
||||||
|
--tw-color-primary-600: 124 58 237;
|
||||||
|
--tw-color-primary-700: 109 40 217;
|
||||||
|
--tw-color-primary-800: 91 33 182;
|
||||||
|
--tw-color-primary-900: 76 29 149;
|
||||||
|
--tw-color-primary-950: 46 16 101;
|
||||||
|
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #f5f3ff */
|
||||||
|
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #ede9fe */
|
||||||
|
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #ddd6fe */
|
||||||
|
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #c4b5fd */
|
||||||
|
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #a78bfa */
|
||||||
|
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #8b5cf6 */
|
||||||
|
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #7c3aed */
|
||||||
|
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #6d28d9 */
|
||||||
|
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #5b21b6 */
|
||||||
|
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #4c1d95 */
|
||||||
|
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #2e1065 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.purple {
|
||||||
|
--tw-color-primary-50: 250 245 255;
|
||||||
|
--tw-color-primary-100: 243 232 255;
|
||||||
|
--tw-color-primary-200: 233 213 255;
|
||||||
|
--tw-color-primary-300: 216 180 254;
|
||||||
|
--tw-color-primary-400: 192 132 252;
|
||||||
|
--tw-color-primary-500: 168 85 247;
|
||||||
|
--tw-color-primary-600: 147 51 234;
|
||||||
|
--tw-color-primary-700: 126 34 206;
|
||||||
|
--tw-color-primary-800: 107 33 168;
|
||||||
|
--tw-color-primary-900: 88 28 135;
|
||||||
|
--tw-color-primary-950: 59 7 100;
|
||||||
|
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #faf5ff */
|
||||||
|
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #f3e8ff */
|
||||||
|
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #e9d5ff */
|
||||||
|
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #d8b4fe */
|
||||||
|
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #c084fc */
|
||||||
|
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #a855f7 */
|
||||||
|
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #9333ea */
|
||||||
|
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #7e22ce */
|
||||||
|
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #6b21a8 */
|
||||||
|
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #581c87 */
|
||||||
|
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #3b0764 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.fuchsia {
|
||||||
|
--tw-color-primary-50: 253 244 255;
|
||||||
|
--tw-color-primary-100: 250 232 255;
|
||||||
|
--tw-color-primary-200: 245 208 254;
|
||||||
|
--tw-color-primary-300: 240 171 252;
|
||||||
|
--tw-color-primary-400: 232 121 249;
|
||||||
|
--tw-color-primary-500: 217 70 239;
|
||||||
|
--tw-color-primary-600: 192 38 211;
|
||||||
|
--tw-color-primary-700: 162 28 175;
|
||||||
|
--tw-color-primary-800: 134 25 143;
|
||||||
|
--tw-color-primary-900: 112 26 117;
|
||||||
|
--tw-color-primary-950: 74 4 78;
|
||||||
|
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #fdf4ff */
|
||||||
|
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #fae8ff */
|
||||||
|
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #f5d0fe */
|
||||||
|
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #f0abfc */
|
||||||
|
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #e879f9 */
|
||||||
|
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #d946ef */
|
||||||
|
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #c026d3 */
|
||||||
|
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #a21caf */
|
||||||
|
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #86198f */
|
||||||
|
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #701a75 */
|
||||||
|
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #4a044e */
|
||||||
|
}
|
||||||
|
|
||||||
|
.pink {
|
||||||
|
--tw-color-primary-50: 253 242 248;
|
||||||
|
--tw-color-primary-100: 252 231 243;
|
||||||
|
--tw-color-primary-200: 251 207 232;
|
||||||
|
--tw-color-primary-300: 249 168 212;
|
||||||
|
--tw-color-primary-400: 244 114 182;
|
||||||
|
--tw-color-primary-500: 236 72 153;
|
||||||
|
--tw-color-primary-600: 219 39 119;
|
||||||
|
--tw-color-primary-700: 190 24 93;
|
||||||
|
--tw-color-primary-800: 157 23 77;
|
||||||
|
--tw-color-primary-900: 131 24 67;
|
||||||
|
--tw-color-primary-950: 80 4 36;
|
||||||
|
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #fdf2f8 */
|
||||||
|
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #fce7f3 */
|
||||||
|
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #fbcfe8 */
|
||||||
|
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #f9a8d4 */
|
||||||
|
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #f472b6 */
|
||||||
|
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #ec4899 */
|
||||||
|
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #db2777 */
|
||||||
|
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #be185d */
|
||||||
|
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #9d174d */
|
||||||
|
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #831843 */
|
||||||
|
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #500724 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.rose {
|
||||||
|
--tw-color-primary-50: 255 241 242;
|
||||||
|
--tw-color-primary-100: 255 228 230;
|
||||||
|
--tw-color-primary-200: 254 205 211;
|
||||||
|
--tw-color-primary-300: 253 164 175;
|
||||||
|
--tw-color-primary-400: 251 113 133;
|
||||||
|
--tw-color-primary-500: 244 63 94;
|
||||||
|
--tw-color-primary-600: 225 29 72;
|
||||||
|
--tw-color-primary-700: 190 18 60;
|
||||||
|
--tw-color-primary-800: 159 18 57;
|
||||||
|
--tw-color-primary-900: 136 19 55;
|
||||||
|
--tw-color-primary-950: 76 5 25;
|
||||||
|
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #fff1f2 */
|
||||||
|
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #ffe4e6 */
|
||||||
|
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #fecdd3 */
|
||||||
|
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #fda4af */
|
||||||
|
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #fb7185 */
|
||||||
|
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #f43f5e */
|
||||||
|
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #e11d48 */
|
||||||
|
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #be123c */
|
||||||
|
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #9f1239 */
|
||||||
|
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #881337 */
|
||||||
|
--color-primary-950: rgb(var(--tw-color-primary-950)); /* #4c0519 */
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* #region /**=========== Primary Color =========== */
|
||||||
|
/* !STARTERCONF Customize these variable, copy and paste from /styles/colors.css for list of colors */
|
||||||
|
--tw-color-primary-50: 240 249 255;
|
||||||
|
--tw-color-primary-100: 224 242 254;
|
||||||
|
--tw-color-primary-200: 186 230 253;
|
||||||
|
--tw-color-primary-300: 125 211 252;
|
||||||
|
--tw-color-primary-400: 56 189 248;
|
||||||
|
--tw-color-primary-500: 14 165 233;
|
||||||
|
--tw-color-primary-600: 2 132 199;
|
||||||
|
--tw-color-primary-700: 3 105 161;
|
||||||
|
--tw-color-primary-800: 7 89 133;
|
||||||
|
--tw-color-primary-900: 12 74 110;
|
||||||
|
--color-primary-50: rgb(var(--tw-color-primary-50)); /* #f0f9ff */
|
||||||
|
--color-primary-100: rgb(var(--tw-color-primary-100)); /* #e0f2fe */
|
||||||
|
--color-primary-200: rgb(var(--tw-color-primary-200)); /* #bae6fd */
|
||||||
|
--color-primary-300: rgb(var(--tw-color-primary-300)); /* #7dd3fc */
|
||||||
|
--color-primary-400: rgb(var(--tw-color-primary-400)); /* #38bdf8 */
|
||||||
|
--color-primary-500: rgb(var(--tw-color-primary-500)); /* #0ea5e9 */
|
||||||
|
--color-primary-600: rgb(var(--tw-color-primary-600)); /* #0284c7 */
|
||||||
|
--color-primary-700: rgb(var(--tw-color-primary-700)); /* #0369a1 */
|
||||||
|
--color-primary-800: rgb(var(--tw-color-primary-800)); /* #075985 */
|
||||||
|
--color-primary-900: rgb(var(--tw-color-primary-900)); /* #0c4a6e */
|
||||||
|
/* #endregion /**======== Primary Color =========== */
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
/* inter var - latin */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-display: block;
|
||||||
|
src: url('/fonts/inter-var-latin.woff2') format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
||||||
|
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212,
|
||||||
|
U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-newtab {
|
||||||
|
cursor: url('/images/new-tab.png') 10 10, pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* #region /**=========== Typography =========== */
|
||||||
|
.h0 {
|
||||||
|
@apply font-primary text-3xl font-bold md:text-5xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
.h1 {
|
||||||
|
@apply font-primary text-2xl font-bold md:text-4xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2,
|
||||||
|
.h2 {
|
||||||
|
@apply font-primary text-xl font-bold md:text-3xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3,
|
||||||
|
.h3 {
|
||||||
|
@apply font-primary text-lg font-bold md:text-2xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4,
|
||||||
|
.h4 {
|
||||||
|
@apply font-primary text-base font-bold md:text-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
body,
|
||||||
|
.p {
|
||||||
|
@apply font-primary text-sm md:text-base;
|
||||||
|
}
|
||||||
|
/* #endregion /**======== Typography =========== */
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
/* 1100px */
|
||||||
|
max-width: 68.75rem;
|
||||||
|
@apply mx-auto w-11/12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-dark a.custom-link {
|
||||||
|
@apply border-gray-200 hover:border-gray-200/0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Class to adjust with sticky footer */
|
||||||
|
.min-h-main {
|
||||||
|
@apply min-h-[calc(100vh-56px)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.animated-underline {
|
||||||
|
background-image: linear-gradient(#33333300, #33333300),
|
||||||
|
linear-gradient(
|
||||||
|
to right,
|
||||||
|
var(--color-primary-400),
|
||||||
|
var(--color-primary-500)
|
||||||
|
);
|
||||||
|
background-size: 100% 2px, 0 2px;
|
||||||
|
background-position: 100% 100%, 0 100%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.animated-underline {
|
||||||
|
transition: 0.3s ease;
|
||||||
|
transition-property: background-size, color, background-color,
|
||||||
|
border-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animated-underline:hover,
|
||||||
|
.animated-underline:focus-visible {
|
||||||
|
background-size: 0 2px, 100% 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/* eslint-disable no-console,@typescript-eslint/no-var-requires */
|
||||||
|
const http = require('http');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// 调用 generate-manifest.js 生成 manifest.json
|
||||||
|
function generateManifest() {
|
||||||
|
console.log('Generating manifest.json for Docker deployment...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const generateManifestScript = path.join(
|
||||||
|
__dirname,
|
||||||
|
'scripts',
|
||||||
|
'generate-manifest.js'
|
||||||
|
);
|
||||||
|
require(generateManifestScript);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error calling generate-manifest.js:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
generateManifest();
|
||||||
|
|
||||||
|
// 直接在当前进程中启动 standalone Server(`server.js`)
|
||||||
|
require('./server.js');
|
||||||
|
|
||||||
|
// 每 1 秒轮询一次,直到请求成功
|
||||||
|
const TARGET_URL = `http://${process.env.HOSTNAME || 'localhost'}:${
|
||||||
|
process.env.PORT || 3000
|
||||||
|
}/login`;
|
||||||
|
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
console.log(`Fetching ${TARGET_URL} ...`);
|
||||||
|
|
||||||
|
const req = http.get(TARGET_URL, (res) => {
|
||||||
|
// 当返回 2xx 状态码时认为成功,然后停止轮询
|
||||||
|
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
console.log('Server is up, stop polling.');
|
||||||
|
clearInterval(intervalId);
|
||||||
|
|
||||||
|
// 服务器启动后,立即执行一次 cron 任务
|
||||||
|
executeCronJob();
|
||||||
|
|
||||||
|
// 然后设置每小时执行一次 cron 任务
|
||||||
|
setInterval(() => {
|
||||||
|
executeCronJob();
|
||||||
|
}, 60 * 60 * 1000); // 每小时执行一次
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
req.setTimeout(2000, () => {
|
||||||
|
req.destroy();
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// 执行 cron 任务的函数
|
||||||
|
function executeCronJob() {
|
||||||
|
const cronUrl = `http://${process.env.HOSTNAME || 'localhost'}:${
|
||||||
|
process.env.PORT || 3000
|
||||||
|
}/api/cron`;
|
||||||
|
|
||||||
|
console.log(`Executing cron job: ${cronUrl}`);
|
||||||
|
|
||||||
|
const req = http.get(cronUrl, (res) => {
|
||||||
|
let data = '';
|
||||||
|
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
console.log('Cron job executed successfully:', data);
|
||||||
|
} else {
|
||||||
|
console.error('Cron job failed:', res.statusCode, data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (err) => {
|
||||||
|
console.error('Error executing cron job:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.setTimeout(30000, () => {
|
||||||
|
console.error('Cron job timeout');
|
||||||
|
req.destroy();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { Config } from 'tailwindcss';
|
||||||
|
import defaultTheme from 'tailwindcss/defaultTheme';
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
darkMode: 'class',
|
||||||
|
content: [
|
||||||
|
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
screens: {
|
||||||
|
'mobile-landscape': {
|
||||||
|
raw: '(orientation: landscape) and (max-height: 700px)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
primary: ['Inter', ...defaultTheme.fontFamily.sans],
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: '#f0f9ff',
|
||||||
|
100: '#e0f2fe',
|
||||||
|
200: '#bae6fd',
|
||||||
|
300: '#7dd3fc',
|
||||||
|
400: '#38bdf8',
|
||||||
|
500: '#0ea5e9',
|
||||||
|
600: '#0284c7',
|
||||||
|
700: '#0369a1',
|
||||||
|
800: '#075985',
|
||||||
|
900: '#0c4a6e',
|
||||||
|
},
|
||||||
|
dark: '#222222',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
flicker: {
|
||||||
|
'0%, 19.999%, 22%, 62.999%, 64%, 64.999%, 70%, 100%': {
|
||||||
|
opacity: '0.99',
|
||||||
|
filter:
|
||||||
|
'drop-shadow(0 0 1px rgba(252, 211, 77)) drop-shadow(0 0 15px rgba(245, 158, 11)) drop-shadow(0 0 1px rgba(252, 211, 77))',
|
||||||
|
},
|
||||||
|
'20%, 21.999%, 63%, 63.999%, 65%, 69.999%': {
|
||||||
|
opacity: '0.4',
|
||||||
|
filter: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
shimmer: {
|
||||||
|
'0%': {
|
||||||
|
backgroundPosition: '-700px 0',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
backgroundPosition: '700px 0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fadeIn: {
|
||||||
|
'0%': { opacity: '0' },
|
||||||
|
'100%': { opacity: '1' },
|
||||||
|
},
|
||||||
|
slideUp: {
|
||||||
|
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||||
|
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||||
|
},
|
||||||
|
slideDown: {
|
||||||
|
'0%': { transform: 'translateY(-10px)', opacity: '0' },
|
||||||
|
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
flicker: 'flicker 3s linear infinite',
|
||||||
|
shimmer: 'shimmer 1.3s linear infinite',
|
||||||
|
'fade-in': 'fadeIn 0.3s ease-in-out',
|
||||||
|
'slide-up': 'slideUp 0.3s ease-in-out',
|
||||||
|
'slide-down': 'slideDown 0.3s ease-in-out',
|
||||||
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
|
||||||
|
'gradient-conic':
|
||||||
|
'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require('@tailwindcss/forms')],
|
||||||
|
} satisfies Config;
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "Node16",
|
||||||
|
"moduleResolution": "node16",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"],
|
||||||
|
"~/*": ["./public/*"]
|
||||||
|
},
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"],
|
||||||
|
"moduleResolution": ["node_modules", ".next", "node"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"headers": [
|
||||||
|
{
|
||||||
|
"source": "/fonts/inter-var-latin.woff2",
|
||||||
|
"headers": [
|
||||||
|
{
|
||||||
|
"key": "Cache-Control",
|
||||||
|
"value": "public, max-age=31536000, immutable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"crons": [
|
||||||
|
{
|
||||||
|
"path": "/api/cron",
|
||||||
|
"schedule": "0 1 * * *"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||