Showing preview only (1,130K chars total). Download the full file or copy to clipboard to get everything.
Repository: MoonTechLab/LunaTV
Branch: main
Commit: af8caa23be1c
Files: 143
Total size: 1.1 MB
Directory structure:
gitextract_6m2hxgck/
├── .dockerignore
├── .eslintrc.js
├── .github/
│ └── workflows/
│ └── docker-image.yml
├── .gitignore
├── .husky/
│ ├── commit-msg
│ ├── post-merge
│ └── pre-commit
├── .npmrc
├── .nvmrc
├── .prettierignore
├── .prettierrc.js
├── .vscode/
│ ├── css.code-snippets
│ ├── extensions.json
│ ├── settings.json
│ └── typescriptreact.code-snippets
├── CHANGELOG
├── Dockerfile
├── LICENSE
├── README.md
├── VERSION.txt
├── commitlint.config.js
├── docker-compose.dev.yml
├── jest.config.js
├── jest.setup.js
├── next.config.js
├── package.json
├── postcss.config.js
├── proxy.worker.js
├── public/
│ └── robots.txt
├── scripts/
│ ├── convert-changelog.js
│ ├── dev-docker.sh
│ └── generate-manifest.js
├── src/
│ ├── app/
│ │ ├── admin/
│ │ │ └── page.tsx
│ │ ├── api/
│ │ │ ├── admin/
│ │ │ │ ├── category/
│ │ │ │ │ └── route.ts
│ │ │ │ ├── config/
│ │ │ │ │ └── route.ts
│ │ │ │ ├── config_file/
│ │ │ │ │ └── route.ts
│ │ │ │ ├── config_subscription/
│ │ │ │ │ └── fetch/
│ │ │ │ │ └── route.ts
│ │ │ │ ├── data_migration/
│ │ │ │ │ ├── export/
│ │ │ │ │ │ └── route.ts
│ │ │ │ │ └── import/
│ │ │ │ │ └── route.ts
│ │ │ │ ├── live/
│ │ │ │ │ ├── refresh/
│ │ │ │ │ │ └── route.ts
│ │ │ │ │ └── route.ts
│ │ │ │ ├── reset/
│ │ │ │ │ └── route.ts
│ │ │ │ ├── site/
│ │ │ │ │ └── route.ts
│ │ │ │ ├── source/
│ │ │ │ │ ├── route.ts
│ │ │ │ │ └── validate/
│ │ │ │ │ └── route.ts
│ │ │ │ └── user/
│ │ │ │ └── route.ts
│ │ │ ├── change-password/
│ │ │ │ └── route.ts
│ │ │ ├── cron/
│ │ │ │ └── route.ts
│ │ │ ├── detail/
│ │ │ │ └── route.ts
│ │ │ ├── douban/
│ │ │ │ ├── categories/
│ │ │ │ │ └── route.ts
│ │ │ │ ├── recommends/
│ │ │ │ │ └── route.ts
│ │ │ │ └── route.ts
│ │ │ ├── favorites/
│ │ │ │ └── route.ts
│ │ │ ├── image-proxy/
│ │ │ │ └── route.ts
│ │ │ ├── live/
│ │ │ │ ├── channels/
│ │ │ │ │ └── route.ts
│ │ │ │ ├── epg/
│ │ │ │ │ └── route.ts
│ │ │ │ ├── precheck/
│ │ │ │ │ └── route.ts
│ │ │ │ └── sources/
│ │ │ │ └── route.ts
│ │ │ ├── login/
│ │ │ │ └── route.ts
│ │ │ ├── logout/
│ │ │ │ └── route.ts
│ │ │ ├── playrecords/
│ │ │ │ └── route.ts
│ │ │ ├── proxy/
│ │ │ │ ├── key/
│ │ │ │ │ └── route.ts
│ │ │ │ ├── logo/
│ │ │ │ │ └── route.ts
│ │ │ │ ├── m3u8/
│ │ │ │ │ └── route.ts
│ │ │ │ └── segment/
│ │ │ │ └── route.ts
│ │ │ ├── search/
│ │ │ │ ├── one/
│ │ │ │ │ └── route.ts
│ │ │ │ ├── resources/
│ │ │ │ │ └── route.ts
│ │ │ │ ├── route.ts
│ │ │ │ ├── suggestions/
│ │ │ │ │ └── route.ts
│ │ │ │ └── ws/
│ │ │ │ └── route.ts
│ │ │ ├── searchhistory/
│ │ │ │ └── route.ts
│ │ │ ├── server-config/
│ │ │ │ └── route.ts
│ │ │ └── skipconfigs/
│ │ │ └── route.ts
│ │ ├── douban/
│ │ │ └── page.tsx
│ │ ├── globals.css
│ │ ├── layout.tsx
│ │ ├── live/
│ │ │ └── page.tsx
│ │ ├── login/
│ │ │ └── page.tsx
│ │ ├── page.tsx
│ │ ├── play/
│ │ │ └── page.tsx
│ │ ├── search/
│ │ │ └── page.tsx
│ │ └── warning/
│ │ └── page.tsx
│ ├── components/
│ │ ├── BackButton.tsx
│ │ ├── CapsuleSwitch.tsx
│ │ ├── ContinueWatching.tsx
│ │ ├── DataMigration.tsx
│ │ ├── DoubanCardSkeleton.tsx
│ │ ├── DoubanCustomSelector.tsx
│ │ ├── DoubanSelector.tsx
│ │ ├── EpgScrollableRow.tsx
│ │ ├── EpisodeSelector.tsx
│ │ ├── GlobalErrorIndicator.tsx
│ │ ├── ImagePlaceholder.tsx
│ │ ├── MobileActionSheet.tsx
│ │ ├── MobileBottomNav.tsx
│ │ ├── MobileHeader.tsx
│ │ ├── MultiLevelSelector.tsx
│ │ ├── PageLayout.tsx
│ │ ├── ScrollableRow.tsx
│ │ ├── SearchResultFilter.tsx
│ │ ├── SearchSuggestions.tsx
│ │ ├── Sidebar.tsx
│ │ ├── SiteProvider.tsx
│ │ ├── ThemeProvider.tsx
│ │ ├── ThemeToggle.tsx
│ │ ├── UserMenu.tsx
│ │ ├── VersionPanel.tsx
│ │ ├── VideoCard.tsx
│ │ ├── VirtualGrid.tsx
│ │ └── WeekdaySelector.tsx
│ ├── hooks/
│ │ └── useLongPress.ts
│ ├── lib/
│ │ ├── admin.types.ts
│ │ ├── auth.ts
│ │ ├── bangumi.client.ts
│ │ ├── changelog.ts
│ │ ├── config.ts
│ │ ├── crypto.ts
│ │ ├── db.client.ts
│ │ ├── db.ts
│ │ ├── douban.client.ts
│ │ ├── douban.ts
│ │ ├── downstream.ts
│ │ ├── fetchVideoDetail.ts
│ │ ├── kvrocks.db.ts
│ │ ├── live.ts
│ │ ├── password.ts
│ │ ├── redis-base.db.ts
│ │ ├── redis.db.ts
│ │ ├── search-cache.ts
│ │ ├── time.ts
│ │ ├── types.ts
│ │ ├── upstash.db.ts
│ │ ├── utils.ts
│ │ ├── version.ts
│ │ ├── version_check.ts
│ │ └── yellow.ts
│ ├── middleware.ts
│ └── styles/
│ ├── colors.css
│ └── globals.css
├── start.js
├── tailwind.config.ts
├── tsconfig.json
└── vercel.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .dockerignore
================================================
.env
.env*.local
================================================
FILE: .eslintrc.js
================================================
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,
},
};
================================================
FILE: .github/workflows/docker-image.yml
================================================
name: Build & Push Docker image
on:
workflow_dispatch:
inputs:
tag:
description: 'Docker 标签'
required: false
default: 'latest'
type: string
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
release:
types: [ published ]
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: Determine Docker tag
id: docker-tag
run: |
if [ "${{ github.event_name }}" = "release" ]; then
echo "tag=${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT"
else
echo "tag=${{ github.event.inputs.tag || 'latest' }}" >> "$GITHUB_OUTPUT"
fi
- 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/moontechlab/lunatv
tags: |
type=raw,value=${{ github.event.inputs.tag || 'latest' }},enable={{is_default_branch}}
type=semver,pattern={{version}},enable=${{ github.event_name == 'release' }}
type=semver,pattern={{major}}.{{minor}},enable=${{ github.event_name == 'release' }}
- 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 }}
tags: ghcr.io/moontechlab/lunatv:${{ steps.docker-tag.outputs.tag }}
outputs: type=image,name=ghcr.io/moontechlab/lunatv,name-canonical=true,push=true
- 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
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: Determine Docker tag
id: docker-tag
run: |
if [ "${{ github.event_name }}" = "release" ]; then
echo "tag=${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT"
else
echo "tag=${{ github.event.inputs.tag || 'latest' }}" >> "$GITHUB_OUTPUT"
fi
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create -t ghcr.io/moontechlab/lunatv:${{ steps.docker-tag.outputs.tag }} \
$(printf 'ghcr.io/moontechlab/lunatv@sha256:%s ' *)
cleanup-refresh:
runs-on: ubuntu-latest
needs:
- merge
if: always()
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
================================================
FILE: .gitignore
================================================
# 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
================================================
FILE: .husky/commit-msg
================================================
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx --no-install commitlint --edit "$1"
================================================
FILE: .husky/post-merge
================================================
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
pnpm install
================================================
FILE: .husky/pre-commit
================================================
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged
================================================
FILE: .npmrc
================================================
================================================
FILE: .nvmrc
================================================
v20.10.0
================================================
FILE: .prettierignore
================================================
# 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
================================================
FILE: .prettierrc.js
================================================
module.exports = {
arrowParens: 'always',
singleQuote: true,
jsxSingleQuote: true,
tabWidth: 2,
semi: true,
};
================================================
FILE: .vscode/css.code-snippets
================================================
{
"Region CSS": {
"prefix": "regc",
"body": [
"/* #region /**=========== ${1} =========== */",
"$0",
"/* #endregion /**======== ${1} =========== */"
]
}
}
================================================
FILE: .vscode/extensions.json
================================================
{
"recommendations": [
// Tailwind CSS Intellisense
"bradlc.vscode-tailwindcss",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"aaron-bond.better-comments"
]
}
================================================
FILE: .vscode/settings.json
================================================
{
"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"
}
================================================
FILE: .vscode/typescriptreact.code-snippets
================================================
{
//#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}')"
]
}
}
================================================
FILE: CHANGELOG
================================================
## [100.1.2] - 2026-03-15
### Changed
- 移除豆瓣图片代理中的「直连」和「豆瓣官方精品 CDN」选项,历史数据自动兼容为服务器代理
## [100.1.1] - 2026-02-27
### Changed
- 搜索页使用虚拟滚动,优化滚动性能
## [100.1.0] - 2026-02-27
### Added
- 管理面板新增开关支持关闭网页直播
### Changed
- 优化用户数据存储结构,加速数据获取
- 用户密码加盐存储
- 新增数据自动迁移
## [100.0.3] - 2025-10-27
### Fixed
- 修复 webkit 下播放器控件的展示 bug
## [100.0.2] - 2025-10-23
### Fixed
- 修复 /api/search/resources 接口越权问题
## [100.0.1] - 2025-09-25
### Fixed
- 修复错误的环境变量 ADMIN_USERNAME
- 修复 bangumi 数据中没有图片导致首页崩溃问题
## [100.0.0] - 2025-08-26
### Added
- 新增对 SITE_BASE 环境变量的支持,解决 m3u8 重写时 base url 错误的问题
### Changed
- 移除授权相关逻辑
- 移除代码混淆
- 移除 melody-cdn-sharon
## [4.3.0] - 2025-08-26
### Added
- 支持将 IPTV 频道添加到收藏中
### Changed
- 禁用 flv 直播,仅支持 m3u8 直播
- 降低代理 ts 分片的内存占用
## [4.2.1] - 2025-08-26
### Fixed
- 修复直播源加载失败或离开页面后依然无限加载的问题
## [4.2.0] - 2025-08-26
### Added
- 支持 flv 直播和直播地址解析到 mp4 的处理
- 增加直播台标的 proxy 以防止 cors
- 支持播放页选集分组的滚动翻页
### Changed
- 管理后台页面的按钮增加加载中的 UI
### Fixed
- /api/proxy/m3u8 仅对 m3u8 内容反序列化,降低内存和 CPU 消耗
## [4.1.1] - 2025-08-25
### Changed
- 增加对 url-tvg 和多 epg url 的支持
### Fixed
- 修复 epg 数据清洗中去重叠逻辑未考虑日期导致的问题
## [4.1.0] - 2025-08-24
### Added
- 解析 m3u 自带的 epg 和自定义 epg,增加今日节目单
### Changed
- 直播源数据刷新改为并发刷新
## [4.0.0] - 2025-08-24
### Added
- 增加 iptv 订阅和播放功能
### Changed
- 搜索页面视频卡片移动端/右键菜单添加豆瓣链接
- 搜索建议遵循色情过滤
## [3.2.1] - 2025-08-22
### Changed
- 新增色色过滤分类
- 调整搜索建议框层级
## [3.2.0] - 2025-08-22
### Added
- 视频源管理支持批量启用、禁用、删除
- 用户管理支持批量设置用户组
- 视频卡片右键/长按菜单新增新标签页播放
### Changed
- 视频卡片移动端 hover 时仅保留播放按钮
- 微调管理页面 UI 和视频卡片右键/长按菜单中的收藏样式
### Fixed
- 修复了搜索栏 enter 键自动选中第一个建议项的问题
## [3.1.2] - 2025-08-22
### Fixed
- 修复移动端卡片无法点击的问题
## [3.1.1] - 2025-08-21
### Fixed
- 修复了视频卡片 hover 的非播放按钮点击后进入播放页的问题
## [3.1.0] - 2025-08-21
### Added
- 增加用户组管理和用户组播放源限制
- 增加管理面板视频源有效性检查
- 搜索栏增加一键删除按钮
### Changed
- 放宽授权心跳对于网络问题的判断标准
- 统一管理面板弹窗使用 createPortal
- VideoCard 允许移动端响应 hover 事件
- 移动端布局 header 常驻,搜索按钮移动到 header 右侧
- 调大搜索接口超时时间
### Fixed
- 修复 bangumi 返回的整数评分无小数导致 UI 不对齐的问题
## [3.0.2] - 2025-08-20
### Changed
- 优化机器码生成逻辑
### Fixed
- 修复 redis url 不支持 rediss 协议的问题
## [3.0.1] - 2025-08-20
### Fixed
- 修复授权初始化错误
## [3.0.0] - 2025-08-20
### Added
- 防盗卖加固
- 支持自定义用户可用视频源
### Changed
- 右键视频卡片可弹出操作菜单
### Fixed
- 过滤掉集数为 0 的搜索结果
## [2.7.1] - 2025-08-17
### Fixed
- 修复 iOS 下版本面板可穿透滚动背景的问题
## [2.7.0] - 2025-08-17
### Added
- 视频卡片新增移动端操作面板,优化触控屏操作体验
### Changed
- 优化集数标题的匹配和展示逻辑
### Fixed
- 修复设置面板和修改密码面板背景可被拖动的问题
## [2.6.0] - 2025-08-17
### Added
- 新增搜索流式输出接口,并设置流式搜索为默认搜索接口,优化搜索体验
- 新增源站搜索结果内存缓存,粒度为源站+关键词+页数,缓存 10 分钟
- 新增豆瓣 CDN provided by @JohnsonRan
### Changed
- 搜索结果默认为无排序状态,不再默认按照年份排序
- 常规搜索接口无结果时,不再设置响应的缓存头
- 移除豆瓣数据源中的 cors-anywhere 方式
### Fixed
- 数据导出时导出站长密码,保证迁移到新账户时原站长用户可正常登录
- 聚合卡片优化移动端源信息展示
## [2.4.1] - 2025-08-15
### Fixed
- 对导入和 db 读取的配置文件做自检,防止 USERNAME 修改导致用户状态异常
## [2.4.0] - 2025-08-15
### Added
- 支持 kvrocks 存储(持久化 kv 存储)
### Fixed
- 修复搜索结果排序不稳定的问题
- 导入数据时同时更新内存缓存的管理员配置
## [2.3.0] - 2025-08-15
### Added
- 支持站长导入导出整站数据
### Changed
- 仅允许站长操作配置文件
- 微调搜索结果过滤面板的移动端样式
## [2.2.1] - 2025-08-14
### Fixed
- 修复了筛选 panel 打开时滚动页面 panel 不跟随的问题
## [2.2.0] - 2025-08-14
### Added
- 搜索结果支持按播放源、标题和年份筛选,支持按年份排序
- 搜索界面视频卡片展示年份信息,聚合卡片展示播放源
### Fixed
- 修复 /api/search/resources 返回空的问题
- 修复 upstash 实例无法编辑自定义分类的问题
## [2.1.0] - 2025-08-13
### Added
- 支持通过订阅获取配置文件
### Changed
- 微调部分文案和 UI
- 删除部分无用代码
## [2.0.1] - 2025-08-13
### Changed
- 版本检查和变更日志请求 Github
### Fixed
- 微调管理面板样式
## [2.0.0] - 2025-08-13
### Added
- 支持配置文件在线配置和编辑
- 搜索页搜索框实时联想
- 去除对 localstorage 模式的支持
### Changed
- 播放记录删除按钮改为垃圾桶图标以消除歧义
### Fixed
- 限制设置面板的最大长度,防止超出视口
## [1.1.1] - 2025-08-12
### Changed
- 修正 zwei 提供的 cors proxy 地址
- 移除废弃代码
### Fixed
- [运维] docker workflow release 日期使用东八区日期
## [1.1.0] - 2025-08-12
### Added
- 每日新番放送功能,展示每日新番放送的番剧
### Fixed
- 修复远程 CHANGELOG 无法提取变更内容的问题
## [1.0.5] - 2025-08-12
### Changed
- 实现基于 Git 标签的自动 Release 工作流
## [1.0.4] - 2025-08-11
### Added
- 优化版本管理工作流,实现单点修改
### Changed
- 版本号现在从 CHANGELOG 自动提取,无需手动维护 VERSION.txt
## [1.0.3] - 2025-08-11
### Changed
- 升级播放器 Artplayer 至版本 5.2.5
## [1.0.2] - 2025-08-11
### Changed
- 版本号比较机制恢复为数字比较,仅当最新版本大于本地版本时才认为有更新
- [运维] 自动替换 version.ts 中的版本号为 VERSION.txt 中的版本号
## [1.0.1] - 2025-08-11
### Fixed
- 修复版本检查功能,只要与最新版本号不一致即认为有更新
## [1.0.0] - 2025-08-10
### Added
- 基于 Semantic Versioning 的版本号机制
- 版本信息面板,展示本地变更日志和远程更新日志
================================================
FILE: Dockerfile
================================================
# ---- 第 1 阶段:安装依赖 ----
FROM node:20-alpine AS deps
# 启用 corepack 并激活 pnpm(Node20 默认提供 corepack)
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
# 仅复制依赖清单,提高构建缓存利用率
COPY package.json pnpm-lock.yaml ./
# 安装所有依赖(含 devDependencies,后续会裁剪)
RUN pnpm install --frozen-lockfile
# ---- 第 2 阶段:构建项目 ----
FROM node:20-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
# 复制依赖
COPY --from=deps /app/node_modules ./node_modules
# 复制全部源代码
COPY . .
# 在构建阶段也显式设置 DOCKER_ENV,
ENV DOCKER_ENV=true
# 生成生产构建
RUN pnpm run build
# ---- 第 3 阶段:生成运行时镜像 ----
FROM node:20-alpine AS runner
# 创建非 root 用户
RUN addgroup -g 1001 -S nodejs && adduser -u 1001 -S nextjs -G nodejs
WORKDIR /app
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
ENV DOCKER_ENV=true
# 从构建器中复制 standalone 输出
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
# 从构建器中复制 scripts 目录
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
# 从构建器中复制 start.js
COPY --from=builder --chown=nextjs:nodejs /app/start.js ./start.js
# 从构建器中复制 public 和 .next/static 目录
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# 切换到非特权用户
USER nextjs
EXPOSE 3000
# 使用自定义启动脚本,先预加载配置再启动服务器
CMD ["node", "start.js"]
================================================
FILE: LICENSE
================================================
Attribution-NonCommercial-ShareAlike 4.0 International
=======================================================================
Creative Commons Corporation ("Creative Commons") is not a law firm and
does not provide legal services or legal advice. Distribution of
Creative Commons public licenses does not create a lawyer-client or
other relationship. Creative Commons makes its licenses and related
information available on an "as-is" basis. Creative Commons gives no
warranties regarding its licenses, any material licensed under their
terms and conditions, or any related information. Creative Commons
disclaims all liability for damages resulting from their use to the
fullest extent possible.
Using Creative Commons Public Licenses
Creative Commons public licenses provide a standard set of terms and
conditions that creators and other rights holders may use to share
original works of authorship and other material subject to copyright
and certain other rights specified in the public license below. The
following considerations are for informational purposes only, are not
exhaustive, and do not form part of our licenses.
Considerations for licensors: Our public licenses are
intended for use by those authorized to give the public
permission to use material in ways otherwise restricted by
copyright and certain other rights. Our licenses are
irrevocable. Licensors should read and understand the terms
and conditions of the license they choose before applying it.
Licensors should also secure all rights necessary before
applying our licenses so that the public can reuse the
material as expected. Licensors should clearly mark any
material not subject to the license. This includes other CC-
licensed material, or material used under an exception or
limitation to copyright. More considerations for licensors:
wiki.creativecommons.org/Considerations_for_licensors
Considerations for the public: By using one of our public
licenses, a licensor grants the public permission to use the
licensed material under specified terms and conditions. If
the licensor's permission is not necessary for any reason--for
example, because of any applicable exception or limitation to
copyright--then that use is not regulated by the license. Our
licenses grant only permissions under copyright and certain
other rights that a licensor has authority to grant. Use of
the licensed material may still be restricted for other
reasons, including because others have copyright or other
rights in the material. A licensor may make special requests,
such as asking that all changes be marked or described.
Although not required by our licenses, you are encouraged to
respect those requests where reasonable. More considerations
for the public:
wiki.creativecommons.org/Considerations_for_licensees
=======================================================================
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
Public License
By exercising the Licensed Rights (defined below), You accept and agree
to be bound by the terms and conditions of this Creative Commons
Attribution-NonCommercial-ShareAlike 4.0 International Public License
("Public License"). To the extent this Public License may be
interpreted as a contract, You are granted the Licensed Rights in
consideration of Your acceptance of these terms and conditions, and the
Licensor grants You such rights in consideration of benefits the
Licensor receives from making the Licensed Material available under
these terms and conditions.
Section 1 -- Definitions.
a. Adapted Material means material subject to Copyright and Similar
Rights that is derived from or based upon the Licensed Material
and in which the Licensed Material is translated, altered,
arranged, transformed, or otherwise modified in a manner requiring
permission under the Copyright and Similar Rights held by the
Licensor. For purposes of this Public License, where the Licensed
Material is a musical work, performance, or sound recording,
Adapted Material is always produced where the Licensed Material is
synched in timed relation with a moving image.
b. Adapter's License means the license You apply to Your Copyright
and Similar Rights in Your contributions to Adapted Material in
accordance with the terms and conditions of this Public License.
c. BY-NC-SA Compatible License means a license listed at
creativecommons.org/compatiblelicenses, approved by Creative
Commons as essentially the equivalent of this Public License.
d. Copyright and Similar Rights means copyright and/or similar rights
closely related to copyright including, without limitation,
performance, broadcast, sound recording, and Sui Generis Database
Rights, without regard to how the rights are labeled or
categorized. For purposes of this Public License, the rights
specified in Section 2(b)(1)-(2) are not Copyright and Similar
Rights.
e. Effective Technological Measures means those measures that, in the
absence of proper authority, may not be circumvented under laws
fulfilling obligations under Article 11 of the WIPO Copyright
Treaty adopted on December 20, 1996, and/or similar international
agreements.
f. Exceptions and Limitations means fair use, fair dealing, and/or
any other exception or limitation to Copyright and Similar Rights
that applies to Your use of the Licensed Material.
g. License Elements means the license attributes listed in the name
of a Creative Commons Public License. The License Elements of this
Public License are Attribution, NonCommercial, and ShareAlike.
h. Licensed Material means the artistic or literary work, database,
or other material to which the Licensor applied this Public
License.
i. Licensed Rights means the rights granted to You subject to the
terms and conditions of this Public License, which are limited to
all Copyright and Similar Rights that apply to Your use of the
Licensed Material and that the Licensor has authority to license.
j. Licensor means the individual(s) or entity(ies) granting rights
under this Public License.
k. NonCommercial means not primarily intended for or directed towards
commercial advantage or monetary compensation. For purposes of
this Public License, the exchange of the Licensed Material for
other material subject to Copyright and Similar Rights by digital
file-sharing or similar means is NonCommercial provided there is
no payment of monetary compensation in connection with the
exchange.
l. Share means to provide material to the public by any means or
process that requires permission under the Licensed Rights, such
as reproduction, public display, public performance, distribution,
dissemination, communication, or importation, and to make material
available to the public including in ways that members of the
public may access the material from a place and at a time
individually chosen by them.
m. Sui Generis Database Rights means rights other than copyright
resulting from Directive 96/9/EC of the European Parliament and of
the Council of 11 March 1996 on the legal protection of databases,
as amended and/or succeeded, as well as other essentially
equivalent rights anywhere in the world.
n. You means the individual or entity exercising the Licensed Rights
under this Public License. Your has a corresponding meaning.
Section 2 -- Scope.
a. License grant.
1. Subject to the terms and conditions of this Public License,
the Licensor hereby grants You a worldwide, royalty-free,
non-sublicensable, non-exclusive, irrevocable license to
exercise the Licensed Rights in the Licensed Material to:
a. reproduce and Share the Licensed Material, in whole or
in part, for NonCommercial purposes only; and
b. produce, reproduce, and Share Adapted Material for
NonCommercial purposes only.
2. Exceptions and Limitations. For the avoidance of doubt, where
Exceptions and Limitations apply to Your use, this Public
License does not apply, and You do not need to comply with
its terms and conditions.
3. Term. The term of this Public License is specified in Section
6(a).
4. Media and formats; technical modifications allowed. The
Licensor authorizes You to exercise the Licensed Rights in
all media and formats whether now known or hereafter created,
and to make technical modifications necessary to do so. The
Licensor waives and/or agrees not to assert any right or
authority to forbid You from making technical modifications
necessary to exercise the Licensed Rights, including
technical modifications necessary to circumvent Effective
Technological Measures. For purposes of this Public License,
simply making modifications authorized by this Section 2(a)
(4) never produces Adapted Material.
5. Downstream recipients.
a. Offer from the Licensor -- Licensed Material. Every
recipient of the Licensed Material automatically
receives an offer from the Licensor to exercise the
Licensed Rights under the terms and conditions of this
Public License.
b. Additional offer from the Licensor -- Adapted Material.
Every recipient of Adapted Material from You
automatically receives an offer from the Licensor to
exercise the Licensed Rights in the Adapted Material
under the conditions of the Adapter's License You apply.
c. No downstream restrictions. You may not offer or impose
any additional or different terms or conditions on, or
apply any Effective Technological Measures to, the
Licensed Material if doing so restricts exercise of the
Licensed Rights by any recipient of the Licensed
Material.
6. No endorsement. Nothing in this Public License constitutes or
may be construed as permission to assert or imply that You
are, or that Your use of the Licensed Material is, connected
with, or sponsored, endorsed, or granted official status by,
the Licensor or others designated to receive attribution as
provided in Section 3(a)(1)(A)(i).
b. Other rights.
1. Moral rights, such as the right of integrity, are not
licensed under this Public License, nor are publicity,
privacy, and/or other similar personality rights; however, to
the extent possible, the Licensor waives and/or agrees not to
assert any such rights held by the Licensor to the limited
extent necessary to allow You to exercise the Licensed
Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this
Public License.
3. To the extent possible, the Licensor waives any right to
collect royalties from You for the exercise of the Licensed
Rights, whether directly or through a collecting society
under any voluntary or waivable statutory or compulsory
licensing scheme. In all other cases the Licensor expressly
reserves any right to collect such royalties, including when
the Licensed Material is used other than for NonCommercial
purposes.
Section 3 -- License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the
following conditions.
a. Attribution.
1. If You Share the Licensed Material (including in modified
form), You must:
a. retain the following if it is supplied by the Licensor
with the Licensed Material:
i. identification of the creator(s) of the Licensed
Material and any others designated to receive
attribution, in any reasonable manner requested by
the Licensor (including by pseudonym if
designated);
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of
warranties;
v. a URI or hyperlink to the Licensed Material to the
extent reasonably practicable;
b. indicate if You modified the Licensed Material and
retain an indication of any previous modifications; and
c. indicate the Licensed Material is licensed under this
Public License, and include the text of, or the URI or
hyperlink to, this Public License.
2. You may satisfy the conditions in Section 3(a)(1) in any
reasonable manner based on the medium, means, and context in
which You Share the Licensed Material. For example, it may be
reasonable to satisfy the conditions by providing a URI or
hyperlink to a resource that includes the required
information.
3. If requested by the Licensor, You must remove any of the
information required by Section 3(a)(1)(A) to the extent
reasonably practicable.
b. ShareAlike.
In addition to the conditions in Section 3(a), if You Share
Adapted Material You produce, the following conditions also apply.
1. The Adapter's License You apply must be a Creative Commons
license with the same License Elements, this version or
later, or a BY-NC-SA Compatible License.
2. You must include the text of, or the URI or hyperlink to, the
Adapter's License You apply. You may satisfy this condition
in any reasonable manner based on the medium, means, and
context in which You Share Adapted Material.
3. You may not offer or impose any additional or different terms
or conditions on, or apply any Effective Technological
Measures to, Adapted Material that restrict exercise of the
rights granted under the Adapter's License You apply.
Section 4 -- Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that
apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
to extract, reuse, reproduce, and Share all or a substantial
portion of the contents of the database for NonCommercial purposes
only;
b. if You include all or a substantial portion of the database
contents in a database in which You have Sui Generis Database
Rights, then the database in which You have Sui Generis Database
Rights (but not its individual contents) is Adapted Material,
including for purposes of Section 3(b); and
c. You must comply with the conditions in Section 3(a) if You Share
all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not
replace Your obligations under this Public License where the Licensed
Rights include other Copyright and Similar Rights.
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
c. The disclaimer of warranties and limitation of liability provided
above shall be interpreted in a manner that, to the extent
possible, most closely approximates an absolute disclaimer and
waiver of all liability.
Section 6 -- Term and Termination.
a. This Public License applies for the term of the Copyright and
Similar Rights licensed here. However, if You fail to comply with
this Public License, then Your rights under this Public License
terminate automatically.
b. Where Your right to use the Licensed Material has terminated under
Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided
it is cured within 30 days of Your discovery of the
violation; or
2. upon express reinstatement by the Licensor.
For the avoidance of doubt, this Section 6(b) does not affect any
right the Licensor may have to seek remedies for Your violations
of this Public License.
c. For the avoidance of doubt, the Licensor may also offer the
Licensed Material under separate terms or conditions or stop
distributing the Licensed Material at any time; however, doing so
will not terminate this Public License.
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
License.
Section 7 -- Other Terms and Conditions.
a. The Licensor shall not be bound by any additional or different
terms or conditions communicated by You unless expressly agreed.
b. Any arrangements, understandings, or agreements regarding the
Licensed Material not stated herein are separate from and
independent of the terms and conditions of this Public License.
Section 8 -- Interpretation.
a. For the avoidance of doubt, this Public License does not, and
shall not be interpreted to, reduce, limit, restrict, or impose
conditions on any use of the Licensed Material that could lawfully
be made without permission under this Public License.
b. To the extent possible, if any provision of this Public License is
deemed unenforceable, it shall be automatically reformed to the
minimum extent necessary to make it enforceable. If the provision
cannot be reformed, it shall be severed from this Public License
without affecting the enforceability of the remaining terms and
conditions.
c. No term or condition of this Public License will be waived and no
failure to comply consented to unless expressly agreed to by the
Licensor.
d. Nothing in this Public License constitutes or may be interpreted
as a limitation upon, or waiver of, any privileges and immunities
that apply to the Licensor or You, including from the legal
processes of any jurisdiction or authority.
=======================================================================
Creative Commons is not a party to its public
licenses. Notwithstanding, Creative Commons may elect to apply one of
its public licenses to material it publishes and in those instances
will be considered the “Licensor.” The text of the Creative Commons
public licenses is dedicated to the public domain under the CC0 Public
Domain Dedication. Except for the limited purpose of indicating that
material is shared under a Creative Commons public license or as
otherwise permitted by the Creative Commons policies published at
creativecommons.org/policies, Creative Commons does not authorize the
use of the trademark "Creative Commons" or any other trademark or logo
of Creative Commons without its prior written consent including,
without limitation, in connection with any unauthorized modifications
to any of its public licenses or any other arrangements,
understandings, or agreements concerning use of licensed material. For
the avoidance of doubt, this paragraph does not form part of the
public licenses.
Creative Commons may be contacted at creativecommons.org.
================================================
FILE: README.md
================================================
# MoonTV
<div align="center">
<img src="public/logo.png" alt="MoonTV Logo" width="120">
</div>
> 🎬 **MoonTV** 是一个开箱即用的、跨平台的影视聚合播放器。它基于 **Next.js 14** + **Tailwind CSS** + **TypeScript** 构建,支持多资源搜索、在线播放、收藏同步、播放记录、云端存储,让你可以随时随地畅享海量免费影视内容。
<div align="center">





