From 995d5f10e0912aa28d328b5581677ba170f16ea4 Mon Sep 17 00:00:00 2001 From: huangxd- <649924757@qq.com> Date: Sun, 7 Dec 2025 16:42:16 +0800 Subject: [PATCH] =?UTF-8?q?feature:=20=E6=96=B0=E5=A2=9EUI=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20(#105)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 增加环境变量处理类 * 完善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的平台 * 添加环境变量修改后说明 * 修改重新部署时长 * 修改版本号 --- .env.example | 22 + README.md | 170 ++-- config.yaml.example | 22 + danmu_api/apis/env-api.js | 96 ++ danmu_api/apis/system-api.js | 198 ++++ danmu_api/configs/envs.js | 93 +- danmu_api/configs/globals.js | 24 +- danmu_api/configs/handlers/base-handler.js | 103 ++ .../configs/handlers/cloudflare-handler.js | 114 +++ danmu_api/configs/handlers/edgeone-handler.js | 111 +++ danmu_api/configs/handlers/handler-factory.js | 35 + danmu_api/configs/handlers/netlify-handler.js | 109 +++ danmu_api/configs/handlers/node-handler.js | 204 ++++ danmu_api/configs/handlers/vercel-handler.js | 167 ++++ danmu_api/ui/README.md | 293 ++++++ danmu_api/ui/css/base.css.js | 165 ++++ danmu_api/ui/css/components.css.js | 710 ++++++++++++++ danmu_api/ui/css/forms.css.js | 318 +++++++ danmu_api/ui/css/responsive.css.js | 68 ++ danmu_api/ui/js/apitest.js | 230 +++++ danmu_api/ui/js/logview.js | 122 +++ danmu_api/ui/js/main.js | 390 ++++++++ danmu_api/ui/js/preview.js | 55 ++ danmu_api/ui/js/pushdanmu.js | 180 ++++ danmu_api/ui/js/systemsettings.js | 889 ++++++++++++++++++ danmu_api/ui/template.js | 298 ++++++ danmu_api/utils/cache-util.js | 2 +- danmu_api/utils/http-util.js | 78 +- danmu_api/utils/log-util.js | 47 +- danmu_api/worker.js | 120 ++- danmu_api/worker.test.js | 178 ++++ 31 files changed, 5490 insertions(+), 121 deletions(-) create mode 100644 danmu_api/apis/env-api.js create mode 100644 danmu_api/apis/system-api.js create mode 100644 danmu_api/configs/handlers/base-handler.js create mode 100644 danmu_api/configs/handlers/cloudflare-handler.js create mode 100644 danmu_api/configs/handlers/edgeone-handler.js create mode 100644 danmu_api/configs/handlers/handler-factory.js create mode 100644 danmu_api/configs/handlers/netlify-handler.js create mode 100644 danmu_api/configs/handlers/node-handler.js create mode 100644 danmu_api/configs/handlers/vercel-handler.js create mode 100644 danmu_api/ui/README.md create mode 100644 danmu_api/ui/css/base.css.js create mode 100644 danmu_api/ui/css/components.css.js create mode 100644 danmu_api/ui/css/forms.css.js create mode 100644 danmu_api/ui/css/responsive.css.js create mode 100644 danmu_api/ui/js/apitest.js create mode 100644 danmu_api/ui/js/logview.js create mode 100644 danmu_api/ui/js/main.js create mode 100644 danmu_api/ui/js/preview.js create mode 100644 danmu_api/ui/js/pushdanmu.js create mode 100644 danmu_api/ui/js/systemsettings.js create mode 100644 danmu_api/ui/template.js diff --git a/.env.example b/.env.example index e2e5b06..d59198f 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index fb1f9e3..f2ca162 100644 --- a/README.md +++ b/README.md @@ -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,会根据标题进行搜索,等待一段时间,选择剧集就行。 @@ -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 中间处理逻辑 ``` ## 注意事项 diff --git a/config.yaml.example b/config.yaml.example index a582d80..d17fc65 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -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 diff --git a/danmu_api/apis/env-api.js b/danmu_api/apis/env-api.js new file mode 100644 index 0000000..1d23c35 --- /dev/null +++ b/danmu_api/apis/env-api.js @@ -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); + } +} diff --git a/danmu_api/apis/system-api.js b/danmu_api/apis/system-api.js new file mode 100644 index 0000000..92f9c8a --- /dev/null +++ b/danmu_api/apis/system-api.js @@ -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); + } +} diff --git a/danmu_api/configs/envs.js b/danmu_api/configs/envs.js index ec45484..794dd5c 100644 --- a/danmu_api/configs/envs.js +++ b/danmu_api/configs/envs.js @@ -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} 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 // 环境变量分类和描述映射 }; } } diff --git a/danmu_api/configs/globals.js b/danmu_api/configs/globals.js index 318c16f..4d0b762 100644 --- a/danmu_api/configs/globals.js +++ b/danmu_api/configs/globals.js @@ -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(); }, diff --git a/danmu_api/configs/handlers/base-handler.js b/danmu_api/configs/handlers/base-handler.js new file mode 100644 index 0000000..dc53d5e --- /dev/null +++ b/danmu_api/configs/handlers/base-handler.js @@ -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"); + } +} diff --git a/danmu_api/configs/handlers/cloudflare-handler.js b/danmu_api/configs/handlers/cloudflare-handler.js new file mode 100644 index 0000000..1bbe659 --- /dev/null +++ b/danmu_api/configs/handlers/cloudflare-handler.js @@ -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; + } +} \ No newline at end of file diff --git a/danmu_api/configs/handlers/edgeone-handler.js b/danmu_api/configs/handlers/edgeone-handler.js new file mode 100644 index 0000000..32fb3d8 --- /dev/null +++ b/danmu_api/configs/handlers/edgeone-handler.js @@ -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; + } + } +} \ No newline at end of file diff --git a/danmu_api/configs/handlers/handler-factory.js b/danmu_api/configs/handlers/handler-factory.js new file mode 100644 index 0000000..0dcacdb --- /dev/null +++ b/danmu_api/configs/handlers/handler-factory.js @@ -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']; + } +} diff --git a/danmu_api/configs/handlers/netlify-handler.js b/danmu_api/configs/handlers/netlify-handler.js new file mode 100644 index 0000000..6b33505 --- /dev/null +++ b/danmu_api/configs/handlers/netlify-handler.js @@ -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; + } + } +} \ No newline at end of file diff --git a/danmu_api/configs/handlers/node-handler.js b/danmu_api/configs/handlers/node-handler.js new file mode 100644 index 0000000..c8a334d --- /dev/null +++ b/danmu_api/configs/handlers/node-handler.js @@ -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); + } + } +} \ No newline at end of file diff --git a/danmu_api/configs/handlers/vercel-handler.js b/danmu_api/configs/handlers/vercel-handler.js new file mode 100644 index 0000000..d57e5ca --- /dev/null +++ b/danmu_api/configs/handlers/vercel-handler.js @@ -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; + } + } +} \ No newline at end of file diff --git a/danmu_api/ui/README.md b/danmu_api/ui/README.md new file mode 100644 index 0000000..61ffdb1 --- /dev/null +++ b/danmu_api/ui/README.md @@ -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 等环境变量是否已配置 +- 如遇到其他问题,请查看系统日志获取更多信息 diff --git a/danmu_api/ui/css/base.css.js b/danmu_api/ui/css/base.css.js new file mode 100644 index 0000000..a8dc7dc --- /dev/null +++ b/danmu_api/ui/css/base.css.js @@ -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; +} +`; diff --git a/danmu_api/ui/css/components.css.js b/danmu_api/ui/css/components.css.js new file mode 100644 index 0000000..26dd8ee --- /dev/null +++ b/danmu_api/ui/css/components.css.js @@ -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; +} +`; diff --git a/danmu_api/ui/css/forms.css.js b/danmu_api/ui/css/forms.css.js new file mode 100644 index 0000000..c4f8a25 --- /dev/null +++ b/danmu_api/ui/css/forms.css.js @@ -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; +} +`; diff --git a/danmu_api/ui/css/responsive.css.js b/danmu_api/ui/css/responsive.css.js new file mode 100644 index 0000000..ec1d14c --- /dev/null +++ b/danmu_api/ui/css/responsive.css.js @@ -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; + } +} +`; diff --git a/danmu_api/ui/js/apitest.js b/danmu_api/ui/js/apitest.js new file mode 100644 index 0000000..4b80287 --- /dev/null +++ b/danmu_api/ui/js/apitest.js @@ -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 = '

此接口无需参数

'; + return; + } + + formDiv.innerHTML = config.params.map(param => { + if (param.type === 'select') { + // 为select类型参数添加默认选项 + let optionsHtml = ''; + if (param.options) { + optionsHtml += param.options.map(opt => \`\`).join(''); + } + return \` +
+ + + \${param.placeholder ? \`
\${param.placeholder}
\` : ''} +
+ \`; + } + // 使用placeholder属性显示示例参数 + const placeholder = param.placeholder ? param.placeholder : "请输入" + param.label; + return \` +
+ + +
+ \`; + }).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 = ''; + 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; + }); +} +`; diff --git a/danmu_api/ui/js/logview.js b/danmu_api/ui/js/logview.js new file mode 100644 index 0000000..279f1be --- /dev/null +++ b/danmu_api/ui/js/logview.js @@ -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 => + \`
[\${log.timestamp}] \${log.message}
\` + ).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, '>'); + + // 高亮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 '' + match + ''; + }); +} +`; diff --git a/danmu_api/ui/js/main.js b/danmu_api/ui/js/main.js new file mode 100644 index 0000000..6706dfb --- /dev/null +++ b/danmu_api/ui/js/main.js @@ -0,0 +1,390 @@ +// language=JavaScript +export const mainJsContent = /* javascript */ ` +// 自定义弹窗组件 +function createCustomAlert() { + // 检查是否已存在自定义弹窗元素 + if (document.getElementById('custom-alert-overlay')) { + return; + } + + // 创建弹窗HTML元素 + const alertHTML = ''; + + // 添加到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>(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(); +`; diff --git a/danmu_api/ui/js/preview.js b/danmu_api/ui/js/preview.js new file mode 100644 index 0000000..a37a892 --- /dev/null +++ b/danmu_api/ui/js/preview.js @@ -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 += \`

\${getCategoryName(category)}

\`; + items.forEach(item => { + html += \` +
+
+
\${item.key}
+
\${item.value}
+
+ \${item.description ? \`
\${item.description}
\` : ''} +
+ \`; + }); + } + }); + + preview.innerHTML = html || '

暂无配置

'; + }) + .catch(error => { + console.error('Failed to load config for preview:', error); + preview.innerHTML = '

加载配置失败: ' + error.message + '

'; + }); +} + +// 获取类别名称 +function getCategoryName(category) { + const names = { + api: '🔗 API配置', + source: '📜 源配置', + match: '🔍 匹配配置', + danmu: '🔣 弹幕配置', + cache: '💾 缓存配置', + system: '⚙️ 系统配置' + }; + return names[category] || category; +} +`; diff --git a/danmu_api/ui/js/pushdanmu.js b/danmu_api/ui/js/pushdanmu.js new file mode 100644 index 0000000..4936627 --- /dev/null +++ b/danmu_api/ui/js/pushdanmu.js @@ -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 = ''; + 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 = '

搜索结果

'; + + animes.forEach(anime => { + const imageUrl = anime.imageUrl || 'https://placehold.co/150x200?text=No+Image'; + html += \` +
+ \${anime.animeTitle} +

\${anime.animeTitle} - 共\${anime.episodeCount}集

+
+ \`; + }); + + html += '
'; + 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 = \`

剧集列表

\${animeTitle}

\`; + + episodes.forEach(episode => { + // 生成弹幕URL + const commentUrl = window.location.origin + buildApiUrl('/api/v2/comment/' + episode.episodeId + '?format=xml'); + html += \` +
+
+ 第\${episode.episodeNumber}集 - \${episode.episodeTitle || '无标题'} +
+ +
+ \`; + }); + + html += '
'; + 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 = ''; + 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; + } + } +} +`; diff --git a/danmu_api/ui/js/systemsettings.js b/danmu_api/ui/js/systemsettings.js new file mode 100644 index 0000000..8584f9a --- /dev/null +++ b/danmu_api/ui/js/systemsettings.js @@ -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 = \` + +
+ + \${checked ? '启用' : '禁用'} +
+ \`; + + 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 = \` + +
+
+ + +
+
\${currentValue}
+
+
+ +
+ \`; + + } else if (type === 'select') { + // 标签选择 + const options = item && item.options ? item.options : ['option1', 'option2', 'option3']; + const optionsInput = item ? '' : \` +
+ + +
+ \`; + + container.innerHTML = \` + \${optionsInput} + +
+ \${options.map(opt => \` +
+ \${opt} +
+ \`).join('')} +
+ \`; + + } 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 ? '' : \` +
+ + +
+ \`; + + container.innerHTML = \` + \${optionsInput} + +
+
+ \${selectedValues.map(val => \` +
+ \${val} + +
+ \`).join('')} +
+ +
+ \${options.map(opt => { + const isSelected = selectedValues.includes(opt); + return \` +
+ \${opt} +
+ \`; + }).join('')} +
+
+ \`; + + // 设置拖动事件 + 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 = \` + + + \`; + } else { + container.innerHTML = \` + + + \`; + } + } +} + +// 调整数字 +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 => \` +
+ \${opt} +
+ \`).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 = \` + \${value} + + \`; + + 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 \` +
+ \${opt} +
+ \`; + }).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 = '

暂无配置项

'; + 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 \` +
+
+ \${item.key}\${typeLabel} +
\${item.value}
+
\${item.description || '无描述'}
+
+
+ + +
+
+ \`; + }).join(''); +} + +// 编辑环境变量 +function editEnv(index) { + const item = envVariables[currentCategory][index]; + const editButton = event.target; // 获取当前点击的编辑按钮 + + // 设置按钮为加载状态 + const originalText = editButton.innerHTML; + editButton.innerHTML = ''; + 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 = ''; + 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'); + } +}); +`; diff --git a/danmu_api/ui/template.js b/danmu_api/ui/template.js new file mode 100644 index 0000000..78b2e40 --- /dev/null +++ b/danmu_api/ui/template.js @@ -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 */ ` + + + + + + LogVar弹幕API + + + + + + +
+ +
+
+
+ +
+
+
+ +

LogVar弹幕API

+
+
+ 当前版本: v${globals.version} + + 🎉 最新版本: 加载中... + + + API端点: 加载中... + +
+
+ +
+ +
+ +
+

配置预览

+

当前生效的环境变量配置

+
+
+ + +
+

日志查看

+
+
+ + +
+ 实时日志监控 +
+
+
+ + +
+

接口调试

+
+
+ + +
+
+ + + + +
+ + +
+

推送弹幕

+

支持OK影视等播放器,两端需要在同一局域网或使用公网ip,推送地址格式如 http://127.0.0.1:9978/action?do=refresh&type=danmaku&path=

+
+
+ + +
+
+ +
+ + +
+
+
+ + +
+ + +
+
+
+

环境变量配置

+

vercel/netlify/edgeone平台修改变量后需要重新部署

+
+
+ + +
+ + + + + + +
+ +
+ + + + + + +
+ +
+
+
+
+ + +
+
+
+
正在处理...
+
请稍候
+
+
+ + + + + +
+ + + +

有问题提issue或私信机器人都ok

+
+ + + + +`; diff --git a/danmu_api/utils/cache-util.js b/danmu_api/utils/cache-util.js index 3da9375..024d67f 100644 --- a/danmu_api/utils/cache-util.js +++ b/danmu_api/utils/cache-util.js @@ -309,7 +309,7 @@ export function cleanupExpiredIPs(currentTime) { } // 获取当前文件目录的兼容方式 -function getDirname() { +export function getDirname() { if (typeof __dirname !== 'undefined') { // CommonJS 环境 (Vercel) return __dirname; diff --git a/danmu_api/utils/http-util.js b/danmu_api/utils/http-util.js index edae845..6b72c64 100644 --- a/danmu_api/utils/http-util.js +++ b/danmu_api/utils/http-util.js @@ -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}>} + */ +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 获取网页内容 diff --git a/danmu_api/utils/log-util.js b/danmu_api/utils/log-util.js index 60d052e..c444451 100644 --- a/danmu_api/utils/log-util.js +++ b/danmu_api/utils/log-util.js @@ -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; } -} \ No newline at end of file +} diff --git a/danmu_api/worker.js b/danmu_api/worker.js index 3f3711f..affcf17 100644 --- a/danmu_api/worker.js +++ b/danmu_api/worker.js @@ -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); }, }; diff --git a/danmu_api/worker.test.js b/danmu_api/worker.test.js index 5ee60b5..5ce5d0b 100644 --- a/danmu_api/worker.test.js +++ b/danmu_api/worker.test.js @@ -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}`); + // }); }); \ No newline at end of file