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 (", "
", " $0", "
", " )", "}" ] }, "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 (", "
", " $0", "
", " )", "}" ] }, //#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 (", " ", " \n", "
\n", "
", "
", " $0", "
", "
", "
", "
", " )", "}" ] }, "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" }, "Infer Get Server Side Props": { "prefix": "igssp", "body": "InferGetServerSidePropsType" }, "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
MoonTV Logo
> 🎬 **MoonTV** 是一个开箱即用的、跨平台的影视聚合播放器。它基于 **Next.js 14** + **Tailwind CSS** + **TypeScript** 构建,支持多资源搜索、在线播放、收藏同步、播放记录、云端存储,让你可以随时随地畅享海量免费影视内容。
![Next.js](https://img.shields.io/badge/Next.js-14-000?logo=nextdotjs) ![TailwindCSS](https://img.shields.io/badge/TailwindCSS-3-38bdf8?logo=tailwindcss) ![TypeScript](https://img.shields.io/badge/TypeScript-4.x-3178c6?logo=typescript) ![License](https://img.shields.io/badge/License-MIT-green) ![Docker Ready](https://img.shields.io/badge/Docker-ready-blue?logo=docker)
--- ## ✨ 功能特性 - 🔍 **多源聚合搜索**:一次搜索立刻返回全源结果。 - 📄 **丰富详情页**:支持剧集列表、演员、年份、简介等完整信息展示。 - ▶️ **流畅在线播放**:集成 HLS.js & ArtPlayer。 - ❤️ **收藏 + 继续观看**:支持 Kvrocks/Redis/Upstash 存储,多端同步进度。 - 📱 **PWA**:离线缓存、安装到桌面/主屏,移动端原生体验。 - 🌗 **响应式布局**:桌面侧边栏 + 移动底部导航,自适应各种屏幕尺寸。 - 👿 **智能去广告**:自动跳过视频中的切片广告(实验性)。 ### 注意:部署后项目为空壳项目,无内置播放源和直播源,需要自行收集
点击查看项目截图 项目截图 项目截图 项目截图
### 请不要在 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 数据库: [![Deploy on Zeabur](https://zeabur.com/button.svg)](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 [![Star History Chart](https://api.star-history.com/svg?repos=MoonTechLab/LunaTV&type=Date)](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: ['/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', '/'], testEnvironment: 'jest-environment-jsdom', /** * Absolute imports and Module Path Aliases */ moduleNameMapper: { '^@/(.*)$': '/src/$1', '^~/(.*)$': '/public/$1', '^.+\\.(svg)$': '/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 ` Proxy Everything
linkProxy Everything
`; } ================================================ 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 ; case 'error': return ; case 'warning': return ; 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(
{getIcon()}

{title}

{message && (

{message}

)} {showConfirm && ( )}
, 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) => { 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({}); 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): Promise => { 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 (
{isExpanded &&
{children}
}
); }; // 用户配置组件 interface UserConfigProps { config: AdminConfig | null; role: 'owner' | 'admin' | null; refreshConfig: () => Promise; } 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([]); const [showConfigureUserGroupModal, setShowConfigureUserGroupModal] = useState(false); const [selectedUserForGroup, setSelectedUserForGroup] = useState<{ username: string; role: 'user' | 'admin' | 'owner'; tags?: string[]; } | null>(null); const [selectedUserGroups, setSelectedUserGroups] = useState([]); const [selectedUsers, setSelectedUsers] = useState>(new Set()); const [showBatchUserGroupModal, setShowBatchUserGroupModal] = useState(false); const [selectedUserGroup, setSelectedUserGroup] = useState(''); 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(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 (
加载中...
); } return (
{/* 用户统计 */}

用户统计

{config.UserConfig.Users.length}
总用户数
{/* 用户组管理 */}

用户组管理

{/* 用户组列表 */}
{userGroups.map((group) => ( ))} {userGroups.length === 0 && ( )}
用户组名称 可用视频源 操作
{group.name}
{group.enabledApis && group.enabledApis.length > 0 ? `${group.enabledApis.length} 个源` : '无限制'}
暂无用户组,请添加用户组来管理用户权限
{/* 用户列表 */}

用户列表

{/* 批量操作按钮 */} {selectedUsers.size > 0 && ( <>
已选择 {selectedUsers.size} 个用户
)}
{/* 添加用户表单 */} {showAddUserForm && (
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' /> 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' />
)} {/* 修改密码表单 */} {showChangePasswordForm && (
修改用户密码
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' />
)} {/* 用户列表 */}
{/* 按规则排序用户:自己 -> 站长(若非自己) -> 管理员 -> 其他 */} {(() => { 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 ( {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 ( ); })} ); })()}
{(() => { // 检查是否有权限操作任何用户 const hasAnyPermission = config?.UserConfig?.Users?.some(user => (role === 'owner' || (role === 'admin' && (user.role === 'user' || user.username === currentUsername))) ); return hasAnyPermission ? ( 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' /> ) : (
); })()}
用户名 角色 状态 用户组 采集源权限 操作
{(role === 'owner' || (role === 'admin' && (user.role === 'user' || user.username === currentUsername))) ? ( 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' /> ) : (
)}
{user.username} {user.role === 'owner' ? '站长' : user.role === 'admin' ? '管理员' : '普通用户'} {!user.banned ? '正常' : '已封禁'}
{user.tags && user.tags.length > 0 ? user.tags.join(', ') : '无用户组'} {/* 配置用户组按钮 */} {(role === 'owner' || (role === 'admin' && (user.role === 'user' || user.username === currentUsername))) && ( )}
{user.enabledApis && user.enabledApis.length > 0 ? `${user.enabledApis.length} 个源` : '无限制'} {/* 配置采集源权限按钮 */} {(role === 'owner' || (role === 'admin' && (user.role === 'user' || user.username === currentUsername))) && ( )}
{/* 修改密码按钮 */} {canChangePassword && ( )} {canOperate && ( <> {/* 其他操作按钮 */} {user.role === 'user' && ( )} {user.role === 'admin' && ( )} {user.role !== 'owner' && (!user.banned ? ( ) : ( ))} )} {/* 删除用户按钮 - 放在最后,使用更明显的红色样式 */} {canDeleteUser && ( )}
{/* 配置用户采集源权限弹窗 */} {showConfigureApisModal && selectedUser && createPortal(
{ setShowConfigureApisModal(false); setSelectedUser(null); setSelectedApis([]); }}>
e.stopPropagation()}>

配置用户采集源权限 - {selectedUser.username}

配置说明

提示:全不选为无限制,选中的采集源将限制用户只能访问这些源

{/* 采集源选择 - 多列布局 */}

选择可用的采集源:

{config?.SourceConfig?.map((source) => ( ))}
{/* 快速操作按钮 */}
已选择: {selectedApis.length > 0 ? `${selectedApis.length} 个源` : '无限制'}
{/* 操作按钮 */}
, document.body )} {/* 添加用户组弹窗 */} {showAddUserGroupForm && createPortal(
{ setShowAddUserGroupForm(false); setNewUserGroup({ name: '', enabledApis: [] }); }}>
e.stopPropagation()}>

添加新用户组

{/* 用户组名称 */}
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' />
{/* 可用视频源 */}
{config?.SourceConfig?.map((source) => ( ))}
{/* 快速操作按钮 */}
{/* 操作按钮 */}
, document.body )} {/* 编辑用户组弹窗 */} {showEditUserGroupForm && editingUserGroup && createPortal(
{ setShowEditUserGroupForm(false); setEditingUserGroup(null); }}>
e.stopPropagation()}>

编辑用户组 - {editingUserGroup.name}

{/* 可用视频源 */}
{config?.SourceConfig?.map((source) => ( ))}
{/* 快速操作按钮 */}
{/* 操作按钮 */}
, document.body )} {/* 配置用户组弹窗 */} {showConfigureUserGroupModal && selectedUserForGroup && createPortal(
{ setShowConfigureUserGroupModal(false); setSelectedUserForGroup(null); setSelectedUserGroups([]); }}>
e.stopPropagation()}>

配置用户组 - {selectedUserForGroup.username}

配置说明

提示:选择"无用户组"为无限制,选择特定用户组将限制用户只能访问该用户组允许的采集源

{/* 用户组选择 - 下拉选择器 */}

选择"无用户组"为无限制,选择特定用户组将限制用户只能访问该用户组允许的采集源

{/* 操作按钮 */}
, document.body )} {/* 删除用户组确认弹窗 */} {showDeleteUserGroupModal && deletingUserGroup && createPortal(
{ setShowDeleteUserGroupModal(false); setDeletingUserGroup(null); }}>
e.stopPropagation()}>

确认删除用户组

危险操作警告

删除用户组 {deletingUserGroup.name} 将影响所有使用该组的用户,此操作不可恢复!

{deletingUserGroup.affectedUsers.length > 0 ? (
⚠️ 将影响 {deletingUserGroup.affectedUsers.length} 个用户:
{deletingUserGroup.affectedUsers.map((user, index) => (
• {user.username} ({user.role})
))}

这些用户的用户组将被自动移除

) : (
✅ 当前没有用户使用此用户组
)}
{/* 操作按钮 */}
, document.body )} {/* 删除用户确认弹窗 */} {showDeleteUserModal && deletingUser && createPortal(
{ setShowDeleteUserModal(false); setDeletingUser(null); }}>
e.stopPropagation()}>

确认删除用户

危险操作警告

删除用户 {deletingUser} 将同时删除其搜索历史、播放记录和收藏夹,此操作不可恢复!

{/* 操作按钮 */}
, document.body )} {/* 批量设置用户组弹窗 */} {showBatchUserGroupModal && createPortal(
{ setShowBatchUserGroupModal(false); setSelectedUserGroup(''); }}>
e.stopPropagation()}>

批量设置用户组

批量操作说明

将为选中的 {selectedUsers.size} 个用户 设置用户组,选择"无用户组"为无限制

选择"无用户组"为无限制,选择特定用户组将限制用户只能访问该用户组允许的采集源

{/* 操作按钮 */}
, document.body )} {/* 通用弹窗组件 */}
); } // 视频源配置组件 const VideoSourceConfig = ({ config, refreshConfig, }: { config: AdminConfig | null; refreshConfig: () => Promise; }) => { const { alertModal, showAlert, hideAlert } = useAlertModal(); const { isLoading, withLoading } = useLoadingState(); const [sources, setSources] = useState([]); const [showAddForm, setShowAddForm] = useState(false); const [orderChanged, setOrderChanged] = useState(false); const [newSource, setNewSource] = useState({ name: '', key: '', api: '', detail: '', disabled: false, from: 'config', }); // 批量操作相关状态 const [selectedSources, setSelectedSources] = useState>(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>([]); // 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) => { 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 ( 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' /> {source.name} {source.key} {source.api} {source.detail || '-'} {!source.disabled ? '启用中' : '已禁用'} {(() => { const status = getValidationStatus(source.key); if (!status) { return ( 未检测 ); } return ( {status.icon} {status.text} ); })()} {source.from !== 'config' && ( )} ); }; // 全选/取消全选 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 (
加载中...
); } return (
{/* 添加视频源表单 */}

视频源列表

{/* 批量操作按钮 - 移动端显示在下一行,PC端显示在左侧 */} {selectedSources.size > 0 && ( <>
已选 {selectedSources.size} 已选择 {selectedSources.size} 个视频源
)}
{showAddForm && (
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' /> 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' /> 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' /> 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' />
)} {/* 视频源表格 */}
s.key)} strategy={verticalListSortingStrategy} > {sources.map((source) => ( ))}
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' /> 名称 Key API 地址 Detail 地址 状态 有效性 操作
{/* 保存排序按钮 */} {orderChanged && (
)} {/* 有效性检测弹窗 */} {showValidationModal && createPortal(
setShowValidationModal(false)}>
e.stopPropagation()}>

视频源有效性检测

请输入检测用的搜索关键词

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()} />
, document.body )} {/* 通用弹窗组件 */} {/* 批量操作确认弹窗 */} {confirmModal.isOpen && createPortal(
e.stopPropagation()}>

{confirmModal.title}

{confirmModal.message}

{/* 操作按钮 */}
, document.body )}
); }; // 分类配置组件 const CategoryConfig = ({ config, refreshConfig, }: { config: AdminConfig | null; refreshConfig: () => Promise; }) => { const { alertModal, showAlert, hideAlert } = useAlertModal(); const { isLoading, withLoading } = useLoadingState(); const [categories, setCategories] = useState([]); const [showAddForm, setShowAddForm] = useState(false); const [orderChanged, setOrderChanged] = useState(false); const [newCategory, setNewCategory] = useState({ 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) => { 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({ action: 'sort', order })) .then(() => { setOrderChanged(false); }) .catch(() => { console.error('操作失败', 'sort', order); }); }; // 可拖拽行封装 (dnd-kit) const DraggableRow = ({ category }: { category: CustomCategory }) => { const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: `${category.query}:${category.type}` }); const style = { transform: CSS.Transform.toString(transform), transition, } as React.CSSProperties; return ( {category.name || '-'} {category.type === 'movie' ? '电影' : '电视剧'} {category.query} {!category.disabled ? '启用中' : '已禁用'} {category.from !== 'config' && ( )} ); }; if (!config) { return (
加载中...
); } return (
{/* 添加分类表单 */}

自定义分类列表

{showAddForm && (
setNewCategory((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' /> setNewCategory((prev) => ({ ...prev, query: 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' />
)} {/* 分类表格 */}
`${c.query}:${c.type}`)} strategy={verticalListSortingStrategy} > {categories.map((category) => ( ))}
分类名称 类型 搜索关键词 状态 操作
{/* 保存排序按钮 */} {orderChanged && (
)} {/* 通用弹窗组件 */}
); }; // 新增配置文件组件 const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig | null; refreshConfig: () => Promise }) => { const { alertModal, showAlert, hideAlert } = useAlertModal(); const { isLoading, withLoading } = useLoadingState(); const [configContent, setConfigContent] = useState(''); const [subscriptionUrl, setSubscriptionUrl] = useState(''); const [autoUpdate, setAutoUpdate] = useState(false); const [lastCheckTime, setLastCheckTime] = useState(''); useEffect(() => { if (config?.ConfigFile) { setConfigContent(config.ConfigFile); } if (config?.ConfigSubscribtion) { setSubscriptionUrl(config.ConfigSubscribtion.URL); setAutoUpdate(config.ConfigSubscribtion.AutoUpdate); setLastCheckTime(config.ConfigSubscribtion.LastCheck || ''); } }, [config]); // 拉取订阅配置 const handleFetchConfig = async () => { if (!subscriptionUrl.trim()) { showError('请输入订阅URL', showAlert); return; } await withLoading('fetchConfig', async () => { try { const resp = await fetch('/api/admin/config_subscription/fetch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: subscriptionUrl }), }); if (!resp.ok) { const data = await resp.json().catch(() => ({})); throw new Error(data.error || `拉取失败: ${resp.status}`); } const data = await resp.json(); if (data.configContent) { setConfigContent(data.configContent); // 更新本地配置的最后检查时间 const currentTime = new Date().toISOString(); setLastCheckTime(currentTime); showSuccess('配置拉取成功', showAlert); } else { showError('拉取失败:未获取到配置内容', showAlert); } } catch (err) { showError(err instanceof Error ? err.message : '拉取失败', showAlert); throw err; } }); }; // 保存配置文件 const handleSave = async () => { await withLoading('saveConfig', async () => { try { const resp = await fetch('/api/admin/config_file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ configFile: configContent, subscriptionUrl, autoUpdate, lastCheckTime: lastCheckTime || new Date().toISOString() }), }); if (!resp.ok) { const data = await resp.json().catch(() => ({})); throw new Error(data.error || `保存失败: ${resp.status}`); } showSuccess('配置文件保存成功', showAlert); await refreshConfig(); } catch (err) { showError(err instanceof Error ? err.message : '保存失败', showAlert); throw err; } }); }; if (!config) { return (
加载中...
); } return (
{/* 配置订阅区域 */}

配置订阅

最后更新: {lastCheckTime ? new Date(lastCheckTime).toLocaleString('zh-CN') : '从未更新'}
{/* 订阅URL输入 */}
setSubscriptionUrl(e.target.value)} placeholder='https://example.com/config.json' disabled={false} className='w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent transition-all duration-200 shadow-sm hover:border-gray-400 dark:hover:border-gray-500' />

输入配置文件的订阅地址,要求 JSON 格式,且使用 Base58 编码

{/* 拉取配置按钮 */}
{/* 自动更新开关 */}

启用后系统将定期自动拉取最新配置

{/* 配置文件编辑区域 */}