diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000..c1de1632cb --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Copy this file to .env.local and fill in your own values. +USERNAME=admin +PASSWORD=replace-with-a-strong-password + +# Server-only TMDB API Read Access Token. Never use a NEXT_PUBLIC_ prefix. +TMDB_API_READ_TOKEN=your-token-here diff --git a/README.md b/README.md index ca35b810b3..617fd88ff0 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ -# MoonTV +# CraterTV
- MoonTV Logo + CraterTV Logo
-> 🎬 **MoonTV** 是一个开箱即用的、跨平台的影视聚合播放器。它基于 **Next.js 14** + **Tailwind CSS** + **TypeScript** 构建,支持多资源搜索、在线播放、收藏同步、播放记录、云端存储,让你可以随时随地畅享海量免费影视内容。 +CraterTV 是一个基于 Next.js 的影视聚合与播放管理工具。项目不内置任何播放源或直播源,部署后需要站长自行配置采集源、用户和站点信息。 + +本分支重点收紧了用户权限模型,用户组支持配置安全预搜索,并简化了动漫分类:动漫页保留  `番剧`  和  `剧场版`,默认进入  `番剧`,不再提供 Bangumi 每日放送分类。
@@ -12,273 +14,159 @@ ![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 | +## 功能 -## 部署 +- 多采集源聚合搜索,搜索结果按当前用户可用源过滤。 +- 在线播放、详情页、收藏、播放记录和搜索历史。 +- 后台管理站点配置、采集源、直播源、自定义分类、用户和用户组。 +- 三层权限:站长、管理员、用户。 +- 用户组作为采集源权限上限,用户个人采集源只能在用户组允许范围内选择。 +- 用户组可启用影视库安全搜索:手动搜索优先使用豆瓣影视库,豆瓣无结果时由 TMDB 过滤成人内容。 +- 豆瓣电影、电视剧、综艺、动漫推荐;动漫仅保留番剧和剧场版。 +- PWA 支持,移动端和桌面端自适应。 -本项目**仅支持 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 -``` +站长由环境变量 `USERNAME` 和 `PASSWORD` 指定,拥有最高权限: -### ☁️ 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 +推荐使用 Vercel 部署,也可以使用普通 Node.js 环境运行。 - # 必填:存储配置 - NEXT_PUBLIC_STORAGE_TYPE=kvrocks - KVROCKS_URL=redis://apachekvrocks:6666 +### Vercel - # 可选:站点配置 - SITE_BASE=https://your-domain.zeabur.app - NEXT_PUBLIC_SITE_NAME=LunaTV Enhanced - ANNOUNCEMENT=欢迎使用 LunaTV Enhanced Edition +1. Fork 本仓库,或将本仓库推送到自己的 GitHub 账号。 +2. 登录 [Vercel](https://vercel.com/),选择 **Add New → Project** 并导入仓库。 +3. Framework Preset 保持 **Next.js**,其余构建设置使用默认值。 +4. 至少配置 `USERNAME`、`PASSWORD` 和存储服务相关环境变量;需要 TMDB 兜底时再配置 `TMDB_API_READ_TOKEN`。 +5. 点击 **Deploy**。部署完成后,用 `USERNAME` 和 `PASSWORD` 登录站长后台。 +6. 在后台导入或填写采集源,并按需建立用户组、启用影视库安全搜索。 - # 可选:豆瓣代理配置(推荐) - NEXT_PUBLIC_DOUBAN_PROXY_TYPE=cmliussss-cdn-tencent - NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE=cmliussss-cdn-tencent - ``` +推荐在 Vercel 上使用 Upstash Redis 存储,以保留后台配置、用户、收藏和播放记录。 - **注意**: - - 使用服务名称作为主机名:`redis://apachekvrocks:6666` - - 如果服务名称不同,请替换为实际名称 - - 两个服务必须在同一个 Project 中 +推荐的 Vercel 环境变量组合: -4. **部署完成** - - Zeabur 会自动拉取镜像并启动服务 - - 等待服务就绪后,需要手动设置访问域名(见下一步) +```env +USERNAME=你的站长用户名 +PASSWORD=高强度随机密码 +NEXT_PUBLIC_STORAGE_TYPE=upstash +UPSTASH_URL=你的 Upstash REST URL +UPSTASH_TOKEN=你的 Upstash REST Token +TMDB_API_READ_TOKEN=你的 TMDB API Read Access Token +NEXT_PUBLIC_SITE_NAME=CraterTV +``` -#### 5. 设置访问域名(必须) +所有变量建议同时应用到 Production、Preview 和 Development。修改服务端环境变量后需要重新部署才会生效。不要为密码、Redis Token 或 TMDB Token 添加 `NEXT_PUBLIC_` 前缀。 - - 在 LunaTV 服务页面,点击 "Networking" 或 "网络" 标签 - - 点击 "Generate Domain" 生成 Zeabur 提供的免费域名(如 `xxx.zeabur.app`) - - 或者绑定自定义域名: - * 点击 "Add Domain" 添加你的域名 - * 按照提示配置 DNS CNAME 记录指向 Zeabur 提供的目标地址 - - 设置完域名后即可通过域名访问 LunaTV +### Node.js -6. **绑定自定义域名(可选)** - - 在服务设置中点击 "Domains" - - 添加你的自定义域名 - - 配置 DNS CNAME 记录指向 Zeabur 提供的域名 +本地或自有服务器需要 Node.js 与 pnpm: -#### 🔄 更新 Docker 镜像 +```bash +corepack enable +corepack pnpm install --frozen-lockfile +corepack pnpm run build +corepack pnpm start +``` -当 Docker 镜像有新版本发布时,Zeabur 不会自动更新。需要手动触发更新。 +开发模式: -**更新步骤:** +```bash +corepack pnpm run dev +``` -1. **进入服务页面** - - 点击需要更新的服务(LunaTV 或 KVRocks) +## 环境变量 -2. **重启服务** - - 点击 **"服务状态"** 页面,再点击 **"重启当前版本"** 按钮 - - Zeabur 会自动拉取最新的 `latest` 镜像并重新部署 +| 变量 | 必填 | 说明 | +| ------------------------------------- | -------------- | --------------------------------------------------------------------- | +| `USERNAME` | 是 | 站长用户名 | +| `PASSWORD` | 是 | 站长密码,也是登录签名密钥 | +| `NEXT_PUBLIC_STORAGE_TYPE` | 建议 | 存储方式:`localstorage`、`upstash`、`redis`、`kvrocks` | +| `UPSTASH_URL` | Upstash 时必填 | Upstash Redis REST URL,也兼容 Vercel KV 注入的 `KV_REST_API_URL` | +| `UPSTASH_TOKEN` | Upstash 时必填 | Upstash Redis REST Token,也兼容 Vercel KV 注入的 `KV_REST_API_TOKEN` | +| `REDIS_URL` | Redis 时必填 | Redis 连接地址 | +| `KVROCKS_URL` | Kvrocks 时必填 | Kvrocks 连接地址 | +| `NEXT_PUBLIC_SITE_NAME` | 否 | 站点名称,默认 `CraterTV` | +| `ANNOUNCEMENT` | 否 | 站点公告 | +| `SITE_BASE` | 否 | 站点外部访问地址,用于部分播放地址重写 | +| `NEXT_PUBLIC_SEARCH_MAX_PAGE` | 否 | 搜索接口最大拉取页数,默认 `5` | +| `TMDB_API_READ_TOKEN` | 安全搜索建议 | TMDB API 读访问令牌,豆瓣无结果时用于规范片名和过滤成人内容 | +| `NEXT_PUBLIC_DOUBAN_PROXY_TYPE` | 否 | 豆瓣数据代理类型,默认 `cmliussss-cdn-tencent` | +| `NEXT_PUBLIC_DOUBAN_PROXY` | 否 | 自定义豆瓣数据代理 URL | +| `NEXT_PUBLIC_DOUBAN_IMAGE_PROXY_TYPE` | 否 | 豆瓣图片代理类型,默认 `cmliussss-cdn-tencent` | +| `NEXT_PUBLIC_DOUBAN_IMAGE_PROXY` | 否 | 自定义豆瓣图片代理 URL | +| `NEXT_PUBLIC_FLUID_SEARCH` | 否 | 是否启用流式搜索,默认启用;设为 `false` 可关闭 | + +`localstorage` 适合临时体验,不适合正式使用。该模式下用户数据和部分配置只保存在浏览器本地,无法多端同步,后台配置也不能可靠持久化。 + +TMDB 令牌应写入部署平台的服务端环境变量或本地 `.env.local`,不要使用 +`NEXT_PUBLIC_` 前缀,也不要提交到版本库。为用户组启用影视库安全搜索后,系统先从 +豆瓣影视库获取规范名称;豆瓣没有影视结果时,再由 TMDB 排除成人内容并提供规范 +名称,最后到该组允许的播放源中做精确匹配。两个影视库都不可用或都没有结果时 +返回空结果。 + +首页豆瓣推荐会标记为豆瓣目录来源:播放时直接使用卡片标题匹配播放源,不再查询 +豆瓣或 TMDB。该简化方式适用于私有站点;公开部署时应改为不可伪造的来源凭证。 + +## 安全搜索策略 + +安全搜索按用户组启用,不影响站长账号: + +```text +首页豆瓣推荐 → 直接使用卡片标题 → 精确匹配播放源 + +用户手动搜索 → 豆瓣影视库有结果 → 使用豆瓣规范标题 → 精确匹配播放源 + └→ 豆瓣无结果 → TMDB 排除成人内容 → 精确匹配播放源 +``` -> 💡 **提示**: -> - 使用 `latest` 标签时,Restart 会自动拉取最新镜像 -> - 生产环境推荐使用固定版本标签(如 `v5.5.6`)避免意外更新 +- “影视库搜索”是模糊搜索,用于找到规范片名。 +- 播放源结果只保留规范化后标题完全一致的条目。 +- 标题规范化会忽略大小写、空格和常见中英文标点。 +- 豆瓣和 TMDB 都没有结果时,受保护用户组返回空结果,不退化为普通全源搜索。 -## 配置文件 +## 采集源配置 -完成部署后为空壳应用,无播放源,需要站长在管理后台的配置文件设置中填写配置文件(后续会支持订阅) +项目默认不提供任何影视采集源。站长需要在后台配置文件中导入采集源,或在后台手动添加。 -配置文件示例如下: +配置示例: ```json { "cache_time": 7200, "api_site": { - "dyttzy": { - "api": "http://xxx.com/api.php/provide/vod", + "demo": { + "api": "https://example.com/api.php/provide/vod", "name": "示例资源", - "detail": "http://xxx.com" + "detail": "https://example.com" } - // ...更多站点 }, "custom_category": [ { @@ -286,123 +174,61 @@ Zeabur 是一站式云端部署平台,使用预构建的 Docker 镜像可以 "type": "movie", "query": "华语" } - ] + ], + "live": { + "demo-live": { + "name": "示例直播", + "url": "https://example.com/live.m3u" + } + } } ``` -- `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 中使用。 +- `cache_time`:接口缓存时间,单位秒。 +- `api_site`:影视采集源,要求兼容常见 CMS V10 JSON API。 +- `api_site.*.api`:采集源 API 地址。 +- `api_site.*.name`:前端展示名称。 +- `api_site.*.detail`:可选,部分源需要网页详情地址辅助解析。 +- `custom_category`:自定义豆瓣分类。 +- `live`:直播源配置。 -## 自动更新 +配置订阅支持将完整配置文件进行 base58 编码后,通过 HTTP URL 提供给后台拉取。 -可借助 [watchtower](https://github.com/containrrr/watchtower) 自动更新镜像容器 +## 使用建议 -dockge/komodo 等 docker compose UI 也有自动更新功能 +1. 部署后先设置强密码。 +2. 不要公开分享自己的实例地址。 +3. 不要将第三方采集源配置提交到公开仓库。 +4. 使用用户组作为权限上限,再给用户分配个人采集源。 +5. 修改用户组采集源后,用户个人采集源会被裁剪到用户组允许范围内。 -## 环境变量 - -| 变量 | 说明 | 可选值 | 默认值 | -| ----------------------------------- | -------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| 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 后端 +- 本项目仅作为学习和个人使用工具。 +- 本项目不提供、不存储、不分发任何影视资源。 +- 所有采集源、直播源和播放内容均由部署者自行配置并承担责任。 +- 请遵守所在地法律法规,不要将实例用于公开服务、商业用途或侵权用途。 +- 请不要在公开社交平台传播项目实例、采集源或订阅链接。 -已实现播放记录和网页端同步 +## Android TV -## 安全与隐私提醒 +项目保留 OrionTV 兼容接口,可作为 Android TV 客户端的后端使用。可用资源同样受当前登录用户的采集源权限限制。 -### 请设置密码保护并关闭公网注册 - -为了您的安全和避免潜在的法律风险,我们要求在部署时**强烈建议关闭公网注册**: - -### 部署要求 - -1. **设置环境变量 `PASSWORD`**:为您的实例设置一个强密码 -2. **仅供个人使用**:请勿将您的实例链接公开分享或传播 -3. **遵守当地法律**:请确保您的使用行为符合当地法律法规 +## 致谢 -### 重要声明 +感谢前序项目和开源作者的工作,本项目是在这些基础上继续调整和维护: -- 本项目仅供学习和个人使用 -- 请勿将部署的实例用于商业用途或公开服务 -- 如因公开分享导致的任何法律问题,用户需自行承担责任 -- 项目开发者不对用户的使用行为承担任何法律责任 -- 本项目不在中国大陆地区提供服务。如有该项目在向中国大陆地区提供服务,属个人行为。在该地区使用所产生的法律风险及责任,属于用户个人行为,与本项目无关,须自行承担全部责任。特此声明 +- [MoonTV](https://github.com/MoonTechLab/LunaTV) 与原项目贡献者。 +- [LibreTV](https://github.com/LibreSpark/LibreTV) 的启发。 +- [ts-nextjs-tailwind-starter](https://github.com/theodorusclarence/ts-nextjs-tailwind-starter) 提供的初始工程思路。 +- [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 服务。 +- 感谢所有为相关生态提供工具、文档和问题反馈的开发者。 ## 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) +[MIT](LICENSE) diff --git a/package.json b/package.json index ee4f82f261..9a97bb1d45 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "moontv", + "name": "creatertv", "version": "0.1.0", "private": true, "scripts": { diff --git a/scripts/generate-manifest.js b/scripts/generate-manifest.js index 14b0f026b0..0285b9a759 100644 --- a/scripts/generate-manifest.js +++ b/scripts/generate-manifest.js @@ -11,7 +11,7 @@ const publicDir = path.join(projectRoot, 'public'); const manifestPath = path.join(publicDir, 'manifest.json'); // 从环境变量获取站点名称 -const siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'MoonTV'; +const siteName = process.env.NEXT_PUBLIC_SITE_NAME || 'CraterTV'; // manifest.json 模板 const manifestTemplate = { diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 830fffee7d..be26661a39 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -50,43 +50,62 @@ 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', + primary: + 'px-3 py-1.5 text-sm font-medium bg-theme-primary hover:bg-theme-primary-hover 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', + success: + 'px-3 py-1.5 text-sm font-medium bg-theme-primary hover:bg-theme-primary-hover 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', + 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', + 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', + 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', + primarySmall: + 'px-2 py-1 text-xs font-medium bg-theme-primary hover:bg-theme-primary-hover 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', + successSmall: + 'px-2 py-1 text-xs font-medium bg-theme-primary hover:bg-theme-primary-hover 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', + 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', + 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', + 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', + 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', + 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', + toggleOn: 'bg-theme-primary', 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', + 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', }; // 通用弹窗组件 @@ -107,7 +126,7 @@ const AlertModal = ({ title, message, timer, - showConfirm = false + showConfirm = false, }: AlertModalProps) => { const [isVisible, setIsVisible] = useState(false); @@ -129,11 +148,11 @@ const AlertModal = ({ const getIcon = () => { switch (type) { case 'success': - return ; + return ; case 'error': - return ; + return ; case 'warning': - return ; + return ; default: return null; } @@ -153,21 +172,25 @@ const AlertModal = ({ }; return createPortal( -
-
-
-
- {getIcon()} -
+
+
+
+
{getIcon()}
-

+

{title}

{message && ( -

- {message} -

+

{message}

)} {showConfirm && ( @@ -205,7 +228,7 @@ const useAlertModal = () => { }; const hideAlert = () => { - setAlertModal(prev => ({ ...prev, isOpen: false })); + setAlertModal((prev) => ({ ...prev, isOpen: false })); }; return { alertModal, showAlert, hideAlert }; @@ -237,12 +260,15 @@ const useLoadingState = () => { const [loadingStates, setLoadingStates] = useState({}); const setLoading = (key: string, loading: boolean) => { - setLoadingStates(prev => ({ ...prev, [key]: loading })); + setLoadingStates((prev) => ({ ...prev, [key]: loading })); }; const isLoading = (key: string) => loadingStates[key] || false; - const withLoading = async (key: string, operation: () => Promise): Promise => { + const withLoading = async ( + key: string, + operation: () => Promise + ): Promise => { setLoading(key, true); try { const result = await operation(); @@ -265,7 +291,6 @@ interface SiteConfig { DoubanProxy: string; DoubanImageProxyType: string; DoubanImageProxy: string; - DisableYellowFilter: boolean; FluidSearch: boolean; EnableWebLive: boolean; } @@ -365,10 +390,13 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { const [newUserGroup, setNewUserGroup] = useState({ name: '', enabledApis: [] as string[], + safeSearchEnabled: false, }); const [editingUserGroup, setEditingUserGroup] = useState<{ name: string; enabledApis: string[]; + safeSearchEnabled?: boolean; + safeSearchApi?: string; } | null>(null); const [showConfigureApisModal, setShowConfigureApisModal] = useState(false); const [selectedUser, setSelectedUser] = useState<{ @@ -378,7 +406,8 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { tags?: string[]; } | null>(null); const [selectedApis, setSelectedApis] = useState([]); - const [showConfigureUserGroupModal, setShowConfigureUserGroupModal] = useState(false); + const [showConfigureUserGroupModal, setShowConfigureUserGroupModal] = + useState(false); const [selectedUserForGroup, setSelectedUserForGroup] = useState<{ username: string; role: 'user' | 'admin' | 'owner'; @@ -388,10 +417,14 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { const [selectedUsers, setSelectedUsers] = useState>(new Set()); const [showBatchUserGroupModal, setShowBatchUserGroupModal] = useState(false); const [selectedUserGroup, setSelectedUserGroup] = useState(''); - const [showDeleteUserGroupModal, setShowDeleteUserGroupModal] = useState(false); + const [showDeleteUserGroupModal, setShowDeleteUserGroupModal] = + useState(false); const [deletingUserGroup, setDeletingUserGroup] = useState<{ name: string; - affectedUsers: Array<{ username: string; role: 'user' | 'admin' | 'owner' }>; + affectedUsers: Array<{ + username: string; + role: 'user' | 'admin' | 'owner'; + }>; } | null>(null); const [showDeleteUserModal, setShowDeleteUserModal] = useState(false); const [deletingUser, setDeletingUser] = useState(null); @@ -401,23 +434,71 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { // 使用 useMemo 计算全选状态,避免每次渲染都重新计算 const selectAllUsers = useMemo(() => { - const selectableUserCount = config?.UserConfig?.Users?.filter(user => - (role === 'owner' || - (role === 'admin' && - (user.role === 'user' || - user.username === currentUsername))) - ).length || 0; + 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 getGroupAllowedApiKeys = useCallback( + (tags?: string[]) => { + if (!tags || tags.length === 0) return null; + + const allowedApiKeys = new Set(); + tags.forEach((tagName) => { + const group = userGroups.find((item) => item.name === tagName); + group?.enabledApis?.forEach((apiKey) => allowedApiKeys.add(apiKey)); + }); + return allowedApiKeys; + }, + [userGroups] + ); + + const getSelectableSourcesForUser = useCallback( + (user?: { tags?: string[] } | null) => { + const sources = config?.SourceConfig || []; + const groupAllowedApiKeys = getGroupAllowedApiKeys(user?.tags); + + if (!groupAllowedApiKeys) { + return sources; + } + + return sources.filter((source) => groupAllowedApiKeys.has(source.key)); + }, + [config?.SourceConfig, getGroupAllowedApiKeys] + ); + + const getEffectiveUserApiCount = useCallback( + (user: { enabledApis?: string[]; tags?: string[] }) => { + if (!user.enabledApis || user.enabledApis.length === 0) { + return null; + } + + const groupAllowedApiKeys = getGroupAllowedApiKeys(user.tags); + if (!groupAllowedApiKeys) { + return user.enabledApis.length; + } + + return user.enabledApis.filter((apiKey) => + groupAllowedApiKeys.has(apiKey) + ).length; + }, + [getGroupAllowedApiKeys] + ); + // 处理用户组相关操作 const handleUserGroupAction = async ( action: 'add' | 'edit' | 'delete', groupName: string, - enabledApis?: string[] + enabledApis?: string[], + safeSearchEnabled?: boolean ) => { return withLoading(`userGroup_${action}_${groupName}`, async () => { try { @@ -429,6 +510,7 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { groupAction: action, groupName, enabledApis, + safeSearchEnabled, }), }); @@ -440,14 +522,25 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { await refreshConfig(); if (action === 'add') { - setNewUserGroup({ name: '', enabledApis: [] }); + setNewUserGroup({ + name: '', + enabledApis: [], + safeSearchEnabled: false, + }); setShowAddUserGroupForm(false); } else if (action === 'edit') { setEditingUserGroup(null); setShowEditUserGroupForm(false); } - showSuccess(action === 'add' ? '用户组添加成功' : action === 'edit' ? '用户组更新成功' : '用户组删除成功', showAlert); + showSuccess( + action === 'add' + ? '用户组添加成功' + : action === 'edit' + ? '用户组更新成功' + : '用户组删除成功', + showAlert + ); } catch (err) { showError(err instanceof Error ? err.message : '操作失败', showAlert); throw err; @@ -457,23 +550,37 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { const handleAddUserGroup = () => { if (!newUserGroup.name.trim()) return; - handleUserGroupAction('add', newUserGroup.name, newUserGroup.enabledApis); + handleUserGroupAction( + 'add', + newUserGroup.name, + newUserGroup.enabledApis, + newUserGroup.safeSearchEnabled + ); }; const handleEditUserGroup = () => { if (!editingUserGroup?.name.trim()) return; - handleUserGroupAction('edit', editingUserGroup.name, editingUserGroup.enabledApis); + handleUserGroupAction( + 'edit', + editingUserGroup.name, + editingUserGroup.enabledApis, + editingUserGroup.safeSearchEnabled + ); }; const handleDeleteUserGroup = (groupName: string) => { // 计算会受影响的用户数量 - const affectedUsers = config?.UserConfig?.Users?.filter(user => - user.tags && user.tags.includes(groupName) - ) || []; + 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 })) + affectedUsers: affectedUsers.map((u) => ({ + username: u.username, + role: u.role, + })), }); setShowDeleteUserGroupModal(true); }; @@ -490,14 +597,26 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { } }; - const handleStartEditUserGroup = (group: { name: string; enabledApis: string[] }) => { - setEditingUserGroup({ ...group }); + const handleStartEditUserGroup = (group: { + name: string; + enabledApis: string[]; + safeSearchEnabled?: boolean; + safeSearchApi?: string; + }) => { + setEditingUserGroup({ + ...group, + safeSearchEnabled: + group.safeSearchEnabled === true || Boolean(group.safeSearchApi), + }); setShowEditUserGroupForm(true); setShowAddUserGroupForm(false); }; // 为用户分配用户组 - const handleAssignUserGroup = async (username: string, userGroups: string[]) => { + const handleAssignUserGroup = async ( + username: string, + userGroups: string[] + ) => { return withLoading(`assignUserGroup_${username}`, async () => { try { const res = await fetch('/api/admin/user', { @@ -529,21 +648,32 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { }; const handleUnbanUser = async (uname: string) => { - await withLoading(`unbanUser_${uname}`, () => handleUserAction('unban', uname)); + await withLoading(`unbanUser_${uname}`, () => + handleUserAction('unban', uname) + ); }; const handleSetAdmin = async (uname: string) => { - await withLoading(`setAdmin_${uname}`, () => handleUserAction('setAdmin', uname)); + await withLoading(`setAdmin_${uname}`, () => + handleUserAction('setAdmin', uname) + ); }; const handleRemoveAdmin = async (uname: string) => { - await withLoading(`removeAdmin_${uname}`, () => handleUserAction('cancelAdmin', uname)); + 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); + await handleUserAction( + 'add', + newUser.username, + newUser.password, + newUser.userGroup + ); setNewUser({ username: '', password: '', userGroup: '' }); setShowAddUserForm(false); }); @@ -551,15 +681,18 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { 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); - }); + await withLoading( + `changePassword_${changePasswordUser.username}`, + async () => { + await handleUserAction( + 'changePassword', + changePasswordUser.username, + changePasswordUser.password + ); + setChangePasswordUser({ username: '', password: '' }); + setShowChangePasswordForm(false); + } + ); }; const handleShowChangePasswordForm = (username: string) => { @@ -577,9 +710,17 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { username: string; role: 'user' | 'admin' | 'owner'; enabledApis?: string[]; + tags?: string[]; }) => { + const groupAllowedApiKeys = getGroupAllowedApiKeys(user.tags); setSelectedUser(user); - setSelectedApis(user.enabledApis || []); + setSelectedApis( + groupAllowedApiKeys + ? (user.enabledApis || []).filter((apiKey) => + groupAllowedApiKeys.has(apiKey) + ) + : user.enabledApis || [] + ); setShowConfigureApisModal(true); }; @@ -596,21 +737,27 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { 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 中处理 + 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 => { + setSelectedUsers((prev) => { const newSelectedUsers = new Set(prev); if (checked) { newSelectedUsers.add(username); @@ -621,20 +768,24 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { }); }, []); - 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 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) => { @@ -661,7 +812,10 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { setSelectedUsers(new Set()); setShowBatchUserGroupModal(false); setSelectedUserGroup(''); - showSuccess(`已为 ${userCount} 个用户设置用户组: ${userGroup}`, showAlert); + showSuccess( + `已为 ${userCount} 个用户设置用户组: ${userGroup}`, + showAlert + ); // 刷新配置 await refreshConfig(); @@ -672,8 +826,6 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { }); }; - - // 提取URL域名的辅助函数 const extractDomain = (url: string): string => { try { @@ -794,8 +946,6 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
- - {/* 用户组管理 */}
@@ -810,7 +960,11 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { setEditingUserGroup(null); } }} - className={showAddUserGroupForm ? buttonStyles.secondary : buttonStyles.primary} + className={ + showAddUserGroupForm + ? buttonStyles.secondary + : buttonStyles.primary + } > {showAddUserGroupForm ? '取消' : '添加用户组'} @@ -834,7 +988,10 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { {userGroups.map((group) => ( - + {group.name} @@ -843,7 +1000,7 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { {group.enabledApis && group.enabledApis.length > 0 ? `${group.enabledApis.length} 个源` - : '无限制'} + : '0 个源'}
@@ -851,7 +1008,11 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { @@ -866,7 +1027,10 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { ))} {userGroups.length === 0 && ( - + 暂无用户组,请添加用户组来管理用户权限 @@ -908,7 +1072,9 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { setChangePasswordUser({ username: '', password: '' }); } }} - className={showAddUserForm ? buttonStyles.secondary : buttonStyles.success} + className={ + showAddUserForm ? buttonStyles.secondary : buttonStyles.success + } > {showAddUserForm ? '取消' : '添加用户'} @@ -925,18 +1091,24 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { placeholder='用户名' value={newUser.username} onChange={(e) => - setNewUser((prev) => ({ ...prev, username: e.target.value })) + 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' + 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-theme-primary focus:border-transparent' /> - setNewUser((prev) => ({ ...prev, password: e.target.value })) + 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' + 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-theme-primary focus:border-transparent' />
@@ -946,14 +1118,21 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { @@ -961,8 +1140,18 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
@@ -995,14 +1184,24 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => { 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' + 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-theme-primary focus:border-transparent' />