</div>
---
## ✨ 功能特性
- 🔍 **多源聚合搜索**:一次搜索立刻返回全源结果。
- 📄 **丰富详情页**:支持剧集列表、演员、年份、简介等完整信息展示。
- ▶️ **流畅在线播放**:集成 HLS.js & ArtPlayer。
- ❤️ **收藏 + 继续观看**:支持 Kvrocks/Redis/Upstash 存储,多端同步进度。
- 📱 **PWA**:离线缓存、安装到桌面/主屏,移动端原生体验。
- 🌗 **响应式布局**:桌面侧边栏 + 移动底部导航,自适应各种屏幕尺寸。
- 👿 **智能去广告**:自动跳过视频中的切片广告(实验性)。
### 注意:部署后项目为空壳项目,无内置播放源和直播源,需要自行收集
<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>
### 请不要在 B站、小红书、微信公众号、抖音、今日头条或其他中国大陆社交平台发布视频或文章宣传本项目,不授权任何“科技周刊/月刊”类项目或站点收录本项目。
## 🗺 目录
- [技术栈](#技术栈)
- [部署](#部署)
- [一键部署](#zeabur-一键部署)
- [Docker 部署](#Kvrocks-存储推荐)
- [配置文件](#配置文件)
- [订阅](#订阅)
- [自动更新](#自动更新)
- [环境变量](#环境变量)
- [客户端](#客户端)
- [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 |
## 部署
本项目**仅支持 Docker 或其他基于 Docker 的平台** 部署。
### zeabur 一键部署
点击下方按钮即可一键部署,自动配置 LunaTV + Kvrocks 数据库:
[](https://zeabur.com/templates/8MPTQU/deploy)
**优势**:
- ✅ 无需配置,一键启动(自动部署完整环境)
- ✅ 自动 HTTPS 和全球 CDN 加速
- ✅ 持久化存储,数据永不丢失
- ✅ 免费额度足够个人使用
**⚠️ 重要提示**:部署完成后,需要在 Zeabur 中为 LunaTV 服务设置访问域名(Domain)才能在浏览器中访问。详见下方 [设置访问域名](#5-设置访问域名必须) 步骤。
### Kvrocks 存储(推荐)
```yml
services:
moontv-core:
image: ghcr.io/moontechlab/lunatv:latest
container_name: moontv-core
restart: on-failure
ports:
- '3000:3000'
environment:
- USERNAME=admin
- PASSWORD=admin_password
- NEXT_PUBLIC_STORAGE_TYPE=kvrocks
- KVROCKS_URL=redis://moontv-kvrocks:6666
networks:
- moontv-network
depends_on:
- moontv-kvrocks
moontv-kvrocks:
image: apache/kvrocks
container_name: moontv-kvrocks
restart: unless-stopped
volumes:
- kvrocks-data:/var/lib/kvrocks
networks:
- moontv-network
networks:
moontv-network:
driver: bridge
volumes:
kvrocks-data:
```
### Redis 存储(有一定的丢数据风险)
```yml
services:
moontv-core:
image: ghcr.io/moontechlab/lunatv:latest
container_name: moontv-core
restart: on-failure
ports:
- '3000:3000'
environment:
- USERNAME=admin
- PASSWORD=admin_password
- NEXT_PUBLIC_STORAGE_TYPE=redis
- REDIS_URL=redis://moontv-redis:6379
networks:
- moontv-network
depends_on:
- moontv-redis
moontv-redis:
image: redis:alpine
container_name: moontv-redis
restart: unless-stopped
networks:
- moontv-network
# 请开启持久化,否则升级/重启后数据丢失
volumes:
- ./data:/data
networks:
moontv-network:
driver: bridge
```
### Upstash 存储
1. 在 [upstash](https://upstash.com/) 注册账号并新建一个 Redis 实例,名称任意。
2. 复制新数据库的 **HTTPS ENDPOINT 和 TOKEN**
3. 使用如下 docker compose
```yml
services:
moontv-core:
image: ghcr.io/moontechlab/lunatv:latest
container_name: moontv-core
restart: on-failure
ports:
- '3000:3000'
environment:
- USERNAME=admin
- PASSWORD=admin_password
- NEXT_PUBLIC_STORAGE_TYPE=upstash
- UPSTASH_URL=上面 https 开头的 HTTPS ENDPOINT
- UPSTASH_TOKEN=上面的 TOKEN
```
### ☁️ Zeabur 部署(推荐)
Thanks to @SzeMeng76
Zeabur 是一站式云端部署平台,使用预构建的 Docker 镜像可以快速部署,无需等待构建。
**部署步骤:**
1. **添加 KVRocks 服务**(先添加数据库)
- 点击 "Add Service" > "Docker Images"
- 输入镜像名称:`apache/kvrocks`
- 配置端口:`6666` (TCP)
- **记住服务名称**(通常是 `apachekvrocks`)
- **配置持久化卷(重要)**:
* 在服务设置中找到 "Volumes" 部分
* 点击 "Add Volume" 添加新卷
* Volume ID: `kvrocks-data`(可自定义,仅支持字母、数字、连字符)
* Path: `/var/lib/kvrocks/db`
* 保存配置
> 💡 **重要提示**:持久化卷路径必须设置为 `/var/lib/kvrocks/db`(KVRocks 数据目录),这样配置文件保留在容器内,数据库文件持久化,重启后数据不会丢失!
2. **添加 LunaTV 服务**
- 点击 "Add Service" > "Docker Images"
- 输入镜像名称:`ghcr.io/moontechlab/lunatv:latest`
- 配置端口:`3000` (HTTP)
3. **配置环境变量**
在 LunaTV 服务的环境变量中添加:
```env
# 必填:管理员账号
USERNAME=admin
PASSWORD=your_secure_password
# 必填:存储配置
NEXT_PUBLIC_STORAGE_TYPE=kvrocks
KVROCKS_URL=redis://apachekvrocks:6666
# 可选:站点配置
SITE_BASE=https://your-domain.zeabur.app
NEXT_PUBLIC_SITE_NAME=LunaTV Enhanced
ANNOUNCEMENT=欢迎使用 LunaTV Enhanced Edition
# 可选:豆瓣代理配置(推荐)
NEXT_PUBLIC_DOUBAN_PROXY_TYPE=cmliussss-cdn-tencent
NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE=cmliussss-cdn-tencent
```
**注意**:
- 使用服务名称作为主机名:`redis://apachekvrocks:6666`
- 如果服务名称不同,请替换为实际名称
- 两个服务必须在同一个 Project 中
4. **部署完成**
- Zeabur 会自动拉取镜像并启动服务
- 等待服务就绪后,需要手动设置访问域名(见下一步)
#### 5. 设置访问域名(必须)
- 在 LunaTV 服务页面,点击 "Networking" 或 "网络" 标签
- 点击 "Generate Domain" 生成 Zeabur 提供的免费域名(如 `xxx.zeabur.app`)
- 或者绑定自定义域名:
* 点击 "Add Domain" 添加你的域名
* 按照提示配置 DNS CNAME 记录指向 Zeabur 提供的目标地址
- 设置完域名后即可通过域名访问 LunaTV
6. **绑定自定义域名(可选)**
- 在服务设置中点击 "Domains"
- 添加你的自定义域名
- 配置 DNS CNAME 记录指向 Zeabur 提供的域名
#### 🔄 更新 Docker 镜像
当 Docker 镜像有新版本发布时,Zeabur 不会自动更新。需要手动触发更新。
**更新步骤:**
1. **进入服务页面**
- 点击需要更新的服务(LunaTV 或 KVRocks)
2. **重启服务**
- 点击 **"服务状态"** 页面,再点击 **"重启当前版本"** 按钮
- Zeabur 会自动拉取最新的 `latest` 镜像并重新部署
> 💡 **提示**:
> - 使用 `latest` 标签时,Restart 会自动拉取最新镜像
> - 生产环境推荐使用固定版本标签(如 `v5.5.6`)避免意外更新
## 配置文件
完成部署后为空壳应用,无播放源,需要站长在管理后台的配置文件设置中填写配置文件(后续会支持订阅)
配置文件示例如下:
```json
{
"cache_time": 7200,
"api_site": {
"dyttzy": {
"api": "http://xxx.com/api.php/provide/vod",
"name": "示例资源",
"detail": "http://xxx.com"
}
// ...更多站点
},
"custom_category": [
{
"name": "华语",
"type": "movie",
"query": "华语"
}
]
}
```
- `cache_time`:接口缓存时间(秒)。
- `api_site`:你可以增删或替换任何资源站,字段说明:
- `key`:唯一标识,保持小写字母/数字。
- `api`:资源站提供的 `vod` JSON API 根地址。
- `name`:在人机界面中展示的名称。
- `detail`:(可选)部分无法通过 API 获取剧集详情的站点,需要提供网页详情根 URL,用于爬取。
- `custom_category`:自定义分类配置,用于在导航中添加个性化的影视分类。以 type + query 作为唯一标识。支持以下字段:
- `name`:分类显示名称(可选,如不提供则使用 query 作为显示名)
- `type`:分类类型,支持 `movie`(电影)或 `tv`(电视剧)
- `query`:搜索关键词,用于在豆瓣 API 中搜索相关内容
custom_category 支持的自定义分类已知如下:
- movie:热门、最新、经典、豆瓣高分、冷门佳片、华语、欧美、韩国、日本、动作、喜剧、爱情、科幻、悬疑、恐怖、治愈
- tv:热门、美剧、英剧、韩剧、日剧、国产剧、港剧、日本动画、综艺、纪录片
也可输入如 "哈利波特" 效果等同于豆瓣搜索
MoonTV 支持标准的苹果 CMS V10 API 格式。
## 订阅
将完整的配置文件 base58 编码后提供 http 服务即为订阅链接,可在 MoonTV 后台/Helios 中使用。
## 自动更新
可借助 [watchtower](https://github.com/containrrr/watchtower) 自动更新镜像容器
dockge/komodo 等 docker compose UI 也有自动更新功能
## 环境变量
| 变量 | 说明 | 可选值 | 默认值 |
| ----------------------------------- | -------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| USERNAME | 站长账号 | 任意字符串 | 无默认,必填字段 |
| PASSWORD | 站长密码 | 任意字符串 | 无默认,必填字段 |
| SITE_BASE | 站点 url | 形如 https://example.com | 空 |
| NEXT_PUBLIC_SITE_NAME | 站点名称 | 任意字符串 | MoonTV |
| ANNOUNCEMENT | 站点公告 | 任意字符串 | 本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。 |
| NEXT_PUBLIC_STORAGE_TYPE | 播放记录/收藏的存储方式 | redis、kvrocks、upstash | 无默认,必填字段 |
| KVROCKS_URL | kvrocks 连接 url | 连接 url | 空 |
| REDIS_URL | redis 连接 url | 连接 url | 空 |
| UPSTASH_URL | upstash redis 连接 url | 连接 url | 空 |
| UPSTASH_TOKEN | upstash redis 连接 token | 连接 token | 空 |
| NEXT_PUBLIC_SEARCH_MAX_PAGE | 搜索接口可拉取的最大页数 | 1-50 | 5 |
| NEXT_PUBLIC_DOUBAN_PROXY_TYPE | 豆瓣数据源请求方式 | 见下方 | direct |
| NEXT_PUBLIC_DOUBAN_PROXY | 自定义豆瓣数据代理 URL | url prefix | (空) |
| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE | 豆瓣图片代理类型 | 见下方 | direct |
| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY | 自定义豆瓣图片代理 URL | url prefix | (空) |
| NEXT_PUBLIC_DISABLE_YELLOW_FILTER | 关闭色情内容过滤 | true/false | false |
| NEXT_PUBLIC_FLUID_SEARCH | 是否开启搜索接口流式输出 | true/ false | true |
NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释:
- direct: 由服务器直接请求豆瓣源站
- cors-proxy-zwei: 浏览器向 cors proxy 请求豆瓣数据,该 cors proxy 由 [Zwei](https://github.com/bestzwei) 搭建
- cmliussss-cdn-tencent: 浏览器向豆瓣 CDN 请求数据,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由腾讯云 cdn 提供加速
- cmliussss-cdn-ali: 浏览器向豆瓣 CDN 请求数据,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由阿里云 cdn 提供加速
- custom: 用户自定义 proxy,由 NEXT_PUBLIC_DOUBAN_PROXY 定义
NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE 选项解释:
- direct:由浏览器直接请求豆瓣分配的默认图片域名
- server:由服务器代理请求豆瓣分配的默认图片域名
- img3:由浏览器请求豆瓣官方的精品 cdn(阿里云)
- cmliussss-cdn-tencent:由浏览器请求豆瓣 CDN,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由腾讯云 cdn 提供加速
- cmliussss-cdn-ali:由浏览器请求豆瓣 CDN,该 CDN 由 [CMLiussss](https://github.com/cmliu) 搭建,并由阿里云 cdn 提供加速
- custom: 用户自定义 proxy,由 NEXT_PUBLIC_DOUBAN_IMAGE_PROXY 定义
## 客户端
v100.0.0 以上版本可配合 [Selene](https://github.com/MoonTechLab/Selene) 使用,移动端体验更加友好,数据完全同步
## AndroidTV 使用
目前该项目可以配合 [OrionTV](https://github.com/zimplexing/OrionTV) 在 Android TV 上使用,可以直接作为 OrionTV 后端
已实现播放记录和网页端同步
## 安全与隐私提醒
### 请设置密码保护并关闭公网注册
为了您的安全和避免潜在的法律风险,我们要求在部署时**强烈建议关闭公网注册**:
### 部署要求
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 流媒体在浏览器中的播放支持。
- [Zwei](https://github.com/bestzwei) — 提供获取豆瓣数据的 cors proxy
- [CMLiussss](https://github.com/cmliu) — 提供豆瓣 CDN 服务
- 感谢所有提供免费影视接口的站点。
## Star History
[](https://www.star-history.com/#MoonTechLab/LunaTV&Date)
================================================
FILE: VERSION.txt
================================================
100.1.2
================================================
FILE: commitlint.config.js
================================================
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',
],
],
},
};
================================================
FILE: docker-compose.dev.yml
================================================
version: '3.8'
services:
redis:
image: redis:7-alpine
container_name: lunatv-redis
volumes:
- redis-data:/data
command: redis-server --appendonly yes
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 3s
retries: 5
app:
build:
context: .
dockerfile: Dockerfile
container_name: lunatv-app
ports:
- '3000:3000'
depends_on:
redis:
condition: service_healthy
environment:
# 存储类型:使用 redis
- NEXT_PUBLIC_STORAGE_TYPE=redis
# Redis 连接地址(容器内通过 service name 访问)
- REDIS_URL=redis://redis:6379
# 站长账号
- USERNAME=admin
# 站长密码
- PASSWORD=admin123
# 站点名称(可选)
- NEXT_PUBLIC_SITE_NAME=MoonTV
volumes:
redis-data:
================================================
FILE: jest.config.js
================================================
// 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);
================================================
FILE: jest.setup.js
================================================
import '@testing-library/jest-dom/extend-expect';
// Allow router mocks.
// eslint-disable-next-line no-undef
jest.mock('next/router', () => require('next-router-mock'));
================================================
FILE: next.config.js
================================================
/** @type {import('next').NextConfig} */
/* eslint-disable @typescript-eslint/no-var-requires */
const nextConfig = {
output: 'standalone',
eslint: {
dirs: ['src'],
},
reactStrictMode: false,
swcMinify: false,
experimental: {
instrumentationHook: process.env.NODE_ENV === 'production',
},
// Uncoment to add domain whitelist
images: {
unoptimized: true,
remotePatterns: [
{
protocol: 'https',
hostname: '**',
},
{
protocol: 'http',
hostname: '**',
},
],
},
webpack(config) {
// Grab the existing rule that handles SVG imports
const fileLoaderRule = config.module.rules.find((rule) =>
rule.test?.test?.('.svg')
);
config.module.rules.push(
// Reapply the existing rule, but only for svg imports ending in ?url
{
...fileLoaderRule,
test: /\.svg$/i,
resourceQuery: /url/, // *.svg?url
},
// Convert all other *.svg imports to React components
{
test: /\.svg$/i,
issuer: { not: /\.(css|scss|sass)$/ },
resourceQuery: { not: /url/ }, // exclude if *.svg?url
loader: '@svgr/webpack',
options: {
dimensions: false,
titleProp: true,
},
}
);
// Modify the file loader rule to ignore *.svg, since we have it handled now.
fileLoaderRule.exclude = /\.svg$/i;
config.resolve.fallback = {
...config.resolve.fallback,
net: false,
tls: false,
crypto: false,
};
return config;
},
};
const withPWA = require('next-pwa')({
dest: 'public',
disable: process.env.NODE_ENV === 'development',
register: true,
skipWaiting: true,
});
module.exports = withPWA(nextConfig);
================================================
FILE: package.json
================================================
{
"name": "moontv",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "pnpm gen:manifest && next dev -H 0.0.0.0",
"build": "pnpm gen:manifest && next build",
"start": "next start",
"lint": "next lint",
"lint:fix": "eslint src --fix && pnpm format",
"lint:strict": "eslint --max-warnings=0 src",
"typecheck": "tsc --noEmit --incremental false",
"test:watch": "jest --watch",
"test": "jest",
"format": "prettier -w .",
"format:check": "prettier -c .",
"gen:manifest": "node scripts/generate-manifest.js",
"postbuild": "echo 'Build completed - sitemap generation disabled'",
"prepare": "husky install"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@headlessui/react": "^2.2.4",
"@heroicons/react": "^2.2.0",
"@tanstack/react-virtual": "^3.13.19",
"@types/crypto-js": "^4.2.2",
"@upstash/redis": "^1.25.0",
"@vidstack/react": "^1.12.13",
"artplayer": "^5.2.5",
"bs58": "^6.0.0",
"clsx": "^2.0.0",
"crypto-js": "^4.2.0",
"framer-motion": "^12.18.1",
"he": "^1.2.0",
"hls.js": "^1.6.10",
"lucide-react": "^0.438.0",
"media-icons": "^1.1.5",
"next": "^14.2.23",
"next-pwa": "^5.6.0",
"next-themes": "^0.4.6",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.4.0",
"redis": "^4.6.7",
"swiper": "^11.2.8",
"tailwind-merge": "^2.6.0",
"vidstack": "^0.6.15",
"zod": "^3.24.1"
},
"devDependencies": {
"@commitlint/cli": "^16.3.0",
"@commitlint/config-conventional": "^16.2.4",
"@svgr/webpack": "^8.1.0",
"@tailwindcss/forms": "^0.5.10",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^15.0.7",
"@types/bs58": "^5.0.0",
"@types/he": "^1.2.3",
"@types/node": "24.0.3",
"@types/react": "^18.3.18",
"@types/react-dom": "^19.1.6",
"@types/testing-library__jest-dom": "^5.14.9",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.1",
"eslint-config-next": "^14.2.23",
"eslint-config-prettier": "^8.10.0",
"eslint-plugin-simple-import-sort": "^7.0.0",
"eslint-plugin-unused-imports": "^2.0.0",
"husky": "^7.0.4",
"jest": "^27.5.1",
"lint-staged": "^12.5.0",
"next-router-mock": "^0.9.0",
"postcss": "^8.5.1",
"prettier": "^2.8.8",
"prettier-plugin-tailwindcss": "^0.5.0",
"tailwindcss": "^3.4.17",
"typescript": "^4.9.5",
"webpack-obfuscator": "^3.5.1"
},
"lint-staged": {
"**/*.{js,jsx,ts,tsx}": [
"eslint --max-warnings=0",
"prettier -w"
],
"**/*.{json,css,scss,md,webmanifest}": [
"prettier -w"
]
},
"packageManager": "pnpm@10.14.0"
}
================================================
FILE: postcss.config.js
================================================
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
================================================
FILE: proxy.worker.js
================================================
/* 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>`;
}
================================================
FILE: public/robots.txt
================================================
# 禁止所有搜索引擎爬取
User-agent: *
Disallow: /
================================================
FILE: scripts/convert-changelog.js
================================================
#!/usr/bin / env node
/* eslint-disable */
const fs = require('fs');
const path = require('path');
function parseChangelog(content) {
const lines = content.split('\n');
const versions = [];
let currentVersion = null;
let currentSection = null;
let inVersionContent = false;
for (const line of lines) {
const trimmedLine = line.trim();
// 匹配版本行: ## [X.Y.Z] - YYYY-MM-DD
const versionMatch = trimmedLine.match(
/^## \[([\d.]+)\] - (\d{4}-\d{2}-\d{2})$/
);
if (versionMatch) {
if (currentVersion) {
versions.push(currentVersion);
}
currentVersion = {
version: versionMatch[1],
date: versionMatch[2],
added: [],
changed: [],
fixed: [],
content: [], // 用于存储原始内容,当没有分类时使用
};
currentSection = null;
inVersionContent = true;
continue;
}
// 如果遇到下一个版本或到达文件末尾,停止处理当前版本
if (inVersionContent && currentVersion) {
// 匹配章节标题
if (trimmedLine === '### Added') {
currentSection = 'added';
continue;
} else if (trimmedLine === '### Changed') {
currentSection = 'changed';
continue;
} else if (trimmedLine === '### Fixed') {
currentSection = 'fixed';
continue;
}
// 匹配条目: - 内容
if (trimmedLine.startsWith('- ') && currentSection) {
const entry = trimmedLine.substring(2);
currentVersion[currentSection].push(entry);
} else if (
trimmedLine &&
!trimmedLine.startsWith('#') &&
!trimmedLine.startsWith('###')
) {
currentVersion.content.push(trimmedLine);
}
}
}
// 添加最后一个版本
if (currentVersion) {
versions.push(currentVersion);
}
// 后处理:如果某个版本没有分类内容,但有 content,则将 content 放到 changed 中
versions.forEach((version) => {
const hasCategories =
version.added.length > 0 ||
version.changed.length > 0 ||
version.fixed.length > 0;
if (!hasCategories && version.content.length > 0) {
version.changed = version.content;
}
// 清理 content 字段
delete version.content;
});
return { versions };
}
function generateTypeScript(changelogData) {
const entries = changelogData.versions
.map((version) => {
const addedEntries = version.added
.map((entry) => ` "${entry}"`)
.join(',\n');
const changedEntries = version.changed
.map((entry) => ` "${entry}"`)
.join(',\n');
const fixedEntries = version.fixed
.map((entry) => ` "${entry}"`)
.join(',\n');
return ` {
version: "${version.version}",
date: "${version.date}",
added: [
${addedEntries || ' // 无新增内容'}
],
changed: [
${changedEntries || ' // 无变更内容'}
],
fixed: [
${fixedEntries || ' // 无修复内容'}
]
}`;
})
.join(',\n');
return `// 此文件由 scripts/convert-changelog.js 自动生成
// 请勿手动编辑
export interface ChangelogEntry {
version: string;
date: string;
added: string[];
changed: string[];
fixed: string[];
}
export const changelog: ChangelogEntry[] = [
${entries}
];
export default changelog;
`;
}
function updateVersionFile(version) {
const versionTxtPath = path.join(process.cwd(), 'VERSION.txt');
try {
fs.writeFileSync(versionTxtPath, version, 'utf8');
console.log(`✅ 已更新 VERSION.txt: ${version}`);
} catch (error) {
console.error(`❌ 无法更新 VERSION.txt:`, error.message);
process.exit(1);
}
}
function updateVersionTs(version) {
const versionTsPath = path.join(process.cwd(), 'src/lib/version.ts');
try {
let content = fs.readFileSync(versionTsPath, 'utf8');
// 替换 CURRENT_VERSION 常量
const updatedContent = content.replace(
/const CURRENT_VERSION = ['"`][^'"`]+['"`];/,
`const CURRENT_VERSION = '${version}';`
);
fs.writeFileSync(versionTsPath, updatedContent, 'utf8');
console.log(`✅ 已更新 version.ts: ${version}`);
} catch (error) {
console.error(`❌ 无法更新 version.ts:`, error.message);
process.exit(1);
}
}
function main() {
try {
const changelogPath = path.join(process.cwd(), 'CHANGELOG');
const outputPath = path.join(process.cwd(), 'src/lib/changelog.ts');
console.log('正在读取 CHANGELOG 文件...');
const changelogContent = fs.readFileSync(changelogPath, 'utf-8');
console.log('正在解析 CHANGELOG 内容...');
const changelogData = parseChangelog(changelogContent);
if (changelogData.versions.length === 0) {
console.error('❌ 未在 CHANGELOG 中找到任何版本');
process.exit(1);
}
// 获取最新版本号(CHANGELOG中的第一个版本)
const latestVersion = changelogData.versions[0].version;
console.log(`🔢 最新版本: ${latestVersion}`);
console.log('正在生成 TypeScript 文件...');
const tsContent = generateTypeScript(changelogData);
// 确保输出目录存在
const outputDir = path.dirname(outputPath);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
fs.writeFileSync(outputPath, tsContent, 'utf-8');
// 读取 VERSION.txt 并同步到 version.ts
const versionTxtPath = path.join(process.cwd(), 'VERSION.txt');
const versionFromFile = fs.readFileSync(versionTxtPath, 'utf8').trim();
console.log(`📄 VERSION.txt 版本: ${versionFromFile}`);
updateVersionTs(versionFromFile);
// 检查是否在 GitHub Actions 环境中运行
const isGitHubActions = process.env.GITHUB_ACTIONS === 'true';
if (isGitHubActions) {
// 在 GitHub Actions 中,更新 VERSION.txt 为 CHANGELOG 最新版本
console.log('正在更新 VERSION.txt...');
updateVersionFile(latestVersion);
updateVersionTs(latestVersion);
}
console.log(`✅ 成功生成 ${outputPath}`);
console.log(`📊 版本统计:`);
changelogData.versions.forEach((version) => {
console.log(
` ${version.version} (${version.date}): +${version.added.length} ~${version.changed.length} !${version.fixed.length}`
);
});
console.log('\n🎉 转换完成!');
} catch (error) {
console.error('❌ 转换失败:', error);
process.exit(1);
}
}
if (require.main === module) {
main();
}
================================================
FILE: scripts/dev-docker.sh
================================================
#!/bin/bash
# 本地构建并启动 Docker 镜像 + Redis
# 用法: ./scripts/dev-docker.sh [up|down|rebuild|logs]
set -e
COMPOSE_FILE="docker-compose.dev.yml"
case "${1:-up}" in
up)
echo "🚀 构建并启动服务..."
docker compose -f "$COMPOSE_FILE" up -d --build
echo ""
echo "✅ 服务已启动"
echo " 应用: http://localhost:3000"
echo " Redis: localhost:6379"
echo ""
echo " 默认账号: admin / admin123"
echo " 查看日志: ./scripts/dev-docker.sh logs"
echo " 停止服务: ./scripts/dev-docker.sh down"
;;
down)
echo "🛑 停止并移除服务..."
docker compose -f "$COMPOSE_FILE" down
echo "✅ 已停止"
;;
rebuild)
echo "🔄 重新构建并启动..."
docker compose -f "$COMPOSE_FILE" down
docker compose -f "$COMPOSE_FILE" up -d --build --force-recreate
echo "✅ 已重新构建并启动"
;;
logs)
docker compose -f "$COMPOSE_FILE" logs -f
;;
*)
echo "用法: $0 [up|down|rebuild|logs]"
exit 1
;;
esac
================================================
FILE: scripts/generate-manifest.js
================================================
#!/usr/bin/env node
/* eslint-disable */
// 根据 NEXT_PUBLIC_SITE_NAME 动态生成 manifest.json
const fs = require('fs');
const path = require('path');
// 获取项目根目录
const projectRoot = path.resolve(__dirname, '..');
const publicDir = path.join(projectRoot, 'public');
const manifestPath = path.join(publicDir, 'manifest.json');
// 从环境变量获取站点名称
const siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV';
// manifest.json 模板
const manifestTemplate = {
name: siteName,
short_name: siteName,
description: '影视聚合',
start_url: '/',
scope: '/',
display: 'standalone',
background_color: '#000000',
'apple-mobile-web-app-capable': 'yes',
'apple-mobile-web-app-status-bar-style': 'black',
icons: [
{
src: '/icons/icon-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/icons/icon-256x256.png',
sizes: '256x256',
type: 'image/png',
},
{
src: '/icons/icon-384x384.png',
sizes: '384x384',
type: 'image/png',
},
{
src: '/icons/icon-512x512.png',
sizes: '512x512',
type: 'image/png',
},
],
};
try {
// 确保 public 目录存在
if (!fs.existsSync(publicDir)) {
fs.mkdirSync(publicDir, { recursive: true });
}
// 写入 manifest.json
fs.writeFileSync(manifestPath, JSON.stringify(manifestTemplate, null, 2));
console.log(`✅ Generated manifest.json with site name: ${siteName}`);
} catch (error) {
console.error('❌ Error generating manifest.json:', error);
process.exit(1);
}
================================================
FILE: src/app/admin/page.tsx
================================================
/* eslint-disable @typescript-eslint/no-explicit-any, no-console, @typescript-eslint/no-non-null-assertion,react-hooks/exhaustive-deps,@typescript-eslint/no-empty-function */
'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 {
AlertCircle,
AlertTriangle,
Check,
CheckCircle,
ChevronDown,
ChevronUp,
Database,
ExternalLink,
FileText,
FolderOpen,
Settings,
Tv,
Users,
Video,
} from 'lucide-react';
import { GripVertical } from 'lucide-react';
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { AdminConfig, AdminConfigResult } from '@/lib/admin.types';
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
import DataMigration from '@/components/DataMigration';
import PageLayout from '@/components/PageLayout';
// 统一按钮样式系统
const buttonStyles = {
// 主要操作按钮(蓝色)- 用于配置、设置、确认等
primary: 'px-3 py-1.5 text-sm font-medium bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-lg transition-colors',
// 成功操作按钮(绿色)- 用于添加、启用、保存等
success: 'px-3 py-1.5 text-sm font-medium bg-green-600 hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700 text-white rounded-lg transition-colors',
// 危险操作按钮(红色)- 用于删除、禁用、重置等
danger: 'px-3 py-1.5 text-sm font-medium bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 text-white rounded-lg transition-colors',
// 次要操作按钮(灰色)- 用于取消、关闭等
secondary: 'px-3 py-1.5 text-sm font-medium bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 text-white rounded-lg transition-colors',
// 警告操作按钮(黄色)- 用于批量禁用等
warning: 'px-3 py-1.5 text-sm font-medium bg-yellow-600 hover:bg-yellow-700 dark:bg-yellow-600 dark:hover:bg-yellow-700 text-white rounded-lg transition-colors',
// 小尺寸主要按钮
primarySmall: 'px-2 py-1 text-xs font-medium bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white rounded-md transition-colors',
// 小尺寸成功按钮
successSmall: 'px-2 py-1 text-xs font-medium bg-green-600 hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700 text-white rounded-md transition-colors',
// 小尺寸危险按钮
dangerSmall: 'px-2 py-1 text-xs font-medium bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 text-white rounded-md transition-colors',
// 小尺寸次要按钮
secondarySmall: 'px-2 py-1 text-xs font-medium bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 text-white rounded-md transition-colors',
// 小尺寸警告按钮
warningSmall: 'px-2 py-1 text-xs font-medium bg-yellow-600 hover:bg-yellow-700 dark:bg-yellow-600 dark:hover:bg-yellow-700 text-white rounded-md transition-colors',
// 圆角小按钮(用于表格操作)
roundedPrimary: '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',
roundedSuccess: '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-200 transition-colors',
roundedDanger: '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-200 transition-colors',
roundedSecondary: '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',
roundedWarning: 'inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 hover:bg-yellow-200 dark:bg-yellow-900/40 dark:hover:bg-yellow-900/60 dark:text-yellow-200 transition-colors',
roundedPurple: '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',
// 禁用状态
disabled: 'px-3 py-1.5 text-sm font-medium bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white rounded-lg transition-colors',
disabledSmall: 'px-2 py-1 text-xs font-medium bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white rounded-md transition-colors',
// 开关按钮样式
toggleOn: 'bg-green-600 dark:bg-green-600',
toggleOff: 'bg-gray-200 dark:bg-gray-700',
toggleThumb: 'bg-white',
toggleThumbOn: 'translate-x-6',
toggleThumbOff: 'translate-x-1',
// 快速操作按钮样式
quickAction: 'px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-400 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-md transition-colors',
};
// 通用弹窗组件
interface AlertModalProps {
isOpen: boolean;
onClose: () => void;
type: 'success' | 'error' | 'warning';
title: string;
message?: string;
timer?: number;
showConfirm?: boolean;
}
const AlertModal = ({
isOpen,
onClose,
type,
title,
message,
timer,
showConfirm = false
}: AlertModalProps) => {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
if (isOpen) {
setIsVisible(true);
if (timer) {
setTimeout(() => {
onClose();
}, timer);
}
} else {
setIsVisible(false);
}
}, [isOpen, timer, onClose]);
if (!isOpen) return null;
const getIcon = () => {
switch (type) {
case 'success':
return <CheckCircle className="w-8 h-8 text-green-500" />;
case 'error':
return <AlertCircle className="w-8 h-8 text-red-500" />;
case 'warning':
return <AlertTriangle className="w-8 h-8 text-yellow-500" />;
default:
return null;
}
};
const getBgColor = () => {
switch (type) {
case 'success':
return 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800';
case 'error':
return 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800';
case 'warning':
return 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800';
default:
return 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800';
}
};
return createPortal(
<div className={`fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4 transition-opacity duration-200 ${isVisible ? 'opacity-100' : 'opacity-0'}`}>
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-sm w-full border ${getBgColor()} transition-all duration-200 ${isVisible ? 'scale-100' : 'scale-95'}`}>
<div className="p-6 text-center">
<div className="flex justify-center mb-4">
{getIcon()}
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
{title}
</h3>
{message && (
<p className="text-gray-600 dark:text-gray-400 mb-4">
{message}
</p>
)}
{showConfirm && (
<button
onClick={onClose}
className={`px-4 py-2 text-sm font-medium ${buttonStyles.primary}`}
>
确定
</button>
)}
</div>
</div>
</div>,
document.body
);
};
// 弹窗状态管理
const useAlertModal = () => {
const [alertModal, setAlertModal] = useState<{
isOpen: boolean;
type: 'success' | 'error' | 'warning';
title: string;
message?: string;
timer?: number;
showConfirm?: boolean;
}>({
isOpen: false,
type: 'success',
title: '',
});
const showAlert = (config: Omit<typeof alertModal, 'isOpen'>) => {
setAlertModal({ ...config, isOpen: true });
};
const hideAlert = () => {
setAlertModal(prev => ({ ...prev, isOpen: false }));
};
return { alertModal, showAlert, hideAlert };
};
// 统一弹窗方法(必须在首次使用前定义)
const showError = (message: string, showAlert?: (config: any) => void) => {
if (showAlert) {
showAlert({ type: 'error', title: '错误', message, showConfirm: true });
} else {
console.error(message);
}
};
const showSuccess = (message: string, showAlert?: (config: any) => void) => {
if (showAlert) {
showAlert({ type: 'success', title: '成功', message, timer: 2000 });
} else {
console.log(message);
}
};
// 通用加载状态管理系统
interface LoadingState {
[key: string]: boolean;
}
const useLoadingState = () => {
const [loadingStates, setLoadingStates] = useState<LoadingState>({});
const setLoading = (key: string, loading: boolean) => {
setLoadingStates(prev => ({ ...prev, [key]: loading }));
};
const isLoading = (key: string) => loadingStates[key] || false;
const withLoading = async (key: string, operation: () => Promise<any>): Promise<any> => {
setLoading(key, true);
try {
const result = await operation();
return result;
} finally {
setLoading(key, false);
}
};
return { loadingStates, setLoading, isLoading, withLoading };
};
// 新增站点配置类型
interface SiteConfig {
SiteName: string;
Announcement: string;
SearchDownstreamMaxPage: number;
SiteInterfaceCacheTime: number;
DoubanProxyType: string;
DoubanProxy: string;
DoubanImageProxyType: string;
DoubanImageProxy: string;
DisableYellowFilter: boolean;
FluidSearch: boolean;
EnableWebLive: boolean;
}
// 视频源数据类型
interface DataSource {
name: string;
key: string;
api: string;
detail?: string;
disabled?: boolean;
from: 'config' | 'custom';
}
// 直播源数据类型
interface LiveDataSource {
name: string;
key: string;
url: string;
ua?: string;
epg?: string;
channelNumber?: number;
disabled?: boolean;
from: 'config' | 'custom';
}
// 自定义分类数据类型
interface CustomCategory {
name?: string;
type: 'movie' | 'tv';
query: 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 { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [showAddUserForm, setShowAddUserForm] = useState(false);
const [showChangePasswordForm, setShowChangePasswordForm] = useState(false);
const [showAddUserGroupForm, setShowAddUserGroupForm] = useState(false);
const [showEditUserGroupForm, setShowEditUserGroupForm] = useState(false);
const [newUser, setNewUser] = useState({
username: '',
password: '',
userGroup: '', // 新增用户组字段
});
const [changePasswordUser, setChangePasswordUser] = useState({
username: '',
password: '',
});
const [newUserGroup, setNewUserGroup] = useState({
name: '',
enabledApis: [] as string[],
});
const [editingUserGroup, setEditingUserGroup] = useState<{
name: string;
enabledApis: string[];
} | null>(null);
const [showConfigureApisModal, setShowConfigureApisModal] = useState(false);
const [selectedUser, setSelectedUser] = useState<{
username: string;
role: 'user' | 'admin' | 'owner';
enabledApis?: string[];
tags?: string[];
} | null>(null);
const [selectedApis, setSelectedApis] = useState<string[]>([]);
const [showConfigureUserGroupModal, setShowConfigureUserGroupModal] = useState(false);
const [selectedUserForGroup, setSelectedUserForGroup] = useState<{
username: string;
role: 'user' | 'admin' | 'owner';
tags?: string[];
} | null>(null);
const [selectedUserGroups, setSelectedUserGroups] = useState<string[]>([]);
const [selectedUsers, setSelectedUsers] = useState<Set<string>>(new Set());
const [showBatchUserGroupModal, setShowBatchUserGroupModal] = useState(false);
const [selectedUserGroup, setSelectedUserGroup] = useState<string>('');
const [showDeleteUserGroupModal, setShowDeleteUserGroupModal] = useState(false);
const [deletingUserGroup, setDeletingUserGroup] = useState<{
name: string;
affectedUsers: Array<{ username: string; role: 'user' | 'admin' | 'owner' }>;
} | null>(null);
const [showDeleteUserModal, setShowDeleteUserModal] = useState(false);
const [deletingUser, setDeletingUser] = useState<string | null>(null);
// 当前登录用户名
const currentUsername = getAuthInfoFromBrowserCookie()?.username || null;
// 使用 useMemo 计算全选状态,避免每次渲染都重新计算
const selectAllUsers = useMemo(() => {
const selectableUserCount = config?.UserConfig?.Users?.filter(user =>
(role === 'owner' ||
(role === 'admin' &&
(user.role === 'user' ||
user.username === currentUsername)))
).length || 0;
return selectedUsers.size === selectableUserCount && selectedUsers.size > 0;
}, [selectedUsers.size, config?.UserConfig?.Users, role, currentUsername]);
// 获取用户组列表
const userGroups = config?.UserConfig?.Tags || [];
// 处理用户组相关操作
const handleUserGroupAction = async (
action: 'add' | 'edit' | 'delete',
groupName: string,
enabledApis?: string[]
) => {
return withLoading(`userGroup_${action}_${groupName}`, async () => {
try {
const res = await fetch('/api/admin/user', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'userGroup',
groupAction: action,
groupName,
enabledApis,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `操作失败: ${res.status}`);
}
await refreshConfig();
if (action === 'add') {
setNewUserGroup({ name: '', enabledApis: [] });
setShowAddUserGroupForm(false);
} else if (action === 'edit') {
setEditingUserGroup(null);
setShowEditUserGroupForm(false);
}
showSuccess(action === 'add' ? '用户组添加成功' : action === 'edit' ? '用户组更新成功' : '用户组删除成功', showAlert);
} catch (err) {
showError(err instanceof Error ? err.message : '操作失败', showAlert);
throw err;
}
});
};
const handleAddUserGroup = () => {
if (!newUserGroup.name.trim()) return;
handleUserGroupAction('add', newUserGroup.name, newUserGroup.enabledApis);
};
const handleEditUserGroup = () => {
if (!editingUserGroup?.name.trim()) return;
handleUserGroupAction('edit', editingUserGroup.name, editingUserGroup.enabledApis);
};
const handleDeleteUserGroup = (groupName: string) => {
// 计算会受影响的用户数量
const affectedUsers = config?.UserConfig?.Users?.filter(user =>
user.tags && user.tags.includes(groupName)
) || [];
setDeletingUserGroup({
name: groupName,
affectedUsers: affectedUsers.map(u => ({ username: u.username, role: u.role }))
});
setShowDeleteUserGroupModal(true);
};
const handleConfirmDeleteUserGroup = async () => {
if (!deletingUserGroup) return;
try {
await handleUserGroupAction('delete', deletingUserGroup.name);
setShowDeleteUserGroupModal(false);
setDeletingUserGroup(null);
} catch (err) {
// 错误处理已在 handleUserGroupAction 中处理
}
};
const handleStartEditUserGroup = (group: { name: string; enabledApis: string[] }) => {
setEditingUserGroup({ ...group });
setShowEditUserGroupForm(true);
setShowAddUserGroupForm(false);
};
// 为用户分配用户组
const handleAssignUserGroup = async (username: string, userGroups: string[]) => {
return withLoading(`assignUserGroup_${username}`, async () => {
try {
const res = await fetch('/api/admin/user', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
targetUsername: username,
action: 'updateUserGroups',
userGroups,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `操作失败: ${res.status}`);
}
await refreshConfig();
showSuccess('用户组分配成功', showAlert);
} catch (err) {
showError(err instanceof Error ? err.message : '操作失败', showAlert);
throw err;
}
});
};
const handleBanUser = async (uname: string) => {
await withLoading(`banUser_${uname}`, () => handleUserAction('ban', uname));
};
const handleUnbanUser = async (uname: string) => {
await withLoading(`unbanUser_${uname}`, () => handleUserAction('unban', uname));
};
const handleSetAdmin = async (uname: string) => {
await withLoading(`setAdmin_${uname}`, () => handleUserAction('setAdmin', uname));
};
const handleRemoveAdmin = async (uname: string) => {
await withLoading(`removeAdmin_${uname}`, () => handleUserAction('cancelAdmin', uname));
};
const handleAddUser = async () => {
if (!newUser.username || !newUser.password) return;
await withLoading('addUser', async () => {
await handleUserAction('add', newUser.username, newUser.password, newUser.userGroup);
setNewUser({ username: '', password: '', userGroup: '' });
setShowAddUserForm(false);
});
};
const handleChangePassword = async () => {
if (!changePasswordUser.username || !changePasswordUser.password) return;
await withLoading(`changePassword_${changePasswordUser.username}`, async () => {
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 = (username: string) => {
setDeletingUser(username);
setShowDeleteUserModal(true);
};
const handleConfigureUserApis = (user: {
username: string;
role: 'user' | 'admin' | 'owner';
enabledApis?: string[];
}) => {
setSelectedUser(user);
setSelectedApis(user.enabledApis || []);
setShowConfigureApisModal(true);
};
const handleConfigureUserGroup = (user: {
username: string;
role: 'user' | 'admin' | 'owner';
tags?: string[];
}) => {
setSelectedUserForGroup(user);
setSelectedUserGroups(user.tags || []);
setShowConfigureUserGroupModal(true);
};
const handleSaveUserGroups = async () => {
if (!selectedUserForGroup) return;
await withLoading(`saveUserGroups_${selectedUserForGroup.username}`, async () => {
try {
await handleAssignUserGroup(selectedUserForGroup.username, selectedUserGroups);
setShowConfigureUserGroupModal(false);
setSelectedUserForGroup(null);
setSelectedUserGroups([]);
} catch (err) {
// 错误处理已在 handleAssignUserGroup 中处理
}
});
};
// 处理用户选择
const handleSelectUser = useCallback((username: string, checked: boolean) => {
setSelectedUsers(prev => {
const newSelectedUsers = new Set(prev);
if (checked) {
newSelectedUsers.add(username);
} else {
newSelectedUsers.delete(username);
}
return newSelectedUsers;
});
}, []);
const handleSelectAllUsers = useCallback((checked: boolean) => {
if (checked) {
// 只选择自己有权限操作的用户
const selectableUsernames = config?.UserConfig?.Users?.filter(user =>
(role === 'owner' ||
(role === 'admin' &&
(user.role === 'user' ||
user.username === currentUsername)))
).map(u => u.username) || [];
setSelectedUsers(new Set(selectableUsernames));
} else {
setSelectedUsers(new Set());
}
}, [config?.UserConfig?.Users, role, currentUsername]);
// 批量设置用户组
const handleBatchSetUserGroup = async (userGroup: string) => {
if (selectedUsers.size === 0) return;
await withLoading('batchSetUserGroup', async () => {
try {
const res = await fetch('/api/admin/user', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'batchUpdateUserGroups',
usernames: Array.from(selectedUsers),
userGroups: userGroup === '' ? [] : [userGroup],
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `操作失败: ${res.status}`);
}
const userCount = selectedUsers.size;
setSelectedUsers(new Set());
setShowBatchUserGroupModal(false);
setSelectedUserGroup('');
showSuccess(`已为 ${userCount} 个用户设置用户组: ${userGroup}`, showAlert);
// 刷新配置
await refreshConfig();
} catch (err) {
showError('批量设置用户组失败', showAlert);
throw err;
}
});
};
// 提取URL域名的辅助函数
const extractDomain = (url: string): string => {
try {
const urlObj = new URL(url);
return urlObj.hostname;
} catch {
// 如果URL格式不正确,返回原字符串
return url;
}
};
const handleSaveUserApis = async () => {
if (!selectedUser) return;
await withLoading(`saveUserApis_${selectedUser.username}`, async () => {
try {
const res = await fetch('/api/admin/user', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
targetUsername: selectedUser.username,
action: 'updateUserApis',
enabledApis: selectedApis,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `操作失败: ${res.status}`);
}
// 成功后刷新配置
await refreshConfig();
setShowConfigureApisModal(false);
setSelectedUser(null);
setSelectedApis([]);
} catch (err) {
showError(err instanceof Error ? err.message : '操作失败', showAlert);
throw err;
}
});
};
// 通用请求函数
const handleUserAction = async (
action:
| 'add'
| 'ban'
| 'unban'
| 'setAdmin'
| 'cancelAdmin'
| 'changePassword'
| 'deleteUser',
targetUsername: string,
targetPassword?: string,
userGroup?: string
) => {
try {
const res = await fetch('/api/admin/user', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
targetUsername,
...(targetPassword ? { targetPassword } : {}),
...(userGroup ? { userGroup } : {}),
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 : '操作失败', showAlert);
}
};
const handleConfirmDeleteUser = async () => {
if (!deletingUser) return;
await withLoading(`deleteUser_${deletingUser}`, async () => {
try {
await handleUserAction('deleteUser', deletingUser);
setShowDeleteUserModal(false);
setDeletingUser(null);
} catch (err) {
// 错误处理已在 handleUserAction 中处理
}
});
};
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>
<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={() => {
setShowAddUserGroupForm(!showAddUserGroupForm);
if (showEditUserGroupForm) {
setShowEditUserGroupForm(false);
setEditingUserGroup(null);
}
}}
className={showAddUserGroupForm ? buttonStyles.secondary : buttonStyles.primary}
>
{showAddUserGroupForm ? '取消' : '添加用户组'}
</button>
</div>
{/* 用户组列表 */}
<div className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[20rem] overflow-y-auto overflow-x-auto relative'>
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
<thead className='bg-gray-50 dark:bg-gray-900 sticky top-0 z-10'>
<tr>
<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'>
可用视频源
</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>
<tbody className='divide-y divide-gray-200 dark:divide-gray-700'>
{userGroups.map((group) => (
<tr key={group.name} 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'>
{group.name}
</td>
<td className='px-6 py-4 whitespace-nowrap'>
<div className='flex items-center space-x-2'>
<span className='text-sm text-gray-900 dark:text-gray-100'>
{group.enabledApis && group.enabledApis.length > 0
? `${group.enabledApis.length} 个源`
: '无限制'}
</span>
</div>
</td>
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
<button
onClick={() => handleStartEditUserGroup(group)}
disabled={isLoading(`userGroup_edit_${group.name}`)}
className={`${buttonStyles.roundedPrimary} ${isLoading(`userGroup_edit_${group.name}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
>
编辑
</button>
<button
onClick={() => handleDeleteUserGroup(group.name)}
className={buttonStyles.roundedDanger}
>
删除
</button>
</td>
</tr>
))}
{userGroups.length === 0 && (
<tr>
<td colSpan={3} className='px-6 py-8 text-center text-sm text-gray-500 dark:text-gray-400'>
暂无用户组,请添加用户组来管理用户权限
</td>
</tr>
)}
</tbody>
</table>
</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>
<div className='flex items-center space-x-2'>
{/* 批量操作按钮 */}
{selectedUsers.size > 0 && (
<>
<div className='flex items-center space-x-3'>
<span className='text-sm text-gray-600 dark:text-gray-400'>
已选择 {selectedUsers.size} 个用户
</span>
<button
onClick={() => setShowBatchUserGroupModal(true)}
className={buttonStyles.primary}
>
批量设置用户组
</button>
</div>
<div className='w-px h-6 bg-gray-300 dark:bg-gray-600'></div>
</>
)}
<button
onClick={() => {
setShowAddUserForm(!showAddUserForm);
if (showChangePasswordForm) {
setShowChangePasswordForm(false);
setChangePasswordUser({ username: '', password: '' });
}
}}
className={showAddUserForm ? buttonStyles.secondary : buttonStyles.success}
>
{showAddUserForm ? '取消' : '添加用户'}
</button>
</div>
</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='space-y-4'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<input
type='text'
placeholder='用户名'
value={newUser.username}
onChange={(e) =>
setNewUser((prev) => ({ ...prev, username: 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 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='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>
<select
value={newUser.userGroup}
onChange={(e) =>
setNewUser((prev) => ({ ...prev, userGroup: 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'
>
<option value=''>无用户组(无限制)</option>
{userGroups.map((group) => (
<option key={group.name} value={group.name}>
{group.name} ({group.enabledApis && group.enabledApis.length > 0 ? `${group.enabledApis.length} 个源` : '无限制'})
</option>
))}
</select>
</div>
<div className='flex justify-end'>
<button
onClick={handleAddUser}
disabled={!newUser.username || !newUser.password || isLoading('addUser')}
className={!newUser.username || !newUser.password || isLoading('addUser') ? buttonStyles.disabled : buttonStyles.success}
>
{isLoading('addUser') ? '添加中...' : '添加'}
</button>
</div>
</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 || isLoading(`changePassword_${changePasswordUser.username}`)}
className={`w-full sm:w-auto ${!changePasswordUser.password || isLoading(`changePassword_${changePasswordUser.username}`) ? buttonStyles.disabled : buttonStyles.primary}`}
>
{isLoading(`changePassword_${changePasswordUser.username}`) ? '修改中...' : '修改密码'}
</button>
<button
onClick={() => {
setShowChangePasswordForm(false);
setChangePasswordUser({ username: '', password: '' });
}}
className={`w-full sm:w-auto ${buttonStyles.secondary}`}
>
取消
</button>
</div>
</div>
)}
{/* 用户列表 */}
<div className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[28rem] overflow-y-auto overflow-x-auto relative' data-table="user-list">
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
<thead className='bg-gray-50 dark:bg-gray-900 sticky top-0 z-10'>
<tr>
<th className='w-4' />
<th className='w-10 px-1 py-3 text-center'>
{(() => {
// 检查是否有权限操作任何用户
const hasAnyPermission = config?.UserConfig?.Users?.some(user =>
(role === 'owner' ||
(role === 'admin' &&
(user.role === 'user' ||
user.username === currentUsername)))
);
return hasAnyPermission ? (
<input
type='checkbox'
checked={selectAllUsers}
onChange={(e) => handleSelectAllUsers(e.target.checked)}
className='w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600'
/>
) : (
<div className='w-4 h-4' />
);
})()}
</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-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='w-4' />
<td className='w-10 px-1 py-3 text-center'>
{(role === 'owner' ||
(role === 'admin' &&
(user.role === 'user' ||
user.username === currentUsername))) ? (
<input
type='checkbox'
checked={selectedUsers.has(user.username)}
onChange={(e) => handleSelectUser(user.username, e.target.checked)}
className='w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600'
/>
) : (
<div className='w-4 h-4' />
)}
</td>
<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'>
<div className='flex items-center space-x-2'>
<span className='text-sm text-gray-900 dark:text-gray-100'>
{user.tags && user.tags.length > 0
? user.tags.join(', ')
: '无用户组'}
</span>
{/* 配置用户组按钮 */}
{(role === 'owner' ||
(role === 'admin' &&
(user.role === 'user' ||
user.username === currentUsername))) && (
<button
onClick={() => handleConfigureUserGroup(user)}
className={buttonStyles.roundedPrimary}
>
配置
</button>
)}
</div>
</td>
<td className='px-6 py-4 whitespace-nowrap'>
<div className='flex items-center space-x-2'>
<span className='text-sm text-gray-900 dark:text-gray-100'>
{user.enabledApis && user.enabledApis.length > 0
? `${user.enabledApis.length} 个源`
: '无限制'}
</span>
{/* 配置采集源权限按钮 */}
{(role === 'owner' ||
(role === 'admin' &&
(user.role === 'user' ||
user.username === currentUsername))) && (
<button
onClick={() => handleConfigureUserApis(user)}
className={buttonStyles.roundedPrimary}
>
配置
</button>
)}
</div>
</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={buttonStyles.roundedPrimary}
>
修改密码
</button>
)}
{canOperate && (
<>
{/* 其他操作按钮 */}
{user.role === 'user' && (
<button
onClick={() => handleSetAdmin(user.username)}
disabled={isLoading(`setAdmin_${user.username}`)}
className={`${buttonStyles.roundedPurple} ${isLoading(`setAdmin_${user.username}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
>
设为管理
</button>
)}
{user.role === 'admin' && (
<button
onClick={() =>
handleRemoveAdmin(user.username)
}
disabled={isLoading(`removeAdmin_${user.username}`)}
className={`${buttonStyles.roundedSecondary} ${isLoading(`removeAdmin_${user.username}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
>
取消管理
</button>
)}
{user.role !== 'owner' &&
(!user.banned ? (
<button
onClick={() => handleBanUser(user.username)}
disabled={isLoading(`banUser_${user.username}`)}
className={`${buttonStyles.roundedDanger} ${isLoading(`banUser_${user.username}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
>
封禁
</button>
) : (
<button
onClick={() =>
handleUnbanUser(user.username)
}
disabled={isLoading(`unbanUser_${user.username}`)}
className={`${buttonStyles.roundedSuccess} ${isLoading(`unbanUser_${user.username}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
>
解封
</button>
))}
</>
)}
{/* 删除用户按钮 - 放在最后,使用更明显的红色样式 */}
{canDeleteUser && (
<button
onClick={() => handleDeleteUser(user.username)}
className={buttonStyles.roundedDanger}
>
删除用户
</button>
)}
</td>
</tr>
);
})}
</tbody>
);
})()}
</table>
</div>
</div>
{/* 配置用户采集源权限弹窗 */}
{showConfigureApisModal && selectedUser && createPortal(
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4' onClick={() => {
setShowConfigureApisModal(false);
setSelectedUser(null);
setSelectedApis([]);
}}>
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[80vh] overflow-y-auto' onClick={(e) => e.stopPropagation()}>
<div className='p-6'>
<div className='flex items-center justify-between mb-6'>
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
配置用户采集源权限 - {selectedUser.username}
</h3>
<button
onClick={() => {
setShowConfigureApisModal(false);
setSelectedUser(null);
setSelectedApis([]);
}}
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
>
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
</svg>
</button>
</div>
<div className='mb-6'>
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4'>
<div className='flex items-center space-x-2 mb-2'>
<svg className='w-5 h-5 text-blue-600 dark:text-blue-400' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z' />
</svg>
<span className='text-sm font-medium text-blue-800 dark:text-blue-300'>
配置说明
</span>
</div>
<p className='text-sm text-blue-700 dark:text-blue-400 mt-1'>
提示:全不选为无限制,选中的采集源将限制用户只能访问这些源
</p>
</div>
</div>
{/* 采集源选择 - 多列布局 */}
<div className='mb-6'>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300 mb-4'>
选择可用的采集源:
</h4>
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'>
{config?.SourceConfig?.map((source) => (
<label key={source.key} className='flex items-center space-x-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer transition-colors'>
<input
type='checkbox'
checked={selectedApis.includes(source.key)}
onChange={(e) => {
if (e.target.checked) {
setSelectedApis([...selectedApis, source.key]);
} else {
setSelectedApis(selectedApis.filter(api => api !== source.key));
}
}}
className='rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700'
/>
<div className='flex-1 min-w-0'>
<div className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate'>
{source.name}
</div>
{source.api && (
<div className='text-xs text-gray-500 dark:text-gray-400 truncate'>
{extractDomain(source.api)}
</div>
)}
</div>
</label>
))}
</div>
</div>
{/* 快速操作按钮 */}
<div className='flex flex-wrap items-center justify-between mb-6 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg'>
<div className='flex space-x-2'>
<button
onClick={() => setSelectedApis([])}
className={buttonStyles.quickAction}
>
全不选(无限制)
</button>
<button
onClick={() => {
const allApis = config?.SourceConfig?.filter(source => !source.disabled).map(s => s.key) || [];
setSelectedApis(allApis);
}}
className={buttonStyles.quickAction}
>
全选
</button>
</div>
<div className='text-sm text-gray-600 dark:text-gray-400'>
已选择:<span className='font-medium text-blue-600 dark:text-blue-400'>
{selectedApis.length > 0 ? `${selectedApis.length} 个源` : '无限制'}
</span>
</div>
</div>
{/* 操作按钮 */}
<div className='flex justify-end space-x-3'>
<button
onClick={() => {
setShowConfigureApisModal(false);
setSelectedUser(null);
setSelectedApis([]);
}}
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
>
取消
</button>
<button
onClick={handleSaveUserApis}
disabled={isLoading(`saveUserApis_${selectedUser?.username}`)}
className={`px-6 py-2.5 text-sm font-medium ${isLoading(`saveUserApis_${selectedUser?.username}`) ? buttonStyles.disabled : buttonStyles.primary}`}
>
{isLoading(`saveUserApis_${selectedUser?.username}`) ? '配置中...' : '确认配置'}
</button>
</div>
</div>
</div>
</div>,
document.body
)}
{/* 添加用户组弹窗 */}
{showAddUserGroupForm && createPortal(
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4' onClick={() => {
setShowAddUserGroupForm(false);
setNewUserGroup({ name: '', enabledApis: [] });
}}>
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[80vh] overflow-y-auto' onClick={(e) => e.stopPropagation()}>
<div className='p-6'>
<div className='flex items-center justify-between mb-6'>
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
添加新用户组
</h3>
<button
onClick={() => {
setShowAddUserGroupForm(false);
setNewUserGroup({ name: '', enabledApis: [] });
}}
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
>
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
</svg>
</button>
</div>
<div className='space-y-6'>
{/* 用户组名称 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
用户组名称
</label>
<input
type='text'
placeholder='请输入用户组名称'
value={newUserGroup.name}
onChange={(e) =>
setNewUserGroup((prev) => ({ ...prev, name: 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-blue-500 focus:border-transparent'
/>
</div>
{/* 可用视频源 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-4'>
可用视频源
</label>
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3'>
{config?.SourceConfig?.map((source) => (
<label key={source.key} className='flex items-center space-x-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer transition-colors'>
<input
type='checkbox'
checked={newUserGroup.enabledApis.includes(source.key)}
onChange={(e) => {
if (e.target.checked) {
setNewUserGroup(prev => ({
...prev,
enabledApis: [...prev.enabledApis, source.key]
}));
} else {
setNewUserGroup(prev => ({
...prev,
enabledApis: prev.enabledApis.filter(api => api !== source.key)
}));
}
}}
className='rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700'
/>
<div className='flex-1 min-w-0'>
<div className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate'>
{source.name}
</div>
{source.api && (
<div className='text-xs text-gray-500 dark:text-gray-400 truncate'>
{extractDomain(source.api)}
</div>
)}
</div>
</label>
))}
</div>
{/* 快速操作按钮 */}
<div className='mt-4 flex space-x-2'>
<button
onClick={() => setNewUserGroup(prev => ({ ...prev, enabledApis: [] }))}
className={buttonStyles.quickAction}
>
全不选(无限制)
</button>
<button
onClick={() => {
const allApis = config?.SourceConfig?.filter(source => !source.disabled).map(s => s.key) || [];
setNewUserGroup(prev => ({ ...prev, enabledApis: allApis }));
}}
className={buttonStyles.quickAction}
>
全选
</button>
</div>
</div>
{/* 操作按钮 */}
<div className='flex justify-end space-x-3 pt-4 border-t border-gray-200 dark:border-gray-700'>
<button
onClick={() => {
setShowAddUserGroupForm(false);
setNewUserGroup({ name: '', enabledApis: [] });
}}
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
>
取消
</button>
<button
onClick={handleAddUserGroup}
disabled={!newUserGroup.name.trim() || isLoading('userGroup_add_new')}
className={`px-6 py-2.5 text-sm font-medium ${!newUserGroup.name.trim() || isLoading('userGroup_add_new') ? buttonStyles.disabled : buttonStyles.primary}`}
>
{isLoading('userGroup_add_new') ? '添加中...' : '添加用户组'}
</button>
</div>
</div>
</div>
</div>
</div>,
document.body
)}
{/* 编辑用户组弹窗 */}
{showEditUserGroupForm && editingUserGroup && createPortal(
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4' onClick={() => {
setShowEditUserGroupForm(false);
setEditingUserGroup(null);
}}>
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[80vh] overflow-y-auto' onClick={(e) => e.stopPropagation()}>
<div className='p-6'>
<div className='flex items-center justify-between mb-6'>
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
编辑用户组 - {editingUserGroup.name}
</h3>
<button
onClick={() => {
setShowEditUserGroupForm(false);
setEditingUserGroup(null);
}}
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
>
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
</svg>
</button>
</div>
<div className='space-y-6'>
{/* 可用视频源 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-4'>
可用视频源
</label>
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3'>
{config?.SourceConfig?.map((source) => (
<label key={source.key} className='flex items-center space-x-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer transition-colors'>
<input
type='checkbox'
checked={editingUserGroup.enabledApis.includes(source.key)}
onChange={(e) => {
if (e.target.checked) {
setEditingUserGroup(prev => prev ? {
...prev,
enabledApis: [...prev.enabledApis, source.key]
} : null);
} else {
setEditingUserGroup(prev => prev ? {
...prev,
enabledApis: prev.enabledApis.filter(api => api !== source.key)
} : null);
}
}}
className='rounded border-gray-300 text-purple-600 focus:ring-purple-500 dark:border-gray-600 dark:bg-gray-700'
/>
<div className='flex-1 min-w-0'>
<div className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate'>
{source.name}
</div>
{source.api && (
<div className='text-xs text-gray-500 dark:text-gray-400 truncate'>
{extractDomain(source.api)}
</div>
)}
</div>
</label>
))}
</div>
{/* 快速操作按钮 */}
<div className='mt-4 flex space-x-2'>
<button
onClick={() => setEditingUserGroup(prev => prev ? { ...prev, enabledApis: [] } : null)}
className={buttonStyles.quickAction}
>
全不选(无限制)
</button>
<button
onClick={() => {
const allApis = config?.SourceConfig?.filter(source => !source.disabled).map(s => s.key) || [];
setEditingUserGroup(prev => prev ? { ...prev, enabledApis: allApis } : null);
}}
className={buttonStyles.quickAction}
>
全选
</button>
</div>
</div>
{/* 操作按钮 */}
<div className='flex justify-end space-x-3 pt-4 border-t border-gray-200 dark:border-gray-700'>
<button
onClick={() => {
setShowEditUserGroupForm(false);
setEditingUserGroup(null);
}}
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
>
取消
</button>
<button
onClick={handleEditUserGroup}
disabled={isLoading(`userGroup_edit_${editingUserGroup?.name}`)}
className={`px-6 py-2.5 text-sm font-medium ${isLoading(`userGroup_edit_${editingUserGroup?.name}`) ? buttonStyles.disabled : buttonStyles.primary}`}
>
{isLoading(`userGroup_edit_${editingUserGroup?.name}`) ? '保存中...' : '保存修改'}
</button>
</div>
</div>
</div>
</div>
</div>,
document.body
)}
{/* 配置用户组弹窗 */}
{showConfigureUserGroupModal && selectedUserForGroup && createPortal(
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4' onClick={() => {
setShowConfigureUserGroupModal(false);
setSelectedUserForGroup(null);
setSelectedUserGroups([]);
}}>
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[80vh] overflow-y-auto' onClick={(e) => e.stopPropagation()}>
<div className='p-6'>
<div className='flex items-center justify-between mb-6'>
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
配置用户组 - {selectedUserForGroup.username}
</h3>
<button
onClick={() => {
setShowConfigureUserGroupModal(false);
setSelectedUserForGroup(null);
setSelectedUserGroups([]);
}}
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
>
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
</svg>
</button>
</div>
<div className='mb-6'>
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4'>
<div className='flex items-center space-x-2 mb-2'>
<svg className='w-5 h-5 text-blue-600 dark:text-blue-400' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z' />
</svg>
<span className='text-sm font-medium text-blue-800 dark:text-blue-300'>
配置说明
</span>
</div>
<p className='text-sm text-blue-700 dark:text-blue-400 mt-1'>
提示:选择"无用户组"为无限制,选择特定用户组将限制用户只能访问该用户组允许的采集源
</p>
</div>
</div>
{/* 用户组选择 - 下拉选择器 */}
<div className='mb-6'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
选择用户组:
</label>
<select
value={selectedUserGroups.length > 0 ? selectedUserGroups[0] : ''}
onChange={(e) => {
const value = e.target.value;
setSelectedUserGroups(value ? [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-blue-500 focus:border-transparent transition-colors'
>
<option value=''>无用户组(无限制)</option>
{userGroups.map((group) => (
<option key={group.name} value={group.name}>
{group.name} {group.enabledApis && group.enabledApis.length > 0 ? `(${group.enabledApis.length} 个源)` : ''}
</option>
))}
</select>
<p className='mt-2 text-xs text-gray-500 dark:text-gray-400'>
选择"无用户组"为无限制,选择特定用户组将限制用户只能访问该用户组允许的采集源
</p>
</div>
{/* 操作按钮 */}
<div className='flex justify-end space-x-3'>
<button
onClick={() => {
setShowConfigureUserGroupModal(false);
setSelectedUserForGroup(null);
setSelectedUserGroups([]);
}}
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
>
取消
</button>
<button
onClick={handleSaveUserGroups}
disabled={isLoading(`saveUserGroups_${selectedUserForGroup?.username}`)}
className={`px-6 py-2.5 text-sm font-medium ${isLoading(`saveUserGroups_${selectedUserForGroup?.username}`) ? buttonStyles.disabled : buttonStyles.primary}`}
>
{isLoading(`saveUserGroups_${selectedUserForGroup?.username}`) ? '配置中...' : '确认配置'}
</button>
</div>
</div>
</div>
</div>,
document.body
)}
{/* 删除用户组确认弹窗 */}
{showDeleteUserGroupModal && deletingUserGroup && createPortal(
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4' onClick={() => {
setShowDeleteUserGroupModal(false);
setDeletingUserGroup(null);
}}>
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full' onClick={(e) => e.stopPropagation()}>
<div className='p-6'>
<div className='flex items-center justify-between mb-6'>
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
确认删除用户组
</h3>
<button
onClick={() => {
setShowDeleteUserGroupModal(false);
setDeletingUserGroup(null);
}}
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
>
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
</svg>
</button>
</div>
<div className='mb-6'>
<div className='bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-4'>
<div className='flex items-center space-x-2 mb-2'>
<svg className='w-5 h-5 text-red-600 dark:text-red-400' 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>
<span className='text-sm font-medium text-red-800 dark:text-red-300'>
危险操作警告
</span>
</div>
<p className='text-sm text-red-700 dark:text-red-400'>
删除用户组 <strong>{deletingUserGroup.name}</strong> 将影响所有使用该组的用户,此操作不可恢复!
</p>
</div>
{deletingUserGroup.affectedUsers.length > 0 ? (
<div className='bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4'>
<div className='flex items-center space-x-2 mb-2'>
<svg className='w-5 h-5 text-yellow-600 dark:text-yellow-400' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z' />
</svg>
<span className='text-sm font-medium text-yellow-800 dark:text-yellow-300'>
⚠️ 将影响 {deletingUserGroup.affectedUsers.length} 个用户:
</span>
</div>
<div className='space-y-1'>
{deletingUserGroup.affectedUsers.map((user, index) => (
<div key={index} className='text-sm text-yellow-700 dark:text-yellow-300'>
• {user.username} ({user.role})
</div>
))}
</div>
<p className='text-xs text-yellow-600 dark:text-yellow-400 mt-2'>
这些用户的用户组将被自动移除
</p>
</div>
) : (
<div className='bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4'>
<div className='flex items-center space-x-2'>
<svg className='w-5 h-5 text-green-600 dark:text-green-400' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M5 13l4 4L19 7' />
</svg>
<span className='text-sm font-medium text-green-800 dark:text-green-300'>
✅ 当前没有用户使用此用户组
</span>
</div>
</div>
)}
</div>
{/* 操作按钮 */}
<div className='flex justify-end space-x-3'>
<button
onClick={() => {
setShowDeleteUserGroupModal(false);
setDeletingUserGroup(null);
}}
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
>
取消
</button>
<button
onClick={handleConfirmDeleteUserGroup}
disabled={isLoading(`userGroup_delete_${deletingUserGroup?.name}`)}
className={`px-6 py-2.5 text-sm font-medium ${isLoading(`userGroup_delete_${deletingUserGroup?.name}`) ? buttonStyles.disabled : buttonStyles.danger}`}
>
{isLoading(`userGroup_delete_${deletingUserGroup?.name}`) ? '删除中...' : '确认删除'}
</button>
</div>
</div>
</div>
</div>,
document.body
)}
{/* 删除用户确认弹窗 */}
{showDeleteUserModal && deletingUser && createPortal(
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4' onClick={() => {
setShowDeleteUserModal(false);
setDeletingUser(null);
}}>
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full' onClick={(e) => e.stopPropagation()}>
<div className='p-6'>
<div className='flex items-center justify-between mb-6'>
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
确认删除用户
</h3>
<button
onClick={() => {
setShowDeleteUserModal(false);
setDeletingUser(null);
}}
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
>
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
</svg>
</button>
</div>
<div className='mb-6'>
<div className='bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-4'>
<div className='flex items-center space-x-2 mb-2'>
<svg className='w-5 h-5 text-red-600 dark:text-red-400' 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>
<span className='text-sm font-medium text-red-800 dark:text-red-300'>
危险操作警告
</span>
</div>
<p className='text-sm text-red-700 dark:text-red-400'>
删除用户 <strong>{deletingUser}</strong> 将同时删除其搜索历史、播放记录和收藏夹,此操作不可恢复!
</p>
</div>
{/* 操作按钮 */}
<div className='flex justify-end space-x-3'>
<button
onClick={() => {
setShowDeleteUserModal(false);
setDeletingUser(null);
}}
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
>
取消
</button>
<button
onClick={handleConfirmDeleteUser}
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.danger}`}
>
确认删除
</button>
</div>
</div>
</div>
</div>
</div>,
document.body
)}
{/* 批量设置用户组弹窗 */}
{showBatchUserGroupModal && createPortal(
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4' onClick={() => {
setShowBatchUserGroupModal(false);
setSelectedUserGroup('');
}}>
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full' onClick={(e) => e.stopPropagation()}>
<div className='p-6'>
<div className='flex items-center justify-between mb-6'>
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
批量设置用户组
</h3>
<button
onClick={() => {
setShowBatchUserGroupModal(false);
setSelectedUserGroup('');
}}
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
>
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
</svg>
</button>
</div>
<div className='mb-6'>
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-4'>
<div className='flex items-center space-x-2 mb-2'>
<svg className='w-5 h-5 text-blue-600 dark:text-blue-400' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z' />
</svg>
<span className='text-sm font-medium text-blue-800 dark:text-blue-300'>
批量操作说明
</span>
</div>
<p className='text-sm text-blue-700 dark:text-blue-400'>
将为选中的 <strong>{selectedUsers.size} 个用户</strong> 设置用户组,选择"无用户组"为无限制
</p>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
选择用户组:
</label>
<select
onChange={(e) => setSelectedUserGroup(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-blue-500 focus:border-transparent transition-colors'
value={selectedUserGroup}
>
<option value=''>无用户组(无限制)</option>
{userGroups.map((group) => (
<option key={group.name} value={group.name}>
{group.name} {group.enabledApis && group.enabledApis.length > 0 ? `(${group.enabledApis.length} 个源)` : ''}
</option>
))}
</select>
<p className='mt-2 text-xs text-gray-500 dark:text-gray-400'>
选择"无用户组"为无限制,选择特定用户组将限制用户只能访问该用户组允许的采集源
</p>
</div>
</div>
{/* 操作按钮 */}
<div className='flex justify-end space-x-3'>
<button
onClick={() => {
setShowBatchUserGroupModal(false);
setSelectedUserGroup('');
}}
className={`px-6 py-2.5 text-sm font-medium ${buttonStyles.secondary}`}
>
取消
</button>
<button
onClick={() => handleBatchSetUserGroup(selectedUserGroup)}
disabled={isLoading('batchSetUserGroup')}
className={`px-6 py-2.5 text-sm font-medium ${isLoading('batchSetUserGroup') ? buttonStyles.disabled : buttonStyles.primary}`}
>
{isLoading('batchSetUserGroup') ? '设置中...' : '确认设置'}
</button>
</div>
</div>
</div>
</div>,
document.body
)}
{/* 通用弹窗组件 */}
<AlertModal
isOpen={alertModal.isOpen}
onClose={hideAlert}
type={alertModal.type}
title={alertModal.title}
message={alertModal.message}
timer={alertModal.timer}
showConfirm={alertModal.showConfirm}
/>
</div>
);
}
// 视频源配置组件
const VideoSourceConfig = ({
config,
refreshConfig,
}: {
config: AdminConfig | null;
refreshConfig: () => Promise<void>;
}) => {
const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
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',
});
// 批量操作相关状态
const [selectedSources, setSelectedSources] = useState<Set<string>>(new Set());
// 使用 useMemo 计算全选状态,避免每次渲染都重新计算
const selectAll = useMemo(() => {
return selectedSources.size === sources.length && selectedSources.size > 0;
}, [selectedSources.size, sources.length]);
// 确认弹窗状态
const [confirmModal, setConfirmModal] = useState<{
isOpen: boolean;
title: string;
message: string;
onConfirm: () => void;
onCancel: () => void;
}>({
isOpen: false,
title: '',
message: '',
onConfirm: () => { },
onCancel: () => { }
});
// 有效性检测相关状态
const [showValidationModal, setShowValidationModal] = useState(false);
const [searchKeyword, setSearchKeyword] = useState('');
const [isValidating, setIsValidating] = useState(false);
const [validationResults, setValidationResults] = useState<Array<{
key: string;
name: string;
status: 'valid' | 'no_results' | 'invalid' | 'validating';
message: string;
resultCount: number;
}>>([]);
// 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);
// 重置选择状态
setSelectedSources(new Set());
}
}, [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 : '操作失败', showAlert);
throw err; // 向上抛出方便调用处判断
}
};
const handleToggleEnable = (key: string) => {
const target = sources.find((s) => s.key === key);
if (!target) return;
const action = target.disabled ? 'enable' : 'disable';
withLoading(`toggleSource_${key}`, () => callSourceApi({ action, key })).catch(() => {
console.error('操作失败', action, key);
});
};
const handleDelete = (key: string) => {
withLoading(`deleteSource_${key}`, () => callSourceApi({ action: 'delete', key })).catch(() => {
console.error('操作失败', 'delete', key);
});
};
const handleAddSource = () => {
if (!newSource.name || !newSource.key || !newSource.api) return;
withLoading('addSource', async () => {
await callSourceApi({
action: 'add',
key: newSource.key,
name: newSource.name,
api: newSource.api,
detail: newSource.detail,
});
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);
withLoading('saveSourceOrder', () => callSourceApi({ action: 'sort', order }))
.then(() => {
setOrderChanged(false);
})
.catch(() => {
console.error('操作失败', 'sort', order);
});
};
// 有效性检测函数
const handleValidateSources = async () => {
if (!searchKeyword.trim()) {
showAlert({ type: 'warning', title: '请输入搜索关键词', message: '搜索关键词不能为空' });
return;
}
await withLoading('validateSources', async () => {
setIsValidating(true);
setValidationResults([]); // 清空之前的结果
setShowValidationModal(false); // 立即关闭弹窗
// 初始化所有视频源为检测中状态
const initialResults = sources.map(source => ({
key: source.key,
name: source.name,
status: 'validating' as const,
message: '检测中...',
resultCount: 0
}));
setValidationResults(initialResults);
try {
// 使用EventSource接收流式数据
const eventSource = new EventSource(`/api/admin/source/validate?q=${encodeURIComponent(searchKeyword.trim())}`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
switch (data.type) {
case 'start':
console.log(`开始检测 ${data.totalSources} 个视频源`);
break;
case 'source_result':
case 'source_error':
// 更新验证结果
setValidationResults(prev => {
const existing = prev.find(r => r.key === data.source);
if (existing) {
return prev.map(r => r.key === data.source ? {
key: data.source,
name: sources.find(s => s.key === data.source)?.name || data.source,
status: data.status,
message: data.status === 'valid' ? '搜索正常' :
data.status === 'no_results' ? '无法搜索到结果' : '连接失败',
resultCount: data.status === 'valid' ? 1 : 0
} : r);
} else {
return [...prev, {
key: data.source,
name: sources.find(s => s.key === data.source)?.name || data.source,
status: data.status,
message: data.status === 'valid' ? '搜索正常' :
data.status === 'no_results' ? '无法搜索到结果' : '连接失败',
resultCount: data.status === 'valid' ? 1 : 0
}];
}
});
break;
case 'complete':
console.log(`检测完成,共检测 ${data.completedSources} 个视频源`);
eventSource.close();
setIsValidating(false);
break;
}
} catch (error) {
console.error('解析EventSource数据失败:', error);
}
};
eventSource.onerror = (error) => {
console.error('EventSource错误:', error);
eventSource.close();
setIsValidating(false);
showAlert({ type: 'error', title: '验证失败', message: '连接错误,请重试' });
};
// 设置超时,防止长时间等待
setTimeout(() => {
if (eventSource.readyState === EventSource.OPEN) {
eventSource.close();
setIsValidating(false);
showAlert({ type: 'warning', title: '验证超时', message: '检测超时,请重试' });
}
}, 60000); // 60秒超时
} catch (error) {
setIsValidating(false);
showAlert({ type: 'error', title: '验证失败', message: error instanceof Error ? error.message : '未知错误' });
throw error;
}
});
};
// 获取有效性状态显示
const getValidationStatus = (sourceKey: string) => {
const result = validationResults.find(r => r.key === sourceKey);
if (!result) return null;
switch (result.status) {
case 'validating':
return {
text: '检测中',
className: 'bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300',
icon: '⟳',
message: result.message
};
case 'valid':
return {
text: '有效',
className: 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300',
icon: '✓',
message: result.message
};
case 'no_results':
return {
text: '无法搜索',
className: 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300',
icon: '⚠',
message: result.message
};
case 'invalid':
return {
text: '无效',
className: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300',
icon: '✗',
message: result.message
};
default:
return null;
}
};
// 可拖拽行封装 (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-2 py-4 text-center'>
<input
type='checkbox'
checked={selectedSources.has(source.key)}
onChange={(e) => handleSelectSource(source.key, e.target.checked)}
className='w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600'
/>
</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 max-w-[1rem]'>
{(() => {
const status = getValidationStatus(source.key);
if (!status) {
return (
<span className='px-2 py-1 text-xs rounded-full bg-gray-100 dark:bg-gray-900/20 text-gray-600 dark:text-gray-400'>
未检测
</span>
);
}
return (
<span className={`px-2 py-1 text-xs rounded-full ${status.className}`} title={status.message}>
{status.icon} {status.text}
</span>
);
})()}
</td>
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
<button
onClick={() => handleToggleEnable(source.key)}
disabled={isLoading(`toggleSource_${source.key}`)}
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium ${!source.disabled
? buttonStyles.roundedDanger
: buttonStyles.roundedSuccess
} transition-colors ${isLoading(`toggleSource_${source.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{!source.disabled ? '禁用' : '启用'}
</button>
{source.from !== 'config' && (
<button
onClick={() => handleDelete(source.key)}
disabled={isLoading(`deleteSource_${source.key}`)}
className={`${buttonStyles.roundedSecondary} ${isLoading(`deleteSource_${source.key}`) ? 'opacity-50 cursor-not-allowed' : ''}`}
>
删除
</button>
)}
</td>
</tr>
);
};
// 全选/取消全选
const handleSelectAll = useCallback((checked: boolean) => {
if (checked) {
const allKeys = sources.map(s => s.key);
setSelectedSources(new Set(allKeys));
} else {
setSelectedSources(new Set());
}
}, [sources]);
// 单个选择
const handleSelectSource = useCallback((key: string, checked: boolean) => {
setSelectedSources(prev => {
const newSelected = new Set(prev);
if (checked) {
newSelected.add(key);
} else {
newSelected.delete(key);
}
return newSelected;
});
}, []);
// 批量操作
const handleBatchOperation = async (action: 'batch_enable' | 'batch_disable' | 'batch_delete') => {
if (selectedSources.size === 0) {
showAlert({ type: 'warning', title: '请先选择要操作的视频源', message: '请选择至少一个视频源' });
return;
}
const keys = Array.from(selectedSources);
let confirmMessage = '';
let actionName = '';
switch (action) {
case 'batch_enable':
confirmMessage = `确定要启用选中的 ${keys.length} 个视频源吗?`;
actionName = '批量启用';
break;
case 'batch_disable':
confirmMessage = `确定要禁用选中的 ${keys.length} 个视频源吗?`;
actionName = '批量禁用';
break;
case 'batch_delete':
confirmMessage = `确定要删除选中的 ${keys.length} 个视频源吗?此操作不可恢复!`;
actionName = '批量删除';
break;
}
// 显示确认弹窗
setConfirmModal({
isOpen: true,
title: '确认操作',
message: confirmMessage,
onConfirm: async () => {
try {
await withLoading(`batchSource_${action}`, () => callSourceApi({ action, keys }));
showAlert({ type: 'success', title: `${actionName}成功`, message: `${actionName}了 ${keys.length} 个视频源`, timer: 2000 });
// 重置选择状态
setSelectedSources(new Set());
} catch (err) {
showAlert({ type: 'error', title: `${actionName}失败`, message: err instanceof Error ? err.message : '操作失败' });
}
setConfirmModal({ isOpen: false, title: '', message: '', onConfirm: () => { }, onCancel: () => { } });
},
onCancel: () => {
setConfirmModal({ isOpen: false, title: '', message: '', onConfirm: () => { }, onCancel: () => { } });
}
});
};
if (!config) {
return (
<div className='text-center text-gray-500 dark:text-gray-400'>
加载中...
</div>
);
}
return (
<div className='space-y-6'>
{/* 添加视频源表单 */}
<div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4'>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
视频源列表
</h4>
<div className='flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-2'>
{/* 批量操作按钮 - 移动端显示在下一行,PC端显示在左侧 */}
{selectedSources.size > 0 && (
<>
<div className='flex flex-wrap items-center gap-3 order-2 sm:order-1'>
<span className='text-sm text-gray-600 dark:text-gray-400'>
<span className='sm:hidden'>已选 {selectedSources.size}</span>
<span className='hidden sm:inline'>已选择 {selectedSources.size} 个视频源</span>
</span>
<button
onClick={() => handleBatchOperation('batch_enable')}
disabled={isLoading('batchSource_batch_enable')}
className={`px-3 py-1 text-sm ${isLoading('batchSource_batch_enable') ? buttonStyles.disabled : buttonStyles.success}`}
>
{isLoading('batchSource_batch_enable') ? '启用中...' : '批量启用'}
</button>
<button
onClick={() => handleBatchOperation('batch_disable')}
disabled={isLoading('batchSource_batch_disable')}
className={`px-3 py-1 text-sm ${isLoading('batchSource_batch_disable') ? buttonStyles.disabled : buttonStyles.warning}`}
>
{isLoading('batchSource_batch_disable') ? '禁用中...' : '批量禁用'}
</button>
<button
onClick={() => handleBatchOperation('batch_delete')}
disabled={isLoading('batchSource_batch_delete')}
className={`px-3 py-1 text-sm ${isLoading('batchSource_batch_delete') ? buttonStyles.disabled : buttonStyles.danger}`}
>
{isLoading('batchSource_batch_delete') ? '删除中...' : '批量删除'}
</button>
</div>
<div className='hidden sm:block w-px h-6 bg-gray-300 dark:bg-gray-600 order-2'></div>
</>
)}
<div className='flex items-center gap-2 order-1 sm:order-2'>
<button
onClick={() => setShowValidationModal(true)}
disabled={isValidating}
className={`px-3 py-1 text-sm rounded-lg transition-colors flex items-center space-x-1 ${isValidating
? buttonStyles.disabled
: buttonStyles.primary
}`}
>
{isValidating ? (
<>
<div className='w-3 h-3 border border-white border-t-transparent rounded-full animate-spin'></div>
<span>检测中...</span>
</>
) : (
'有效性检测'
)}
</button>
<button
onClick={() => setShowAddForm(!showAddForm)}
className={showAddForm ? buttonStyles.secondary : buttonStyles.success}
>
{showAddForm ? '取消' : '添加视频源'}
</button>
</div>
</div>
</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 || isLoading('addSource')}
className={`w-full sm:w-auto px-4 py-2 ${!newSource.name || !newSource.key || !newSource.api || isLoading('addSource') ? buttonStyles.disabled : buttonStyles.success}`}
>
{isLoading('addSource') ? '添加中...' : '添加'}
</button>
</div>
</div>
)}
{/* 视频源表格 */}
<div className='border border-gray-200 dark:border-gray-700 rounded-lg max-h-[28rem] overflow-y-auto overflow-x-auto relative' data-table="source-list">
<table className='min-w-full divide-y divide-gray-200 dark:divide-gray-700'>
<thead className='bg-gray-50 dark:bg-gray-900 sticky top-0 z-10'>
<tr>
<th className='w-8' />
<th className='w-12 px-2 py-3 text-center'>
<input
type='checkbox'
checked={selectAll}
onChange={(e) => handleSelectAll(e.target.checked)}
className='w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600'
/>
</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-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-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}
disabled={isLoading('saveSourceOrder')}
className={`px-3 py-1.5 text-sm ${isLoading('saveSourceOrder') ? buttonStyles.disabled : buttonStyles.primary}`}
>
{isLoading('saveSourceOrder') ? '保存中...' : '保存排序'}
</button>
</div>
)}
{/* 有效性检测弹窗 */}
{showValidationModal && createPortal(
<div className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50' onClick={() => setShowValidationModal(false)}>
<div className='bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-md mx-4' onClick={(e) => e.stopPropagation()}>
<h3 className='text-lg font-medium text-gray-900 dark:text-gray-100 mb-4'>
视频源有效性检测
</h3>
<p className='text-sm text-gray-600 dark:text-gray-400 mb-4'>
请输入检测用的搜索关键词
</p>
<div className='space-y-4'>
<input
type='text'
placeholder='请输入搜索关键词'
value={searchKeyword}
onChange={(e) => setSearchKeyword(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'
onKeyPress={(e) => e.key === 'Enter' && handleValidateSources()}
/>
<div className='flex justify-end space-x-3'>
<button
onClick={() => setShowValidationModal(false)}
className='px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition-colors'
>
取消
</button>
<button
onClick={handleValidateSources}
disabled={!searchKeyword.trim()}
className={`px-4 py-2 ${!searchKeyword.trim() ? buttonStyles.disabled : buttonStyles.primary}`}
>
开始检测
</button>
</div>
</div>
</div>
</div>,
document.body
)}
{/* 通用弹窗组件 */}
<AlertModal
isOpen={alertModal.isOpen}
onClose={hideAlert}
type={alertModal.type}
title={alertModal.title}
message={alertModal.message}
timer={alertModal.timer}
showConfirm={alertModal.showConfirm}
/>
{/* 批量操作确认弹窗 */}
{confirmModal.isOpen && createPortal(
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4' onClick={confirmModal.onCancel}>
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full' onClick={(e) => e.stopPropagation()}>
<div className='p-6'>
<div className='flex items-center justify-between mb-4'>
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
{confirmModal.title}
</h3>
<button
onClick={confirmModal.onCancel}
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
>
<svg className='w-5 h-5' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
</svg>
</button>
</div>
<div className='mb-6'>
<p className='text-sm text-gray-600 dark:text-gray-400'>
{confirmModal.message}
</p>
</div>
{/* 操作按钮 */}
<div className='flex justify-end space-x-3'>
<button
onClick={confirmModal.onCancel}
className={`px-4 py-2 text-sm font-medium ${buttonStyles.secondary}`}
>
取消
</button>
<button
onClick={confirmModal.onConfirm}
disabled={isLoading('batchSource_batch_enable') || isLoading('batchSource_batch_disable') || isLoading('batchSource_batch_delete')}
className={`px-4 py-2 text-sm font-medium ${isLoading('batchSource_batch_enable') || isLoading('batchSource_batch_disable') || isLoading('batchSource_batch_delete') ? buttonStyles.disabled : buttonStyles.primary}`}
>
{isLoading('batchSource_batch_enable') || isLoading('batchSource_batch_disable') || isLoading('batchSource_batch_delete') ? '操作中...' : '确认'}
</button>
</div>
</div>
</div>
</div>,
document.body
)}
</div>
);
};
// 分类配置组件
const CategoryConfig = ({
config,
refreshConfig,
}: {
config: AdminConfig | null;
refreshConfig: () => Promise<void>;
}) => {
const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
const [categories, setCategories] = useState<CustomCategory[]>([]);
const [showAddForm, setShowAddForm] = useState(false);
const [orderChanged, setOrderChanged] = useState(false);
const [newCategory, setNewCategory] = useState<CustomCategory>({
name: '',
type: 'movie',
query: '',
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?.CustomCategories) {
setCategories(config.CustomCategories);
// 进入时重置 orderChanged
setOrderChanged(false);
}
}, [config]);
// 通用 API 请求
const callCategoryApi = async (body: Record<string, any>) => {
try {
const resp = await fetch('/api/admin/category', {
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 : '操作失败', showAlert);
throw err; // 向上抛出方便调用处判断
}
};
const handleToggleEnable = (query: string, type: 'movie' | 'tv') => {
const target = categories.find((c) => c.query === query && c.type === type);
if (!target) return;
const action = target.disabled ? 'enable' : 'disable';
withLoading(`toggleCategory_${query}_${type}`, () => callCategoryApi({ action, query, type })).catch(() => {
console.error('操作失败', action, query, type);
});
};
const handleDelete = (query: string, type: 'movie' | 'tv') => {
withLoading(`deleteCategory_${query}_${type}`, () => callCategoryApi({ action: 'delete', query, type })).catch(() => {
console.error('操作失败', 'delete', query, type);
});
};
const handleAddCategory = () => {
if (!newCategory.name || !newCategory.query) return;
withLoading('addCategory', async () => {
await callCategoryApi({
action: 'add',
name: newCategory.name,
type: newCategory.type,
query: newCategory.query,
});
setNewCategory({
name: '',
type: 'movie',
query: '',
disabled: false,
from: 'custom',
});
setShowAddForm(false);
}).catch(() => {
console.error('操作失败', 'add', newCategory);
});
};
const handleDragEnd = (event: any) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = categories.findIndex(
(c) => `${c.query}:${c.type}` === active.id
);
const newIndex = categories.findIndex(
(c) => `${c.query}:${c.type}` === over.id
);
setCategories((prev) => arrayMove(prev, oldIndex, newIndex));
setOrderChanged(true);
};
const handleSaveOrder = () => {
const order = categories.map((c) => `${c.query}:${c.type}`);
withLoading('saveCategoryOrder', () => callCategoryApi({
gitextract_6m2hxgck/ ├── .dockerignore ├── .eslintrc.js ├── .github/ │ └── workflows/ │ └── docker-image.yml ├── .gitignore ├── .husky/ │ ├── commit-msg │ ├── post-merge │ └── pre-commit ├── .npmrc ├── .nvmrc ├── .prettierignore ├── .prettierrc.js ├── .vscode/ │ ├── css.code-snippets │ ├── extensions.json │ ├── settings.json │ └── typescriptreact.code-snippets ├── CHANGELOG ├── Dockerfile ├── LICENSE ├── README.md ├── VERSION.txt ├── commitlint.config.js ├── docker-compose.dev.yml ├── jest.config.js ├── jest.setup.js ├── next.config.js ├── package.json ├── postcss.config.js ├── proxy.worker.js ├── public/ │ └── robots.txt ├── scripts/ │ ├── convert-changelog.js │ ├── dev-docker.sh │ └── generate-manifest.js ├── src/ │ ├── app/ │ │ ├── admin/ │ │ │ └── page.tsx │ │ ├── api/ │ │ │ ├── admin/ │ │ │ │ ├── category/ │ │ │ │ │ └── route.ts │ │ │ │ ├── config/ │ │ │ │ │ └── route.ts │ │ │ │ ├── config_file/ │ │ │ │ │ └── route.ts │ │ │ │ ├── config_subscription/ │ │ │ │ │ └── fetch/ │ │ │ │ │ └── route.ts │ │ │ │ ├── data_migration/ │ │ │ │ │ ├── export/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── import/ │ │ │ │ │ └── route.ts │ │ │ │ ├── live/ │ │ │ │ │ ├── refresh/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ │ ├── reset/ │ │ │ │ │ └── route.ts │ │ │ │ ├── site/ │ │ │ │ │ └── route.ts │ │ │ │ ├── source/ │ │ │ │ │ ├── route.ts │ │ │ │ │ └── validate/ │ │ │ │ │ └── route.ts │ │ │ │ └── user/ │ │ │ │ └── route.ts │ │ │ ├── change-password/ │ │ │ │ └── route.ts │ │ │ ├── cron/ │ │ │ │ └── route.ts │ │ │ ├── detail/ │ │ │ │ └── route.ts │ │ │ ├── douban/ │ │ │ │ ├── categories/ │ │ │ │ │ └── route.ts │ │ │ │ ├── recommends/ │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ ├── favorites/ │ │ │ │ └── route.ts │ │ │ ├── image-proxy/ │ │ │ │ └── route.ts │ │ │ ├── live/ │ │ │ │ ├── channels/ │ │ │ │ │ └── route.ts │ │ │ │ ├── epg/ │ │ │ │ │ └── route.ts │ │ │ │ ├── precheck/ │ │ │ │ │ └── route.ts │ │ │ │ └── sources/ │ │ │ │ └── route.ts │ │ │ ├── login/ │ │ │ │ └── route.ts │ │ │ ├── logout/ │ │ │ │ └── route.ts │ │ │ ├── playrecords/ │ │ │ │ └── route.ts │ │ │ ├── proxy/ │ │ │ │ ├── key/ │ │ │ │ │ └── route.ts │ │ │ │ ├── logo/ │ │ │ │ │ └── route.ts │ │ │ │ ├── m3u8/ │ │ │ │ │ └── route.ts │ │ │ │ └── segment/ │ │ │ │ └── route.ts │ │ │ ├── search/ │ │ │ │ ├── one/ │ │ │ │ │ └── route.ts │ │ │ │ ├── resources/ │ │ │ │ │ └── route.ts │ │ │ │ ├── route.ts │ │ │ │ ├── suggestions/ │ │ │ │ │ └── route.ts │ │ │ │ └── ws/ │ │ │ │ └── route.ts │ │ │ ├── searchhistory/ │ │ │ │ └── route.ts │ │ │ ├── server-config/ │ │ │ │ └── route.ts │ │ │ └── skipconfigs/ │ │ │ └── route.ts │ │ ├── douban/ │ │ │ └── page.tsx │ │ ├── globals.css │ │ ├── layout.tsx │ │ ├── live/ │ │ │ └── page.tsx │ │ ├── login/ │ │ │ └── page.tsx │ │ ├── page.tsx │ │ ├── play/ │ │ │ └── page.tsx │ │ ├── search/ │ │ │ └── page.tsx │ │ └── warning/ │ │ └── page.tsx │ ├── components/ │ │ ├── BackButton.tsx │ │ ├── CapsuleSwitch.tsx │ │ ├── ContinueWatching.tsx │ │ ├── DataMigration.tsx │ │ ├── DoubanCardSkeleton.tsx │ │ ├── DoubanCustomSelector.tsx │ │ ├── DoubanSelector.tsx │ │ ├── EpgScrollableRow.tsx │ │ ├── EpisodeSelector.tsx │ │ ├── GlobalErrorIndicator.tsx │ │ ├── ImagePlaceholder.tsx │ │ ├── MobileActionSheet.tsx │ │ ├── MobileBottomNav.tsx │ │ ├── MobileHeader.tsx │ │ ├── MultiLevelSelector.tsx │ │ ├── PageLayout.tsx │ │ ├── ScrollableRow.tsx │ │ ├── SearchResultFilter.tsx │ │ ├── SearchSuggestions.tsx │ │ ├── Sidebar.tsx │ │ ├── SiteProvider.tsx │ │ ├── ThemeProvider.tsx │ │ ├── ThemeToggle.tsx │ │ ├── UserMenu.tsx │ │ ├── VersionPanel.tsx │ │ ├── VideoCard.tsx │ │ ├── VirtualGrid.tsx │ │ └── WeekdaySelector.tsx │ ├── hooks/ │ │ └── useLongPress.ts │ ├── lib/ │ │ ├── admin.types.ts │ │ ├── auth.ts │ │ ├── bangumi.client.ts │ │ ├── changelog.ts │ │ ├── config.ts │ │ ├── crypto.ts │ │ ├── db.client.ts │ │ ├── db.ts │ │ ├── douban.client.ts │ │ ├── douban.ts │ │ ├── downstream.ts │ │ ├── fetchVideoDetail.ts │ │ ├── kvrocks.db.ts │ │ ├── live.ts │ │ ├── password.ts │ │ ├── redis-base.db.ts │ │ ├── redis.db.ts │ │ ├── search-cache.ts │ │ ├── time.ts │ │ ├── types.ts │ │ ├── upstash.db.ts │ │ ├── utils.ts │ │ ├── version.ts │ │ ├── version_check.ts │ │ └── yellow.ts │ ├── middleware.ts │ └── styles/ │ ├── colors.css │ └── globals.css ├── start.js ├── tailwind.config.ts ├── tsconfig.json └── vercel.json
SYMBOL INDEX (461 symbols across 105 files)
FILE: next.config.js
method webpack (line 32) | webpack(config) {
FILE: proxy.worker.js
function handleRequest (line 7) | async function handleRequest(request) {
function ensureProtocol (line 87) | function ensureProtocol(url, defaultProtocol) {
function handleRedirect (line 94) | function handleRedirect(response, body) {
function handleHtmlContent (line 108) | async function handleHtmlContent(response, protocol, host, actualUrlStr) {
function replaceRelativePaths (line 122) | function replaceRelativePaths(text, protocol, host, origin) {
function jsonResponse (line 128) | function jsonResponse(data, status) {
function filterHeaders (line 138) | function filterHeaders(headers, filterFunc) {
function setNoCacheHeaders (line 143) | function setNoCacheHeaders(headers) {
function setCorsHeaders (line 148) | function setCorsHeaders(headers) {
function getRootHtml (line 155) | function getRootHtml() {
FILE: scripts/convert-changelog.js
function parseChangelog (line 8) | function parseChangelog(content) {
function generateTypeScript (line 89) | function generateTypeScript(changelogData) {
function updateVersionFile (line 137) | function updateVersionFile(version) {
function updateVersionTs (line 148) | function updateVersionTs(version) {
function main (line 167) | function main() {
FILE: src/app/admin/page.tsx
type AlertModalProps (line 93) | interface AlertModalProps {
type LoadingState (line 232) | interface LoadingState {
type SiteConfig (line 259) | interface SiteConfig {
type DataSource (line 274) | interface DataSource {
type LiveDataSource (line 284) | interface LiveDataSource {
type CustomCategory (line 296) | interface CustomCategory {
type CollapsibleTabProps (line 305) | interface CollapsibleTabProps {
type UserConfigProps (line 343) | interface UserConfigProps {
type UserInfo (line 1089) | type UserInfo = (typeof config.UserConfig.Users)[number];
function AdminPageClient (line 4529) | function AdminPageClient() {
function AdminPage (line 4836) | function AdminPage() {
FILE: src/app/api/admin/category/route.ts
type Action (line 12) | type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort';
type BaseBody (line 14) | interface BaseBody {
function POST (line 18) | async function POST(request: NextRequest) {
FILE: src/app/api/admin/config/route.ts
function GET (line 11) | async function GET(request: NextRequest) {
FILE: src/app/api/admin/config_file/route.ts
function POST (line 11) | async function POST(request: NextRequest) {
FILE: src/app/api/admin/config_subscription/fetch/route.ts
function POST (line 9) | async function POST(request: NextRequest) {
FILE: src/app/api/admin/data_migration/export/route.ts
function POST (line 16) | async function POST(req: NextRequest) {
function getUserPassword (line 122) | async function getUserPassword(username: string): Promise<string | null> {
FILE: src/app/api/admin/data_migration/import/route.ts
function POST (line 16) | async function POST(req: NextRequest) {
FILE: src/app/api/admin/live/refresh/route.ts
function POST (line 12) | async function POST(request: NextRequest) {
FILE: src/app/api/admin/live/route.ts
function POST (line 12) | async function POST(request: NextRequest) {
FILE: src/app/api/admin/reset/route.ts
function GET (line 10) | async function GET(request: NextRequest) {
FILE: src/app/api/admin/site/route.ts
function POST (line 11) | async function POST(request: NextRequest) {
FILE: src/app/api/admin/source/route.ts
type Action (line 12) | type Action = 'add' | 'disable' | 'enable' | 'delete' | 'sort' | 'batch_...
type BaseBody (line 14) | interface BaseBody {
function POST (line 18) | async function POST(request: NextRequest) {
FILE: src/app/api/admin/source/validate/route.ts
function GET (line 11) | async function GET(request: NextRequest) {
FILE: src/app/api/admin/user/route.ts
constant ACTIONS (line 12) | const ACTIONS = [
function POST (line 26) | async function POST(request: NextRequest) {
FILE: src/app/api/change-password/route.ts
function POST (line 10) | async function POST(request: NextRequest) {
FILE: src/app/api/cron/route.ts
function GET (line 13) | async function GET(request: NextRequest) {
function cronJob (line 40) | async function cronJob() {
function refreshAllLiveChannels (line 46) | async function refreshAllLiveChannels() {
function refreshConfig (line 69) | async function refreshConfig() {
function refreshRecordAndFavorites (line 109) | async function refreshRecordAndFavorites() {
FILE: src/app/api/detail/route.ts
function GET (line 9) | async function GET(request: NextRequest) {
FILE: src/app/api/douban/categories/route.ts
type DoubanCategoryApiResponse (line 7) | interface DoubanCategoryApiResponse {
function GET (line 25) | async function GET(request: Request) {
FILE: src/app/api/douban/recommends/route.ts
type DoubanRecommendApiResponse (line 9) | interface DoubanRecommendApiResponse {
function GET (line 28) | async function GET(request: NextRequest) {
FILE: src/app/api/douban/route.ts
type DoubanApiResponse (line 7) | interface DoubanApiResponse {
function GET (line 18) | async function GET(request: Request) {
function handleTop250 (line 98) | function handleTop250(pageStart: number) {
FILE: src/app/api/favorites/route.ts
function GET (line 19) | async function GET(request: NextRequest) {
function POST (line 73) | async function POST(request: NextRequest) {
function DELETE (line 144) | async function DELETE(request: NextRequest) {
FILE: src/app/api/image-proxy/route.ts
function GET (line 6) | async function GET(request: Request) {
FILE: src/app/api/live/channels/route.ts
function GET (line 7) | async function GET(request: NextRequest) {
FILE: src/app/api/live/epg/route.ts
function GET (line 7) | async function GET(request: NextRequest) {
FILE: src/app/api/live/precheck/route.ts
function GET (line 9) | async function GET(request: NextRequest) {
FILE: src/app/api/live/sources/route.ts
function GET (line 9) | async function GET(request: NextRequest) {
FILE: src/app/api/login/route.ts
constant STORAGE_TYPE (line 10) | const STORAGE_TYPE =
function generateSignature (line 19) | async function generateSignature(
function generateAuthCookie (line 46) | async function generateAuthCookie(
function POST (line 70) | async function POST(req: NextRequest) {
FILE: src/app/api/logout/route.ts
function POST (line 5) | async function POST() {
FILE: src/app/api/playrecords/route.ts
function GET (line 12) | async function GET(request: NextRequest) {
function POST (line 45) | async function POST(request: NextRequest) {
function DELETE (line 111) | async function DELETE(request: NextRequest) {
FILE: src/app/api/proxy/key/route.ts
function GET (line 9) | async function GET(request: Request) {
FILE: src/app/api/proxy/logo/route.ts
function GET (line 9) | async function GET(request: Request) {
FILE: src/app/api/proxy/m3u8/route.ts
function GET (line 10) | async function GET(request: Request) {
function rewriteM3U8Content (line 97) | function rewriteM3U8Content(content: string, baseUrl: string, req: Reque...
function rewriteMapUri (line 161) | function rewriteMapUri(line: string, baseUrl: string, proxyBase: string) {
function rewriteKeyUri (line 172) | function rewriteKeyUri(line: string, baseUrl: string, proxyBase: string) {
FILE: src/app/api/proxy/segment/route.ts
function GET (line 9) | async function GET(request: Request) {
FILE: src/app/api/search/one/route.ts
function GET (line 11) | async function GET(request: NextRequest) {
FILE: src/app/api/search/resources/route.ts
function GET (line 11) | async function GET(request: NextRequest) {
FILE: src/app/api/search/route.ts
function GET (line 12) | async function GET(request: NextRequest) {
FILE: src/app/api/search/suggestions/route.ts
function GET (line 13) | async function GET(request: NextRequest) {
function generateSuggestions (line 52) | async function generateSuggestions(config: AdminConfig, query: string, u...
FILE: src/app/api/search/ws/route.ts
function GET (line 12) | async function GET(request: NextRequest) {
FILE: src/app/api/searchhistory/route.ts
constant HISTORY_LIMIT (line 12) | const HISTORY_LIMIT = 20;
function GET (line 18) | async function GET(request: NextRequest) {
function POST (line 55) | async function POST(request: NextRequest) {
function DELETE (line 107) | async function DELETE(request: NextRequest) {
FILE: src/app/api/server-config/route.ts
function GET (line 10) | async function GET(request: NextRequest) {
FILE: src/app/api/skipconfigs/route.ts
function GET (line 12) | async function GET(request: NextRequest) {
function POST (line 55) | async function POST(request: NextRequest) {
function DELETE (line 108) | async function DELETE(request: NextRequest) {
FILE: src/app/douban/page.tsx
function DoubanPageClient (line 24) | function DoubanPageClient() {
function DoubanPage (line 824) | function DoubanPage() {
FILE: src/app/layout.tsx
function generateMetadata (line 18) | async function generateMetadata(): Promise<Metadata> {
function RootLayout (line 37) | async function RootLayout({
FILE: src/app/live/page.tsx
type HTMLVideoElement (line 25) | interface HTMLVideoElement {
type LiveChannel (line 31) | interface LiveChannel {
type LiveSource (line 41) | interface LiveSource {
function LivePageClient (line 52) | function LivePageClient() {
function LivePage (line 1606) | function LivePage() {
function LivePageGuard (line 1614) | function LivePageGuard() {
FILE: src/app/login/page.tsx
function VersionDisplay (line 16) | function VersionDisplay() {
function LoginPageClient (line 70) | function LoginPageClient() {
function LoginPage (line 189) | function LoginPage() {
FILE: src/app/page.tsx
function HomeClient (line 30) | function HomeClient() {
function Home (line 517) | function Home() {
FILE: src/app/play/page.tsx
type HTMLVideoElement (line 32) | interface HTMLVideoElement {
type WakeLockSentinel (line 38) | interface WakeLockSentinel {
function PlayPageClient (line 45) | function PlayPageClient() {
function PlayPage (line 2092) | function PlayPage() {
FILE: src/app/search/page.tsx
function SearchPageClient (line 23) | function SearchPageClient() {
function SearchPage (line 904) | function SearchPage() {
FILE: src/app/warning/page.tsx
function WarningPage (line 8) | function WarningPage() {
FILE: src/components/BackButton.tsx
function BackButton (line 3) | function BackButton() {
FILE: src/components/CapsuleSwitch.tsx
type CapsuleSwitchProps (line 5) | interface CapsuleSwitchProps {
FILE: src/components/ContinueWatching.tsx
type ContinueWatchingProps (line 16) | interface ContinueWatchingProps {
function ContinueWatching (line 20) | function ContinueWatching({ className }: ContinueWatchingProps) {
FILE: src/components/DataMigration.tsx
type DataMigrationProps (line 8) | interface DataMigrationProps {
type AlertModalProps (line 12) | interface AlertModalProps {
FILE: src/components/DoubanCustomSelector.tsx
type CustomCategory (line 7) | interface CustomCategory {
type DoubanCustomSelectorProps (line 13) | interface DoubanCustomSelectorProps {
FILE: src/components/DoubanSelector.tsx
type SelectorOption (line 10) | interface SelectorOption {
type DoubanSelectorProps (line 15) | interface DoubanSelectorProps {
FILE: src/components/EpgScrollableRow.tsx
type EpgProgram (line 8) | interface EpgProgram {
type EpgScrollableRowProps (line 14) | interface EpgScrollableRowProps {
function EpgScrollableRow (line 20) | function EpgScrollableRow({
FILE: src/components/EpisodeSelector.tsx
type VideoInfo (line 16) | interface VideoInfo {
type EpisodeSelectorProps (line 23) | interface EpisodeSelectorProps {
FILE: src/components/GlobalErrorIndicator.tsx
type ErrorInfo (line 5) | interface ErrorInfo {
function GlobalErrorIndicator (line 11) | function GlobalErrorIndicator() {
function triggerGlobalError (line 97) | function triggerGlobalError(message: string) {
FILE: src/components/MobileActionSheet.tsx
type ActionItem (line 5) | interface ActionItem {
type MobileActionSheetProps (line 14) | interface MobileActionSheetProps {
FILE: src/components/MobileBottomNav.tsx
type MobileBottomNavProps (line 10) | interface MobileBottomNavProps {
FILE: src/components/MobileHeader.tsx
type MobileHeaderProps (line 10) | interface MobileHeaderProps {
FILE: src/components/MultiLevelSelector.tsx
type MultiLevelOption (line 6) | interface MultiLevelOption {
type MultiLevelCategory (line 11) | interface MultiLevelCategory {
type MultiLevelSelectorProps (line 18) | interface MultiLevelSelectorProps {
FILE: src/components/PageLayout.tsx
type PageLayoutProps (line 8) | interface PageLayoutProps {
FILE: src/components/ScrollableRow.tsx
type ScrollableRowProps (line 4) | interface ScrollableRowProps {
function ScrollableRow (line 9) | function ScrollableRow({
FILE: src/components/SearchResultFilter.tsx
type SearchFilterKey (line 7) | type SearchFilterKey = 'source' | 'title' | 'year' | 'yearOrder';
type SearchFilterOption (line 9) | interface SearchFilterOption {
type SearchFilterCategory (line 14) | interface SearchFilterCategory {
type SearchResultFilterProps (line 20) | interface SearchResultFilterProps {
constant DEFAULTS (line 26) | const DEFAULTS: Record<SearchFilterKey, string> = {
FILE: src/components/SearchSuggestions.tsx
type SearchSuggestionsProps (line 5) | interface SearchSuggestionsProps {
type SuggestionItem (line 13) | interface SuggestionItem {
function SearchSuggestions (line 19) | function SearchSuggestions({
FILE: src/components/Sidebar.tsx
type SidebarContextType (line 19) | interface SidebarContextType {
type SidebarProps (line 44) | interface SidebarProps {
type Window (line 51) | interface Window {
FILE: src/components/SiteProvider.tsx
function SiteProvider (line 14) | function SiteProvider({
FILE: src/components/ThemeProvider.tsx
function ThemeProvider (line 7) | function ThemeProvider({ children, ...props }: ThemeProviderProps) {
FILE: src/components/ThemeToggle.tsx
function ThemeToggle (line 10) | function ThemeToggle() {
FILE: src/components/UserMenu.tsx
type AuthInfo (line 26) | interface AuthInfo {
FILE: src/components/VersionPanel.tsx
type VersionPanelProps (line 22) | interface VersionPanelProps {
type RemoteChangelogEntry (line 27) | interface RemoteChangelogEntry {
FILE: src/components/VideoCard.tsx
type VideoCardProps (line 30) | interface VideoCardProps {
type VideoCardHandle (line 52) | type VideoCardHandle = {
FILE: src/components/VirtualGrid.tsx
type VirtualGridProps (line 6) | interface VirtualGridProps<T> {
function VirtualGrid (line 25) | function VirtualGrid<T>({
FILE: src/components/WeekdaySelector.tsx
type WeekdaySelectorProps (line 7) | interface WeekdaySelectorProps {
FILE: src/hooks/useLongPress.ts
type UseLongPressOptions (line 3) | interface UseLongPressOptions {
type TouchPosition (line 10) | interface TouchPosition {
FILE: src/lib/admin.types.ts
type AdminConfig (line 1) | interface AdminConfig {
type AdminConfigResult (line 61) | interface AdminConfigResult {
FILE: src/lib/auth.ts
function getAuthInfoFromCookie (line 4) | function getAuthInfoFromCookie(request: NextRequest): {
function getAuthInfoFromBrowserCookie (line 26) | function getAuthInfoFromBrowserCookie(): {
FILE: src/lib/bangumi.client.ts
type BangumiCalendarData (line 3) | interface BangumiCalendarData {
function GetBangumiCalendarData (line 25) | async function GetBangumiCalendarData(): Promise<BangumiCalendarData[]> {
FILE: src/lib/changelog.ts
type ChangelogEntry (line 4) | interface ChangelogEntry {
FILE: src/lib/config.ts
type ApiSite (line 7) | interface ApiSite {
type LiveCfg (line 14) | interface LiveCfg {
type ConfigFileStruct (line 21) | interface ConfigFileStruct {
constant API_CONFIG (line 36) | const API_CONFIG = {
function refineConfig (line 61) | function refineConfig(adminConfig: AdminConfig): AdminConfig {
function getInitConfig (line 185) | async function getInitConfig(configFile: string, subConfig: {
function getConfig (line 293) | async function getConfig(): Promise<AdminConfig> {
function configSelfCheck (line 317) | function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
function resetConfig (line 398) | async function resetConfig() {
function getCacheTime (line 415) | async function getCacheTime(): Promise<number> {
function getAvailableApiSites (line 420) | async function getAvailableApiSites(user?: string): Promise<ApiSite[]> {
function setCachedConfig (line 470) | async function setCachedConfig(config: AdminConfig) {
FILE: src/lib/crypto.ts
class SimpleCrypto (line 7) | class SimpleCrypto {
method encrypt (line 14) | static encrypt(data: string, password: string): string {
method decrypt (line 29) | static decrypt(encryptedData: string, password: string): string {
method canDecrypt (line 50) | static canDecrypt(encryptedData: string, password: string): boolean {
FILE: src/lib/db.client.ts
function triggerGlobalError (line 21) | function triggerGlobalError(message: string) {
type PlayRecord (line 32) | interface PlayRecord {
type Favorite (line 46) | interface Favorite {
type CacheData (line 58) | interface CacheData<T> {
type UserCacheStore (line 64) | interface UserCacheStore {
constant PLAY_RECORDS_KEY (line 72) | const PLAY_RECORDS_KEY = 'moontv_play_records';
constant FAVORITES_KEY (line 73) | const FAVORITES_KEY = 'moontv_favorites';
constant SEARCH_HISTORY_KEY (line 74) | const SEARCH_HISTORY_KEY = 'moontv_search_history';
constant CACHE_PREFIX (line 77) | const CACHE_PREFIX = 'moontv_cache_';
constant CACHE_VERSION (line 78) | const CACHE_VERSION = '1.0.0';
constant CACHE_EXPIRE_TIME (line 79) | const CACHE_EXPIRE_TIME = 60 * 60 * 1000;
constant STORAGE_TYPE (line 82) | const STORAGE_TYPE = (() => {
constant SEARCH_HISTORY_LIMIT (line 97) | const SEARCH_HISTORY_LIMIT = 20;
class HybridCacheManager (line 100) | class HybridCacheManager {
method getInstance (line 103) | static getInstance(): HybridCacheManager {
method getCurrentUsername (line 113) | private getCurrentUsername(): string | null {
method getUserCacheKey (line 121) | private getUserCacheKey(username: string): string {
method getUserCache (line 128) | private getUserCache(username: string): UserCacheStore {
method saveUserCache (line 144) | private saveUserCache(username: string, cache: UserCacheStore): void {
method cleanOldCache (line 178) | private cleanOldCache(cache: UserCacheStore): void {
method clearAllCache (line 196) | private clearAllCache(): void {
method isCacheValid (line 208) | private isCacheValid<T>(cache: CacheData<T>): boolean {
method createCacheData (line 219) | private createCacheData<T>(data: T): CacheData<T> {
method getCachedPlayRecords (line 230) | getCachedPlayRecords(): Record<string, PlayRecord> | null {
method cachePlayRecords (line 247) | cachePlayRecords(data: Record<string, PlayRecord>): void {
method getCachedFavorites (line 259) | getCachedFavorites(): Record<string, Favorite> | null {
method cacheFavorites (line 276) | cacheFavorites(data: Record<string, Favorite>): void {
method getCachedSearchHistory (line 288) | getCachedSearchHistory(): string[] | null {
method cacheSearchHistory (line 305) | cacheSearchHistory(data: string[]): void {
method getCachedSkipConfigs (line 317) | getCachedSkipConfigs(): Record<string, SkipConfig> | null {
method cacheSkipConfigs (line 334) | cacheSkipConfigs(data: Record<string, SkipConfig>): void {
method clearUserCache (line 346) | clearUserCache(username?: string): void {
method clearExpiredCaches (line 361) | clearExpiredCaches(): void {
function handleDatabaseOperationFailure (line 405) | async function handleDatabaseOperationFailure(
function fetchWithAuth (line 459) | async function fetchWithAuth(
function fetchFromApi (line 487) | async function fetchFromApi<T>(path: string): Promise<T> {
function generateStorageKey (line 495) | function generateStorageKey(source: string, id: string): string {
function getAllPlayRecords (line 505) | async function getAllPlayRecords(): Promise<Record<string, PlayRecord>> {
function savePlayRecord (line 569) | async function savePlayRecord(
function deletePlayRecord (line 633) | async function deletePlayRecord(
function getSearchHistory (line 694) | async function getSearchHistory(): Promise<string[]> {
function addSearchHistory (line 758) | async function addSearchHistory(keyword: string): Promise<void> {
function clearSearchHistory (line 821) | async function clearSearchHistory(): Promise<void> {
function deleteSearchHistory (line 859) | async function deleteSearchHistory(keyword: string): Promise<void> {
function getAllFavorites (line 915) | async function getAllFavorites(): Promise<Record<string, Favorite>> {
function saveFavorite (line 979) | async function saveFavorite(
function deleteFavorite (line 1043) | async function deleteFavorite(
function isFavorited (line 1102) | async function isFavorited(
function clearAllPlayRecords (line 1158) | async function clearAllPlayRecords(): Promise<void> {
function clearAllFavorites (line 1199) | async function clearAllFavorites(): Promise<void> {
function clearUserCache (line 1242) | function clearUserCache(): void {
function refreshAllCache (line 1252) | async function refreshAllCache(): Promise<void> {
function getCacheStatus (line 1310) | function getCacheStatus(): {
type CacheUpdateEvent (line 1339) | type CacheUpdateEvent =
function subscribeToDataUpdates (line 1356) | function subscribeToDataUpdates<T>(
function preloadUserData (line 1379) | async function preloadUserData(): Promise<void> {
function getSkipConfig (line 1406) | async function getSkipConfig(
function saveSkipConfig (line 1475) | async function saveSkipConfig(
function getAllSkipConfigs (line 1539) | async function getAllSkipConfigs(): Promise<Record<string, SkipConfig>> {
function deleteSkipConfig (line 1603) | async function deleteSkipConfig(
FILE: src/lib/db.ts
constant STORAGE_TYPE (line 10) | const STORAGE_TYPE =
function createStorage (line 19) | function createStorage(): IStorage {
function getStorage (line 36) | function getStorage(): IStorage {
function generateStorageKey (line 44) | function generateStorageKey(source: string, id: string): string {
class DbManager (line 49) | class DbManager {
method constructor (line 53) | constructor() {
method ensureMigrated (line 69) | private async ensureMigrated(): Promise<void> {
method getPlayRecord (line 77) | async getPlayRecord(
method savePlayRecord (line 86) | async savePlayRecord(
method getAllPlayRecords (line 96) | async getAllPlayRecords(userName: string): Promise<{
method deletePlayRecord (line 103) | async deletePlayRecord(
method deleteAllPlayRecords (line 112) | async deleteAllPlayRecords(userName: string): Promise<void> {
method getFavorite (line 117) | async getFavorite(
method saveFavorite (line 126) | async saveFavorite(
method getAllFavorites (line 136) | async getAllFavorites(
method deleteFavorite (line 143) | async deleteFavorite(
method deleteAllFavorites (line 152) | async deleteAllFavorites(userName: string): Promise<void> {
method isFavorited (line 156) | async isFavorited(
method registerUser (line 166) | async registerUser(userName: string, password: string): Promise<void> {
method verifyUser (line 170) | async verifyUser(userName: string, password: string): Promise<boolean> {
method checkUserExist (line 175) | async checkUserExist(userName: string): Promise<boolean> {
method changePassword (line 179) | async changePassword(userName: string, newPassword: string): Promise<v...
method deleteUser (line 183) | async deleteUser(userName: string): Promise<void> {
method getSearchHistory (line 188) | async getSearchHistory(userName: string): Promise<string[]> {
method addSearchHistory (line 192) | async addSearchHistory(userName: string, keyword: string): Promise<voi...
method deleteSearchHistory (line 196) | async deleteSearchHistory(userName: string, keyword?: string): Promise...
method getAllUsers (line 201) | async getAllUsers(): Promise<string[]> {
method getAdminConfig (line 209) | async getAdminConfig(): Promise<AdminConfig | null> {
method saveAdminConfig (line 216) | async saveAdminConfig(config: AdminConfig): Promise<void> {
method getSkipConfig (line 223) | async getSkipConfig(
method setSkipConfig (line 234) | async setSkipConfig(
method deleteSkipConfig (line 245) | async deleteSkipConfig(
method getAllSkipConfigs (line 255) | async getAllSkipConfigs(
method clearAllData (line 265) | async clearAllData(): Promise<void> {
FILE: src/lib/douban.client.ts
type DoubanCategoriesParams (line 5) | interface DoubanCategoriesParams {
type DoubanCategoryApiResponse (line 13) | interface DoubanCategoryApiResponse {
type DoubanListApiResponse (line 29) | interface DoubanListApiResponse {
type DoubanRecommendApiResponse (line 40) | interface DoubanRecommendApiResponse {
function fetchWithTimeout (line 60) | async function fetchWithTimeout(
function getDoubanProxyConfig (line 95) | function getDoubanProxyConfig(): {
function fetchDoubanCategories (line 122) | async function fetchDoubanCategories(
function getDoubanCategories (line 195) | async function getDoubanCategories(
type DoubanListParams (line 221) | interface DoubanListParams {
function getDoubanList (line 228) | async function getDoubanList(
function fetchDoubanList (line 254) | async function fetchDoubanList(
type DoubanRecommendsParams (line 324) | interface DoubanRecommendsParams {
function getDoubanRecommends (line 337) | async function getDoubanRecommends(
function fetchDoubanRecommends (line 374) | async function fetchDoubanRecommends(
FILE: src/lib/douban.ts
function fetchDoubanData (line 6) | async function fetchDoubanData<T>(url: string): Promise<T> {
FILE: src/lib/downstream.ts
type ApiSearchItem (line 8) | interface ApiSearchItem {
function searchWithCache (line 24) | async function searchWithCache(
function searchFromApi (line 138) | async function searchFromApi(
constant M3U8_PATTERN (line 198) | const M3U8_PATTERN = /(https?:\/\/[^"'\s]+?\.m3u8)/g;
function getDetailFromApi (line 200) | async function getDetailFromApi(
function handleSpecialSourceDetail (line 289) | async function handleSpecialSourceDetail(
FILE: src/lib/fetchVideoDetail.ts
type FetchVideoDetailOptions (line 6) | interface FetchVideoDetailOptions {
function fetchVideoDetail (line 17) | async function fetchVideoDetail({
FILE: src/lib/kvrocks.db.ts
class KvrocksStorage (line 5) | class KvrocksStorage extends BaseRedisStorage {
method constructor (line 6) | constructor() {
FILE: src/lib/live.ts
type LiveChannels (line 8) | interface LiveChannels {
function deleteCachedLiveChannels (line 30) | function deleteCachedLiveChannels(key: string) {
function getCachedLiveChannels (line 34) | async function getCachedLiveChannels(key: string): Promise<LiveChannels ...
function refreshLiveChannels (line 51) | async function refreshLiveChannels(liveInfo: {
function parseEpg (line 83) | async function parseEpg(epgUrl: string, ua: string, tvgIds: string[]): P...
function parseM3U (line 190) | function parseM3U(sourceKey: string, m3uContent: string): {
function resolveUrl (line 277) | function resolveUrl(baseUrl: string, relativePath: string) {
function fallbackUrlResolve (line 300) | function fallbackUrlResolve(baseUrl: string, relativePath: string) {
function getBaseUrl (line 335) | function getBaseUrl(m3u8Url: string) {
FILE: src/lib/password.ts
constant SALT_LENGTH (line 3) | const SALT_LENGTH = 16;
constant KEY_LENGTH (line 4) | const KEY_LENGTH = 64;
constant SCRYPT_COST (line 5) | const SCRYPT_COST = 16384;
constant BLOCK_SIZE (line 6) | const BLOCK_SIZE = 8;
constant PARALLELIZATION (line 7) | const PARALLELIZATION = 1;
function hashPassword (line 12) | function hashPassword(password: string): string {
function verifyPassword (line 28) | function verifyPassword(
function isHashed (line 56) | function isHashed(storedValue: string): boolean {
FILE: src/lib/redis-base.db.ts
constant SEARCH_HISTORY_LIMIT (line 10) | const SEARCH_HISTORY_LIMIT = 20;
function ensureString (line 13) | function ensureString(value: any): string {
function ensureStringArray (line 17) | function ensureStringArray(value: any[]): string[] {
type RedisConnectionConfig (line 22) | interface RedisConnectionConfig {
function createRetryWrapper (line 28) | function createRetryWrapper(clientName: string, getClient: () => RedisCl...
function createRedisClient (line 76) | function createRedisClient(config: RedisConnectionConfig, globalSymbol: ...
method constructor (line 149) | constructor(config: RedisConnectionConfig, globalSymbol: symbol) {
method prHashKey (line 155) | private prHashKey(user: string) {
method getPlayRecord (line 159) | async getPlayRecord(
method setPlayRecord (line 169) | async setPlayRecord(
method getAllPlayRecords (line 179) | async getAllPlayRecords(
method deletePlayRecord (line 194) | async deletePlayRecord(userName: string, key: string): Promise<void> {
method deleteAllPlayRecords (line 200) | async deleteAllPlayRecords(userName: string): Promise<void> {
method favHashKey (line 205) | private favHashKey(user: string) {
method getFavorite (line 209) | async getFavorite(userName: string, key: string): Promise<Favorite | nul...
method setFavorite (line 216) | async setFavorite(
method getAllFavorites (line 226) | async getAllFavorites(userName: string): Promise<Record<string, Favorite...
method deleteFavorite (line 239) | async deleteFavorite(userName: string, key: string): Promise<void> {
method deleteAllFavorites (line 245) | async deleteAllFavorites(userName: string): Promise<void> {
method userPwdKey (line 250) | private userPwdKey(user: string) {
method registerUser (line 254) | async registerUser(userName: string, password: string): Promise<void> {
method verifyUser (line 261) | async verifyUser(userName: string, password: string): Promise<boolean> {
method checkUserExist (line 277) | async checkUserExist(userName: string): Promise<boolean> {
method changePassword (line 286) | async changePassword(userName: string, newPassword: string): Promise<voi...
method deleteUser (line 294) | async deleteUser(userName: string): Promise<void> {
method shKey (line 315) | private shKey(user: string) {
method getSearchHistory (line 319) | async getSearchHistory(userName: string): Promise<string[]> {
method addSearchHistory (line 327) | async addSearchHistory(userName: string, keyword: string): Promise<void> {
method deleteSearchHistory (line 337) | async deleteSearchHistory(userName: string, keyword?: string): Promise<v...
method usersSetKey (line 347) | private usersSetKey() {
method getAllUsers (line 351) | async getAllUsers(): Promise<string[]> {
method adminConfigKey (line 357) | private adminConfigKey() {
method getAdminConfig (line 361) | async getAdminConfig(): Promise<AdminConfig | null> {
method setAdminConfig (line 366) | async setAdminConfig(config: AdminConfig): Promise<void> {
method skipHashKey (line 373) | private skipHashKey(user: string) {
method skipField (line 377) | private skipField(source: string, id: string) {
method getSkipConfig (line 381) | async getSkipConfig(
method setSkipConfig (line 392) | async setSkipConfig(
method deleteSkipConfig (line 407) | async deleteSkipConfig(
method getAllSkipConfigs (line 417) | async getAllSkipConfigs(
method migrationKey (line 433) | private migrationKey() {
method migrateData (line 437) | async migrateData(): Promise<void> {
method pwdMigrationKey (line 545) | private pwdMigrationKey() {
method migratePasswords (line 549) | async migratePasswords(): Promise<void> {
method clearAllData (line 579) | async clearAllData(): Promise<void> {
FILE: src/lib/redis.db.ts
class RedisStorage (line 5) | class RedisStorage extends BaseRedisStorage {
method constructor (line 6) | constructor() {
FILE: src/lib/search-cache.ts
type CachedPageStatus (line 4) | type CachedPageStatus = 'ok' | 'timeout' | 'forbidden';
type CachedPageEntry (line 7) | interface CachedPageEntry {
constant SEARCH_CACHE_TTL_MS (line 15) | const SEARCH_CACHE_TTL_MS = 10 * 60 * 1000;
constant CACHE_CLEANUP_INTERVAL_MS (line 16) | const CACHE_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
constant MAX_CACHE_SIZE (line 17) | const MAX_CACHE_SIZE = 1000;
constant SEARCH_CACHE (line 18) | const SEARCH_CACHE: Map<string, CachedPageEntry> = new Map();
function makeSearchCacheKey (line 27) | function makeSearchCacheKey(sourceKey: string, query: string, page: numb...
function getCachedSearchPage (line 34) | function getCachedSearchPage(
function setCachedSearchPage (line 55) | function setCachedSearchPage(
function ensureAutoCleanupStarted (line 84) | function ensureAutoCleanupStarted(): void {
function performCacheCleanup (line 93) | function performCacheCleanup(): { expired: number; total: number; sizeLi...
function startAutoCleanup (line 133) | function startAutoCleanup(): void {
FILE: src/lib/time.ts
function parseCustomTimeFormat (line 5) | function parseCustomTimeFormat(timeStr: string): Date {
function formatTimeToHHMM (line 30) | function formatTimeToHHMM(timeString: string): string {
function isValidTime (line 49) | function isValidTime(timeString: string): boolean {
FILE: src/lib/types.ts
type PlayRecord (line 4) | interface PlayRecord {
type Favorite (line 18) | interface Favorite {
type IStorage (line 30) | interface IStorage {
type SearchResult (line 97) | interface SearchResult {
type DoubanItem (line 113) | interface DoubanItem {
type DoubanResult (line 121) | interface DoubanResult {
type SkipConfig (line 128) | interface SkipConfig {
FILE: src/lib/upstash.db.ts
constant SEARCH_HISTORY_LIMIT (line 10) | const SEARCH_HISTORY_LIMIT = 20;
function ensureString (line 13) | function ensureString(value: any): string {
function ensureStringArray (line 17) | function ensureStringArray(value: any[]): string[] {
function withRetry (line 22) | async function withRetry<T>(
class UpstashRedisStorage (line 57) | class UpstashRedisStorage implements IStorage {
method constructor (line 60) | constructor() {
method prHashKey (line 65) | private prHashKey(user: string) {
method getPlayRecord (line 69) | async getPlayRecord(
method setPlayRecord (line 79) | async setPlayRecord(
method getAllPlayRecords (line 89) | async getAllPlayRecords(
method deletePlayRecord (line 105) | async deletePlayRecord(userName: string, key: string): Promise<void> {
method deleteAllPlayRecords (line 109) | async deleteAllPlayRecords(userName: string): Promise<void> {
method favHashKey (line 114) | private favHashKey(user: string) {
method getFavorite (line 118) | async getFavorite(userName: string, key: string): Promise<Favorite | n...
method setFavorite (line 125) | async setFavorite(
method getAllFavorites (line 135) | async getAllFavorites(userName: string): Promise<Record<string, Favori...
method deleteFavorite (line 149) | async deleteFavorite(userName: string, key: string): Promise<void> {
method deleteAllFavorites (line 153) | async deleteAllFavorites(userName: string): Promise<void> {
method userPwdKey (line 158) | private userPwdKey(user: string) {
method registerUser (line 162) | async registerUser(userName: string, password: string): Promise<void> {
method verifyUser (line 169) | async verifyUser(userName: string, password: string): Promise<boolean> {
method checkUserExist (line 185) | async checkUserExist(userName: string): Promise<boolean> {
method changePassword (line 194) | async changePassword(userName: string, newPassword: string): Promise<v...
method deleteUser (line 202) | async deleteUser(userName: string): Promise<void> {
method shKey (line 223) | private shKey(user: string) {
method getSearchHistory (line 227) | async getSearchHistory(userName: string): Promise<string[]> {
method addSearchHistory (line 235) | async addSearchHistory(userName: string, keyword: string): Promise<voi...
method deleteSearchHistory (line 245) | async deleteSearchHistory(userName: string, keyword?: string): Promise...
method usersSetKey (line 255) | private usersSetKey() {
method getAllUsers (line 259) | async getAllUsers(): Promise<string[]> {
method adminConfigKey (line 265) | private adminConfigKey() {
method getAdminConfig (line 269) | async getAdminConfig(): Promise<AdminConfig | null> {
method setAdminConfig (line 274) | async setAdminConfig(config: AdminConfig): Promise<void> {
method skipHashKey (line 279) | private skipHashKey(user: string) {
method skipField (line 283) | private skipField(source: string, id: string) {
method getSkipConfig (line 287) | async getSkipConfig(
method setSkipConfig (line 298) | async setSkipConfig(
method deleteSkipConfig (line 311) | async deleteSkipConfig(
method getAllSkipConfigs (line 321) | async getAllSkipConfigs(
method migrationKey (line 338) | private migrationKey() {
method migrateData (line 342) | async migrateData(): Promise<void> {
method pwdMigrationKey (line 450) | private pwdMigrationKey() {
method migratePasswords (line 454) | async migratePasswords(): Promise<void> {
method clearAllData (line 484) | async clearAllData(): Promise<void> {
function getUpstashRedisClient (line 506) | function getUpstashRedisClient(): Redis {
FILE: src/lib/utils.ts
function getDoubanImageProxyConfig (line 5) | function getDoubanImageProxyConfig(): {
function processImageUrl (line 34) | function processImageUrl(originalUrl: string): string {
function getVideoResolutionFromM3u8 (line 68) | async function getVideoResolutionFromM3u8(m3u8Url: string): Promise<{
function cleanHtmlTags (line 219) | function cleanHtmlTags(text: string): string {
FILE: src/lib/version.ts
constant CURRENT_VERSION (line 3) | const CURRENT_VERSION = '100.1.2';
FILE: src/lib/version_check.ts
type UpdateStatus (line 8) | enum UpdateStatus {
constant VERSION_CHECK_URLS (line 15) | const VERSION_CHECK_URLS = [
function checkForUpdates (line 23) | async function checkForUpdates(): Promise<UpdateStatus> {
function fetchVersionFromUrl (line 50) | async function fetchVersionFromUrl(url: string): Promise<string | null> {
function compareVersions (line 88) | function compareVersions(remoteVersion: string): UpdateStatus {
FILE: src/middleware.ts
function middleware (line 7) | async function middleware(request: NextRequest) {
function verifySignature (line 63) | async function verifySignature(
function handleAuthFailure (line 101) | function handleAuthFailure(
function shouldSkipAuth (line 119) | function shouldSkipAuth(pathname: string): boolean {
FILE: start.js
function generateManifest (line 8) | function generateManifest() {
constant TARGET_URL (line 30) | const TARGET_URL = `http://${process.env.HOSTNAME || 'localhost'}:${proc...
function executeCronJob (line 60) | function executeCronJob() {
Condensed preview — 143 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (1,151K chars).
[
{
"path": ".dockerignore",
"chars": 16,
"preview": ".env\n.env*.local"
},
{
"path": ".eslintrc.js",
"chars": 2149,
"preview": "module.exports = {\n env: {\n browser: true,\n es2021: true,\n node: true,\n },\n plugins: ['@typescript-eslint', "
},
{
"path": ".github/workflows/docker-image.yml",
"chars": 4684,
"preview": "name: Build & Push Docker image\n\non:\n workflow_dispatch:\n inputs:\n tag:\n description: 'Docker 标签'\n "
},
{
"path": ".gitignore",
"chars": 490,
"preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pn"
},
{
"path": ".husky/commit-msg",
"chars": 82,
"preview": "#!/bin/sh\n. \"$(dirname \"$0\")/_/husky.sh\"\n\nnpx --no-install commitlint --edit \"$1\"\n"
},
{
"path": ".husky/post-merge",
"chars": 55,
"preview": "#!/bin/sh\n. \"$(dirname \"$0\")/_/husky.sh\"\n\npnpm install\n"
},
{
"path": ".husky/pre-commit",
"chars": 57,
"preview": "#!/bin/sh\n. \"$(dirname \"$0\")/_/husky.sh\"\n\nnpx lint-staged"
},
{
"path": ".npmrc",
"chars": 0,
"preview": ""
},
{
"path": ".nvmrc",
"chars": 9,
"preview": "v20.10.0\n"
},
{
"path": ".prettierignore",
"chars": 451,
"preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pn"
},
{
"path": ".prettierrc.js",
"chars": 121,
"preview": "module.exports = {\n arrowParens: 'always',\n singleQuote: true,\n jsxSingleQuote: true,\n tabWidth: 2,\n semi: true,\n};"
},
{
"path": ".vscode/css.code-snippets",
"chars": 191,
"preview": "{\n \"Region CSS\": {\n \"prefix\": \"regc\",\n \"body\": [\n \"/* #region /**=========== ${1} =========== */\",\n \"$"
},
{
"path": ".vscode/extensions.json",
"chars": 190,
"preview": "{\n \"recommendations\": [\n // Tailwind CSS Intellisense\n \"bradlc.vscode-tailwindcss\",\n \"esbenp.prettier-vscode\","
},
{
"path": ".vscode/settings.json",
"chars": 464,
"preview": "{\n \"css.validate\": false,\n \"editor.formatOnSave\": true,\n \"editor.tabSize\": 2,\n \"editor.codeActionsOnSave\": {\n \"so"
},
{
"path": ".vscode/typescriptreact.code-snippets",
"chars": 5279,
"preview": "{\n //#region //*=========== React ===========\n \"import React\": {\n \"prefix\": \"ir\",\n \"body\": [\"import * as React "
},
{
"path": "CHANGELOG",
"chars": 4763,
"preview": "## [100.1.2] - 2026-03-15\r\n\r\n### Changed\r\n\r\n- 移除豆瓣图片代理中的「直连」和「豆瓣官方精品 CDN」选项,历史数据自动兼容为服务器代理\r\n\r\n## [100.1.1] - 2026-02-27\r"
},
{
"path": "Dockerfile",
"chars": 1347,
"preview": "# ---- 第 1 阶段:安装依赖 ----\nFROM node:20-alpine AS deps\n\n# 启用 corepack 并激活 pnpm(Node20 默认提供 corepack)\nRUN corepack enable &&"
},
{
"path": "LICENSE",
"chars": 20844,
"preview": "Attribution-NonCommercial-ShareAlike 4.0 International\n\n================================================================"
},
{
"path": "README.md",
"chars": 14223,
"preview": "# MoonTV\n\n<div align=\"center\">\n <img src=\"public/logo.png\" alt=\"MoonTV Logo\" width=\"120\">\n</div>\n\n> 🎬 **MoonTV** 是一个开箱即"
},
{
"path": "VERSION.txt",
"chars": 7,
"preview": "100.1.2"
},
{
"path": "commitlint.config.js",
"chars": 435,
"preview": "module.exports = {\n extends: ['@commitlint/config-conventional'],\n rules: {\n // TODO Add Scope Enum Here\n // '"
},
{
"path": "docker-compose.dev.yml",
"chars": 788,
"preview": "version: '3.8'\n\nservices:\n redis:\n image: redis:7-alpine\n container_name: lunatv-redis\n volumes:\n - redis"
},
{
"path": "jest.config.js",
"chars": 1029,
"preview": "// eslint-disable-next-line @typescript-eslint/no-var-requires\nconst nextJest = require('next/jest');\n\nconst createJestC"
},
{
"path": "jest.setup.js",
"chars": 172,
"preview": "import '@testing-library/jest-dom/extend-expect';\n\n// Allow router mocks.\n// eslint-disable-next-line no-undef\njest.mock"
},
{
"path": "next.config.js",
"chars": 1763,
"preview": "/** @type {import('next').NextConfig} */\n/* eslint-disable @typescript-eslint/no-var-requires */\n\nconst nextConfig = {\n "
},
{
"path": "package.json",
"chars": 2893,
"preview": "{\n \"name\": \"moontv\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"scripts\": {\n \"dev\": \"pnpm gen:manifest && next dev "
},
{
"path": "postcss.config.js",
"chars": 83,
"preview": "module.exports = {\n plugins: {\n tailwindcss: {},\n autoprefixer: {},\n },\n};\n"
},
{
"path": "proxy.worker.js",
"chars": 7432,
"preview": "/* eslint-disable */\n\naddEventListener('fetch', (event) => {\n event.respondWith(handleRequest(event.request));\n});\n\nasy"
},
{
"path": "public/robots.txt",
"chars": 39,
"preview": "# 禁止所有搜索引擎爬取\nUser-agent: *\nDisallow: /\n"
},
{
"path": "scripts/convert-changelog.js",
"chars": 6023,
"preview": "#!/usr/bin / env node\n\n/* eslint-disable */\n\nconst fs = require('fs');\nconst path = require('path');\n\nfunction parseChan"
},
{
"path": "scripts/dev-docker.sh",
"chars": 908,
"preview": "#!/bin/bash\n# 本地构建并启动 Docker 镜像 + Redis\n# 用法: ./scripts/dev-docker.sh [up|down|rebuild|logs]\n\nset -e\n\nCOMPOSE_FILE=\"dock"
},
{
"path": "scripts/generate-manifest.js",
"chars": 1501,
"preview": "#!/usr/bin/env node\n/* eslint-disable */\n// 根据 NEXT_PUBLIC_SITE_NAME 动态生成 manifest.json\n\nconst fs = require('fs');\nconst"
},
{
"path": "src/app/admin/page.tsx",
"chars": 188074,
"preview": "/* eslint-disable @typescript-eslint/no-explicit-any, no-console, @typescript-eslint/no-non-null-assertion,react-hooks/e"
},
{
"path": "src/app/api/admin/category/route.ts",
"chars": 5628,
"preview": "/* eslint-disable @typescript-eslint/no-explicit-any,no-console */\n\nimport { NextRequest, NextResponse } from 'next/serv"
},
{
"path": "src/app/api/admin/config/route.ts",
"chars": 1632,
"preview": "/* eslint-disable no-console */\n\nimport { NextRequest, NextResponse } from 'next/server';\n\nimport { AdminConfigResult } "
},
{
"path": "src/app/api/admin/config_file/route.ts",
"chars": 2417,
"preview": "/* eslint-disable no-console,@typescript-eslint/no-explicit-any */\n\nimport { NextRequest, NextResponse } from 'next/serv"
},
{
"path": "src/app/api/admin/config_subscription/fetch/route.ts",
"chars": 1656,
"preview": "/* eslint-disable no-console */\n\nimport { NextRequest, NextResponse } from 'next/server';\n\nimport { getAuthInfoFromCooki"
},
{
"path": "src/app/api/admin/data_migration/export/route.ts",
"chars": 4078,
"preview": "/* eslint-disable @typescript-eslint/no-explicit-any,no-console */\n\nimport { NextRequest, NextResponse } from 'next/serv"
},
{
"path": "src/app/api/admin/data_migration/import/route.ts",
"chars": 4275,
"preview": "/* eslint-disable @typescript-eslint/no-explicit-any,no-console */\n\nimport { NextRequest, NextResponse } from 'next/serv"
},
{
"path": "src/app/api/admin/live/refresh/route.ts",
"chars": 1541,
"preview": "/* eslint-disable no-console */\n\nimport { NextRequest, NextResponse } from 'next/server';\n\nimport { getAuthInfoFromCooki"
},
{
"path": "src/app/api/admin/live/route.ts",
"chars": 4955,
"preview": "/* eslint-disable no-console,no-case-declarations */\n\nimport { NextRequest, NextResponse } from 'next/server';\n\nimport {"
},
{
"path": "src/app/api/admin/reset/route.ts",
"chars": 1193,
"preview": "/* eslint-disable no-console */\n\nimport { NextRequest, NextResponse } from 'next/server';\n\nimport { getAuthInfoFromCooki"
},
{
"path": "src/app/api/admin/site/route.ts",
"chars": 3161,
"preview": "/* eslint-disable @typescript-eslint/no-explicit-any,no-console */\n\nimport { NextRequest, NextResponse } from 'next/serv"
},
{
"path": "src/app/api/admin/source/route.ts",
"chars": 7759,
"preview": "/* eslint-disable @typescript-eslint/no-explicit-any,no-console */\n\nimport { NextRequest, NextResponse } from 'next/serv"
},
{
"path": "src/app/api/admin/source/validate/route.ts",
"chars": 5351,
"preview": "/* eslint-disable @typescript-eslint/no-explicit-any,no-console */\n\nimport { NextRequest, NextResponse } from 'next/serv"
},
{
"path": "src/app/api/admin/user/route.ts",
"chars": 13363,
"preview": "/* eslint-disable @typescript-eslint/no-explicit-any,no-console,@typescript-eslint/no-non-null-assertion */\n\nimport { Ne"
},
{
"path": "src/app/api/change-password/route.ts",
"chars": 1498,
"preview": "/* eslint-disable no-console*/\n\nimport { NextRequest, NextResponse } from 'next/server';\n\nimport { getAuthInfoFromCookie"
},
{
"path": "src/app/api/cron/route.ts",
"chars": 8651,
"preview": "/* eslint-disable no-console,@typescript-eslint/no-explicit-any */\n\nimport { NextRequest, NextResponse } from 'next/serv"
},
{
"path": "src/app/api/detail/route.ts",
"chars": 1621,
"preview": "import { NextRequest, NextResponse } from 'next/server';\n\nimport { getAuthInfoFromCookie } from '@/lib/auth';\nimport { g"
},
{
"path": "src/app/api/douban/categories/route.ts",
"chars": 2629,
"preview": "import { NextResponse } from 'next/server';\n\nimport { getCacheTime } from '@/lib/config';\nimport { fetchDoubanData } fro"
},
{
"path": "src/app/api/douban/recommends/route.ts",
"chars": 3675,
"preview": "/* eslint-disable @typescript-eslint/no-explicit-any,no-console */\n\nimport { NextRequest, NextResponse } from 'next/serv"
},
{
"path": "src/app/api/douban/route.ts",
"chars": 4808,
"preview": "import { NextResponse } from 'next/server';\n\nimport { getCacheTime } from '@/lib/config';\nimport { fetchDoubanData } fro"
},
{
"path": "src/app/api/favorites/route.ts",
"chars": 5182,
"preview": "/* eslint-disable no-console */\n\nimport { NextRequest, NextResponse } from 'next/server';\n\nimport { getAuthInfoFromCooki"
},
{
"path": "src/app/api/image-proxy/route.ts",
"chars": 1652,
"preview": "import { NextResponse } from 'next/server';\n\nexport const runtime = 'nodejs';\n\n// OrionTV 兼容接口\nexport async function GET"
},
{
"path": "src/app/api/live/channels/route.ts",
"chars": 779,
"preview": "import { NextRequest, NextResponse } from 'next/server';\n\nimport { getCachedLiveChannels } from '@/lib/live';\n\nexport co"
},
{
"path": "src/app/api/live/epg/route.ts",
"chars": 1241,
"preview": "import { NextRequest, NextResponse } from 'next/server';\n\nimport { getCachedLiveChannels } from '@/lib/live';\n\nexport co"
},
{
"path": "src/app/api/live/precheck/route.ts",
"chars": 1720,
"preview": "/* eslint-disable @typescript-eslint/no-explicit-any */\n\nimport { NextRequest, NextResponse } from 'next/server';\n\nimpor"
},
{
"path": "src/app/api/live/sources/route.ts",
"chars": 737,
"preview": "/* eslint-disable no-console */\n\nimport { NextRequest, NextResponse } from 'next/server';\n\nimport { getConfig } from '@/"
},
{
"path": "src/app/api/login/route.ts",
"chars": 5594,
"preview": "/* eslint-disable no-console,@typescript-eslint/no-explicit-any */\nimport { NextRequest, NextResponse } from 'next/serve"
},
{
"path": "src/app/api/logout/route.ts",
"chars": 392,
"preview": "import { NextResponse } from 'next/server';\n\nexport const runtime = 'nodejs';\n\nexport async function POST() {\n const re"
},
{
"path": "src/app/api/playrecords/route.ts",
"chars": 4476,
"preview": "/* eslint-disable no-console */\n\nimport { NextRequest, NextResponse } from 'next/server';\n\nimport { getAuthInfoFromCooki"
},
{
"path": "src/app/api/proxy/key/route.ts",
"chars": 1471,
"preview": "/* eslint-disable no-console,@typescript-eslint/no-explicit-any */\n\nimport { NextResponse } from \"next/server\";\n\nimport "
},
{
"path": "src/app/api/proxy/logo/route.ts",
"chars": 1733,
"preview": "/* eslint-disable @typescript-eslint/no-explicit-any */\n\nimport { NextResponse } from 'next/server';\n\nimport { getConfig"
},
{
"path": "src/app/api/proxy/m3u8/route.ts",
"chars": 5917,
"preview": "/* eslint-disable no-console,@typescript-eslint/no-explicit-any */\n\nimport { NextResponse } from \"next/server\";\n\nimport "
},
{
"path": "src/app/api/proxy/segment/route.ts",
"chars": 3617,
"preview": "/* eslint-disable no-console,@typescript-eslint/no-explicit-any */\n\nimport { NextResponse } from \"next/server\";\n\nimport "
},
{
"path": "src/app/api/search/one/route.ts",
"chars": 2687,
"preview": "import { NextRequest, NextResponse } from 'next/server';\n\nimport { getAuthInfoFromCookie } from '@/lib/auth';\nimport { g"
},
{
"path": "src/app/api/search/resources/route.ts",
"chars": 678,
"preview": "/* eslint-disable no-console */\n\nimport { NextRequest, NextResponse } from 'next/server';\n\nimport { getAuthInfoFromCooki"
},
{
"path": "src/app/api/search/route.ts",
"chars": 2781,
"preview": "/* eslint-disable @typescript-eslint/no-explicit-any,no-console */\n\nimport { NextRequest, NextResponse } from 'next/serv"
},
{
"path": "src/app/api/search/suggestions/route.ts",
"chars": 3755,
"preview": "/* eslint-disable @typescript-eslint/no-explicit-any,no-console */\n\nimport { NextRequest, NextResponse } from 'next/serv"
},
{
"path": "src/app/api/search/ws/route.ts",
"chars": 5239,
"preview": "/* eslint-disable @typescript-eslint/no-explicit-any,no-console */\n\nimport { NextRequest, NextResponse } from 'next/serv"
},
{
"path": "src/app/api/searchhistory/route.ts",
"chars": 3911,
"preview": "/* eslint-disable no-console */\n\nimport { NextRequest, NextResponse } from 'next/server';\n\nimport { getAuthInfoFromCooki"
},
{
"path": "src/app/api/server-config/route.ts",
"chars": 564,
"preview": "/* eslint-disable no-console */\n\nimport { NextRequest, NextResponse } from 'next/server';\n\nimport { getConfig } from '@/"
},
{
"path": "src/app/api/skipconfigs/route.ts",
"chars": 4353,
"preview": "/* eslint-disable no-console */\n\nimport { NextRequest, NextResponse } from 'next/server';\n\nimport { getAuthInfoFromCooki"
},
{
"path": "src/app/douban/page.tsx",
"chars": 24901,
"preview": "/* eslint-disable no-console,react-hooks/exhaustive-deps,@typescript-eslint/no-explicit-any */\n\n'use client';\n\nimport { "
},
{
"path": "src/app/globals.css",
"chars": 3196,
"preview": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n@layer utilities {\n .scrollbar-hide {\n -ms-overflow-styl"
},
{
"path": "src/app/layout.tsx",
"chars": 4355,
"preview": "/* eslint-disable @typescript-eslint/no-explicit-any */\n\nimport type { Metadata, Viewport } from 'next';\nimport { Inter "
},
{
"path": "src/app/live/page.tsx",
"chars": 59076,
"preview": "/* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps, no"
},
{
"path": "src/app/login/page.tsx",
"chars": 6699,
"preview": "/* eslint-disable @typescript-eslint/no-explicit-any */\n\n'use client';\n\nimport { AlertCircle, CheckCircle } from 'lucide"
},
{
"path": "src/app/page.tsx",
"chars": 19707,
"preview": "/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps, no-console */\n\n'use client';\n\nimport "
},
{
"path": "src/app/play/page.tsx",
"chars": 68591,
"preview": "/* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps, no"
},
{
"path": "src/app/search/page.tsx",
"chars": 33044,
"preview": "/* eslint-disable react-hooks/exhaustive-deps, @typescript-eslint/no-explicit-any,@typescript-eslint/no-non-null-asserti"
},
{
"path": "src/app/warning/page.tsx",
"chars": 3682,
"preview": "import { Metadata } from 'next';\n\nexport const metadata: Metadata = {\n title: '安全警告 - MoonTV',\n description: '站点安全配置警告"
},
{
"path": "src/components/BackButton.tsx",
"chars": 414,
"preview": "import { ArrowLeft } from 'lucide-react';\n\nexport function BackButton() {\n return (\n <button\n onClick={() => wi"
},
{
"path": "src/components/CapsuleSwitch.tsx",
"chars": 2920,
"preview": "/* eslint-disable react-hooks/exhaustive-deps */\n\nimport React, { useEffect, useRef, useState } from 'react';\n\ninterface"
},
{
"path": "src/components/ContinueWatching.tsx",
"chars": 4616,
"preview": "/* eslint-disable no-console */\n'use client';\n\nimport { useEffect, useState } from 'react';\n\nimport type { PlayRecord } "
},
{
"path": "src/components/DataMigration.tsx",
"chars": 17338,
"preview": "/* eslint-disable @typescript-eslint/no-explicit-any */\n'use client';\n\nimport { AlertCircle, AlertTriangle, CheckCircle,"
},
{
"path": "src/components/DoubanCardSkeleton.tsx",
"chars": 685,
"preview": "import { ImagePlaceholder } from '@/components/ImagePlaceholder';\n\nconst DoubanCardSkeleton = () => {\n return (\n <di"
},
{
"path": "src/components/DoubanCustomSelector.tsx",
"chars": 9820,
"preview": "/* eslint-disable react-hooks/exhaustive-deps */\n\n'use client';\n\nimport React, { useEffect, useRef, useState } from 'rea"
},
{
"path": "src/components/DoubanSelector.tsx",
"chars": 18603,
"preview": "/* eslint-disable react-hooks/exhaustive-deps */\n\n'use client';\n\nimport React, { useEffect, useRef, useState } from 'rea"
},
{
"path": "src/components/EpgScrollableRow.tsx",
"chars": 10493,
"preview": "/* eslint-disable react-hooks/exhaustive-deps */\n\nimport { Clock, Target, Tv } from 'lucide-react';\nimport { useEffect, "
},
{
"path": "src/components/EpisodeSelector.tsx",
"chars": 25446,
"preview": "/* eslint-disable @next/next/no-img-element */\n\nimport { useRouter } from 'next/navigation';\nimport React, {\n useCallba"
},
{
"path": "src/components/GlobalErrorIndicator.tsx",
"chars": 2598,
"preview": "'use client';\n\nimport { useEffect, useState } from 'react';\n\ninterface ErrorInfo {\n id: string;\n message: string;\n ti"
},
{
"path": "src/components/ImagePlaceholder.tsx",
"chars": 998,
"preview": "// 图片占位符组件 - 实现骨架屏效果(支持暗色模式)\nconst ImagePlaceholder = ({ aspectRatio }: { aspectRatio: string }) => (\n <div\n classNa"
},
{
"path": "src/components/MobileActionSheet.tsx",
"chars": 10998,
"preview": "import { Radio, X } from 'lucide-react';\nimport Image from 'next/image';\nimport React, { useEffect, useState } from 'rea"
},
{
"path": "src/components/MobileBottomNav.tsx",
"chars": 3349,
"preview": "/* eslint-disable @typescript-eslint/no-explicit-any */\n\n'use client';\n\nimport { Cat, Clover, Film, Home, Radio, Star, T"
},
{
"path": "src/components/MobileHeader.tsx",
"chars": 2010,
"preview": "'use client';\n\nimport Link from 'next/link';\n\nimport { BackButton } from './BackButton';\nimport { useSite } from './Site"
},
{
"path": "src/components/MultiLevelSelector.tsx",
"chars": 19245,
"preview": "'use client';\n\nimport React, { useEffect, useRef, useState } from 'react';\nimport { createPortal } from 'react-dom';\n\nin"
},
{
"path": "src/components/PageLayout.tsx",
"chars": 1793,
"preview": "import { BackButton } from './BackButton';\nimport MobileBottomNav from './MobileBottomNav';\nimport MobileHeader from './"
},
{
"path": "src/components/ScrollableRow.tsx",
"chars": 4981,
"preview": "import { ChevronLeft, ChevronRight } from 'lucide-react';\nimport { useEffect, useRef, useState } from 'react';\n\ninterfac"
},
{
"path": "src/components/SearchResultFilter.tsx",
"chars": 9713,
"preview": "'use client';\n\nimport { ArrowDownWideNarrow, ArrowUpDown,ArrowUpNarrowWide } from 'lucide-react';\nimport React, { useEff"
},
{
"path": "src/components/SearchSuggestions.tsx",
"chars": 4371,
"preview": "'use client';\n\nimport { useCallback, useEffect, useRef, useState } from 'react';\n\ninterface SearchSuggestionsProps {\n q"
},
{
"path": "src/components/Sidebar.tsx",
"chars": 10891,
"preview": "/* eslint-disable @typescript-eslint/no-explicit-any */\n\n'use client';\n\nimport { Cat, Clover, Film, Home, Menu, Radio, S"
},
{
"path": "src/components/SiteProvider.tsx",
"chars": 609,
"preview": "'use client';\n\nimport { createContext, ReactNode, useContext } from 'react';\n\nconst SiteContext = createContext<{ siteNa"
},
{
"path": "src/components/ThemeProvider.tsx",
"chars": 425,
"preview": "'use client';\n\nimport type { ThemeProviderProps } from 'next-themes';\nimport { ThemeProvider as NextThemesProvider } fro"
},
{
"path": "src/components/ThemeToggle.tsx",
"chars": 1943,
"preview": "/* eslint-disable @typescript-eslint/no-explicit-any,react-hooks/exhaustive-deps */\n\n'use client';\n\nimport { Moon, Sun }"
},
{
"path": "src/components/UserMenu.tsx",
"chars": 42702,
"preview": "/* eslint-disable no-console,@typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */\n\n'use clien"
},
{
"path": "src/components/VersionPanel.tsx",
"chars": 21807,
"preview": "/* eslint-disable no-console,react-hooks/exhaustive-deps */\n\n'use client';\n\nimport {\n Bug,\n CheckCircle,\n ChevronDown"
},
{
"path": "src/components/VideoCard.tsx",
"chars": 36220,
"preview": "/* eslint-disable @typescript-eslint/no-explicit-any,react-hooks/exhaustive-deps,@typescript-eslint/no-empty-function */"
},
{
"path": "src/components/VirtualGrid.tsx",
"chars": 3355,
"preview": "'use client';\n\nimport { useVirtualizer } from '@tanstack/react-virtual';\nimport React, { useCallback, useEffect, useRef,"
},
{
"path": "src/components/WeekdaySelector.tsx",
"chars": 2169,
"preview": "/* eslint-disable react-hooks/exhaustive-deps */\n\n'use client';\n\nimport React, { useEffect, useState } from 'react';\n\nin"
},
{
"path": "src/hooks/useLongPress.ts",
"chars": 3412,
"preview": "import { useCallback, useRef } from 'react';\n\ninterface UseLongPressOptions {\n onLongPress: () => void;\n onClick?: () "
},
{
"path": "src/lib/admin.types.ts",
"chars": 1386,
"preview": "export interface AdminConfig {\n ConfigSubscribtion: {\n URL: string;\n AutoUpdate: boolean;\n LastCheck: string;\n"
},
{
"path": "src/lib/auth.ts",
"chars": 1631,
"preview": "import { NextRequest } from 'next/server';\n\n// 从cookie获取认证信息 (服务端使用)\nexport function getAuthInfoFromCookie(request: Next"
},
{
"path": "src/lib/bangumi.client.ts",
"chars": 713,
"preview": "'use client';\n\nexport interface BangumiCalendarData {\n weekday: {\n en: string;\n };\n items: {\n id: number;\n n"
},
{
"path": "src/lib/changelog.ts",
"chars": 8626,
"preview": "// 此文件由 scripts/convert-changelog.js 自动生成\n// 请勿手动编辑\n\nexport interface ChangelogEntry {\n version: string;\n date: string"
},
{
"path": "src/lib/config.ts",
"chars": 12867,
"preview": "/* eslint-disable @typescript-eslint/no-explicit-any, no-console, @typescript-eslint/no-non-null-assertion */\n\nimport { "
},
{
"path": "src/lib/crypto.ts",
"chars": 1239,
"preview": "import CryptoJS from 'crypto-js';\n\n/**\n * 简单的对称加密工具\n * 使用 AES 加密算法\n */\nexport class SimpleCrypto {\n /**\n * 加密数据\n * "
},
{
"path": "src/lib/db.client.ts",
"chars": 40805,
"preview": "/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-function */\n'use client';\n"
},
{
"path": "src/lib/db.ts",
"chars": 7419,
"preview": "/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */\n\nimport { "
},
{
"path": "src/lib/douban.client.ts",
"chars": 12555,
"preview": "/* eslint-disable @typescript-eslint/no-explicit-any,no-console,no-case-declarations */\n\nimport { DoubanItem, DoubanResu"
},
{
"path": "src/lib/douban.ts",
"chars": 953,
"preview": "/**\n * 通用的豆瓣数据获取函数\n * @param url 请求的URL\n * @returns Promise<T> 返回指定类型的数据\n */\nexport async function fetchDoubanData<T>(ur"
},
{
"path": "src/lib/downstream.ts",
"chars": 10168,
"preview": "/* eslint-disable @typescript-eslint/no-explicit-any */\n\nimport { API_CONFIG, ApiSite, getConfig } from '@/lib/config';\n"
},
{
"path": "src/lib/fetchVideoDetail.ts",
"chars": 1274,
"preview": "import { getAvailableApiSites } from '@/lib/config';\nimport { SearchResult } from '@/lib/types';\n\nimport { getDetailFrom"
},
{
"path": "src/lib/kvrocks.db.ts",
"chars": 434,
"preview": "/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */\n\nimport { "
},
{
"path": "src/lib/live.ts",
"chars": 9468,
"preview": "/* eslint-disable no-constant-condition */\n\nimport { getConfig } from \"@/lib/config\";\nimport { db } from \"@/lib/db\";\n\nco"
},
{
"path": "src/lib/password.ts",
"chars": 1560,
"preview": "import { randomBytes, scryptSync, timingSafeEqual } from 'crypto';\n\nconst SALT_LENGTH = 16;\nconst KEY_LENGTH = 64;\nconst"
},
{
"path": "src/lib/redis-base.db.ts",
"chars": 18221,
"preview": "/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */\n\nimport { "
},
{
"path": "src/lib/redis.db.ts",
"chars": 426,
"preview": "/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */\n\nimport { "
},
{
"path": "src/lib/search-cache.ts",
"chars": 3126,
"preview": "import { SearchResult } from '@/lib/types';\n\n// 缓存状态类型\nexport type CachedPageStatus = 'ok' | 'timeout' | 'forbidden';\n\n/"
},
{
"path": "src/lib/time.ts",
"chars": 1284,
"preview": "/**\n * 时间格式转换函数\n * 处理形如 \"20250824000000 +0800\" 的时间格式\n */\nexport function parseCustomTimeFormat(timeStr: string): Date {\n"
},
{
"path": "src/lib/types.ts",
"chars": 3365,
"preview": "import { AdminConfig } from './admin.types';\n\n// 播放记录数据结构\nexport interface PlayRecord {\n title: string;\n source_name: "
},
{
"path": "src/lib/upstash.db.ts",
"chars": 15970,
"preview": "/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */\n\nimport { "
},
{
"path": "src/lib/utils.ts",
"chars": 6460,
"preview": "/* eslint-disable @typescript-eslint/no-explicit-any,no-console */\nimport he from 'he';\nimport Hls from 'hls.js';\n\nfunct"
},
{
"path": "src/lib/version.ts",
"chars": 115,
"preview": "/* eslint-disable no-console */\n\nconst CURRENT_VERSION = '100.1.2';\n\n// 导出当前版本号供其他地方使用\nexport { CURRENT_VERSION };\n"
},
{
"path": "src/lib/version_check.ts",
"chars": 3736,
"preview": "/* eslint-disable no-console */\n\n'use client';\n\nimport { CURRENT_VERSION } from \"@/lib/version\";\n\n// 版本检查结果枚举\nexport enu"
},
{
"path": "src/lib/yellow.ts",
"chars": 290,
"preview": "export const yellowWords = [\n '伦理片',\n '福利',\n '里番动漫',\n '门事件',\n '萝莉少女',\n '制服诱惑',\n '国产传媒',\n 'cosplay',\n '黑丝诱惑',\n "
},
{
"path": "src/middleware.ts",
"chars": 3209,
"preview": "/* eslint-disable no-console */\n\nimport { NextRequest, NextResponse } from 'next/server';\n\nimport { getAuthInfoFromCooki"
},
{
"path": "src/styles/colors.css",
"chars": 26617,
"preview": "/* //!STARTERCONF Remove this file after copying your desired color, this is a large file you should remove it. */\n\n.sla"
},
{
"path": "src/styles/globals.css",
"chars": 3343,
"preview": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n:root {\n /* #region /**=========== Primary Color ========="
},
{
"path": "start.js",
"chars": 2186,
"preview": "#!/usr/bin/env node\n\n/* eslint-disable no-console,@typescript-eslint/no-var-requires */\nconst http = require('http');\nco"
},
{
"path": "tailwind.config.ts",
"chars": 2747,
"preview": "import type { Config } from 'tailwindcss';\nimport defaultTheme from 'tailwindcss/defaultTheme';\n\nconst config: Config = "
},
{
"path": "tsconfig.json",
"chars": 750,
"preview": "{\n \"compilerOptions\": {\n \"target\": \"es5\",\n \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n \"allowJs\": true,\n \"sk"
},
{
"path": "vercel.json",
"chars": 308,
"preview": "{\n \"headers\": [\n {\n \"source\": \"/fonts/inter-var-latin.woff2\",\n \"headers\": [\n {\n \"key\": \"Ca"
}
]
About this extraction
This page contains the full source code of the MoonTechLab/LunaTV GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 143 files (1.1 MB), approximately 287.5k tokens, and a symbol index with 461 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.