feature: 新增UI支持 (#105)

* 增加环境变量处理类

* 完善node-handler.js

* 完善node-handler.js

* 完善vercel-handler.js

* 增加netlify-handler

* 修改netlify-handler.js

* 完善netlify-handler.js

* 增加cloudflare-handler.js

* 完善cloudflare-handler.js

* 完善cloudflare-handler.js

* 新增edgeone-handler.js

* edgeone和vercel增加部署函数

* 增加部署平台环境变量配置指南

* 优化小点

* 增加UI界面,非真实数据,main.js待后续拆分

* 增加当前版本和最新版本通过后端接口获取

* 修复云端部署的问题

* 前端使用分离文件模块

* 前端使用分离文件模块

* 在html和script上使用globals全局变量信息

* 使用 Language Injection 注释,支持语法高亮、补全、格式化、跳转

* 清理ui无用文件

* 优化缓存清理和重新部署确认弹窗UI

* 修改调试api

* 使用正式调试api返回数据

* 完善接口调试实际接口数据

* 日志查看调用真实数据

* 修改成系统配置

* 合并冲突

* 配置预览使用真实数据

* 提取log接口函数到system-api

* ui上增加API端点展示

* 系统配置里的环境变量使用真实数据

* 删除添加配置按钮,优化变量值输入框

* 系统设置环境变量从原始环境变量获取

* 增删改环境变量对接真实数据

* 修复ui请求报401的问题

* 优化deployPlatform

* 数字类型的环境变量增加最小值和最大值限制

* 重新部署对接真实接口

* 修复云服务部署

* 日志中隐藏敏感字符

* 清理缓存打通真实数据接口

* 将main.js中的css提取到css文件中

* 增加ADMIN_TOKEN环境变量

* 完善admin_token访问权限控制

* 增加NODE_TLS_REJECT_UNAUTHORIZED,用于控制在建立 HTTPS 连接时是否验证服务器的 SSL/TLS 证书

* 所有alert都替换成自定义样式弹窗

* 增加部署平台的检查,如果没有配置部署相关的环境变量,也提示需要配置

* 没有token的情况下访问不了日志查看和接口调试

* 增加推送弹幕模块

* 修复推送弹幕

* 推送弹幕界面当点击一个anime获取到剧集列表后直接跳转到剧集列表处

* 弹幕推送ui适配手机样式

* 推送弹幕中的按钮修改为点击后加载转圈

* 推送校验地址挪到推送按钮

* 所有确认弹窗都改成适配ui样式

* 优化API端点显示

* 修复如果是默认token则用admin_token访问不了的问题

* 抽出css到css文件

* 修改handler-factory.js,将各个import的语句挪到switch相对应的case里,延迟引入

* 测试下

* 再测试下

* 尝试解决cloudflare和edgeone部署失败

* 再次尝试解决cloudflare和edgeone部署失败

* 再次尝试解决cloudflare和edgeone部署失败+1

* 再次尝试解决cloudflare和edgeone部署失败+2

* 再次尝试解决cloudflare和edgeone部署失败+3

* 再次尝试解决cloudflare和edgeone部署失败+4

* 修复弹幕推送跨域问题

* 修改标题等小细节

* 修复一些小细节

* 添加ui界面的readme

* 将handlers下的README挪到UI目录下的README

* 修改readme

* readme增加目录指引

* 刷新目录结构

* main.js中的init里的非函数抽出函数

* 拆分main.js,单独拆出preview.js

* 抽出日志查看模块

* 提取出接口调试部分

* 提取出弹幕推送模块

* 抽出系统配置模块js

* 更新项目结构

* 配置预览优化显示

* 系统配置变量值背景色修改

* 修改右上角的五个按钮布局

* 修复token非默认值的情况下api端点直接显示了token的问题

* 修复在非默认值TOKEN的情况下访问url api端点显示了token的问题

* 修改README

* 增加日志打印deployPlatform

* 调试vercel参数

* 修复vercel平台部署被判断为cloudfalre的问题

* 修复/api/config获取在没有权限的情况下不应该返回私密信息的问题

* 修复api/config接口的权限问题

* 修复日志查看、接口调试和推送弹幕模块默认token场景下没有token也能访问

* 优化currentToken获取

* main.js中部分代码挪到其他js

* 测试vercel部署方式判断

* 修复部署平台参数的问题

* 修复部署平台的几个参数问题

* 修复没有环境变量的情况下设置失败的问题

* 挪动修改环境变量代码

* 修改环境变量描述

* 修改变量的值和描述的字体颜色

* 修改cloudflare才是需要DEPLOY_PLATFROM_ACCOUNT的平台

* 添加环境变量修改后说明

* 修改重新部署时长

* 修改版本号
This commit is contained in:
huangxd- authored and GitHub committed 2025-12-07 16:42:16 +08:00
1 parent 55da0c489e
commit 995d5f10e0
31 files changed
+5490 -121

No files matched your search

+22
View File
@@ -21,6 +21,11 @@
# 默认值:87654321
TOKEN=87654321
# 系统管理访问令牌(用于系统管理功能)
# 如果未配置此值,则无法访问系统管理功能,需要先配置后在URL中填入此token才能打开系统管理
# 默认值:空(不启用系统管理访问控制)
# ADMIN_TOKEN=
# 其他服务器地址(第三方弹幕服务器)
# 默认值:https://api.danmu.icu
OTHER_SERVER=https://api.danmu.icu
@@ -244,3 +249,20 @@ MAX_LAST_SELECT_MAP=100
# 默认值:空
# UPSTASH_REDIS_REST_TOKEN=
# ==================== 部署平台API接口信息 配置 ====================
# 部署账号ID,调用部署服务API需要,配置后可使用UI界面配置服务
# 默认值:空
#DEPLOY_PLATFROM_ACCOUNT=
# 部署项目名称,调用部署服务API需要,配置后可使用UI界面配置服务
# 默认值:空
#DEPLOY_PLATFROM_PROJECT=
# 部署平台token,调用部署服务API需要,配置后可使用UI界面配置服务
# 默认值:空
#DEPLOY_PLATFROM_TOKEN=
# 在建立 HTTPS 连接时是否验证服务器的 SSL/TLS 证书,0表示忽略
# 默认值:1
# NODE_TLS_REJECT_UNAUTHORIZED=1
+112 -58
View File
@@ -27,6 +27,24 @@ LogVar 弹幕 API 服务器
> 请不要在国内媒体平台宣传本项目!
# 目录
- [功能](#功能)
- [前置条件](#前置条件)
- [本地运行](#本地运行)
- [使用 Docker 运行](#使用-docker-运行)
- [Docker 一键启动 【推荐】](#docker-一键启动-推荐)
- [部署到 Vercel 【推荐】](#部署到-vercel-推荐)
- [部署到 Netlify 【推荐】](#部署到-netlify-推荐)
- [部署到 腾讯云 edgeone pages](#部署到-腾讯云-edgeone-pages)
- [部署到 Cloudflare](#部署到-cloudflare)
- [API食用指南](#api食用指南)
- [环境变量列表](#环境变量列表)
- [采集源及对应平台列表](#采集源及对应平台列表)
- [项目结构](#项目结构)
- [注意事项](#注意事项)
- [贡献者](#贡献者)
## 功能
- **API 接口**:
- `GET /api/v2/search/anime?keyword=${queryTitle}`:根据关键字搜索动漫。
@@ -57,6 +75,12 @@ LogVar 弹幕 API 服务器
- 转换弹幕颜色为白色或彩色(`CONVERT_COLOR`)
- 解决部分播放器不支持顶部/底部弹幕和彩色弹幕的问题
- **弹幕限制数量**:支持通过环境变量配置等间隔采样弹幕数量。
- **UI界面-后台配置管理系统**:支持通过UI执行一些操作(详细见 [UI 系统使用说明](https://github.com/huangxd-/danmu_api/tree/main/danmu_api/ui/README.md) ),包括:
- 配置预览
- 日志查看
- 接口调试
- 推送弹幕
- 系统管理
## 前置条件
- Node.js(v18.0.0 或更高版本;理论兼容更低版本,请自行测试)
@@ -268,12 +292,12 @@ LogVar 弹幕 API 服务器
> cf部署可能不稳定,推荐用vercel/netlify部署。
## API食用指南
支持 forward/senplayer/hills/小幻/yamby/eplayerx/afusekt/uz影视/dscloud/lenna/danmaku-anywhere 等支持弹幕API的播放器。
支持 forward/senplayer/hills/小幻/yamby/eplayerx/afusekt/uz影视/dscloud/lenna/danmaku-anywhere/omnibox 等支持弹幕API的播放器。
配合 dd-danmaku 扩展新增对 Emby Web 端弹幕的支持,具体使用方法参考 [PR #98](https://github.com/huangxd-/danmu_api/pull/98) 。
以`senplayer`为例:
1. 获取到部署之后的API地址,如 `http://192.168.1.7:9321/87654321` ,其中`87654321`是默认token(默认为87654321的情况下也可以不带token),如果有自定义环境变量TOKEN,请替换成相应的token
1. 获取到部署之后的API地址,如 `http://192.168.1.7:9321/87654321` ,其中`87654321`是默认token(默认为87654321的情况下也可以不带token),如果有自定义环境变量TOKEN,请替换成相应的token;API地址也可直接在UI界面上点击API端点直接复制
2. 将API地址填入自定义弹幕API,在`设置 - 弹幕设置 - 自定义弹幕API`
3. 播放界面点击`弹幕按钮 - 搜索弹幕`,选择你的弹幕API,会根据标题进行搜索,等待一段时间,选择剧集就行。
<img src="https://i.mji.rip/2025/09/14/1dae193008f23e507d3cc3733a92f0a1.jpeg" style="width:400px" />
@@ -327,6 +351,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
| 变量名称 | 描述 |
| ----------- | ----------- |
| TOKEN | 【可选】自定义用户token,不填默认为`87654321` |
| ADMIN_TOKEN | 【可选】系统管理访问令牌,如果未配置此值,则无法访问系统管理功能,需要先配置后在URL中填入此token才能打开系统管理 |
| OTHER_SERVER | 【可选】兜底第三方弹幕服务器,不填默认为`https://api.danmu.icu`,其他可选:`https://fc.lyz05.cn`,`https://dmku.hls.one`,`https://se.678.ooo`,`https://danmu.56uxi.com`,`https://dm.lxlad.com` |
| VOD_SERVERS | 【可选】VOD服务器列表,支持多个服务器并发查询,格式:`名称@URL,名称@URL,...`,示例:`金蝉@https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风@https://gctf.tfdh.top`,不填默认为`金蝉@https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风@https://gctf.tfdh.top` |
| VOD_RETURN_MODE | 【可选】VOD返回模式,可选值:`all`(返回所有站点结果)、`fastest`(只返回最快的站点结果),默认为`fastest`。当配置多个VOD站点时,`all`模式会返回所有站点的结果(结果较多),`fastest`模式只返回首先响应成功的站点结果(结果较少,避免重复) |
@@ -356,12 +381,16 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
| MAX_LAST_SELECT_MAP | 【可选】最后选择映射缓存大小限制,默认为`100`,lastSelectMap最多保存的条目数,超过限制时删除最早的条目(FIFO),用于存储查询关键字上次选择的animeId |
| UPSTASH_REDIS_REST_URL | 【可选】Upstash redis url,需配合UPSTASH_REDIS_REST_TOKEN使用,用于持久化存储,不会因为冷启动而丢失过去的查询信息(在cf/eo/claw上配置后应该能更稳定点,也能解决小幻掉匹配的问题,但会稍微影响请求速度),获取方法请参考:`https://cloud.tencent.cn/developer/article/2424508` |
| UPSTASH_REDIS_REST_TOKEN | 【可选】Upstash redis token,需配合UPSTASH_REDIS_REST_URL使用,用于持久化存储,不会因为冷启动而丢失过去的查询信息(在cf/eo/claw上配置后应该能更稳定点,也能解决小幻掉匹配的问题,但会稍微影响请求速度),获取方法请参考:`https://cloud.tencent.cn/developer/article/2424508` |
| DEPLOY_PLATFROM_ACCOUNT | 【可选】部署账号ID,调用部署服务API需要,配置后可使用UI界面配置服务,不同部署平台获取方式可查看 [部署平台环境变量配置指南](https://github.com/huangxd-/danmu_api/tree/main/danmu_api/ui/README.md#部署平台环境变量配置指南) ,docker部署和本地node部署并不需要配置 |
| DEPLOY_PLATFROM_PROJECT | 【可选】部署项目名称,调用部署服务API需要,配置后可使用UI界面配置服务,不同部署平台获取方式可查看 [部署平台环境变量配置指南](https://github.com/huangxd-/danmu_api/tree/main/danmu_api/ui/README.md#部署平台环境变量配置指南) ,docker部署和本地node部署并不需要配置 |
| DEPLOY_PLATFROM_TOKEN | 【可选】部署平台token,调用部署服务API需要,配置后可使用UI界面配置服务,不同部署平台获取方式可查看 [部署平台环境变量配置指南](https://github.com/huangxd-/danmu_api/tree/main/danmu_api/ui/README.md#部署平台环境变量配置指南) ,docker部署和本地node部署并不需要配置 |
| NODE_TLS_REJECT_UNAUTHORIZED | 【可选】在建立 HTTPS 连接时是否验证服务器的 SSL/TLS 证书,0表示忽略,默认为1 |
```regex
# EPISODE_TITLE_FILTER 默认值
(特别|惊喜|纳凉)?企划|合伙人手记|超前(营业|vlog)?|速览|vlog|reaction|纯享|加更(版|篇)?|抢先(看|版|集|篇)?|抢鲜|预告|花絮(独家)?|特辑|彩蛋|专访|幕后(故事|花絮|独家)?|直播(陪看|回顾)?|未播(片段)?|衍生|番外|会员(专享|加长|尊享|专属|版)?|片花|精华|看点|速看|解读|影评|解说|吐槽|盘点|拍摄花絮|制作花絮|幕后花絮|未播花絮|独家花絮|花絮特辑|先导预告|终极预告|正式预告|官方预告|彩蛋片段|删减片段|未播片段|番外彩蛋|精彩片段|精彩看点|精彩回顾|精彩集锦|看点解析|看点预告|NG镜头|NG花絮|番外篇|番外特辑|制作特辑|拍摄特辑|幕后特辑|导演特辑|演员特辑|片尾曲|插曲|高光回顾|背景音乐|OST|音乐MV|歌曲MV|前季回顾|剧情回顾|往期回顾|内容总结|剧情盘点|精选合集|剪辑合集|混剪视频|独家专访|演员访谈|导演访谈|主创访谈|媒体采访|发布会采访|采访|陪看(记)?|试看版|短剧|精编|Plus|独家版|特别版|短片|发布会|解忧局|走心局|火锅局|巅峰时刻|坞里都知道|福持目标坞民|.{3,}篇|(?!.*(入局|破冰局|做局)).{2,}局|观察室|上班那点事儿|周top|赛段|直拍|REACTION|VLOG|全纪录|开播|先导|总宣|展演|集锦|旅行日记|精彩分享|剧情揭秘
# 如果你想新增过滤词,请自定义EPISODE_TITLE_FILTER,示例如下,每个词用'|'隔开,增加的词都会追加到默认值后面
# 如果你想自定义过滤词,请新增EPISODE_TITLE_FILTER环境变量,示例如下,每个词用'|'隔开,也可参照默认值填写
测试|test
```
@@ -410,66 +439,91 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
danmu_api/
├── .github/
│ └── workflows/
│ ├── docker-image.yml
│ └── sync_fork.yml # vercel自动同步配置文件
├── danmu_api/
│ └── apis/
│ └── dandan-api.js # 弹弹play兼容接口函数
│ └── configs/
│ ├── envs.js # 环境变量处理脚本
│ └── globals.js # 全局变量处理脚本
│ └── models/
│ └── dandan-model.js # 弹弹play数据模型
│ └── sources/
│ ├── bahamut.js # 巴哈姆特源
│ ├── base.js # 弹幕源获取基类
│ ├── bilibili.js # b站源
│ ├── dandan.js # 弹弹play源
│ ├── douban.js # 豆瓣源
│ ├── hanjutv.js # 韩剧TV源
│ ├── iqiyi.js # 爱奇艺源
│ ├── kan360.js # 360看源
│ ├── mango.js # 芒果TV源
│ ├── other.js # 第三方弹幕服务器
│ ├── renren.js # 人人视频源
│ ├── tencent.js # 腾讯视频源
│ ├── tmdb.js # TMDB源
│ ├── vod.js # vod源
│ └── youku.js # 优酷源
│ └── utils/
│ ├── cache-util.js # 缓存数据处理工具
│ ├── codec-util.js # 编解码工具
│ ├── common-util.js # 通用工具
│ ├── danmu-util.js # 弹幕处理工具
│ ├── douban-util.js # 豆瓣API请求工具
│ ├── http-util.js # 请求工具
│ ├── imdb-util.js # IMDB API请求工具
│ ├── log-util.js # 日志工具
│ ├── redis-util.js # redis工具
│ ├── time-util.js # 时间日期工具
│ ├── tmdb-util.js # TMDB API请求处理工具
│ └── zh-util.js # 中文繁简转换工具
│ ├── esm-shim.js # Node.js低版本兼容层
│ ├── server.js # 本地node启动脚本
│ ├── worker.js # 主 API 服务器代码
│ └── worker.test.js # 测试文件
├── netlify/
│ └── functions/
│ └── api.js # netlify 中间处理逻辑
├── node-functions/
│ ├── [[...path]]..js # edgeone pages 所有路由跳转指向index
│ └── index.js # edgeone pages 中间处理逻辑
├── .env.example # .env 配置文件示例
├── config.yaml.example # YAML 配置文件示例(无法编辑 .env 时使用)
│ ├── docker-image.yml # Docker镜像构建和推送工作流
│ └── sync_fork.yml # Fork同步上游仓库工作流(vercel自动同步配置文件)
├── .env.example # .env 配置文件示例
├── .gitignore
├── config.yaml.example # YAML 配置文件示例(无法编辑 .env 时使用)
├── Dockerfile
├── edgeone.json # edgeone pages 配置文件
├── edgeone.json # edgeone pages 配置文件
├── LICENSE
├── netlify.toml # netlify 配置文件
├── netlify.toml # netlify 配置文件
├── package.json
├── README.md
├── vercel.json # vercel 配置文件
└── wrangler.toml # cloudflare worker 配置文件
├── vercel.json # vercel 配置文件
├── wrangler.toml # cloudflare worker 配置文件
├── danmu_api/
│ ├── esm-shim.js # Node.js低版本兼容层
│ ├── server.js # 本地node启动脚本
│ ├── worker.js # 主 API 服务器代码
│ ├── worker.test.js # 测试文件
│ ├── apis/
│ │ ├── dandan-api.js # 弹弹play兼容接口函数
│ │ ├── env-api.js # 环境变量接口函数
│ │ └── system-api.js # 系统管理接口函数
│ ├── configs/
│ │ ├── envs.js # 环境变量处理脚本
│ │ └── globals.js # 全局变量处理脚本
│ │ └── handlers/ # 部署平台API调用及环境变量处理类
│ │ ├── base-handler.js
│ │ ├── cloudflare-handler.js
│ │ ├── edgeone-handler.js
│ │ ├── handler-factory.js
│ │ ├── netlify-handler.js
│ │ ├── node-handler.js
│ │ └── vercel-handler.js
│ ├── models/
│ │ └── dandan-model.js # 弹弹play数据模型
│ ├── sources/
│ │ ├── bahamut.js # 巴哈姆特源
│ │ ├── base.js # 弹幕源获取基类
│ │ ├── bilibili.js # b站源
│ │ ├── dandan.js # 弹弹play源
│ │ ├── douban.js # 豆瓣源
│ │ ├── hanjutv.js # 韩剧TV源
│ │ ├── iqiyi.js # 爱奇艺源
│ │ ├── kan360.js # 360看源
│ │ ├── mango.js # 芒果TV源
│ │ ├── other.js # 第三方弹幕服务器
│ │ ├── renren.js # 人人视频源
│ │ ├── tencent.js # 腾讯视频源
│ │ ├── tmdb.js # TMDB源
│ │ ├── vod.js # vod源
│ │ └── youku.js # 优酷源
│ ├── ui/
│ │ ├── README.md # UI系统使用说明
│ │ ├── template.js # UI模板文件
│ │ ├── css/
│ │ │ ├── base.css.js # 基础样式
│ │ │ ├── components.css.js # 组件样式
│ │ │ ├── forms.css.js # 表单样式
│ │ │ └── responsive.css.js # 响应式样式
│ │ └── js/
│ │ ├── apitest.js # API测试脚本
│ │ ├── logview.js # 日志查看脚本
│ │ ├── main.js # UI主脚本
│ │ ├── preview.js # 预览功能脚本
│ │ ├── pushdanmu.js # 推送弹幕脚本
│ │ └── systemsettings.js # 系统设置脚本
│ └── utils/
│ ├── cache-util.js # 缓存数据处理工具
│ ├── codec-util.js # 编解码工具
│ ├── common-util.js # 通用工具
│ ├── danmu-util.js # 弹幕处理工具
│ ├── douban-util.js # 豆瓣API请求工具
│ ├── http-util.js # 请求工具
│ ├── imdb-util.js # IMDB API请求工具
│ ├── log-util.js # 日志工具
│ ├── redis-util.js # redis工具
│ ├── time-util.js # 时间日期工具
│ ├── tmdb-util.js # TMDB API请求处理工具
│ └── zh-util.js # 中文繁简转换工具
├── netlify/
│ └── functions/
│ └── api.js # netlify 中间处理逻辑
└── node-functions/
├── [[...path]]..js # edgeone pages 所有路由跳转指向index
└── index.js # edgeone pages 中间处理逻辑
```
## 注意事项
+22
View File
@@ -17,6 +17,11 @@
# 默认值:87654321
TOKEN: "87654321"
# 系统管理访问令牌(用于系统管理功能)
# 如果未配置此值,则无法访问系统管理功能,需要先配置后在URL中填入此token才能打开系统管理
# 默认值:空(不启用系统管理访问控制)
# ADMIN_TOKEN:
# 其他服务器地址(第三方弹幕服务器)
# 默认值:https://api.danmu.icu
OTHER_SERVER: "https://api.danmu.icu"
@@ -238,3 +243,20 @@ MAX_LAST_SELECT_MAP: 100
# 默认值:空
# UPSTASH_REDIS_REST_TOKEN: ""
# ==================== 部署平台API接口信息 配置 ====================
# 部署账号ID,调用部署服务API需要,配置后可使用UI界面配置服务
# 默认值:空
#DEPLOY_PLATFROM_ACCOUNT: ""
# 部署项目名称,调用部署服务API需要,配置后可使用UI界面配置服务
# 默认值:空
#DEPLOY_PLATFROM_PROJECT: ""
# 部署平台token,调用部署服务API需要,配置后可使用UI界面配置服务
# 默认值:空
#DEPLOY_PLATFROM_TOKEN: ""
# 在建立 HTTPS 连接时是否验证服务器的 SSL/TLS 证书,0表示忽略
# 默认值:1
# NODE_TLS_REJECT_UNAUTHORIZED: 1
+96
View File
@@ -0,0 +1,96 @@
import { jsonResponse } from '../utils/http-util.js';
import { HandlerFactory } from '../configs/handlers/handler-factory.js';
import { globals } from '../configs/globals.js';
/**
* 处理设置环境变量的请求
*/
export async function handleSetEnv(request) {
try {
const { key, value } = await request.json();
if (!key) {
return jsonResponse({ success: false, message: '缺少环境变量名称' }, 400);
}
// 获取当前部署平台
const deployPlatform = globals.deployPlatform;
// 根据部署平台获取相应的handler
const handler = await HandlerFactory.getHandler(deployPlatform);
// 调用handler的setEnv方法
const result = await handler.setEnv(key, value);
if (result) {
return jsonResponse({ success: true, message: `环境变量 ${key} 设置成功` });
} else {
return jsonResponse({ success: false, message: `环境变量 ${key} 设置失败` }, 500);
}
} catch (error) {
console.error('设置环境变量失败:', error);
return jsonResponse({ success: false, message: `设置环境变量失败: ${error.message}` }, 500);
}
}
/**
* 处理添加环境变量的请求
*/
export async function handleAddEnv(request) {
try {
const { key, value } = await request.json();
if (!key) {
return jsonResponse({ success: false, message: '缺少环境变量名称' }, 400);
}
// 获取当前部署平台
const deployPlatform = globals.deployPlatform ;
// 根据部署平台获取相应的handler
const handler = await HandlerFactory.getHandler(deployPlatform);
// 调用handler的addEnv方法
const result = await handler.addEnv(key, value);
if (result) {
return jsonResponse({ success: true, message: `环境变量 ${key} 添加成功` });
} else {
return jsonResponse({ success: false, message: `环境变量 ${key} 添加失败` }, 500);
}
} catch (error) {
console.error('添加环境变量失败:', error);
return jsonResponse({ success: false, message: `添加环境变量失败: ${error.message}` }, 500);
}
}
/**
* 处理删除环境变量的请求
*/
export async function handleDelEnv(request) {
try {
const { key } = await request.json();
if (!key) {
return jsonResponse({ success: false, message: '缺少环境变量名称' }, 400);
}
// 获取当前部署平台
const deployPlatform = globals.deployPlatform;
// 根据部署平台获取相应的handler
const handler = await HandlerFactory.getHandler(deployPlatform);
// 调用handler的delEnv方法
const result = await handler.delEnv(key);
if (result) {
return jsonResponse({ success: true, message: `环境变量 ${key} 删除成功` });
} else {
return jsonResponse({ success: false, message: `环境变量 ${key} 删除失败` }, 500);
}
} catch (error) {
console.error('删除环境变量失败:', error);
return jsonResponse({ success: false, message: `删除环境变量失败: ${error.message}` }, 500);
}
}
+198
View File
@@ -0,0 +1,198 @@
import { globals } from "../configs/globals.js";
import { jsonResponse } from "../utils/http-util.js";
import { HTML_TEMPLATE } from "../ui/template.js";
import { formatLogMessage, log } from "../utils/log-util.js";
import { HandlerFactory } from "../configs/handlers/handler-factory.js";
export function handleUI() {
return new Response(HTML_TEMPLATE.replace("globals.currentToken", globals.currentToken), {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Access-Control-Allow-Origin': '*'
}
});
}
export function handleConfig(hasPermission = false) {
// 获取环境变量配置
const envVarConfig = globals.envVarConfig;
// 分类环境变量
const categorizedVars = {
api: [],
source: [],
match: [],
danmu: [],
cache: [],
system: []
};
// 获取所有环境变量 - 这是用于配置预览的
const previewEnvVars = {
...globals.accessedEnvVars,
localCacheValid: globals.localCacheValid,
redisValid: globals.redisValid,
deployPlatform: globals.deployPlatform
};
// 将环境变量按分类组织 - 使用原始环境变量进行分类,但保持预览格式
Object.keys(previewEnvVars).forEach(key => {
const varConfig = envVarConfig[key] || { category: 'system', type: 'text', description: '未分类配置项' };
const category = varConfig.category || 'system';
categorizedVars[category].push({
key: key,
value: previewEnvVars[key].value || previewEnvVars[key], // 如果是新格式则取value字段,否则直接使用原值
type: previewEnvVars[key].type || varConfig.type || 'text', // 如果是新格式则取type字段,否则使用配置中的type或默认text
description: varConfig.description || '无描述',
options: previewEnvVars[key].options || varConfig.options // 如果是新格式则取options字段
});
});
// 检查是否配置了ADMIN_TOKEN
const adminToken = globals.adminToken || '';
const hasAdminToken = adminToken.trim() !== '';
// 准备原始环境变量,无权限时也需要脱敏
let originalEnvVars = { ...globals.originalEnvVars };
if (!hasPermission || globals.currentToken !== globals.adminToken) {
Object.keys(originalEnvVars).forEach(key => {
if (globals.currentToken !== globals.token || key !== "TOKEN") {
if (key in previewEnvVars && /^\*+$/.test(previewEnvVars[key])) {
originalEnvVars[key] = previewEnvVars[key];
}
}
});
}
return jsonResponse({
message: "Welcome to the LogVar Danmu API server",
version: globals.VERSION,
envs: previewEnvVars, // 配置预览使用
categorizedEnvVars: categorizedVars,
envVarConfig: envVarConfig,
originalEnvVars: originalEnvVars, // 系统设置使用原始环境变量(已脱敏)
hasAdminToken: hasAdminToken, // 添加admin token配置状态
repository: "https://github.com/huangxd-/danmu_api.git",
description: "一个人人都能部署的基于 js 的弹幕 API 服务器,支持爱优腾芒哔人韩巴弹幕直接获取,兼容弹弹play的搜索、详情查询和弹幕获取接口规范,并提供日志记录,支持vercel/netlify/edgeone/cloudflare/docker/claw等部署方式,不用提前下载弹幕,没有nas或小鸡也能一键部署。",
notice: "本项目仅为个人爱好开发,代码开源。如有任何侵权行为,请联系本人删除。有问题提issue或私信机器人都ok,TG MSG ROBOT: [https://t.me/ddjdd_bot]; 推荐加互助群咨询,TG GROUP: [https://t.me/logvar_danmu_group]; 关注频道获取最新更新内容,TG CHANNEL: [https://t.me/logvar_danmu_channel]。"
});
}
/**
* 处理重新部署请求
* @returns {Response} 部署操作结果
*/
export async function handleDeploy() {
try {
const deployPlatform = globals.deployPlatform;
log("info", `[server] Deployment request received for platform: ${deployPlatform}`);
// 如果是 Node 部署,直接返回成功,因为 Node 环境不需要重新部署
if (deployPlatform.toLowerCase() === 'node') {
log("info", `[server] Node/Docker deployment - no redeployment needed, config changes take effect automatically`);
return jsonResponse({ success: true, message: "Node/Docker deployment - configuration changes take effect automatically" }, 200);
}
// 对于其他平台(如 Cloudflare、Vercel、Netlify 等),使用相应的 Handler 触发部署
const handler = await HandlerFactory.getHandler(deployPlatform);
if (!handler) {
log("error", `[server] No handler found for platform: ${deployPlatform}`);
return jsonResponse({ success: false, message: `No handler found for platform: ${deployPlatform}` }, 400);
}
// 调用 handler 的 deploy 方法
const deployResult = await handler.deploy();
if (deployResult) {
log("info", `[server] Deployment triggered successfully for platform: ${deployPlatform}`);
return jsonResponse({ success: true, message: "Deployment triggered successfully" }, 200);
} else {
log("error", `[server] Failed to trigger deployment for platform: ${deployPlatform}`);
return jsonResponse({ success: false, message: "Failed to trigger deployment" }, 500);
}
} catch (error) {
log("error", `[server] Deployment error: ${error.message}`);
return jsonResponse({ success: false, message: `Deployment failed: ${error.message}` }, 500);
}
}
/**
* 处理获取日志的请求
* @returns {Response} 包含日志文本的响应
*/
export function handleLogs() {
const logText = globals.logBuffer
.map(
(log) =>
`[${log.timestamp}] ${log.level}: ${formatLogMessage(log.message)}`
)
.join("\n");
return new Response(logText, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
}
/**
* 处理清除日志的请求
* @returns {Response} 表示操作成功的响应
*/
export function handleClearLogs() {
globals.logBuffer = [];
return jsonResponse({ success: true, message: "Logs cleared" }, 200);
}
/**
* 处理清理缓存的请求
* @returns {Response} 表示操作成功的响应
*/
export async function handleClearCache() {
try {
// 清理 globals 中的缓存数据
globals.animes = [];
globals.episodeIds = [];
globals.episodeNum = 10001; // 重置为初始值
globals.lastSelectMap = new Map(); // 重新创建 Map 对象
// 清理搜索和弹幕缓存
globals.searchCache = new Map();
globals.commentCache = new Map();
globals.requestHistory = new Map();
log("info", `[server] Memory cache cleared successfully`);
// 同步清理本地缓存和Redis缓存
try {
// 如果本地缓存有效,更新本地缓存
if (globals.localCacheValid) {
const { updateLocalCaches } = await import("../utils/cache-util.js");
await updateLocalCaches();
log("info", `[server] Local cache cleared successfully`);
}
} catch (localError) {
log("warn", `[server] Local cache may not be available: ${localError.message}`);
}
try {
// 如果Redis有效,更新Redis缓存
if (globals.redisValid) {
const { updateRedisCaches } = await import("../utils/redis-util.js");
await updateRedisCaches();
log("info", `[server] Redis cache cleared successfully`);
}
} catch (redisError) {
log("warn", `[server] Redis may not be available: ${redisError.message}`);
}
log("info", `[server] All caches cleared successfully`);
return jsonResponse({ success: true, message: "Cache cleared successfully", clearedItems: {
animes: 0,
episodeIds: 0,
episodeNum: 10001,
lastSelectMap: 0,
searchCache: 0,
commentCache: 0,
requestHistory: 0
}}, 200);
} catch (error) {
log("error", `[server] Cache clear failed: ${error.message}`);
return jsonResponse({ success: false, message: `Cache clear failed: ${error.message}` }, 500);
}
}
+78 -15
View File
@@ -6,6 +6,7 @@ export class Envs {
static env;
// 记录获取过的环境变量
static originalEnvVars = new Map();
static accessedEnvVars = new Map();
static VOD_ALLOWED_PLATFORMS = ['qiyi', 'bilibili1', 'imgo', 'youku', 'qq']; // vod允许的播放平台
@@ -23,10 +24,13 @@ export class Envs {
let value;
if (typeof this.env !== 'undefined' && this.env[key]) {
value = this.env[key];
this.originalEnvVars.set(key, value);
} else if (typeof process !== 'undefined' && process.env?.[key]) {
value = process.env[key];
this.originalEnvVars.set(key, value);
} else {
value = defaultValue;
this.originalEnvVars.set(key, "");
}
let parsedValue;
@@ -75,10 +79,9 @@ export class Envs {
/**
* 解析 VOD 服务器配置
* @param {Object} env 环境对象
* @returns {Array} 服务器列表
*/
static resolveVodServers(env) {
static resolveVodServers() {
const defaultVodServers = '金蝉@https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风@https://gctf.tfdh.top';
let vodServersConfig = this.get('VOD_SERVERS', defaultVodServers, 'string');
@@ -102,11 +105,9 @@ export class Envs {
/**
* 解析源排序
* @param {Object} env 环境对象
* @param {string} deployPlatform 部署平台
* @returns {Array} 源排序数组
*/
static resolveSourceOrder(env, deployPlatform) {
static resolveSourceOrder() {
let sourceOrder = this.get('SOURCE_ORDER', '360,vod,renren,hanjutv', 'string');
const orderArr = sourceOrder
@@ -121,10 +122,9 @@ export class Envs {
/**
* 解析平台排序
* @param {Object} env 环境对象
* @returns {Array} 平台排序数组
*/
static resolvePlatformOrder(env) {
static resolvePlatformOrder() {
const orderArr = this.get('PLATFORM_ORDER', '', 'string')
.split(',')
.map(s => s.trim())
@@ -137,10 +137,9 @@ export class Envs {
/**
* 解析剧集标题过滤正则
* @param {Object} env 环境对象
* @returns {RegExp} 过滤正则表达式
*/
static resolveEpisodeTitleFilter(env) {
static resolveEpisodeTitleFilter() {
const defaultFilter = '(特别|惊喜|纳凉)?企划|合伙人手记|超前(营业|vlog)?|速览|vlog|reaction|纯享|加更(版|篇)?|抢先(看|版|集|篇)?|抢鲜|预告|花絮(独家)?|' +
'特辑|彩蛋|专访|幕后(故事|花絮|独家)?|直播(陪看|回顾)?|未播(片段)?|衍生|番外|会员(专享|加长|尊享|专属|版)?|片花|精华|看点|速看|解读|影评|解说|吐槽|盘点|拍摄花絮|制作花絮|幕后花絮|未播花絮|独家花絮|' +
'花絮特辑|先导预告|终极预告|正式预告|官方预告|彩蛋片段|删减片段|未播片段|番外彩蛋|精彩片段|精彩看点|精彩回顾|精彩集锦|看点解析|看点预告|' +
@@ -164,7 +163,14 @@ export class Envs {
}
/**
* 解析弹幕转换颜色
* 获取记录的原始环境变量 JSON
* @returns {Map<any, any>} JSON 字符串
*/
static getOriginalEnvVars() {
return this.originalEnvVars;
}
/** 解析弹幕转换颜色
* @returns {string} 弹幕转换颜色
*/
static resolveConvertColor() {
@@ -187,21 +193,73 @@ export class Envs {
* @param {string} deployPlatform 部署平台
* @returns {Object} 配置对象
*/
static load(env = {}, deployPlatform = 'node') {
static load(env = {}) {
this.env = env;
// 环境变量分类和描述映射
const envVarConfig = {
// API配置
'TOKEN': { category: 'api', type: 'text', description: 'API访问令牌' },
'ADMIN_TOKEN': { category: 'api', type: 'text', description: '系统管理访问令牌' },
'RATE_LIMIT_MAX_REQUESTS': { category: 'api', type: 'number', description: '限流配置:1分钟内最大请求次数,0表示不限流,默认3', min: 0, max: 50 },
// 源配置
'SOURCE_ORDER': { category: 'source', type: 'multi-select', options: this.ALLOWED_SOURCES, description: '源排序配置,默认360,vod,renren,hanjutv' },
'OTHER_SERVER': { category: 'source', type: 'text', description: '第三方弹幕服务器,默认https://api.danmu.icu' },
'VOD_SERVERS': { category: 'source', type: 'text', description: 'VOD站点配置,格式:名称@URL,名称@URL,默认金蝉@https://zy.jinchancaiji.com,789@https://www.caiji.cyou,听风@https://gctf.tfdh.top' },
'VOD_RETURN_MODE': { category: 'source', type: 'select', options: ['all', 'fastest'], description: 'VOD返回模式:all(所有站点)或 fastest(最快的站点),默认fastest' },
'VOD_REQUEST_TIMEOUT': { category: 'source', type: 'number', description: 'VOD请求超时时间,默认10000', min: 5000, max: 30000 },
'BILIBILI_COOKIE': { category: 'source', type: 'text', description: 'B站Cookie' },
'YOUKU_CONCURRENCY': { category: 'source', type: 'number', description: '优酷并发配置,默认8', min: 1, max: 16 },
// 匹配配置
'PLATFORM_ORDER': { category: 'match', type: 'multi-select', options: this.ALLOWED_PLATFORMS, description: '平台排序配置' },
'EPISODE_TITLE_FILTER': { category: 'match', type: 'text', description: '剧集标题过滤规则' },
'ENABLE_EPISODE_FILTER': { category: 'match', type: 'boolean', description: '集标题过滤开关' },
'STRICT_TITLE_MATCH': { category: 'match', type: 'boolean', description: '严格标题匹配模式' },
'TITLE_TO_CHINESE': { category: 'match', type: 'boolean', description: '外语标题转换中文开关' },
// 弹幕配置
'BLOCKED_WORDS': { category: 'danmu', type: 'text', description: '屏蔽词列表' },
'GROUP_MINUTE': { category: 'danmu', type: 'number', description: '分钟内合并去重(0表示不去重),默认1', min: 0, max: 30 },
'DANMU_LIMIT': { category: 'danmu', type: 'number', description: '弹幕数量限制,单位为k,即千:默认 0,表示不限制弹幕数', min: 0, max: 100 },
'DANMU_SIMPLIFIED': { category: 'danmu', type: 'boolean', description: '弹幕繁体转简体开关' },
'CONVERT_TOP_BOTTOM_TO_SCROLL': { category: 'danmu', type: 'boolean', description: '顶部/底部弹幕转换为浮动弹幕' },
'CONVERT_COLOR': { category: 'danmu', type: 'select', options: ['default', 'white', 'color'], description: '弹幕转换颜色配置' },
'DANMU_OUTPUT_FORMAT': { category: 'danmu', type: 'select', options: ['json', 'xml'], description: '弹幕输出格式,默认json' },
// 缓存配置
'SEARCH_CACHE_MINUTES': { category: 'cache', type: 'number', description: '搜索结果缓存时间(分钟),默认1', min: 1, max: 120 },
'COMMENT_CACHE_MINUTES': { category: 'cache', type: 'number', description: '弹幕缓存时间(分钟),默认1', min: 1, max: 120 },
'REMEMBER_LAST_SELECT': { category: 'cache', type: 'boolean', description: '记住手动选择结果' },
'MAX_LAST_SELECT_MAP': { category: 'cache', type: 'number', description: '记住上次选择映射缓存大小限制', min: 10, max: 1000 },
'UPSTASH_REDIS_REST_URL': { category: 'cache', type: 'text', description: 'Upstash Redis请求链接' },
'UPSTASH_REDIS_REST_TOKEN': { category: 'cache', type: 'text', description: 'Upstash Redis访问令牌' },
// 系统配置
'PROXY_URL': { category: 'system', type: 'text', description: '代理/反代地址' },
'TMDB_API_KEY': { category: 'system', type: 'text', description: 'TMDB API密钥' },
'LOG_LEVEL': { category: 'system', type: 'select', options: ['debug', 'info', 'warn', 'error'], description: '日志级别配置' },
'DEPLOY_PLATFROM_ACCOUNT': { category: 'system', type: 'text', description: '部署平台账号ID' },
'DEPLOY_PLATFROM_PROJECT': { category: 'system', type: 'text', description: '部署平台项目名称' },
'DEPLOY_PLATFROM_TOKEN': { category: 'system', type: 'text', description: '部署平台访问令牌' },
'NODE_TLS_REJECT_UNAUTHORIZED': { category: 'system', type: 'number', description: '在建立 HTTPS 连接时是否验证服务器的 SSL/TLS 证书,0表示忽略' },
};
return {
vodAllowedPlatforms: this.VOD_ALLOWED_PLATFORMS,
allowedPlatforms: this.ALLOWED_PLATFORMS,
token: this.get('TOKEN', '87654321', 'string', true), // token,默认为87654321
adminToken: this.get('ADMIN_TOKEN', '', 'string', true), // admin token,用于系统管理访问控制
sourceOrderArr: this.resolveSourceOrder(), // 源排序
otherServer: this.get('OTHER_SERVER', 'https://api.danmu.icu', 'string'), // 第三方弹幕服务器
vodServers: this.resolveVodServers(env), // vod站点配置,格式:名称@URL,名称@URL
vodServers: this.resolveVodServers(), // vod站点配置,格式:名称@URL,名称@URL
vodReturnMode: this.get('VOD_RETURN_MODE', 'fastest', 'string').toLowerCase(), // vod返回模式:all(所有站点)或 fastest(最快的站点)
vodRequestTimeout: this.get('VOD_REQUEST_TIMEOUT', '10000', 'string'), // vod超时时间(默认10秒)
bilibliCookie: this.get('BILIBILI_COOKIE', '', 'string', true), // b站cookie
youkuConcurrency: Math.min(this.get('YOUKU_CONCURRENCY', 8, 'number'), 16), // 优酷并发配置
sourceOrderArr: this.resolveSourceOrder(env, deployPlatform), // 源排序
platformOrderArr: this.resolvePlatformOrder(env), // 自动匹配优选平台
episodeTitleFilter: this.resolveEpisodeTitleFilter(env), // 剧集标题正则过滤
platformOrderArr: this.resolvePlatformOrder(), // 自动匹配优选平台
episodeTitleFilter: this.resolveEpisodeTitleFilter(), // 剧集标题正则过滤
blockedWords: this.get('BLOCKED_WORDS', '', 'string'), // 屏蔽词列表
groupMinute: Math.min(this.get('GROUP_MINUTE', 1, 'number'), 30), // 分钟内合并去重(默认 1,最大值30,0表示不去重)
danmuLimit: this.get('DANMU_LIMIT', 0, 'number'), // 等间隔采样限制弹幕总数,单位为k,即千:默认 0,表示不限制弹幕数,若改为5,弹幕总数在超过5000的情况下会将弹幕数控制在5000
@@ -222,6 +280,11 @@ export class Envs {
titleToChinese: this.get('TITLE_TO_CHINESE', false, 'boolean'), // 外语标题转换中文开关
rememberLastSelect: this.get('REMEMBER_LAST_SELECT', true, 'boolean'), // 是否记住手动选择结果,用于match自动匹配时优选上次的选择(默认 true,记住)
MAX_LAST_SELECT_MAP: this.get('MAX_LAST_SELECT_MAP', 100, 'number'), // 记住上次选择映射缓存大小限制(默认 100)
deployPlatformAccount: this.get('DEPLOY_PLATFROM_ACCOUNT', '', 'string', true), // 部署平台账号ID配置(默认空)
deployPlatformProject: this.get('DEPLOY_PLATFROM_PROJECT', '', 'string', true), // 部署平台项目名称配置(默认空)
deployPlatformToken: this.get('DEPLOY_PLATFROM_TOKEN', '', 'string', true), // 部署平台项目名称配置(默认空)
NODE_TLS_REJECT_UNAUTHORIZED: this.get('NODE_TLS_REJECT_UNAUTHORIZED', 1, 'number'), // 在建立 HTTPS 连接时是否验证服务器的 SSL/TLS 证书,0表示忽略,默认为1
envVarConfig: envVarConfig // 环境变量分类和描述映射
};
}
}
+20 -4
View File
@@ -7,11 +7,13 @@ import { Envs } from './envs.js';
*/
export const Globals = {
// 缓存环境变量
env: {},
envs: {},
originalEnvVars: {},
accessedEnvVars: {},
// 静态常量
VERSION: '1.8.1',
VERSION: '1.9.0',
MAX_LOGS: 500, // 日志存储,最多保存 500 行
MAX_ANIMES: 100,
@@ -34,15 +36,29 @@ export const Globals = {
},
searchCache: new Map(), // 搜索结果缓存,存储格式:{ keyword: { results, timestamp } }
commentCache: new Map(), // 弹幕缓存,存储格式:{ videoUrl: { comments, timestamp } }
deployPlatform: '', // 部署平台配置
currentToken: '', // 标识当前可用token
/**
* 初始化全局变量,加载环境变量依赖
* @param {Object} env 环境对象
* @param {string} deployPlatform 部署平台
* @returns {Object} 全局配置对象
*/
init(env = {}, deployPlatform = 'node') {
this.envs = Envs.load(env, deployPlatform);
init(env = {}) {
this.env = env;
this.envs = Envs.load(this.env);
this.originalEnvVars = Object.fromEntries(Envs.getOriginalEnvVars());
this.accessedEnvVars = Object.fromEntries(Envs.getAccessedEnvVars());
return this.getConfig();
},
/**
* 重新初始化全局变量,加载环境变量依赖
* @returns {Object} 全局配置对象
*/
reInit() {
this.envs = Envs.load(this.env);
this.originalEnvVars = Object.fromEntries(Envs.getOriginalEnvVars());
this.accessedEnvVars = Object.fromEntries(Envs.getAccessedEnvVars());
return this.getConfig();
},
+103
View File
@@ -0,0 +1,103 @@
import { globals } from '../globals.js';
import { log } from "../../utils/log-util.js";
// =====================
// 环境变量处理基类
// =====================
export default class BaseHandler {
constructor() {
// 构造函数,初始化通用配置
}
// 更新本地环境变量
updateLocalEnv(key, value) {
// 立即更新 (避免重新加载文件的开销)
if (typeof globals.env !== 'undefined') {
globals.env[key] = value;
} else if (typeof process !== 'undefined') {
process.env[key] = value;
}
// 重新初始化全局配置
globals.reInit();
log("info", `[server] ✓ Environment variable updated successfully: ${key}`);
return true;
}
delLocalEnv(key) {
// 删除
if (typeof globals.env !== 'undefined' && globals.env[key]) {
delete globals.env[key];
} else if (typeof process !== 'undefined' && process.env?.[key]) {
delete process.env[key];
}
// 重新初始化全局配置
globals.reInit();
log("info", `[server] ✓ Environment variable deleted successfully: ${key}`);
return true;
}
// 获取所有环境变量
getAllEnv() {
// 获取原始环境变量
const originalEnvVars = globals.originalEnvVars;
// 获取环境变量配置信息
const envVarConfig = globals.envVarConfig;
// 构建带类型信息的环境变量对象
const envWithTypes = {};
// 遍历所有环境变量
for (const [key, value] of Object.entries(originalEnvVars)) {
// 获取该环境变量的配置信息
const config = envVarConfig[key] || { category: 'system', type: 'text', description: '未分类配置项' };
// 构建带类型信息的对象
envWithTypes[key] = {
value: value,
category: config.category,
type: config.type,
description: config.description,
options: config.options // 仅对 select 和 multi-select 类型有效
};
}
return envWithTypes;
}
// 获取某个环境变量
getEnv(key) {
return this.getAllEnv()[key];
}
// 设置环境变量的值
async setEnv(key, value) {
throw new Error("Method 'setEnv' must be implemented");
}
// 添加环境变量
async addEnv(key, value) {
throw new Error("Method 'addEnv' must be implemented");
}
// 删除环境变量
async delEnv(key) {
throw new Error("Method 'delEnv' must be implemented");
}
// 校验必填参数
async checkParams(accountId, projectId, token) {
throw new Error("Method 'checkParams' must be implemented");
}
// 部署
async deploy(accountId, projectId, token) {
throw new Error("Method 'deploy' must be implemented");
}
}
@@ -0,0 +1,114 @@
import BaseHandler from "./base-handler.js";
import { globals } from '../globals.js';
import { log } from "../../utils/log-util.js";
import { httpGet, httpPatch } from "../../utils/http-util.js";
// =====================
// Cloudflare环境变量处理类
// =====================
export class CloudflareHandler extends BaseHandler {
API_URL = 'https://api.cloudflare.com';
async _getAllEnvs(accountId, projectId, token) {
const url = `${this.API_URL}/client/v4/accounts/${accountId}/workers/scripts/${projectId}/settings`;
const options = {
headers: { Authorization: `Bearer ${token}` },
};
const res = await httpGet(url, options);
if (res && res?.data && res?.data?.result && res?.data?.result?.bindings) {
return res?.data?.result?.bindings;
} else {
return null;
}
}
/**
* 设置/删除环境变量
* @param {Array} envs 环境变量数组(每个元素 {name, text, type})
* @param {string} key 变量名
* @param {string|null|undefined} value
* - 字符串:新增或修改为该值
* @returns {Array} 返回处理后的 envs(直接修改原数组并返回)
*/
_setEnv(envs, key, value) {
// 查找已有项的索引
const idx = envs.findIndex(item => item.name === key);
if (value === null || value === undefined) {
// 删除:value 为 null/undefined 时删除该变量
if (idx !== -1) {
envs.splice(idx, 1);
}
} else if (idx !== -1) {
// 已存在 → 修改
envs[idx].text = value;
} else {
// 不存在 → 新增
envs.push({
name: key,
text: value,
type: "plain_text"
});
}
return envs;
}
async setEnv(key, value) {
try {
// 获取所有环境变量
const envs = await this._getAllEnvs(globals.deployPlatformAccount, globals.deployPlatformProject, globals.deployPlatformToken);
if (envs === null) {
log("error", '[server] ✗ Failed to set environment variable: envs is null');
return;
}
// 更新云端环境变量
const url = `${this.API_URL}/client/v4/accounts/${globals.deployPlatformAccount}/workers/scripts/${globals.deployPlatformProject}/settings`;
const options = {
headers: { Authorization: `Bearer ${globals.deployPlatformToken}` },
};
const formData = new FormData();
const settings = {
bindings: this._setEnv(envs, key, value == null ? null : value.toString())
};
formData.append(
"settings",
new Blob([JSON.stringify(settings)], { type: "application/json" }),
"settings.json"
);
await httpPatch(url, formData, options);
return this.updateLocalEnv(key, value);
} catch (error) {
log("error", '[server] ✗ Failed to set environment variable:', error.message);
}
}
async addEnv(key, value) {
// addEnv 和 setEnv 在这个场景下逻辑相同,只是不存在该key
return await this.setEnv(key, value);
}
async delEnv(key) {
// addEnv 和 setEnv 在这个场景下逻辑相同,只是value设置为null
await this.setEnv(key, null);
return this.delLocalEnv(key);
}
async checkParams(accountId, projectId, token) {
try {
await this._getAllEnvs(accountId, projectId, token);
return true;
} catch (error) {
log("error", 'checkParams failed! accountId, projectId or token is not valid:', error.message);
return false;
}
}
async deploy() {
log("log", 'After modifying the environment variables on the cloudflare platform, it will be automatically deployed.');
return true;
}
}
@@ -0,0 +1,111 @@
import BaseHandler from "./base-handler.js";
import { globals } from '../globals.js';
import { log } from "../../utils/log-util.js";
import { httpPost } from "../../utils/http-util.js";
// =====================
// Edgeone环境变量处理类
// =====================
export class EdgeoneHandler extends BaseHandler {
API_URL = 'https://pages-api.cloud.tencent.com/v1';
async _httpPost(token, data) {
const url = `${this.API_URL}`;
const options = {
headers: {Authorization: `Bearer ${token}`, 'Content-Type': 'application/json'},
};
return await httpPost(url, JSON.stringify(data), options);
}
async _getAllEnvs(projectId, token) {
const data = {
Action: "ModifyPagesProjectEnvs",
ProjectId: projectId
};
const res = await this._httpPost(token, data);
if (res?.data?.Code !== 0) return null;
const list = res?.data.Data?.Response?.EnvVars ?? [];
return Object.fromEntries(list.map(({Key, Value}) => [Key, Value]));
}
async setEnv(key, value) {
try {
// 更新云端环境变量
const data = {
Action: "ModifyPagesProjectEnvs",
ProjectId: globals.deployPlatformProject,
EnvVars: [
{
Key: key,
Value: value.toString()
}
]
};
await this._httpPost(globals.deployPlatformToken, data);
return this.updateLocalEnv(key, value);
} catch (error) {
log("error", '[server] ✗ Failed to set environment variable:', error.message);
}
}
async addEnv(key, value) {
// addEnv 和 setEnv 在这个场景下逻辑相同
return await this.setEnv(key, value);
}
async delEnv(key) {
try {
// 删除云端环境变量
const data = {
Action: "DeletePagesProjectEnvs",
ProjectId: globals.deployPlatformProject,
EnvVars: [key]
};
await this._httpPost(globals.deployPlatformToken, data);
return this.delLocalEnv(key);
} catch (error) {
log("error", '[server] ✗ Failed to del environment variable:', error.message);
}
}
async checkParams(accountId, projectId, token) {
try {
await this._getAllEnvs(projectId, token);
return true;
} catch (error) {
log("error", 'checkParams failed! accountId, projectId or token is not valid:', error.message);
return false;
}
}
async deploy() {
try {
// 触发云端部署
const data = {
Action: "CreatePagesDeployment",
ProjectId: globals.deployPlatformProject,
RepoBranch: "main",
ViaMeta: "Github",
Provider: "Github"
};
const res = await this._httpPost(globals.deployPlatformToken, data);
if (res?.data?.Code !== 0) {
log("error", '[server] ✗ Failed to deploy:', JSON.stringify(res?.data));
return false;
}
return true;
} catch (error) {
log("error", '[server] ✗ Failed to deploy:', error.message);
return false;
}
}
}
@@ -0,0 +1,35 @@
/**
* Handler工厂类 - 根据部署平台返回相应的Handler实例
*/
export class HandlerFactory {
static async getHandler(deployPlatform) {
switch (deployPlatform?.toLowerCase()) {
case 'cloudflare':
const { CloudflareHandler } = await import('./cloudflare-handler.js');
return new CloudflareHandler();
case 'vercel':
const { VercelHandler } = await import('./vercel-handler.js');
return new VercelHandler();
case 'netlify':
const { NetlifyHandler } = await import('./netlify-handler.js');
return new NetlifyHandler();
case 'edgeone':
const { EdgeoneHandler } = await import('./edgeone-handler.js');
return new EdgeoneHandler();
case 'node':
const { NodeHandler } = await import(['./node-handler', '.js'].join(''));
return new NodeHandler();
default:
// 默认返回NodeHandler,适用于本地开发或无法识别的平台
const { NodeHandler: DefaultNodeHandler } = await import(['./node-handler', '.js'].join(''));
return new DefaultNodeHandler();
}
}
/**
* 获取所有支持的平台列表
*/
static getSupportedPlatforms() {
return ['cloudflare', 'vercel', 'netlify', 'edgeone', 'node'];
}
}
@@ -0,0 +1,109 @@
import BaseHandler from "./base-handler.js";
import { globals } from '../globals.js';
import { log } from "../../utils/log-util.js";
import { httpGet, httpPost, httpDelete, httpPut } from "../../utils/http-util.js";
// =====================
// Netlify环境变量处理类
// =====================
export class NetlifyHandler extends BaseHandler {
API_URL = 'https://api.netlify.com';
async _getAllEnvs(accountId, projectId, token) {
const url = `${this.API_URL}/api/v1/accounts/${accountId}/env?site_id=${projectId}`;
const options = {
headers: { Authorization: `Bearer ${token}` },
};
return await httpGet(url, options);
}
async setEnv(key, value) {
try {
// 更新云端环境变量
const url = `${this.API_URL}/api/v1/accounts/${globals.deployPlatformAccount}/env/${key}?site_id=${globals.deployPlatformProject}`;
const options = {
headers: { Authorization: `Bearer ${globals.deployPlatformToken}`, 'Content-Type': 'application/json' },
};
const data = {
key: key,
values: [
{ context: 'all', value: value.toString() },
]
};
await httpPut(url, JSON.stringify(data), options);
return this.updateLocalEnv(key, value);
} catch (error) {
log("error", '[server] ✗ Failed to set environment variable:', error.message);
}
}
async addEnv(key, value) {
try {
// 更新云端环境变量
const url = `${this.API_URL}/api/v1/accounts/${globals.deployPlatformAccount}/env?site_id=${globals.deployPlatformProject}`;
const options = {
headers: { Authorization: `Bearer ${globals.deployPlatformToken}`, 'Content-Type': 'application/json' },
};
const data = [{
key: key,
values: [
{ context: 'all', value: value },
],
}];
await httpPost(url, JSON.stringify(data), options);
return this.updateLocalEnv(key, value);
} catch (error) {
log("error", '[server] ✗ Failed to add environment variable:', error.message);
}
}
async delEnv(key) {
try {
// 更新云端环境变量
const url = `${this.API_URL}/api/v1/accounts/${globals.deployPlatformAccount}/env/${key}?site_id=${globals.deployPlatformProject}`;
const options = {
headers: { Authorization: `Bearer ${globals.deployPlatformToken}`, 'Content-Type': 'application/json' },
};
await httpDelete(url, options);
return this.delLocalEnv(key);
} catch (error) {
log("error", '[server] ✗ Failed to add environment variable:', error.message);
}
}
async checkParams(accountId, projectId, token) {
try {
await this._getAllEnvs(accountId, projectId, token);
return true;
} catch (error) {
log("error", 'checkParams failed! accountId, projectId or token is not valid:', error.message);
return false;
}
}
async deploy() {
try {
// 触发云端部署
const url = `${this.API_URL}/api/v1/sites/${globals.deployPlatformProject}/builds`;
const options = {
headers: { Authorization: `Bearer ${globals.deployPlatformToken}`, 'Content-Type': 'application/json' },
};
const data = {};
const res = await httpPost(url, JSON.stringify(data), options);
if (!res?.data?.id) {
log("error", '[server] ✗ Failed to deploy:', JSON.stringify(res?.data));
return false;
}
return true;
} catch (error) {
log("error", '[server] ✗ Failed to deploy:', error.message);
return false;
}
}
}
+204
View File
@@ -0,0 +1,204 @@
import BaseHandler from "./base-handler.js";
import { log } from "../../utils/log-util.js";
import fs from 'fs';
import path from 'path';
import yaml from 'js-yaml';
import { fileURLToPath } from 'url';
// =====================
// Node环境变量处理类
// =====================
export class NodeHandler extends BaseHandler {
/**
* 在本地配置文件中设置环境变量
*/
updateConfigValue(key, value) {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const envPath = path.join(__dirname, '..', '..', '..', '.env');
const yamlPath = path.join(__dirname, '..', '..', '..', 'config.yaml');
const envExists = fs.existsSync(envPath);
const yamlExists = fs.existsSync(yamlPath);
if (!envExists && !yamlExists) {
throw new Error('Neither .env nor config.yaml found');
}
let updated = false;
try {
// 更新 .env 文件
if (envExists) {
const envContent = fs.readFileSync(envPath, 'utf8');
const lines = envContent.split('\n');
let keyFound = false;
// 查找并更新现有键
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (trimmed && !trimmed.startsWith('#')) {
const match = trimmed.match(/^([^=]+)=/);
if (match && match[1].trim() === key) {
lines[i] = `${key}=${value}`;
keyFound = true;
break;
}
}
}
// 如果键不存在,添加到文件末尾
if (!keyFound) {
if (lines[lines.length - 1] !== '') {
lines.push('');
}
lines.push(`${key}=${value}`);
}
fs.writeFileSync(envPath, lines.join('\n'), 'utf8');
log("info", `[server] Updated ${key} in .env`);
updated = true;
}
// 更新 config.yaml 文件
if (yamlExists) {
const yamlContent = fs.readFileSync(yamlPath, 'utf8');
let yamlConfig = yaml.load(yamlContent) || {};
// 将扁平的环境变量键转换为嵌套对象路径
const keys = key.toLowerCase().split('_');
let current = yamlConfig;
// 遍历到倒数第二层
for (let i = 0; i < keys.length - 1; i++) {
const k = keys[i];
if (!current[k] || typeof current[k] !== 'object') {
current[k] = {};
}
current = current[k];
}
// 设置最后一层的值,尝试智能转换类型
const lastKey = keys[keys.length - 1];
let parsedValue = value;
// 尝试转换为数字
if (!isNaN(value) && value !== '') {
parsedValue = Number(value);
}
// 尝试转换为布尔值
else if (value === 'true') {
parsedValue = true;
} else if (value === 'false') {
parsedValue = false;
}
current[lastKey] = parsedValue;
// 写回文件
fs.writeFileSync(yamlPath, yaml.dump(yamlConfig), 'utf8');
log("info", `[server] Updated ${key} in config.yaml`);
updated = true;
}
return updated;
} catch (error) {
log("error", '[server] Error updating configuration:', error.message);
throw error;
}
}
/**
* 设置环境变量并重新初始化全局配置
*/
async setEnv(key, value) {
log("info", '[server] Setting environment variable:', key, '=', value);
try {
// 更新配置文件
const updated = this.updateConfigValue(key, value);
if (!updated) {
throw new Error('Failed to update configuration files');
}
return this.updateLocalEnv(key, value);
} catch (error) {
log("error", '[server] ✗ Failed to set environment variable:', error.message);
}
}
/**
* 添加新的环境变量
*/
async addEnv(key, value) {
// addEnv 和 setEnv 在这个场景下逻辑相同
return await this.setEnv(key, value);
}
/**
* 删除环境变量
*/
async delEnv(key) {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const envPath = path.join(__dirname, '..', '..', '..', '.env');
const yamlPath = path.join(__dirname, '..', '..', '..', 'config.yaml');
let deleted = false;
try {
// 从 .env 文件删除
if (fs.existsSync(envPath)) {
const envContent = fs.readFileSync(envPath, 'utf8');
const lines = envContent.split('\n');
const filteredLines = lines.filter(line => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return true;
const match = trimmed.match(/^([^=]+)=/);
return !(match && match[1].trim() === key);
});
fs.writeFileSync(envPath, filteredLines.join('\n'), 'utf8');
log("info", `[server] Deleted ${key} from .env`);
deleted = true;
}
// 从 config.yaml 文件删除
if (fs.existsSync(yamlPath)) {
const yamlContent = fs.readFileSync(yamlPath, 'utf8');
let yamlConfig = yaml.load(yamlContent) || {};
const keys = key.toLowerCase().split('_');
let current = yamlConfig;
// 遍历到倒数第二层
for (let i = 0; i < keys.length - 1; i++) {
const k = keys[i];
if (!current[k]) return;
current = current[k];
}
// 删除最后一层的键
const lastKey = keys[keys.length - 1];
if (current && lastKey in current) {
delete current[lastKey];
fs.writeFileSync(yamlPath, yaml.dump(yamlConfig), 'utf8');
log("info", `[server] Deleted ${key} from config.yaml`);
deleted = true;
}
}
if (deleted) {
return this.delLocalEnv(key);
}
return false;
} catch (error) {
log("error", '[server] ✗ Failed to delete environment variable:', error.message);
}
}
}
@@ -0,0 +1,167 @@
import BaseHandler from "./base-handler.js";
import { globals } from '../globals.js';
import { log } from "../../utils/log-util.js";
import { httpGet, httpPost, httpPatch, httpDelete } from "../../utils/http-util.js";
// =====================
// Vercel环境变量处理类
// =====================
export class VercelHandler extends BaseHandler {
API_URL = 'https://api.vercel.com';
async _getAllEnvs(projectId, token) {
const url = `${this.API_URL}/v10/projects/${projectId}/env`;
const options = {
headers: { Authorization: `Bearer ${token}` },
};
const res = await httpGet(url, options);
const allEnv = res.data.envs;
return allEnv;
}
async _getEevId(key) {
try {
const allEnv = await this._getAllEnvs(globals.deployPlatformProject, globals.deployPlatformToken);
// 查找第一个 key 字段匹配的元素
const envItem = allEnv.find(env => env.key === key);
// 如果找到了,返回 id
if (envItem) {
return envItem;
} else {
// 如果没有找到匹配的元素,返回一个合适的值(比如 null)
return null;
}
} catch (error) {
log("error", '[server] ✗ Failed to _getEevId:', error.message);
}
}
async setEnv(key, value) {
// 首先获取云端环境变量
const envItem = await this._getEevId(key);
if (!envItem) {
log("error", '[server] Error setEnv: 没有找到对应的环境变量!');
return;
}
try {
// 更新云端环境变量
const url = `${this.API_URL}/v9/projects/${globals.deployPlatformProject}/env/${envItem.id}`;
const options = {
headers: { Authorization: `Bearer ${globals.deployPlatformToken}`, 'Content-Type': 'application/json' },
};
const data = {
key: key,
value: value.toString(),
target: envItem.target,
type: envItem.type,
};
await httpPatch(url, JSON.stringify(data), options);
return this.updateLocalEnv(key, value);
} catch (error) {
log("error", '[server] ✗ Failed to set environment variable:', error.message);
}
}
async addEnv(key, value) {
try {
// 更新云端环境变量
const url = `${this.API_URL}/v10/projects/${globals.deployPlatformProject}/env`;
const options = {
headers: { Authorization: `Bearer ${globals.deployPlatformToken}`, 'Content-Type': 'application/json' },
};
const data = {
key: key,
value: value.toString(),
target: [
'production',
'preview',
'development',
],
type: 'encrypted',
};
await httpPost(url, JSON.stringify(data), options);
return this.updateLocalEnv(key, value);
} catch (error) {
log("error", '[server] ✗ Failed to add environment variable:', error.message);
}
}
async delEnv(key) {
// 首先获取云端环境变量
const envItem = await this._getEevId(key);
if (!envItem) {
log("error", '[server] Error setEnv: 没有找到对应的环境变量!');
return;
}
try {
// 更新云端环境变量
const url = `${this.API_URL}/v9/projects/${globals.deployPlatformProject}/env/${envItem.id}`;
const options = {
headers: { Authorization: `Bearer ${globals.deployPlatformToken}`, 'Content-Type': 'application/json' },
};
await httpDelete(url, options);
return this.delLocalEnv(key);
} catch (error) {
log("error", '[server] ✗ Failed to add environment variable:', error.message);
}
}
async checkParams(accountId, projectId, token) {
try {
await this._getAllEnvs(projectId, token);
return true;
} catch (error) {
log("error", 'checkParams failed! projectId or token is not valid:', error.message);
return false;
}
}
async deploy() {
try {
// 获取上一次部署id
const urlList = `${this.API_URL}/v6/deployments?projectId=${globals.deployPlatformProject}&limit=1`;
const optionsList = {
headers: { Authorization: `Bearer ${globals.deployPlatformToken}` },
};
const resList = await httpGet(urlList, optionsList);
let lastestDeployId;
if (resList?.data && resList?.data?.deployments && resList?.data?.deployments.length > 0) {
lastestDeployId = resList.data.deployments[0].uid;
} else {
log("error", '[server] ✗ Failed to deploy:', JSON.stringify(resList?.data));
return false;
}
// 触发云端部署
const url = `${this.API_URL}/v13/deployments`;
const options = {
headers: { Authorization: `Bearer ${globals.deployPlatformToken}`, 'Content-Type': 'application/json' },
};
const data = {
name: globals.deployPlatformProject,
deploymentId: lastestDeployId,
target: "production"
};
const res = await httpPost(url, JSON.stringify(data), options);
if (!res?.data?.id) {
log("error", '[server] ✗ Failed to deploy:', JSON.stringify(res?.data));
return false;
}
return true;
} catch (error) {
log("error", '[server] ✗ Failed to deploy:', error.message);
return false;
}
}
}
+293
View File
@@ -0,0 +1,293 @@
# 目录
- [UI 系统使用说明](#ui-系统使用说明)
- [功能概览](#功能概览)
- [访问方式](#访问方式)
- [功能详解](#功能详解)
- [1. 配置预览](#1-配置预览)
- [2. 日志查看](#2-日志查看)
- [3. 接口调试](#3-接口调试)
- [4. 推送弹幕](#4-推送弹幕)
- [5. 系统配置](#5-系统配置)
- [安全说明](#安全说明)
- [部署平台支持](#部署平台支持)
- [部署平台环境变量配置指南](#部署平台环境变量配置指南)
- [各平台变量获取详细步骤](#各平台变量获取详细步骤)
- [1. Vercel 平台](#1-vercel-平台)
- [2. Netlify 平台](#2-netlify-平台)
- [3. EdgeOne (腾讯云 Pages) 平台](#3-edgeone-腾讯云-pages-平台)
- [4. Cloudflare 平台](#4-cloudflare-平台)
- [常见问题](#常见问题)
- [API 端点](#api-端点)
- [故障排除](#故障排除)
# UI 系统使用说明
本项目包含一个基于 Web 的用户界面,用于管理和配置弹幕 API 服务。以下是 UI 系统的详细使用说明。
## 功能概览
UI 系统提供了以下主要功能模块:
1. **配置预览** - 查看当前生效的环境变量配置
2. **日志查看** - 实时查看系统运行日志
3. **接口调试** - 测试和调试 API 接口
4. **推送弹幕** - 向播放器推送弹幕
5. **系统配置** - 环境变量配置和系统管理
## 访问方式
UI 系统需要通过在 URL 中添加 TOKEN 来访问,以确保安全性:
- 普通功能访问:`http://your-domain/{TOKEN}`
- 系统管理功能:`http://your-domain/{ADMIN_TOKEN}`
## 功能详解
### 1. 配置预览
在配置预览页面,您可以:
- 查看当前生效的环境变量配置
- 了解各配置项的当前值和描述
- 配置按类别分组显示(API配置、源配置、匹配配置等)
### 2. 日志查看
日志查看页面提供:
- 实时日志监控
- 刷新日志功能
- 清空日志功能(需要 ADMIN_TOKEN)
### 3. 接口调试
接口调试功能允许您:
- 选择不同的 API 接口进行测试
- 输入接口参数
- 发送请求并查看响应结果
- 支持的接口包括:
- 搜索动漫 - `/api/v2/search/anime`
- 搜索剧集 - `/api/v2/search/episodes`
- 匹配动漫 - `/api/v2/match`
- 获取番剧详情 - `/api/v2/bangumi/:animeId`
- 获取弹幕 - `/api/v2/comment/:commentId`
### 4. 推送弹幕
推送弹幕功能支持:
- 向 OK 影视等播放器推送弹幕
- 搜索动漫并选择剧集
- 推送地址格式如:`http://127.0.0.1:9978/action?do=refresh&type=danmaku&path=`
- 需要在同一局域网或使用公网 IP
### 5. 系统配置
系统配置页面包含:
#### 环境变量管理
- **API配置** - API 相关设置
- **源配置** - 数据源配置
- **匹配配置** - 匹配算法设置
- **弹幕配置** - 弹幕相关参数
- **缓存配置** - 缓存策略设置
- **系统配置** - 系统级配置
#### 配置项类型
- **文本** - 普通文本输入
- **布尔值** - 开关选择
- **数字** - 数字滚轮输入 (带范围限制)
- **单选** - 从预定义选项中选择
- **多选** - 选择多个选项并可拖动排序
#### 系统管理功能
- **清理缓存** - 清除系统缓存(需要 ADMIN_TOKEN)
- **重新部署** - 重新部署系统(需要 ADMIN_TOKEN)
## 安全说明
- 访问 UI 系统需要在 URL 中配置 TOKEN
- 系统管理功能(日志查看、环境变量配置等)需要 ADMIN_TOKEN
- 确保 TOKEN 和 ADMIN_TOKEN 的安全性
## 部署平台支持
系统支持多种部署平台:
- Node.js
- Vercel
- Netlify
- Cloudflare
- Docker
- EdgeOne
根据部署平台的不同,可能需要配置额外的环境变量。
## 部署平台环境变量配置指南
### 平台与所需变量对照表
| 平台 | DEPLOY_PLATFROM_ACCOUNT | DEPLOY_PLATFROM_PROJECT | DEPLOY_PLATFROM_TOKEN |
|------|----------------------|----------------------|---------------------|
| Vercel | ❌ | ✅ | ✅ |
| Netlify | ✅ | ✅ | ✅ |
| EdgeOne | ❌ | ✅ | ✅ |
| Cloudflare | ✅ | ✅ | ✅ |
| Node.js | ❌ | ❌ | ❌ |
| Docker | ❌ | ❌ | ❌ |
---
### 各平台变量获取详细步骤
#### 1. Vercel 平台
#### 需要的变量
- `DEPLOY_PLATFROM_PROJECT`: 项目 ID
- `DEPLOY_PLATFROM_TOKEN`: API Token
#### 获取步骤
**获取 Project ID (`DEPLOY_PLATFROM_PROJECT`)**
1. 登录 [Vercel Dashboard](https://vercel.com/dashboard)
2. 选择你的项目
3. 进入项目后,点击 **Settings** 标签
4. 在左侧菜单中选择 **General**
5. 向下滚动找到 **Project ID** 部分
6. 复制显示的项目 ID(格式类似: `prj_xxxxxxxxxxxx`)
**获取 API Token (`DEPLOY_PLATFROM_TOKEN`)**
1. 点击右上角头像,选择 **Settings**
2. 在左侧菜单中选择 **Tokens**
3. 点击 **Create Token** 按钮
4. 输入 Token 名称(如: `environment-variables-api`)
5. 选择 **Scope**:
- 可以选择 **Full Account** 或特定项目
- 建议选择特定项目以提高安全性
6. 设置过期时间(可选)
7. 点击 **Create** 创建 Token
8. **立即复制并保存** Token(只显示一次)
---
### 2. Netlify 平台
#### 需要的变量
- `DEPLOY_PLATFROM_ACCOUNT`: 账户 ID
- `DEPLOY_PLATFROM_PROJECT`: 站点 ID
- `DEPLOY_PLATFROM_TOKEN`: Personal Access Token
#### 获取步骤
**获取 Account ID (`DEPLOY_PLATFROM_ACCOUNT`)**
1. 登录 [Netlify Dashboard](https://app.netlify.com/)
2. 点击左下角头像,选择 **User settings**
3. 点击 **Team settings** 可以看到你的 Account Slug
4. 或者在左侧菜单选择 **Applications**
5. 在 API 端点中可以找到 Account ID
**获取 Site ID (`DEPLOY_PLATFROM_PROJECT`)**
1. 在 Netlify Dashboard 中选择你的项目
2. 进入项目后,点击 **Project configuration**
3. 在 **General** > **Project details** 部分
4. 找到 **Project information** 下的 **Project ID**
5. 复制显示的站点 ID(格式类似: `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`)
**获取 Personal Access Token (`DEPLOY_PLATFROM_TOKEN`)**
1. 点击左下角头像,选择 **User settings**
2. 在左侧菜单中选择 **Applications**
3. 滚动到 **Personal access tokens** 部分
4. 点击 **New access token** 按钮
5. 输入 Token 描述(如: `Environment Variables API`)
6. 点击 **Generate token**
7. **立即复制并保存** Token(只显示一次)
---
### 3. EdgeOne (腾讯云 Pages) 平台
#### 需要的变量
- `DEPLOY_PLATFROM_PROJECT`: 项目 ID
- `DEPLOY_PLATFROM_TOKEN`: API 密钥
#### 获取步骤
**获取 Project ID (`DEPLOY_PLATFROM_PROJECT`)**
1. 登录 [腾讯云 EdgeOne 控制台](https://console.cloud.tencent.com/edgeone)
2. 进入 **Pages** 服务
3. 选择你的项目
4. 在URL可以看到项目 ID(格式类似: `pages-xxxxxxxxxxxx`)
**获取 API 密钥 (`DEPLOY_PLATFROM_TOKEN`)**
1. 登录 [腾讯云 EdgeOne 控制台](https://console.cloud.tencent.com/edgeone)
2. 进入 **Pages** 服务
3. 选择 **API Token** 标签页
4. 点击 **创建 API Token** 按钮
5. 输入描述和过期时间,点击提交后复制相应Token
---
### 4. Cloudflare 平台
#### 需要的变量
- `DEPLOY_PLATFROM_ACCOUNT`: 账户 ID
- `DEPLOY_PLATFROM_PROJECT`: Workers 脚本名称
- `DEPLOY_PLATFROM_TOKEN`: API Token
#### 获取步骤
**获取 Account ID (`DEPLOY_PLATFROM_ACCOUNT`)**
1. 登录 [Cloudflare Dashboard](https://dash.cloudflare.com/)
2. 在右侧可以看到 **Account ID**
3. 或者点击任意域名,在右侧栏可以找到 **Account ID**
4. 复制该 ID(格式类似: `xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`)
**获取 Workers 脚本名称 (`DEPLOY_PLATFROM_PROJECT`)**
1. 在 Cloudflare Dashboard 左侧菜单选择 **Workers & Pages**
2. 找到你的 Workers 脚本
3. 脚本名称就是列表中显示的名称
4. 或者点击进入脚本详情,在 URL 中可以看到脚本名称
**获取 API Token (`DEPLOY_PLATFROM_TOKEN`)**
1. 点击右上角头像,选择 **配置文件**
2. 在左侧菜单选择 **API Tokens**
3. 点击 **Create Token** 按钮
4. 可以选择模板或自定义:
- 选择 **Edit Cloudflare Workers** 模板
- 或创建 **Custom token**
5. 配置权限:
- **Account** > **Workers Scripts** > **Edit**
- 选择特定账户
6. (可选)设置 IP 限制和 TTL
7. 点击 **Continue to summary**
8. 确认后点击 **Create Token**
9. **立即复制并保存** Token(只显示一次)
---
### 常见问题
**Q: Token 创建后忘记复制怎么办?**
A: 大多数平台的 Token 只显示一次,如果忘记复制需要删除后重新创建。
## API 端点
在 UI 页面顶部显示当前 API 端点,可点击复制到剪贴板。
## 故障排除
- 如果无法访问功能页面,请检查 URL 中的 TOKEN 是否正确
- 如果系统管理功能不可用,请确认 ADMIN_TOKEN 等环境变量是否已配置
- 如遇到其他问题,请查看系统日志获取更多信息
+165
View File
@@ -0,0 +1,165 @@
// language=CSS
export const baseCssContent = /* css */ `
/* 基础样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 15px;
}
.header-left {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 15px;
flex-wrap: wrap;
}
.logo-title-container {
display: flex;
align-items: center;
gap: 15px;
}
.logo {
width: 50px;
height: 50px;
background: white;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
overflow: hidden; /* 关键:防止溢出 */
}
.logo img {
width: 100%; /* 或 90% 留点内边距更好看 */
height: 100%;
object-fit: cover; /* 图片会被裁剪成正方形,充满容器 */
/* object-fit: contain; /* 如果想完整显示图片(会留白)就用这个 */
border-radius: 12px; /* 让图片也跟随圆角 */
}
.header h1 {
font-size: 24px;
margin: 0;
}
.version-info {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
opacity: 0.95;
}
.version-badge {
background: rgba(255,255,255,0.2);
padding: 3px 10px;
border-radius: 12px;
font-weight: 500;
}
.update-badge {
background: #ffd700;
color: #333;
padding: 3px 10px;
border-radius: 12px;
font-weight: 500;
animation: pulse 2s infinite;
cursor: pointer;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.content {
padding: 20px;
}
.section {
display: none;
}
.section.active {
display: block;
animation: fadeIn 0.3s;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* footer 样式 */
.footer {
padding: 20px;
background: transparent;
text-align: center;
font-size: 14px;
}
/* footer 文字样式 */
.footer-text {
color: #ffeb3b;
margin: 10px 0;
}
/* footer 链接样式 */
.footer-links {
margin: 10px 0;
}
.footer-link {
margin: 0 10px;
text-decoration: none;
color: #ffc107;
}
.footer-link:hover {
color: #ffeb3b; /* 可选,增加 hover 效果 */
}
/* GitHub 链接特定样式 */
.github-link {
display: inline-flex;
align-items: center;
}
.github-icon {
width: 14px;
vertical-align: middle;
margin-right: 5px;
}
`;
+710
View File
@@ -0,0 +1,710 @@
// language=CSS
export const componentsCssContent = /* css */ `
/* 组件样式 */
.nav-buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.nav-btn {
padding: 8px 16px;
background: rgba(255,255,255,0.2);
border: 1px solid rgba(255,255,255,0.3);
color: white;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s;
font-size: 14px;
}
.nav-btn:hover {
background: rgba(255,255,255,0.3);
}
.nav-btn.active {
background: white;
color: #667eea;
}
/* 环境变量样式 */
.env-categories {
display: flex;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.category-btn {
padding: 10px 20px;
background: #f0f0f0;
border: 2px solid transparent;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s;
}
.category-btn.active {
background: #667eea;
color: white;
border-color: #667eea;
}
.env-list {
margin-bottom: 20px;
}
.env-item {
background: #f8f9fa;
padding: 15px;
margin-bottom: 10px;
border-radius: 8px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.env-item .env-info {
flex: 1;
min-width: 200px;
word-break: break-word;
overflow-wrap: break-word;
word-wrap: break-word;
}
.env-item .env-info strong {
color: #667eea;
display: block;
margin-bottom: 5px;
word-break: break-all;
}
.env-item .env-info > div.text-dark-gray {
word-break: break-all;
white-space: normal;
background-color: #f1f3f5;
padding: 8px;
border-radius: 4px;
font-family: monospace;
margin-bottom: 5px;
}
.env-item .env-info span {
word-break: break-all;
white-space: normal;
}
.env-item .env-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.env-info {
flex: 1;
min-width: 200px;
}
.env-info strong {
color: #667eea;
display: block;
margin-bottom: 5px;
}
.env-actions {
display: flex;
gap: 8px;
}
/* 按钮样式 */
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.btn-success {
background: #28a745;
color: white;
position: relative;
overflow: hidden;
}
.btn-success:hover {
background: #218838;
}
.btn-success::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255,255,255,0.3);
transform: translate(-50%, -50%);
transition: width 0.6s, height 0.6s;
}
.btn-success:active::before {
width: 300px;
height: 300px;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #c82333;
}
/* 预览区域 */
.preview-area {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-top: 20px;
}
.preview-item {
padding: 10px;
background: white;
margin-bottom: 8px;
border-radius: 6px;
border-left: 4px solid #667eea;
word-break: break-word;
overflow-wrap: break-word;
word-wrap: break-word;
}
.preview-item .preview-item-content {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 5px;
}
.preview-item .preview-key {
font-weight: bold;
color: #67eea;
align-self: flex-start;
}
.preview-item .preview-value {
word-break: break-all;
white-space: normal;
width: 100%;
background-color: #f8f9fa;
padding: 8px;
border-radius: 4px;
font-family: monospace;
color: #333; /* 更黑的字体颜色 */
font-weight: bold; /* 加粗显示 */
}
/* 日志样式 */
.log-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
flex-wrap: wrap;
gap: 10px;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(40, 167, 69, 0.5); }
70% { box-shadow: 0 0 0 10px rgba(40, 167, 69, 0); }
100% { box-shadow: 0 0 0 0 rgba(40, 167, 69, 0); }
}
.log-container {
background: #1e1e1e;
color: #d4d4d4;
padding: 15px;
border-radius: 8px;
max-height: 500px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
}
.log-entry {
margin-bottom: 8px;
padding: 5px;
border-radius: 4px;
}
.log-entry.info { color: #4fc3f7; }
.log-entry.warn { color: #ffb74d; }
.log-entry.error { color: #e57373; }
.log-entry.success { color: #81c784; }
/* 表单帮助文本 */
.form-help {
font-size: 12px;
color: #666;
margin-top: 5px;
font-style: italic;
}
/* API调试样式 */
.api-selector {
margin-bottom: 20px;
}
.api-params {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.api-response {
background: #1e1e1e;
color: #d4d4d4;
padding: 15px;
border-radius: 8px;
max-height: 400px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 13px;
white-space: pre-wrap;
}
/* XML响应样式 */
.api-response.xml {
color: #88ccff;
}
/* JSON高亮样式 */
.json-response {
background: #1e1e1e;
color: #d4d4d4;
padding: 15px;
border-radius: 8px;
max-height: 400px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 13px;
white-space: pre-wrap;
}
.json-response .key {
color: #9cdcfe;
}
.json-response .string {
color: #ce9178;
}
.json-response .number {
color: #b5cea8;
}
.json-response .boolean {
color: #569cd6;
}
.json-response .null {
color: #569cd6;
}
.json-response .undefined {
color: #569cd6;
}
.error-response {
background: #1e1e1e;
color: #d4d4d4;
padding: 15px;
border-radius: 8px;
max-height: 400px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 13px;
white-space: pre-wrap;
border-left: 4px solid #dc3545;
}
/* 模态框 */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
padding: 20px;
overflow-y: auto;
}
.modal.active {
display: flex;
justify-content: center;
align-items: center;
}
.modal-content {
background: white;
padding: 30px;
border-radius: 12px;
max-width: 600px;
width: 100%;
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
position: relative;
top: 0;
left: 0;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
padding-bottom: 15px;
border-bottom: 1px solid #eee;
}
.modal-header h3 {
color: #667eea;
margin: 0;
}
.modal-body {
margin-bottom: 25px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding-top: 15px;
border-top: 1px solid #eee;
}
.confirmation-list {
padding-left: 20px;
margin: 0;
list-style: none;
}
.confirmation-list li {
position: relative;
padding-left: 10px;
margin: 8px 0;
}
.confirmation-list li::before {
content: "•";
position: absolute;
left: 0;
color: #667eea;
font-size: 16px;
}
.warning-box {
background: #fff3cd;
border-left: 4px solid #ffc107;
padding: 15px;
border-radius: 6px;
margin-top: 15px;
margin-bottom: 20px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
padding-bottom: 15px;
border-bottom: 1px solid #eee;
}
.modal-header h3 {
color: #667eea;
margin: 0;
}
.close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #999;
}
.close-btn:hover {
color: #333;
}
/* 值类型标识 */
.value-type-badge {
display: inline-block;
padding: 2px 8px;
background: #667eea;
color: white;
border-radius: 12px;
font-size: 11px;
margin-left: 8px;
}
/* 确认模态框样式 */
.confirmation-list {
padding-left: 20px;
margin: 0;
list-style: none;
}
.confirmation-list li {
position: relative;
padding-left: 10px;
margin: 8px 0;
}
.confirmation-list li::before {
content: "•";
position: absolute;
left: 0;
color: #667eea;
font-size: 16px;
}
.warning-box {
background: #fff3cd;
border-left: 4px solid #ffc107;
padding: 15px;
border-radius: 6px;
margin-top: 15px;
margin-bottom: 20px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 25px;
padding-top: 15px;
border-top: 1px solid #eee;
}
.value-type-badge.multi {
background: #ff6b6b;
}
/* 进度条 */
.progress-container {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 4px;
background: rgba(0,0,0,0.1);
z-index: 9999;
display: none;
}
.progress-container.active {
display: block;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #667eea, #764ba2);
width: 0;
transition: width 0.3s;
box-shadow: 0 0 10px rgba(102, 126, 234, 0.5);
}
/* 加载提示 */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.7);
display: none;
justify-content: center;
align-items: center;
z-index: 9998;
}
.loading-overlay.active {
display: flex;
}
.loading-content {
background: white;
padding: 40px;
border-radius: 12px;
text-align: center;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
max-width: 400px;
}
.loading-spinner {
width: 60px;
height: 60px;
border: 5px solid #f3f3f3;
border-top: 5px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
.loading-spinner-small {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid #ffffff;
border-top: 2px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 8px;
vertical-align: middle;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-text {
font-size: 18px;
color: #333;
font-weight: 500;
margin-bottom: 10px;
}
.loading-detail {
font-size: 14px;
color: #666;
}
/* 通用内联样式类 */
.text-center {
text-align: center;
}
.text-gray {
color: #bbb; /* 更淡的字体颜色 */
}
.text-red {
color: #e74c3c;
}
.text-dark-gray {
color: #333; /* 更黑的字体颜色 */
font-weight: bold; /* 加粗显示 */
}
.text-purple {
color: #67eea;
}
.text-yellow-gold {
color: #ffd700;
}
.padding-20 {
padding: 20px;
}
.margin-bottom-10 {
margin-bottom: 10px;
}
.margin-top-3 {
margin-top: 3px;
}
.margin-top-15 {
margin-top: 15px;
}
.font-size-12 {
font-size: 12px;
}
.margin-bottom-15 {
margin-bottom: 15px;
}
.text-monospace {
font-family: monospace;
}
/* 推送弹幕相关样式 */
.anime-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 10px;
margin-top: 15px;
}
.anime-item {
border: 1px solid #ddd;
border-radius: 8px;
padding: 8px;
text-align: center;
cursor: pointer;
}
.anime-item-img {
width: 100%;
height: 150px;
object-fit: cover;
border-radius: 4px;
}
.anime-title {
margin: 8px 0 5px;
font-size: 12px;
}
.episode-list-container {
max-height: 400px;
overflow-y: auto;
}
.episode-item {
padding: 10px;
border-bottom: 1px solid #eee;
}
.episode-item-content {
display: inline-block;
width: calc(100% - 100px);
vertical-align: middle;
}
.episode-push-btn {
width: 80px;
display: inline-block;
margin-left: 10px;
}
`;
+318
View File
@@ -0,0 +1,318 @@
// language=CSS
export const formsCssContent = /* css */ `
/* 表单样式 */
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
color: #333;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
}
.form-group textarea {
resize: vertical;
min-height: 80px;
font-family: 'Courier New', Consolas, monospace;
line-height: 1.5;
}
/* 开关按钮 */
.switch-container {
display: flex;
align-items: center;
gap: 10px;
}
.switch {
position: relative;
display: inline-block;
width: 50px;
height: 26px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 26px;
}
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 4px;
bottom: 4px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: #667eea;
}
input:checked + .slider:before {
transform: translateX(24px);
}
.switch-label {
font-weight: 500;
color: #333;
}
/* 数字滚轮 */
.number-picker {
display: flex;
align-items: center;
gap: 15px;
background: #f8f9fa;
padding: 15px;
border-radius: 8px;
}
.number-display {
font-size: 32px;
font-weight: bold;
color: #667eea;
min-width: 60px;
text-align: center;
}
.number-controls {
display: flex;
flex-direction: column;
gap: 5px;
}
.number-btn {
width: 40px;
height: 40px;
border: 2px solid #667eea;
background: white;
color: #667eea;
border-radius: 8px;
cursor: pointer;
font-size: 20px;
font-weight: bold;
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
}
.number-btn:hover {
background: #667eea;
color: white;
}
.number-btn:active {
transform: scale(0.95);
}
.number-range {
width: 100%;
margin-top: 10px;
}
.number-range input[type="range"] {
width: 100%;
height: 6px;
border-radius: 3px;
background: #ddd;
outline: none;
-webkit-appearance: none;
}
.number-range input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #667eea;
cursor: pointer;
}
.number-range input[type="range"]::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #667eea;
cursor: pointer;
border: none;
}
/* 标签选择 */
.tag-selector {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.tag-option {
padding: 10px 20px;
background: #f0f0f0;
border: 2px solid transparent;
border-radius: 20px;
cursor: pointer;
transition: all 0.3s;
font-size: 14px;
font-weight: 500;
}
.tag-option:hover {
background: #e0e0e0;
}
.tag-option.selected {
background: #667eea;
color: white;
border-color: #667eea;
}
.tag-option.selected:hover {
background: #5568d3;
}
/* 多选标签 */
.multi-select-container {
display: flex;
flex-direction: column;
gap: 15px;
}
.selected-tags {
min-height: 60px;
background: #f8f9fa;
border: 2px dashed #ddd;
border-radius: 8px;
padding: 10px;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: flex-start;
}
.selected-tags.empty::before {
content: '拖动或点击下方选项添加...';
color: #999;
font-size: 14px;
width: 100%;
text-align: center;
padding: 15px 0;
}
.selected-tag {
display: inline-flex;
align-items: center;
gap: 8px;
background: #667eea;
color: white;
padding: 8px 12px;
border-radius: 20px;
cursor: move;
user-select: none;
transition: all 0.3s;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.selected-tag:hover {
background: #5568d3;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
}
.selected-tag.dragging {
opacity: 0.5;
transform: rotate(5deg);
}
.selected-tag .tag-text {
font-weight: 500;
}
.selected-tag .remove-btn {
width: 18px;
height: 18px;
background: rgba(255,255,255,0.3);
border: none;
border-radius: 50%;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: bold;
transition: all 0.2s;
}
.selected-tag .remove-btn:hover {
background: rgba(255,255,255,0.5);
transform: scale(1.1);
}
.available-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.available-tag {
padding: 8px 16px;
background: #f0f0f0;
border: 2px solid transparent;
border-radius: 20px;
cursor: pointer;
transition: all 0.3s;
font-size: 14px;
font-weight: 500;
user-select: none;
}
.available-tag:hover {
background: #e0e0e0;
transform: translateY(-2px);
}
.available-tag.disabled {
opacity: 0.5;
cursor: not-allowed;
background: #f8f9fa;
}
.available-tag.disabled:hover {
transform: none;
}
.drag-over {
background: #e8eaf6 !important;
border-color: #667eea !important;
}
`;
+68
View File
@@ -0,0 +1,68 @@
// language=CSS
export const responsiveCssContent = /* css */ `
/* 响应式样式 */
@media (max-width: 768px) {
.logo {
width: 40px;
height: 40px;
font-size: 22px;
}
.header-left {
width: 100%;
flex-direction: column;
align-items: flex-start;
}
.logo-title-container {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
}
.header h1 {
font-size: 18px;
margin: 0;
}
.version-info {
font-size: 11px;
flex-wrap: wrap;
margin-top: 5px;
width: 100%;
}
.nav-buttons {
width: 100%;
flex-wrap: wrap;
gap: 5px;
justify-content: center;
}
.nav-btn {
flex: 1 1 calc(33.333% - 5px);
text-align: center;
font-size: 11px;
padding: 8px 5px;
white-space: nowrap;
min-width: 70px;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
.env-item {
flex-direction: column;
align-items: flex-start;
}
.env-actions {
width: 100%;
}
.btn {
flex: 1;
}
}
`;
+230
View File
@@ -0,0 +1,230 @@
// language=JavaScript
export const apitestJsContent = /* javascript */ `
// API 配置
const apiConfigs = {
searchAnime: {
name: '搜索动漫',
method: 'GET',
path: '/api/v2/search/anime',
params: [
{ name: 'keyword', label: '关键词', type: 'text', required: true, placeholder: '示例: 生万物' }
]
},
searchEpisodes: {
name: '搜索剧集',
method: 'GET',
path: '/api/v2/search/episodes',
params: [
{ name: 'anime', label: '动漫名称', type: 'text', required: true, placeholder: '示例: 生万物' }
]
},
matchAnime: {
name: '匹配动漫',
method: 'POST',
path: '/api/v2/match',
params: [
{ name: 'fileName', label: '文件名', type: 'text', required: true, placeholder: '示例: 生万物 S02E08, 无忧渡.S02E08.2160p.WEB-DL.H265.DDP.5.1, 爱情公寓.ipartment.2009.S02E08.H.265.25fps.mkv, 亲爱的X S02E08, 宇宙Marry Me? S02E08' }
]
},
getBangumi: {
name: '获取番剧详情',
method: 'GET',
path: '/api/v2/bangumi/:animeId',
params: [
{ name: 'animeId', label: '动漫ID', type: 'text', required: true, placeholder: '示例: 236379' }
]
},
getComment: {
name: '获取弹幕',
method: 'GET',
path: '/api/v2/comment/:commentId',
params: [
{ name: 'commentId', label: '弹幕ID', type: 'text', required: true, placeholder: '示例: 10009' },
{ name: 'format', label: '格式', type: 'select', required: false, placeholder: '可选: json或xml', options: ['json', 'xml'] }
]
}
};
// 接口调试相关
function loadApiParams() {
const select = document.getElementById('api-select');
const apiKey = select.value;
const paramsDiv = document.getElementById('api-params');
const formDiv = document.getElementById('params-form');
if (!apiKey) {
paramsDiv.style.display = 'none';
return;
}
const config = apiConfigs[apiKey];
paramsDiv.style.display = 'block';
if (config.params.length === 0) {
formDiv.innerHTML = '<p class="text-gray">此接口无需参数</p>';
return;
}
formDiv.innerHTML = config.params.map(param => {
if (param.type === 'select') {
// 为select类型参数添加默认选项
let optionsHtml = '<option value="">-- 请选择 --</option>';
if (param.options) {
optionsHtml += param.options.map(opt => \`<option value="\${opt}">\${opt}</option>\`).join('');
}
return \`
<div class="form-group">
<label>\${param.label}\${param.required ? ' *' : ''}</label>
<select id="param-\${param.name}">
\${optionsHtml}
</select>
\${param.placeholder ? \`<div class="form-help">\${param.placeholder}</div>\` : ''}
</div>
\`;
}
// 使用placeholder属性显示示例参数
const placeholder = param.placeholder ? param.placeholder : "请输入" + param.label;
return \`
<div class="form-group">
<label>\${param.label}\${param.required ? ' *' : ''}</label>
<input type="\${param.type}" id="param-\${param.name}" placeholder="\${placeholder}" \${param.required ? 'required' : ''}>
</div>
\`;
}).join('');
}
function testApi() {
const select = document.getElementById('api-select');
const apiKey = select.value;
const sendButton = document.querySelector('#api-params .btn-success'); // 获取发送请求按钮
if (!apiKey) {
addLog('请先选择接口', 'error');
return;
}
// 设置按钮为加载状态
const originalText = sendButton.innerHTML;
sendButton.innerHTML = '<span class="loading-spinner-small"></span>';
sendButton.disabled = true;
const config = apiConfigs[apiKey];
const params = {};
config.params.forEach(param => {
const value = document.getElementById(\`param-\${param.name}\`).value;
if (value) params[param.name] = value;
});
addLog(\`调用接口: \${config.name} (\${config.method} \${config.path})\`, 'info');
addLog(\`请求参数: \${JSON.stringify(params)}\`, 'info');
// 构建请求URL
let url = config.path;
// 检查是否为路径参数接口
const isPathParameterApi = config.path.includes(':');
if (isPathParameterApi) {
// 处理路径参数接口 (/api/v2/comment 和 /api/v2/bangumi)
// 先分离路径参数和查询参数
const pathParams = {};
const queryParams = {};
// 分类参数
for (const [key, value] of Object.entries(params)) {
// 检查参数是否为路径参数
if (config.path.includes(':' + key)) {
pathParams[key] = value;
} else {
// 其他参数作为查询参数
queryParams[key] = value;
}
}
// 替换路径参数
for (const [key, value] of Object.entries(pathParams)) {
url = url.replace(':' + key, encodeURIComponent(value));
}
// 添加查询参数
if (config.method === 'GET' && Object.keys(queryParams).length > 0) {
const queryString = new URLSearchParams(queryParams).toString();
url = url + '?' + queryString;
}
} else {
// 保持原来的逻辑,用于 search/anime 等接口
if (config.method === 'GET') {
const queryString = new URLSearchParams(params).toString();
url = url + '?' + queryString;
}
}
// 配置请求选项
const requestOptions = {
method: config.method,
headers: {
'Content-Type': 'application/json'
}
};
if (config.method === 'POST') {
requestOptions.body = JSON.stringify(params);
}
// 发送真实API请求
fetch(buildApiUrl(url), requestOptions)
.then(response => {
if (!response.ok) {
throw new Error(\`HTTP error! status: \${response.status}\`);
}
// 检查format参数以确定如何处理响应
const formatParam = params.format || 'json';
if (formatParam.toLowerCase() === 'xml') {
// 对于XML格式,返回文本内容
return response.text().then(text => ({
data: text,
format: 'xml'
}));
} else {
// 对于JSON格式或其他情况,返回JSON对象
return response.json().then(json => ({
data: json,
format: 'json'
}));
}
})
.then(result => {
// 显示响应结果
document.getElementById('api-response-container').style.display = 'block';
if (result.format === 'xml') {
// 显示XML响应
document.getElementById('api-response').textContent = result.data;
document.getElementById('api-response').className = 'api-response xml'; // 使用XML专用样式类
} else {
// 显示JSON响应
document.getElementById('api-response').className = 'json-response';
document.getElementById('api-response').innerHTML = highlightJSON(result.data);
}
addLog('接口调用成功', 'success');
})
.catch(error => {
// 处理错误
const errorMessage = \`API请求失败: \${error.message}\`;
document.getElementById('api-response-container').style.display = 'block';
document.getElementById('api-response').textContent = errorMessage;
// 添加错误信息的CSS类
document.getElementById('api-response').className = 'error-response';
addLog(errorMessage, 'error');
})
.finally(() => {
// 恢复按钮状态
sendButton.innerHTML = originalText;
sendButton.disabled = false;
});
}
`;
+122
View File
@@ -0,0 +1,122 @@
// language=JavaScript
export const logviewJsContent = /* javascript */ `
// 日志相关
function addLog(message, type = 'info') {
const timestamp = new Date().toLocaleTimeString();
logs.push({ timestamp, message, type });
if (logs.length > 100) logs.shift();
renderLogs();
}
function renderLogs() {
const container = document.getElementById('log-container');
container.innerHTML = logs.map(log =>
\`<div class="log-entry \${log.type}">[\${log.timestamp}] \${log.message}</div>\`
).join('');
container.scrollTop = container.scrollHeight;
}
// 从API获取真实日志数据
async function fetchRealLogs() {
try {
// 日志查看使用普通token访问,不需要admin token
const response = await fetch(buildApiUrl('/api/logs')); // 不使用admin token
if (!response.ok) {
throw new Error(\`HTTP error! status: \${response.status}\`);
}
const logText = await response.text();
// 解析日志文本为数组
const logLines = logText.split('\\n').filter(line => line.trim() !== '');
// 转换为logs数组格式
logs = logLines.map(line => {
// 解析日志行,提取时间戳、级别和消息
const match = line.match(/\\[([^\\]]+)\\] (\\w+): (.*)/);
if (match) {
return {
timestamp: match[1],
type: match[2],
message: match[3]
};
}
// 如果无法解析,返回原始行
return {
timestamp: new Date().toLocaleTimeString(),
type: 'info',
message: line
};
});
renderLogs();
} catch (error) {
console.error('Failed to fetch logs:', error);
addLog(\`获取日志失败: \${error.message}\`, 'error');
}
}
function refreshLogs() {
// 从API获取真实日志数据
fetchRealLogs();
}
async function clearLogs() {
// 检查部署平台配置
const configCheck = await checkDeployPlatformConfig();
if (!configCheck.success) {
customAlert(configCheck.message);
return;
}
customConfirm('确定要清空所有日志吗?', '清空确认').then(async confirmed => {
if (confirmed) {
try {
const response = await fetch(buildApiUrl('/api/logs/clear', true), { // 使用admin token
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(\`HTTP error! status: \${response.status}\`);
}
const result = await response.json();
if (result.success) {
// 清空前端显示的日志
logs = [];
renderLogs();
addLog('日志已清空', 'warn');
} else {
addLog(\`清空日志失败: \${result.message}\`, 'error');
}
} catch (error) {
console.error('Failed to clear logs:', error);
addLog(\`清空日志失败: \${error.message}\`, 'error');
}
}
});
}
// JSON高亮函数
function highlightJSON(obj) {
let json = JSON.stringify(obj, null, 2);
// 转义HTML特殊字符
json = json.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
// 高亮JSON语法
return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) {
let cls = 'number';
if (/^"/.test(match)) {
if (/:$/.test(match)) {
cls = 'key';
} else {
cls = 'string';
}
} else if (/true|false/.test(match)) {
cls = 'boolean';
} else if (/null/.test(match)) {
cls = 'null';
}
return '<span class="' + cls + '">' + match + '</span>';
});
}
`;
+390
View File
@@ -0,0 +1,390 @@
// language=JavaScript
export const mainJsContent = /* javascript */ `
// 自定义弹窗组件
function createCustomAlert() {
// 检查是否已存在自定义弹窗元素
if (document.getElementById('custom-alert-overlay')) {
return;
}
// 创建弹窗HTML元素
const alertHTML = '<div class="modal" id="custom-alert-overlay"><div class="modal-content" id="custom-alert-content"><div class="modal-header"><h3 id="custom-alert-title">提示</h3><button class="close-btn" id="custom-alert-close">&times;</button></div><div class="modal-body"><p id="custom-alert-message"></p></div><div class="modal-footer"><button class="btn btn-primary" id="custom-alert-confirm">确定</button></div></div></div>';
// 添加到body
document.body.insertAdjacentHTML('beforeend', alertHTML);
// 获取元素
const overlay = document.getElementById('custom-alert-overlay');
const closeBtn = document.getElementById('custom-alert-close');
const confirmBtn = document.getElementById('custom-alert-confirm');
// 关闭弹窗函数
function closeAlert() {
overlay.classList.remove('active');
// 重置标题和消息
document.getElementById('custom-alert-title').textContent = '提示';
}
// 事件监听器
closeBtn.addEventListener('click', closeAlert);
confirmBtn.addEventListener('click', closeAlert);
// 点击遮罩层关闭弹窗
overlay.addEventListener('click', function(e) {
if (e.target === overlay) {
closeAlert();
}
});
}
// 自定义alert函数
function customAlert(message, title = '提示') {
// 确保弹窗元素已创建
createCustomAlert();
// 获取元素
const overlay = document.getElementById('custom-alert-overlay');
const titleElement = document.getElementById('custom-alert-title');
const messageElement = document.getElementById('custom-alert-message');
// 设置标题和消息
titleElement.textContent = title;
messageElement.textContent = message;
// 显示弹窗
overlay.classList.add('active');
}
// 自定义confirm函数(如果需要)
function customConfirm(message, title = '确认') {
return new Promise((resolve) => {
// 确保弹窗元素已创建
createCustomAlert();
// 获取元素
const overlay = document.getElementById('custom-alert-overlay');
const titleElement = document.getElementById('custom-alert-title');
const messageElement = document.getElementById('custom-alert-message');
const confirmBtn = document.getElementById('custom-alert-confirm');
// 移除之前的事件监听器(如果有)
const newConfirmBtn = confirmBtn.cloneNode(true);
confirmBtn.parentNode.replaceChild(newConfirmBtn, confirmBtn);
// 设置标题和消息
titleElement.textContent = title;
messageElement.textContent = message;
// 确定按钮事件
newConfirmBtn.addEventListener('click', () => {
overlay.classList.remove('active');
resolve(true);
});
// 关闭按钮事件
document.getElementById('custom-alert-close').addEventListener('click', () => {
overlay.classList.remove('active');
resolve(false);
});
// 点击遮罩层关闭
overlay.addEventListener('click', function(e) {
if (e.target === overlay) {
overlay.classList.remove('active');
resolve(false);
}
});
// 显示弹窗
overlay.classList.add('active');
});
}
// 初始化自定义弹窗
document.addEventListener('DOMContentLoaded', createCustomAlert);
// 数据存储
let envVariables = {};
let currentCategory = 'api'; // 默认分类改为api
let editingKey = null;
let logs = []; // 保留本地日志数组,用于UI显示
// 版本信息
let currentVersion = '';
let latestVersion = '';
let currentToken = 'globals.currentToken';
let currentAdminToken = ''; // admin token,用于系统管理
let originalToken = '';
// 构建带token的API请求路径
function buildApiUrl(path, isSystemPath = false) {
// 如果是系统管理路径且有admin token,则使用admin token
if (isSystemPath && currentAdminToken && currentAdminToken.trim() !== '' && currentAdminToken.trim() !== '*'.repeat(currentAdminToken.length)) {
return '/' + currentAdminToken + path;
}
// 否则使用普通token
return (currentToken ? '/' + currentToken : "") + path;
}
// 从API加载真实环境变量数据
function loadEnvVariables() {
// 从API获取真实配置数据
fetch(buildApiUrl('/api/config', true))
.then(response => response.json())
.then(config => {
// 从配置中获取admin token
currentAdminToken = config.originalEnvVars?.ADMIN_TOKEN || '';
originalToken = config.originalEnvVars?.TOKEN || '';
// 使用从API获取的原始环境变量,用于系统设置
const originalEnvVars = config.originalEnvVars || {};
// 重新组织数据结构以适配现有UI
envVariables = {};
// 将原始环境变量转换为UI所需格式
// 这里需要将原始环境变量按类别组织
Object.keys(originalEnvVars).forEach(key => {
// 从envVarConfig获取配置信息
const varConfig = config.envVarConfig?.[key] || { category: 'system', type: 'text', description: '未分类配置项' };
const category = varConfig.category || 'system';
// 如果该分类不存在,创建它
if (!envVariables[category]) {
envVariables[category] = [];
}
// 添加到对应分类,包含完整的配置信息
envVariables[category].push({
key: key,
value: originalEnvVars[key],
description: varConfig.description || '',
type: varConfig.type || 'text',
min: varConfig.min,
max: varConfig.max,
options: varConfig.options || [] // 仅对 select 和 multi-select 类型有效
});
});
// 渲染环境变量列表
renderEnvList();
})
.catch(error => {
console.error('Failed to load env variables:', error);
});
}
// 更新API端点信息
function updateApiEndpoint() {
return fetch(buildApiUrl('/api/config', true))
.then(response => response.json())
.then(config => {
// 获取当前页面的协议、主机和端口
const protocol = window.location.protocol;
const host = window.location.host;
const token = config.originalEnvVars?.TOKEN || '87654321'; // 默认token值
const adminToken = config.originalEnvVars?.ADMIN_TOKEN;
// 获取URL路径并提取token
const urlPath = window.location.pathname;
const pathParts = urlPath.split('/').filter(part => part !== '');
const urlToken = pathParts.length > 0 ? pathParts[0] : '';
let apiToken = '********';
// 判断是否使用默认token
if (token === '87654321') {
// 如果是默认token,则显示真实token
apiToken = token;
} else {
// 如果不是默认token,则检查URL中的token是否匹配,匹配则显示真实token,否则显示星号
if (urlToken === token || (adminToken !== "" && urlToken === adminToken)) {
apiToken = token; // 更新全局token变量
}
}
// 构造API端点URL
const apiEndpoint = protocol + '//' + host + '/' + apiToken;
const apiEndpointElement = document.getElementById('api-endpoint');
if (apiEndpointElement) {
apiEndpointElement.textContent = apiEndpoint;
}
return config; // 返回配置信息,以便链式调用
})
.catch(error => {
console.error('获取配置信息失败:', error);
// 出错时显示默认值
const protocol = window.location.protocol;
const host = window.location.host;
const apiEndpoint = protocol + '//' + host + '/********';
const apiEndpointElement = document.getElementById('api-endpoint');
if (apiEndpointElement) {
apiEndpointElement.textContent = apiEndpoint;
}
throw error; // 抛出错误,以便调用者可以处理
});
}
function getDockerVersion() {
const url = "https://img.shields.io/docker/v/logvar/danmu-api?sort=semver";
fetch(url)
.then(response => response.text())
.then(svgContent => {
// 使用正则表达式从 SVG 中提取版本号
const versionMatch = svgContent.match(/version<\\/text><text.*?>(v[\\d\\.]+)/);
if (versionMatch && versionMatch[1]) {
console.log("Version:", versionMatch[1]);
const latestVersionElement = document.getElementById('latest-version');
if (latestVersionElement) {
latestVersionElement.textContent = versionMatch[1];
}
} else {
console.log("Version not found");
}
})
.catch(error => {
console.error("Error fetching the SVG:", error);
});
}
// 切换导航
function switchSection(section) {
// 检查是否尝试访问受token保护的section(日志查看、接口调试、系统配置需要token访问)
if (section === 'logs' || section === 'api' || section === 'env' || section === 'push') {
// 获取URL路径并提取token
const urlPath = window.location.pathname;
const pathParts = urlPath.split('/').filter(part => part !== '');
const urlToken = pathParts.length > 0 ? pathParts[0] : '';
// 检查URL中是否有token
if (!urlToken && originalToken !== "87654321") {
// 提示用户需要在URL中配置TOKEN
setTimeout(() => {
// 获取当前页面的协议、主机和端口
const protocol = window.location.protocol;
const host = window.location.host;
customAlert('请在URL中配置相应的TOKEN以访问此功能!\\n\\n访问方式:' + protocol + '//' + host + '/{TOKEN}');
}, 100);
return;
}
// 如果是系统配置页面,还需要检查是否配置了ADMIN_TOKEN且URL中的token等于currentAdminToken
if (section === 'env') {
// 检查部署平台配置
checkDeployPlatformConfig().then(result => {
if (!result.success) {
// 如果配置检查不通过,只显示提示,不切换页面
setTimeout(() => {
customAlert(result.message);
}, 100);
} else {
// 如果配置检查通过,才切换到env页面
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
document.getElementById(\`\${section}-section\`).classList.add('active');
event.target.classList.add('active');
addLog(\`切换到\${section === 'env' ? '环境变量' : section === 'preview' ? '配置预览' : section === 'logs' ? '日志查看' : section === 'push' ? '推送弹幕' : '接口调试'}模块\`, 'info');
}
});
} else {
// 对于日志查看、接口调试和推送弹幕页面,只要URL中有token就可以访问
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
document.getElementById(\`\${section}-section\`).classList.add('active');
event.target.classList.add('active');
addLog(\`切换到\${section === 'env' ? '环境变量' : section === 'preview' ? '配置预览' : section === 'logs' ? '日志查看' : section === 'push' ? '推送弹幕' : '接口调试'}模块\`, 'info');
}
} else {
// 对于非受保护页面(如配置预览),正常切换
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
document.getElementById(\`\${section}-section\`).classList.add('active');
event.target.classList.add('active');
addLog(\`切换到\${section === 'env' ? '环境变量' : section === 'preview' ? '配置预览' : section === 'logs' ? '日志查看' : section === 'push' ? '推送弹幕' : '接口调试'}模块\`, 'info');
}
}
// 切换类别
function switchCategory(category) {
currentCategory = category;
document.querySelectorAll('.category-btn').forEach(b => b.classList.remove('active'));
event.target.classList.add('active');
renderEnvList();
}
// 关闭模态框
function closeModal() {
document.getElementById('env-modal').classList.remove('active');
// 重置表单字段状态
document.getElementById('env-category').disabled = false;
document.getElementById('env-key').readOnly = false;
document.getElementById('value-type').disabled = false;
document.getElementById('env-description').readOnly = false;
}
// 页面加载完成后初始化时获取一次日志
async function init() {
try {
await updateApiEndpoint(); // 等待API端点更新完成
getDockerVersion();
// 从API获取配置信息,包括检查是否有admin token
await fetchAndSetConfig();
// 检查并处理管理员令牌
checkAndHandleAdminToken();
loadEnvVariables(); // 从API加载真实环境变量数据
renderEnvList();
renderPreview();
addLog('系统初始化完成', 'success');
// 获取真实日志数据
fetchRealLogs();
} catch (error) {
console.error('初始化失败:', error);
addLog('系统初始化失败: ' + error.message, 'error');
// 即使初始化失败,也要尝试获取日志
fetchRealLogs();
}
}
// 复制API端点到剪贴板
function copyApiEndpoint() {
const apiEndpointElement = document.getElementById('api-endpoint');
if (apiEndpointElement) {
const apiEndpoint = apiEndpointElement.textContent;
navigator.clipboard.writeText(apiEndpoint)
.then(() => {
// 临时改变显示文本以提供反馈
const originalText = apiEndpointElement.textContent;
apiEndpointElement.textContent = '已复制!';
apiEndpointElement.style.color = '#ff6b6b';
// 2秒后恢复原始文本
setTimeout(() => {
apiEndpointElement.textContent = originalText;
apiEndpointElement.style.color = '#4CAF50';
}, 2000);
addLog('API端点已复制到剪贴板: ' + apiEndpoint, 'success');
})
.catch(err => {
console.error('复制失败:', err);
customAlert('复制失败: ' + err);
addLog('复制API端点失败: ' + err, 'error');
});
}
}
// 页面加载完成后初始化
init();
`;
+55
View File
@@ -0,0 +1,55 @@
// language=JavaScript
export const previewJsContent = /* javascript */ `
// 渲染配置预览
function renderPreview() {
const preview = document.getElementById('preview-area');
// 从API获取真实配置数据
fetch('/api/config')
.then(response => response.json())
.then(config => {
// 使用从API获取的分类环境变量
const categorizedVars = config.categorizedEnvVars || {};
// 渲染预览内容
let html = '';
Object.keys(categorizedVars).forEach(category => {
const items = categorizedVars[category];
if (items && items.length > 0) {
html += \`<h3 class="text-purple margin-bottom-10">\${getCategoryName(category)}</h3>\`;
items.forEach(item => {
html += \`
<div class="preview-item">
<div class="preview-item-content">
<div class="preview-key"><strong>\${item.key}</strong></div>
<div class="preview-value">\${item.value}</div>
</div>
\${item.description ? \`<div class="text-gray font-size-12 margin-top-3">\${item.description}</div>\` : ''}
</div>
\`;
});
}
});
preview.innerHTML = html || '<p class="text-gray">暂无配置</p>';
})
.catch(error => {
console.error('Failed to load config for preview:', error);
preview.innerHTML = '<p class="text-red">加载配置失败: ' + error.message + '</p>';
});
}
// 获取类别名称
function getCategoryName(category) {
const names = {
api: '🔗 API配置',
source: '📜 源配置',
match: '🔍 匹配配置',
danmu: '🔣 弹幕配置',
cache: '💾 缓存配置',
system: '⚙️ 系统配置'
};
return names[category] || category;
}
`;
+180
View File
@@ -0,0 +1,180 @@
// language=JavaScript
export const pushDanmuJsContent = /* javascript */ `
// 推送弹幕功能相关
// 搜索动漫用于推送
function searchAnimeForPush() {
const keyword = document.getElementById('push-search-keyword').value.trim();
const pushUrl = document.getElementById('push-url').value.trim();
const searchBtn = document.querySelector('#push-section .btn-primary');
if (!keyword) {
customAlert('请输入搜索关键字');
return;
}
// 搜索时不再校验pushUrl是否为空,只在推送时校验
// 添加加载状态
const originalText = searchBtn.textContent;
searchBtn.innerHTML = '<span class="loading-spinner-small"></span>';
searchBtn.disabled = true;
// 构建搜索API请求URL
const searchUrl = buildApiUrl('/api/v2/search/anime?keyword=' + encodeURIComponent(keyword));
addLog(\`开始搜索动漫: \${keyword}\`, 'info');
// 发送搜索请求
fetch(searchUrl)
.then(response => {
if (!response.ok) {
throw new Error(\`HTTP error! status: \${response.status}\`);
}
return response.json();
})
.then(data => {
if (data.success && data.animes.length > 0) {
displayAnimeListForPush(data.animes, pushUrl);
} else {
document.getElementById('push-anime-list').style.display = 'none';
document.getElementById('push-episode-list').style.display = 'none';
customAlert('未找到相关动漫');
addLog('未找到相关动漫', 'warn');
}
})
.catch(error => {
console.error('搜索动漫失败:', error);
customAlert('搜索动漫失败: ' + error.message);
addLog('搜索动漫失败: ' + error.message, 'error');
})
.finally(() => {
// 恢复按钮状态
searchBtn.innerHTML = originalText;
searchBtn.disabled = false;
});
}
// 展示动漫列表用于推送
function displayAnimeListForPush(animes, pushUrl) {
const container = document.getElementById('push-anime-list');
let html = '<h3>搜索结果</h3><div class="anime-grid">';
animes.forEach(anime => {
const imageUrl = anime.imageUrl || 'https://placehold.co/150x200?text=No+Image';
html += \`
<div class="anime-item" onclick="getBangumiForPush(\${anime.animeId}, '\${pushUrl}')">
<img src="\${imageUrl}" alt="\${anime.animeTitle}" referrerpolicy="no-referrer" class="anime-item-img">
<h4 class="anime-title">\${anime.animeTitle} - 共\${anime.episodeCount}集</h4>
</div>
\`;
});
html += '</div>';
container.innerHTML = html;
container.style.display = 'block';
addLog(\`显示 \${animes.length} 个动漫结果\`, 'info');
}
// 获取番剧详情用于推送
function getBangumiForPush(animeId, pushUrl) {
const bangumiUrl = buildApiUrl('/api/v2/bangumi/' + animeId);
addLog(\`获取番剧详情: \${animeId}\`, 'info');
fetch(bangumiUrl)
.then(response => {
if (!response.ok) {
throw new Error(\`HTTP error! status: \${response.status}\`);
}
return response.json();
})
.then(data => {
if (data.success && data.bangumi && data.bangumi.episodes) {
displayEpisodeListForPush(data.bangumi.animeTitle, data.bangumi.episodes, pushUrl);
} else {
customAlert('该动漫暂无剧集信息');
addLog('该动漫暂无剧集信息', 'warn');
}
})
.catch(error => {
console.error('获取番剧详情失败:', error);
customAlert('获取番剧详情失败: ' + error.message);
addLog('获取番剧详情失败: ' + error.message, 'error');
});
}
// 展示剧集列表用于推送
function displayEpisodeListForPush(animeTitle, episodes, pushUrl) {
const container = document.getElementById('push-episode-list');
let html = \`<h3>剧集列表</h3><h4 class="text-yellow-gold">\${animeTitle}</h4><div class="episode-list-container">\`;
episodes.forEach(episode => {
// 生成弹幕URL
const commentUrl = window.location.origin + buildApiUrl('/api/v2/comment/' + episode.episodeId + '?format=xml');
html += \`
<div class="episode-item">
<div class="episode-item-content">
<strong>第\${episode.episodeNumber}集</strong> - \${episode.episodeTitle || '无标题'}
</div>
<button class="btn btn-success btn-sm episode-push-btn" onclick="pushDanmu('\${pushUrl}', '\${commentUrl}', '\${episode.episodeTitle || '第' + episode.episodeNumber + '集'}')">推送</button>
</div>
\`;
});
html += '</div>';
container.innerHTML = html;
container.style.display = 'block';
addLog(\`显示 \${episodes.length} 个剧集\`, 'info');
// 自动滚动到剧集列表处
setTimeout(() => {
container.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 10);
}
// 推送弹幕
async function pushDanmu(pushUrl, commentUrl, episodeTitle) {
// 获取推送按钮元素
const pushButton = event.target; // 通过事件对象获取当前点击的按钮
// 校验推送地址是否为空
if (!pushUrl || pushUrl.trim() === '') {
customAlert('请输入推送地址');
if (pushButton) {
pushButton.innerHTML = '推送';
pushButton.disabled = false;
}
return;
}
if (pushButton) {
const originalText = pushButton.innerHTML;
pushButton.innerHTML = '<span class="loading-spinner-small"></span>';
pushButton.disabled = true;
}
try {
// 向推送地址发送弹幕数据
const pushResponse = await fetch(pushUrl + encodeURIComponent(commentUrl), {
method: 'GET',
mode: 'no-cors', // 由于跨域限制,使用no-cors模式
});
customAlert('弹幕推送成功!' + episodeTitle);
addLog('弹幕推送成功 - ' + episodeTitle, 'success');
} catch (error) {
console.error('推送弹幕失败:', error);
customAlert('推送弹幕失败: ' + error.message);
addLog('推送弹幕失败: ' + error.message, 'error');
} finally {
// 恢复按钮状态
if (pushButton) {
pushButton.innerHTML = '推送';
pushButton.disabled = false;
}
}
}
`;
+889
View File
@@ -0,0 +1,889 @@
// language=JavaScript
export const systemSettingsJsContent = /* javascript */ `
// 显示清理缓存确认模态框
function showClearCacheModal() {
document.getElementById('clear-cache-modal').classList.add('active');
}
// 隐藏清理缓存确认模态框
function hideClearCacheModal() {
document.getElementById('clear-cache-modal').classList.remove('active');
}
// 确认清理缓存
async function confirmClearCache() {
// 检查部署平台配置
const configCheck = await checkDeployPlatformConfig();
if (!configCheck.success) {
hideClearCacheModal();
customAlert(configCheck.message);
return;
}
hideClearCacheModal();
showLoading('正在清理缓存...', '清除中,请稍候');
addLog('开始清理缓存', 'info');
try {
// 调用真实的清理缓存API
const response = await fetch(buildApiUrl('/api/cache/clear', true), { // 使用admin token
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const result = await response.json();
if (result.success) {
updateLoadingText('清理完成', '缓存已成功清除');
addLog('缓存清理完成', 'success');
addLog('✅ 缓存清理成功!已清理: ' + JSON.stringify(result.clearedItems), 'success');
} else {
updateLoadingText('清理失败', '请查看日志了解详情');
addLog('缓存清理失败: ' + result.message, 'error');
}
} catch (error) {
updateLoadingText('清理失败', '网络错误或服务不可用');
addLog('缓存清理请求失败: ' + error.message, 'error');
} finally {
setTimeout(() => {
hideLoading();
}, 10);
}
}
// 显示重新部署确认模态框
function showDeploySystemModal() {
document.getElementById('deploy-system-modal').classList.add('active');
}
// 隐藏重新部署确认模态框
function hideDeploySystemModal() {
document.getElementById('deploy-system-modal').classList.remove('active');
}
// 确认重新部署系统
function confirmDeploySystem() {
// 检查部署平台配置
checkDeployPlatformConfig().then(configCheck => {
if (!configCheck.success) {
hideDeploySystemModal();
customAlert(configCheck.message);
return;
}
hideDeploySystemModal();
showLoading('准备部署...', '正在检查系统状态');
addLog('===== 开始系统部署 =====', 'info');
// 获取当前部署平台
fetch(buildApiUrl('/api/config', true))
.then(response => response.json())
.then(config => {
const deployPlatform = config.envs.deployPlatform || 'node';
addLog(\`检测到部署平台: \${deployPlatform}\`, 'info');
if (deployPlatform.toLowerCase() === 'node') {
// Node部署不需要重新部署
setTimeout(() => {
hideLoading();
addLog('===== 部署完成 =====', 'success');
addLog('Node部署模式,环境变量已生效', 'info');
addLog('✅ Node部署模式 - 在Node部署模式下,环境变量修改后会自动生效,无需重新部署。系统已更新配置', 'success');
}, 150);
} else {
// 调用真实的部署API
fetch(buildApiUrl('/api/deploy', true), { // 使用admin token
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(result => {
if (result.success) {
addLog('云端部署触发成功', 'success');
// 模拟云端部署过程
simulateDeployProcess();
} else {
hideLoading();
addLog(\`云端部署失败: \${result.message}\`, 'error');
addLog(\`❌ 云端部署失败: \${result.message}\`, 'error');
}
})
.catch(error => {
hideLoading();
addLog(\`云端部署请求失败: \${error.message}\`, 'error');
addLog(\`❌ 云端部署请求失败: \${error.message}\`, 'error');
});
}
})
.catch(error => {
hideLoading();
addLog(\`获取部署平台信息失败: \${error.message}\`, 'error');
console.error('获取部署平台信息失败:', error);
});
});
}
// 模拟云端部署过程
function simulateDeployProcess() {
let progress = 0;
const progressInterval = setInterval(() => {
progress += Math.random() * 8;
if (progress >= 100) {
progress = 10;
clearInterval(progressInterval);
}
updateProgress(progress);
}, 300);
// 模拟部署步骤
const steps = [
{ delay: 100, text: '检查环境变量...', detail: '验证配置文件', log: '配置文件验证通过' },
{ delay: 5000, text: '触发云端部署...', detail: '部署到当前平台', log: '云端部署已触发' },
{ delay: 9500, text: '构建项目...', detail: '云端构建中', log: '云端构建完成' },
{ delay: 5000, text: '部署更新...', detail: '发布到生产环境', log: '更新已部署' },
{ delay: 5500, text: '服务重启...', detail: '应用新配置', log: '服务已重启' },
{ delay: 5000, text: '健康检查...', detail: '验证服务状态', log: '所有服务运行正常' },
];
steps.forEach(step => {
setTimeout(() => {
updateLoadingText(step.text, step.detail);
addLog(step.log, 'success');
}, step.delay);
});
// 部署后检查服务是否可用
setTimeout(() => {
checkDeploymentStatus();
}, 900); // 延长延迟以确保模拟部署过程完成
}
// 检查部署状态,每隔5秒请求/api/logs接口直到请求成功
function checkDeploymentStatus() {
const checkInterval = setInterval(() => {
updateLoadingText('部署完成,检查服务状态...', '正在请求 /api/logs 接口');
addLog('正在检查服务状态...', 'info');
fetch(buildApiUrl('/api/logs'))
.then(response => {
if (response.ok) {
// 请求成功,停止检查
clearInterval(checkInterval);
// 更新加载状态而不是立即隐藏
updateLoadingText('部署成功!', '服务已重启并正常运行');
addLog('===== 部署完成 =====', 'success');
addLog('部署版本: ' + latestVersion, 'info');
addLog('系统已更新并重启', 'success');
// 部署完成后再次确认,访问/api/logs接口来确认部署完成
confirmDeploymentByLogs();
} else {
addLog('服务检查中 - 状态码: ' + response.status, 'info');
}
})
.catch(error => {
addLog('服务检查中 - 连接失败: ' + error.message, 'info');
});
}, 500); // 每5秒检查一次
}
// 部署完成后通过访问/api/logs接口来确认部署完成
function confirmDeploymentByLogs() {
// 部署完成后的确认检查
let confirmationAttempts = 0;
const maxAttempts = 3; // 最多尝试3次确认部署完成
const confirmationInterval = setInterval(() => {
confirmationAttempts++;
updateLoadingText('部署完成确认中...', '正在确认部署完成 (' + confirmationAttempts + '/' + maxAttempts + ')');
addLog('部署完成确认 - 尝试 ' + confirmationAttempts + '/' + maxAttempts, 'info');
fetch(buildApiUrl('/api/logs'))
.then(response => {
if (response.ok) {
// 请求成功,停止确认检查
clearInterval(confirmationInterval);
// 显示成功信息后延迟隐藏加载遮罩
updateLoadingText('部署确认成功!', '服务已重启并正常运行');
addLog('部署确认成功 - /api/logs 接口访问正常', 'success');
setTimeout(() => {
hideLoading();
// 显示成功弹窗
customAlert('🎉 部署成功!云端部署已完成,服务已重启,配置已生效');
addLog('🎉 部署成功!云端部署已完成,服务已重启,配置已生效', 'success');
}, 200);
} else if (confirmationAttempts >= maxAttempts) {
// 达到最大尝试次数,停止确认检查
clearInterval(confirmationInterval);
updateLoadingText('部署确认完成', '服务已重启');
addLog('部署确认完成 - 已达到最大尝试次数', 'warn');
setTimeout(() => {
hideLoading();
// 显示成功弹窗
customAlert('🎉 部署成功!云端部署已完成,服务已重启,配置已生效');
addLog('🎉 部署成功!云端部署已完成,服务已重启,配置已生效', 'success');
}, 200);
} else {
addLog('部署确认中 - 状态码: ' + response.status, 'info');
}
})
.catch(error => {
if (confirmationAttempts >= maxAttempts) {
// 达到最大尝试次数,停止确认检查
clearInterval(confirmationInterval);
updateLoadingText('部署确认完成', '服务已重启');
addLog('部署确认完成 - 已达到最大尝试次数', 'warn');
setTimeout(() => {
hideLoading();
// 显示成功弹窗
customAlert('🎉 部署成功!云端部署已完成,服务已重启,配置已生效');
addLog('🎉 部署成功!云端部署已完成,服务已重启,配置已生效', 'success');
}, 200);
} else {
addLog('部署确认中 - 连接失败: ' + error.message, 'info');
}
});
}, 5000); // 每5秒检查一次,用于确认部署完成
}
// 检查URL中的token是否与currentAdminToken匹配
function checkAdminToken() {
// 获取URL路径并提取token
const urlPath = window.location.pathname;
const pathParts = urlPath.split('/').filter(part => part !== '');
const urlToken = pathParts.length > 0 ? pathParts[0] : currentToken; // 如果没有路径段,使用默认token
// 检查是否配置了ADMIN_TOKEN且URL中的token等于currentAdminToken
return currentAdminToken && currentAdminToken.trim() !== '' && urlToken === currentAdminToken;
}
// 检查部署平台相关配置
async function checkDeployPlatformConfig() {
// 首先检查是否配置了ADMIN_TOKEN
if (!checkAdminToken()) {
// 获取当前页面的协议、主机和端口
const protocol = window.location.protocol;
const host = window.location.host;
return { success: false, message: '请先配置ADMIN_TOKEN环境变量并使用正确的token访问以启用系统部署功能!\\n\\n访问方式:' + protocol + '//' + host + '/{ADMIN_TOKEN}' };
}
try {
const response = await fetch(buildApiUrl('/api/config', true));
if (!response.ok) {
throw new Error('HTTP error! status: ' + response.status);
}
const config = await response.json();
const deployPlatform = config.envs.deployPlatform || 'node';
// 如果是node部署平台,只需要检查ADMIN_TOKEN
if (deployPlatform.toLowerCase() === 'node') {
return { success: true, message: 'Node部署平台,仅需配置ADMIN_TOKEN' };
}
// 对于其他部署平台,收集所有缺失的环境变量
const missingVars = [];
const deployPlatformProject = config.originalEnvVars.DEPLOY_PLATFROM_PROJECT;
const deployPlatformToken = config.originalEnvVars.DEPLOY_PLATFROM_TOKEN;
const deployPlatformAccount = config.originalEnvVars.DEPLOY_PLATFROM_ACCOUNT;
if (!deployPlatformProject || deployPlatformProject.trim() === '') {
missingVars.push('DEPLOY_PLATFROM_PROJECT');
}
if (!deployPlatformToken || deployPlatformToken.trim() === '') {
missingVars.push('DEPLOY_PLATFROM_TOKEN');
}
// 对于netlify和cloudflare部署平台,还需要检查DEPLOY_PLATFROM_ACCOUNT
if (deployPlatform.toLowerCase() === 'netlify' || deployPlatform.toLowerCase() === 'cloudflare') {
if (!deployPlatformAccount || deployPlatformAccount.trim() === '') {
missingVars.push('DEPLOY_PLATFROM_ACCOUNT');
}
}
if (missingVars.length > 0) {
const missingVarsStr = missingVars.join('、');
return { success: false, message: '部署平台为' + deployPlatform + ',请配置以下缺失的环境变量:' + missingVarsStr };
}
return { success: true, message: deployPlatform + '部署平台配置完整' };
} catch (error) {
console.error('检查部署平台配置失败:', error);
return { success: false, message: '检查部署平台配置失败: ' + error.message };
}
}
// 获取并设置配置信息
async function fetchAndSetConfig() {
const config = await fetch(buildApiUrl('/api/config', true)).then(response => response.json());
const hasAdminToken = config.hasAdminToken;
currentAdminToken = config.originalEnvVars?.ADMIN_TOKEN || '';
return config;
}
// 检查并处理管理员令牌
function checkAndHandleAdminToken() {
if (!checkAdminToken()) {
// 禁用系统配置按钮并添加提示
const envNavBtn = document.getElementById('env-nav-btn');
if (envNavBtn) {
envNavBtn.title = '请先配置ADMIN_TOKEN并使用正确的admin token访问以启用系统管理功能';
}
}
}
// 渲染值输入控件
function renderValueInput(item) {
const container = document.getElementById('value-input-container');
const type = item ? item.type : document.getElementById('value-type').value;
const value = item ? item.value : '';
if (type === 'boolean') {
// 布尔开关
const checked = value === 'true' || value === true;
container.innerHTML = \`
<label>值</label>
<div class="switch-container">
<label class="switch">
<input type="checkbox" id="bool-value" \${checked ? 'checked' : ''}>
<span class="slider"></span>
</label>
<span class="switch-label" id="bool-label">\${checked ? '启用' : '禁用'}</span>
</div>
\`;
document.getElementById('bool-value').addEventListener('change', function(e) {
document.getElementById('bool-label').textContent = e.target.checked ? '启用' : '禁用';
});
} else if (type === 'number') {
// 数字滚轮
const min = item && item.min !== undefined ? item.min : 1;
const max = item && item.max !== undefined ? item.max : 100;
const currentValue = value || min;
container.innerHTML = \`
<label>值 (\${min}-\${max})</label>
<div class="number-picker">
<div class="number-controls">
<button type="button" class="number-btn" onclick="adjustNumber(1)">▲</button>
<button type="button" class="number-btn" onclick="adjustNumber(-1)">▼</button>
</div>
<div class="number-display" id="num-value">\${currentValue}</div>
</div>
<div class="number-range">
<input type="range" id="num-slider" min="\${min}" max="\${max}" value="\${currentValue}"
oninput="updateNumberDisplay(this.value)">
</div>
\`;
} else if (type === 'select') {
// 标签选择
const options = item && item.options ? item.options : ['option1', 'option2', 'option3'];
const optionsInput = item ? '' : \`
<div class="form-group margin-bottom-15">
<label>可选项 (逗号分隔)</label>
<input type="text" id="select-options" placeholder="例如: debug,info,warn,error"
value="\${options.join(',')}" onchange="updateTagOptions()">
</div>
\`;
container.innerHTML = \`
\${optionsInput}
<label>选择值</label>
<div class="tag-selector" id="tag-selector">
\${options.map(opt => \`
<div class="tag-option \${opt === value ? 'selected' : ''}"
data-value="\${opt}" onclick="selectTag(this)">
\${opt}
</div>
\`).join('')}
</div>
\`;
} else if (type === 'multi-select') {
// 多选标签(可拖动排序)
const options = item && item.options ? item.options : ['option1', 'option2', 'option3', 'option4'];
// 确保value是字符串类型后再进行split操作
const stringValue = typeof value === 'string' ? value : String(value || '');
const selectedValues = stringValue ? stringValue.split(',').map(v => v.trim()).filter(v => v) : [];
const optionsInput = item ? '' : \`
<div class="form-group margin-bottom-15">
<label>可选项 (逗号分隔)</label>
<input type="text" id="multi-options" placeholder="例如: auth,payment,analytics"
value="\${options.join(',')}" onchange="updateMultiOptions()">
</div>
\`;
container.innerHTML = \`
\${optionsInput}
<label>已选择 (拖动调整顺序)</label>
<div class="multi-select-container">
<div class="selected-tags \${selectedValues.length === 0 ? 'empty' : ''}" id="selected-tags">
\${selectedValues.map(val => \`
<div class="selected-tag" draggable="true" data-value="\${val}">
<span class="tag-text">\${val}</span>
<button type="button" class="remove-btn" onclick="removeSelectedTag(this)">×</button>
</div>
\`).join('')}
</div>
<label>可选项 (点击添加)</label>
<div class="available-tags" id="available-tags">
\${options.map(opt => {
const isSelected = selectedValues.includes(opt);
return \`
<div class="available-tag \${isSelected ? 'disabled' : ''}"
data-value="\${opt}" onclick="addSelectedTag(this)">
\${opt}
</div>
\`;
}).join('')}
</div>
</div>
\`;
// 设置拖动事件
setupDragAndDrop();
} else {
// 文本输入
// 如果值太长,使用textarea而不是input
if (value && value.length > 50) {
// 计算行数,每行约50个字符
const rows = Math.min(Math.max(Math.ceil(value.length / 50), 3), 10); // 最少3行,最多10行
container.innerHTML = \`
<label>变量值 *</label>
<textarea id="text-value" placeholder="例如: localhost" rows="\${rows}" class="text-monospace">\${value}</textarea>
\`;
} else {
container.innerHTML = \`
<label>变量值 *</label>
<input type="text" id="text-value" placeholder="例如: localhost" value="\${value}" required>
\`;
}
}
}
// 调整数字
function adjustNumber(delta) {
const display = document.getElementById('num-value');
const slider = document.getElementById('num-slider');
let value = parseInt(display.textContent) + delta;
value = Math.max(parseInt(slider.min), Math.min(parseInt(slider.max), value));
display.textContent = value;
slider.value = value;
}
// 更新数字显示
function updateNumberDisplay(value) {
document.getElementById('num-value').textContent = value;
}
// 选择标签
function selectTag(element) {
document.querySelectorAll('.tag-option').forEach(el => el.classList.remove('selected'));
element.classList.add('selected');
}
// 更新标签选项
function updateTagOptions() {
const input = document.getElementById('select-options');
const options = input.value.split(',').map(s => s.trim()).filter(s => s);
const container = document.getElementById('tag-selector');
container.innerHTML = options.map(opt => \`
<div class="tag-option" data-value="\${opt}" onclick="selectTag(this)">
\${opt}
</div>
\`).join('');
}
// 添加已选标签
function addSelectedTag(element) {
if (element.classList.contains('disabled')) return;
const value = element.dataset.value;
const container = document.getElementById('selected-tags');
// 移除empty类
container.classList.remove('empty');
// 创建新标签
const tag = document.createElement('div');
tag.className = 'selected-tag';
tag.draggable = true;
tag.dataset.value = value;
tag.innerHTML = \`
<span class="tag-text">\${value}</span>
<button type="button" class="remove-btn" onclick="removeSelectedTag(this)">×</button>
\`;
container.appendChild(tag);
// 禁用可选项
element.classList.add('disabled');
// 重新设置拖动事件
setupDragAndDrop();
}
// 移除已选标签
function removeSelectedTag(button) {
const tag = button.parentElement;
const value = tag.dataset.value;
const container = document.getElementById('selected-tags');
// 移除标签
tag.remove();
// 如果没有标签了,添加empty类
if (container.children.length === 0) {
container.classList.add('empty');
}
// 启用对应的可选项
const availableTag = document.querySelector(\`.available-tag[data-value="\${value}"]\`);
if (availableTag) {
availableTag.classList.remove('disabled');
}
}
// 更新多选选项
function updateMultiOptions() {
const input = document.getElementById('multi-options');
const options = input.value.split(',').map(s => s.trim()).filter(s => s);
const selectedValues = Array.from(document.querySelectorAll('.selected-tag'))
.map(el => el.dataset.value);
const container = document.getElementById('available-tags');
container.innerHTML = options.map(opt => {
const isSelected = selectedValues.includes(opt);
return \`
<div class="available-tag \${isSelected ? 'disabled' : ''}"
data-value="\${opt}" onclick="addSelectedTag(this)">
\${opt}
</div>
\`;
}).join('');
}
// 设置拖放功能
let draggedElement = null;
function setupDragAndDrop() {
const container = document.getElementById('selected-tags');
const tags = container.querySelectorAll('.selected-tag');
tags.forEach(tag => {
tag.addEventListener('dragstart', handleDragStart);
tag.addEventListener('dragend', handleDragEnd);
tag.addEventListener('dragover', handleDragOver);
tag.addEventListener('drop', handleDrop);
tag.addEventListener('dragenter', handleDragEnter);
tag.addEventListener('dragleave', handleDragLeave);
});
}
function handleDragStart(e) {
draggedElement = this;
this.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
}
function handleDragEnd(e) {
this.classList.remove('dragging');
document.querySelectorAll('.selected-tag').forEach(tag => {
tag.classList.remove('drag-over');
});
}
function handleDragOver(e) {
if (e.preventDefault) {
e.preventDefault();
}
e.dataTransfer.dropEffect = 'move';
return false;
}
function handleDragEnter(e) {
if (this !== draggedElement) {
this.classList.add('drag-over');
}
}
function handleDragLeave(e) {
this.classList.remove('drag-over');
}
function handleDrop(e) {
if (e.stopPropagation) {
e.stopPropagation();
}
if (draggedElement !== this) {
const container = document.getElementById('selected-tags');
const allTags = Array.from(container.querySelectorAll('.selected-tag'));
const draggedIndex = allTags.indexOf(draggedElement);
const targetIndex = allTags.indexOf(this);
if (draggedIndex < targetIndex) {
this.parentNode.insertBefore(draggedElement, this.nextSibling);
} else {
this.parentNode.insertBefore(draggedElement, this);
}
}
this.classList.remove('drag-over');
return false;
}
// 显示加载遮罩
function showLoading(text, detail) {
document.getElementById('loading-text').textContent = text;
document.getElementById('loading-detail').textContent = detail;
document.getElementById('loading-overlay').classList.add('active');
document.getElementById('progress-container').classList.add('active');
updateProgress(0);
}
// 隐藏加载遮罩
function hideLoading() {
document.getElementById('loading-overlay').classList.remove('active');
setTimeout(() => {
document.getElementById('progress-container').classList.remove('active');
updateProgress(0);
}, 300);
}
// 更新加载文本
function updateLoadingText(text, detail) {
document.getElementById('loading-text').textContent = text;
document.getElementById('loading-detail').textContent = detail;
}
// 更新进度条
function updateProgress(percent) {
document.getElementById('progress-bar').style.width = percent + '%';
}
// 渲染环境变量列表
function renderEnvList() {
const list = document.getElementById('env-list');
const items = envVariables[currentCategory] || [];
if (items.length === 0) {
list.innerHTML = '<p class="text-gray padding-20 text-center">暂无配置项</p>';
return;
}
list.innerHTML = items.map((item, index) => {
const typeLabel = item.type === 'boolean' ? '布尔' :
item.type === 'number' ? '数字' :
item.type === 'select' ? '单选' :
item.type === 'multi-select' ? '多选' : '文本';
const badgeClass = item.type === 'multi-select' ? 'multi' : '';
return \`
<div class="env-item">
<div class="env-info">
<strong>\${item.key}<span class="value-type-badge \${badgeClass}">\${typeLabel}</span></strong>
<div class="text-dark-gray">\${item.value}</div>
<div class="text-gray font-size-12 margin-top-3">\${item.description || '无描述'}</div>
</div>
<div class="env-actions">
<button class="btn btn-primary" onclick="editEnv(\${index})">编辑</button>
<button class="btn btn-danger" onclick="deleteEnv(\${index})">删除</button>
</div>
</div>
\`;
}).join('');
}
// 编辑环境变量
function editEnv(index) {
const item = envVariables[currentCategory][index];
const editButton = event.target; // 获取当前点击的编辑按钮
// 设置按钮为加载状态
const originalText = editButton.innerHTML;
editButton.innerHTML = '<span class="loading-spinner-small"></span>';
editButton.disabled = true;
editingKey = index;
document.getElementById('modal-title').textContent = '编辑配置项';
document.getElementById('env-category').value = currentCategory;
document.getElementById('env-key').value = item.key;
document.getElementById('env-description').value = item.description || '';
document.getElementById('value-type').value = item.type || 'text';
// 设置字段为只读(编辑模式下)
document.getElementById('env-category').disabled = true;
document.getElementById('env-key').readOnly = true;
document.getElementById('value-type').disabled = true;
document.getElementById('env-description').readOnly = true;
// 渲染对应的值输入控件
renderValueInput(item);
document.getElementById('env-modal').classList.add('active');
// 恢复按钮状态(在实际场景中,这会在编辑完成后发生,比如在保存后或取消后)
// 为了演示,这里立即恢复按钮状态,实际使用中应该在适当的地方恢复按钮状态
editButton.innerHTML = originalText;
editButton.disabled = false;
}
// 删除环境变量
function deleteEnv(index) {
customConfirm('确定要删除这个配置项吗?', '删除确认').then(confirmed => {
if (confirmed) {
const item = envVariables[currentCategory][index];
const key = item.key;
const deleteButton = event.target; // 获取当前点击的删除按钮
// 设置按钮为加载状态
const originalText = deleteButton.innerHTML;
deleteButton.innerHTML = '<span class="loading-spinner-small"></span>';
deleteButton.disabled = true;
// 调用API删除环境变量
fetch(buildApiUrl('/api/env/del'), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ key })
})
.then(response => response.json())
.then(result => {
if (result.success) {
// 从本地数据中删除
envVariables[currentCategory].splice(index, 1);
renderEnvList();
renderPreview();
addLog(\`删除配置项: \${key}\`, 'warn');
} else {
addLog(\`删除配置项失败: \${result.message}\`, 'error');
addLog(\`❌ 删除配置项失败: \${result.message}\`, 'error');
}
})
.catch(error => {
addLog(\`删除配置项失败: \${error.message}\`, 'error');
addLog(\`❌ 删除配置项失败: \${error.message}\`, 'error');
})
.finally(() => {
// 恢复按钮状态
deleteButton.innerHTML = originalText;
deleteButton.disabled = false;
});
}
});
}
// 表单提交
document.getElementById('env-form').addEventListener('submit', async function(e) {
e.preventDefault();
const category = document.getElementById('env-category').value;
const key = document.getElementById('env-key').value.trim();
const description = document.getElementById('env-description').value.trim();
const type = document.getElementById('value-type').value;
// 根据类型获取值
let value, itemData;
if (type === 'boolean') {
value = document.getElementById('bool-value').checked ? 'true' : 'false';
itemData = { key, value, description, type };
} else if (type === 'number') {
value = document.getElementById('num-value').textContent;
const min = parseInt(document.getElementById('num-slider').min);
const max = parseInt(document.getElementById('num-slider').max);
itemData = { key, value, description, type, min, max };
} else if (type === 'select') {
const selected = document.querySelector('.tag-option.selected');
value = selected ? selected.dataset.value : '';
const options = Array.from(document.querySelectorAll('.tag-option')).map(el => el.dataset.value);
itemData = { key, value, description, type, options };
} else if (type === 'multi-select') {
const selectedTags = Array.from(document.querySelectorAll('.selected-tag'))
.map(el => el.dataset.value);
value = selectedTags.join(',');
const options = Array.from(document.querySelectorAll('.available-tag')).map(el => el.dataset.value);
itemData = { key, value, description, type, options };
} else {
value = document.getElementById('text-value').value.trim();
itemData = { key, value, description, type };
}
// 调用API更新环境变量 - 先尝试set接口,失败则调用add接口
try {
// 首先尝试使用set接口更新
let response = await fetch(buildApiUrl('/api/env/set'), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ key, value })
});
let result = await response.json();
if (!result.success) {
// 如果set接口失败,尝试使用add接口
response = await fetch(buildApiUrl('/api/env/add'), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ key, value })
});
result = await response.json();
}
if (result.success) {
// 更新本地数据
if (!envVariables[category]) {
envVariables[category] = [];
}
if (editingKey !== null) {
envVariables[currentCategory][editingKey] = itemData;
addLog(\`更新配置项: \${key} = \${value}\`, 'success');
} else {
envVariables[category].push(itemData);
addLog(\`添加配置项: \${key} = \${value}\`, 'success');
}
if (category !== currentCategory) {
currentCategory = category;
document.querySelectorAll('.category-btn').forEach((btn, i) => {
btn.classList.toggle('active', ['api', 'source', 'match', 'danmu', 'cache', 'system'][i] === category);
});
}
renderEnvList();
renderPreview();
closeModal();
} else {
addLog(\`操作失败: \${result.message}\`, 'error');
addLog(\`❌ 操作失败: \${result.message}\`, 'error');
}
} catch (error) {
addLog(\`更新环境变量失败: \${error.message}\`, 'error');
addLog(\`❌ 更新环境变量失败: \${error.message}\`, 'error');
}
});
`;
+298
View File
@@ -0,0 +1,298 @@
import { globals } from "../configs/globals.js";
import { baseCssContent } from "./css/base.css.js";
import { componentsCssContent } from "./css/components.css.js";
import { formsCssContent } from "./css/forms.css.js";
import { responsiveCssContent } from "./css/responsive.css.js";
import { mainJsContent } from "./js/main.js";
import { previewJsContent } from "./js/preview.js";
import { logviewJsContent } from "./js/logview.js";
import { apitestJsContent } from "./js/apitest.js";
import { pushDanmuJsContent } from "./js/pushdanmu.js";
import { systemSettingsJsContent } from "./js/systemsettings.js";
// language=HTML
export const HTML_TEMPLATE = /* html */ `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LogVar弹幕API</title>
<style>${baseCssContent}</style>
<style>${componentsCssContent}</style>
<style>${formsCssContent}</style>
<style>${responsiveCssContent}</style>
</head>
<body>
<div class="container">
<!-- 进度条 -->
<div class="progress-container" id="progress-container">
<div class="progress-bar" id="progress-bar"></div>
</div>
<div class="header">
<div class="header-left">
<div class="logo-title-container">
<div class="logo"><img src="https://i.mji.rip/2025/09/27/eedc7b701c0fa5c1f7c175b22f441ad9.jpeg" width="500"/></div>
<h1>LogVar弹幕API</h1>
</div>
<div class="version-info">
<span class="version-badge">当前版本: <span id="current-version">v${globals.version}</span></span>
<span class="update-badge" id="update-badge">
🎉 最新版本: <span id="latest-version">加载中...</span>
</span>
<span class="api-endpoint-badge">
API端点: <span id="api-endpoint" title="点击复制API端点" style="cursor: pointer; color: #4CAF50; font-weight: bold;" onclick="copyApiEndpoint()">加载中...</span>
</span>
</div>
</div>
<div class="nav-buttons">
<button class="nav-btn active" onclick="switchSection('preview')">配置预览</button>
<button class="nav-btn" onclick="switchSection('logs')">日志查看</button>
<button class="nav-btn" onclick="switchSection('api')">接口调试</button>
<button class="nav-btn" onclick="switchSection('push')">推送弹幕</button>
<button class="nav-btn" onclick="switchSection('env')" id="env-nav-btn">系统配置</button>
</div>
</div>
<div class="content">
<!-- 配置预览 -->
<div class="section active" id="preview-section">
<h2>配置预览</h2>
<p style="color: #666; margin-bottom: 20px;">当前生效的环境变量配置</p>
<div class="preview-area" id="preview-area"></div>
</div>
<!-- 日志查看 -->
<div class="section" id="logs-section">
<h2>日志查看</h2>
<div class="log-controls">
<div>
<button class="btn btn-primary" onclick="refreshLogs()">🔄 刷新日志</button>
<button class="btn btn-danger" onclick="clearLogs()">🗑️ 清空日志</button>
</div>
<span style="color: #666;">实时日志监控</span>
</div>
<div class="log-container" id="log-container"></div>
</div>
<!-- 接口调试 -->
<div class="section" id="api-section">
<h2>接口调试</h2>
<div class="api-selector">
<div class="form-group">
<label>选择接口</label>
<select id="api-select" onchange="loadApiParams()">
<option value="">请选择接口</option>
<option value="searchAnime">搜索动漫 - /api/v2/search/anime</option>
<option value="searchEpisodes">搜索剧集 - /api/v2/search/episodes</option>
<option value="matchAnime">匹配动漫 - /api/v2/match</option>
<option value="getBangumi">获取番剧详情 - /api/v2/bangumi/:animeId</option>
<option value="getComment">获取弹幕 - /api/v2/comment/:commentId</option>
</select>
</div>
</div>
<div class="api-params" id="api-params" style="display: none;">
<h3 style="margin-bottom: 15px;">接口参数</h3>
<div id="params-form"></div>
<button class="btn btn-success" onclick="testApi()">发送请求</button>
</div>
<div id="api-response-container" style="display: none;">
<h3 style="margin: 20px 0 10px;">响应结果</h3>
<div class="api-response" id="api-response"></div>
</div>
</div>
<!-- 推送弹幕 -->
<div class="section" id="push-section">
<h2>推送弹幕</h2>
<p style="color: #666; margin-bottom: 15px;">支持OK影视等播放器,两端需要在同一局域网或使用公网ip,推送地址格式如 http://127.0.0.1:9978/action?do=refresh&type=danmaku&path=</p>
<div class="push-controls" style="margin-bottom: 20px;">
<div class="form-group" style="margin-bottom: 15px;">
<label>推送地址</label>
<input type="text" id="push-url" placeholder="请输入推送地址,例如: http://127.0.0.1:9978/action?do=refresh&type=danmaku&path=" style="width: 100%; padding: 8px; margin-top: 5px;">
</div>
<div class="form-group" style="margin-bottom: 15px;">
<label>搜索关键字</label>
<div style="margin-top: 5px;">
<input type="text" id="push-search-keyword" placeholder="请输入搜索关键字" style="width: calc(100% - 100px); padding: 8px; display: inline-block;">
<button class="btn btn-primary" onclick="searchAnimeForPush()" style="width: 80px; display: inline-block; margin-left: 10px;">搜索</button>
</div>
</div>
</div>
<div id="push-anime-list" class="anime-list" style="display: none;"></div>
<div id="push-episode-list" class="episode-list" style="display: none; margin-top: 20px;"></div>
</div>
<!-- 系统配置 -->
<div class="section" id="env-section">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 10px;">
<div>
<h2 style="margin: 0;">环境变量配置</h2>
<p style="margin: 5px 0 0 0; color: #666; font-size: 0.9em;">vercel/netlify/edgeone平台修改变量后需要重新部署</p>
</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<button class="btn btn-danger" onclick="showClearCacheModal()" title="清理系统缓存">
🗑️ 清理缓存
</button>
<button class="btn btn-success" onclick="showDeploySystemModal()" title="重新部署系统">
🚀 重新部署
</button>
</div>
<!-- 清理缓存确认模态框 -->
<div class="modal" id="clear-cache-modal">
<div class="modal-content">
<div class="modal-header">
<h3>确认清理缓存</h3>
<button class="close-btn" onclick="hideClearCacheModal()">&times;</button>
</div>
<div class="modal-body">
<p style="margin-bottom: 20px;">确定要清理所有缓存吗?这将清除:</p>
<ul class="confirmation-list">
<li>动漫搜索缓存 (animes)</li>
<li>剧集ID缓存 (episodeIds)</li>
<li>剧集编号缓存 (episodeNum)</li>
<li>最后选择映射缓存 (lastSelectMap)</li>
<li>搜索结果缓存</li>
<li>弹幕内容缓存</li>
<li>请求历史记录</li>
</ul>
<p style="color: #666; margin-top: 20px;">清理后可能需要重新登录</p>
</div>
<div class="modal-footer">
<button class="btn btn-success" onclick="confirmClearCache()">确认清理</button>
<button class="btn btn-danger" onclick="hideClearCacheModal()">取消</button>
</div>
</div>
</div>
<!-- 重新部署确认模态框 -->
<div class="modal" id="deploy-system-modal">
<div class="modal-content">
<div class="modal-header">
<h3>确认重新部署</h3>
<button class="close-btn" onclick="hideDeploySystemModal()">&times;</button>
</div>
<div class="modal-body">
<p style="margin-bottom: 20px;">确定要重新部署系统吗?</p>
<div class="warning-box">
<p style="margin: 0;">部署过程中:</p>
<ul class="confirmation-list">
<li>系统将短暂不可用</li>
<li>所有配置将重新加载</li>
<li>服务将自动重启</li>
</ul>
<p style="margin-top: 10px;">预计耗时:30-90秒</p>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-success" onclick="confirmDeploySystem()">确认部署</button>
<button class="btn btn-danger" onclick="hideDeploySystemModal()">取消</button>
</div>
</div>
</div>
</div>
<div class="env-categories">
<button class="category-btn active" onclick="switchCategory('api')">🔗 API配置</button>
<button class="category-btn" onclick="switchCategory('source')">📜 源配置</button>
<button class="category-btn" onclick="switchCategory('match')">🔍 匹配配置</button>
<button class="category-btn" onclick="switchCategory('danmu')">🔣 弹幕配置</button>
<button class="category-btn" onclick="switchCategory('cache')">💾 缓存配置</button>
<button class="category-btn" onclick="switchCategory('system')">⚙️ 系统配置</button>
</div>
<div class="env-list" id="env-list"></div>
</div>
</div>
</div>
<!-- 加载遮罩层 -->
<div class="loading-overlay" id="loading-overlay">
<div class="loading-content">
<div class="loading-spinner"></div>
<div class="loading-text" id="loading-text">正在处理...</div>
<div class="loading-detail" id="loading-detail">请稍候</div>
</div>
</div>
<!-- 编辑模态框 -->
<div class="modal" id="env-modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="modal-title">编辑配置项</h3>
<button class="close-btn" onclick="closeModal()">&times;</button>
</div>
<form id="env-form">
<div class="form-group">
<label>变量类别</label>
<select id="env-category" disabled>
<option value="api">🔗 API配置</option>
<option value="source">📜 源配置</option>
<option value="match">🔍 匹配配置</option>
<option value="danmu">🔣 弹幕配置</option>
<option value="cache">💾 缓存配置</option>
<option value="system">⚙️ 系统配置</option>
</select>
</div>
<div class="form-group">
<label>变量名</label>
<input type="text" id="env-key" placeholder="例如: DB_HOST" required readonly>
</div>
<div class="form-group">
<label>值类型</label>
<select id="value-type" onchange="renderValueInput()" disabled>
<option value="text">文本</option>
<option value="boolean">布尔值</option>
<option value="number">数字 (1-100)</option>
<option value="select">单选</option>
<option value="multi-select">多选 (可排序)</option>
</select>
</div>
<div class="form-group" id="value-input-container">
<!-- 动态渲染的值输入控件 -->
</div>
<div class="form-group">
<label>描述</label>
<textarea id="env-description" placeholder="配置项说明" readonly></textarea>
</div>
<div style="display: flex; gap: 10px;">
<button type="submit" class="btn btn-success" style="flex: 1;">保存</button>
<button type="button" class="btn btn-danger" onclick="closeModal()" style="flex: 1;">取消</button>
</div>
</form>
</div>
</div>
<!-- 项目声明 -->
<footer class="footer">
<p class="footer-text">
一个人人都能部署的基于 js 的弹幕 API 服务器,支持爱优腾芒哔人韩巴弹幕直接获取,兼容弹弹play的搜索、详情查询和弹幕获取接口规范,并提供日志记录,支持vercel/netlify/edgeone/cloudflare/docker/claw等部署方式,不用提前下载弹幕,没有nas或小鸡也能一键部署。
</p>
<p class="footer-text">本项目仅为个人爱好开发,代码开源。如有任何侵权行为,请联系本人删除。</p>
<p class="footer-links">
<a href="https://t.me/ddjdd_bot" target="_blank" class="footer-link">💬 TG MSG ROBOT</a>
<a href="https://t.me/logvar_danmu_group" target="_blank" class="footer-link">👥 TG GROUP</a>
<a href="https://t.me/logvar_danmu_channel" target="_blank" class="footer-link">📢 TG CHANNEL</a>
<a href="https://github.com/huangxd-/danmu_api" target="_blank" class="footer-link github-link">
<img src="https://upload.wikimedia.org/wikipedia/commons/9/91/Octicons-mark-github.svg" alt="GitHub" class="github-icon">
GitHub Repo
</a>
</p>
<p>有问题提issue或私信机器人都ok</p>
</footer>
<script>
${mainJsContent}
${previewJsContent}
${logviewJsContent}
${apitestJsContent}
${pushDanmuJsContent}
${systemSettingsJsContent}
</script>
</body>
</html>
`;
+1 -1
View File
@@ -309,7 +309,7 @@ export function cleanupExpiredIPs(currentTime) {
}
// 获取当前文件目录的兼容方式
function getDirname() {
export function getDirname() {
if (typeof __dirname !== 'undefined') {
// CommonJS 环境 (Vercel)
return __dirname;
+77 -1
View File
@@ -199,11 +199,14 @@ export async function httpPost(url, body, options = {}) {
clearTimeout(timeoutId);
const data = await response.text();
if (!response.ok) {
log("error", `[请求模拟] response data: `, data);
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.text();
let parsedData;
try {
parsedData = JSON.parse(data); // 尝试将文本解析为 JSON
@@ -260,6 +263,79 @@ export async function httpPost(url, body, options = {}) {
throw lastError;
}
/**
* 通用 HTTP 请求函数(模拟环境返回结构)
* @param {string} method - HTTP 方法
* @param {string} url - 请求地址
* @param {any} [body] - 请求体(可选)
* @param {object} [options] - 选项
* @param {object} [options.headers] - 请求头
* @param {object} [options.params] - 查询参数(暂未实现)
* @param {boolean} [options.allow_redirects=true] - 是否允许重定向(暂未实现)
* @returns {Promise<{data: any, status: number, headers: Record<string, string>}>}
*/
async function httpRequestMethod(method, url, body, options = {}) {
log("info", `[请求模拟] HTTP ${method}: ${url}`);
const { headers = {}, params, allow_redirects = true } = options;
const fetchOptions = {
method,
headers: { ...headers },
};
// 只有在 body 存在时才设置(DELETE 通常无 body)
if (body !== undefined && body !== null) {
fetchOptions.body = body;
}
try {
const response = await fetch(url, fetchOptions);
const textData = await response.text();
if (!response.ok) {
log("error", `[请求模拟] response data: `, textData);
throw new Error(`HTTP error! status: ${response.status}`);
}
let parsedData;
try {
parsedData = JSON.parse(textData);
} catch (e) {
parsedData = textData;
}
return {
data: parsedData,
status: response.status,
headers: Object.fromEntries(response.headers.entries())
};
} catch (error) {
log("error", `[请求模拟] 请求失败:`, error.message);
log("error", '详细诊断:');
log("error", '- URL:', url);
log("error", '- 错误类型:', error.name);
log("error", '- 消息:', error.message);
if (error.cause) {
log("error", '- 码:', error.cause?.code);
log("error", '- 原因:', error.cause?.message);
}
throw error;
}
}
export async function httpPatch(url, body, options = {}) {
return httpRequestMethod('PATCH', url, body, options);
}
export async function httpPut(url, body, options = {}) {
return httpRequestMethod('PUT', url, body, options);
}
export async function httpDelete(url, options = {}) {
return httpRequestMethod('DELETE', url, undefined, options); // DELETE 不传 body
}
export async function getPageTitle(url) {
try {
// 使用 httpGet 获取网页内容
+43 -4
View File
@@ -12,18 +12,57 @@ export function log(level, ...args) {
return; // 日志级别不符合,不输出
}
const message = args
// 处理日志参数,隐藏敏感信息
const processedArgs = args.map(arg => {
if (typeof arg === "object") {
// 如果是对象,转换为字符串并隐藏敏感信息
const jsonString = JSON.stringify(arg);
return hideSensitiveInfo(jsonString);
} else {
// 如果是字符串,直接隐藏敏感信息
return typeof arg === 'string' ? hideSensitiveInfo(arg) : arg;
}
});
const message = processedArgs
.map((arg) => (typeof arg === "object" ? JSON.stringify(arg) : arg))
.join(" ");
// 获取上海时区时间(UTC+8)
const now = new Date();
const shanghaiTime = new Date(now.getTime() + (8 * 60 * 60 * 1000));
const shanghaiTime = new Date(now.getTime() + (8 * 60 * 1000));
const timestamp = shanghaiTime.toISOString().replace('Z', '+08:00');
globals.logBuffer.push({ timestamp, level, message });
if (globals.logBuffer.length > globals.MAX_LOGS) globals.logBuffer.shift();
console[level](...args);
console[level](...processedArgs);
}
// 隐藏敏感信息的辅助函数
function hideSensitiveInfo(message) {
let processedMessage = message;
// 从 globals.originalEnvVars 获取被加密的环境变量值并替换
// 通过比较 globals.originalEnvVars 和 globals.accessedEnvVars 来识别加密变量
// 如果 originalEnvVars 中的值与 accessedEnvVars 中的值不同,且 accessedEnvVars 中是星号,则说明该变量被加密
if (globals.originalEnvVars && globals.accessedEnvVars) {
for (const [envVar, originalValue] of Object.entries(globals.originalEnvVars)) {
const accessedValue = globals.accessedEnvVars[envVar];
// 检查是否为加密变量:原始值存在、访问值存在、访问值是星号且与原始值不同
if (originalValue && typeof originalValue === 'string' && originalValue.length > 0 &&
accessedValue && typeof accessedValue === 'string' &&
accessedValue.match(/^\*+$/) && accessedValue.length === originalValue.length) {
// 这是一个加密变量,需要隐藏
const mask = '*'.repeat(originalValue.length);
// 创建正则表达式来匹配环境变量值,使用全局替换
const regex = new RegExp(originalValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g');
processedMessage = processedMessage.replace(regex, mask);
}
}
}
return processedMessage;
}
export function formatLogMessage(message) {
@@ -33,4 +72,4 @@ export function formatLogMessage(message) {
} catch {
return message;
}
}
}
+82 -38
View File
@@ -5,17 +5,20 @@ import { getRedisCaches, judgeRedisValid } from "./utils/redis-util.js";
import { cleanupExpiredIPs, findUrlById, getCommentCache, getLocalCaches, judgeLocalCacheValid } from "./utils/cache-util.js";
import { formatDanmuResponse } from "./utils/danmu-util.js";
import { getBangumi, getComment, getCommentByUrl, matchAnime, searchAnime, searchEpisodes } from "./apis/dandan-api.js";
import { handleConfig, handleUI, handleLogs, handleClearLogs, handleDeploy, handleClearCache } from "./apis/system-api.js";
import { handleSetEnv, handleAddEnv, handleDelEnv } from "./apis/env-api.js";
let globals;
async function handleRequest(req, env, deployPlatform, clientIp) {
// 加载全局变量和环境变量配置
globals = Globals.init(env, deployPlatform);
globals = Globals.init(env);
const url = new URL(req.url);
let path = url.pathname;
const method = req.method;
globals.deployPlatform = deployPlatform;
if (deployPlatform === "node") {
await judgeLocalCacheValid(path, deployPlatform);
}
@@ -25,6 +28,21 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
log("info", `request path: ${path}`);
log("info", `client ip: ${clientIp}`);
// --- 校验 token ---
const parts = path.split("/").filter(Boolean); // 去掉空段
const knownApiPaths = ["api", "v1", "v2", "search", "match", "bangumi", "comment"];
const firstPart = parts[0] || "";
const isDefaultToken = globals.token === "87654321";
const isValidToken = firstPart === globals.token || firstPart === globals.adminToken;
globals.currentToken =
isValidToken ? firstPart :
isDefaultToken && (firstPart === "87654321" || knownApiPaths.includes(firstPart)) ?
(firstPart === "87654321" ? firstPart : "87654321") :
"";
if (deployPlatform === "node" && globals.localCacheValid && path !== "/favicon.ico" && path !== "/robots.txt") {
await getLocalCaches();
}
@@ -32,25 +50,9 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
await getRedisCaches();
}
function handleHomepage() {
log("info", "Accessed homepage with repository information");
return jsonResponse({
message: "Welcome to the LogVar Danmu API server",
version: globals.VERSION,
envs: {
...globals.accessedEnvVars,
localCacheValid: globals.localCacheValid,
redisValid: globals.redisValid
},
repository: "https://github.com/huangxd-/danmu_api.git",
description: "一个人人都能部署的基于 js 的弹幕 API 服务器,支持爱优腾芒哔人韩巴弹幕直接获取,兼容弹弹play的搜索、详情查询和弹幕获取接口规范,并提供日志记录,支持vercel/netlify/edgeone/cloudflare/docker/claw等部署方式,不用提前下载弹幕,没有nas或小鸡也能一键部署。",
notice: "本项目仅为个人爱好开发,代码开源。如有任何侵权行为,请联系本人删除。有问题提issue或私信机器人都ok,TG MSG ROBOT: [https://t.me/ddjdd_bot]; 推荐加互助群咨询,TG GROUP: [https://t.me/logvar_danmu_group]; 关注频道获取最新更新内容,TG CHANNEL: [https://t.me/logvar_danmu_channel]。"
});
}
// GET /
if (path === "/" && method === "GET") {
return handleHomepage();
return handleUI();
}
if (path === "/favicon.ico" || path === "/robots.txt" || method === "OPTIONS") {
@@ -64,20 +66,18 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
});
}
// --- 校验 token ---
const parts = path.split("/").filter(Boolean); // 去掉空段
// 如果 token 是默认值 87654321
if (globals.token === "87654321") {
// 检查第一段是否是已知的 API 路径(不是 token)
const knownApiPaths = ["api", "v1", "v2", "search", "match", "bangumi", "comment"];
if (parts.length > 0) {
// 如果第一段是正确的默认 token
if (parts[0] === "87654321") {
if (parts[0] === "87654321" || parts[0] === globals.adminToken) {
// 移除 token,继续处理
path = "/" + parts.slice(1).join("/");
} else if (!knownApiPaths.includes(parts[0])) {
// 对于 /api/config 路径,我们允许无 token 访问,但返回有限信息
if (path === "/api/config" && method === "GET") {
return handleConfig(false); // 无权限
}
// 第一段不是已知的 API 路径,可能是错误的 token
// 返回 401
log("error", `Invalid token in path: ${path}`);
@@ -90,7 +90,11 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
}
} else {
// token 不是默认值,必须严格校验
if (parts.length < 1 || parts[0] !== globals.token) {
if (parts.length < 1 || (parts[0] !== globals.token && parts[0] !== globals.adminToken)) {
// 对于 /api/config 路径,如果使用默认 token,我们允许无 token 访问,但返回有限信息
if (path === "/api/config" && method === "GET") {
return handleConfig(false); // 无权限
}
log("error", `Invalid or missing token in path: ${path}`);
return jsonResponse(
{ errorCode: 401, success: false, errorMessage: "Unauthorized" },
@@ -101,10 +105,16 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
path = "/" + parts.slice(1).join("/");
}
// GET /api/config - 获取配置信息 (需要 token)
if (path === "/api/config" && method === "GET") {
return handleConfig(true); // 有权限
}
log("info", path);
// 智能处理API路径前缀,确保最终有一个正确的 /api/v2
if (path !== "/" && path !== "/api/logs") {
if (path !== "/" && path !== "/api/logs" && !path.startsWith('/api/env')
&& !path.startsWith('/api/deploy') && !path.startsWith('/api/cache')) {
log("info", `[Path Check] Starting path normalization for: "${path}"`);
const pathBeforeCleanup = path; // 保存清理前的路径检查是否修改
@@ -124,7 +134,8 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
// 2. 补全:如果路径缺少前缀(例如请求原始路径为 /search/anime),则补全
const pathBeforePrefixCheck = path;
if (!path.startsWith('/api/v2') && path !== '/' && !path.startsWith('/api/logs')) {
if (!path.startsWith('/api/v2') && path !== '/' && !path.startsWith('/api/logs')
&& !path.startsWith('/api/env') && !path.startsWith('/api/env') && !path.startsWith('/api/cache')) {
log("info", `[Path Check] Path is missing /api/v2 prefix. Adding...`);
path = '/api/v2' + path;
}
@@ -139,7 +150,7 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
// GET /
if (path === "/" && method === "GET") {
return handleHomepage();
return handleUI();
}
// GET /api/v2/search/anime
@@ -279,19 +290,52 @@ async function handleRequest(req, env, deployPlatform, clientIp) {
// GET /api/logs
if (path === "/api/logs" && method === "GET") {
const logText = globals.logBuffer
.map(
(log) =>
`[${log.timestamp}] ${log.level}: ${formatLogMessage(log.message)}`
)
.join("\n");
return new Response(logText, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
return handleLogs();
}
// POST /api/logs/clear
if (path === "/api/logs/clear" && method === "POST") {
return handleClearLogs();
}
// POST /api/env/set - 设置环境变量
if (path === "/api/env/set" && method === "POST") {
return handleSetEnv(req);
}
// POST /api/env/add - 添加环境变量
if (path === "/api/env/add" && method === "POST") {
return handleAddEnv(req);
}
// POST /api/env/del - 删除环境变量
if (path === "/api/env/del" && method === "POST") {
return handleDelEnv(req);
}
// POST /api/deploy - 重新部署
if (path === "/api/deploy" && method === "POST") {
return handleDeploy();
}
// POST /api/cache/clear - 清理缓存
if (path === "/api/cache/clear" && method === "POST") {
return handleClearCache();
}
return jsonResponse({ message: "Not found" }, 404);
}
function isRunningOnVercel() {
if (typeof process === 'undefined' || !process.env) {
return false;
}
return !!(
process.env.VERCEL ||
process.env.VERCEL_ENV ||
process.env.VERCEL_URL
);
}
// --- Cloudflare Workers 入口 ---
export default {
@@ -299,7 +343,7 @@ export default {
// 获取客户端的真实 IP
const clientIp = request.headers.get('cf-connecting-ip') || request.headers.get('x-forwarded-for') || 'unknown';
return handleRequest(request, env, "cloudflare", clientIp);
return handleRequest(request, env, isRunningOnVercel() ? "vercel" : "cloudflare", clientIp);
},
};
+178
View File
@@ -19,6 +19,11 @@ import MangoSource from "./sources/mango.js";
import BilibiliSource from "./sources/bilibili.js";
import YoukuSource from "./sources/youku.js";
import OtherSource from "./sources/other.js";
import { NodeHandler } from "./configs/handlers/node-handler.js";
import { VercelHandler } from "./configs/handlers/vercel-handler.js";
import { NetlifyHandler } from "./configs/handlers/netlify-handler.js";
import { CloudflareHandler } from "./configs/handlers/cloudflare-handler.js";
import { EdgeoneHandler } from "./configs/handlers/edgeone-handler.js";
// Mock Request class for testing
class MockRequest {
@@ -300,4 +305,177 @@ test('worker.js API endpoints', async (t) => {
// const res = await getTMDBChineseTitle("Blood River", 1, 4);
// assert(res === "暗河传", `Expected res === "暗河传", but got ${res}`);
// });
// // 测试获取全部环境变量
// await t.test('Config getAllEnv', async () => {
// const handler = new NodeHandler();
// const res = handler.getAllEnv();
// assert(Number(res.DANMU_LIMIT) === 0, `Expected Number(res.DANMU_LIMIT) === 0, but got ${Number(res.DANMU_LIMIT)}`);
// });
// // 测试获取某个环境变量
// await t.test('Config getEnv', async () => {
// const handler = new NodeHandler();
// const res = handler.getEnv("DANMU_LIMIT");
// assert(Number(res) === 0, `Expected Number(res) === 0, but got ${Number(res)}`);
// });
// // 测试Node设置环境变量
// await t.test('Node Config setEnv', async () => {
// const handler = new NodeHandler();
// let res = handler.getEnv("DANMU_LIMIT");
// assert(Number(res) === 0, `Expected Number(res) === 0, but got ${Number(res)}`);
// await handler.setEnv("DANMU_LIMIT", 1);
// res = handler.getEnv("DANMU_LIMIT");
// assert(Number(res) === 1, `Expected Number(res) === 1, but got ${Number(res)}`);
// await handler.setEnv("DANMU_LIMIT", 0);
// });
//
// // 测试Node添加和删除环境变量
// await t.test('Node Config addEnv and del Env', async () => {
// const handler = new NodeHandler();
// await handler.addEnv("UPSTASH_REDIS_REST_TOKEN", "xxxx");
// let res = handler.getEnv("UPSTASH_REDIS_REST_TOKEN");
// assert(res === "xxxx", `Expected res === "xxxx", but got ${res}`);
// await handler.delEnv("UPSTASH_REDIS_REST_TOKEN");
// res = handler.getEnv("UPSTASH_REDIS_REST_TOKEN");
// assert(res === "", `Expected res === "", but got ${res}`);
// });
// // 测试Vercel设置环境变量
// await t.test('Vercel Config setEnv', async () => {
// const handler = new VercelHandler();
// let res = handler.getEnv("DANMU_LIMIT");
// assert(Number(res) === 0, `Expected Number(res) === 0, but got ${Number(res)}`);
// await handler.setEnv("DANMU_LIMIT", 1);
// res = handler.getEnv("DANMU_LIMIT");
// assert(Number(res) === 1, `Expected Number(res) === 1, but got ${Number(res)}`);
// await handler.setEnv("DANMU_LIMIT", 0);
// });
//
// // 测试Vercel添加和删除环境变量
// await t.test('Vercel Config addEnv and del Env', async () => {
// const handler = new VercelHandler();
// await handler.addEnv("UPSTASH_REDIS_REST_TOKEN", "xxxx");
// let res = handler.getEnv("UPSTASH_REDIS_REST_TOKEN");
// assert(res === "xxxx", `Expected res === "xxxx", but got ${res}`);
// await handler.delEnv("UPSTASH_REDIS_REST_TOKEN");
// res = handler.getEnv("UPSTASH_REDIS_REST_TOKEN");
// assert(res === "", `Expected res === "", but got ${res}`);
// });
// // 测试Vercel项目变量是否生效
// await t.test('Vercel Check Params', async () => {
// const handler = new VercelHandler();
// const res = await handler.checkParams("", "", "");
// assert(res, `Expected res is true, but got ${res}`);
// });
// // 测试Vercel触发部署
// await t.test('Vercel deploy', async () => {
// const handler = new VercelHandler();
// const res = await handler.deploy();
// assert(res, `Expected res is true, but got ${res}`);
// });
// // 测试Netlify设置环境变量
// await t.test('Netlify Config setEnv', async () => {
// const handler = new NetlifyHandler();
// let res = handler.getEnv("DANMU_LIMIT");
// assert(Number(res) === 0, `Expected Number(res) === 0, but got ${Number(res)}`);
// await handler.setEnv("DANMU_LIMIT", 1);
// res = handler.getEnv("DANMU_LIMIT");
// assert(Number(res) === 1, `Expected Number(res) === 1, but got ${Number(res)}`);
// await handler.setEnv("DANMU_LIMIT", 0);
// });
//
// // 测试Netlify添加和删除环境变量
// await t.test('Netlify Config addEnv and del Env', async () => {
// const handler = new NetlifyHandler();
// await handler.addEnv("UPSTASH_REDIS_REST_TOKEN", "xxxx");
// let res = handler.getEnv("UPSTASH_REDIS_REST_TOKEN");
// assert(res === "xxxx", `Expected res === "xxxx", but got ${res}`);
// await handler.delEnv("UPSTASH_REDIS_REST_TOKEN");
// res = handler.getEnv("UPSTASH_REDIS_REST_TOKEN");
// assert(res === "", `Expected res === "", but got ${res}`);
// });
// // 测试Netlify项目变量是否生效
// await t.test('Netlify Check Params', async () => {
// const handler = new NetlifyHandler();
// const res = await handler.checkParams("", "", "");
// assert(res, `Expected res is true, but got ${res}`);
// });
// // 测试Netlify触发部署
// await t.test('Netlify deploy', async () => {
// const handler = new NetlifyHandler();
// const res = await handler.deploy();
// assert(res, `Expected res is true, but got ${res}`);
// });
// // 测试Cloudflare设置环境变量
// await t.test('Cloudflare Config setEnv', async () => {
// const handler = new CloudflareHandler();
// let res = handler.getEnv("DANMU_LIMIT");
// assert(Number(res) === 0, `Expected Number(res) === 0, but got ${Number(res)}`);
// await handler.setEnv("DANMU_LIMIT", 1);
// res = handler.getEnv("DANMU_LIMIT");
// assert(Number(res) === 1, `Expected Number(res) === 1, but got ${Number(res)}`);
// await handler.setEnv("DANMU_LIMIT", 0);
// });
// // 测试Cloudflare添加和删除环境变量
// await t.test('Cloudflare Config addEnv and del Env', async () => {
// const handler = new CloudflareHandler();
// await handler.addEnv("UPSTASH_REDIS_REST_TOKEN", "xxxx");
// let res = handler.getEnv("UPSTASH_REDIS_REST_TOKEN");
// assert(res === "xxxx", `Expected res === "xxxx", but got ${res}`);
// await handler.delEnv("UPSTASH_REDIS_REST_TOKEN");
// res = handler.getEnv("UPSTASH_REDIS_REST_TOKEN");
// assert(res === "", `Expected res === "", but got ${res}`);
// });
// // 测试Cloudflare项目变量是否生效
// await t.test('Cloudflare Check Params', async () => {
// const handler = new CloudflareHandler();
// const res = await handler.checkParams("", "", "");
// assert(res, `Expected res is true, but got ${res}`);
// });
// // 测试Edgeone设置环境变量
// await t.test('Edgeone Config setEnv', async () => {
// const handler = new EdgeoneHandler();
// let res = handler.getEnv("DANMU_LIMIT");
// assert(Number(res) === 0, `Expected Number(res) === 0, but got ${Number(res)}`);
// await handler.setEnv("DANMU_LIMIT", 1);
// res = handler.getEnv("DANMU_LIMIT");
// assert(Number(res) === 1, `Expected Number(res) === 1, but got ${Number(res)}`);
// await handler.setEnv("DANMU_LIMIT", 0);
// });
// // 测试Edgeone添加和删除环境变量
// await t.test('Edgeone Config addEnv and del Env', async () => {
// const handler = new EdgeoneHandler();
// await handler.addEnv("PROXY_URL", "xxxx");
// let res = handler.getEnv("PROXY_URL");
// assert(res === "xxxx", `Expected res === "xxxx", but got ${res}`);
// await handler.delEnv("PROXY_URL");
// res = handler.getEnv("PROXY_URL");
// assert(res === "", `Expected res === "", but got ${res}`);
// });
// // 测试Edgeone项目变量是否生效
// await t.test('Edgeone Check Params', async () => {
// const handler = new EdgeoneHandler();
// const res = await handler.checkParams("", "", "");
// assert(res, `Expected res is true, but got ${res}`);
// });
// // 测试Edgeone触发部署
// await t.test('Edgeone deploy', async () => {
// const handler = new EdgeoneHandler();
// const res = await handler.deploy();
// assert(res, `Expected res is true, but got ${res}`);
// });
});