feat: complete unified plugin admin v1.1.0
This commit is contained in:
@@ -7,6 +7,8 @@
|
||||
bin/
|
||||
dist/
|
||||
build/
|
||||
ui-build/
|
||||
node_modules/
|
||||
data/
|
||||
.cache/
|
||||
*.log
|
||||
@@ -22,3 +24,4 @@ coverage.out
|
||||
# Browser test output
|
||||
playwright-report/
|
||||
test-results/
|
||||
.screenshots/
|
||||
|
||||
@@ -9,11 +9,17 @@ HTTP API、管理员鉴权和 `custom_menu_items` 接入 Core;插件不导入
|
||||
|
||||
- `plugins/plugin-admin`:通用插件管理控制面,负责清单、签名、插件市场、
|
||||
下载入库、启用、停用、升级、回滚、删除、配置、健康检查、审计和菜单注入。
|
||||
- `plugins/subscription-admin`:可选的订阅管理业务插件。它不是插件管理
|
||||
控制面,只有安装、启用并应用菜单后才会出现。
|
||||
- `plugins/subscription-admin`:可选的订阅管理业务模块后端。它不是插件管理
|
||||
控制面;只有安装并启用后才会挂载到 Plugin Admin 的统一导航中。
|
||||
- `docs/`:插件框架、清单、边界、架构、开发和验收契约。
|
||||
|
||||
两个插件都是独立 Go module,可以分别构建和发布。生产环境应使用独立的
|
||||
Plugin Admin 的浏览器控制面位于 `plugins/plugin-admin/ui-vue`,基于腾讯
|
||||
TDesign Vue Next starter。运行 `plugins/plugin-admin/build-ui.sh` 会完成
|
||||
TypeScript/Vite 构建,并把本地打包的 TDesign、线性图标和 ECharts 嵌入
|
||||
Go 服务;官方 Core 的 `frontend/` 不参与构建。
|
||||
|
||||
控制面和业务模块后端可以独立构建和发布,但浏览器端只有一个 Plugin Admin
|
||||
登录入口。生产环境应使用独立的
|
||||
低权限服务账号、HTTPS 反向代理、签名包和稳定的 Core API 兼容基线。
|
||||
|
||||
## 推荐部署顺序
|
||||
@@ -48,12 +54,16 @@ Core 仓库,也不要让插件连接 Core PostgreSQL/Redis。
|
||||
|
||||
## 接入顺序
|
||||
|
||||
1. 启动 `plugin-admin` 和需要的业务插件,各自监听独立端口。
|
||||
2. 使用 Core 管理员账号登录插件服务;普通账号被拒绝。
|
||||
1. 启动 `plugin-admin` 和需要的业务模块后端,各自监听独立端口。
|
||||
2. 使用 Core 管理员账号登录 Plugin Admin 一次;普通账号被拒绝,业务模块不再单独登录。
|
||||
3. 在 `plugin-admin` 上传或从插件市场下载并校验业务插件包;包只进入“已入库,待启用”状态。
|
||||
4. 配置 loopback `service_url`,点击启用并完成健康检查后,插件才会启动。
|
||||
5. 预览、确认并应用插件声明的管理员菜单;停用后可删除插件。
|
||||
|
||||
订阅模块进入统一控制面后使用 `/modules/subscription/*` 路由。Plugin Admin
|
||||
通过同源 BFF 代理固定的套餐、订阅和用户只读接口,浏览器不再访问订阅
|
||||
服务的登录页,也不会创建第二个管理员会话。
|
||||
|
||||
Core 继续作为用户、余额、订阅、计费和用量账本的权威来源。插件浏览器端
|
||||
不持有 Core JWT、Admin Key 或其他服务密钥。
|
||||
|
||||
@@ -62,14 +72,22 @@ Core 继续作为用户、余额、订阅、计费和用量账本的权威来源
|
||||
Linux + systemd 环境可直接使用仓库内的安装脚本:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://git.awaioi.com/awaioi/sub2api-add/raw/branch/main/deploy/install.sh \
|
||||
| sudo bash -s -- --plugin all
|
||||
RELEASE_SHA=COMMIT_SHA_40_HEX
|
||||
curl -fsSL "https://git.awaioi.com/awaioi/sub2api-add/raw/commit/${RELEASE_SHA}/deploy/install.sh" \
|
||||
| sudo env PLUGIN_REF=v1.1.0 PLUGIN_COMMIT_SHA="$RELEASE_SHA" bash -s -- --plugin all
|
||||
```
|
||||
|
||||
生产安装必须提供发布提交的 `PLUGIN_COMMIT_SHA`;上面的 `RELEASE_SHA`
|
||||
应从受信任的发布记录中复制,并与 `PLUGIN_REF` 对应。脚本默认使用
|
||||
`v1.1.0` tag,但 tag 本身不作为完整性证明。可变分支和未 pin 的 tag
|
||||
仅能在开发环境分别显式开启 `PLUGIN_ALLOW_MUTABLE_REF=true` 或
|
||||
`PLUGIN_ALLOW_UNPINNED_TAG=true`。
|
||||
|
||||
默认卸载并保留配置/数据:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://git.awaioi.com/awaioi/sub2api-add/raw/branch/main/deploy/uninstall.sh \
|
||||
RELEASE_SHA=COMMIT_SHA_40_HEX
|
||||
curl -fsSL "https://git.awaioi.com/awaioi/sub2api-add/raw/commit/${RELEASE_SHA}/deploy/uninstall.sh" \
|
||||
| sudo bash -s -- --plugin all
|
||||
```
|
||||
|
||||
|
||||
+24
-8
@@ -22,22 +22,35 @@ docker compose -f docker-compose.yml up -d
|
||||
在一台已安装 Go 1.23+、Git 和 systemd 的 Linux 主机上:
|
||||
|
||||
```sh
|
||||
RELEASE_SHA=COMMIT_SHA_40_HEX
|
||||
curl -fsSL "https://git.awaioi.com/awaioi/sub2api-add/raw/commit/${RELEASE_SHA}/deploy/install.sh" \
|
||||
| sudo env PLUGIN_REF=v1.1.0 PLUGIN_COMMIT_SHA="$RELEASE_SHA" bash -s -- --plugin all
|
||||
```
|
||||
|
||||
生产安装必须固定 `PLUGIN_COMMIT_SHA`,并从受信任的发布记录复制
|
||||
`RELEASE_SHA`。tag 只是选择版本,不是完整性证明。开发环境如需使用
|
||||
可变分支或未 pin 的 tag,必须显式设置对应的开发开关。
|
||||
|
||||
```sh
|
||||
export PLUGIN_ENV=development PLUGIN_REF=main PLUGIN_ALLOW_MUTABLE_REF=true
|
||||
curl -fsSL https://git.awaioi.com/awaioi/sub2api-add/raw/branch/main/deploy/install.sh \
|
||||
| sudo bash -s -- --plugin all
|
||||
| sudo -E bash -s -- --plugin all
|
||||
```
|
||||
|
||||
只安装控制面:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://git.awaioi.com/awaioi/sub2api-add/raw/branch/main/deploy/install.sh \
|
||||
| sudo bash -s -- --plugin plugin-admin
|
||||
RELEASE_SHA=COMMIT_SHA_40_HEX
|
||||
curl -fsSL "https://git.awaioi.com/awaioi/sub2api-add/raw/commit/${RELEASE_SHA}/deploy/install.sh" \
|
||||
| sudo env PLUGIN_REF=v1.1.0 PLUGIN_COMMIT_SHA="$RELEASE_SHA" bash -s -- --plugin plugin-admin
|
||||
```
|
||||
|
||||
安装订阅插件:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://git.awaioi.com/awaioi/sub2api-add/raw/branch/main/deploy/install.sh \
|
||||
| sudo bash -s -- --plugin subscription-admin
|
||||
RELEASE_SHA=COMMIT_SHA_40_HEX
|
||||
curl -fsSL "https://git.awaioi.com/awaioi/sub2api-add/raw/commit/${RELEASE_SHA}/deploy/install.sh" \
|
||||
| sudo env PLUGIN_REF=v1.1.0 PLUGIN_COMMIT_SHA="$RELEASE_SHA" bash -s -- --plugin subscription-admin
|
||||
```
|
||||
|
||||
默认安装位置:
|
||||
@@ -63,7 +76,8 @@ sudo systemctl restart sub2api-plugin-admin sub2api-subscription-admin
|
||||
`PLUGIN_CONFIG_KEY` 和受信发布者公钥。插件市场默认读取
|
||||
`/var/lib/sub2api-add/plugin-admin/marketplace/index.json`;可通过
|
||||
`PLUGIN_MARKETPLACE_INDEX` 指向受控本地索引或 HTTPS 索引,并用
|
||||
`PLUGIN_MARKETPLACE_ALLOWED_HOSTS` 限定远程索引和包的精确主机。
|
||||
`PLUGIN_MARKETPLACE_ALLOWED_HOSTS` 限定远程索引和包的精确主机。生产远程索引
|
||||
还必须设置 `PLUGIN_MARKETPLACE_INDEX_SHA256`,把目录内容固定到已审核的摘要。
|
||||
|
||||
## 菜单接入
|
||||
|
||||
@@ -81,14 +95,16 @@ sudo systemctl restart sub2api-plugin-admin sub2api-subscription-admin
|
||||
默认卸载服务和二进制,但保留配置与插件数据,便于重新安装:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://git.awaioi.com/awaioi/sub2api-add/raw/branch/main/deploy/uninstall.sh \
|
||||
RELEASE_SHA=COMMIT_SHA_40_HEX
|
||||
curl -fsSL "https://git.awaioi.com/awaioi/sub2api-add/raw/commit/${RELEASE_SHA}/deploy/uninstall.sh" \
|
||||
| sudo bash -s -- --plugin all
|
||||
```
|
||||
|
||||
确认同时删除插件配置、插件数据和源码:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://git.awaioi.com/awaioi/sub2api-add/raw/branch/main/deploy/uninstall.sh \
|
||||
RELEASE_SHA=COMMIT_SHA_40_HEX
|
||||
curl -fsSL "https://git.awaioi.com/awaioi/sub2api-add/raw/commit/${RELEASE_SHA}/deploy/uninstall.sh" \
|
||||
| sudo bash -s -- --plugin all --purge --yes
|
||||
```
|
||||
|
||||
|
||||
+63
-3
@@ -3,12 +3,56 @@ set -Eeuo pipefail
|
||||
|
||||
REPO_URL=${PLUGIN_REPO_URL:-https://git.awaioi.com/awaioi/sub2api-add.git}
|
||||
SOURCE_DIR=${PLUGIN_SOURCE_DIR:-/opt/sub2api-add}
|
||||
REF=${PLUGIN_REF:-main}
|
||||
REF=${PLUGIN_REF:-v1.1.0}
|
||||
EXPECTED_SHA=${PLUGIN_COMMIT_SHA:-}
|
||||
ALLOW_MUTABLE_REF=${PLUGIN_ALLOW_MUTABLE_REF:-false}
|
||||
DEPLOY_ENV=${PLUGIN_ENV:-production}
|
||||
ALLOW_UNPINNED_TAG=${PLUGIN_ALLOW_UNPINNED_TAG:-false}
|
||||
|
||||
die() { printf '错误:%s\n' "$*" >&2; exit 1; }
|
||||
|
||||
validate_path_components() {
|
||||
local value=$1 label=$2 component current=""
|
||||
[[ -n "$value" && "$value" = /* && "$value" != "/" ]] || die "$label 必须是非根绝对路径"
|
||||
[[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] || die "$label 包含非法换行"
|
||||
local parts=()
|
||||
IFS='/' read -r -a parts <<< "${value#/}"
|
||||
for component in "${parts[@]}"; do
|
||||
[[ -z "$component" ]] && continue
|
||||
[[ "$component" != "." && "$component" != ".." ]] || die "$label 不能包含 . 或 .. 路径组件"
|
||||
current="$current/$component"
|
||||
[[ ! -L "$current" ]] || die "$label 的路径组件不能是符号链接:$current"
|
||||
done
|
||||
}
|
||||
|
||||
validate_path_components "$SOURCE_DIR" PLUGIN_SOURCE_DIR
|
||||
if [[ "$SOURCE_DIR" != */sub2api-add && "${PLUGIN_ALLOW_CUSTOM_PATHS:-false}" != "true" ]]; then
|
||||
die "PLUGIN_SOURCE_DIR 必须以 sub2api-add 结尾;如确需自定义路径请显式设置 PLUGIN_ALLOW_CUSTOM_PATHS=true"
|
||||
fi
|
||||
[[ $EUID -eq 0 ]] || die "请使用 root 或 sudo 运行"
|
||||
command -v git >/dev/null 2>&1 || die "缺少 git"
|
||||
|
||||
is_sha() { [[ "$1" =~ ^[0-9a-fA-F]{40}$ ]]; }
|
||||
is_release_tag() { [[ "$1" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; }
|
||||
[[ "$DEPLOY_ENV" == "development" || "$DEPLOY_ENV" == "production" ]] || die "PLUGIN_ENV 必须是 development 或 production"
|
||||
if [[ -n "$EXPECTED_SHA" ]] && ! is_sha "$EXPECTED_SHA"; then
|
||||
die "PLUGIN_COMMIT_SHA 必须是 40 位 commit SHA"
|
||||
fi
|
||||
if [[ "$ALLOW_MUTABLE_REF" == "true" && "$DEPLOY_ENV" != "development" ]]; then
|
||||
die "PLUGIN_ALLOW_MUTABLE_REF=true 仅允许在 PLUGIN_ENV=development 使用"
|
||||
fi
|
||||
if ! is_sha "$REF" && ! is_release_tag "$REF" && [[ "$ALLOW_MUTABLE_REF" != "true" ]]; then
|
||||
die "默认只接受不可变 commit SHA 或版本 tag;开发环境显式设置 PLUGIN_ALLOW_MUTABLE_REF=true 才能使用分支"
|
||||
fi
|
||||
if [[ -z "$EXPECTED_SHA" ]] && is_release_tag "$REF"; then
|
||||
if [[ "$DEPLOY_ENV" != "development" || "$ALLOW_UNPINNED_TAG" != "true" ]]; then
|
||||
die "使用版本 tag 时必须设置 PLUGIN_COMMIT_SHA;仅开发环境显式设置 PLUGIN_ALLOW_UNPINNED_TAG=true 才能跳过 pin"
|
||||
fi
|
||||
fi
|
||||
if [[ "$ALLOW_UNPINNED_TAG" == "true" && "$DEPLOY_ENV" != "development" ]]; then
|
||||
die "PLUGIN_ALLOW_UNPINNED_TAG=true 仅允许在 PLUGIN_ENV=development 使用"
|
||||
fi
|
||||
|
||||
if [[ -e "$SOURCE_DIR" && ! -d "$SOURCE_DIR/.git" ]]; then
|
||||
die "$SOURCE_DIR 已存在但不是本插件仓库;请设置 PLUGIN_SOURCE_DIR"
|
||||
fi
|
||||
@@ -17,10 +61,26 @@ if [[ -d "$SOURCE_DIR/.git" ]]; then
|
||||
if [[ -n "$(git -C "$SOURCE_DIR" status --porcelain)" ]]; then
|
||||
die "$SOURCE_DIR 有未提交修改,先清理后再升级"
|
||||
fi
|
||||
git -C "$SOURCE_DIR" fetch --depth 1 origin "$REF"
|
||||
git -C "$SOURCE_DIR" fetch --depth 1 --no-tags origin "$REF"
|
||||
git -C "$SOURCE_DIR" checkout --force FETCH_HEAD
|
||||
else
|
||||
git clone --depth 1 --branch "$REF" "$REPO_URL" "$SOURCE_DIR"
|
||||
if is_sha "$REF"; then
|
||||
git clone --depth 1 --no-tags "$REPO_URL" "$SOURCE_DIR"
|
||||
git -C "$SOURCE_DIR" fetch --depth 1 --no-tags origin "$REF"
|
||||
git -C "$SOURCE_DIR" checkout --force FETCH_HEAD
|
||||
else
|
||||
git clone --depth 1 --no-tags --branch "$REF" "$REPO_URL" "$SOURCE_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
RESOLVED_SHA=$(git -C "$SOURCE_DIR" rev-parse HEAD)
|
||||
NORMALIZED_RESOLVED_SHA=$(printf '%s' "$RESOLVED_SHA" | tr '[:upper:]' '[:lower:]')
|
||||
NORMALIZED_EXPECTED_SHA=$(printf '%s' "$EXPECTED_SHA" | tr '[:upper:]' '[:lower:]')
|
||||
if [[ -n "$EXPECTED_SHA" && "$NORMALIZED_RESOLVED_SHA" != "$NORMALIZED_EXPECTED_SHA" ]]; then
|
||||
die "检出的 commit 与 PLUGIN_COMMIT_SHA 不一致:$RESOLVED_SHA"
|
||||
fi
|
||||
if ! is_sha "$REF" && ! is_release_tag "$REF" && [[ "$ALLOW_MUTABLE_REF" == "true" ]]; then
|
||||
printf '%s\n' '警告:当前使用可变 Git ref,仅限开发环境。' >&2
|
||||
fi
|
||||
|
||||
exec "$SOURCE_DIR/scripts/install-local.sh" "$@"
|
||||
|
||||
+39
-6
@@ -10,8 +10,27 @@ SELECTION=all
|
||||
PURGE=false
|
||||
YES=false
|
||||
|
||||
validate_managed_root() {
|
||||
local value=$1 label=$2 component current=""
|
||||
[[ -n "$value" && "$value" = /* ]] || die "$label 必须是绝对路径"
|
||||
[[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] || die "$label 包含非法换行"
|
||||
[[ "$value" != "/" ]] || die "$label 不允许指向根目录"
|
||||
local parts=()
|
||||
IFS='/' read -r -a parts <<< "${value#/}"
|
||||
for component in "${parts[@]}"; do
|
||||
[[ -z "$component" ]] && continue
|
||||
[[ "$component" != "." && "$component" != ".." ]] || die "$label 不能包含 . 或 .. 路径组件"
|
||||
current="$current/$component"
|
||||
[[ ! -L "$current" ]] || die "$label 的路径组件不能是符号链接:$current"
|
||||
done
|
||||
if [[ "$value" != */sub2api-add && "${PLUGIN_ALLOW_CUSTOM_PATHS:-false}" != "true" ]]; then
|
||||
die "$label 必须位于受管的 sub2api-add 目录;如确需自定义路径请显式设置 PLUGIN_ALLOW_CUSTOM_PATHS=true"
|
||||
fi
|
||||
}
|
||||
|
||||
die() { printf '错误:%s\n' "$*" >&2; exit 1; }
|
||||
[[ $EUID -eq 0 ]] || die "请使用 root 或 sudo 运行"
|
||||
command -v systemctl >/dev/null 2>&1 || die "缺少 systemd/systemctl"
|
||||
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
@@ -29,6 +48,14 @@ case "$SELECTION" in
|
||||
*) die "插件必须是 all、plugin-admin 或 subscription-admin" ;;
|
||||
esac
|
||||
|
||||
validate_managed_root "$ETC_DIR" PLUGIN_ETC_DIR
|
||||
validate_managed_root "$VAR_DIR" PLUGIN_VAR_DIR
|
||||
validate_managed_root "$PREFIX" PLUGIN_INSTALL_PREFIX
|
||||
validate_managed_root "$SOURCE_DIR" PLUGIN_SOURCE_DIR
|
||||
if $PURGE && [[ "${PLUGIN_PURGE_SOURCE:-true}" == "true" && "$SOURCE_DIR" != "$PREFIX" && "${PLUGIN_ALLOW_CUSTOM_PATHS:-false}" != "true" ]]; then
|
||||
die "为避免误删,--purge 默认要求 PLUGIN_SOURCE_DIR 与 PLUGIN_INSTALL_PREFIX 相同"
|
||||
fi
|
||||
|
||||
if $PURGE && ! $YES; then
|
||||
[[ -t 0 ]] || die "非交互环境执行 --purge 时必须同时提供 --yes"
|
||||
read -r -p "将删除插件配置和数据,继续?[y/N] " answer
|
||||
@@ -38,21 +65,27 @@ fi
|
||||
for plugin in "${PLUGINS[@]}"; do
|
||||
unit="sub2api-$plugin.service"
|
||||
systemctl disable --now "$unit" 2>/dev/null || true
|
||||
rm -f "/etc/systemd/system/$unit"
|
||||
rm -f -- "/etc/systemd/system/$unit"
|
||||
# Keep the checked-out source clean so a later install can fast-forward it.
|
||||
# Only generated binaries are removed unless --purge is explicitly used.
|
||||
rm -f "$PREFIX/$plugin/bin/$plugin"
|
||||
rm -f -- "$PREFIX/$plugin/bin/$plugin"
|
||||
if $PURGE; then
|
||||
rm -rf "$PREFIX/$plugin"
|
||||
rm -f "$ETC_DIR/$plugin.env"
|
||||
rm -rf "$VAR_DIR/$plugin"
|
||||
plugin_root="$PREFIX/$plugin"
|
||||
if [[ -d "$plugin_root" && ! -L "$plugin_root" ]]; then
|
||||
rm -rf -- "${plugin_root:?}"
|
||||
fi
|
||||
rm -f -- "$ETC_DIR/$plugin.env"
|
||||
plugin_data="$VAR_DIR/$plugin"
|
||||
if [[ -d "$plugin_data" && ! -L "$plugin_data" ]]; then
|
||||
rm -rf -- "${plugin_data:?}"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
systemctl daemon-reload
|
||||
if $PURGE && [[ -d "$ETC_DIR" ]]; then rmdir "$ETC_DIR" 2>/dev/null || true; fi
|
||||
if $PURGE && [[ -d "$VAR_DIR" ]]; then rmdir "$VAR_DIR" 2>/dev/null || true; fi
|
||||
if $PURGE && [[ -d "$SOURCE_DIR" ]]; then rm -rf "$SOURCE_DIR"; fi
|
||||
if $PURGE && [[ "${PLUGIN_PURGE_SOURCE:-true}" == "true" && "$SOURCE_DIR" == "$PREFIX" && -d "$SOURCE_DIR" && ! -L "$SOURCE_DIR" ]]; then rm -rf -- "$SOURCE_DIR"; fi
|
||||
|
||||
if $PURGE; then
|
||||
userdel "$RUN_USER" 2>/dev/null || true
|
||||
|
||||
@@ -5,12 +5,15 @@
|
||||
| AUTH-01 | 鉴权 | Core 管理员登录控制面 | `plugins/plugin-admin/main_test.go:TestAdminLoginDoesNotExposeCoreTokens`;本地浏览器登录 | passed |
|
||||
| AUTH-02 | 鉴权 | Core 2FA 登录 | challenge 一次性消费,成功创建会话 | passed |
|
||||
| AUTH-03 | 鉴权 | 普通用户登录和 API | `plugins/plugin-admin/main_test.go:TestOrdinaryCoreUserIsRejected` | passed |
|
||||
| AUTH-03A | 鉴权 | Core CAPTCHA 配置与一次性 proof | `TestCaptchaConfigReturnsOnlyPublicFields`、`TestLoginForwardsCaptchaProof`;登录页按 provider 渲染挑战 | passed |
|
||||
| AUTH-04 | 会话 | 过期、撤销、登出和刷新 | `plugins/plugin-admin/main_test.go:TestRefreshRevalidatesAdminRole` | passed |
|
||||
| AUTH-05 | CSRF | 所有写请求 | `plugins/plugin-admin/main_test.go:TestMutationRequiresCSRFAndIdempotency` | passed |
|
||||
| SEC-01 | 秘密 | 浏览器、URL、HTML、JS、LocalStorage、下载、日志 | 登录/配置测试断言 token 和 secret 不回显;浏览器 DOM 未出现 Core token | passed |
|
||||
| SEC-02 | 出站 | Core URL、重定向、代理和 SSRF | `TestHealthProbeRejectsRedirectAndRequiresReadiness`;loopback URL 校验 | passed |
|
||||
| SEC-02A | 市场出站 | 索引/归档 HTTPS、精确主机 allowlist、DNS 私网拒绝、体积和重定向门禁 | `main_test.go:TestRemoteMarketplaceRequiresAllowlistAndExpiry`;`marketplace.go` 出站策略 | passed |
|
||||
| SEC-03 | 脱敏 | Core 响应和错误 | token/password/secret/cookie 不出现在响应和日志 | passed |
|
||||
| DEPLOY-01 | 发布完整性 | 生产安装提交 pin | `deploy/install.sh` 对 tag 要求 `PLUGIN_COMMIT_SHA`,并校验检出 commit;脚本可从 immutable commit URL 获取 | passed |
|
||||
| DEPLOY-02 | 路径安全 | 安装/卸载根目录与父路径 | 安装路径拒绝根目录、`.`/`..` 和任一级符号链接父路径 | passed |
|
||||
| MAN-01 | 清单 | 未知字段、尾随 JSON、路径跳转 | `plugins/plugin-admin/internal/manifest/manifest_test.go`;包上传 smoke | passed |
|
||||
| MAN-02 | 签名 | Ed25519、key ID、哈希 | `manifest_test.go:TestSignatureAndKeyID`;生产不受信发布者路径 | passed |
|
||||
| MAN-03 | 兼容 | Core baseline、tested versions、capability | `manifest_test.go:TestCompatibility`;上传卡片显示 compatible | passed |
|
||||
@@ -21,7 +24,7 @@
|
||||
| LIFE-04 | 卸载 | 先停用再卸载 | 先提交注册表删除,成功后再清理插件资源,不删除 Core 数据 | passed |
|
||||
| LIFE-04A | 删除接口 | `DELETE /api/plugins/{id}` 与卸载语义一致 | 市场生命周期测试覆盖标准 DELETE | passed |
|
||||
| MENU-01 | 菜单 | preview/apply 自有 `custom_menu_items` | `main_test.go:TestMenuPreviewAndApplyPreserveOtherMenuItems` | passed |
|
||||
| MENU-02 | 嵌入 | iframe 和新窗口 | 本地控制面三视口登录/刷新;插件提供独立登录和新窗口入口 | passed |
|
||||
| MENU-02 | 嵌入 | iframe 和新窗口 | Plugin Admin 统一登录/刷新;订阅模块从统一控制面入口进入,iframe/新窗口均复用同一会话,不提供第二个登录页或 Cookie | passed |
|
||||
| API-01 | allowlist | 未声明路径和查询参数 | `allowedCorePath` 单元路径门禁;业务插件自身 allowlist 测试 | passed |
|
||||
| API-02 | Core 错误 | 401/403/409/429/5xx | 失败关闭、刷新一次、错误脱敏和请求 ID 传播 | passed |
|
||||
| UI-01 | 响应式 | 425px、900px、1440px | 本地 Browser 验收:三个视口 `scrollWidth == innerWidth`,插件卡片可见 | passed |
|
||||
@@ -40,6 +43,7 @@ go vet ./...
|
||||
node --check <all-ui-scripts>
|
||||
production build
|
||||
manifest verification
|
||||
bash -n <all-shell-scripts>
|
||||
git diff --check
|
||||
```
|
||||
|
||||
@@ -49,10 +53,10 @@ git diff --check
|
||||
|
||||
- `plugins/plugin-admin` 和 `plugins/subscription-admin`:`go test -race ./...`、`go vet ./...`、`node --check ui/app.js` 均通过。
|
||||
- `plugins/subscription-admin/package.sh` 生成的 `.s2plugin` 已通过 `unzip -t`,并通过控制面真实上传接口进入 `disabled` 状态。
|
||||
- 本地浏览器登录后,控制面首页显示“已登记插件”与订阅插件卡片;425、900、1440 视口均无横向溢出。
|
||||
- 本地浏览器登录后,控制面首页、插件市场和操作记录可访问;普通登录与 Turnstile 模拟登录在 425、900、1440 视口均通过,无横向溢出或敏感响应字段。
|
||||
- 仍需部署环境追加:真实生产签名密钥、跨实例共享会话、真实 Core iframe 刷新和跨节点升级演练;这些属于部署级验证,不改变本地 V1 控制面契约。
|
||||
|
||||
截图证据保存在 `.playwright-cli/plugin-admin-v1-final/`、`.playwright-cli/plugin-admin-v1-sensitive/`、`.playwright-cli/subscription-admin-v1-final/` 和 `.playwright-cli/subscription-admin-v1-sensitive/`,每组包含 425px、900px、1440px 三种视口。
|
||||
浏览器脚本通过 `PLUGIN_SCREENSHOT_DIR` 输出 425px、900px、1440px 截图;本轮使用系统 Chrome 运行普通控制面、Turnstile fixture 和订阅模块三套验收,截图保留在本机临时证据目录。
|
||||
|
||||
## 本地生命周期硬化证据
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@ flowchart TD
|
||||
B[管理员浏览器] --> C[Core custom_menu_items]
|
||||
C --> I[Core /custom/:id sandbox iframe]
|
||||
I --> P[反向代理 /extensions/:plugin-id/]
|
||||
P --> M[Plugin Control Plane]
|
||||
M --> S[独立业务插件服务]
|
||||
S --> A[Typed Core API Adapter]
|
||||
P --> M[Plugin Control Plane / TDesign Shell]
|
||||
M --> S[订阅业务模块 BFF / 独立服务]
|
||||
M --> A[Typed Core API Adapter]
|
||||
A --> K[Core Auth/Admin API]
|
||||
K --> D[Core 权威账本与审计]
|
||||
M --> R[Plugin Registry / Revisions / Audit]
|
||||
@@ -23,15 +23,16 @@ sequenceDiagram
|
||||
participant B as Browser
|
||||
participant P as Plugin BFF
|
||||
participant C as Core
|
||||
B->>P: POST /login
|
||||
B->>P: POST /login(唯一登录入口)
|
||||
P->>C: POST /api/v1/auth/login
|
||||
C-->>P: access/refresh 或 2FA challenge
|
||||
P->>C: POST /api/v1/auth/login/2fa (按需)
|
||||
P->>C: GET /api/v1/auth/me
|
||||
C-->>P: role=admin
|
||||
P-->>B: HttpOnly plugin session + CSRF token
|
||||
B->>P: GET /api/plugins
|
||||
P-->>B: 唯一 HttpOnly plugin session + CSRF token
|
||||
B->>P: GET /api/plugins 或 /modules/subscription/*
|
||||
P->>C: Bearer Core JWT + X-Request-ID
|
||||
P->>S: 复用同一管理员会话调用订阅模块
|
||||
P-->>B: 脱敏业务数据
|
||||
```
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Business Plugin V1 边界
|
||||
|
||||
> **前端架构修订(2026-08-30)**:Plugin Admin 是唯一的插件管理控制面和登录入口。订阅属于已安装业务模块,复用控制面会话、导航和 CSRF;订阅后端可以独立进程运行,但订阅前端不得再提供独立登录页、Cookie 或管理员身份。
|
||||
|
||||
## Core 负责
|
||||
|
||||
- 用户身份、密码、2FA、TokenVersion、会话撤销和管理员角色;
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
状态:Accepted Contract / V1 参考实现已完成本地验收
|
||||
|
||||
本文定义与 Sub2API Core 解耦的通用业务插件框架。业务插件是独立服务、独立端口、独立版本和独立 UI;它可以通过现有管理员自定义菜单嵌入 Core,也可以在新窗口运行。订阅管理只是一个可选业务插件,不能成为框架后台、Core 热路径或插件生命周期的固定组成部分。
|
||||
本文定义与 Sub2API Core 解耦的通用业务插件框架。业务插件的后端服务、端口和版本独立;浏览器端由统一的 Plugin Admin 控制面承载,业务插件 UI 以模块方式挂载到同一个管理员 Shell 中。订阅管理只是一个可选业务模块,不能成为框架后台、Core 热路径或插件生命周期的固定组成部分。
|
||||
|
||||
> **前端架构修订(2026-08-30)**:此前“独立 UI/独立管理员后台”的表述仅指后端服务可以独立部署,不表示业务模块要再次登录。Plugin Admin 负责唯一的管理员登录、会话、导航和 CSRF;订阅模块安装并启用后才出现在控制面导航中,继承同一会话,不提供第二个登录页或第二套 Cookie。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
@@ -14,7 +16,7 @@ V1 需要提供一个独立的插件控制面,负责:
|
||||
- 保存插件版本、服务地址、运行状态、菜单声明和操作审计;
|
||||
- 通过 Core 现有管理员鉴权复用操作者身份;
|
||||
- 将已启用插件的管理员菜单注入 `custom_menu_items`;
|
||||
- 让每个业务插件仅通过自己的 BFF 调用 Core 明确允许的 API。
|
||||
- 让每个业务插件仅通过控制面或其受控模块 BFF 调用 Core 明确允许的 API。
|
||||
|
||||
V1 不改变 Core Go/Vue、数据库迁移、现有鉴权、前端路由或 `.s2plugin` transport ABI。控制面自己的注册表、安装目录、进程和配置存储属于独立服务;它不连接 Core PostgreSQL、Redis 或宿主业务表。
|
||||
|
||||
@@ -25,7 +27,7 @@ V1 不改变 Core Go/Vue、数据库迁移、现有鉴权、前端路由或 `.s2
|
||||
| 运行方式 | Core 子进程 + gRPC | 独立服务 + HTTP/BFF |
|
||||
| 能力 | `openai.oauth.outbound_transport.v1` | 由清单声明的业务能力 |
|
||||
| 生命周期 | Core `PluginManager` | 独立 Plugin Control Plane |
|
||||
| UI | 配置 iframe + UI Bridge | 业务后台/用户工具页面 |
|
||||
| UI | 配置 iframe + UI Bridge | 统一控制面中的业务模块页面 |
|
||||
| 数据边界 | Core 负责账号转发与计费 | Core 负责权威业务数据,插件只读/投影 |
|
||||
| 菜单 | Core 固定插件管理页 | `custom_menu_items` 管理员入口 |
|
||||
|
||||
@@ -60,7 +62,7 @@ Sub2API Core 现有鉴权、Admin API 和领域账本
|
||||
- `plugin_operator`:查看状态、日志摘要和健康诊断,不改变包或凭据。
|
||||
- `plugin_readonly`:只读查看已登记插件。
|
||||
|
||||
V1 的控制面只允许 Core `role=admin` 登录。插件不创建第二套 Core 用户表;插件会话只保存 `plugin_id`、`admin_user_id`、角色、会话版本和过期时间。UI 隐藏按钮不等于授权,控制面和 Core API 均须重新校验权限。
|
||||
V1 的 Plugin Admin 控制面只允许 Core `role=admin` 登录。插件不创建第二套 Core 用户表;控制面会话保存 `admin_user_id`、角色、会话版本和过期时间,订阅等业务模块直接继承该会话。模块不得创建自己的登录页、Cookie 或独立权限入口。UI 隐藏按钮不等于授权,控制面、模块后端和 Core API 均须重新校验权限。
|
||||
|
||||
## 5. 管理面契约
|
||||
|
||||
@@ -157,9 +159,16 @@ Core 返回 `401` 时,一次用户请求最多 refresh 一次;并发 refresh
|
||||
|
||||
## 9. 会话和嵌入
|
||||
|
||||
插件登录调用 Core 现有 `/auth/login`、按需 `/auth/login/2fa`,再调用 `/auth/me` 校验管理员角色。Core token 只存插件服务端会话,浏览器只持有 HttpOnly、Secure、SameSite Cookie 和插件 CSRF token。
|
||||
Plugin Admin 登录调用 Core 现有 `/auth/login`、按需 `/auth/login/2fa`,再调用 `/auth/me` 校验管理员角色。Core token 只存控制面服务端会话,浏览器只持有控制面的 HttpOnly、Secure、SameSite Cookie 和 CSRF token;订阅模块请求沿用这套会话。
|
||||
|
||||
Core 的自定义页面当前使用 sandbox iframe,且不会自动继承 Core `localStorage` 登录态。因此 V1 必须同时提供新窗口入口;iframe 首屏显示插件登录页是已知行为。真正无感 SSO 需要 V1.1 的一次性 code/state 或受控 `postMessage` 交接,不得把 JWT 放在 URL。
|
||||
会话和进程是两个独立的生命周期:插件作为常驻服务运行,Core access token
|
||||
到期时由后端按需 refresh,不要求重启插件。默认会话空闲 30 分钟、绝对上限
|
||||
8 小时;refresh 失败或 Core 撤销管理员后清除会话并要求重新登录。V1 会话
|
||||
默认只在插件进程内存中保存,所以控制面重启后需要重新登录一次,但已启用
|
||||
插件会按 registry 恢复。跨实例或跨重启免登录必须接入插件自有加密共享会话
|
||||
存储,不能把 Core token 放入浏览器或 Core 数据库。
|
||||
|
||||
Core 的自定义页面当前使用 sandbox iframe,且不会自动继承 Core `localStorage` 登录态。因此 iframe 首屏由 Plugin Admin 显示一次登录页;进入订阅模块时不再追加登录。真正无感 Core SSO 仍需要 V1.1 的一次性 code/state 或受控 `postMessage` 交接,不得把 JWT 放在 URL。
|
||||
|
||||
菜单注入使用 Core 现有 `custom_menu_items`:
|
||||
|
||||
@@ -167,7 +176,7 @@ Core 的自定义页面当前使用 sandbox iframe,且不会自动继承 Core
|
||||
{
|
||||
"id": "DOMAIN_PLUGIN_ID",
|
||||
"label": "DOMAIN_PLUGIN_LABEL",
|
||||
"url": "https://CORE_ORIGIN/extensions/DOMAIN_PLUGIN_ID/",
|
||||
"url": "https://PLUGIN_PUBLIC_ORIGIN/extensions/qiu.plugin-admin/admin/#/modules/DOMAIN_MODULE/overview",
|
||||
"visibility": "admin",
|
||||
"sort_order": 200
|
||||
}
|
||||
@@ -200,7 +209,7 @@ Core 始终是用户身份、余额、订阅、订单、用量、权限、计费
|
||||
|
||||
### Phase 2:业务插件适配
|
||||
|
||||
提供 `DOMAIN_PLUGIN_ID` 级别的 SDK/模板和契约测试。订阅管理作为首个独立业务插件接入,只实现自身领域页面和 Core 只读 API,不改变控制面。
|
||||
提供 `DOMAIN_PLUGIN_ID` 级别的 SDK/模板和契约测试。订阅管理作为首个业务模块接入统一控制面,只实现自身领域页面和 Core 只读 API,不复制控制面的登录、导航和会话。
|
||||
|
||||
### Phase 3:生产增强
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# 插件控制面 UI 信息架构
|
||||
|
||||
## 目标
|
||||
|
||||
插件控制面是统一的插件运维后台,不是 Core 主站的复制品。订阅是安装后的业务模块,不是第二个后台系统。界面按任务拆分页面,避免把安装、版本、配置、菜单、订阅和审计动作堆在同一张卡片或同一个长页面中。
|
||||
|
||||
## 页面层级
|
||||
|
||||
```text
|
||||
插件管理
|
||||
├── 概览 # 控制面总览,不执行高风险操作
|
||||
├── 已安装插件 # 插件摘要列表,只保留常用主操作
|
||||
│ └── 插件详情/:id
|
||||
│ ├── 运行概况 # 健康、状态、端点、兼容性
|
||||
│ ├── 版本与升级 # revision、升级、回滚
|
||||
│ ├── 配置 # 服务地址、菜单地址、密钥提示
|
||||
│ ├── 菜单接入 # 预览和应用管理员菜单
|
||||
│ └── 操作历史 # 当前插件的审计记录
|
||||
├── 插件市场 # 受控目录元数据和入库入口
|
||||
└── 操作记录 # 全局操作审计和操作详情
|
||||
|
||||
已安装并启用的业务模块
|
||||
└── 订阅管理 # 复用控制面会话,不提供第二个登录页
|
||||
├── 概览
|
||||
├── 套餐
|
||||
├── 用户订阅
|
||||
└── 操作记录
|
||||
```
|
||||
|
||||
页面使用 hash 路由,便于刷新、复制链接和从 Core 自定义菜单直接打开:
|
||||
|
||||
```text
|
||||
#/overview
|
||||
#/plugins
|
||||
#/plugins/{plugin_id}/overview
|
||||
#/plugins/{plugin_id}/revisions
|
||||
#/plugins/{plugin_id}/config
|
||||
#/plugins/{plugin_id}/menu
|
||||
#/plugins/{plugin_id}/operations
|
||||
#/marketplace
|
||||
#/operations
|
||||
#/modules/subscription/overview
|
||||
#/modules/subscription/plans
|
||||
#/modules/subscription/subscriptions
|
||||
#/modules/subscription/audit
|
||||
```
|
||||
|
||||
hash 只表达页面位置,不承载凭据、Core JWT、Admin Key 或服务密钥。
|
||||
|
||||
## 一级页面职责
|
||||
|
||||
### 概览
|
||||
|
||||
只展示已登记、运行中、待启用和需要关注的数量,以及最近操作和生命周期提示。概览不直接承载上传、启停、回滚或卸载按钮,避免误操作;通过“管理插件”和“查看全部”进入专门页面。
|
||||
|
||||
### 已安装插件
|
||||
|
||||
每个插件条目只显示名称、版本、状态、Core 兼容性、活动 revision 和更新时间。条目保留一个生命周期主操作(启用或停用)和“查看详情”;升级、回滚、菜单和卸载进入“更多操作”菜单,防止按钮挤压或误触。
|
||||
|
||||
### 插件详情
|
||||
|
||||
详情页的每个二级页签只有一个任务:
|
||||
|
||||
- **运行概况**:判断当前是否健康、是否已配置、是否可以启用;错误仅展示脱敏后的最近错误。
|
||||
- **版本与升级**:查看 revision、活动版本和校验时间;上传升级包、选择回滚版本。
|
||||
- **配置**:读取和保存服务/菜单地址;敏感配置仍由服务端加密,页面不回显原值。
|
||||
- **菜单接入**:查看声明的菜单元数据,先预览再应用,不把菜单操作混在生命周期按钮中。
|
||||
- **操作历史**:只看当前插件关联的操作,操作 ID 可以打开详情。
|
||||
|
||||
### 插件市场
|
||||
|
||||
市场只显示受控索引提供的名称、版本、发布者、兼容性、发布时间和归档哈希。安装动作的语义是“下载并校验后入库”,完成后插件处于待启用状态;已经登记的插件跳转到版本页,不在市场卡片上直接覆盖现有版本。
|
||||
|
||||
### 操作记录
|
||||
|
||||
提供按插件和操作类型筛选的全局审计视图。列表展示操作、插件、结果和时间;操作 ID 进入详情弹窗,详情中包含请求 ID、状态和脱敏错误。
|
||||
|
||||
### 订阅业务模块
|
||||
|
||||
订阅模块只有在 `qiu.subscription-admin` 安装、启用并通过健康检查后才出现在导航中。它沿用 Plugin Admin 的管理员会话、CSRF、主题和响应式 Shell;模块页面不显示登录表单、不创建 Cookie,也不重复调用 Core 登录。套餐、用户订阅和余额数据仍从模块 BFF 读取,Core 继续是权威来源。
|
||||
|
||||
## 导航与响应式规则
|
||||
|
||||
- 桌面端使用左侧一级导航,内容区只显示当前路由页面;导航项同时显示页面名称和一句职责提示。
|
||||
- 移动端(宽度不超过 760px)将一级导航变为抽屉,通过“菜单”按钮打开;打开导航不会改变页面宽度,也不会让整个页面依赖横向滚动。
|
||||
- 详情页签只允许页签条自身横向滚动,内容区和页面主体始终保持 `scrollWidth <= innerWidth`。
|
||||
- 桌面端统计卡片四列,中等宽度两列,手机端仍使用两列但缩小内边距;内容面板在中等宽度以下改为单列。
|
||||
- 列表卡片的次要动作进入操作菜单;移动端按钮按可用宽度换行,不使用固定宽度挤压文字。
|
||||
- 审计表格在手机端保留最小可读列宽,仅表格容器横向滚动,页面本身不横向溢出。
|
||||
- 页面、卡片和表单使用统一 4px/6px 圆角、36px 控件高度和统一边距;页面不会用额外的局部颜色覆盖 Core 主题。
|
||||
|
||||
## 数据加载与权限
|
||||
|
||||
- 页面数据按路由按需加载:概览加载插件和审计,市场加载插件和市场索引,操作记录加载插件和审计,详情加载指定插件和审计;订阅模块只在启用后加载自己的数据。
|
||||
- 所有请求继续通过插件后端会话和 CSRF;浏览器不会直接调用 Core,也不会收到 Core token 或 Admin Key。
|
||||
- 订阅模块请求只能沿用控制面会话,不允许出现第二个登录 endpoint 或模块级会话。
|
||||
- 页面上的按钮隐藏只改善交互,真正的管理员权限、生命周期状态和幂等校验仍由插件后端负责。
|
||||
- mutation 返回 operation ID 后,前端短暂读取操作详情,再刷新当前路由;操作记录页面始终保留最终审计结果。
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 登录后默认进入概览,导航可进入控制面页面和已启用的业务模块,浏览器刷新后 hash 路由不丢失。
|
||||
2. 已安装列表不再展示所有生命周期按钮;插件详情的五个二级页签分别承担单一职责。
|
||||
3. 市场入库、配置、启停、升级、回滚、菜单和卸载接口行为与现有后端契约一致。
|
||||
4. 425px、900px、1440px 视口没有页面级横向溢出;移动端导航、详情页签和操作菜单可触达。
|
||||
5. 页面 DOM、JSON 响应、URL、错误提示和操作详情不出现 Core JWT、Admin Key、密码或服务密钥。
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
状态:V1 通用插件控制面参考实现已落库;订阅业务插件只读适配与 Core Host Adapter/写操作仍为 Draft
|
||||
|
||||
本文规划一种不改动 Sub2API 核心代码、数据库和现有插件 ABI 的独立业务插件框架。当前 V1 控制面参考实现位于 `plugins/plugin-admin`,它负责插件清单、签名、插件市场、下载入库、启停、升级、回滚、卸载、配置、审计和菜单注入;控制面本身不是订阅后台。每个业务插件(包括独立的 `plugins/subscription-admin`)作为可选的独立服务运行在自己的端口,通过控制面安装后再由部署层反向代理和现有“管理员可见自定义菜单”嵌入 Sub2API 页面。插件登录直接调用 Core 的现有鉴权,普通账号没有访问权限,也不复制 Core 用户表。
|
||||
本文规划一种不改动 Sub2API 核心代码、数据库和现有插件 ABI 的独立业务插件框架。当前 V1 控制面参考实现位于 `plugins/plugin-admin`,它负责插件清单、签名、插件市场、下载入库、启停、升级、回滚、卸载、配置、审计和菜单注入;订阅不是第二个后台,而是安装到控制面后的业务模块。订阅后端可以作为独立服务运行在自己的端口,但浏览器端统一由 Plugin Admin Shell 承载,所有业务模块共享一次管理员登录、会话、导航和 CSRF,不创建第二个登录页或 Cookie。插件登录直接调用 Core 的现有鉴权,普通账号没有访问权限,也不复制 Core 用户表。
|
||||
|
||||
> **前端架构修订(2026-08-30)**:文档中“独立服务”表示部署和进程边界,不表示每个业务模块都是独立产品。订阅模块只能在插件控制面会话内访问;模块入口由插件清单的 capability/menu 声明决定,未安装或未启用时不显示。
|
||||
|
||||
V1 已实现范围以 `plugins/plugin-admin` 控制面和本文“当前实现范围”章节为准;本文中的订阅业务插件只是首个适配样例。Core Host Adapter、短时 Plugin Access Token、无感 SSO 和余额写操作仍是后续版本设计,不代表当前 Core 已提供这些接口。
|
||||
|
||||
@@ -19,7 +21,7 @@ V1 已实现范围以 `plugins/plugin-admin` 控制面和本文“当前实现
|
||||
- 插件只在服务端调用 Core 现有 API,浏览器不持有 `x-api-key` 或 Core JWT;
|
||||
- 第一阶段只搭框架、登录、权限、健康检查、嵌入和只读联调,不实现订阅购买写操作。
|
||||
|
||||
现有自定义菜单可以完成“把插件页面显示在 Sub2API 管理页面内”,但现有 iframe 使用 sandbox,且 Core 前端 JWT 保存在 `localStorage`,不会自动注入跨端口 iframe。因此在完全不改 Core 的前提下,V1 的登录方式是:插件登录页把凭据转交给插件后端,插件后端调用 Core 现有登录和二次验证接口,确认 `role=admin` 后只保留短时插件会话及服务端 Core token;这复用同一套 Core 用户和角色,不复制用户表,但不是无感知的当前页面会话共享。
|
||||
现有自定义菜单可以完成“把插件控制面显示在 Sub2API 管理页面内”,但现有 iframe 使用 sandbox,且 Core 前端 JWT 保存在 `localStorage`,不会自动注入跨端口 iframe。因此在完全不改 Core 的前提下,V1 的登录方式是:Plugin Admin 登录页把凭据转交给插件后端,插件后端调用 Core 现有登录和二次验证接口,确认 `role=admin` 后只保留短时控制面会话及服务端 Core token;这复用同一套 Core 用户和角色,不复制用户表。订阅模块继承控制面会话,不得再次调用 Core 登录。
|
||||
|
||||
如果以后要求“已登录 Core 后打开 iframe 立即无感登录”,需要一个很小的 Core 一次性登录交接接口或前端 `postMessage` 适配;这属于 V1.1,不应通过 URL 明文传递 JWT。反向代理只改变网络路径,不改变这一认证边界。
|
||||
|
||||
@@ -37,12 +39,13 @@ Core `/custom/PLUGIN_ID`
|
||||
反向代理 `/extensions/PLUGIN_ID/*`
|
||||
│ 转发到独立服务 `127.0.0.1:PLUGIN_PORT`
|
||||
▼
|
||||
业务插件后台
|
||||
├─ `/login` 接收管理员登录请求
|
||||
Plugin Admin 控制面(统一 TDesign Shell)
|
||||
├─ `/login` 唯一登录入口
|
||||
├─ 服务端调用 Core `/api/v1/auth/login`(需要时调用 `/login/2fa`)
|
||||
├─ 调用 Core `/api/v1/auth/me`,确认 `role=admin`
|
||||
├─ 建立插件 HttpOnly 会话,Core access/refresh token 只在服务端保存
|
||||
└─ 通过 Bearer Core JWT 调用现有 Core Admin API
|
||||
├─ 建立唯一的插件 HttpOnly 会话,Core access/refresh token 只在服务端保存
|
||||
├─ 挂载已启用业务模块(订阅等),模块不再登录
|
||||
└─ 通过受控 BFF/内部服务调用业务模块和 Core Admin API
|
||||
▼
|
||||
Sub2API Core 现有认证、Admin API 和订阅/余额账本
|
||||
```
|
||||
@@ -53,7 +56,7 @@ Sub2API Core 现有认证、Admin API 和订阅/余额账本
|
||||
|
||||
### 1.2 V1 的默认范围
|
||||
|
||||
- 插件拥有独立后台和独立发布版本。
|
||||
- 插件后端拥有独立服务和独立发布版本;前端由统一控制面承载。
|
||||
- 只允许管理员登录;普通用户访问插件后台一律拒绝。
|
||||
- 首版支持管理员登录、权限校验、健康检查、嵌入和只读订阅数据展示。
|
||||
- 余额购买命令暂不实现,待框架验收后再复用现有订阅逻辑设计原子入口。
|
||||
@@ -78,8 +81,8 @@ Sub2API Core 现有认证、Admin API 和订阅/余额账本
|
||||
| 术语 | 定义 |
|
||||
|---|---|
|
||||
| Core | Sub2API 主服务,拥有用户、余额、Group、Key、订阅和用量账本。 |
|
||||
| Business Plugin | 独立部署的后台服务,提供一个业务域的管理 UI 和 BFF。 |
|
||||
| Plugin UI | 由 Business Plugin 提供的页面,只调用自己的后端。 |
|
||||
| Business Plugin | 独立部署的后台服务或业务模块,提供一个业务域的 UI 和 BFF。 |
|
||||
| Plugin UI | 由统一控制面挂载的业务模块页面,只调用模块 BFF。 |
|
||||
| Plugin Backend | Business Plugin 的服务端,保存插件配置、会话和操作幂等记录。 |
|
||||
| Core API Adapter | V1 插件后端对 Core 现有 REST API 的服务端客户端,只允许访问明确的认证、管理员和只读业务端点。 |
|
||||
| Future Host Adapter | V1.1 以后、需要修改 Core 才能提供的版本化业务 API;不属于本次无 Core 改动的实现范围。 |
|
||||
@@ -91,27 +94,27 @@ Sub2API Core 现有认证、Admin API 和订阅/余额账本
|
||||
|
||||
### 4.1 管理员登录
|
||||
|
||||
V1 采用“插件会话 + Core 现有登录”的两层模型,不创建插件用户表:
|
||||
V1 采用“控制面会话 + Core 现有登录”的两层模型,不创建插件用户表;所有业务模块继承控制面会话:
|
||||
|
||||
1. 浏览器打开插件 `/login`,只向插件后端提交 Core 管理员凭据和必要的 2FA 信息。
|
||||
1. 浏览器打开 Plugin Admin `/login`,只向控制面后端提交 Core 管理员凭据和必要的 2FA 信息。
|
||||
2. 插件后端服务端调用 Core `POST /api/v1/auth/login`;需要二次验证时继续调用 `POST /api/v1/auth/login/2fa`。
|
||||
3. 插件后端用返回的 Core access token 调用 `GET /api/v1/auth/me`,确认用户状态正常且 `role=admin`。
|
||||
4. 插件后端建立自己的短时 HttpOnly 会话;Core access/refresh token 只保存在插件服务端的会话存储中,不回传浏览器。
|
||||
5. 插件业务请求只携带插件会话 Cookie,插件后端再用对应管理员的 Core Bearer token 调用允许的 Core API。
|
||||
4. 控制面建立唯一的短时 HttpOnly 会话;Core access/refresh token 只保存在控制面服务端的会话存储中,不回传浏览器。
|
||||
5. 订阅等业务模块请求只携带控制面会话 Cookie,由控制面 BFF 或受控内部转发调用模块和允许的 Core API。
|
||||
|
||||
```text
|
||||
浏览器 -> Plugin /login(插件会话 Cookie 尚未建立)
|
||||
Plugin -> Core /api/v1/auth/login
|
||||
Plugin -> Core /api/v1/auth/login/2fa(按 Core 返回的要求)
|
||||
Plugin -> Core /api/v1/auth/me(确认 role=admin)
|
||||
Plugin <- 建立 HttpOnly 插件会话
|
||||
浏览器 -> Plugin /admin/*(只带插件会话 Cookie)
|
||||
Plugin -> Core /api/v1/admin/*(只在服务端带 Bearer Core JWT)
|
||||
浏览器 -> Plugin Admin /login(控制面会话 Cookie 尚未建立)
|
||||
Plugin Admin -> Core /api/v1/auth/login
|
||||
Plugin Admin -> Core /api/v1/auth/login/2fa(按 Core 返回的要求)
|
||||
Plugin Admin -> Core /api/v1/auth/me(确认 role=admin)
|
||||
Plugin Admin <- 建立唯一 HttpOnly 控制面会话
|
||||
浏览器 -> Plugin Admin /admin/* 与 /modules/*(只带同一会话 Cookie)
|
||||
Plugin Admin -> 订阅模块 BFF / Core /api/v1/admin/*(只在服务端带 Bearer Core JWT)
|
||||
```
|
||||
|
||||
这不是独立账号,也不是把 Core 用户复制到插件;密码只用于一次 Core 登录请求,插件不落库。插件会话可使用内存存储;多实例部署时使用插件自己的 Redis/会话存储,不连接 Core 数据库。Core 继续负责密码、2FA、限流、TokenVersion、撤销和管理员角色校验。
|
||||
这不是独立账号,也不是把 Core 用户复制到插件;密码只用于一次 Core 登录请求,控制面不落库。插件会话可使用内存存储;多实例部署时使用插件自己的 Redis/会话存储,不连接 Core 数据库。订阅等业务模块直接复用控制面会话,不创建模块级会话。Core 继续负责密码、2FA、限流、TokenVersion、撤销和管理员角色校验。
|
||||
|
||||
当前 Core 没有给自定义 iframe 提供 token handoff,因此“Core 已登录后打开 iframe 自动登录”不属于 V1。V1 允许在 iframe 内显示插件登录页;由于 sandbox/第三方 Cookie 策略可能让嵌入会话在刷新后失效,插件必须提供“新窗口打开插件”入口作为稳定登录路径。以后如需真正无感 SSO,另行设计一次性 code + state 或 `postMessage` 交接机制,JWT 不应放在 URL。
|
||||
当前 Core 没有给自定义 iframe 提供 token handoff,因此“Core 已登录后打开 iframe 自动登录”不属于 V1。V1 允许在 iframe 内显示一次 Plugin Admin 登录页;进入订阅模块时不再追加登录。由于 sandbox/第三方 Cookie 策略可能让嵌入会话在刷新后失效,控制面必须提供“新窗口打开”入口。以后如需真正无感 SSO,另行设计一次性 code + state 或 `postMessage` 交接机制,JWT 不应放在 URL。
|
||||
|
||||
### 4.2 权限判定
|
||||
|
||||
@@ -131,6 +134,14 @@ Plugin -> Core /api/v1/admin/*(只在服务端带 Bearer Core JWT)
|
||||
- 独立 origin 嵌入时按浏览器策略使用 `SameSite=None; Secure`,同源反代优先使用 `Lax`;必须在目标浏览器验证刷新、退出和第三方 Cookie 行为。
|
||||
- 插件 UI 不把 Core JWT、Admin Key 或服务凭据写入 LocalStorage、URL、HTML、日志或错误提示。
|
||||
|
||||
会话有效期与插件进程生命周期相互独立。插件作为常驻 HTTP 服务运行,Core
|
||||
access token 过期时由后端按需调用 `/auth/refresh` 并更新服务端会话,不需要
|
||||
重启插件。只有 refresh 失败、管理员被 Core 撤销、插件会话达到空闲/绝对
|
||||
TTL,或管理员主动退出时,浏览器才需要重新登录。V1 默认使用内存会话,因
|
||||
此控制面进程重启会使现有插件会话失效一次;这不影响 registry 中已登记插件
|
||||
的恢复。需要跨重启免登录时,使用插件自有的加密共享会话存储,不改变 Core
|
||||
身份权威,也不把 refresh token 下发给浏览器。
|
||||
|
||||
## 5. Admin Key 与服务凭据安全
|
||||
|
||||
### 5.1 绝对禁止的做法
|
||||
@@ -145,7 +156,7 @@ Plugin -> Core /api/v1/admin/*(只在服务端带 Bearer Core JWT)
|
||||
|
||||
| 环境 | 凭据 | 用途 | 约束 |
|
||||
|---|---|---|---|
|
||||
| 开发 | Core 管理员的临时登录凭据 | 调用 Core `/auth/login` 联调 | 只由开发者输入到插件登录页,不写入代码、配置和日志。 |
|
||||
| 开发 | Core 管理员的临时登录凭据 | 调用 Core `/auth/login` 联调 | 只由开发者输入到 Plugin Admin 登录页,不写入代码、配置和日志。 |
|
||||
| 测试 | Core 返回的 access/refresh token | 建立插件服务端会话 | 只存插件服务端会话存储,短 TTL,测试 Core 与测试管理员专用。 |
|
||||
| 生产 | Core 返回的 access/refresh token | 代表实际登录的 Core 管理员调用现有 Admin API | 服务端加密保存或内存保存,按 Core TokenVersion/撤销结果失效;不回传浏览器。 |
|
||||
|
||||
@@ -155,8 +166,8 @@ V1.1 如需在不保存 Core refresh token 的情况下运行,再设计 Core
|
||||
|
||||
### 5.3 凭据生命周期
|
||||
|
||||
1. 插件后端接收管理员登录请求,但不保存密码。
|
||||
2. 插件后端调用 Core 登录/2FA,保存返回 token 到服务端会话,并绑定 `admin_user_id`。
|
||||
1. Plugin Admin 后端接收管理员登录请求,但不保存密码。
|
||||
2. Plugin Admin 后端调用 Core 登录/2FA,保存返回 token 到唯一的服务端会话,并绑定 `admin_user_id`。
|
||||
3. 每次 Core 请求都使用 TLS 和 Core Bearer token;Core 继续校验签名、TokenVersion、会话绑定和角色。
|
||||
4. access token 过期时只使用对应 refresh token 调用 Core `/auth/refresh`;刷新失败就销毁插件会话并要求重新登录。
|
||||
5. 登出、Core 管理员撤销会话、停用插件或发现泄露时,立即删除插件会话;Admin Key 过渡模式由运维轮换并撤销。
|
||||
@@ -302,11 +313,11 @@ Core `/custom/example.subscription`
|
||||
└── sandbox iframe -> 反向代理 -> `127.0.0.1:PLUGIN_PORT`
|
||||
```
|
||||
|
||||
`visibility=admin` 只负责隐藏普通账号的菜单入口,插件后端仍必须独立鉴权。现有 iframe 的 sandbox、跨端口 origin 和 Core JWT `localStorage` 使其不会自动共享 Core 登录态;因此 iframe 首屏显示插件登录页是 V1 的预期行为。插件也应提供“新窗口打开”,便于登录后保持自身会话。
|
||||
`visibility=admin` 只负责隐藏普通账号的菜单入口,Plugin Admin 后端仍必须鉴权。现有 iframe 的 sandbox、跨端口 origin 和 Core JWT `localStorage` 使其不会自动共享 Core 登录态;因此 iframe 首屏显示一次控制面登录页是 V1 的预期行为。订阅模块显示在控制面内部,不再出现独立登录页。控制面也应提供“新窗口打开”,便于登录后保持自身会话。
|
||||
|
||||
生产部署建议把插件外部地址挂在与 Core 相同的 HTTPS 站点下,由 Nginx/Caddy 按路径反代到独立端口;这只减少浏览器跨域问题,不改变插件必须登录和服务端调用 Core 的事实。若使用独立 origin,必须在 Core CORS 中精确加入该 origin,禁止 `*`,并仅允许必要的 `Authorization` 请求头。
|
||||
|
||||
插件页面只调用自己的 `/plugin-api/*`,由插件后端调用 Core 现有认证和管理员 API。V1 不允许插件动态注入主应用 Vue 路由、修改核心菜单组件或覆盖全局 CSS;主应用只提供一个受权限控制的“业务插件”入口,插件内部菜单由插件自己管理。
|
||||
插件模块页面只调用控制面提供的 `/plugin-api/*` 或受控模块 BFF,由控制面调用 Core 现有认证和管理员 API。V1 不允许插件动态注入 Core Vue 路由、修改 Core 菜单组件或覆盖全局 CSS;Plugin Admin 只挂载已安装、已启用且管理员可见的业务模块。
|
||||
|
||||
## 10. 威胁模型与处置
|
||||
|
||||
@@ -345,7 +356,7 @@ Core `/custom/example.subscription`
|
||||
|
||||
### 11.3 浏览器和部署测试
|
||||
|
||||
- 管理员可登录,普通用户无法登录或访问任何后台 API;
|
||||
- 管理员在 Plugin Admin 完成一次登录后可进入订阅模块,普通用户无法登录或访问任何后台 API;
|
||||
- 不同屏幕下页面无横向泄露和敏感字段;
|
||||
- 浏览器 DevTools 的请求、下载和页面源中没有 Admin Key;
|
||||
- HTTPS、反向代理、容器低权限、secret 文件权限和日志脱敏;
|
||||
@@ -355,7 +366,7 @@ Core `/custom/example.subscription`
|
||||
|
||||
### Phase 0:冻结部署契约
|
||||
|
||||
- 选择外部服务部署方式(推荐同源反向代理 + 独立服务);
|
||||
- 选择外部服务部署方式(推荐同源反向代理 + 独立服务),并冻结统一控制面模块挂载方式;
|
||||
- 冻结插件端口、反向代理路径、`custom_menu_items` 字段和健康检查;
|
||||
- 冻结允许调用的现有 Core API 路径、字段、分页和错误处理;
|
||||
- 明确插件登录通过 Core `/auth/login`/`/login/2fa`,不创建用户表;
|
||||
@@ -369,10 +380,10 @@ Core `/custom/example.subscription`
|
||||
- 插件健康检查、启停、操作审计和同源入口;
|
||||
- Core API allowlist、contract test 与本地示例插件。
|
||||
|
||||
### Phase 2:订阅插件试验
|
||||
### Phase 2:订阅业务模块试验
|
||||
|
||||
- 只读套餐、用户余额和订阅列表;
|
||||
- 管理员操作页面、查询缓存和审计;
|
||||
- 在统一控制面内挂载只读套餐、用户余额和订阅列表模块;
|
||||
- 复用控制面管理员会话、查询缓存和审计;
|
||||
- 使用测试 Core 和测试账户,不连接生产余额;
|
||||
- 余额购买、续费和撤销暂不实现,等待 Core 原子接口冻结。
|
||||
|
||||
|
||||
+3
-1
@@ -8,12 +8,14 @@
|
||||
- [Manifest V1](BUSINESS_PLUGIN_MANIFEST_V1.md)
|
||||
- [Boundaries](BUSINESS_PLUGIN_BOUNDARIES.md)
|
||||
- [Architecture](BUSINESS_PLUGIN_ARCHITECTURE.md)
|
||||
- [Plugin Admin UI Information Architecture](PLUGIN_ADMIN_UI_INFORMATION_ARCHITECTURE.md)
|
||||
- [TDesign Frontend Migration Assessment](TDESIGN_FRONTEND_MIGRATION_ASSESSMENT.md)
|
||||
- [Acceptance](BUSINESS_PLUGIN_ACCEPTANCE.md)
|
||||
- [Development](BUSINESS_PLUGIN_DEVELOPMENT.md)
|
||||
|
||||
## 领域插件
|
||||
|
||||
- `subscription-admin`:独立管理员只读订阅插件。它是框架的第一个领域样例,不是通用插件后台,也不负责安装或管理其他插件。实现与运行方式见 [`../plugins/subscription-admin/README.md`](../plugins/subscription-admin/README.md)。
|
||||
- `subscription-admin`:订阅业务模块后端。它是框架的第一个领域样例,不是通用插件后台,也不负责安装或管理其他插件;前端由 Plugin Admin 统一挂载并共享一次登录。实现与运行方式见 [`../plugins/subscription-admin/README.md`](../plugins/subscription-admin/README.md)。
|
||||
|
||||
## 现有 `.s2plugin`
|
||||
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
|
||||
状态:V1 只读试验实现已落库;余额购买、续费、撤销仍为 V1.1 Draft
|
||||
|
||||
本文定义基于 [`PLUGIN_FRAMEWORK_V1_RFC.md`](./PLUGIN_FRAMEWORK_V1_RFC.md) 的第一个业务插件试验,对应实现为 `plugins/subscription-admin`。插件是独立端口的管理员后台,复用 Sub2API Core 的管理员鉴权,不创建 Core 用户表,也不直接连接 Core 数据库。当前实现已完成框架、管理员会话、只读套餐/余额/订阅/审计联调;余额购买、续费和撤销写操作后置到 Core 原子接口冻结之后。
|
||||
本文定义基于 [`PLUGIN_FRAMEWORK_V1_RFC.md`](./PLUGIN_FRAMEWORK_V1_RFC.md) 的第一个业务模块试验,对应后端实现为 `plugins/subscription-admin`。订阅后端可以独立端口运行,但它不是第二个管理员后台:浏览器入口由 `plugins/plugin-admin` 统一控制面承载,复用同一套管理员会话、导航、CSRF 和权限,不创建 Core 用户表,也不直接连接 Core 数据库。当前实现已完成框架、管理员会话、只读套餐/余额/订阅/审计联调;余额购买、续费和撤销写操作后置到 Core 原子接口冻结之后。
|
||||
|
||||
> **前端架构修订(2026-08-30)**:本 RFC 中原有“订阅插件登录页/独立后台”描述由本条覆盖。订阅模块不得提供第二个 `/login`、独立 Cookie、独立管理员身份或重复的 Core 登录;安装并启用后才在 Plugin Admin 导航中出现。
|
||||
|
||||
## 1. 目标和范围
|
||||
|
||||
### 1.1 V1 目标
|
||||
|
||||
- 提供独立的管理员订阅后台,普通账号拒绝登录和访问;
|
||||
- 插件登录调用 Core 现有 `/api/v1/auth/login`、`/api/v1/auth/login/2fa` 和 `/api/v1/auth/me`,不维护第二套用户密码;
|
||||
- 提供挂载在统一 Plugin Admin 控制面内的管理员订阅模块,普通账号拒绝登录和访问;
|
||||
- 由 Plugin Admin 统一调用 Core 现有 `/api/v1/auth/login`、`/api/v1/auth/login/2fa` 和 `/api/v1/auth/me`;订阅模块不维护第二套用户密码或登录态;
|
||||
- 展示 Core 中的套餐、用户余额和订阅实例状态;
|
||||
- 通过现有 `custom_menu_items` 的 `visibility=admin` 入口嵌入 Core 页面,也支持新窗口打开;
|
||||
- 插件可独立升级、停用和回滚,不影响 Core 网关、余额账本和已有订阅;
|
||||
@@ -42,14 +44,15 @@
|
||||
Core 管理后台
|
||||
└─ custom_menu_items (visibility=admin)
|
||||
└─ sandbox iframe / 新窗口
|
||||
└─ 反向代理 -> Plugin `127.0.0.1:PLUGIN_PORT`
|
||||
├─ Plugin /login -> Core /api/v1/auth/login (+ /login/2fa)
|
||||
├─ Plugin /auth/me -> 只允许 role=admin
|
||||
├─ HttpOnly 插件会话(不建 Core 用户表)
|
||||
└─ 反向代理 -> Plugin Admin `127.0.0.1:PLUGIN_PORT`
|
||||
├─ Plugin Admin /login -> Core /api/v1/auth/login (+ /login/2fa)
|
||||
├─ Plugin Admin /auth/me -> 只允许 role=admin
|
||||
├─ 唯一 HttpOnly 控制面会话(不建 Core 用户表)
|
||||
├─ 挂载订阅模块 /modules/subscription/*(不重复登录)
|
||||
└─ 服务端 Bearer Core JWT -> 现有 Core Admin API(V1 只读)
|
||||
```
|
||||
|
||||
插件 UI 只访问自己的 BFF;Core JWT、refresh token 和 Admin API Key 只在插件服务端会话或 secret 中出现。iframe 不会自动继承 Core `localStorage` 登录态,因此 V1 首屏显示插件登录页属于预期行为。sandbox 或第三方 Cookie 策略可能导致嵌入会话刷新后失效,插件必须提供新窗口登录路径;登录密码只在一次转发请求中经过插件后端,不落库、不写日志。
|
||||
订阅模块 UI 只访问控制面的模块 BFF;Core JWT、refresh token 和 Admin API Key 只在控制面服务端会话或 secret 中出现。iframe 不会自动继承 Core `localStorage` 登录态,因此 V1 首屏显示一次 Plugin Admin 登录页属于预期行为;进入订阅模块时不再登录。sandbox 或第三方 Cookie 策略可能导致嵌入会话刷新后失效,控制面必须提供新窗口入口;登录密码只在一次转发请求中经过控制面后端,不落库、不写日志。
|
||||
|
||||
## 4. 权威边界
|
||||
|
||||
@@ -65,7 +68,7 @@ Core 管理后台
|
||||
|
||||
### 4.2 插件可以负责
|
||||
|
||||
- 管理员登录代理、插件会话和插件内角色;
|
||||
- 控制面负责管理员登录代理、统一插件会话和插件内角色;订阅模块只消费经过授权的模块会话;
|
||||
- 套餐、用户和订阅的分页筛选与展示;
|
||||
- 只读缓存、操作结果页和管理员审计视图;
|
||||
- 未来写操作的确认表单,但提交必须调用 Core 原子命令;
|
||||
@@ -210,7 +213,7 @@ accepted -> processing -> completed
|
||||
|
||||
### 11.3 浏览器和部署
|
||||
|
||||
- 管理员可在 iframe 和新窗口完成登录;普通用户菜单不可见且 API 拒绝;
|
||||
- 管理员在 Plugin Admin 的 iframe 或新窗口完成一次登录即可进入订阅模块;普通用户菜单不可见且 API 拒绝;
|
||||
- 425px、900px、1440px 下无横向溢出、遮挡或敏感字段泄露;
|
||||
- DevTools 请求、下载、页面源和日志中没有 secret;
|
||||
- 反向代理、HTTPS、低权限运行、停用、升级和回滚流程可恢复。
|
||||
@@ -247,7 +250,7 @@ accepted -> processing -> completed
|
||||
|
||||
- [ ] 插件只允许 Core `role=admin` 登录,且不创建用户表。
|
||||
- [ ] 独立端口、反向代理路径和 `visibility=admin` 菜单入口已确定。
|
||||
- [ ] iframe 登录页和新窗口登录页均可用,已知晓 V1 不提供无感 SSO。
|
||||
- [ ] Plugin Admin 统一登录入口在 iframe 和新窗口场景均可用;订阅模块不提供独立登录,V1 不提供无感 SSO。
|
||||
- [ ] 只读 Core API allowlist、分页、字段脱敏和缓存策略已冻结。
|
||||
- [ ] 同档位多实例、单独续费、用户不可取消和管理员撤销规则已确认。
|
||||
- [ ] 余额购买写操作明确等待 Core 原子接口,不使用现有多个接口拼接。
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
# TDesign 插件前端重构评估
|
||||
|
||||
## 1. 范围与结论
|
||||
|
||||
本次只重构独立插件仓库的前端,不修改官方 Sub2API Core 的 Go、Vue、迁移、鉴权或构建配置。
|
||||
|
||||
独立插件仓库当前工作目录:`/tmp/sub2api-add-repo.pzrg17`
|
||||
|
||||
官方 Core 当前工作目录:`/Users/qiu/Desktop/Sub2API`
|
||||
|
||||
建议把 TDesign starter 作为统一插件控制面的 UI 基座,而不是把 starter 合并到 Core 的 `frontend/`。插件控制面只登录一次;订阅是安装后挂载到控制面里的业务模块,不是第二个后台系统。
|
||||
|
||||
## 2. Starter 基线
|
||||
|
||||
已拉取:`/Users/qiu/Desktop/tdesign-vue-next-starter-v1`
|
||||
|
||||
```text
|
||||
仓库: https://github.com/Tencent/tdesign-vue-next-starter
|
||||
分支: develop
|
||||
提交: d6f8fafad9c1596cac8dfac8d52a88d0ca692acc
|
||||
版本: package.json 0.14.0
|
||||
```
|
||||
|
||||
已确认的技术栈:
|
||||
|
||||
- Vue 3.5、TypeScript、Vite 8、Pinia 3、Vue Router 5
|
||||
- `tdesign-vue-next` `^1.20.2`
|
||||
- `tdesign-icons-vue-next` `^0.4.4`
|
||||
- ECharts `^6.1.0`
|
||||
- Node.js `>=22.12.0`
|
||||
|
||||
Starter 的基线构建已通过:`npm ci --ignore-scripts`、`npm run build`。
|
||||
|
||||
ECharts 已在 starter 的 `src/hooks/index.ts` 中通过 `echarts/core` 初始化。starter 没有 `line-icons` 或 `lineicon` 依赖;TDesign Icons 本身是线性图标风格,后续统一使用 `tdesign-icons-vue-next`,避免混用多个图标系统。
|
||||
|
||||
## 3. 当前插件页面清单
|
||||
|
||||
### 3.1 Plugin Admin
|
||||
|
||||
入口:`plugins/plugin-admin/ui/index.html`、`plugins/plugin-admin/ui/app.js`
|
||||
|
||||
这是通用插件控制面,不是订阅业务页面。现有职责应拆成以下路由:
|
||||
|
||||
```text
|
||||
#/overview
|
||||
#/plugins
|
||||
#/plugins/:plugin_id/overview
|
||||
#/plugins/:plugin_id/revisions
|
||||
#/plugins/:plugin_id/config
|
||||
#/plugins/:plugin_id/menu
|
||||
#/plugins/:plugin_id/operations
|
||||
#/marketplace
|
||||
#/operations
|
||||
```
|
||||
|
||||
页面职责:
|
||||
|
||||
| 页面 | 主要内容 | TDesign 组件方向 |
|
||||
| --- | --- | --- |
|
||||
| 登录 | Core 管理员账号登录、2FA、会话过期 | `TForm`、`TInput`、`TButton`、`TAlert` |
|
||||
| 概览 | 已登记、运行中、待启用、需关注、最近操作 | `TCard`、`TStatistic`、`TTag`、`TTimeline` |
|
||||
| 已安装插件 | 摘要列表、生命周期主操作、查看详情 | `TTable` 或响应式 `TCard`、`TDropdown` |
|
||||
| 运行概况 | 健康、端点、兼容性、活动 revision | `TDescriptions`、`TProgress`、`TTag` |
|
||||
| 版本与升级 | revision、校验、升级包、回滚 | `TTable`、`TUpload`、`TDialog` |
|
||||
| 配置 | 服务地址、菜单地址、敏感配置提示 | `TForm`、`TInput`、`TAlert` |
|
||||
| 菜单接入 | 菜单声明、预览、应用 | `TDescriptions`、`TDialog`、`TButton` |
|
||||
| 操作历史 | 当前插件或全局审计记录、详情 | `TTable`、`TDrawer`、`TTag` |
|
||||
| 插件市场 | 受控索引、版本、兼容性、哈希、入库 | `TCard`、`TTag`、`TButton` |
|
||||
|
||||
现有接口保持不变:
|
||||
|
||||
```text
|
||||
GET /api/plugins
|
||||
POST /api/plugins/upload
|
||||
POST /api/plugins/:id/enable
|
||||
POST /api/plugins/:id/disable
|
||||
POST /api/plugins/:id/test
|
||||
POST /api/plugins/:id/ui-session
|
||||
DELETE /api/plugins/:id
|
||||
GET/PUT /api/plugins/:id/config
|
||||
GET /api/marketplace
|
||||
GET /api/audit
|
||||
```
|
||||
|
||||
### 3.2 订阅业务模块
|
||||
|
||||
入口:`plugins/subscription-admin/ui/index.html`、`plugins/subscription-admin/ui/app.js`
|
||||
|
||||
订阅管理是可选业务模块,由 Plugin Admin 安装、启用和卸载。它复用控制面的登录态、导航、CSRF 和管理员权限,不再出现第二个登录页。模块后端可以继续作为独立进程运行,但浏览器只访问控制面提供的同源模块路由。
|
||||
|
||||
```text
|
||||
#/modules/subscription/overview
|
||||
#/modules/subscription/plans
|
||||
#/modules/subscription/subscriptions
|
||||
#/modules/subscription/audit
|
||||
```
|
||||
|
||||
页面职责:
|
||||
|
||||
| 页面 | 主要内容 | TDesign 组件方向 |
|
||||
| --- | --- | --- |
|
||||
| 概览 | Core 连接状态、套餐数量、订阅数量、余额查询 | `TStatistic`、`TCard`、`TAlert` |
|
||||
| 套餐 | Core 返回的套餐目录和覆盖分组 | `TTable`、`TTag`、`TEmpty` |
|
||||
| 用户订阅 | 用户、状态、分组、服务端分页和筛选 | `TForm`、`TSelect`、`TTable`、`TPagination` |
|
||||
| 操作记录 | 插件会话和只读查询审计 | `TTable`、`TDrawer` |
|
||||
| 模块设置 | 运行模式、allowlist、凭据状态 | 插件详情的“配置/运行概况”页,不重复做模块登录 |
|
||||
|
||||
订阅模块现有 Core 代理接口、分页参数、状态枚举和只读边界不变。余额购买、续费、撤销等后续能力仍应通过模块后端 API 增量加入,不能在此次 UI 换肤时偷偷改变业务语义。
|
||||
|
||||
## 4. 推荐前端目录
|
||||
|
||||
不要复用官方 Core 的 `frontend/` 目录。建议在独立插件仓库新增一个统一 TDesign 控制面,并把订阅前端作为可挂载模块构建:
|
||||
|
||||
```text
|
||||
plugins/
|
||||
├── plugin-admin/
|
||||
│ ├── ui/ # 统一 TDesign 应用、登录、导航和模块路由
|
||||
│ └── ui-modules/ # 订阅等业务模块的 Vue/TS 源码
|
||||
└── subscription-admin/
|
||||
├── service/ # 可继续独立运行的业务后端
|
||||
└── ui-module/ # 被 plugin-admin 挂载的订阅模块,不包含登录页
|
||||
```
|
||||
|
||||
订阅模块可以独立打包和回滚,但它的浏览器入口由 Plugin Admin 统一托管。Go 的 `embed`、静态文件路径和部署脚本必须在插件仓库内同步,不与 Core 构建耦合。
|
||||
|
||||
共享但不跨 Core 的内容:
|
||||
|
||||
- TDesign 主题 token、字体、间距、移动端断点
|
||||
- 请求封装、插件会话/CSRF、错误提示
|
||||
- ECharts 按需注册和 resize composable
|
||||
- TDesign Icons 的图标命名约定
|
||||
|
||||
## 5. 不可改变的系统边界
|
||||
|
||||
```text
|
||||
浏览器
|
||||
↓ 一次登录:Plugin Admin Session Cookie + CSRF
|
||||
统一 Plugin Admin TDesign Shell
|
||||
├─ 插件管理页面
|
||||
└─ 订阅业务模块路由
|
||||
↓ 控制面同源 BFF / 内部服务调用
|
||||
独立插件 Go 服务(订阅模块)
|
||||
↓ 服务端 allowlist + Bearer Core token
|
||||
官方 Sub2API Core REST API
|
||||
```
|
||||
|
||||
- 浏览器不能拿到 Core access token、refresh token、Admin Key 或服务密钥。
|
||||
- 所有模块前端只能使用统一控制面的会话和模块 API,不能直连 Core API、PostgreSQL 或 Redis。
|
||||
- 订阅模块不得再实现 `/login`、独立 Cookie 或第二套管理员会话。
|
||||
- `custom_menu_items` 和 Core `/custom/:id` iframe 注入方式保持不变。
|
||||
- Plugin Admin 的配置 iframe `postMessage` bridge、`ui-session`、step-up 和来源校验保持不变。
|
||||
- Core 的源码仓库、版本文件、数据库迁移和前端页面不在本次改动范围内。
|
||||
|
||||
## 6. 分阶段实施
|
||||
|
||||
### 阶段 A:基座复制与边界固定
|
||||
|
||||
从 starter 复制应用骨架,替换 demo 路由、mock 数据、示例登录和品牌资源;保留 TDesign Layout、主题切换、Pinia、Vue Router、ECharts 基础能力。先让统一控制面独立构建,并定义模块注册契约。
|
||||
|
||||
### 阶段 B:Plugin Admin
|
||||
|
||||
先完成登录和应用壳,再按“概览 → 已安装 → 详情页签 → 市场 → 审计”迁移。所有 mutation 仍由现有 Go endpoint 执行,前端只负责表单、状态和操作确认。
|
||||
|
||||
### 阶段 C:订阅业务模块
|
||||
|
||||
订阅 UI 作为控制面的一个模块挂载,复用同一套会话、导航、视觉 token 和错误处理。先迁移概览、套餐、订阅列表和操作记录,服务端分页/筛选参数保持原样。订阅后端是否独立进程不影响前端只有一次登录。
|
||||
|
||||
### 阶段 D:图表与响应式
|
||||
|
||||
只在概览和需要趋势的页面引入 ECharts;图表容器使用固定最小高度、`resize` observer 和按需导入,避免页面被超长 canvas 撑开。列表在移动端只允许表格容器横向滚动,页面主体不得横向溢出。
|
||||
|
||||
### 阶段 E:接入验收
|
||||
|
||||
验证统一控制面一次登录后进入插件管理和订阅模块、Core 菜单 iframe、会话过期、CSRF、管理员权限、插件启停、配置 bridge、市场入库和审计链路。确认官方 Core 工作区没有任何变更。
|
||||
|
||||
## 7. 主要风险与处理
|
||||
|
||||
1. **starter 自带 mock/示例权限**:全部删除,改为 Plugin Admin 会话和管理员权限;不把 starter 的演示用户带入生产。
|
||||
2. **旧 UI 是原生 HTML/JS**:不要强行在同一页面混用原生模板和 TDesign;按应用整体迁移,减少样式优先级冲突。
|
||||
3. **配置 iframe bridge**:只能替换外层视觉,消息名称、token、来源校验、超时和 step-up 语义不变。
|
||||
4. **表格移动端**:使用固定列/可滚动列的明确容器,禁止给 `body` 或整个页面设置横向滚动。
|
||||
5. **图标包选择**:统一 `tdesign-icons-vue-next`;它提供线性图标,不再额外引入未知的 `line-icons` 包。
|
||||
6. **模块登录分裂**:订阅模块不得复制登录页、Cookie 或权限判断;模块调用统一控制面 BFF,由控制面把管理员身份传给订阅服务。
|
||||
7. **Core 更新兼容性**:插件只依赖已声明的 HTTP allowlist 和响应 DTO,Core 更新时只做 API 契约兼容检查。
|
||||
|
||||
## 8. 第一版验收标准
|
||||
|
||||
- 官方 Core `/Users/qiu/Desktop/Sub2API` 保持干净,版本和源码不被修改。
|
||||
- 统一 Plugin Admin 前端能独立 `npm run build`,不依赖 Core 的 Vite 配置;订阅模块以模块产物或受控动态入口挂载。
|
||||
- 管理员只登录一次即可访问插件控制面和已启用订阅模块;直接刷新模块路由仍保持同一插件会话,不把凭据写入 URL。
|
||||
- 425px、768px、1440px 下无页面级横向溢出;表格需要横向查看时只滚动表格容器。
|
||||
- 所有管理员 mutation 仍经过插件后端的会话、CSRF、step-up 和幂等校验。
|
||||
- DOM、网络响应、日志和 URL 不出现 Core token、Admin Key、密码或服务密钥。
|
||||
- ECharts 图表只展示有数据的系列,容器尺寸稳定,窗口变化后可重绘。
|
||||
|
||||
## 9. 当前状态
|
||||
|
||||
已完成:
|
||||
|
||||
- 拉取官方 TDesign Vue Next starter。
|
||||
- 固定 starter 基线和依赖版本。
|
||||
- 完成 starter 基线构建检查。
|
||||
- 完成独立插件页面、API、组件和迁移边界评估。
|
||||
- 根据反馈修正页面边界:订阅从“独立后台”改为统一插件控制面内的业务模块,不再单独登录。
|
||||
|
||||
已完成第一版落地:
|
||||
|
||||
- `plugins/plugin-admin/ui-vue` 基于 TDesign Vue Next starter 建立独立 Vue 3/Vite 应用。
|
||||
- Plugin Admin 登录、统一 Shell、概览、插件列表/详情、市场和审计页面已迁移,图标统一使用 `tdesign-icons-vue-next`,概览趋势图使用 ECharts。
|
||||
- 订阅作为 Shell 内的 `/modules/subscription/*` 业务模块挂载,不再渲染第二个登录页;控制面新增同源、allowlist 约束的订阅只读 BFF。
|
||||
- Go 静态资源支持挂载路径、SPA 回退和路径穿越拒绝;`build-ui.sh` 负责可重复构建并复制嵌入产物。
|
||||
- 已完成 TypeScript/Vite、Go 单元、API 契约和 425/900/1440 多视口浏览器验收;下一轮可继续补充真实业务写操作,但不改变 Core 边界。
|
||||
@@ -2,12 +2,17 @@ CORE_BASE_URL=http://127.0.0.1:8080
|
||||
PLUGIN_ENV=production
|
||||
PLUGIN_HOST=127.0.0.1
|
||||
PLUGIN_PORT=8090
|
||||
PLUGIN_REGISTRY_DIR=/var/lib/sub2api/plugin-admin
|
||||
PLUGIN_REGISTRY_DIR=/var/lib/sub2api-add/plugin-admin
|
||||
PLUGIN_PUBLIC_BASE_PATH=/extensions/qiu.plugin-admin
|
||||
# Externally reachable Plugin Admin base URL (without /admin). Required when
|
||||
# applying the subscription module menu so Core always opens the shared Shell.
|
||||
PLUGIN_PUBLIC_URL=https://CORE_ORIGIN/extensions/qiu.plugin-admin
|
||||
PLUGIN_COOKIE_PATH=/extensions/qiu.plugin-admin/
|
||||
PLUGIN_COOKIE_SECURE=false
|
||||
PLUGIN_COOKIE_SAMESITE=lax
|
||||
PLUGIN_FRAME_ANCESTORS='self'
|
||||
# Accept X-Forwarded-For only when the immediate proxy is loopback/trusted.
|
||||
PLUGIN_TRUST_PROXY=false
|
||||
PLUGIN_ALLOW_UNSIGNED=false
|
||||
PLUGIN_CONFIG_KEY=generate-and-replace-with-a-random-32-byte-secret
|
||||
# JSON object: {"publisher-key-id":"BASE64_ED25519_PUBLIC_KEY"}
|
||||
@@ -16,3 +21,5 @@ PLUGIN_TRUSTED_PUBLISHERS={}
|
||||
# and list its exact host (including port when non-standard) below.
|
||||
PLUGIN_MARKETPLACE_INDEX=/var/lib/sub2api-add/plugin-admin/marketplace/index.json
|
||||
PLUGIN_MARKETPLACE_ALLOWED_HOSTS=
|
||||
# Required for a remote production catalog; pin the exact catalog bytes.
|
||||
PLUGIN_MARKETPLACE_INDEX_SHA256=
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: test build check browser-check
|
||||
.PHONY: test build ui-build check browser-check
|
||||
|
||||
test:
|
||||
go test ./... -count=1
|
||||
@@ -7,7 +7,10 @@ build:
|
||||
mkdir -p bin
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o bin/plugin-admin .
|
||||
|
||||
check: test
|
||||
ui-build:
|
||||
./build-ui.sh
|
||||
|
||||
check: test ui-build
|
||||
node --check ui/app.js
|
||||
git diff --check
|
||||
|
||||
|
||||
@@ -31,17 +31,69 @@ key). `PLUGIN_CONFIG_KEY` is required in every environment; use a randomly
|
||||
generated secret in production and keep it stable across restarts so encrypted
|
||||
plugin configuration remains decryptable.
|
||||
|
||||
When the optional subscription module is enabled, set `PLUGIN_PUBLIC_URL` to
|
||||
the externally reachable Plugin Admin base URL (without `/admin`), for example
|
||||
`https://CORE_ORIGIN/extensions/qiu.plugin-admin`. Menu apply then always emits
|
||||
`/admin/#/modules/subscription/overview`; a subscription service URL is never
|
||||
used as a browser entrypoint.
|
||||
|
||||
Open `/admin/` directly or expose the service through the reverse proxy in
|
||||
`deploy/`. The first login is the existing Core administrator login; no plugin
|
||||
user table is created. The control plane stores only a short-lived server-side
|
||||
session and encrypted plugin configuration.
|
||||
|
||||
The administrator UI is organized as separate hash-routed pages: overview,
|
||||
installed plugins, marketplace, and operations. Each plugin has its own detail
|
||||
route with secondary tabs for health, revisions, configuration, menu
|
||||
integration, and history. See
|
||||
[`../../docs/PLUGIN_ADMIN_UI_INFORMATION_ARCHITECTURE.md`](../../docs/PLUGIN_ADMIN_UI_INFORMATION_ARCHITECTURE.md)
|
||||
for the page responsibilities and responsive layout contract.
|
||||
|
||||
### TDesign UI build
|
||||
|
||||
The browser shell is a standalone Vue 3 application based on the Tencent
|
||||
TDesign Vue Next starter. It is built outside the Core repository and then
|
||||
embedded by this Go service:
|
||||
|
||||
```sh
|
||||
./build-ui.sh
|
||||
```
|
||||
|
||||
The command runs `npm ci`, type-checks the application, bundles TDesign Icons
|
||||
and ECharts locally (the service CSP does not allow a CDN), and copies the
|
||||
three runtime files into `ui/`. The generated shell keeps the existing
|
||||
`/login`, `/api/me`, CSRF and lifecycle contracts; no Core source or frontend
|
||||
build configuration is involved.
|
||||
|
||||
### 会话与令牌生命周期
|
||||
|
||||
插件进程是常驻服务,不会因为一次登录、一次请求或一次令牌刷新而重启。
|
||||
Core 的 access token 过期时,插件后端在当前请求链路中使用对应的 refresh
|
||||
token 刷新一次,并继续完成请求;刷新后的 token 仍只保存在插件服务端。
|
||||
浏览器始终只持有插件的 HttpOnly 会话 Cookie 和 CSRF token。
|
||||
|
||||
默认会话策略如下:
|
||||
|
||||
| 情况 | 行为 |
|
||||
| --- | --- |
|
||||
| 持续使用 | 每次请求滑动续期,通常无需重新登录 |
|
||||
| 空闲超过 30 分钟 | 插件会话失效,下一次访问回到登录页 |
|
||||
| 会话达到 8 小时 | 绝对过期,需要重新登录 |
|
||||
| Core access token 过期 | 后端透明 refresh,不重启插件 |
|
||||
| refresh token 被撤销/失效 | 清除插件会话并要求重新登录 |
|
||||
| 插件进程重启 | V1 内存会话清空,需要重新登录一次;已登记的插件由控制面按 registry 恢复 |
|
||||
|
||||
因此日常使用不需要“用完就重启”。生产多实例若需要跨实例或跨重启保留
|
||||
会话,应接入插件自己的加密 Redis/会话存储,并使用稳定的密钥;不要把
|
||||
Core token 写入浏览器、Core 数据库或 URL。
|
||||
|
||||
## Control-plane endpoints
|
||||
|
||||
```text
|
||||
GET /healthz
|
||||
GET /readyz
|
||||
POST /login POST /login/2fa POST /logout
|
||||
GET /api/captcha-config
|
||||
GET /api/marketplace POST /api/marketplace/install (JSON: plugin_id, version)
|
||||
GET /api/me GET /api/plugins GET /api/plugins/{id}
|
||||
POST /api/plugins/install (multipart field: package)
|
||||
@@ -53,6 +105,11 @@ GET|PUT /api/plugins/{id}/config
|
||||
POST /api/plugins/{id}/menu-preview|menu-apply
|
||||
POST /api/menu-items/preview|apply (JSON: {"plugin_id":"..."})
|
||||
GET /api/audit
|
||||
GET /api/subscription/status
|
||||
GET /api/subscription/audit
|
||||
GET /api/subscription/plans
|
||||
GET /api/subscription/subscriptions[/{id}]
|
||||
GET /api/subscription/users/{id}[/subscriptions]
|
||||
```
|
||||
|
||||
Every mutation requires the plugin CSRF token and an `Idempotency-Key`. A
|
||||
@@ -65,6 +122,13 @@ installs its runtime/menu. Failed installation and upgrade never replace the
|
||||
active revision. Delete/uninstall is allowed only after disable and removes
|
||||
plugin files, not Core data.
|
||||
|
||||
The login page reads only public CAPTCHA fields from `/api/captcha-config`.
|
||||
When Core enables Turnstile, Tencent Captcha, or Aliyun Captcha, the matching
|
||||
challenge is rendered in this page and its one-time proof is forwarded
|
||||
server-side to Core. Provider secrets are never returned. Keep the provider
|
||||
origins in the example CSP when CAPTCHA is enabled; changing the Core setting
|
||||
is picked up on the next login-page load and does not require a plugin restart.
|
||||
|
||||
## Marketplace catalog
|
||||
|
||||
The marketplace is server-side only. The browser receives metadata and sends a
|
||||
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
cd "$ROOT/ui-vue"
|
||||
|
||||
# Keep the lockfile authoritative in CI and never install dependencies into
|
||||
# the embedded Go source tree.
|
||||
npm ci --ignore-scripts
|
||||
npm run build
|
||||
cp "$ROOT/ui-build/index.html" "$ROOT/ui-build/app.js" "$ROOT/ui-build/styles.css" "$ROOT/ui/"
|
||||
printf '%s\n' "TDesign UI built into $ROOT/ui"
|
||||
@@ -2,7 +2,7 @@
|
||||
"schema_version": 1,
|
||||
"plugin_id": "qiu.plugin-admin",
|
||||
"name": "Business Plugin Control Plane",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"core_api_baseline": "sub2api-0.1.183",
|
||||
"tested_core_versions": ["0.1.183"],
|
||||
"capabilities": ["plugin.admin.v1"],
|
||||
@@ -28,6 +28,11 @@
|
||||
"POST /api/v1/auth/logout",
|
||||
"GET /api/v1/auth/me",
|
||||
"GET /api/v1/settings/public",
|
||||
"GET /api/v1/admin/settings"
|
||||
"GET /api/v1/admin/settings",
|
||||
"GET /api/v1/admin/payment/plans",
|
||||
"GET /api/v1/admin/subscriptions",
|
||||
"GET /api/v1/admin/subscriptions/{id}",
|
||||
"GET /api/v1/admin/users/{id}",
|
||||
"GET /api/v1/admin/users/{id}/subscriptions"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,16 +7,29 @@ Wants=network-online.target
|
||||
Type=simple
|
||||
User=sub2api-plugin
|
||||
Group=sub2api-plugin
|
||||
WorkingDirectory=/opt/sub2api/plugin-admin
|
||||
EnvironmentFile=/etc/sub2api/plugin-admin.env
|
||||
ExecStart=/opt/sub2api/plugin-admin/bin/plugin-admin
|
||||
WorkingDirectory=/var/lib/sub2api-add/plugin-admin
|
||||
EnvironmentFile=/etc/sub2api-add/plugin-admin.env
|
||||
ExecStart=/opt/sub2api-add/plugin-admin/bin/plugin-admin
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/sub2api/plugin-admin
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelLogs=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
CapabilityBoundingSet=
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
TasksMax=128
|
||||
MemoryMax=512M
|
||||
CPUQuota=200%
|
||||
ReadWritePaths=/var/lib/sub2api-add/plugin-admin
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
+892
-103
File diff suppressed because it is too large
Load Diff
@@ -195,6 +195,123 @@ func TestAdminLoginDoesNotExposeCoreTokens(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptchaConfigReturnsOnlyPublicFields(t *testing.T) {
|
||||
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/settings/public" {
|
||||
t.Fatalf("unexpected Core path: %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{"turnstile_enabled":true,"turnstile_site_key":"site-key","tencent_captcha_enabled":false,"aliyun_captcha_enabled":false,"turnstile_secret_key":"must-not-leak","admin_password":"must-not-leak"}}`)
|
||||
}))
|
||||
defer coreServer.Close()
|
||||
reg, err := openRegistry(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := newApp(core, reg, t.TempDir())
|
||||
recorder := httptest.NewRecorder()
|
||||
a.routes().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/captcha-config", nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
body := recorder.Body.String()
|
||||
if !strings.Contains(body, `"turnstile_enabled":true`) || !strings.Contains(body, `"turnstile_site_key":"site-key"`) {
|
||||
t.Fatalf("public captcha fields missing: %s", body)
|
||||
}
|
||||
if strings.Contains(body, "must-not-leak") || strings.Contains(body, "secret_key") || strings.Contains(body, "admin_password") {
|
||||
t.Fatalf("sensitive Core settings leaked: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginForwardsCaptchaProof(t *testing.T) {
|
||||
var received struct {
|
||||
TurnstileToken string `json:"turnstile_token"`
|
||||
}
|
||||
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/auth/login":
|
||||
if err := json.NewDecoder(r.Body).Decode(&received); err != nil {
|
||||
t.Fatalf("decode login body: %v", err)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{"access_token":"A","refresh_token":"R"}}`)
|
||||
case "/api/v1/auth/me":
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
|
||||
default:
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
|
||||
}
|
||||
}))
|
||||
defer coreServer.Close()
|
||||
reg, _ := openRegistry(t.TempDir())
|
||||
a := newApp(core, reg, t.TempDir())
|
||||
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"email":"admin@example.com","password":"password","turnstile_token":"proof-token"}`))
|
||||
rec := httptest.NewRecorder()
|
||||
a.login(rec, req)
|
||||
if rec.Code != http.StatusOK || received.TurnstileToken != "proof-token" {
|
||||
t.Fatalf("proof was not forwarded: status=%d body=%s received=%#v", rec.Code, rec.Body.String(), received)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRateLimitBoundsCoreAttempts(t *testing.T) {
|
||||
var loginCalls int
|
||||
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/auth/login":
|
||||
loginCalls++
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{"access_token":"A","refresh_token":"R"}}`)
|
||||
case "/api/v1/auth/me":
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
|
||||
default:
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
|
||||
}
|
||||
}))
|
||||
defer coreServer.Close()
|
||||
reg, err := openRegistry(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := newApp(core, reg, t.TempDir())
|
||||
for attempt := 0; attempt < loginLimit+1; attempt++ {
|
||||
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"email":"admin@example.com","password":"password"}`))
|
||||
rec := httptest.NewRecorder()
|
||||
a.login(rec, req)
|
||||
if attempt < loginLimit && rec.Code != http.StatusOK {
|
||||
t.Fatalf("attempt %d status=%d body=%s", attempt+1, rec.Code, rec.Body.String())
|
||||
}
|
||||
if attempt == loginLimit && rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("limit status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if loginCalls != loginLimit {
|
||||
t.Fatalf("Core received %d login calls, want %d", loginCalls, loginLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingLoginCapAndExpiry(t *testing.T) {
|
||||
now := time.Now()
|
||||
a := newApp(nil, nil, t.TempDir())
|
||||
a.clock = func() time.Time { return now }
|
||||
for i := 0; i < maxPendingLogins; i++ {
|
||||
if _, ok := a.addPendingLogin(pendingLogin{TempToken: "TEMP", Expires: now.Add(time.Minute)}); !ok {
|
||||
t.Fatalf("pending challenge %d was unexpectedly rejected", i)
|
||||
}
|
||||
}
|
||||
if _, ok := a.addPendingLogin(pendingLogin{TempToken: "OVERFLOW", Expires: now.Add(time.Minute)}); ok {
|
||||
t.Fatal("pending challenge cap was not enforced")
|
||||
}
|
||||
if got := len(a.pending); got != maxPendingLogins {
|
||||
t.Fatalf("pending map size=%d want %d", got, maxPendingLogins)
|
||||
}
|
||||
a.clock = func() time.Time { return now.Add(pendingLoginTTL + time.Second) }
|
||||
if _, ok := a.addPendingLogin(pendingLogin{TempToken: "AFTER-EXPIRY", Expires: now.Add(2 * pendingLoginTTL)}); !ok {
|
||||
t.Fatal("expired pending challenges were not evicted")
|
||||
}
|
||||
if got := len(a.pending); got != 1 {
|
||||
t.Fatalf("pending map size after expiry=%d want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeJSONRejectsTrailingValuesAndOversizeBodies(t *testing.T) {
|
||||
var input struct {
|
||||
Name string `json:"name"`
|
||||
@@ -420,6 +537,43 @@ func TestRemoteMarketplaceRequiresAllowlistAndExpiry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketplaceVersionComparisonRejectsDowngrades(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
left, right string
|
||||
want int
|
||||
}{
|
||||
{"1.2.0", "1.1.9", 1},
|
||||
{"1.0.0", "1.0.0", 0},
|
||||
{"1.0.0-beta.2", "1.0.0-beta.10", -1},
|
||||
{"1.0.0", "1.0.0-rc.1", 1},
|
||||
} {
|
||||
if got := compareMarketplaceVersions(test.left, test.right); got != test.want {
|
||||
t.Fatalf("compare(%q,%q)=%d want %d", test.left, test.right, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketplaceIndexDigestPin(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "index.json")
|
||||
raw := []byte(`{"schema_version":1,"entries":[]}`)
|
||||
if err := os.WriteFile(path, raw, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service, err := newMarketplaceService(path, "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.indexSHA256 = sha256Hex(raw)
|
||||
if _, _, err := service.loadIndex(context.Background()); err != nil {
|
||||
t.Fatalf("matching digest rejected: %v", err)
|
||||
}
|
||||
service.indexSHA256 = strings.Repeat("0", 64)
|
||||
if _, _, err := service.loadIndex(context.Background()); err == nil {
|
||||
t.Fatal("mismatched digest was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionPackageRequiresTrustedSignature(t *testing.T) {
|
||||
t.Setenv("CORE_VERSION", "0.1.183")
|
||||
reg, _ := openRegistry(t.TempDir())
|
||||
@@ -587,6 +741,7 @@ func TestIdempotencyKeyReplaysSameBodyAndRetainsFailedOperation(t *testing.T) {
|
||||
|
||||
func TestRefreshRevalidatesAdminRole(t *testing.T) {
|
||||
var meCalls int
|
||||
var logoutTokens []string
|
||||
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
@@ -601,6 +756,9 @@ func TestRefreshRevalidatesAdminRole(t *testing.T) {
|
||||
case "/api/v1/auth/refresh":
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{"access_token":"NEW","refresh_token":"NEW-R"}}`)
|
||||
case "/api/v1/auth/logout":
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
logoutTokens = append(logoutTokens, body["refresh_token"])
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
|
||||
default:
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
|
||||
@@ -619,6 +777,16 @@ func TestRefreshRevalidatesAdminRole(t *testing.T) {
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected demoted user rejection: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
foundRotated := false
|
||||
for _, value := range logoutTokens {
|
||||
if value == "NEW-R" {
|
||||
foundRotated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundRotated {
|
||||
t.Fatalf("rotated refresh token was not revoked: %#v", logoutTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthProbeRejectsRedirectAndRequiresReadiness(t *testing.T) {
|
||||
@@ -647,9 +815,209 @@ func TestRoutesSetSecurityHeadersAndProtectAPI(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.StatusCode != http.StatusUnauthorized || response.Header.Get("Content-Security-Policy") == "" || response.Header.Get("X-Content-Type-Options") != "nosniff" {
|
||||
csp := response.Header.Get("Content-Security-Policy")
|
||||
if response.StatusCode != http.StatusUnauthorized || csp == "" || response.Header.Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Fatalf("status=%d headers=%v", response.StatusCode, response.Header)
|
||||
}
|
||||
for _, origin := range []string{"https://challenges.cloudflare.com", "https://turing.captcha.qcloud.com", "https://o.alicdn.com"} {
|
||||
if !strings.Contains(csp, origin) {
|
||||
t.Fatalf("captcha origin %q missing from CSP: %s", origin, csp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func staticTestApp(t *testing.T) *app {
|
||||
t.Helper()
|
||||
reg, err := openRegistry(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return newApp(nil, reg, t.TempDir())
|
||||
}
|
||||
|
||||
func TestStaticServesAssetsWithMIME(t *testing.T) {
|
||||
a := staticTestApp(t)
|
||||
for path, wantType := range map[string]string{"/admin/app.js": "text/javascript", "/admin/styles.css": "text/css"} {
|
||||
recorder := httptest.NewRecorder()
|
||||
a.static(recorder, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if recorder.Code != http.StatusOK || !strings.HasPrefix(recorder.Header().Get("Content-Type"), wantType) {
|
||||
t.Fatalf("path=%s status=%d content-type=%q", path, recorder.Code, recorder.Header().Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticRejectsPathTraversal(t *testing.T) {
|
||||
a := staticTestApp(t)
|
||||
recorder := httptest.NewRecorder()
|
||||
a.static(recorder, httptest.NewRequest(http.MethodGet, "/admin/%2e%2e/main.go", nil))
|
||||
if recorder.Code != http.StatusNotFound || strings.Contains(recorder.Body.String(), "package main") {
|
||||
t.Fatalf("expected traversal to be rejected: status=%d body=%q", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticReplacesHTMLBasePlaceholderAndSupportsMountedSPA(t *testing.T) {
|
||||
a := staticTestApp(t)
|
||||
a.publicBasePath = "/console"
|
||||
for _, path := range []string{"/console/admin/", "/console/admin/plugins/example.plugin/config"} {
|
||||
recorder := httptest.NewRecorder()
|
||||
a.static(recorder, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if recorder.Code != http.StatusOK || !strings.HasPrefix(recorder.Header().Get("Content-Type"), "text/html") {
|
||||
t.Fatalf("path=%s status=%d content-type=%q", path, recorder.Code, recorder.Header().Get("Content-Type"))
|
||||
}
|
||||
body := recorder.Body.String()
|
||||
if strings.Contains(body, "__PLUGIN_BASE_PATH_JSON__") || !strings.Contains(body, `meta name="plugin-base-path" content="/console"`) || strings.Contains(body, "window.__PLUGIN_BASE_PATH__ =") {
|
||||
t.Fatalf("path=%s base placeholder was not replaced: %q", path, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriptionProxySharesControlPlaneSessionAndRedactsCoreSecrets(t *testing.T) {
|
||||
var gotAuthorization, gotPath string
|
||||
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/auth/me":
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin","email":"admin@example.com"}}`)
|
||||
case "/api/v1/admin/subscriptions":
|
||||
gotAuthorization = r.Header.Get("Authorization")
|
||||
gotPath = r.URL.RequestURI()
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{"access_token":"CORE_LEAK","items":[{"id":1,"refresh_token":"REFRESH_LEAK","status":"active"}]}}`)
|
||||
default:
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
|
||||
}
|
||||
}))
|
||||
defer coreServer.Close()
|
||||
reg, err := openRegistry(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := newApp(core, reg, t.TempDir())
|
||||
reg.data.Plugins[subscriptionPluginID] = pluginRecord{Manifest: manifest.Manifest{PluginID: subscriptionPluginID, Name: "Subscription", Version: "0.2.0"}, State: "healthy", ActiveRevision: "rev-1"}
|
||||
cookie := adminSession(a)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/subscription/subscriptions?page=1&evil=ignored", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
a.subscriptionProxy(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected unknown query to be rejected, status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/subscription/subscriptions?page=1&page_size=20", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
a.subscriptionProxy(rec, req)
|
||||
if rec.Code != http.StatusOK || gotAuthorization != "Bearer ACCESS" || gotPath != "/api/v1/admin/subscriptions?page=1&page_size=20" {
|
||||
t.Fatalf("status=%d auth=%q path=%q body=%s", rec.Code, gotAuthorization, gotPath, rec.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "CORE_LEAK") || strings.Contains(rec.Body.String(), "REFRESH_LEAK") {
|
||||
t.Fatalf("Core secret leaked in module response: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriptionStatusDeclaresSharedSession(t *testing.T) {
|
||||
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
|
||||
}))
|
||||
defer coreServer.Close()
|
||||
reg, err := openRegistry(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := newApp(core, reg, t.TempDir())
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/subscription/status", nil)
|
||||
req.AddCookie(adminSession(a))
|
||||
rec := httptest.NewRecorder()
|
||||
a.subscriptionStatus(rec, req)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"session_mode":"shared"`) || strings.Contains(rec.Body.String(), "access_token") {
|
||||
t.Fatalf("unexpected status response: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriptionProxyRequiresHealthyModule(t *testing.T) {
|
||||
var coreReads int
|
||||
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.URL.Path == "/api/v1/auth/me" {
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/api/v1/admin/subscriptions" {
|
||||
coreReads++
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{"items":[]}}`)
|
||||
}))
|
||||
defer coreServer.Close()
|
||||
reg, _ := openRegistry(t.TempDir())
|
||||
a := newApp(core, reg, t.TempDir())
|
||||
cookie := adminSession(a)
|
||||
request := func(endpoint string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, endpoint, nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := httptest.NewRecorder()
|
||||
if endpoint == "/api/subscription/audit" {
|
||||
a.subscriptionAudit(rec, req)
|
||||
} else {
|
||||
a.subscriptionProxy(rec, req)
|
||||
}
|
||||
return rec
|
||||
}
|
||||
endpoints := []string{
|
||||
"/api/subscription/plans",
|
||||
"/api/subscription/subscriptions",
|
||||
"/api/subscription/subscriptions/1",
|
||||
"/api/subscription/users/1",
|
||||
"/api/subscription/users/1/subscriptions",
|
||||
"/api/subscription/audit",
|
||||
}
|
||||
for _, endpoint := range endpoints {
|
||||
if rec := request(endpoint); rec.Code != http.StatusNotFound || coreReads != 0 {
|
||||
t.Fatalf("uninstalled module endpoint=%s leaked data: status=%d reads=%d body=%s", endpoint, rec.Code, coreReads, rec.Body.String())
|
||||
}
|
||||
}
|
||||
reg.data.Plugins[subscriptionPluginID] = pluginRecord{Manifest: manifest.Manifest{PluginID: subscriptionPluginID, Version: "0.2.0"}, State: "disabled"}
|
||||
for _, endpoint := range endpoints {
|
||||
if rec := request(endpoint); rec.Code != http.StatusNotFound || coreReads != 0 {
|
||||
t.Fatalf("stopped module endpoint=%s leaked data: status=%d reads=%d", endpoint, rec.Code, coreReads)
|
||||
}
|
||||
}
|
||||
reg.data.Plugins[subscriptionPluginID] = pluginRecord{Manifest: manifest.Manifest{PluginID: subscriptionPluginID, Version: "0.2.0"}, State: "healthy"}
|
||||
if rec := request("/api/subscription/subscriptions"); rec.Code != http.StatusOK || coreReads != 1 {
|
||||
t.Fatalf("healthy module was not proxied: status=%d reads=%d body=%s", rec.Code, coreReads, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriptionMenuAlwaysTargetsUnifiedShell(t *testing.T) {
|
||||
var applied []byte
|
||||
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/auth/me":
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
|
||||
case "/api/v1/admin/settings":
|
||||
if r.Method == http.MethodPut {
|
||||
applied, _ = io.ReadAll(r.Body)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{"custom_menu_items":[]}}`)
|
||||
default:
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{}}`)
|
||||
}
|
||||
}))
|
||||
defer coreServer.Close()
|
||||
reg, _ := openRegistry(t.TempDir())
|
||||
a := newApp(core, reg, t.TempDir())
|
||||
a.publicURL = "https://console.example.test/extensions/qiu.plugin-admin"
|
||||
reg.data.Plugins[subscriptionPluginID] = pluginRecord{Manifest: manifest.Manifest{PluginID: subscriptionPluginID, Name: "Subscription", Version: "0.2.0", UI: manifest.UI{Menu: manifest.Menu{ID: subscriptionPluginID, Label: "订阅管理", Visibility: "admin", SortOrder: 200, URL: "https://wrong.example.test/login"}}}, State: "healthy", ActiveRevision: "rev-1"}
|
||||
cookie := adminSession(a)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/plugins/"+subscriptionPluginID+"/menu-apply", nil)
|
||||
req.AddCookie(cookie)
|
||||
req.Header.Set("X-CSRF-Token", "CSRF")
|
||||
req.Header.Set("Idempotency-Key", "subscription-menu-unified")
|
||||
rec := httptest.NewRecorder()
|
||||
a.menu(rec, req, true)
|
||||
want := "https://console.example.test/extensions/qiu.plugin-admin/admin/#/modules/subscription/overview"
|
||||
if rec.Code != http.StatusAccepted || !strings.Contains(string(applied), want) || strings.Contains(string(applied), "wrong.example.test") {
|
||||
t.Fatalf("subscription menu was not normalized: status=%d body=%s applied=%s", rec.Code, rec.Body.String(), applied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMenuPreviewAndApplyPreserveOtherMenuItems(t *testing.T) {
|
||||
@@ -682,7 +1050,7 @@ func TestMenuPreviewAndApplyPreserveOtherMenuItems(t *testing.T) {
|
||||
preview.Header.Set("Idempotency-Key", "menu-preview-1")
|
||||
previewRec := httptest.NewRecorder()
|
||||
a.menu(previewRec, preview, false)
|
||||
if previewRec.Code != http.StatusOK || !strings.Contains(previewRec.Body.String(), "core.home") || !strings.Contains(previewRec.Body.String(), "example.plugin") {
|
||||
if previewRec.Code != http.StatusOK || !strings.Contains(previewRec.Body.String(), "core.home") || !strings.Contains(previewRec.Body.String(), "example-plugin") {
|
||||
t.Fatalf("unexpected menu preview: %d %s", previewRec.Code, previewRec.Body.String())
|
||||
}
|
||||
global := httptest.NewRequest(http.MethodPost, "/api/menu-items/preview", strings.NewReader(`{"plugin_id":"example.plugin"}`))
|
||||
@@ -691,7 +1059,7 @@ func TestMenuPreviewAndApplyPreserveOtherMenuItems(t *testing.T) {
|
||||
global.Header.Set("Idempotency-Key", "global-menu-preview-1")
|
||||
globalRec := httptest.NewRecorder()
|
||||
a.menuGlobal(globalRec, global, false)
|
||||
if globalRec.Code != http.StatusOK || !strings.Contains(globalRec.Body.String(), "example.plugin") {
|
||||
if globalRec.Code != http.StatusOK || !strings.Contains(globalRec.Body.String(), "example-plugin") {
|
||||
t.Fatalf("unexpected global menu preview: %d %s", globalRec.Code, globalRec.Body.String())
|
||||
}
|
||||
apply := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/menu-apply", nil)
|
||||
@@ -700,11 +1068,28 @@ func TestMenuPreviewAndApplyPreserveOtherMenuItems(t *testing.T) {
|
||||
apply.Header.Set("Idempotency-Key", "menu-apply-1")
|
||||
applyRec := httptest.NewRecorder()
|
||||
a.menu(applyRec, apply, true)
|
||||
if applyRec.Code != http.StatusAccepted || len(applied) == 0 || !strings.Contains(string(applied), "core.home") || !strings.Contains(string(applied), "example.plugin") {
|
||||
if applyRec.Code != http.StatusAccepted || len(applied) == 0 || !strings.Contains(string(applied), "core.home") || !strings.Contains(string(applied), "example-plugin") || strings.Contains(string(applied), "example.plugin") {
|
||||
t.Fatalf("unexpected menu apply: %d body=%s request=%s", applyRec.Code, applyRec.Body.String(), applied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreMenuIDNormalizesPluginNamespaceAndBoundsLength(t *testing.T) {
|
||||
if got, err := coreMenuID("qiu.subscription-admin"); err != nil || got != "qiu-subscription-admin" {
|
||||
t.Fatalf("namespace ID normalization: got=%q err=%v", got, err)
|
||||
}
|
||||
if got, err := coreMenuID("already_valid"); err != nil || got != "already_valid" {
|
||||
t.Fatalf("valid ID changed: got=%q err=%v", got, err)
|
||||
}
|
||||
long := strings.Repeat("plugin.", 12)
|
||||
got, err := coreMenuID(long)
|
||||
if err != nil || len(got) > 32 || !coreMenuIDPattern.MatchString(got) {
|
||||
t.Fatalf("long ID normalization: got=%q len=%d err=%v", got, len(got), err)
|
||||
}
|
||||
if _, err := coreMenuID("..."); err == nil {
|
||||
t.Fatal("expected an all-separator ID to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedUpgradeKeepsActiveRevision(t *testing.T) {
|
||||
t.Setenv("CORE_VERSION", "0.1.183")
|
||||
pluginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -790,6 +1175,51 @@ func TestFailedUpgradeKeepsActiveRevision(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackHonorsExplicitRevisionSelection(t *testing.T) {
|
||||
t.Setenv("CORE_VERSION", "0.1.183")
|
||||
pluginServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/healthz" || r.URL.Path == "/readyz" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"status":"ok","version":"0.8.0"}`)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer pluginServer.Close()
|
||||
core, coreServer := testCore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{"id":1,"role":"admin"}}`)
|
||||
}))
|
||||
defer coreServer.Close()
|
||||
reg, _ := openRegistry(t.TempDir())
|
||||
a := newApp(core, reg, t.TempDir())
|
||||
base := manifest.Manifest{PluginID: "example.plugin", Name: "Example", Version: "1.0.0", CoreAPIBaseline: "sub2api-0.1.183", TestedCoreVersions: []string{"0.1.183"}, Backend: manifest.Backend{HealthPath: "/healthz", ReadinessPath: "/readyz"}}
|
||||
oldManifest, middleManifest := base, base
|
||||
oldManifest.Version, middleManifest.Version = "0.8.0", "0.9.0"
|
||||
p := pluginRecord{Manifest: base, State: "disabled", ActiveRevision: "rev-new", Endpoint: pluginServer.URL, Revisions: []revision{
|
||||
{ID: "rev-old", Version: "0.8.0", Manifest: oldManifest},
|
||||
{ID: "rev-middle", Version: "0.9.0", Manifest: middleManifest},
|
||||
{ID: "rev-new", Version: "1.0.0", Manifest: base},
|
||||
}}
|
||||
reg.data.Plugins[p.Manifest.PluginID] = p
|
||||
cookie := adminSession(a)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/plugins/example.plugin/rollback", strings.NewReader(`{"revision":"rev-old"}`))
|
||||
req.AddCookie(cookie)
|
||||
req.Header.Set("X-CSRF-Token", "CSRF")
|
||||
req.Header.Set("Idempotency-Key", "rollback-explicit-old")
|
||||
rec := httptest.NewRecorder()
|
||||
a.rollback(rec, req)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("rollback status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
reg.mu.Lock()
|
||||
got := reg.data.Plugins[p.Manifest.PluginID]
|
||||
reg.mu.Unlock()
|
||||
if got.ActiveRevision != "rev-old" || got.Manifest.Version != "0.8.0" {
|
||||
t.Fatalf("explicit rollback selected wrong revision: active=%q manifest=%q", got.ActiveRevision, got.Manifest.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLifecycleEnableDisableUninstall(t *testing.T) {
|
||||
t.Setenv("CORE_VERSION", "0.1.183")
|
||||
var applied []byte
|
||||
@@ -1024,6 +1454,9 @@ func TestRecoverCommandPluginAfterRestart(t *testing.T) {
|
||||
t.Skip("python3 is unavailable")
|
||||
}
|
||||
source = strings.Replace(source, "#!/usr/bin/env python3", "#!"+pythonPath, 1)
|
||||
if err := os.WriteFile(command, []byte(source), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, _ := openRegistry(filepath.Join(root, "registry"))
|
||||
pluginManifest := manifest.Manifest{PluginID: "command.plugin", Name: "Command", Version: "1.0.0", CoreAPIBaseline: "sub2api-0.1.183", TestedCoreVersions: []string{"0.1.183"}, Backend: manifest.Backend{HealthPath: "/healthz", ReadinessPath: "/readyz", Command: "service/run.py", ListenEnv: "PLUGIN_PORT"}}
|
||||
reg.data.Plugins[pluginManifest.PluginID] = pluginRecord{Manifest: pluginManifest, State: "healthy", ActiveRevision: "rev-1", Revisions: []revision{{ID: "rev-1", Version: "1.0.0", Path: pluginDir, Manifest: pluginManifest}}}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -31,6 +32,75 @@ var (
|
||||
marketplaceSHA256Pattern = regexp.MustCompile(`^[a-f0-9]{64}$`)
|
||||
)
|
||||
|
||||
// compareMarketplaceVersions implements the semver subset accepted by the
|
||||
// manifest validator. It is used only for marketplace installs, where a
|
||||
// catalog entry must never silently downgrade an already registered plugin.
|
||||
func compareMarketplaceVersions(left, right string) int {
|
||||
parse := func(value string) ([3]int, []string) {
|
||||
value = strings.TrimPrefix(strings.TrimSpace(value), "v")
|
||||
parts := strings.SplitN(value, "-", 2)
|
||||
core := strings.Split(parts[0], ".")
|
||||
var numbers [3]int
|
||||
for i := 0; i < len(numbers) && i < len(core); i++ {
|
||||
numbers[i], _ = strconv.Atoi(core[i])
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
return numbers, nil
|
||||
}
|
||||
return numbers, strings.Split(parts[1], ".")
|
||||
}
|
||||
leftCore, leftPre := parse(left)
|
||||
rightCore, rightPre := parse(right)
|
||||
for i := range leftCore {
|
||||
if leftCore[i] < rightCore[i] {
|
||||
return -1
|
||||
}
|
||||
if leftCore[i] > rightCore[i] {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
if len(leftPre) == 0 && len(rightPre) == 0 {
|
||||
return 0
|
||||
}
|
||||
if len(leftPre) == 0 {
|
||||
return 1
|
||||
}
|
||||
if len(rightPre) == 0 {
|
||||
return -1
|
||||
}
|
||||
for i := 0; i < len(leftPre) && i < len(rightPre); i++ {
|
||||
l, r := leftPre[i], rightPre[i]
|
||||
if l == r {
|
||||
continue
|
||||
}
|
||||
ln, le := strconv.Atoi(l)
|
||||
rn, re := strconv.Atoi(r)
|
||||
if le == nil && re == nil {
|
||||
if ln < rn {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if le == nil {
|
||||
return -1
|
||||
}
|
||||
if re == nil {
|
||||
return 1
|
||||
}
|
||||
if l < r {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if len(leftPre) < len(rightPre) {
|
||||
return -1
|
||||
}
|
||||
if len(leftPre) > len(rightPre) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// marketplaceEntry is deliberately metadata-only. The browser never receives
|
||||
// the archive URL; downloads are performed by this server after catalog and
|
||||
// transport policy validation.
|
||||
@@ -63,6 +133,7 @@ type marketplaceService struct {
|
||||
remoteURL *url.URL
|
||||
allowedHosts map[string]struct{}
|
||||
allowLoopback bool
|
||||
indexSHA256 string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
@@ -238,6 +309,12 @@ func (m marketplaceService) loadIndex(ctx context.Context) (marketplaceIndex, ma
|
||||
return marketplaceIndex{}, origin, errors.New("marketplace index exceeds size limit")
|
||||
}
|
||||
}
|
||||
if expected := strings.TrimSpace(m.indexSHA256); expected != "" {
|
||||
sum := sha256.Sum256(raw)
|
||||
if !strings.EqualFold(hex.EncodeToString(sum[:]), expected) {
|
||||
return marketplaceIndex{}, origin, errors.New("marketplace index sha256 does not match the configured pin")
|
||||
}
|
||||
}
|
||||
var index marketplaceIndex
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
@@ -9,7 +9,9 @@ const email = process.env.PLUGIN_TEST_EMAIL || 'admin@example.com'
|
||||
const password = process.env.PLUGIN_TEST_PASSWORD || 'password'
|
||||
|
||||
await fs.mkdir(outputDir, { recursive: true })
|
||||
const browser = await chromium.launch({ headless: true })
|
||||
const launchOptions = { headless: true }
|
||||
if (process.env.PLAYWRIGHT_EXECUTABLE_PATH) launchOptions.executablePath = process.env.PLAYWRIGHT_EXECUTABLE_PATH
|
||||
const browser = await chromium.launch(launchOptions)
|
||||
try {
|
||||
for (const width of [425, 900, 1440]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 900 }, deviceScaleFactor: 1 })
|
||||
@@ -21,15 +23,72 @@ try {
|
||||
if (/access_token|refresh_token|admin[_-]?api[_-]?key|password|client_secret/i.test(body)) responseLeaks.push(response.url())
|
||||
} catch (_) {}
|
||||
})
|
||||
if (process.env.PLUGIN_EXPECT_CAPTCHA === 'turnstile') {
|
||||
// The fixture uses a deterministic SDK shim; production pages load the
|
||||
// provider SDK from the CSP allowlist. This keeps the browser contract
|
||||
// test network-independent while exercising the proof handoff.
|
||||
await page.addInitScript(() => {
|
||||
window.turnstile = {
|
||||
render(container, options) {
|
||||
const marker = document.createElement('span')
|
||||
marker.dataset.testid = 'turnstile-fixture'
|
||||
marker.textContent = 'fixture captcha'
|
||||
container.appendChild(marker)
|
||||
queueMicrotask(() => options.callback('MOCK-TURNSTILE-TOKEN'))
|
||||
return 'fixture-widget'
|
||||
},
|
||||
reset() {},
|
||||
remove() {},
|
||||
}
|
||||
})
|
||||
}
|
||||
await page.goto(entry, { waitUntil: 'networkidle' })
|
||||
await page.getByLabel('邮箱').fill(email)
|
||||
await page.getByLabel('密码').fill(password)
|
||||
await page.locator('input[autocomplete="username"]').fill(email)
|
||||
await page.locator('input[autocomplete="current-password"]').fill(password)
|
||||
if (process.env.PLUGIN_EXPECT_CAPTCHA === 'turnstile') {
|
||||
await page.locator('[data-testid="turnstile-fixture"]').waitFor({ state: 'visible' })
|
||||
}
|
||||
await page.getByRole('button', { name: '登录' }).click()
|
||||
await page.getByRole('heading', { name: '已登记插件' }).waitFor()
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)
|
||||
if (overflow) throw new Error(`horizontal overflow at ${width}px`)
|
||||
const buttons = await page.locator('button, .file-button').evaluateAll((items) => items.filter((item) => item.getClientRects().length > 0 && getComputedStyle(item).visibility !== 'hidden').every((item) => item.getBoundingClientRect().height >= 28 && item.getBoundingClientRect().width >= 28))
|
||||
if (!buttons) throw new Error(`control collapsed at ${width}px`)
|
||||
await page.getByRole('heading', { name: '概览' }).waitFor()
|
||||
|
||||
const assertViewport = async (label) => {
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)
|
||||
if (overflow) throw new Error(`horizontal overflow at ${width}px (${label})`)
|
||||
const controls = await page.locator('button, .file-button, input[type="file"]').evaluateAll((items) => items.filter((item) => !item.classList.contains('visually-hidden') && item.getClientRects().length > 0 && getComputedStyle(item).visibility !== 'hidden').every((item) => item.getBoundingClientRect().height >= 28 && item.getBoundingClientRect().width >= 28))
|
||||
if (!controls) throw new Error(`control collapsed at ${width}px (${label})`)
|
||||
}
|
||||
const visitView = async (view, heading) => {
|
||||
await page.evaluate((hash) => { window.location.hash = hash }, `#/${view}`)
|
||||
await page.getByRole('heading', { name: heading }).waitFor()
|
||||
if (!page.url().includes(`#/${view}`)) throw new Error(`hash route missing for ${view}: ${page.url()}`)
|
||||
await assertViewport(view)
|
||||
}
|
||||
await visitView('plugins', '已安装插件')
|
||||
await visitView('marketplace', '插件市场')
|
||||
await visitView('operations', '操作记录')
|
||||
|
||||
// The optional subscription module is served by the same Shell. When the
|
||||
// fixture has it enabled, verify the deep link remains inside the unified
|
||||
// hash router instead of opening a second service/login surface.
|
||||
if (process.env.PLUGIN_EXPECT_SUBSCRIPTION === 'true') {
|
||||
await visitView('modules/subscription/overview', '订阅管理')
|
||||
if (!page.url().includes('#/modules/subscription/overview')) throw new Error(`subscription route escaped unified shell: ${page.url()}`)
|
||||
}
|
||||
|
||||
// When the backend has installed plugins, exercise a detail route and its secondary tab.
|
||||
await visitView('plugins', '已安装插件')
|
||||
const detailTrigger = page.getByRole('button', { name: '查看详情' }).first()
|
||||
if (await detailTrigger.count()) {
|
||||
await detailTrigger.click()
|
||||
await page.getByRole('heading', { name: '插件详情' }).waitFor().catch(() => {})
|
||||
const secondary = page.getByRole('tab', { name: '版本与升级' })
|
||||
if (await secondary.count()) await secondary.click()
|
||||
await assertViewport('detail')
|
||||
}
|
||||
|
||||
await page.reload({ waitUntil: 'networkidle' })
|
||||
if (!page.url().includes('#')) throw new Error(`hash route lost after refresh: ${page.url()}`)
|
||||
await assertViewport('refresh')
|
||||
await page.waitForTimeout(50)
|
||||
if (responseLeaks.length) throw new Error(`sensitive response field exposed at ${width}px: ${responseLeaks.join(', ')}`)
|
||||
await page.screenshot({ path: path.join(outputDir, `plugin-admin-${width}.png`), fullPage: true })
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="plugin-base-path" content="__PLUGIN_BASE_PATH__" />
|
||||
<title>插件管理控制面</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1945
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@awaioi/plugin-admin-ui",
|
||||
"private": true,
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1 --port 3002",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"echarts": "^6.1.0",
|
||||
"pinia": "^3.0.4",
|
||||
"tdesign-icons-vue-next": "^0.4.4",
|
||||
"tdesign-vue-next": "^1.20.2",
|
||||
"vue": "^3.5.39",
|
||||
"vue-router": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"typescript": "~6.0.3",
|
||||
"vite": "^8.1.5",
|
||||
"vue-tsc": "^3.3.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<t-config-provider>
|
||||
<div v-if="!authState.ready" class="boot-screen"><t-loading size="32px" text="正在连接控制面" /></div>
|
||||
<router-view v-else-if="authState.user" />
|
||||
<Login v-else />
|
||||
</t-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import router from './router'
|
||||
import { authState, hydrateSession, loadSubscriptionState } from './services/api'
|
||||
import Login from './views/Login.vue'
|
||||
|
||||
onMounted(async () => {
|
||||
await hydrateSession()
|
||||
// The first router pass happens while authState.ready is false. Re-check a
|
||||
// deep link after hydration so a direct subscription URL cannot bypass the
|
||||
// route-level module gate.
|
||||
const current = router.currentRoute.value
|
||||
if (!authState.user && current.path !== '/login') {
|
||||
await router.replace('/login')
|
||||
} else if (authState.user && current.path.startsWith('/modules/subscription')) {
|
||||
if (!(await loadSubscriptionState(true))) await router.replace('/overview')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<div v-if="sceneId && prefix" class="captcha-action">
|
||||
<button
|
||||
:id="buttonId"
|
||||
type="button"
|
||||
class="captcha-action__trigger"
|
||||
:class="{ 'is-verified': state === 'verified' }"
|
||||
:disabled="state === 'verified' || state === 'verifying'"
|
||||
@click="handleTrigger"
|
||||
>
|
||||
<span class="captcha-action__dot" :class="{ 'is-verified': state === 'verified' }" />
|
||||
{{ state === 'verified' ? '验证通过' : state === 'verifying' ? '验证中...' : '点击完成安全验证' }}
|
||||
</button>
|
||||
<div :id="elementId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
interface AliyunInitOptions {
|
||||
SceneId: string
|
||||
prefix: string
|
||||
mode: 'popup' | 'embed'
|
||||
element: string
|
||||
button: string
|
||||
captchaVerifyCallback: (param: string) => { captchaResult: boolean; bizResult?: boolean }
|
||||
onBizResultCallback: (result: boolean) => void
|
||||
getInstance: (instance: unknown) => void
|
||||
slideStyle?: { width: number; height: number }
|
||||
language?: string
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
initAliyunCaptcha?: (options: AliyunInitOptions) => void
|
||||
AliyunCaptchaConfig?: { region: string; prefix: string }
|
||||
}
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{ sceneId: string; prefix: string; region?: string }>(), { region: 'cn' })
|
||||
const emit = defineEmits<{ verify: [param: string]; expire: []; error: [] }>()
|
||||
const uid = Math.random().toString(36).slice(2, 10)
|
||||
const buttonId = `plugin-aliyun-captcha-button-${uid}`
|
||||
const elementId = `plugin-aliyun-captcha-element-${uid}`
|
||||
const state = ref<'idle' | 'verifying' | 'verified'>('idle')
|
||||
const scriptSrc = 'https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js'
|
||||
let readyPromise: Promise<void> | null = null
|
||||
let cachedParam: string | null = null
|
||||
let pending: { resolve: (value: string | null) => void } | null = null
|
||||
let pendingTimer: number | null = null
|
||||
let initialized = false
|
||||
|
||||
function loadScript(): Promise<void> {
|
||||
if (window.initAliyunCaptcha) return Promise.resolve()
|
||||
if (readyPromise) return readyPromise
|
||||
window.AliyunCaptchaConfig = { region: props.region || 'cn', prefix: props.prefix }
|
||||
readyPromise = new Promise<void>((resolve, reject) => {
|
||||
const existing = document.querySelector<HTMLScriptElement>('script[src*="aliyunCaptcha/AliyunCaptcha"]')
|
||||
if (existing) {
|
||||
existing.addEventListener('load', () => window.initAliyunCaptcha ? resolve() : reject(new Error('Aliyun Captcha SDK unavailable')), { once: true })
|
||||
existing.addEventListener('error', () => reject(new Error('Failed to load Aliyun Captcha SDK')), { once: true })
|
||||
return
|
||||
}
|
||||
const script = document.createElement('script')
|
||||
script.src = scriptSrc
|
||||
script.async = true
|
||||
script.onload = () => window.initAliyunCaptcha ? resolve() : reject(new Error('Aliyun Captcha SDK unavailable'))
|
||||
script.onerror = () => reject(new Error('Failed to load Aliyun Captcha SDK'))
|
||||
document.head.appendChild(script)
|
||||
}).catch((error) => {
|
||||
readyPromise = null
|
||||
throw error
|
||||
})
|
||||
return readyPromise
|
||||
}
|
||||
|
||||
function resolveParam(param: string) {
|
||||
if (pendingTimer !== null) {
|
||||
window.clearTimeout(pendingTimer)
|
||||
pendingTimer = null
|
||||
}
|
||||
const value = param.trim()
|
||||
if (!value) {
|
||||
state.value = 'idle'
|
||||
pending?.resolve(null)
|
||||
pending = null
|
||||
emit('error')
|
||||
return
|
||||
}
|
||||
cachedParam = value
|
||||
state.value = 'verified'
|
||||
emit('verify', value)
|
||||
pending?.resolve(value)
|
||||
pending = null
|
||||
}
|
||||
|
||||
function initCaptcha() {
|
||||
if (initialized) return
|
||||
if (!window.initAliyunCaptcha) throw new Error('Aliyun Captcha SDK unavailable')
|
||||
window.initAliyunCaptcha({
|
||||
SceneId: props.sceneId,
|
||||
prefix: props.prefix,
|
||||
mode: 'popup',
|
||||
element: `#${elementId}`,
|
||||
button: `#${buttonId}`,
|
||||
captchaVerifyCallback: (param) => {
|
||||
resolveParam(param)
|
||||
return { captchaResult: true }
|
||||
},
|
||||
onBizResultCallback: () => undefined,
|
||||
getInstance: () => undefined,
|
||||
slideStyle: { width: 360, height: 40 },
|
||||
language: 'cn',
|
||||
})
|
||||
initialized = true
|
||||
}
|
||||
|
||||
function handleTrigger() {
|
||||
if (state.value === 'verified' || state.value === 'verifying') return
|
||||
state.value = 'verifying'
|
||||
void verify().catch(() => {
|
||||
state.value = 'idle'
|
||||
emit('error')
|
||||
})
|
||||
}
|
||||
|
||||
async function verify(): Promise<string | null> {
|
||||
if (cachedParam) {
|
||||
const value = cachedParam
|
||||
cachedParam = null
|
||||
return value
|
||||
}
|
||||
await loadScript()
|
||||
if (!window.initAliyunCaptcha) throw new Error('Aliyun Captcha SDK unavailable')
|
||||
initCaptcha()
|
||||
return new Promise<string | null>((resolve) => {
|
||||
pending = { resolve }
|
||||
if (pendingTimer !== null) window.clearTimeout(pendingTimer)
|
||||
pendingTimer = window.setTimeout(() => {
|
||||
pendingTimer = null
|
||||
state.value = 'idle'
|
||||
pending?.resolve(null)
|
||||
pending = null
|
||||
}, 15000)
|
||||
document.getElementById(buttonId)?.click()
|
||||
})
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (pendingTimer !== null) {
|
||||
window.clearTimeout(pendingTimer)
|
||||
pendingTimer = null
|
||||
}
|
||||
cachedParam = null
|
||||
state.value = 'idle'
|
||||
pending?.resolve(null)
|
||||
pending = null
|
||||
document.getElementById('aliyunCaptcha-mask')?.remove()
|
||||
document.getElementById('aliyunCaptcha-window-popup')?.remove()
|
||||
emit('expire')
|
||||
}
|
||||
|
||||
defineExpose({ verify, reset })
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.sceneId || !props.prefix) return
|
||||
void loadScript().then(initCaptcha).catch(() => emit('error'))
|
||||
})
|
||||
onUnmounted(() => {
|
||||
if (pendingTimer !== null) {
|
||||
window.clearTimeout(pendingTimer)
|
||||
pendingTimer = null
|
||||
}
|
||||
pending?.resolve(null)
|
||||
pending = null
|
||||
document.getElementById('aliyunCaptcha-mask')?.remove()
|
||||
document.getElementById('aliyunCaptcha-window-popup')?.remove()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.captcha-action { width: 100%; }
|
||||
.captcha-action__trigger {
|
||||
display: inline-flex; width: 100%; min-height: 42px; align-items: center; justify-content: center; gap: 9px;
|
||||
border: 1px solid #d8dee8; border-radius: 6px; background: #f8fafc; color: #4b5563;
|
||||
cursor: pointer; font-size: 13px; transition: border-color .15s ease, background-color .15s ease;
|
||||
}
|
||||
.captcha-action__trigger:hover:not(:disabled) { border-color: #0052d9; background: #f0f5ff; }
|
||||
.captcha-action__trigger:disabled { cursor: default; opacity: .85; }
|
||||
.captcha-action__trigger.is-verified { border-color: #00a870; background: #f0fff8; color: #087f5b; }
|
||||
.captcha-action__dot { width: 8px; height: 8px; border-radius: 50%; background: #a0aec0; }
|
||||
.captcha-action__dot.is-verified { background: #00a870; }
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div v-if="enabled" class="captcha-challenge" aria-live="polite">
|
||||
<TurnstileWidget
|
||||
v-if="provider === 'turnstile' && turnstileSiteKey"
|
||||
ref="turnstileRef"
|
||||
:site-key="turnstileSiteKey"
|
||||
@verify="onTurnstileVerify"
|
||||
@expire="onExpire"
|
||||
@error="onError"
|
||||
/>
|
||||
<TencentCaptchaGate
|
||||
v-else-if="provider === 'tencent' && tencentAppId"
|
||||
ref="tencentRef"
|
||||
:app-id="tencentAppId"
|
||||
:region="tencentRegion"
|
||||
@verify="onTencentVerify"
|
||||
@expire="onExpire"
|
||||
@error="onError"
|
||||
/>
|
||||
<AliyunCaptchaWidget
|
||||
v-else-if="provider === 'aliyun' && aliyunSceneId && aliyunPrefix"
|
||||
ref="aliyunRef"
|
||||
:scene-id="aliyunSceneId"
|
||||
:prefix="aliyunPrefix"
|
||||
:region="aliyunRegion"
|
||||
@verify="onAliyunVerify"
|
||||
@expire="onExpire"
|
||||
@error="onError"
|
||||
/>
|
||||
<div v-else class="captcha-challenge__missing">验证码配置不完整,请联系管理员</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import TurnstileWidget from './TurnstileWidget.vue'
|
||||
import TencentCaptchaGate from './TencentCaptchaGate.vue'
|
||||
import AliyunCaptchaWidget from './AliyunCaptchaWidget.vue'
|
||||
import type { TencentCaptchaProof } from '../services/tencentCaptcha'
|
||||
|
||||
export interface CaptchaProof { token: string; randstr: string; provider: 'turnstile' | 'tencent' | 'aliyun' }
|
||||
|
||||
const props = defineProps<{
|
||||
enabled: boolean
|
||||
provider: string
|
||||
turnstileSiteKey?: string
|
||||
tencentAppId?: string
|
||||
tencentRegion?: string
|
||||
aliyunSceneId?: string
|
||||
aliyunPrefix?: string
|
||||
aliyunRegion?: string
|
||||
}>()
|
||||
const emit = defineEmits<{ verify: [proof: CaptchaProof]; expire: []; error: [] }>()
|
||||
|
||||
const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null)
|
||||
const tencentRef = ref<InstanceType<typeof TencentCaptchaGate> | null>(null)
|
||||
const aliyunRef = ref<InstanceType<typeof AliyunCaptchaWidget> | null>(null)
|
||||
const turnstileToken = ref('')
|
||||
const tencentProof = ref<TencentCaptchaProof | null>(null)
|
||||
const aliyunToken = ref('')
|
||||
const provider = computed(() => props.provider)
|
||||
|
||||
function onTurnstileVerify(token: string) {
|
||||
turnstileToken.value = token
|
||||
emit('verify', { token, randstr: '', provider: 'turnstile' })
|
||||
}
|
||||
function onTencentVerify(proof: TencentCaptchaProof) {
|
||||
tencentProof.value = proof
|
||||
emit('verify', { token: proof.ticket, randstr: proof.randstr, provider: 'tencent' })
|
||||
}
|
||||
function onAliyunVerify(token: string) {
|
||||
aliyunToken.value = token
|
||||
emit('verify', { token, randstr: '', provider: 'aliyun' })
|
||||
}
|
||||
function onExpire() {
|
||||
turnstileToken.value = ''
|
||||
tencentProof.value = null
|
||||
aliyunToken.value = ''
|
||||
emit('expire')
|
||||
}
|
||||
function onError() {
|
||||
onExpire()
|
||||
emit('error')
|
||||
}
|
||||
|
||||
async function verifyAction(): Promise<CaptchaProof | null> {
|
||||
if (!props.enabled) return null
|
||||
if (provider.value === 'turnstile') {
|
||||
return turnstileToken.value ? { token: turnstileToken.value, randstr: '', provider: 'turnstile' } : null
|
||||
}
|
||||
if (provider.value === 'tencent') {
|
||||
const proof = tencentProof.value || await tencentRef.value?.verify() || null
|
||||
return proof ? { token: proof.ticket, randstr: proof.randstr, provider: 'tencent' } : null
|
||||
}
|
||||
if (provider.value === 'aliyun') {
|
||||
const token = aliyunToken.value || await aliyunRef.value?.verify() || ''
|
||||
return token ? { token, randstr: '', provider: 'aliyun' } : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function reset() {
|
||||
turnstileToken.value = ''
|
||||
tencentProof.value = null
|
||||
aliyunToken.value = ''
|
||||
turnstileRef.value?.reset()
|
||||
tencentRef.value?.reset()
|
||||
aliyunRef.value?.reset()
|
||||
}
|
||||
|
||||
defineExpose({ verifyAction, reset })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.captcha-challenge { width: 100%; margin: 2px 0 10px; }
|
||||
.captcha-challenge__missing { padding: 10px 12px; border: 1px solid #f3c7c4; border-radius: 6px; color: #a61b1b; background: #fff7f7; font-size: 12px; line-height: 1.5; }
|
||||
</style>
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<header class="page-header"><div><p class="eyebrow">{{ eyebrow }}</p><h1>{{ title }}</h1><p class="page-subtitle">{{ subtitle }}</p></div><div class="page-actions"><slot name="actions" /></div></header>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
defineProps<{ eyebrow: string; title: string; subtitle: string }>()
|
||||
</script>
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div class="captcha-action">
|
||||
<div v-if="isInternational" ref="internationalContainerRef" class="captcha-action__international" />
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="captcha-action__trigger"
|
||||
:disabled="state === 'verified' || state === 'verifying'"
|
||||
@click="handleTrigger"
|
||||
>
|
||||
<span class="captcha-action__dot" :class="{ 'is-verified': state === 'verified' }" />
|
||||
{{ state === 'verified' ? '验证通过' : state === 'verifying' ? '验证中...' : '点击完成安全验证' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { loadTencentCaptcha, normalizeTencentCaptchaRegion, type TencentCaptchaProof } from '../services/tencentCaptcha'
|
||||
|
||||
const props = withDefaults(defineProps<{ appId: string; region?: string }>(), { region: 'cn' })
|
||||
const emit = defineEmits<{ verify: [proof: TencentCaptchaProof]; expire: []; error: [] }>()
|
||||
|
||||
const isInternational = computed(() => normalizeTencentCaptchaRegion(props.region) === 'intl')
|
||||
const internationalContainerRef = ref<HTMLElement | null>(null)
|
||||
const state = ref<'idle' | 'verifying' | 'verified'>('idle')
|
||||
let instance: { show(): void; destroy(): void } | null = null
|
||||
let pending: Promise<TencentCaptchaProof | null> | null = null
|
||||
let resolvePending: ((proof: TencentCaptchaProof | null) => void) | null = null
|
||||
let cachedProof: TencentCaptchaProof | null = null
|
||||
|
||||
function handleResult(result: { ret: number; ticket?: string | null; randstr?: string | null; errorCode?: number }) {
|
||||
if (result.ret === 2) {
|
||||
state.value = 'idle'
|
||||
resolvePending?.(null)
|
||||
resolvePending = null
|
||||
pending = null
|
||||
return
|
||||
}
|
||||
const ticket = result.ticket?.trim() || ''
|
||||
const randstr = result.randstr?.trim() || ''
|
||||
if (!ticket || !randstr || ticket.startsWith('trerror_') || result.errorCode !== undefined) {
|
||||
state.value = 'idle'
|
||||
resolvePending?.(null)
|
||||
resolvePending = null
|
||||
pending = null
|
||||
emit('error')
|
||||
return
|
||||
}
|
||||
const proof = { ticket, randstr }
|
||||
cachedProof = proof
|
||||
state.value = 'verified'
|
||||
emit('verify', proof)
|
||||
resolvePending?.(proof)
|
||||
resolvePending = null
|
||||
pending = null
|
||||
}
|
||||
|
||||
async function ensureInstance() {
|
||||
const Constructor = await loadTencentCaptcha(normalizeTencentCaptchaRegion(props.region))
|
||||
if (instance) return
|
||||
if (isInternational.value) {
|
||||
if (!internationalContainerRef.value) throw new Error('Tencent Captcha container unavailable')
|
||||
instance = new Constructor(internationalContainerRef.value, props.appId, handleResult, { enableAutoCheck: false, type: 'popup' })
|
||||
} else {
|
||||
instance = new Constructor(props.appId, handleResult, { userLanguage: 'zh-cn' })
|
||||
}
|
||||
}
|
||||
|
||||
function handleTrigger() {
|
||||
if (state.value === 'verified' || state.value === 'verifying') return
|
||||
state.value = 'verifying'
|
||||
void verify()
|
||||
}
|
||||
|
||||
async function verify(): Promise<TencentCaptchaProof | null> {
|
||||
if (cachedProof) {
|
||||
const proof = cachedProof
|
||||
cachedProof = null
|
||||
return proof
|
||||
}
|
||||
if (pending) return pending
|
||||
const verification = new Promise<TencentCaptchaProof | null>((resolve) => { resolvePending = resolve })
|
||||
pending = verification
|
||||
try {
|
||||
await ensureInstance()
|
||||
instance?.show()
|
||||
} catch (error) {
|
||||
state.value = 'idle'
|
||||
resolvePending?.(null)
|
||||
resolvePending = null
|
||||
pending = null
|
||||
emit('error')
|
||||
throw error
|
||||
}
|
||||
return verification
|
||||
}
|
||||
|
||||
function reset() {
|
||||
instance?.destroy()
|
||||
instance = null
|
||||
cachedProof = null
|
||||
state.value = 'idle'
|
||||
resolvePending?.(null)
|
||||
resolvePending = null
|
||||
pending = null
|
||||
emit('expire')
|
||||
}
|
||||
|
||||
defineExpose({ verify, reset })
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.appId) return
|
||||
// Preload the international checkbox so the first click is visible and
|
||||
// never gets swallowed by the asynchronous SDK initialization.
|
||||
if (isInternational.value) void ensureInstance().catch(() => emit('error'))
|
||||
})
|
||||
onBeforeUnmount(reset)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.captcha-action { width: 100%; }
|
||||
.captcha-action__international { display: flex; width: 100%; min-height: 60px; align-items: center; justify-content: center; }
|
||||
.captcha-action__trigger {
|
||||
display: inline-flex;
|
||||
width: 100%; min-height: 42px; align-items: center; justify-content: center; gap: 9px;
|
||||
border: 1px solid #d8dee8; border-radius: 6px; background: #f8fafc; color: #4b5563;
|
||||
cursor: pointer; font-size: 13px; transition: border-color .15s ease, background-color .15s ease;
|
||||
}
|
||||
.captcha-action__trigger:hover:not(:disabled) { border-color: #0052d9; background: #f0f5ff; }
|
||||
.captcha-action__trigger:disabled { cursor: default; opacity: .85; }
|
||||
.captcha-action__dot { width: 8px; height: 8px; border-radius: 50%; background: #a0aec0; }
|
||||
.captcha-action__dot.is-verified { background: #00a870; }
|
||||
</style>
|
||||
@@ -0,0 +1,32 @@
|
||||
<template><div ref="element" class="trend-chart" aria-label="近期操作趋势" /></template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BarChart, LineChart } from 'echarts/charts'
|
||||
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
|
||||
import * as echarts from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
echarts.use([BarChart, LineChart, GridComponent, LegendComponent, TooltipComponent, CanvasRenderer])
|
||||
const props = defineProps<{ labels: string[]; values: number[] }>()
|
||||
const element = ref<HTMLElement | null>(null)
|
||||
let chart: echarts.ECharts | null = null
|
||||
let observer: ResizeObserver | null = null
|
||||
|
||||
function render() {
|
||||
if (!element.value) return
|
||||
chart ||= echarts.init(element.value)
|
||||
chart.setOption({
|
||||
animationDuration: 350,
|
||||
grid: { left: 36, right: 16, top: 20, bottom: 24 },
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'category', boundaryGap: false, data: props.labels, axisLine: { lineStyle: { color: '#d7dce3' } }, axisLabel: { color: '#718096', fontSize: 11 } },
|
||||
yAxis: { type: 'value', minInterval: 1, splitLine: { lineStyle: { color: '#edf0f4' } }, axisLabel: { color: '#718096', fontSize: 11 } },
|
||||
series: [{ name: '操作数', type: 'line', smooth: true, symbol: 'circle', symbolSize: 5, data: props.values, lineStyle: { color: '#0052d9', width: 2 }, itemStyle: { color: '#0052d9' }, areaStyle: { color: 'rgba(0,82,217,.10)' } }],
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => { render(); if (element.value) { observer = new ResizeObserver(() => chart?.resize()); observer.observe(element.value) } })
|
||||
watch(() => [props.labels, props.values], render, { deep: true })
|
||||
onBeforeUnmount(() => { observer?.disconnect(); chart?.dispose(); chart = null })
|
||||
</script>
|
||||
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div v-if="siteKey" class="captcha-turnstile">
|
||||
<div ref="containerRef" class="captcha-turnstile__container" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
interface TurnstileRenderOptions {
|
||||
sitekey: string
|
||||
callback: (token: string) => void
|
||||
'expired-callback'?: () => void
|
||||
'error-callback'?: () => void
|
||||
theme?: 'light' | 'dark' | 'auto'
|
||||
size?: 'normal' | 'compact' | 'flexible'
|
||||
}
|
||||
|
||||
interface TurnstileAPI {
|
||||
render: (container: HTMLElement, options: TurnstileRenderOptions) => string
|
||||
reset: (widgetId?: string) => void
|
||||
remove: (widgetId?: string) => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: TurnstileAPI
|
||||
__pluginTurnstileReady?: () => void
|
||||
}
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ siteKey: string; theme?: 'light' | 'dark' | 'auto'; size?: 'normal' | 'compact' | 'flexible' }>(),
|
||||
{ theme: 'auto', size: 'flexible' }
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ verify: [token: string]; expire: []; error: [] }>()
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
let widgetId: string | null = null
|
||||
let scriptPromise: Promise<void> | null = null
|
||||
|
||||
function loadScript(): Promise<void> {
|
||||
if (window.turnstile) return Promise.resolve()
|
||||
if (scriptPromise) return scriptPromise
|
||||
scriptPromise = new Promise<void>((resolve, reject) => {
|
||||
const existing = document.querySelector<HTMLScriptElement>('script[src*="challenges.cloudflare.com/turnstile"]')
|
||||
if (existing) {
|
||||
const onLoad = () => window.turnstile ? resolve() : reject(new Error('Turnstile SDK unavailable'))
|
||||
existing.addEventListener('load', onLoad, { once: true })
|
||||
if (window.turnstile) resolve()
|
||||
return
|
||||
}
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
|
||||
script.async = true
|
||||
script.defer = true
|
||||
script.onload = () => window.turnstile ? resolve() : reject(new Error('Turnstile SDK unavailable'))
|
||||
script.onerror = () => reject(new Error('Failed to load Turnstile SDK'))
|
||||
document.head.appendChild(script)
|
||||
}).catch((error) => {
|
||||
scriptPromise = null
|
||||
throw error
|
||||
})
|
||||
return scriptPromise
|
||||
}
|
||||
|
||||
function renderWidget() {
|
||||
if (!window.turnstile || !containerRef.value || !props.siteKey) return
|
||||
if (widgetId) {
|
||||
try { window.turnstile.remove(widgetId) } catch { /* SDK cleanup is best effort. */ }
|
||||
widgetId = null
|
||||
}
|
||||
containerRef.value.innerHTML = ''
|
||||
widgetId = window.turnstile.render(containerRef.value, {
|
||||
sitekey: props.siteKey,
|
||||
theme: props.theme,
|
||||
size: props.size,
|
||||
callback: (token) => emit('verify', token),
|
||||
'expired-callback': () => emit('expire'),
|
||||
'error-callback': () => emit('error'),
|
||||
})
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (window.turnstile && widgetId) {
|
||||
try { window.turnstile.reset(widgetId) } catch { /* SDK cleanup is best effort. */ }
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ reset })
|
||||
|
||||
onMounted(async () => {
|
||||
if (!props.siteKey) return
|
||||
try {
|
||||
await loadScript()
|
||||
renderWidget()
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize Turnstile:', error)
|
||||
emit('error')
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.siteKey, (value) => {
|
||||
if (value && window.turnstile) renderWidget()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (window.turnstile && widgetId) {
|
||||
try { window.turnstile.remove(widgetId) } catch { /* SDK cleanup is best effort. */ }
|
||||
}
|
||||
widgetId = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.captcha-turnstile { width: 100%; min-height: 66px; }
|
||||
.captcha-turnstile__container { width: 100%; min-height: 66px; }
|
||||
.captcha-turnstile__container :deep(iframe) { display: block; max-width: 100%; }
|
||||
</style>
|
||||
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<t-layout class="app-shell">
|
||||
<div v-if="mobileOpen" class="mobile-scrim" @click="mobileOpen = false" />
|
||||
<t-aside :class="['app-aside', { 'app-aside-mobile-open': mobileOpen }]" width="252px">
|
||||
<div class="brand">
|
||||
<div class="brand-mark"><ControlPlatformIcon size="22px" /></div>
|
||||
<div>
|
||||
<div class="brand-title">Sub2API</div>
|
||||
<div class="brand-subtitle">插件控制面</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-caption">控制台</div>
|
||||
<t-menu :value="activeMenu" class="main-menu" @change="onMenuChange">
|
||||
<t-menu-item value="/overview"><template #icon><DashboardIcon /></template>概览</t-menu-item>
|
||||
<t-menu-item value="/plugins"><template #icon><AppIcon /></template>已安装插件</t-menu-item>
|
||||
<t-menu-item value="/marketplace"><template #icon><ShopIcon /></template>插件市场</t-menu-item>
|
||||
<t-menu-item value="/operations"><template #icon><HistoryIcon /></template>操作记录</t-menu-item>
|
||||
<template v-if="subscriptionEnabled">
|
||||
<t-divider class="nav-divider" />
|
||||
<div class="nav-caption nav-caption-module">业务模块</div>
|
||||
</template>
|
||||
<t-menu-item v-if="subscriptionEnabled" value="/modules/subscription/overview">
|
||||
<template #icon><ChartLineIcon /></template>订阅管理
|
||||
</t-menu-item>
|
||||
</t-menu>
|
||||
<div class="aside-footer">
|
||||
<t-tag theme="success" variant="light" class="status-tag"><CheckCircleIcon size="14px" /> Core 会话正常</t-tag>
|
||||
<span class="version-text">Plugin Admin v1.1.0</span>
|
||||
</div>
|
||||
</t-aside>
|
||||
<t-layout class="main-layout">
|
||||
<t-header class="app-header">
|
||||
<div class="header-left">
|
||||
<t-button class="mobile-menu-button" variant="text" shape="square" aria-label="打开导航" @click="mobileOpen = true">
|
||||
<template #icon><MenuIcon /></template>
|
||||
</t-button>
|
||||
<div class="breadcrumbs"><span>插件管理</span><ChevronRightIcon size="15px" /><strong>{{ pageTitle }}</strong></div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<t-tag theme="success" variant="light" class="secure-tag"><CheckCircleIcon size="14px" /> 安全会话</t-tag>
|
||||
<t-dropdown :options="userOptions" trigger="click" @click="onUserAction">
|
||||
<t-button variant="text" class="user-button">
|
||||
<template #icon><UserCircleIcon /></template>
|
||||
<span class="user-name">{{ displayName }}</span><ChevronDownIcon size="15px" />
|
||||
</t-button>
|
||||
</t-dropdown>
|
||||
</div>
|
||||
</t-header>
|
||||
<t-content class="app-content"><router-view /></t-content>
|
||||
</t-layout>
|
||||
</t-layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
AppIcon, ChartLineIcon, CheckCircleIcon, ChevronDownIcon, ChevronRightIcon, ControlPlatformIcon,
|
||||
DashboardIcon, HistoryIcon, MenuIcon, ShopIcon, UserCircleIcon,
|
||||
} from 'tdesign-icons-vue-next'
|
||||
import { MessagePlugin } from 'tdesign-vue-next'
|
||||
import { authState, loadSubscriptionState, logout, moduleState } from '../services/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const mobileOpen = ref(false)
|
||||
const subscriptionEnabled = computed(() => moduleState.subscriptionEnabled)
|
||||
const pageTitle = computed(() => String(route.meta.title || (route.path.startsWith('/modules/subscription') ? '订阅管理' : '概览')))
|
||||
const activeMenu = computed(() => route.path.startsWith('/plugins') ? '/plugins' : route.path.startsWith('/modules/subscription') ? '/modules/subscription/overview' : route.path)
|
||||
const displayName = computed(() => String(authState.user?.email || authState.user?.username || '管理员'))
|
||||
const userOptions = [{ content: '退出登录', value: 'logout' }]
|
||||
|
||||
async function loadModules() {
|
||||
await loadSubscriptionState(true)
|
||||
}
|
||||
|
||||
function onMenuChange(value: string | number) {
|
||||
const target = String(value)
|
||||
if (target === '/modules/subscription/overview' && !subscriptionEnabled.value) {
|
||||
MessagePlugin.warning('请先安装并启用订阅模块')
|
||||
return
|
||||
}
|
||||
router.push(target)
|
||||
mobileOpen.value = false
|
||||
}
|
||||
|
||||
async function onUserAction(data: any) {
|
||||
if (String(data?.value || '') !== 'logout') return
|
||||
await logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
onMounted(loadModules)
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import TDesign from 'tdesign-vue-next'
|
||||
import 'tdesign-vue-next/es/style/index.css'
|
||||
import './styles.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(TDesign)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import Shell from './layouts/Shell.vue'
|
||||
import Login from './views/Login.vue'
|
||||
import Overview from './views/Overview.vue'
|
||||
import Plugins from './views/Plugins.vue'
|
||||
import PluginDetail from './views/PluginDetail.vue'
|
||||
import Marketplace from './views/Marketplace.vue'
|
||||
import Operations from './views/Operations.vue'
|
||||
import Subscription from './views/Subscription.vue'
|
||||
import { authState, loadSubscriptionState } from './services/api'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{ path: '/login', component: Login, meta: { public: true } },
|
||||
{
|
||||
path: '/', component: Shell,
|
||||
children: [
|
||||
{ path: '', redirect: '/overview' },
|
||||
{ path: 'overview', component: Overview, meta: { title: '概览' } },
|
||||
{ path: 'plugins', component: Plugins, meta: { title: '已安装插件' } },
|
||||
{ path: 'plugins/:id/:tab?', component: PluginDetail, meta: { title: '插件详情' } },
|
||||
{ path: 'marketplace', component: Marketplace, meta: { title: '插件市场' } },
|
||||
{ path: 'operations', component: Operations, meta: { title: '操作记录' } },
|
||||
{ path: 'modules/subscription/:tab?', component: Subscription, meta: { title: '订阅管理' } },
|
||||
],
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/overview' },
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
if (!authState.ready) return true
|
||||
if (!authState.user && !to.meta.public) return '/login'
|
||||
if (authState.user && to.path === '/login') return '/overview'
|
||||
if (authState.user && to.path.startsWith('/modules/subscription')) {
|
||||
const enabled = await loadSubscriptionState()
|
||||
if (!enabled) return '/overview'
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,212 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export interface User {
|
||||
id?: string | number
|
||||
email?: string
|
||||
username?: string
|
||||
role?: string
|
||||
status?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface CaptchaConfig {
|
||||
enabled: boolean
|
||||
provider: '' | 'turnstile' | 'tencent' | 'aliyun' | string
|
||||
turnstile_enabled: boolean
|
||||
turnstile_site_key: string
|
||||
tencent_captcha_enabled: boolean
|
||||
tencent_captcha_app_id: string
|
||||
tencent_captcha_region: string
|
||||
aliyun_captcha_enabled: boolean
|
||||
aliyun_captcha_scene_id: string
|
||||
aliyun_captcha_prefix: string
|
||||
aliyun_captcha_region: string
|
||||
}
|
||||
|
||||
export const authState = reactive<{
|
||||
ready: boolean
|
||||
user: User | null
|
||||
csrf: string
|
||||
pendingToken: string
|
||||
}>({ ready: false, user: null, csrf: '', pendingToken: '' })
|
||||
|
||||
export const moduleState = reactive<{
|
||||
subscriptionEnabled: boolean
|
||||
loaded: boolean
|
||||
loading: boolean
|
||||
}>({ subscriptionEnabled: false, loaded: false, loading: false })
|
||||
|
||||
const basePath = () => {
|
||||
const meta = document.querySelector('meta[name="plugin-base-path"]')
|
||||
return (meta?.getAttribute('content') || '').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
constructor(message: string, status = 0) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
export async function request<T = any>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(options.headers || {})
|
||||
headers.set('Accept', 'application/json')
|
||||
const method = (options.method || 'GET').toUpperCase()
|
||||
if (options.body && !(options.body instanceof FormData)) headers.set('Content-Type', 'application/json')
|
||||
if (authState.csrf && method !== 'GET') headers.set('X-CSRF-Token', authState.csrf)
|
||||
const response = await fetch(`${basePath()}${path}`, { ...options, method, headers, credentials: 'same-origin' })
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && !path.startsWith('/login')) {
|
||||
authState.user = null
|
||||
authState.csrf = ''
|
||||
}
|
||||
throw new ApiError(data?.error || `请求失败 (${response.status})`, response.status)
|
||||
}
|
||||
return data as T
|
||||
}
|
||||
|
||||
export async function hydrateSession() {
|
||||
try {
|
||||
const data = await request<{ user: User; csrf_token: string }>('/api/me')
|
||||
authState.user = data.user || null
|
||||
authState.csrf = data.csrf_token || ''
|
||||
} catch (_) {
|
||||
authState.user = null
|
||||
authState.csrf = ''
|
||||
moduleState.loaded = false
|
||||
moduleState.subscriptionEnabled = false
|
||||
} finally {
|
||||
authState.ready = true
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCaptchaConfig(): Promise<CaptchaConfig> {
|
||||
const data = await request<Partial<CaptchaConfig>>('/api/captcha-config')
|
||||
return {
|
||||
enabled: data.enabled === true,
|
||||
provider: data.provider || '',
|
||||
turnstile_enabled: data.turnstile_enabled === true,
|
||||
turnstile_site_key: String(data.turnstile_site_key || ''),
|
||||
tencent_captcha_enabled: data.tencent_captcha_enabled === true,
|
||||
tencent_captcha_app_id: String(data.tencent_captcha_app_id || ''),
|
||||
tencent_captcha_region: String(data.tencent_captcha_region || 'cn'),
|
||||
aliyun_captcha_enabled: data.aliyun_captcha_enabled === true,
|
||||
aliyun_captcha_scene_id: String(data.aliyun_captcha_scene_id || ''),
|
||||
aliyun_captcha_prefix: String(data.aliyun_captcha_prefix || ''),
|
||||
aliyun_captcha_region: String(data.aliyun_captcha_region || 'cn'),
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadSubscriptionState(force = false): Promise<boolean> {
|
||||
if (moduleState.loaded && !force) return moduleState.subscriptionEnabled
|
||||
if (moduleState.loading) {
|
||||
// A concurrent router/Shell request should observe the same eventual
|
||||
// server decision instead of issuing an unbounded request fan-out.
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = window.setInterval(() => {
|
||||
if (!moduleState.loading) {
|
||||
window.clearInterval(timer)
|
||||
resolve()
|
||||
}
|
||||
}, 20)
|
||||
})
|
||||
return moduleState.subscriptionEnabled
|
||||
}
|
||||
moduleState.loading = true
|
||||
try {
|
||||
const data = await request<any>('/api/subscription/status')
|
||||
const payload = unwrap<any>(data) || {}
|
||||
moduleState.subscriptionEnabled = payload.enabled === true
|
||||
} catch (_) {
|
||||
moduleState.subscriptionEnabled = false
|
||||
} finally {
|
||||
moduleState.loaded = true
|
||||
moduleState.loading = false
|
||||
}
|
||||
return moduleState.subscriptionEnabled
|
||||
}
|
||||
|
||||
export async function login(email: string, password: string, extra: Record<string, unknown> = {}) {
|
||||
const data = await request<{ requires_2fa?: boolean; pending_token?: string; user?: User; csrf_token?: string }>('/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password, ...extra }),
|
||||
})
|
||||
if (data.requires_2fa) {
|
||||
authState.pendingToken = data.pending_token || ''
|
||||
return { requires2fa: true }
|
||||
}
|
||||
authState.pendingToken = ''
|
||||
authState.user = data.user || null
|
||||
authState.csrf = data.csrf_token || ''
|
||||
moduleState.loaded = false
|
||||
return { requires2fa: false }
|
||||
}
|
||||
|
||||
export async function verify2fa(code: string) {
|
||||
const data = await request<{ user?: User; csrf_token?: string }>('/login/2fa', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ pending_token: authState.pendingToken, totp_code: code }),
|
||||
})
|
||||
authState.pendingToken = ''
|
||||
authState.user = data.user || null
|
||||
authState.csrf = data.csrf_token || ''
|
||||
moduleState.loaded = false
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
try {
|
||||
await request('/logout', { method: 'POST' })
|
||||
} finally {
|
||||
authState.user = null
|
||||
authState.csrf = ''
|
||||
authState.pendingToken = ''
|
||||
moduleState.loaded = false
|
||||
moduleState.subscriptionEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
export function unwrap<T = any>(response: any): T {
|
||||
if (response && Object.prototype.hasOwnProperty.call(response, 'data') && ('code' in response || 'message' in response)) return response.data as T
|
||||
return response as T
|
||||
}
|
||||
|
||||
export function listFrom(value: any): any[] {
|
||||
const data = unwrap<any>(value)
|
||||
if (Array.isArray(data)) return data
|
||||
if (Array.isArray(data?.items)) return data.items
|
||||
if (Array.isArray(data?.list)) return data.list
|
||||
if (Array.isArray(data?.rows)) return data.rows
|
||||
return []
|
||||
}
|
||||
|
||||
export function formatDate(value: unknown) {
|
||||
if (!value) return '-'
|
||||
const date = new Date(String(value))
|
||||
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
export function stateLabel(value: unknown) {
|
||||
const labels: Record<string, string> = {
|
||||
healthy: '运行中', enabled: '运行中', disabled: '已停用', incompatible: 'Core 不兼容', error: '启用失败',
|
||||
starting: '启动中', draining: '停用中', upgrading: '升级中', rollback_pending: '等待回滚',
|
||||
}
|
||||
return labels[String(value)] || String(value || '未知')
|
||||
}
|
||||
|
||||
export function operationSucceeded(value: unknown) {
|
||||
return ['completed', 'success', 'succeeded'].includes(String(value || '').toLowerCase())
|
||||
}
|
||||
|
||||
export function operationTheme(value: unknown) {
|
||||
const state = String(value || '').toLowerCase()
|
||||
if (operationSucceeded(state)) return 'success'
|
||||
if (state === 'failed' || state === 'error') return 'danger'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
export function operationKey(kind: string, id = '') {
|
||||
return `${kind}-${id}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { AppIcon, CheckCircleIcon, CloudDownloadIcon, DeleteIcon, HistoryIcon, RollbackIcon, SettingIcon, UploadIcon } from 'tdesign-icons-vue-next'
|
||||
|
||||
export const actionLabels: Record<string, string> = { install: '入库', marketplace_install: '市场入库', enable: '启用', disable: '停用', upgrade: '升级', rollback: '回滚', uninstall: '卸载', menu_preview: '菜单预览', menu_apply: '应用菜单', config: '保存配置' }
|
||||
export function actionLabel(action: unknown) { return actionLabels[String(action)] || String(action || '操作') }
|
||||
export function actionIcon(action: unknown) {
|
||||
const icons: Record<string, any> = { install: CloudDownloadIcon, marketplace_install: CloudDownloadIcon, enable: CheckCircleIcon, disable: SettingIcon, upgrade: UploadIcon, rollback: RollbackIcon, uninstall: DeleteIcon, menu_preview: AppIcon, menu_apply: AppIcon, config: SettingIcon }
|
||||
return icons[String(action)] || HistoryIcon
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
export type TencentCaptchaRegion = 'cn' | 'intl'
|
||||
|
||||
export interface TencentCaptchaProof {
|
||||
ticket: string
|
||||
randstr: string
|
||||
}
|
||||
|
||||
interface TencentCaptchaResult {
|
||||
ret: number
|
||||
ticket?: string | null
|
||||
randstr?: string | null
|
||||
errorCode?: number
|
||||
}
|
||||
|
||||
interface TencentCaptchaInstance {
|
||||
show(): void
|
||||
destroy(): void
|
||||
}
|
||||
|
||||
interface TencentCaptchaConstructor {
|
||||
new (appId: string, callback: (result: TencentCaptchaResult) => void, options?: Record<string, unknown>): TencentCaptchaInstance
|
||||
new (element: HTMLElement, appId: string, callback: (result: TencentCaptchaResult) => void, options?: Record<string, unknown>): TencentCaptchaInstance
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
TencentCaptcha?: TencentCaptchaConstructor
|
||||
TCaptchaGlobal?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const SCRIPT_SRC: Record<TencentCaptchaRegion, string> = {
|
||||
cn: 'https://turing.captcha.qcloud.com/TJCaptcha.js',
|
||||
intl: 'https://ca.turing.captcha.qcloud.com/TJNCaptcha-global.js',
|
||||
}
|
||||
|
||||
let scriptPromise: Promise<TencentCaptchaConstructor> | null = null
|
||||
let loadedRegion: TencentCaptchaRegion | null = null
|
||||
|
||||
export function normalizeTencentCaptchaRegion(value?: string | null): TencentCaptchaRegion {
|
||||
return value === 'intl' ? 'intl' : 'cn'
|
||||
}
|
||||
|
||||
function existingRegion(): TencentCaptchaRegion {
|
||||
return window.TCaptchaGlobal === true ? 'intl' : 'cn'
|
||||
}
|
||||
|
||||
export function loadTencentCaptcha(region: TencentCaptchaRegion = 'cn'): Promise<TencentCaptchaConstructor> {
|
||||
if (window.TencentCaptcha && (loadedRegion === region || existingRegion() === region)) {
|
||||
return Promise.resolve(window.TencentCaptcha)
|
||||
}
|
||||
if (window.TencentCaptcha && loadedRegion !== null && loadedRegion !== region) {
|
||||
return Promise.reject(new Error('Tencent Captcha region changed; reload the page to apply it'))
|
||||
}
|
||||
if (scriptPromise && loadedRegion === region) return scriptPromise
|
||||
|
||||
loadedRegion = region
|
||||
scriptPromise = new Promise<TencentCaptchaConstructor>((resolve, reject) => {
|
||||
const existing = document.querySelector<HTMLScriptElement>(`script[src="${SCRIPT_SRC[region]}"]`)
|
||||
if (existing) {
|
||||
const finish = () => window.TencentCaptcha && existingRegion() === region
|
||||
? resolve(window.TencentCaptcha)
|
||||
: reject(new Error('Tencent Captcha SDK unavailable'))
|
||||
if (window.TencentCaptcha) finish()
|
||||
else existing.addEventListener('load', finish, { once: true })
|
||||
existing.addEventListener('error', () => reject(new Error('Failed to load Tencent Captcha SDK')), { once: true })
|
||||
return
|
||||
}
|
||||
const script = document.createElement('script')
|
||||
script.src = SCRIPT_SRC[region]
|
||||
script.async = true
|
||||
script.onload = () => {
|
||||
if (window.TencentCaptcha && existingRegion() === region) resolve(window.TencentCaptcha)
|
||||
else reject(new Error('Tencent Captcha SDK unavailable'))
|
||||
}
|
||||
script.onerror = () => reject(new Error('Failed to load Tencent Captcha SDK'))
|
||||
document.head.appendChild(script)
|
||||
}).catch((error) => {
|
||||
scriptPromise = null
|
||||
loadedRegion = null
|
||||
throw error
|
||||
})
|
||||
return scriptPromise
|
||||
}
|
||||
|
||||
export function resetTencentCaptchaLoaderForTest() {
|
||||
scriptPromise = null
|
||||
loadedRegion = null
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
:root {
|
||||
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
color: #1f2937;
|
||||
background: #f3f6fa;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
--app-blue: #0052d9;
|
||||
--app-ink: #1f2937;
|
||||
--app-muted: #718096;
|
||||
--app-border: #e5e7eb;
|
||||
--app-panel: #fff;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #app { min-width: 320px; min-height: 100%; margin: 0; }
|
||||
body { overflow-x: hidden; background: #f3f6fa; }
|
||||
button, input, textarea, select { font: inherit; }
|
||||
a, button { -webkit-tap-highlight-color: transparent; }
|
||||
.mono, code, .numeric, .stat-card strong { font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; font-variant-numeric: tabular-nums; }
|
||||
.wrap-anywhere { overflow-wrap: anywhere; word-break: break-word; }
|
||||
.visually-hidden { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0, 0, 0, 0) !important; white-space: nowrap !important; border: 0 !important; }
|
||||
.boot-screen { min-height: 100vh; display: grid; place-items: center; color: var(--app-muted); }
|
||||
|
||||
/* Login */
|
||||
.login-page { position: relative; min-height: 100vh; display: grid; place-items: center; padding: 40px 24px; background: radial-gradient(circle at 12% 10%, #e8f1ff 0, transparent 34%), #f5f7fb; }
|
||||
.login-accent { position: absolute; inset: 0 0 auto; height: 4px; background: var(--app-blue); }
|
||||
.login-layout { width: min(1060px, 100%); display: grid; grid-template-columns: minmax(0, 1fr) minmax(360px, 440px); gap: 80px; align-items: center; }
|
||||
.brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
.brand-mark { display: grid; place-items: center; width: 42px; height: 42px; flex: 0 0 auto; border-radius: 10px; color: #fff; background: var(--app-blue); box-shadow: 0 8px 20px rgb(0 82 217 / 20%); }
|
||||
.brand-title { color: #111827; font-size: 18px; font-weight: 700; letter-spacing: .01em; }
|
||||
.brand-subtitle { margin-top: 2px; color: var(--app-muted); font-size: 12px; }
|
||||
.brand-login { margin-bottom: 72px; }
|
||||
.login-intro .eyebrow { margin-bottom: 14px; }
|
||||
.eyebrow { margin: 0 0 7px; color: #7b8794; font-size: 11px; font-weight: 700; letter-spacing: .12em; line-height: 1.3; }
|
||||
.login-intro h1 { max-width: 560px; margin: 0 0 18px; color: #111827; font-size: clamp(32px, 4vw, 48px); line-height: 1.12; letter-spacing: 0; }
|
||||
.login-lead { max-width: 560px; margin: 0; color: #5f6b7a; font-size: 16px; line-height: 1.75; }
|
||||
.login-points { display: grid; gap: 13px; margin-top: 34px; }
|
||||
.login-points div { display: flex; align-items: center; gap: 10px; color: #4b5563; font-size: 13px; }
|
||||
.login-points svg { color: #00a870; flex: 0 0 auto; }
|
||||
.login-card { border-radius: 10px !important; box-shadow: 0 18px 50px rgb(15 23 42 / 9%); }
|
||||
.login-card .t-card__body { padding: 30px; }
|
||||
.login-card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
|
||||
.login-card h2 { margin: 0; color: #111827; font-size: 25px; line-height: 1.25; }
|
||||
.login-card > .t-card__body > .muted { margin: 0 0 24px; }
|
||||
.muted, .secondary-text { color: var(--app-muted); font-size: 13px; line-height: 1.55; }
|
||||
.login-form { display: grid; gap: 4px; }
|
||||
.login-form .t-form__item { margin-bottom: 14px; }
|
||||
.login-form .t-button { height: 42px; margin-top: 5px; }
|
||||
.login-form .captcha-challenge + .t-button { margin-top: 2px; }
|
||||
.login-error { margin-top: 18px; }
|
||||
.login-security { display: flex; align-items: center; gap: 7px; justify-content: center; margin-top: 24px; padding-top: 18px; border-top: 1px solid #edf0f4; color: #8792a2; font-size: 11px; }
|
||||
|
||||
/* App shell */
|
||||
.app-shell { min-height: 100vh; }
|
||||
.app-aside { position: relative; z-index: 30; display: flex; flex-direction: column; min-height: 100vh; border-right: 1px solid #e6eaf0; background: #fff; }
|
||||
.brand { padding: 23px 22px 20px; }
|
||||
.app-aside .brand-mark { width: 36px; height: 36px; border-radius: 8px; box-shadow: none; }
|
||||
.nav-caption { padding: 17px 22px 8px; color: #9aa5b4; font-size: 11px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
|
||||
.nav-caption-module { padding-top: 5px; }
|
||||
.main-menu { border-right: 0 !important; }
|
||||
.main-menu .t-menu__item { min-height: 44px; margin: 2px 10px; border-radius: 5px; color: #4b5563; font-size: 14px; }
|
||||
.main-menu .t-menu__item.t-is-active { color: var(--app-blue); background: #e8f1ff; }
|
||||
.main-menu .t-menu__item:hover { background: #f4f7fb; }
|
||||
.main-menu .t-menu__item.t-is-disabled { opacity: .55; }
|
||||
.nav-divider { margin: 10px 22px 0 !important; }
|
||||
.aside-footer { display: grid; gap: 10px; margin-top: auto; padding: 18px 22px 22px; border-top: 1px solid #edf0f4; }
|
||||
.status-tag { justify-self: start; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.version-text { color: #9aa5b4; font-size: 11px; }
|
||||
.main-layout { min-width: 0; min-height: 100vh; }
|
||||
.app-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; height: 64px; padding: 0 34px; border-bottom: 1px solid #e6eaf0; background: #fff; }
|
||||
.header-left, .header-right, .breadcrumbs, .user-button { display: flex; align-items: center; min-width: 0; }
|
||||
.header-left, .header-right { gap: 13px; }
|
||||
.breadcrumbs { gap: 9px; color: #8a96a5; font-size: 13px; }
|
||||
.breadcrumbs strong { overflow: hidden; color: #1f2937; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.secure-tag { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
|
||||
.user-button { gap: 6px; color: #374151; }
|
||||
.user-name { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mobile-menu-button { display: none; }
|
||||
.app-content { min-width: 0; padding: 32px 34px 56px; }
|
||||
.page-view { width: min(1400px, 100%); min-width: 0; margin: 0 auto; }
|
||||
.page-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 22px; margin-bottom: 24px; padding-bottom: 21px; border-bottom: 0; }
|
||||
.page-header h1 { margin: 0 0 5px; color: #111827; font-size: 28px; font-weight: 700; line-height: 1.25; letter-spacing: 0; }
|
||||
.page-subtitle { max-width: 720px; margin: 0; color: #718096; font-size: 13px; line-height: 1.6; }
|
||||
.page-actions, .toolbar-actions, .table-actions, .detail-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
|
||||
.page-actions { flex: 0 0 auto; }
|
||||
.page-alert { margin-bottom: 16px; }
|
||||
.page-toolbar, .section-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
|
||||
.section-toolbar { align-items: flex-end; }
|
||||
.section-toolbar h2 { margin: 0 0 4px; color: #1f2937; font-size: 20px; }
|
||||
.toolbar-hint { color: #9aa5b4; font-size: 12px; }
|
||||
|
||||
/* Cards and statistics */
|
||||
.stat-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
|
||||
.stat-card { min-width: 0; border-top: 3px solid #94a3b8 !important; border-radius: 7px !important; }
|
||||
.stat-card .t-card__body { padding: 18px; }
|
||||
.stat-card.stat-success { border-top-color: #00a870 !important; }
|
||||
.stat-card.stat-warning { border-top-color: #ed7b2f !important; }
|
||||
.stat-card.stat-danger { border-top-color: #d54941 !important; }
|
||||
.stat-card.stat-primary { border-top-color: var(--app-blue) !important; }
|
||||
.stat-card-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 15px; color: #637083; font-size: 12px; }
|
||||
.stat-card-top svg { color: var(--app-blue); }
|
||||
.stat-card strong { display: block; margin-bottom: 7px; color: #111827; font-size: 29px; font-weight: 700; line-height: 1; }
|
||||
.stat-card small { color: #96a0ae; font-size: 11px; }
|
||||
.stat-success .stat-card-top svg { color: #00a870; }
|
||||
.stat-warning .stat-card-top svg { color: #ed7b2f; }
|
||||
.stat-danger .stat-card-top svg { color: #d54941; }
|
||||
.overview-grid, .detail-grid { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(340px, .8fr); gap: 16px; margin-bottom: 16px; }
|
||||
.panel-card, .table-card, .filter-card, .empty-card { min-width: 0; border-radius: 7px !important; }
|
||||
.panel-card .t-card__header, .table-card .t-card__header { padding: 18px 20px 0; }
|
||||
.panel-card .t-card__title, .table-card .t-card__title { color: #1f2937; font-size: 16px; font-weight: 600; }
|
||||
.panel-card .t-card__body { padding: 18px 20px 20px; }
|
||||
.panel-card .t-card__actions { color: var(--app-blue); }
|
||||
.trend-chart { width: 100%; height: 255px; min-height: 220px; }
|
||||
.guidance-card { margin-bottom: 0; }
|
||||
.guidance-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 22px; }
|
||||
.guidance-item { display: grid; grid-template-columns: 31px minmax(0, 1fr); gap: 10px; }
|
||||
.guidance-number { color: var(--app-blue); font-size: 12px; font-weight: 700; line-height: 1.7; }
|
||||
.guidance-item strong { color: #344054; font-size: 13px; }
|
||||
.guidance-item p { margin: 5px 0 0; color: #7c8795; font-size: 12px; line-height: 1.55; }
|
||||
.table-action { display: inline-flex; align-items: center; gap: 7px; color: #344054; }
|
||||
.table-actions { justify-content: flex-start; flex-wrap: nowrap; }
|
||||
.table-actions .t-button { flex: 0 0 auto; }
|
||||
.cell-note { display: block; margin-top: 3px; color: #9aa5b4; font-size: 11px; }
|
||||
.plugin-cell { display: flex; align-items: center; gap: 11px; min-width: 0; }
|
||||
.plugin-cell > div:last-child { display: grid; gap: 3px; min-width: 0; }
|
||||
.plugin-cell strong { overflow: hidden; color: #263244; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.plugin-avatar, .market-icon { display: grid; place-items: center; flex: 0 0 auto; width: 34px; height: 34px; border-radius: 7px; color: var(--app-blue); background: #e8f1ff; }
|
||||
.empty-card { padding: 34px; }
|
||||
.filter-card { margin-bottom: 16px; }
|
||||
.filter-card .t-card__body { padding: 17px 20px; }
|
||||
.filter-grid { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr) auto; align-items: end; gap: 12px; }
|
||||
.filter-grid .t-form__item { margin-bottom: 0; }
|
||||
|
||||
/* Detail */
|
||||
.detail-back { margin-bottom: 14px; }
|
||||
.detail-back .t-link { font-size: 13px; }
|
||||
.detail-tabs { margin-bottom: 22px; }
|
||||
.detail-tabs .t-tabs__nav { overflow-x: auto; }
|
||||
.detail-tabs .t-tabs__nav-container { min-width: max-content; }
|
||||
.detail-tabs .t-tabs__content { display: none; }
|
||||
.detail-content { min-width: 0; }
|
||||
.detail-actions { margin-bottom: 16px; }
|
||||
.description-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px 24px; margin: 0; }
|
||||
.description-grid > div { min-width: 0; }
|
||||
.description-grid dt { margin-bottom: 5px; color: #8a96a5; font-size: 12px; }
|
||||
.description-grid dd { min-width: 0; color: #344054; font-size: 13px; line-height: 1.5; }
|
||||
.inline-alert { margin-top: 20px; }
|
||||
.narrow-content { max-width: 900px; }
|
||||
.tag-gap { margin-left: 8px; }
|
||||
.preview-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.preview-grid h3 { margin: 0 0 8px; font-size: 13px; }
|
||||
.preview-grid pre { min-height: 210px; max-height: 420px; margin: 0; padding: 13px; overflow: auto; border: 1px solid #e5e7eb; border-radius: 5px; color: #344054; background: #f7f9fc; font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.loading-panel { display: grid; place-items: center; min-height: 260px; }
|
||||
.pagination-row { display: flex; justify-content: flex-end; padding-top: 17px; }
|
||||
.drawer-description { margin-bottom: 20px; }
|
||||
|
||||
/* Marketplace */
|
||||
.market-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||
.market-card { min-width: 0; border-radius: 7px !important; }
|
||||
.market-card .t-card__header { padding: 20px 20px 0; }
|
||||
.market-card .t-card__body { padding: 16px 20px; }
|
||||
.market-card .t-card__footer { padding: 0 20px 20px; }
|
||||
.market-heading { display: flex; align-items: flex-start; gap: 11px; min-width: 0; }
|
||||
.market-title { min-width: 0; flex: 1 1 auto; }
|
||||
.market-title h2 { margin: 0 0 4px; overflow: hidden; color: #263244; font-size: 16px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.market-heading > .t-tag { flex: 0 0 auto; }
|
||||
.market-description { min-height: 42px; margin: 0 0 18px; color: #687585; font-size: 13px; line-height: 1.6; }
|
||||
.market-meta { gap: 13px 20px; margin-bottom: 17px; }
|
||||
.market-hash { display: grid; gap: 5px; padding-top: 13px; border-top: 1px solid #edf0f4; }
|
||||
.market-hash span { color: #9aa5b4; font-size: 11px; }
|
||||
.market-hash code { color: #556274; font-size: 11px; overflow-wrap: anywhere; }
|
||||
|
||||
/* TDesign table containment */
|
||||
.table-card { overflow: hidden; }
|
||||
.table-card .t-card__body { min-width: 0; padding: 0 0 18px; }
|
||||
.table-card .t-table { min-width: 680px; }
|
||||
.table-card .t-table__content { overflow-x: auto; }
|
||||
.table-card .t-table__header th { background: #f8fafc; color: #64748b; font-size: 12px; font-weight: 600; }
|
||||
.table-card .t-table__body td { color: #344054; font-size: 13px; }
|
||||
.table-card .t-table__pagination { padding: 15px 20px 0; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.login-layout { gap: 44px; }
|
||||
.app-header { padding: 0 24px; }
|
||||
.app-content { padding: 28px 24px 44px; }
|
||||
.stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.overview-grid, .detail-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.login-page { padding: 26px 16px; place-items: start center; }
|
||||
.login-layout { grid-template-columns: 1fr; gap: 28px; margin-top: 16px; }
|
||||
.brand-login { margin-bottom: 34px; }
|
||||
.login-intro h1 { font-size: 33px; }
|
||||
.login-lead { font-size: 14px; }
|
||||
.login-points { display: none; }
|
||||
.login-card .t-card__body { padding: 22px 18px; }
|
||||
.app-aside { position: fixed; inset: 0 auto 0 0; width: min(280px, calc(100vw - 48px)); min-height: 100vh; transform: translateX(-105%); transition: transform .2s ease; box-shadow: 12px 0 32px rgb(15 23 42 / 16%); }
|
||||
.app-aside.app-aside-mobile-open { transform: translateX(0); }
|
||||
.mobile-scrim { position: fixed; z-index: 20; inset: 0; background: rgb(15 23 42 / 32%); }
|
||||
.mobile-menu-button { display: inline-flex; }
|
||||
.app-header { height: 58px; padding: 0 14px; }
|
||||
.header-left, .header-right { gap: 8px; }
|
||||
.secure-tag { display: none; }
|
||||
.user-name { max-width: 108px; }
|
||||
.app-content { padding: 22px 14px 36px; }
|
||||
.page-header { align-items: flex-start; flex-direction: column; gap: 14px; margin-bottom: 18px; }
|
||||
.page-header h1 { font-size: 23px; }
|
||||
.page-actions { width: 100%; justify-content: flex-start; }
|
||||
.page-actions .t-button { flex: 1 1 auto; min-width: 0; }
|
||||
.page-toolbar, .section-toolbar { align-items: flex-start; flex-direction: column; gap: 8px; }
|
||||
.section-toolbar .t-button, .toolbar-actions { width: 100%; justify-content: flex-start; }
|
||||
.stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; }
|
||||
.stat-card .t-card__body { padding: 13px; }
|
||||
.stat-card strong { font-size: 23px; }
|
||||
.stat-card-top { margin-bottom: 11px; }
|
||||
.overview-grid, .detail-grid, .market-grid { grid-template-columns: 1fr; gap: 12px; }
|
||||
.guidance-grid { grid-template-columns: 1fr; gap: 15px; }
|
||||
.panel-card .t-card__header, .table-card .t-card__header { padding: 15px 15px 0; }
|
||||
.panel-card .t-card__body { padding: 15px; }
|
||||
.filter-card .t-card__body { padding: 14px; }
|
||||
.filter-grid { grid-template-columns: 1fr; gap: 10px; }
|
||||
.filter-grid .t-button { justify-self: start; }
|
||||
.description-grid { grid-template-columns: 1fr; gap: 13px; }
|
||||
.detail-tabs { margin-bottom: 17px; }
|
||||
.detail-tabs .t-tabs__nav-item { padding: 0 12px; }
|
||||
.preview-grid { grid-template-columns: 1fr; }
|
||||
.market-card .t-card__header { padding: 16px 15px 0; }
|
||||
.market-card .t-card__body { padding: 14px 15px; }
|
||||
.market-card .t-card__footer { padding: 0 15px 15px; }
|
||||
.table-actions { flex-wrap: wrap; }
|
||||
.table-card .t-table { min-width: 720px; }
|
||||
.pagination-row { justify-content: flex-start; overflow-x: auto; }
|
||||
}
|
||||
@media (max-width: 430px) {
|
||||
.app-content { padding-left: 10px; padding-right: 10px; }
|
||||
.page-actions .t-button { padding-left: 10px; padding-right: 10px; font-size: 12px; }
|
||||
.breadcrumbs span { display: none; }
|
||||
.breadcrumbs strong { max-width: 150px; }
|
||||
.user-name { max-width: 90px; }
|
||||
.stat-card small { font-size: 10px; }
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<main class="login-page">
|
||||
<div class="login-accent" />
|
||||
<section class="login-layout">
|
||||
<div class="login-intro">
|
||||
<div class="brand brand-login"><div class="brand-mark"><ControlPlatformIcon size="24px" /></div><div><div class="brand-title">Sub2API</div><div class="brand-subtitle">插件控制面</div></div></div>
|
||||
<p class="eyebrow">BUSINESS PLUGIN CONTROL PLANE</p>
|
||||
<h1>统一管理你的业务插件</h1>
|
||||
<p class="login-lead">使用 Core 管理员会话进入插件市场、生命周期和业务模块。所有插件操作都在同一安全控制面完成。</p>
|
||||
<div class="login-points">
|
||||
<div><CheckCircleIcon size="18px" /><span>一次登录,访问已启用业务模块</span></div>
|
||||
<div><CheckCircleIcon size="18px" /><span>凭据仅保存在服务端会话</span></div>
|
||||
<div><CheckCircleIcon size="18px" /><span>每次变更都有可追溯操作记录</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<t-card class="login-card" :bordered="true">
|
||||
<div class="login-card-heading"><div><p class="eyebrow">ADMIN ACCESS</p><h2>管理员登录</h2></div><t-tag theme="primary" variant="light">仅管理员</t-tag></div>
|
||||
<p class="muted">使用 Sub2API Core 的管理员账号登录,普通账号没有访问权限。</p>
|
||||
<t-alert v-if="captchaConfigError" class="login-error" theme="warning" :message="captchaConfigError" />
|
||||
<t-form v-if="!twoFactor" ref="loginForm" class="login-form" :data="credentials" :rules="rules" @submit="submitLogin">
|
||||
<t-form-item label="邮箱" name="email"><t-input v-model="credentials.email" type="text" aria-label="邮箱" autocomplete="username" placeholder="admin@example.com" /></t-form-item>
|
||||
<t-form-item label="密码" name="password"><t-input v-model="credentials.password" type="password" aria-label="密码" autocomplete="current-password" placeholder="输入管理员密码" /></t-form-item>
|
||||
<CaptchaChallenge
|
||||
v-if="captchaLoaded && captchaConfig.enabled"
|
||||
ref="captchaRef"
|
||||
:enabled="captchaConfig.enabled"
|
||||
:provider="captchaConfig.provider"
|
||||
:turnstile-site-key="captchaConfig.turnstile_site_key"
|
||||
:tencent-app-id="captchaConfig.tencent_captcha_app_id"
|
||||
:tencent-region="captchaConfig.tencent_captcha_region"
|
||||
:aliyun-scene-id="captchaConfig.aliyun_captcha_scene_id"
|
||||
:aliyun-prefix="captchaConfig.aliyun_captcha_prefix"
|
||||
:aliyun-region="captchaConfig.aliyun_captcha_region"
|
||||
@verify="onCaptchaVerify"
|
||||
@expire="onCaptchaExpire"
|
||||
@error="onCaptchaError"
|
||||
/>
|
||||
<t-button theme="primary" type="submit" block :loading="loading" :disabled="!captchaLoaded || Boolean(captchaConfigError)"><template #icon><UserCircleIcon /></template>登录</t-button>
|
||||
</t-form>
|
||||
<t-form v-else class="login-form" :data="twoFactorData" @submit="submit2fa">
|
||||
<t-alert theme="info" title="需要二次验证">请输入 Core 返回的 6 位验证码。</t-alert>
|
||||
<t-form-item label="验证码" name="totp_code"><t-input v-model="twoFactorData.totp_code" inputmode="numeric" maxlength="6" placeholder="000000" /></t-form-item>
|
||||
<t-button theme="primary" type="submit" block :loading="loading"><template #icon><CheckCircleIcon /></template>验证并继续</t-button>
|
||||
<t-button variant="text" block @click="resetTwoFactor">返回账号登录</t-button>
|
||||
</t-form>
|
||||
<t-alert v-if="errorMessage" class="login-error" theme="error" :message="errorMessage" />
|
||||
<div class="login-security"><LockOnIcon size="15px" /> HttpOnly 会话 · CSRF 保护 · 8 小时绝对有效期</div>
|
||||
</t-card>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { CheckCircleIcon, ControlPlatformIcon, LockOnIcon, UserCircleIcon } from 'tdesign-icons-vue-next'
|
||||
import { MessagePlugin } from 'tdesign-vue-next'
|
||||
import CaptchaChallenge, { type CaptchaProof } from '../components/CaptchaChallenge.vue'
|
||||
import { getCaptchaConfig, login, verify2fa, type CaptchaConfig } from '../services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const twoFactor = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const captchaLoaded = ref(false)
|
||||
const captchaConfigError = ref('')
|
||||
const captchaRef = ref<InstanceType<typeof CaptchaChallenge> | null>(null)
|
||||
const captchaProof = ref<CaptchaProof | null>(null)
|
||||
const captchaConfig = reactive<CaptchaConfig>({
|
||||
enabled: false,
|
||||
provider: '',
|
||||
turnstile_enabled: false,
|
||||
turnstile_site_key: '',
|
||||
tencent_captcha_enabled: false,
|
||||
tencent_captcha_app_id: '',
|
||||
tencent_captcha_region: 'cn',
|
||||
aliyun_captcha_enabled: false,
|
||||
aliyun_captcha_scene_id: '',
|
||||
aliyun_captcha_prefix: '',
|
||||
aliyun_captcha_region: 'cn',
|
||||
})
|
||||
const credentials = reactive({ email: '', password: '' })
|
||||
const twoFactorData = reactive({ totp_code: '' })
|
||||
const rules = {
|
||||
email: [{ required: true, message: '请输入管理员邮箱', type: 'error' as const }],
|
||||
password: [{ required: true, message: '请输入密码', type: 'error' as const }],
|
||||
}
|
||||
|
||||
async function submitLogin() {
|
||||
if (!credentials.email.trim() || !credentials.password) { errorMessage.value = '请输入邮箱和密码'; return }
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const proof = captchaConfig.enabled ? (await captchaRef.value?.verifyAction()) || null : null
|
||||
if (captchaConfig.enabled && !proof) {
|
||||
errorMessage.value = '请先完成安全验证'
|
||||
return
|
||||
}
|
||||
captchaProof.value = proof
|
||||
const extra = proof?.provider === 'tencent'
|
||||
? { tencent_captcha_ticket: proof.token, tencent_captcha_randstr: proof.randstr }
|
||||
: proof?.provider === 'aliyun'
|
||||
? { turnstile_token: proof.token }
|
||||
: proof?.provider === 'turnstile'
|
||||
? { turnstile_token: proof.token }
|
||||
: {}
|
||||
const result = await login(credentials.email.trim(), credentials.password, extra)
|
||||
if (result.requires2fa) twoFactor.value = true
|
||||
else {
|
||||
MessagePlugin.success('登录成功')
|
||||
router.push('/overview')
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '登录失败,请稍后重试'
|
||||
} finally {
|
||||
captchaRef.value?.reset()
|
||||
captchaProof.value = null
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submit2fa() {
|
||||
if (!/^\d{6}$/.test(twoFactorData.totp_code)) {
|
||||
errorMessage.value = '请输入 6 位数字验证码'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
await verify2fa(twoFactorData.totp_code)
|
||||
MessagePlugin.success('登录成功')
|
||||
router.push('/overview')
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '验证码校验失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetTwoFactor() {
|
||||
twoFactor.value = false
|
||||
twoFactorData.totp_code = ''
|
||||
errorMessage.value = ''
|
||||
}
|
||||
|
||||
function onCaptchaVerify(proof: CaptchaProof) {
|
||||
captchaProof.value = proof
|
||||
errorMessage.value = ''
|
||||
}
|
||||
|
||||
function onCaptchaExpire() {
|
||||
captchaProof.value = null
|
||||
}
|
||||
|
||||
function onCaptchaError() {
|
||||
captchaProof.value = null
|
||||
errorMessage.value = '安全验证加载失败,请刷新页面重试'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
captchaConfigError.value = ''
|
||||
try {
|
||||
const loaded = await getCaptchaConfig()
|
||||
Object.assign(captchaConfig, loaded)
|
||||
const enabledProviders = [
|
||||
loaded.turnstile_enabled,
|
||||
loaded.tencent_captcha_enabled,
|
||||
loaded.aliyun_captcha_enabled,
|
||||
].filter(Boolean).length
|
||||
if (enabledProviders > 1) {
|
||||
captchaConfigError.value = 'Core 同时启用了多个验证码服务,请保留一个服务商后重试'
|
||||
} else if (loaded.enabled && !loaded.provider) {
|
||||
captchaConfigError.value = '验证码配置不完整,请联系管理员'
|
||||
}
|
||||
} catch (error) {
|
||||
captchaConfigError.value = error instanceof Error ? `验证码配置加载失败:${error.message}` : '验证码配置加载失败,请刷新页面重试'
|
||||
} finally {
|
||||
captchaLoaded.value = true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div class="page-view">
|
||||
<PageHeader eyebrow="MARKETPLACE" title="插件市场" subtitle="从受控目录查看可用版本,下载后先入库,再由管理员手动启用。">
|
||||
<template #actions><t-button variant="outline" :loading="loading" @click="load"><template #icon><RefreshIcon /></template>刷新市场</t-button></template>
|
||||
</PageHeader>
|
||||
<t-alert theme="info" class="page-alert" message="归档地址只在控制面服务端访问,浏览器不会接触下载 URL。" />
|
||||
<t-alert v-if="error" theme="error" class="page-alert" :message="error" />
|
||||
<section v-if="items.length" class="market-grid">
|
||||
<t-card v-for="item in items" :key="`${item.plugin_id}-${item.version}`" class="market-card" :bordered="true">
|
||||
<template #header><div class="market-heading"><div class="market-icon"><CloudDownloadIcon size="20px" /></div><div class="market-title"><h2>{{ item.name || item.plugin_id }}</h2><span class="secondary-text mono">{{ item.plugin_id }} · v{{ item.version || '-' }}</span></div><t-tag :theme="marketState(item).theme as any" variant="light">{{ marketState(item).label }}</t-tag></div></template>
|
||||
<p class="market-description">{{ item.description || '由受控插件目录提供的 Business Plugin' }}</p>
|
||||
<dl class="description-grid market-meta"><div><dt>发布者</dt><dd>{{ item.publisher || item.publisher_key_id || '-' }}</dd></div><div><dt>兼容性</dt><dd>{{ item.compatibility?.status || 'unknown' }}</dd></div><div><dt>发布时间</dt><dd>{{ formatDate(item.published_at) }}</dd></div><div><dt>包大小</dt><dd>{{ formatSize(item.archive_size) }}</dd></div></dl>
|
||||
<div class="market-hash"><span>SHA-256</span><code>{{ item.archive_sha256 || '-' }}</code></div>
|
||||
<template #footer><t-button v-if="!item.installed" theme="primary" block :loading="busy === item.plugin_id" @click="install(item)"><template #icon><CloudDownloadIcon /></template>下载并入库</t-button><t-button v-else variant="outline" block @click="router.push(`/plugins/${encodeURIComponent(item.plugin_id)}/revisions`)">查看已安装版本</t-button></template>
|
||||
</t-card>
|
||||
</section>
|
||||
<t-card v-else class="empty-card" :bordered="true"><t-empty description="暂无可用插件" /></t-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { CloudDownloadIcon, RefreshIcon } from 'tdesign-icons-vue-next'
|
||||
import { MessagePlugin } from 'tdesign-vue-next'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import { formatDate, listFrom, operationKey, request } from '../services/api'
|
||||
|
||||
const router = useRouter(); const items = ref<any[]>([]); const loading = ref(false); const error = ref(''); const busy = ref('')
|
||||
function marketState(item: any) { if (!item.installed) return { label: '可入库', theme: 'primary' }; return String(item.installed_version).replace(/^v/, '') === String(item.version).replace(/^v/, '') ? { label: '已入库', theme: 'success' } : { label: '已有版本', theme: 'warning' } }
|
||||
function formatSize(value: unknown) { const n = Number(value); if (!Number.isFinite(n) || n <= 0) return '-'; if (n > 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`; return `${Math.ceil(n / 1024)} KB` }
|
||||
async function load() { loading.value = true; error.value = ''; try { const [market, installed] = await Promise.all([request<any>('/api/marketplace'), request<any>('/api/plugins')]); const map = new Map(listFrom(installed).map((p) => [p.plugin_id, p])); items.value = listFrom(market).map((item) => ({ ...item, installed: item.installed || map.has(item.plugin_id), installed_version: item.installed_version || map.get(item.plugin_id)?.version })) } catch (e) { error.value = e instanceof Error ? e.message : '市场加载失败' } finally { loading.value = false } }
|
||||
async function install(item: any) { busy.value = item.plugin_id; try { const result = await request<any>('/api/marketplace/install', { method: 'POST', headers: { 'Idempotency-Key': operationKey('marketplace-install', `${item.plugin_id}-${item.version}`) }, body: JSON.stringify({ plugin_id: item.plugin_id, version: item.version }) }); MessagePlugin.success(`插件已入库,待手动启用:${result.operation_id || result.state}`); await load() } catch (e) { error.value = e instanceof Error ? e.message : '插件入库失败' } finally { busy.value = '' } }
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="page-view">
|
||||
<PageHeader eyebrow="OPERATIONS" title="操作记录" subtitle="按插件和操作类型筛选控制面审计结果。">
|
||||
<template #actions><t-button variant="outline" :loading="loading" @click="load"><template #icon><RefreshIcon /></template>刷新</t-button></template>
|
||||
</PageHeader>
|
||||
<t-card class="filter-card" :bordered="true"><div class="filter-grid"><t-select v-model="filters.plugin" label="插件" placeholder="全部插件" clearable><t-option value="" label="全部插件" /> <t-option v-for="plugin in plugins" :key="plugin.plugin_id" :value="plugin.plugin_id" :label="plugin.name || plugin.plugin_id" /></t-select><t-select v-model="filters.action" label="操作类型" placeholder="全部操作" clearable><t-option value="" label="全部操作" /><t-option v-for="action in actionSet" :key="action" :value="action" :label="actionLabel(action)" /></t-select><t-button variant="text" @click="clearFilters">清除筛选</t-button></div></t-card>
|
||||
<t-alert v-if="error" theme="error" :message="error" class="page-alert" />
|
||||
<t-card class="table-card" :bordered="true"><t-table :data="filteredItems" :columns="columns" row-key="operation_id" :loading="loading" :hover="true"><template #action="{ row }"><span class="table-action"><component :is="actionIcon(row.action)" size="15px" />{{ actionLabel(row.action) }}</span></template><template #plugin_id="{ row }"><span class="mono wrap-anywhere">{{ row.plugin_id || '-' }}</span></template><template #result="{ row }"><t-tag :theme="operationTheme(row.result)" variant="light">{{ operationLabel(row.result) }}</t-tag></template><template #request_id="{ row }"><span class="secondary-text mono wrap-anywhere">{{ row.request_id || '-' }}</span></template><template #time="{ row }"><span class="secondary-text">{{ formatDate(row.time) }}</span></template><template #operation_id="{ row }"><t-link v-if="row.operation_id" theme="primary" @click="openDetail(row.operation_id)">查看详情</t-link></template></t-table><t-empty v-if="!filteredItems.length" description="还没有操作记录" /></t-card>
|
||||
<t-drawer v-model:visible="detailVisible" header="操作详情" size="min(520px, 100vw)"><dl v-if="detail" class="description-grid drawer-description"><div><dt>操作 ID</dt><dd class="mono wrap-anywhere">{{ detail.id }}</dd></div><div><dt>操作类型</dt><dd>{{ actionLabel(detail.kind) }}</dd></div><div><dt>插件</dt><dd class="mono wrap-anywhere">{{ detail.plugin_id || '-' }}</dd></div><div><dt>状态</dt><dd>{{ detail.state || '-' }}</dd></div><div><dt>请求 ID</dt><dd class="mono wrap-anywhere">{{ detail.request_id || '-' }}</dd></div><div><dt>更新时间</dt><dd>{{ formatDate(detail.updated_at) }}</dd></div></dl><t-alert v-if="detail?.error" theme="error" :message="detail.error" /><t-alert v-if="detail?.warning" theme="warning" :message="detail.warning" /></t-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { RefreshIcon } from 'tdesign-icons-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import { actionIcon, actionLabel } from '../services/labels'
|
||||
import { formatDate, listFrom, operationSucceeded, operationTheme, request } from '../services/api'
|
||||
|
||||
const router = useRouter(); const plugins = ref<any[]>([]); const items = ref<any[]>([]); const loading = ref(false); const error = ref(''); const filters = ref({ plugin: '', action: '' }); const detailVisible = ref(false); const detail = ref<any>(null)
|
||||
const columns = [{ colKey: 'action', title: '操作', width: 170 }, { colKey: 'plugin_id', title: '插件', minWidth: 220 }, { colKey: 'result', title: '结果', width: 100 }, { colKey: 'request_id', title: '请求 ID', minWidth: 220 }, { colKey: 'time', title: '时间', width: 180 }, { colKey: 'operation_id', title: '', width: 100, fixed: 'right' as const }]
|
||||
const operationLabel = (value: unknown) => operationSucceeded(value) ? '已完成' : String(value || '未知')
|
||||
const actionSet = computed(() => [...new Set(items.value.map((item) => item.action).filter(Boolean))].sort())
|
||||
const filteredItems = computed(() => items.value.filter((item) => (!filters.value.plugin || item.plugin_id === filters.value.plugin) && (!filters.value.action || item.action === filters.value.action)).sort((a, b) => new Date(String(b.time)).getTime() - new Date(String(a.time)).getTime()))
|
||||
function clearFilters() { filters.value = { plugin: '', action: '' } }
|
||||
async function load() { loading.value = true; error.value = ''; try { const [audit, pluginData] = await Promise.all([request<any>('/api/audit'), request<any>('/api/plugins')]); items.value = listFrom(audit); plugins.value = listFrom(pluginData) } catch (e) { error.value = e instanceof Error ? e.message : '操作记录加载失败' } finally { loading.value = false } }
|
||||
async function openDetail(id: string) { detailVisible.value = true; detail.value = await request(`/api/operations/${encodeURIComponent(id)}`).catch(() => null) }
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div class="page-view">
|
||||
<PageHeader eyebrow="OVERVIEW" title="概览" subtitle="查看插件控制面的总体状态、运行健康和最近活动。">
|
||||
<template #actions><t-button theme="primary" @click="router.push('/plugins')"><template #icon><AppIcon /></template>管理插件</t-button></template>
|
||||
</PageHeader>
|
||||
<t-alert v-if="error" theme="warning" :message="error" class="page-alert" />
|
||||
<section class="stat-grid" aria-label="插件统计">
|
||||
<t-card v-for="stat in stats" :key="stat.label" class="stat-card" :class="`stat-${stat.theme}`" :bordered="true">
|
||||
<div class="stat-card-top"><span>{{ stat.label }}</span><component :is="stat.icon" size="18px" /></div>
|
||||
<strong>{{ stat.value }}</strong><small>{{ stat.caption }}</small>
|
||||
</t-card>
|
||||
</section>
|
||||
<section class="overview-grid">
|
||||
<t-card class="panel-card" title="近期操作" :bordered="true">
|
||||
<template #actions><t-link theme="primary" @click="router.push('/operations')">查看全部</t-link></template>
|
||||
<t-table v-if="recent.length" :data="recent" :columns="recentColumns" row-key="id" size="small" :hover="true">
|
||||
<template #action="{ row }"><span class="table-action"><component :is="actionIcon(row.action)" size="15px" />{{ actionLabel(row.action) }}</span></template>
|
||||
<template #result="{ row }"><t-tag :theme="operationTheme(row.result)" variant="light">{{ operationLabel(row.result) }}</t-tag></template>
|
||||
<template #time="{ row }"><span class="secondary-text">{{ formatDate(row.time) }}</span></template>
|
||||
</t-table>
|
||||
<t-empty v-else description="还没有操作记录" />
|
||||
</t-card>
|
||||
<t-card class="panel-card" title="操作趋势" :bordered="true">
|
||||
<template #actions><t-tag theme="primary" variant="light">最近 7 天</t-tag></template>
|
||||
<TrendChart :labels="trendLabels" :values="trendValues" />
|
||||
</t-card>
|
||||
</section>
|
||||
<t-card class="panel-card guidance-card" title="生命周期提示" :bordered="true">
|
||||
<div class="guidance-grid">
|
||||
<div v-for="(item, index) in guidance" :key="item.title" class="guidance-item"><span class="guidance-number">{{ String(index + 1).padStart(2, '0') }}</span><div><strong>{{ item.title }}</strong><p>{{ item.body }}</p></div></div>
|
||||
</div>
|
||||
</t-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { AppIcon, CheckCircleIcon, ErrorCircleIcon, TimeIcon, HistoryIcon } from 'tdesign-icons-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import TrendChart from '../components/TrendChart.vue'
|
||||
import { actionIcon, actionLabel } from '../services/labels'
|
||||
import { formatDate, listFrom, operationSucceeded, operationTheme, request } from '../services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const plugins = ref<any[]>([])
|
||||
const recent = ref<any[]>([])
|
||||
const error = ref('')
|
||||
const recentColumns = [
|
||||
{ colKey: 'action', title: '操作', width: 150 },
|
||||
{ colKey: 'plugin_id', title: '插件', ellipsis: true },
|
||||
{ colKey: 'result', title: '结果', width: 90 },
|
||||
{ colKey: 'time', title: '时间', width: 170 },
|
||||
]
|
||||
const operationLabel = (value: unknown) => operationSucceeded(value) ? '已完成' : String(value || '未知')
|
||||
const stats = computed(() => {
|
||||
const healthy = plugins.value.filter((p) => ['healthy', 'enabled'].includes(p.state)).length
|
||||
const staged = plugins.value.filter((p) => ['disabled', 'incompatible'].includes(p.state) || p.installation_status === 'staged').length
|
||||
const attention = plugins.value.filter((p) => ['error', 'starting', 'upgrading', 'rollback_pending'].includes(p.state)).length
|
||||
return [
|
||||
{ label: '已登记插件', value: plugins.value.length, caption: '全部已校验版本', theme: 'neutral', icon: AppIcon },
|
||||
{ label: '运行中', value: healthy, caption: '健康检查通过', theme: 'success', icon: CheckCircleIcon },
|
||||
{ label: '待启用', value: staged, caption: '已入库,等待启动', theme: 'warning', icon: TimeIcon },
|
||||
{ label: '需要关注', value: attention, caption: '失败或迁移中', theme: 'danger', icon: ErrorCircleIcon },
|
||||
]
|
||||
})
|
||||
const guidance = [
|
||||
{ title: '入库不等于启用', body: '上传或市场下载只完成验签、哈希和兼容性校验。' },
|
||||
{ title: '启用前先配置', body: '服务地址和菜单地址在插件详情的配置页维护。' },
|
||||
{ title: '高风险操作可追溯', body: '升级、回滚和卸载均保留操作 ID 与审计结果。' },
|
||||
]
|
||||
const trendLabels = computed(() => ['-6 天', '-5 天', '-4 天', '-3 天', '-2 天', '昨天', '今天'])
|
||||
const trendValues = computed(() => {
|
||||
const values = new Array(7).fill(0)
|
||||
recent.value.forEach((item) => { const date = new Date(item.time); if (!Number.isNaN(date.getTime())) { const days = Math.floor((Date.now() - date.getTime()) / 86400000); if (days >= 0 && days < 7) values[6 - days] += 1 } })
|
||||
return values
|
||||
})
|
||||
async function load() {
|
||||
try {
|
||||
const [pluginData, auditData] = await Promise.all([request('/api/plugins'), request('/api/audit')])
|
||||
plugins.value = listFrom(pluginData)
|
||||
recent.value = listFrom(auditData).sort((a, b) => new Date(String(b.time)).getTime() - new Date(String(a.time)).getTime()).slice(0, 8)
|
||||
} catch (e) { error.value = e instanceof Error ? e.message : '概览数据加载失败' }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div class="page-view">
|
||||
<div class="detail-back"><t-link theme="primary" @click="router.push('/plugins')"><template #prefix-icon><ChevronLeftIcon /></template>返回已安装插件</t-link></div>
|
||||
<PageHeader eyebrow="PLUGIN DETAIL" :title="plugin?.name || String(route.params.id)" :subtitle="`${String(route.params.id)} · v${plugin?.version || '-'}`">
|
||||
<template #actions><t-tag v-if="plugin" :theme="tagTheme(plugin.state)" variant="light">{{ stateLabel(plugin.state) }}</t-tag></template>
|
||||
</PageHeader>
|
||||
<t-alert v-if="error" theme="error" :message="error" class="page-alert" />
|
||||
<t-tabs v-model="activeTab" class="detail-tabs" @change="onTabChange">
|
||||
<t-tab-panel v-for="tab in tabs" :key="tab.value" :value="tab.value" :label="tab.label" />
|
||||
</t-tabs>
|
||||
<div v-if="loading" class="loading-panel"><t-loading text="加载中" /></div>
|
||||
<template v-else-if="plugin">
|
||||
<section v-if="activeTab === 'overview'" class="detail-content">
|
||||
<div class="detail-actions"><t-button v-if="isHealthy(plugin)" variant="outline" :loading="busy === 'disable'" @click="mutate('disable')"><template #icon><PauseIcon /></template>停用插件</t-button><t-button v-else theme="primary" :disabled="plugin.state === 'incompatible'" :loading="busy === 'enable'" @click="mutate('enable')"><template #icon><PlayCircleIcon /></template>启用插件</t-button><t-button theme="danger" variant="outline" :disabled="isHealthy(plugin)" :loading="busy === 'uninstall'" @click="mutate('uninstall')"><template #icon><DeleteIcon /></template>卸载插件</t-button></div>
|
||||
<div class="detail-grid">
|
||||
<t-card title="健康状态" class="panel-card" :bordered="true"><template #actions><t-tag :theme="tagTheme(plugin.state)" variant="light">{{ stateLabel(plugin.state) }}</t-tag></template><dl class="description-grid"><div><dt>安装状态</dt><dd>{{ plugin.installation_status || '-' }}</dd></div><div><dt>服务地址</dt><dd class="mono wrap-anywhere">{{ plugin.endpoint || '未配置' }}</dd></div><div><dt>活动 revision</dt><dd class="mono">{{ plugin.active_revision || '-' }}</dd></div><div><dt>待切换 revision</dt><dd class="mono">{{ plugin.pending_revision || '-' }}</dd></div></dl><t-alert v-if="plugin.last_error" theme="error" :message="plugin.last_error" class="inline-alert" /><t-alert v-else theme="success" message="最近一次检查未发现错误。" class="inline-alert" /></t-card>
|
||||
<t-card title="插件信息" class="panel-card" :bordered="true"><dl class="description-grid"><div><dt>能力</dt><dd class="wrap-anywhere">{{ (plugin.capabilities || []).join(', ') || '未声明' }}</dd></div><div><dt>Core 兼容性</dt><dd>{{ plugin.compatibility?.status || 'unknown' }}</dd></div><div><dt>更新时间</dt><dd>{{ formatDate(plugin.updated_at) }}</dd></div><div><dt>菜单标识</dt><dd class="mono wrap-anywhere">{{ plugin.menu?.id || '未声明' }}</dd></div></dl></t-card>
|
||||
</div>
|
||||
</section>
|
||||
<section v-else-if="activeTab === 'revisions'" class="detail-content">
|
||||
<div class="section-toolbar"><div><h2>版本与升级</h2><p class="secondary-text">新版本先校验并进入候选 revision,健康检查通过后才会切换。</p></div><input ref="upgradeInput" class="visually-hidden" type="file" accept=".zip,.s2plugin,application/zip" @change="onUpgradeSelected" /><t-button theme="primary" :loading="busy === 'upgrade'" @click="upgradeInput?.click()"><template #icon><UploadIcon /></template>上传升级</t-button></div>
|
||||
<t-card class="table-card" :bordered="true"><t-table v-if="visibleRevisions.length" :data="visibleRevisions" :columns="revisionColumns" row-key="id" :hover="true"><template #version="{ row }"><div><strong>v{{ row.version || '-' }}</strong><t-tag v-if="row.id === plugin.active_revision" theme="success" variant="light" class="tag-gap">当前活动</t-tag><div class="secondary-text mono wrap-anywhere">{{ row.id }}</div></div></template><template #hash="{ row }"><span class="mono wrap-anywhere">{{ row.archive_sha256 || '-' }}</span></template><template #verified="{ row }"><span class="secondary-text">{{ formatDate(row.verified_at) }}</span></template><template #actions="{ row }"><t-button v-if="row.id !== plugin.active_revision && !row.retired" size="medium" variant="outline" :loading="busy === row.id" @click="mutate('rollback', row.id)"><template #icon><RollbackIcon /></template>回滚到此版本</t-button></template></t-table><t-empty v-else description="暂无可用版本记录" /></t-card>
|
||||
</section>
|
||||
<section v-else-if="activeTab === 'config'" class="detail-content narrow-content">
|
||||
<div class="section-toolbar"><div><h2>配置</h2><p class="secondary-text">服务地址和菜单地址由控制面保存,密钥不会回显到浏览器。</p></div></div>
|
||||
<t-card class="panel-card" :bordered="true"><t-form :data="configForm" :rules="configRules" @submit="saveConfig"><t-form-item label="服务地址" name="service_url"><t-input v-model="configForm.service_url" placeholder="http://127.0.0.1:18090" /></t-form-item><t-form-item v-if="!isSubscription" label="菜单地址" name="public_url"><t-input v-model="configForm.public_url" placeholder="https://CORE_ORIGIN/extensions/PLUGIN_ID/" /></t-form-item><t-alert v-if="isSubscription" theme="info" message="订阅模块的菜单固定进入 Plugin Admin 统一 Shell;这里只需配置服务地址用于健康检查。" class="inline-alert" /><t-alert v-else theme="info" message="敏感配置只在服务端加密保存,页面不会回显原值。" class="inline-alert" /><t-button theme="primary" type="submit" :loading="busy === 'config'"><template #icon><SaveIcon /></template>保存配置</t-button></t-form></t-card>
|
||||
</section>
|
||||
<section v-else-if="activeTab === 'menu'" class="detail-content narrow-content">
|
||||
<div class="section-toolbar"><div><h2>菜单接入</h2><p class="secondary-text">先预览菜单变更,再应用到 Core 管理员导航。</p></div><div class="toolbar-actions"><t-button variant="outline" :loading="busy === 'menu-preview'" @click="menuPreview"><template #icon><ViewListIcon /></template>预览菜单</t-button><t-button theme="primary" :loading="busy === 'menu-apply'" @click="mutate('menu-apply')"><template #icon><CheckIcon /></template>应用菜单</t-button></div></div>
|
||||
<t-card class="panel-card" :bordered="true"><dl class="description-grid"><div><dt>菜单标识</dt><dd class="mono wrap-anywhere">{{ plugin.menu?.id || '-' }}</dd></div><div><dt>菜单名称</dt><dd>{{ plugin.menu?.label || '-' }}</dd></div><div><dt>可见性</dt><dd>{{ plugin.menu?.visibility || 'admin' }}</dd></div><div><dt>排序</dt><dd>{{ plugin.menu?.sort_order ?? '-' }}</dd></div></dl><t-alert theme="info" message="应用菜单只会更新 Core 的 custom_menu_items,不会修改 Core 源码。" class="inline-alert" /></t-card>
|
||||
</section>
|
||||
<section v-else class="detail-content"><div class="section-toolbar"><div><h2>操作历史</h2><p class="secondary-text">只显示当前插件的生命周期和菜单操作。</p></div><t-button variant="outline" @click="load"><template #icon><RefreshIcon /></template>刷新</t-button></div><t-card class="table-card" :bordered="true"><t-table :data="pluginAudit" :columns="auditColumns" row-key="operation_id" :hover="true"><template #action="{ row }">{{ actionLabel(row.action) }}</template><template #result="{ row }"><t-tag :theme="operationTheme(row.result)" variant="light">{{ operationLabel(row.result) }}</t-tag></template><template #time="{ row }"><span class="secondary-text">{{ formatDate(row.time) }}</span></template></t-table><t-empty v-if="!pluginAudit.length" description="还没有操作记录" /></t-card></section>
|
||||
</template>
|
||||
<t-card v-else class="empty-card" :bordered="true"><t-empty description="插件不存在或已卸载" /></t-card>
|
||||
<t-dialog v-model:visible="previewVisible" header="菜单变更预览" width="min(880px, calc(100vw - 32px))" :footer="false"><div class="preview-grid"><div><h3>当前菜单</h3><pre>{{ JSON.stringify(menuCurrent, null, 2) }}</pre></div><div><h3>应用后菜单</h3><pre>{{ JSON.stringify(menuNext, null, 2) }}</pre></div></div></t-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { CheckIcon, ChevronLeftIcon, DeleteIcon, PauseIcon, PlayCircleIcon, RefreshIcon, RollbackIcon, SaveIcon, UploadIcon, ViewListIcon } from 'tdesign-icons-vue-next'
|
||||
import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import { actionLabel } from '../services/labels'
|
||||
import { formatDate, operationKey, operationSucceeded, operationTheme, request, stateLabel } from '../services/api'
|
||||
|
||||
const route = useRoute(); const router = useRouter()
|
||||
const plugin = ref<any | null>(null); const pluginAudit = ref<any[]>([]); const loading = ref(false); const error = ref(''); const busy = ref('')
|
||||
const tabs = [{ value: 'overview', label: '运行概况' }, { value: 'revisions', label: '版本与升级' }, { value: 'config', label: '配置' }, { value: 'menu', label: '菜单接入' }, { value: 'operations', label: '操作历史' }]
|
||||
const activeTab = ref(String(route.params.tab || 'overview')); const upgradeInput = ref<HTMLInputElement | null>(null)
|
||||
const configForm = reactive({ service_url: '', public_url: '' }); const configRules = { service_url: [{ required: true, message: '请输入服务地址', type: 'error' as const }] }
|
||||
const previewVisible = ref(false); const menuCurrent = ref<any[]>([]); const menuNext = ref<any[]>([])
|
||||
const revisionColumns = [{ colKey: 'version', title: '版本', minWidth: 250 }, { colKey: 'hash', title: 'SHA-256', minWidth: 300 }, { colKey: 'verified', title: '验证时间', width: 180 }, { colKey: 'actions', title: '操作', width: 160 }]
|
||||
const auditColumns = [{ colKey: 'action', title: '操作', width: 180 }, { colKey: 'result', title: '结果', width: 100 }, { colKey: 'request_id', title: '请求 ID', minWidth: 250 }, { colKey: 'time', title: '时间', width: 180 }]
|
||||
const isHealthy = (item: any) => ['healthy', 'enabled'].includes(item.state)
|
||||
const tagTheme = (state: string) => isHealthy({ state }) ? 'success' : ['error', 'incompatible'].includes(state) ? 'danger' : ['starting', 'draining', 'upgrading', 'rollback_pending'].includes(state) ? 'warning' : 'default'
|
||||
const isSubscription = computed(() => plugin.value?.plugin_id === 'qiu.subscription-admin')
|
||||
const visibleRevisions = computed(() => (plugin.value?.revisions || []).filter((revision: any) => !revision.retired))
|
||||
const operationLabel = (value: unknown) => operationSucceeded(value) ? '已完成' : String(value || '未知')
|
||||
|
||||
function onTabChange(value: string | number) { activeTab.value = String(value); router.replace(`/plugins/${encodeURIComponent(String(route.params.id))}/${String(value)}`) }
|
||||
async function load() {
|
||||
loading.value = true; error.value = ''
|
||||
try {
|
||||
const id = encodeURIComponent(String(route.params.id)); plugin.value = await request(`/api/plugins/${id}`)
|
||||
const audits = await request<any>('/api/audit'); pluginAudit.value = (Array.isArray(audits.items) ? audits.items : []).filter((item: any) => item.plugin_id === plugin.value.plugin_id).sort((a: any, b: any) => new Date(String(b.time)).getTime() - new Date(String(a.time)).getTime())
|
||||
if (['config', 'menu'].includes(activeTab.value)) { const cfg = await request<any>(`/api/plugins/${id}/config`); configForm.service_url = cfg.endpoint || ''; configForm.public_url = typeof cfg.config?.public_url === 'string' ? cfg.config.public_url : '' }
|
||||
} catch (e) { error.value = e instanceof Error ? e.message : '插件详情加载失败' }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
async function waitOperation(id: string) { for (let i = 0; i < 20 && id; i += 1) { const op = await request<any>(`/api/operations/${encodeURIComponent(id)}`).catch(() => null); if (op?.state && op.state !== 'running') return op; await new Promise((resolve) => window.setTimeout(resolve, 200)) } return null }
|
||||
async function mutate(action: string, revision = '') {
|
||||
const id = encodeURIComponent(String(route.params.id)); const endpoint = action === 'rollback' ? `/api/plugins/${id}/rollback` : `/api/plugins/${id}/${action}`
|
||||
if (['disable', 'uninstall'].includes(action)) { const dialog = DialogPlugin.confirm({ header: action === 'uninstall' ? '确认卸载插件' : '确认停用插件', body: action === 'uninstall' ? '卸载会移除插件文件,但不会删除 Core 数据。' : '停用后 Core 管理员菜单会被移除。', confirmBtn: action === 'uninstall' ? '卸载' : '停用', cancelBtn: '取消', onConfirm: () => { dialog.hide(); performMutation(endpoint, action, revision) } }); return }
|
||||
await performMutation(endpoint, action, revision)
|
||||
}
|
||||
async function performMutation(endpoint: string, action: string, revision = '') {
|
||||
busy.value = action === 'rollback' ? revision : action
|
||||
try {
|
||||
const body = action === 'rollback' && revision ? JSON.stringify({ revision }) : undefined
|
||||
const result = await request<any>(endpoint, { method: 'POST', headers: { 'Idempotency-Key': operationKey(action, revision) }, body })
|
||||
const final = await waitOperation(result.operation_id)
|
||||
const state = final?.state || result.state
|
||||
if (state === 'failed') throw new Error(final?.error || result.error || `${actionLabel(action)}失败`)
|
||||
if (state && !operationSucceeded(state)) MessagePlugin.warning(`${actionLabel(action)}操作仍在处理中`)
|
||||
else MessagePlugin.success(`${actionLabel(action)}已完成`)
|
||||
await load()
|
||||
} catch (e) { error.value = e instanceof Error ? e.message : '操作失败' } finally { busy.value = '' }
|
||||
}
|
||||
async function saveConfig() { if (!configForm.service_url.trim()) { error.value = '请输入服务地址'; return }; busy.value = 'config'; try { const payload = isSubscription.value ? { service_url: configForm.service_url } : configForm; const result = await request<any>(`/api/plugins/${encodeURIComponent(String(route.params.id))}/config`, { method: 'PUT', headers: { 'Idempotency-Key': operationKey('config', String(route.params.id)) }, body: JSON.stringify(payload) }); const final = await waitOperation(result.operation_id); if ((final?.state || result.state) === 'failed') throw new Error(final?.error || result.error || '配置保存失败'); MessagePlugin.success('配置已保存'); await load() } catch (e) { error.value = e instanceof Error ? e.message : '配置保存失败' } finally { busy.value = '' } }
|
||||
async function menuPreview() { busy.value = 'menu-preview'; try { const result = await request<any>(`/api/plugins/${encodeURIComponent(String(route.params.id))}/menu-preview`, { method: 'POST', headers: { 'Idempotency-Key': operationKey('menu-preview', String(route.params.id)) } }); menuCurrent.value = result.current || []; menuNext.value = result.next || []; previewVisible.value = true } catch (e) { error.value = e instanceof Error ? e.message : '菜单预览失败' } finally { busy.value = '' } }
|
||||
async function onUpgradeSelected(event: Event) { const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''; if (!file) return; busy.value = 'upgrade'; try { const body = new FormData(); body.append('package', file); const result = await request<any>(`/api/plugins/${encodeURIComponent(String(route.params.id))}/upgrade`, { method: 'POST', headers: { 'Idempotency-Key': operationKey('upgrade', String(route.params.id)) }, body }); const final = await waitOperation(result.operation_id); if ((final?.state || result.state) === 'failed') throw new Error(final?.error || result.error || '升级失败'); MessagePlugin.success('升级已完成'); await load() } catch (e) { error.value = e instanceof Error ? e.message : '升级上传失败' } finally { busy.value = '' } }
|
||||
watch(() => route.params.tab, (value) => { activeTab.value = String(value || 'overview'); load() })
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div class="page-view">
|
||||
<PageHeader eyebrow="INSTALLED PLUGINS" title="已安装插件" subtitle="查看插件状态、版本和生命周期。高风险操作进入插件详情页。">
|
||||
<template #actions>
|
||||
<input ref="packageInput" class="visually-hidden" type="file" accept=".zip,.s2plugin,application/zip" @change="onPackageSelected" />
|
||||
<t-button theme="primary" :loading="busy === 'upload'" @click="packageInput?.click()"><template #icon><UploadIcon /></template>上传并入库</t-button>
|
||||
<t-button variant="outline" :loading="loading" @click="load"><template #icon><RefreshIcon /></template>刷新</t-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
<t-alert v-if="error" theme="error" :message="error" class="page-alert" />
|
||||
<div class="page-toolbar"><span class="secondary-text">共 {{ plugins.length }} 个插件</span><span class="toolbar-hint">下载包仅入库,不会自动启动</span></div>
|
||||
<t-card v-if="plugins.length" class="table-card" :bordered="true">
|
||||
<t-table :data="plugins" :columns="columns" row-key="plugin_id" :loading="loading" :hover="true" stripe>
|
||||
<template #plugin="{ row }"><div class="plugin-cell"><div class="plugin-avatar"><AppIcon size="19px" /></div><div><strong>{{ row.name || row.plugin_id }}</strong><span class="secondary-text mono">{{ row.plugin_id }}</span></div></div></template>
|
||||
<template #version="{ row }"><span class="mono">v{{ row.version || '-' }}</span></template>
|
||||
<template #state="{ row }"><t-tag :theme="tagTheme(row.state)" variant="light">{{ stateLabel(row.state) }}</t-tag><span class="cell-note">{{ row.installation_status === 'staged' ? '待启用' : '' }}</span></template>
|
||||
<template #compatibility="{ row }"><t-tag :theme="row.compatibility?.compatible ? 'success' : 'warning'" variant="light">{{ row.compatibility?.status || 'unknown' }}</t-tag></template>
|
||||
<template #updated="{ row }"><span class="secondary-text">{{ formatDate(row.updated_at) }}</span></template>
|
||||
<template #actions="{ row }"><div class="table-actions"><t-button size="medium" variant="outline" @click="router.push(`/plugins/${encodeURIComponent(row.plugin_id)}/overview`)">查看详情</t-button><t-button size="medium" :theme="isHealthy(row) ? 'default' : 'primary'" :variant="isHealthy(row) ? 'outline' : 'base'" :loading="busy === row.plugin_id" :disabled="isTransitioning(row) || row.state === 'incompatible'" @click="mutate(isHealthy(row) ? 'disable' : 'enable', row.plugin_id)">{{ isHealthy(row) ? '停用' : '启用' }}</t-button><t-dropdown :options="moreOptions(row)" trigger="click" @click="(data: any) => onMore(String(data?.value || ''), row)"><t-button size="medium" variant="text" shape="square" aria-label="更多操作"><template #icon><MoreIcon /></template></t-button></t-dropdown></div></template>
|
||||
</t-table>
|
||||
</t-card>
|
||||
<t-card v-else class="empty-card" :bordered="true"><t-empty description="还没有登记业务插件" /></t-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { AppIcon, MoreIcon, RefreshIcon, UploadIcon } from 'tdesign-icons-vue-next'
|
||||
import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import { formatDate, listFrom, operationKey, request, stateLabel } from '../services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const plugins = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const busy = ref('')
|
||||
const packageInput = ref<HTMLInputElement | null>(null)
|
||||
const columns = [
|
||||
{ colKey: 'plugin', title: '插件', minWidth: 230 },
|
||||
{ colKey: 'version', title: '版本', width: 110 },
|
||||
{ colKey: 'state', title: '状态', width: 150 },
|
||||
{ colKey: 'compatibility', title: 'Core 兼容性', width: 145 },
|
||||
{ colKey: 'updated', title: '最近更新', width: 180 },
|
||||
{ colKey: 'actions', title: '操作', width: 250, fixed: 'right' as const },
|
||||
]
|
||||
const healthyStates = ['healthy', 'enabled']
|
||||
const isHealthy = (row: any) => healthyStates.includes(row.state)
|
||||
const isTransitioning = (row: any) => ['starting', 'draining', 'upgrading', 'rollback_pending'].includes(row.state)
|
||||
const tagTheme = (state: string) => healthyStates.includes(state) ? 'success' : ['error', 'incompatible'].includes(state) ? 'danger' : ['starting', 'draining', 'upgrading', 'rollback_pending'].includes(state) ? 'warning' : 'default'
|
||||
|
||||
async function load() {
|
||||
loading.value = true; error.value = ''
|
||||
try { plugins.value = listFrom(await request('/api/plugins')).sort((a, b) => String(a.name || a.plugin_id).localeCompare(String(b.name || b.plugin_id))) }
|
||||
catch (e) { error.value = e instanceof Error ? e.message : '插件列表加载失败' }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function waitOperation(id: string) {
|
||||
if (!id) return null
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
const op = await request<any>(`/api/operations/${encodeURIComponent(id)}`).catch(() => null)
|
||||
if (op && op.state && op.state !== 'running') return op
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 150))
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function mutate(action: string, id: string) {
|
||||
busy.value = id; error.value = ''
|
||||
try {
|
||||
const result = await request<any>(`/api/plugins/${encodeURIComponent(id)}/${action}`, { method: 'POST', headers: { 'Idempotency-Key': operationKey(action, id) } })
|
||||
const final = await waitOperation(result.operation_id)
|
||||
MessagePlugin.success(`${action === 'enable' ? '启用' : action === 'disable' ? '停用' : action}操作已完成${final?.state ? `:${final.state}` : ''}`)
|
||||
await load()
|
||||
} catch (e) { error.value = e instanceof Error ? e.message : '操作失败' }
|
||||
finally { busy.value = '' }
|
||||
}
|
||||
|
||||
function moreOptions(row: any) {
|
||||
return [
|
||||
{ content: '配置', value: 'config' },
|
||||
{ content: '版本与升级', value: 'revisions' },
|
||||
{ content: '菜单接入', value: 'menu' },
|
||||
{ content: '卸载插件', value: 'uninstall', disabled: isHealthy(row) },
|
||||
]
|
||||
}
|
||||
function onMore(value: string, row: any) {
|
||||
if (value === 'config' || value === 'revisions' || value === 'menu') router.push(`/plugins/${encodeURIComponent(row.plugin_id)}/${value}`)
|
||||
if (value === 'uninstall') {
|
||||
const dialog = DialogPlugin.confirm({ header: '确认卸载插件', body: '卸载会移除插件文件,但不会删除 Core 数据。', confirmBtn: '卸载', cancelBtn: '取消', onConfirm: () => { dialog.hide(); mutate('uninstall', row.plugin_id) } })
|
||||
}
|
||||
}
|
||||
async function onPackageSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
busy.value = 'upload'; error.value = ''
|
||||
try {
|
||||
const body = new FormData(); body.append('package', file)
|
||||
const result = await request<any>('/api/plugins/install', { method: 'POST', headers: { 'Idempotency-Key': operationKey('install') }, body })
|
||||
MessagePlugin.success(`插件已入库,待手动启用:${result.operation_id || result.state}`)
|
||||
await load()
|
||||
} catch (e) { error.value = e instanceof Error ? e.message : '上传失败' }
|
||||
finally { busy.value = '' }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="page-view">
|
||||
<PageHeader eyebrow="BUSINESS MODULE · SUBSCRIPTION" title="订阅管理" subtitle="订阅是插件控制面中的业务模块,沿用当前管理员会话,不需要再次登录。">
|
||||
<template #actions><t-tag theme="success" variant="light"><CheckCircleIcon size="14px" /> 共享控制面会话</t-tag></template>
|
||||
</PageHeader>
|
||||
<t-alert v-if="error" theme="warning" :message="error" class="page-alert" />
|
||||
<t-tabs v-model="activeTab" class="detail-tabs" @change="changeTab"><t-tab-panel value="overview" label="概览" /><t-tab-panel value="plans" label="套餐" /><t-tab-panel value="subscriptions" label="用户订阅" /><t-tab-panel value="audit" label="操作记录" /></t-tabs>
|
||||
<section v-if="activeTab === 'overview'" class="detail-content">
|
||||
<div class="stat-grid module-stats"><t-card class="stat-card stat-neutral" :bordered="true"><div class="stat-card-top"><span>Core 连接</span><LinkIcon size="18px" /></div><strong>{{ status?.core_base_configured ? '正常' : '未配置' }}</strong><small>服务端 allowlist</small></t-card><t-card class="stat-card stat-primary" :bordered="true"><div class="stat-card-top"><span>套餐数量</span><CatalogIcon size="18px" /></div><strong>{{ plans.length }}</strong><small>来自 Core 套餐目录</small></t-card><t-card class="stat-card stat-success" :bordered="true"><div class="stat-card-top"><span>订阅记录</span><ChartLineIcon size="18px" /></div><strong>{{ subscriptionTotal }}</strong><small>当前筛选范围</small></t-card><t-card class="stat-card stat-warning" :bordered="true"><div class="stat-card-top"><span>会话模式</span><LockOnIcon size="18px" /></div><strong>共享</strong><small>不创建模块 Cookie</small></t-card></div>
|
||||
<t-card class="panel-card module-note" title="模块边界" :bordered="true"><t-alert theme="info" message="浏览器只请求 Plugin Admin 的同源 BFF。Core access token、refresh token 和服务密钥始终保存在服务端会话。" /><div class="module-flow"><span>统一登录</span><ChevronRightIcon /><span>订阅模块</span><ChevronRightIcon /><span>Core 只读接口</span></div></t-card>
|
||||
</section>
|
||||
<section v-else-if="activeTab === 'plans'" class="detail-content"><div class="section-toolbar"><div><h2>套餐目录</h2><p class="secondary-text">套餐数据由 Core 返回,订阅模块只负责展示和查询。</p></div><t-button variant="outline" :loading="loading" @click="loadPlans"><template #icon><RefreshIcon /></template>刷新</t-button></div><t-card class="table-card" :bordered="true"><t-table :data="plans" :columns="planColumns" row-key="id" :loading="loading" :hover="true"><template #name="{ row }"><div><strong>{{ row.name || row.title || `套餐 ${row.id || '-'}` }}</strong><span class="secondary-text">{{ row.description || 'Core 套餐' }}</span></div></template><template #price="{ row }"><span class="numeric">{{ formatMoney(row.price ?? row.amount) }}</span></template><template #status="{ row }"><t-tag :theme="row.enabled === false ? 'default' : 'success'" variant="light">{{ row.enabled === false ? '已停用' : '可用' }}</t-tag></template></t-table><t-empty v-if="!plans.length" description="暂无套餐数据" /></t-card></section>
|
||||
<section v-else-if="activeTab === 'subscriptions'" class="detail-content"><div class="section-toolbar"><div><h2>用户订阅</h2><p class="secondary-text">服务端分页和筛选参数沿用订阅模块 API。</p></div><t-button variant="outline" :loading="loading" @click="loadSubscriptions"><template #icon><RefreshIcon /></template>刷新</t-button></div><t-card class="filter-card" :bordered="true"><div class="filter-grid subscription-filter"><t-input v-model="filters.user_id" label="用户 ID" placeholder="输入用户 ID" clearable /><t-input v-model="filters.group_id" label="分组 ID" placeholder="输入分组 ID" clearable /><t-select v-model="filters.status" label="状态" placeholder="全部状态" clearable><t-option value="active" label="有效" /><t-option value="expired" label="已过期" /><t-option value="revoked" label="已撤销" /></t-select><t-button theme="primary" @click="applyFilters"><template #icon><SearchIcon /></template>查询</t-button></div></t-card><t-card class="table-card" :bordered="true"><t-table :data="subscriptions" :columns="subscriptionColumns" row-key="id" :loading="loading" :hover="true"><template #user="{ row }"><div><strong>{{ row.user?.username || row.user?.email || row.user_id || '-' }}</strong><span class="secondary-text">用户 ID {{ row.user_id || '-' }}</span></div></template><template #plan="{ row }">{{ row.plan?.name || row.plan_name || row.group?.name || row.group_id || '-' }}</template><template #status="{ row }"><t-tag :theme="subscriptionTheme(row.status)" variant="light">{{ subscriptionLabel(row.status) }}</t-tag></template><template #period="{ row }"><span class="secondary-text">{{ formatDate(row.start_at || row.started_at) }} 至 {{ formatDate(row.expire_at || row.expires_at) }}</span></template></t-table><t-empty v-if="!subscriptions.length" description="暂无订阅记录" /><div class="pagination-row"><t-pagination v-model="pagination.current" v-model:page-size="pagination.pageSize" :total="pagination.total" :show-jumper="true" @change="onPageChange" /></div></t-card></section>
|
||||
<section v-else class="detail-content"><div class="section-toolbar"><div><h2>模块操作记录</h2><p class="secondary-text">只记录通过统一控制面发起的订阅查询。</p></div><t-button variant="outline" :loading="loading" @click="loadAudit"><template #icon><RefreshIcon /></template>刷新</t-button></div><t-card class="table-card" :bordered="true"><t-table :data="audit" :columns="auditColumns" row-key="request"><template #action="{ row }">{{ row.action || '-' }}</template><template #result="{ row }"><t-tag :theme="operationTheme(row.result)" variant="light">{{ operationLabel(row.result) }}</t-tag></template><template #time="{ row }"><span class="secondary-text">{{ formatDate(row.time) }}</span></template></t-table><t-empty v-if="!audit.length" description="还没有模块操作记录" /></t-card></section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { CatalogIcon, ChartLineIcon, CheckCircleIcon, ChevronRightIcon, LinkIcon, LockOnIcon, RefreshIcon, SearchIcon } from 'tdesign-icons-vue-next'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import { formatDate, listFrom, loadSubscriptionState, operationSucceeded, operationTheme, request, unwrap } from '../services/api'
|
||||
|
||||
const route = useRoute(); const router = useRouter(); const activeTab = ref(String(route.params.tab || 'overview')); const loading = ref(false); const error = ref(''); const status = ref<any>(null); const plans = ref<any[]>([]); const subscriptions = ref<any[]>([]); const audit = ref<any[]>([]); const pagination = reactive({ current: 1, pageSize: 20, total: 0 }); const filters = reactive({ user_id: '', group_id: '', status: '' })
|
||||
const planColumns = [{ colKey: 'name', title: '套餐', minWidth: 260 }, { colKey: 'price', title: '价格', width: 150 }, { colKey: 'status', title: '状态', width: 120 }]
|
||||
const subscriptionColumns = [{ colKey: 'user', title: '用户', minWidth: 220 }, { colKey: 'plan', title: '套餐/分组', minWidth: 200 }, { colKey: 'status', title: '状态', width: 120 }, { colKey: 'period', title: '有效期', minWidth: 270 }]
|
||||
const auditColumns = [{ colKey: 'action', title: '操作', minWidth: 240 }, { colKey: 'result', title: '结果', width: 110 }, { colKey: 'request', title: '请求 ID', minWidth: 220 }, { colKey: 'time', title: '时间', width: 180 }]
|
||||
const operationLabel = (value: unknown) => operationSucceeded(value) ? '已完成' : String(value || '未知')
|
||||
const subscriptionTotal = computed(() => pagination.total || subscriptions.value.length)
|
||||
function formatMoney(value: unknown) { const n = Number(value); return Number.isFinite(n) ? `¥${n.toFixed(2)}` : '-' }
|
||||
function subscriptionTheme(statusValue: unknown) { return statusValue === 'active' || statusValue === 'enabled' ? 'success' : statusValue === 'revoked' ? 'danger' : 'warning' }
|
||||
function subscriptionLabel(statusValue: unknown) { return ({ active: '有效', enabled: '有效', expired: '已过期', revoked: '已撤销' } as Record<string, string>)[String(statusValue)] || String(statusValue || '未知') }
|
||||
function changeTab(value: string | number) { activeTab.value = String(value); router.replace(`/modules/subscription/${String(value)}`); loadForTab() }
|
||||
function extractPage(value: any) { const data = unwrap<any>(value) || {}; const rows = listFrom(value); return { rows, total: Number(data.total ?? data.pagination?.total ?? rows.length) || rows.length } }
|
||||
async function loadStatus() { status.value = await request('/api/subscription/status').then(unwrap).catch(() => null) }
|
||||
async function loadPlans() { loading.value = true; error.value = ''; try { plans.value = listFrom(await request('/api/subscription/plans')) } catch (e) { error.value = e instanceof Error ? e.message : '套餐加载失败' } finally { loading.value = false } }
|
||||
async function loadSubscriptions() { loading.value = true; error.value = ''; try { const query = new URLSearchParams({ page: String(pagination.current), page_size: String(pagination.pageSize) }); Object.entries(filters).forEach(([key, value]) => { if (value) query.set(key, value) }); const result = extractPage(await request(`/api/subscription/subscriptions?${query.toString()}`)); subscriptions.value = result.rows; pagination.total = result.total } catch (e) { error.value = e instanceof Error ? e.message : '订阅加载失败' } finally { loading.value = false } }
|
||||
async function loadAudit() { loading.value = true; error.value = ''; try { audit.value = listFrom(await request('/api/subscription/audit')) } catch (e) { error.value = e instanceof Error ? e.message : '审计加载失败' } finally { loading.value = false } }
|
||||
function applyFilters() { pagination.current = 1; loadSubscriptions() }
|
||||
function onPageChange(info: any) { pagination.current = Number(info?.current || info?.page || info || 1); pagination.pageSize = Number(info?.pageSize || pagination.pageSize); loadSubscriptions() }
|
||||
function loadForTab() { if (activeTab.value === 'plans') loadPlans(); else if (activeTab.value === 'subscriptions') loadSubscriptions(); else if (activeTab.value === 'audit') loadAudit(); else loadStatus() }
|
||||
watch(() => route.params.tab, async (value) => {
|
||||
activeTab.value = String(value || 'overview')
|
||||
if (await loadSubscriptionState()) loadForTab()
|
||||
})
|
||||
onMounted(async () => {
|
||||
if (!(await loadSubscriptionState())) {
|
||||
await router.replace('/overview')
|
||||
return
|
||||
}
|
||||
await loadStatus()
|
||||
loadForTab()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "vue",
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client", "tdesign-vue-next/global"],
|
||||
"noEmit": true,
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["src/*"] }
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "src/**/*.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
|
||||
},
|
||||
build: {
|
||||
outDir: '../ui-build',
|
||||
emptyOutDir: true,
|
||||
cssCodeSplit: false,
|
||||
assetsInlineLimit: 1024 * 1024 * 8,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
entryFileNames: 'app.js',
|
||||
chunkFileNames: 'chunk-[name].js',
|
||||
assetFileNames: (asset) => asset.name?.endsWith('.css') ? 'styles.css' : 'assets/[name][extname]',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
File diff suppressed because one or more lines are too long
@@ -1,89 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>插件管理</title>
|
||||
<link rel="stylesheet" href="../styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">SUB2API EXTENSIONS</p>
|
||||
<h1>插件管理</h1>
|
||||
<p class="muted">独立业务插件控制面</p>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<span id="operator" class="operator"></span>
|
||||
<button id="logout" class="button button-quiet" hidden>退出</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="login-panel" class="panel auth-panel">
|
||||
<h2>管理员登录</h2>
|
||||
<p class="muted">使用 Sub2API Core 管理员账号登录。普通账号没有访问权限。</p>
|
||||
<form id="login-form" class="form-grid">
|
||||
<label>邮箱<input name="email" type="email" autocomplete="username" required></label>
|
||||
<label>密码<input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button class="button" type="submit">登录</button>
|
||||
</form>
|
||||
<form id="twofa-form" class="form-grid" hidden>
|
||||
<label>验证码<input name="totp_code" inputmode="numeric" maxlength="6" pattern="[0-9]{6}" required></label>
|
||||
<button class="button" type="submit">验证并继续</button>
|
||||
</form>
|
||||
<p id="login-error" class="error" role="alert"></p>
|
||||
</section>
|
||||
|
||||
<section id="app-panel" hidden>
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h2>已登记插件</h2>
|
||||
<p class="muted">上传后只完成验签、哈希和 Core 兼容性校验并入库;点击启用后才会启动插件。</p>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<label class="file-button">上传并入库<input id="package-file" type="file" accept=".zip,.s2plugin,application/zip"></label>
|
||||
<button id="refresh" class="button button-secondary" type="button">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="notice" class="notice" role="status"></p>
|
||||
<section class="marketplace-section" aria-labelledby="marketplace-heading">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2 id="marketplace-heading">插件市场</h2>
|
||||
<p class="muted">从受控目录查看可安装版本。安装后仅入库,需在下方手动启用。</p>
|
||||
</div>
|
||||
<button id="marketplace-refresh" class="button button-secondary" type="button">刷新市场</button>
|
||||
</div>
|
||||
<div id="marketplace" class="marketplace-list" aria-live="polite"></div>
|
||||
</section>
|
||||
<section aria-labelledby="installed-heading">
|
||||
<div class="section-heading installed-heading">
|
||||
<div>
|
||||
<h2 id="installed-heading">已入库插件</h2>
|
||||
<p class="muted">启用、停用、升级、回滚和卸载均需在插件卡片中手动操作。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="plugins" class="plugin-list"></div>
|
||||
</section>
|
||||
<section class="panel audit-panel">
|
||||
<div class="section-heading"><h2>操作审计</h2><button id="audit-refresh" class="button button-quiet" type="button">刷新</button></div>
|
||||
<div id="audit" class="audit-list"></div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
<dialog id="config-dialog" class="config-dialog">
|
||||
<form id="config-form" method="dialog" class="config-form">
|
||||
<div class="section-heading"><h2>插件配置</h2><button id="config-close" class="button button-quiet" type="button">关闭</button></div>
|
||||
<p id="config-plugin" class="muted mono"></p>
|
||||
<label>服务地址<input name="service_url" type="url" placeholder="http://127.0.0.1:18090" required></label>
|
||||
<label>菜单地址<input name="public_url" type="url" placeholder="https://CORE_ORIGIN/extensions/PLUGIN_ID/"></label>
|
||||
<p class="muted">密钥只在服务端加密保存,页面不会回显原值。</p>
|
||||
<div class="dialog-actions"><button id="config-cancel" class="button button-secondary" type="button">取消</button><button class="button" type="submit">保存配置</button></div>
|
||||
<p id="config-error" class="error" role="alert"></p>
|
||||
</form>
|
||||
</dialog>
|
||||
<script>window.__PLUGIN_BASE_PATH__ = __PLUGIN_BASE_PATH_JSON__;</script>
|
||||
<script src="../app.js" defer></script>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="plugin-base-path" content="__PLUGIN_BASE_PATH__" />
|
||||
<title>插件管理控制面</title>
|
||||
<script type="module" crossorigin src="./app.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -14,3 +14,8 @@ PLUGIN_COOKIE_SAMESITE=lax
|
||||
PLUGIN_FRAME_ANCESTORS='self'
|
||||
# Set true only when the immediate reverse proxy is trusted and supplies XFF.
|
||||
PLUGIN_TRUST_PROXY=false
|
||||
# Development-only compatibility endpoints. Keep false in production; the
|
||||
# unified Plugin Admin owns login, sessions and subscription BFF routes.
|
||||
# Compatibility login/API is accepted only with PLUGIN_ENV=development and a
|
||||
# loopback PLUGIN_HOST. Production should leave this false.
|
||||
PLUGIN_STANDALONE_AUTH=false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Sub2API Subscription Admin Business Plugin V1
|
||||
|
||||
这是一个独立运行的管理员只读业务插件,不是现有 `.s2plugin` transport 插件,也不是插件管理后台。它不导入 Sub2API `internal` 包,不连接 Core 数据库,也不修改 Core Go/Vue、迁移、路由或 `.s2plugin` ABI。
|
||||
这是一个可独立运行后端的管理员只读订阅业务模块,不是现有 `.s2plugin` transport 插件,也不是插件管理控制面。它不导入 Sub2API `internal` 包,不连接 Core 数据库,也不修改 Core Go/Vue、迁移、路由或 `.s2plugin` ABI。浏览器前端由 `plugin-admin` 统一 TDesign 控制面承载,订阅模块不提供第二个登录页或 Cookie。
|
||||
|
||||
生产/集成环境由通用 `plugins/plugin-admin` 控制面安装、启用和升级本插件;本插件不会预装,也不会成为控制面首页。只有健康检查通过并由管理员执行菜单预览/应用后,Core 管理员菜单才会出现“订阅管理”入口。直接运行本目录仅用于本地开发和契约测试。
|
||||
|
||||
@@ -13,12 +13,12 @@ PLUGIN_PORT=8091 \
|
||||
go run .
|
||||
```
|
||||
|
||||
打开 `http://127.0.0.1:8091/admin/`。生产环境应通过 HTTPS 反向代理,并设置 `PLUGIN_COOKIE_SECURE=true`。挂载到子路径时同时设置 `PLUGIN_PUBLIC_BASE_PATH` 和 `PLUGIN_COOKIE_PATH`,例如 `/extensions/qiu.subscription-admin`。
|
||||
本地后端可打开 `http://127.0.0.1:8091/healthz` 进行服务契约调试;生产浏览器入口应从 Plugin Admin 的订阅模块路由进入。生产环境应通过 HTTPS 反向代理,并设置 `PLUGIN_COOKIE_SECURE=true`。默认情况下本服务只暴露健康检查、就绪检查和交接页,不暴露第二套登录、Cookie 或 Core 数据 API。`PLUGIN_STANDALONE_AUTH=true` 仅在 `PLUGIN_ENV=development` 且监听地址为 loopback 时生效,生产必须保持关闭。
|
||||
|
||||
## V1 范围
|
||||
|
||||
- Core 管理员账号登录和 Core 2FA;普通账号统一拒绝。
|
||||
- 插件 HttpOnly、SameSite 会话和写请求 CSRF 校验。
|
||||
- 由 Plugin Admin 统一完成 Core 管理员账号登录和 Core 2FA;普通账号统一拒绝。
|
||||
- 订阅模块复用控制面的 HttpOnly、SameSite 会话和写请求 CSRF 校验,不创建模块级登录会话。
|
||||
- Core token 只保存在插件服务端内存会话中,不进入浏览器、URL、HTML、LocalStorage、响应或日志。
|
||||
- 只读套餐、订阅列表、订阅详情和插件操作记录。
|
||||
- Core access token 失效时最多刷新一次;刷新失败会销毁插件会话。
|
||||
@@ -28,7 +28,7 @@ go run .
|
||||
|
||||
## Core API allowlist
|
||||
|
||||
插件服务端仅调用这些明确路径:
|
||||
插件服务端在 `PLUGIN_STANDALONE_AUTH=true`、`PLUGIN_ENV=development` 且 loopback 的本地兼容模式下仅调用这些明确路径;生产数据访问由 Plugin Admin 同源 BFF 完成:
|
||||
|
||||
```text
|
||||
POST /api/v1/auth/login
|
||||
@@ -54,13 +54,13 @@ GET /api/v1/admin/users/{id}/subscriptions
|
||||
{
|
||||
"id": "qiu.subscription-admin",
|
||||
"label": "订阅管理",
|
||||
"url": "https://CORE_ORIGIN/extensions/qiu.subscription-admin/",
|
||||
"url": "https://CORE_ORIGIN/extensions/qiu.plugin-admin/admin/#/modules/subscription/overview",
|
||||
"visibility": "admin",
|
||||
"sort_order": 200
|
||||
}
|
||||
```
|
||||
|
||||
Core 自定义页面的 sandbox iframe 不会继承 Core `localStorage` 登录态,因此 V1 首屏显示插件登录页是预期行为;同时提供新窗口入口。不要把 JWT 放进 URL。
|
||||
Core 自定义页面的 sandbox iframe 不会继承 Core `localStorage` 登录态,因此菜单应指向 Plugin Admin 的统一控制面入口;订阅模块本身不显示登录页、不创建 Cookie。不要把 JWT 放进 URL。
|
||||
|
||||
## 测试
|
||||
|
||||
@@ -78,10 +78,13 @@ node --check ui/app.js
|
||||
```
|
||||
|
||||
脚本生成 `dist/qiu.subscription-admin.s2plugin`,包内根文件名为
|
||||
`manifest.json`,并包含清单声明哈希的 UI 文件。该插件采用外部服务模式:
|
||||
`manifest.json`,并包含清单声明哈希的 UI 文件。该模块采用外部服务模式:
|
||||
安装后先独立启动 `subscription-admin`,再在 `plugin-admin` 的配置中填写
|
||||
`service_url`(插件 loopback 地址)和 `public_url`(反向代理地址),然后执行
|
||||
启用、健康检查和菜单应用。生产环境必须把签名文件通过
|
||||
`service_url`(插件 loopback 地址),并为 Plugin Admin 设置
|
||||
`PLUGIN_PUBLIC_URL`(例如 `https://CORE_ORIGIN/extensions/qiu.plugin-admin`)。
|
||||
然后执行启用、健康检查和菜单应用。浏览器前端由 `plugin-admin` 统一挂载并共享
|
||||
控制面会话,菜单固定跳转到 `#/modules/subscription/overview`,不会跳转到本服务
|
||||
的登录页。生产环境必须把签名文件通过
|
||||
`SIGNATURE_FILE=/path/to/signature.json ./package.sh` 放入包内,并将对应公钥
|
||||
加入控制面受信发布者配置;未签名包仅限 development + loopback。
|
||||
|
||||
@@ -89,9 +92,23 @@ node --check ui/app.js
|
||||
|
||||
`business-plugin-manifest.v1.json` 是部署层清单,不由 Core 读取。生产发布应由独立 CI 签名并校验清单、版本、健康路径和兼容的 Core 版本;不要把发布私钥放入仓库或插件包。插件版本独立于 `backend/cmd/server/VERSION`。
|
||||
|
||||
发布构建必须显式启用签名门禁:
|
||||
|
||||
```sh
|
||||
RELEASE_BUILD=true SIGNATURE_FILE=/secure/signature.json ./package.sh
|
||||
```
|
||||
|
||||
脚本会把 `signature.json` 放入归档并生成同名 `.sha256` 校验文件;未签名包
|
||||
仅用于 development + loopback。
|
||||
|
||||
## 已知限制
|
||||
|
||||
- V1 使用内存会话,服务重启会要求重新登录;多实例部署需将会话存储替换为插件自有 Redis/共享会话服务。
|
||||
- 模块后端是常驻服务,不需要每次使用后重启。Core access token 过期时,后端会
|
||||
按需 refresh 并继续当前请求;只有 refresh 失效、Core 撤销管理员、会话
|
||||
空闲超过 30 分钟或达到 8 小时绝对上限时才需要重新登录。
|
||||
- V1 控制面使用内存会话,控制面服务重启会要求重新登录一次;订阅模块不会
|
||||
单独要求登录。多实例或跨重启免登录需将控制面会话存储替换为插件自有的
|
||||
加密 Redis/共享会话服务。
|
||||
- 现有 Core 自定义 iframe 没有 token handoff,V1 不提供无感 SSO;真正 SSO 需要单独的 V1.1 Core 交接接口。
|
||||
- Core 当前套餐响应中的 `features` 可能是 JSON 字符串,UI 会兼容字符串和数组。
|
||||
- Core 开启验证码时,管理员必须先完成对应提供商的挑战并将结果填入登录表单;插件不保存验证码票据。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"schema_version": 1,
|
||||
"plugin_id": "qiu.subscription-admin",
|
||||
"name": "Subscription Admin",
|
||||
"version": "0.1.1",
|
||||
"version": "0.2.0",
|
||||
"core_api_baseline": "sub2api-0.1.183",
|
||||
"capabilities": ["subscription.admin.v1"],
|
||||
"tested_core_versions": ["0.1.183"],
|
||||
@@ -37,8 +37,8 @@
|
||||
"GET /api/v1/admin/users/{id}/subscriptions"
|
||||
],
|
||||
"files": {
|
||||
"ui/index.html": "a189f81675f1bf7820111123221d8056111c86ca104fad2906e961fad6ef4697",
|
||||
"ui/app.js": "51896358caa769c1fc6353613b92d02bbdd340a24dca567b4f86fcaf1f5f36ca",
|
||||
"ui/styles.css": "d96dff24fa6f7d96a977f5d8f09986cedb987aad1bb26177b9bf4d7b5982ae54"
|
||||
"ui/index.html": "1c863feae45d0c2b17e7c3f267f0360b5998b796961eb191deb783ea5cc5ebf7",
|
||||
"ui/app.js": "c6b5742f7926a89df8ddf7a18b60903f235b80ee39d697192a7b57b145f5c126",
|
||||
"ui/styles.css": "2f0a1848586f66c3df4a54e7b674618788c2309dc8e3068e88a2676a6dcb09e9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "qiu.subscription-admin",
|
||||
"label": "订阅管理",
|
||||
"url": "https://CORE_ORIGIN/extensions/qiu.subscription-admin/",
|
||||
"url": "https://CORE_ORIGIN/extensions/qiu.plugin-admin/admin/#/modules/subscription/overview",
|
||||
"visibility": "admin",
|
||||
"sort_order": 200
|
||||
}
|
||||
|
||||
@@ -7,16 +7,29 @@ Wants=network-online.target
|
||||
Type=simple
|
||||
User=sub2api-plugin
|
||||
Group=sub2api-plugin
|
||||
WorkingDirectory=/opt/sub2api/subscription-admin
|
||||
EnvironmentFile=/etc/sub2api/subscription-admin.env
|
||||
ExecStart=/opt/sub2api/subscription-admin/bin/subscription-admin
|
||||
WorkingDirectory=/var/lib/sub2api-add/subscription-admin
|
||||
EnvironmentFile=/etc/sub2api-add/subscription-admin.env
|
||||
ExecStart=/opt/sub2api-add/subscription-admin/bin/subscription-admin
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/sub2api/subscription-admin
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelLogs=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
CapabilityBoundingSet=
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
TasksMax=128
|
||||
MemoryMax=512M
|
||||
CPUQuota=200%
|
||||
ReadWritePaths=/var/lib/sub2api-add/subscription-admin
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"embed"
|
||||
@@ -32,8 +33,9 @@ const (
|
||||
sessionTTL = 30 * time.Minute
|
||||
sessionMaxTTL = 8 * time.Hour
|
||||
pendingTTL = 5 * time.Minute
|
||||
maxPendingLogins = 1024
|
||||
pluginID = "qiu.subscription-admin"
|
||||
pluginVersion = "0.1.1"
|
||||
pluginVersion = "0.2.0"
|
||||
requestIDHeader = "X-Request-ID"
|
||||
maxRequestIDBytes = 64
|
||||
)
|
||||
@@ -436,10 +438,33 @@ func token(n int) string {
|
||||
}
|
||||
|
||||
func decodeJSON(r *http.Request, out any) error {
|
||||
if r == nil || r.Body == nil {
|
||||
return errors.New("request body is required")
|
||||
}
|
||||
defer r.Body.Close()
|
||||
dec := json.NewDecoder(io.LimitReader(r.Body, maxBodyBytes))
|
||||
raw, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if int64(len(raw)) > maxBodyBytes {
|
||||
return errors.New("request body exceeds size limit")
|
||||
}
|
||||
if len(bytes.TrimSpace(raw)) == 0 {
|
||||
return errors.New("request body is required")
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
return dec.Decode(out)
|
||||
if err := dec.Decode(out); err != nil {
|
||||
return err
|
||||
}
|
||||
var trailing any
|
||||
if err := dec.Decode(&trailing); err != io.EOF {
|
||||
if err == nil {
|
||||
return errors.New("request body must contain exactly one JSON value")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *app) writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
@@ -480,6 +505,13 @@ func (a *app) allowLoginAttempt(r *http.Request, identity string) bool {
|
||||
now := a.clock()
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
// Expire buckets opportunistically so a long-lived development compatibility
|
||||
// process cannot retain one map entry for every attempted identity forever.
|
||||
for candidate, attempt := range a.loginAttempts {
|
||||
if attempt.started.IsZero() || now.Sub(attempt.started) >= a.loginWindow {
|
||||
delete(a.loginAttempts, candidate)
|
||||
}
|
||||
}
|
||||
attempt := a.loginAttempts[key]
|
||||
if attempt.started.IsZero() || now.Sub(attempt.started) >= a.loginWindow {
|
||||
attempt = loginAttempt{started: now}
|
||||
@@ -493,6 +525,32 @@ func (a *app) allowLoginAttempt(r *http.Request, identity string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// addPendingLogin keeps the optional standalone 2FA compatibility mode
|
||||
// bounded. A client can create a challenge without completing it, so expired
|
||||
// entries are removed before enforcing the hard cap.
|
||||
func (a *app) addPendingLogin(value pendingLogin) (string, bool) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
now := time.Now()
|
||||
if a.clock != nil {
|
||||
now = a.clock()
|
||||
}
|
||||
for id, pending := range a.pending {
|
||||
if !pending.expires.After(now) {
|
||||
delete(a.pending, id)
|
||||
}
|
||||
}
|
||||
if len(a.pending) >= maxPendingLogins {
|
||||
return "", false
|
||||
}
|
||||
if a.pending == nil {
|
||||
a.pending = make(map[string]pendingLogin)
|
||||
}
|
||||
id := token(24)
|
||||
a.pending[id] = value
|
||||
return id, true
|
||||
}
|
||||
|
||||
func (a *app) clientIP(r *http.Request) string {
|
||||
return trustedClientIPWithConfig(r, a.trustProxy)
|
||||
}
|
||||
@@ -526,10 +584,12 @@ func (a *app) login(w http.ResponseWriter, r *http.Request) {
|
||||
a.writeJSON(w, http.StatusBadGateway, map[string]string{"error": "core 2fa challenge missing"})
|
||||
return
|
||||
}
|
||||
pendingID := token(24)
|
||||
a.mu.Lock()
|
||||
a.pending[pendingID] = pendingLogin{tempToken: temp, expires: a.clock().Add(pendingTTL), clientIP: a.clientIP(r)}
|
||||
a.mu.Unlock()
|
||||
pendingID, accepted := a.addPendingLogin(pendingLogin{tempToken: temp, expires: a.clock().Add(pendingTTL), clientIP: a.clientIP(r)})
|
||||
if !accepted {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
a.writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "too many pending login challenges"})
|
||||
return
|
||||
}
|
||||
a.writeJSON(w, http.StatusOK, map[string]any{"requires_2fa": true, "pending_token": pendingID})
|
||||
return
|
||||
}
|
||||
@@ -760,6 +820,13 @@ func (a *app) userProxy(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if ce, unauthorized := err.(*coreError); unauthorized && ce.status == http.StatusUnauthorized {
|
||||
a.removeSessionIfCurrent(sessionID, s.accessToken)
|
||||
a.core.logout(r.Context(), s.refreshToken, correlationID)
|
||||
a.setSessionCookie(w, "", -1)
|
||||
a.writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "core session expired"})
|
||||
return
|
||||
}
|
||||
a.coreError(w, err, "user lookup failed")
|
||||
return
|
||||
}
|
||||
@@ -853,18 +920,25 @@ func (a *app) refreshSession(ctx context.Context, id string, stale session) (ses
|
||||
return session{}, false
|
||||
}
|
||||
data := envelopeData(refreshed)
|
||||
candidateRefresh := current.refreshToken
|
||||
if nextRefresh, ok := data["refresh_token"].(string); ok && nextRefresh != "" {
|
||||
candidateRefresh = nextRefresh
|
||||
}
|
||||
access, _ := data["access_token"].(string)
|
||||
if access == "" {
|
||||
a.core.logout(ctx, candidateRefresh, correlationID)
|
||||
return session{}, false
|
||||
}
|
||||
// Refresh-token rotation is common. Do not commit the replacement until the
|
||||
// new access token has passed the Core identity and admin-role checks; if a
|
||||
// check fails, revoke the replacement instead of leaving it live.
|
||||
nextMe, err := a.core.me(ctx, access, correlationID)
|
||||
if err != nil || !isAdmin(envelopeData(nextMe)) {
|
||||
a.core.logout(ctx, candidateRefresh, correlationID)
|
||||
return session{}, false
|
||||
}
|
||||
current.accessToken = access
|
||||
if nextRefresh, ok := data["refresh_token"].(string); ok && nextRefresh != "" {
|
||||
current.refreshToken = nextRefresh
|
||||
}
|
||||
current.refreshToken = candidateRefresh
|
||||
current.user = publicUser(envelopeData(nextMe))
|
||||
current.lastSeen = a.clock()
|
||||
a.mu.Lock()
|
||||
@@ -995,40 +1069,63 @@ func (a *app) static(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
// standaloneAuthEnabled is intentionally fail-closed. The module's own
|
||||
// login/API compatibility surface is useful for local contract tests only;
|
||||
// production deployments must expose the shared Plugin Admin Shell instead.
|
||||
func standaloneAuthEnabled() bool {
|
||||
if !strings.EqualFold(strings.TrimSpace(os.Getenv("PLUGIN_STANDALONE_AUTH")), "true") {
|
||||
return false
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(os.Getenv("PLUGIN_ENV")), "development") {
|
||||
return false
|
||||
}
|
||||
host := strings.Trim(strings.TrimSpace(os.Getenv("PLUGIN_HOST")), "[]")
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
return isLoopbackHost(host)
|
||||
}
|
||||
|
||||
func (a *app) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", a.health)
|
||||
mux.HandleFunc("/readyz", a.ready)
|
||||
mux.HandleFunc("/login", a.login)
|
||||
mux.HandleFunc("/login/2fa", a.login2FA)
|
||||
mux.HandleFunc("/logout", a.logout)
|
||||
mux.HandleFunc("/api/me", a.me)
|
||||
mux.HandleFunc("/api/status", a.status)
|
||||
mux.HandleFunc("/api/captcha-config", a.captchaConfig)
|
||||
mux.HandleFunc("/api/audit", a.auditLog)
|
||||
mux.Handle("/api/plans", a.readProxy("/api/v1/admin/payment/plans"))
|
||||
mux.Handle("/api/subscriptions", a.readProxy("/api/v1/admin/subscriptions"))
|
||||
mux.HandleFunc("/api/subscriptions/", func(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/subscriptions/")
|
||||
if !positiveID(id) || strings.Contains(id, "/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
a.readProxy("/api/v1/admin/subscriptions/"+id)(w, r)
|
||||
})
|
||||
mux.HandleFunc("/api/users/", func(w http.ResponseWriter, r *http.Request) {
|
||||
rest := strings.TrimPrefix(r.URL.Path, "/api/users/")
|
||||
parts := strings.Split(rest, "/")
|
||||
if len(parts) == 1 {
|
||||
a.userProxy(w, r)
|
||||
return
|
||||
}
|
||||
if len(parts) != 2 || !positiveID(parts[0]) || parts[1] != "subscriptions" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
a.readProxy("/api/v1/admin/users/"+parts[0]+"/subscriptions")(w, r)
|
||||
})
|
||||
// The business module is mounted by Plugin Admin. A standalone compatibility
|
||||
// API is available only when explicitly opted into for local contract tests;
|
||||
// production deployments leave it disabled so there is no second login,
|
||||
// session cookie, or Core-data BFF on the module's own port.
|
||||
if standaloneAuthEnabled() {
|
||||
mux.HandleFunc("/login", a.login)
|
||||
mux.HandleFunc("/login/2fa", a.login2FA)
|
||||
mux.HandleFunc("/logout", a.logout)
|
||||
mux.HandleFunc("/api/me", a.me)
|
||||
mux.HandleFunc("/api/status", a.status)
|
||||
mux.HandleFunc("/api/captcha-config", a.captchaConfig)
|
||||
mux.HandleFunc("/api/audit", a.auditLog)
|
||||
mux.Handle("/api/plans", a.readProxy("/api/v1/admin/payment/plans"))
|
||||
mux.Handle("/api/subscriptions", a.readProxy("/api/v1/admin/subscriptions"))
|
||||
mux.HandleFunc("/api/subscriptions/", func(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/subscriptions/")
|
||||
if !positiveID(id) || strings.Contains(id, "/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
a.readProxy("/api/v1/admin/subscriptions/"+id)(w, r)
|
||||
})
|
||||
mux.HandleFunc("/api/users/", func(w http.ResponseWriter, r *http.Request) {
|
||||
rest := strings.TrimPrefix(r.URL.Path, "/api/users/")
|
||||
parts := strings.Split(rest, "/")
|
||||
if len(parts) == 1 {
|
||||
a.userProxy(w, r)
|
||||
return
|
||||
}
|
||||
if len(parts) != 2 || !positiveID(parts[0]) || parts[1] != "subscriptions" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
a.readProxy("/api/v1/admin/users/"+parts[0]+"/subscriptions")(w, r)
|
||||
})
|
||||
}
|
||||
mux.HandleFunc("/", a.static)
|
||||
return requestIDMiddleware(a.securityHeaders(mux))
|
||||
}
|
||||
|
||||
@@ -259,6 +259,97 @@ func TestReadProxyRefreshesAtMostOncePerRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRotationRevokesCandidateWhenAdminCheckFails(t *testing.T) {
|
||||
var meCalls int32
|
||||
var logoutTokens []string
|
||||
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/auth/me":
|
||||
if atomic.AddInt32(&meCalls, 1) == 1 {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"code":401,"message":"expired"}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":2,"role":"user"}}`))
|
||||
case "/api/v1/auth/refresh":
|
||||
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"access_token":"NEW","refresh_token":"NEW-REFRESH"}}`))
|
||||
case "/api/v1/auth/logout":
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
logoutTokens = append(logoutTokens, body["refresh_token"])
|
||||
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
|
||||
default:
|
||||
t.Errorf("unexpected Core path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
a := newApp(c, false)
|
||||
now := time.Now()
|
||||
a.sessions["sid"] = session{accessToken: "OLD", refreshToken: "REFRESH", csrfToken: "CSRF", createdAt: now, lastSeen: now, user: map[string]any{"id": 1, "role": "admin"}}
|
||||
a.sessionLocks["sid"] = &sync.Mutex{}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
|
||||
rec := httptest.NewRecorder()
|
||||
a.me(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected refreshed session rejection: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
for _, value := range logoutTokens {
|
||||
if value == "NEW-REFRESH" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("rotated refresh token was not revoked: %#v", logoutTokens)
|
||||
}
|
||||
|
||||
func TestUserProxyClearsSessionWhenReadTokenIsRevoked(t *testing.T) {
|
||||
var logoutCalls int32
|
||||
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/auth/me":
|
||||
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"id":1,"role":"admin"}}`))
|
||||
case "/api/v1/admin/users/1":
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"code":401,"message":"revoked"}`))
|
||||
case "/api/v1/auth/refresh":
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"code":401,"message":"refresh revoked"}`))
|
||||
case "/api/v1/auth/logout":
|
||||
atomic.AddInt32(&logoutCalls, 1)
|
||||
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
|
||||
default:
|
||||
t.Errorf("unexpected Core path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
a := newApp(c, false)
|
||||
now := time.Now()
|
||||
a.sessions["sid"] = session{accessToken: "TOKEN", refreshToken: "REFRESH", csrfToken: "CSRF", createdAt: now, lastSeen: now, user: map[string]any{"id": 1, "role": "admin"}}
|
||||
a.sessionLocks["sid"] = &sync.Mutex{}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/users/1", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "sid"})
|
||||
rec := httptest.NewRecorder()
|
||||
a.userProxy(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, ok := a.sessions["sid"]; ok {
|
||||
t.Fatal("revoked session remained in plugin store")
|
||||
}
|
||||
if atomic.LoadInt32(&logoutCalls) != 1 {
|
||||
t.Fatalf("logout calls=%d", logoutCalls)
|
||||
}
|
||||
cleared := false
|
||||
for _, cookie := range rec.Result().Cookies() {
|
||||
if cookie.Name == sessionCookieName && cookie.MaxAge < 0 {
|
||||
cleared = true
|
||||
}
|
||||
}
|
||||
if !cleared {
|
||||
t.Fatalf("session cookie was not cleared: %#v", rec.Result().Cookies())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionExpiry(t *testing.T) {
|
||||
now := time.Now()
|
||||
a := newApp(nil, false)
|
||||
@@ -285,6 +376,61 @@ func TestTwoFactorPendingTokenIsSingleUse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingLoginCapAndExpiry(t *testing.T) {
|
||||
now := time.Now()
|
||||
a := newApp(nil, false)
|
||||
a.clock = func() time.Time { return now }
|
||||
for i := 0; i < maxPendingLogins; i++ {
|
||||
if _, ok := a.addPendingLogin(pendingLogin{tempToken: "TEMP", expires: now.Add(time.Minute)}); !ok {
|
||||
t.Fatalf("pending challenge %d was unexpectedly rejected", i)
|
||||
}
|
||||
}
|
||||
if _, ok := a.addPendingLogin(pendingLogin{tempToken: "OVERFLOW", expires: now.Add(time.Minute)}); ok {
|
||||
t.Fatal("pending challenge cap was not enforced")
|
||||
}
|
||||
if got := len(a.pending); got != maxPendingLogins {
|
||||
t.Fatalf("pending map size=%d want %d", got, maxPendingLogins)
|
||||
}
|
||||
a.clock = func() time.Time { return now.Add(pendingTTL + time.Second) }
|
||||
if _, ok := a.addPendingLogin(pendingLogin{tempToken: "AFTER-EXPIRY", expires: now.Add(2 * pendingTTL)}); !ok {
|
||||
t.Fatal("expired pending challenges were not evicted")
|
||||
}
|
||||
if got := len(a.pending); got != 1 {
|
||||
t.Fatalf("pending map size after expiry=%d want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandaloneLoginRateLimitBoundsCoreAttempts(t *testing.T) {
|
||||
t.Setenv("PLUGIN_STANDALONE_AUTH", "true")
|
||||
t.Setenv("PLUGIN_ENV", "development")
|
||||
var loginCalls int32
|
||||
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.URL.Path == "/api/v1/auth/login" {
|
||||
atomic.AddInt32(&loginCalls, 1)
|
||||
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{"requires_2fa":true,"temp_token":"TEMP"}}`))
|
||||
return
|
||||
}
|
||||
t.Errorf("unexpected Core path %s", r.URL.Path)
|
||||
_, _ = w.Write([]byte(`{"code":0,"message":"success","data":{}}`))
|
||||
}))
|
||||
a := newAppWithConfig(c, appConfig{LoginLimit: 2, LoginWindow: time.Hour})
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(`{"email":"admin@example.com","password":"password"}`))
|
||||
rec := httptest.NewRecorder()
|
||||
a.login(rec, req)
|
||||
if attempt < 2 && rec.Code != http.StatusOK {
|
||||
t.Fatalf("attempt %d status=%d body=%s", attempt+1, rec.Code, rec.Body.String())
|
||||
}
|
||||
if attempt == 2 && (rec.Code != http.StatusTooManyRequests || rec.Header().Get("Retry-After") == "") {
|
||||
t.Fatalf("limit response status=%d headers=%v body=%s", rec.Code, rec.Header(), rec.Body.String())
|
||||
}
|
||||
}
|
||||
if got := atomic.LoadInt32(&loginCalls); got != 2 {
|
||||
t.Fatalf("Core received %d login calls want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowedReadPathRejectsTraversalAndUnknownRoutes(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"/api/v1/admin/subscriptions/1/progress",
|
||||
@@ -299,22 +445,39 @@ func TestAllowedReadPathRejectsTraversalAndUnknownRoutes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRoutesProtectReadOnlyEndpointsAndSetSecurityHeaders(t *testing.T) {
|
||||
a := newApp(nil, false)
|
||||
t.Setenv("PLUGIN_STANDALONE_AUTH", "false")
|
||||
t.Setenv("PLUGIN_ENV", "production")
|
||||
var coreCalls int32
|
||||
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&coreCalls, 1)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
a := newApp(c, false)
|
||||
server := httptest.NewServer(a.routes())
|
||||
t.Cleanup(server.Close)
|
||||
response, err := server.Client().Get(server.URL + "/api/plans")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
for _, endpoint := range []string{"/login", "/login/2fa", "/logout", "/api/me", "/api/status", "/api/plans", "/api/subscriptions", "/api/users/1"} {
|
||||
response, err := server.Client().Get(server.URL + endpoint)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("endpoint=%s status=%d", endpoint, response.StatusCode)
|
||||
}
|
||||
if len(response.Cookies()) != 0 {
|
||||
t.Fatalf("endpoint=%s unexpectedly set cookies: %#v", endpoint, response.Cookies())
|
||||
}
|
||||
if response.Header.Get("Content-Security-Policy") == "" || response.Header.Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Fatalf("endpoint=%s security headers missing: %#v", endpoint, response.Header)
|
||||
}
|
||||
}
|
||||
if response.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status=%d", response.StatusCode)
|
||||
}
|
||||
if response.Header.Get("Content-Security-Policy") == "" || response.Header.Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Fatalf("security headers missing: %#v", response.Header)
|
||||
if atomic.LoadInt32(&coreCalls) != 0 {
|
||||
t.Fatalf("disabled standalone routes called Core %d times", coreCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutesPropagateRequestIDAndBootstrapSession(t *testing.T) {
|
||||
t.Setenv("PLUGIN_STANDALONE_AUTH", "true")
|
||||
t.Setenv("PLUGIN_ENV", "development")
|
||||
var coreRequestID string
|
||||
c := testCoreClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
coreRequestID = r.Header.Get(requestIDHeader)
|
||||
@@ -358,6 +521,26 @@ func TestRoutesPropagateRequestIDAndBootstrapSession(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandaloneAuthFailsClosedOutsideDevelopmentLoopback(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
env string
|
||||
host string
|
||||
}{
|
||||
{name: "production", env: "production", host: "127.0.0.1"},
|
||||
{name: "public-development", env: "development", host: "0.0.0.0"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Setenv("PLUGIN_STANDALONE_AUTH", "true")
|
||||
t.Setenv("PLUGIN_ENV", test.env)
|
||||
t.Setenv("PLUGIN_HOST", test.host)
|
||||
if standaloneAuthEnabled() {
|
||||
t.Fatal("standalone auth unexpectedly enabled")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func readBody(t *testing.T, response *http.Response) string {
|
||||
t.Helper()
|
||||
defer response.Body.Close()
|
||||
|
||||
@@ -3,6 +3,7 @@ set -eu
|
||||
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
OUT=${OUT:-"$ROOT/dist/qiu.subscription-admin.s2plugin"}
|
||||
RELEASE_BUILD=${RELEASE_BUILD:-false}
|
||||
command -v jq >/dev/null 2>&1 || { printf '%s\n' 'jq is required to build a package' >&2; exit 2; }
|
||||
TMP=$(mktemp -d)
|
||||
cleanup() {
|
||||
@@ -24,10 +25,30 @@ jq --arg index_hash "$INDEX_HASH" --arg app_hash "$APP_HASH" --arg styles_hash "
|
||||
"$ROOT/business-plugin-manifest.v1.json" > "$TMP/manifest.json"
|
||||
|
||||
if [ -n "${SIGNATURE_FILE:-}" ]; then
|
||||
cp "$SIGNATURE_FILE" "$TMP/signature.json"
|
||||
[ -f "$SIGNATURE_FILE" ] || { printf '%s\n' 'SIGNATURE_FILE does not exist' >&2; exit 2; }
|
||||
SIGNATURE_KEY=$(jq -r '.publisher.key_id // empty' "$TMP/manifest.json")
|
||||
SIGNATURE_ALGORITHM=$(jq -r '.algorithm // empty' "$SIGNATURE_FILE")
|
||||
SIGNATURE_FILE_KEY=$(jq -r '.key_id // empty' "$SIGNATURE_FILE")
|
||||
SIGNATURE_VALUE=$(jq -r '.signature // empty' "$SIGNATURE_FILE")
|
||||
[ "$SIGNATURE_ALGORITHM" = "ed25519" ] || { printf '%s\n' 'signature.json must use ed25519' >&2; exit 2; }
|
||||
[ -n "$SIGNATURE_KEY" ] && [ "$SIGNATURE_FILE_KEY" = "$SIGNATURE_KEY" ] || { printf '%s\n' 'signature key_id does not match manifest publisher' >&2; exit 2; }
|
||||
[ -n "$SIGNATURE_VALUE" ] || { printf '%s\n' 'signature value is required' >&2; exit 2; }
|
||||
cp "$SIGNATURE_FILE" "$TMP/signature.json"
|
||||
elif [ "$RELEASE_BUILD" = "true" ]; then
|
||||
printf '%s\n' 'RELEASE_BUILD=true requires SIGNATURE_FILE' >&2
|
||||
exit 2
|
||||
else
|
||||
printf '%s\n' 'warning: building an unsigned development package' >&2
|
||||
fi
|
||||
|
||||
OUT_DIR=$(CDPATH= cd -- "$(dirname -- "$OUT")" && pwd)
|
||||
OUT_PATH="$OUT_DIR/$(basename -- "$OUT")"
|
||||
(cd "$TMP" && zip -q -r "$OUT_PATH" manifest.json ui)
|
||||
find "$TMP" -type f -exec touch -t 198001010000 {} +
|
||||
if [ -f "$TMP/signature.json" ]; then
|
||||
(cd "$TMP" && zip -X -q -r "$OUT_PATH" manifest.json signature.json ui)
|
||||
else
|
||||
(cd "$TMP" && zip -X -q -r "$OUT_PATH" manifest.json ui)
|
||||
fi
|
||||
ARCHIVE_HASH=$(shasum -a 256 "$OUT_PATH" | awk '{print $1}')
|
||||
printf '%s %s\n' "$ARCHIVE_HASH" "$(basename -- "$OUT_PATH")" > "$OUT_PATH.sha256"
|
||||
printf '%s\n' "$OUT_PATH"
|
||||
|
||||
@@ -8,7 +8,9 @@ const outputDir = process.env.PLUGIN_SCREENSHOT_DIR || '.playwright-cli/subscrip
|
||||
|
||||
await fs.mkdir(path.resolve(outputDir), { recursive: true })
|
||||
|
||||
const browser = await chromium.launch({ headless: true })
|
||||
const launchOptions = { headless: true }
|
||||
if (process.env.PLAYWRIGHT_EXECUTABLE_PATH) launchOptions.executablePath = process.env.PLAYWRIGHT_EXECUTABLE_PATH
|
||||
const browser = await chromium.launch(launchOptions)
|
||||
try {
|
||||
for (const width of [425, 900, 1440]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 900 }, deviceScaleFactor: 1 })
|
||||
@@ -21,18 +23,12 @@ try {
|
||||
} catch (_) {}
|
||||
})
|
||||
await page.goto(entry, { waitUntil: 'domcontentloaded' })
|
||||
await page.getByLabel('管理员邮箱').fill('admin@example.com')
|
||||
await page.getByLabel('密码').fill('password')
|
||||
await page.getByRole('button', { name: '登录' }).click()
|
||||
await page.locator('#app-view').waitFor({ state: 'visible' })
|
||||
await page.getByRole('heading', { name: '订阅管理已纳入统一控制面' }).waitFor()
|
||||
if (await page.locator('input[type="password"], form').count()) throw new Error('subscription module exposed an independent login form')
|
||||
await page.waitForTimeout(50)
|
||||
if (responseLeaks.length) throw new Error(`sensitive response field exposed at ${width}px: ${responseLeaks.join(', ')}`)
|
||||
await page.getByRole('button', { name: '用户订阅' }).click()
|
||||
await page.locator('#subscriptions-list table').waitFor()
|
||||
await page.getByRole('button', { name: '下一页' }).click()
|
||||
await page.getByRole('button', { name: '概览' }).click()
|
||||
await page.reload({ waitUntil: 'domcontentloaded' })
|
||||
await page.locator('#app-view').waitFor({ state: 'visible' })
|
||||
await page.getByRole('heading', { name: '订阅管理已纳入统一控制面' }).waitFor()
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)
|
||||
if (overflow) throw new Error(`horizontal overflow at ${width}px`)
|
||||
await page.screenshot({ path: path.join(outputDir, `subscription-admin-${width}.png`), fullPage: true })
|
||||
|
||||
@@ -3,6 +3,7 @@ import http from 'node:http'
|
||||
const port = Number(process.env.MOCK_CORE_PORT || 18080)
|
||||
const token = process.env.MOCK_ACCESS_TOKEN || 'MOCK-ACCESS'
|
||||
const refresh = process.env.MOCK_REFRESH_TOKEN || 'MOCK-REFRESH'
|
||||
const captchaProvider = String(process.env.MOCK_CAPTCHA_PROVIDER || '').toLowerCase()
|
||||
const users = new Map([
|
||||
['admin@example.com', { id: 1, role: 'admin', email: 'admin@example.com', username: 'admin' }],
|
||||
['user@example.com', { id: 2, role: 'user', email: 'user@example.com', username: 'user' }]
|
||||
@@ -47,6 +48,9 @@ const server = http.createServer(async (req, res) => {
|
||||
const input = await body(req)
|
||||
const user = users.get(input.email)
|
||||
if (!user || input.password !== 'password') return json(res, 401, { code: 401, message: 'invalid credentials' })
|
||||
if (captchaProvider === 'turnstile' && input.turnstile_token !== 'MOCK-TURNSTILE-TOKEN') return json(res, 400, { code: 400, message: 'captcha required' })
|
||||
if (captchaProvider === 'tencent' && (!input.tencent_captcha_ticket || !input.tencent_captcha_randstr)) return json(res, 400, { code: 400, message: 'captcha required' })
|
||||
if (captchaProvider === 'aliyun' && !input.turnstile_token) return json(res, 400, { code: 400, message: 'captcha required' })
|
||||
return json(res, 200, { code: 0, message: 'success', data: { access_token: token, refresh_token: refresh, user } })
|
||||
}
|
||||
if (req.method === 'POST' && path === '/api/v1/auth/refresh') {
|
||||
@@ -55,7 +59,12 @@ const server = http.createServer(async (req, res) => {
|
||||
return json(res, 200, { code: 0, message: 'success', data: { access_token: 'NEW-MOCK-ACCESS', refresh_token: 'NEW-MOCK-REFRESH' } })
|
||||
}
|
||||
if (req.method === 'POST' && path === '/api/v1/auth/logout') return json(res, 200, { code: 0, message: 'success', data: {} })
|
||||
if (req.method === 'GET' && path === '/api/v1/settings/public') return json(res, 200, { code: 0, message: 'success', data: { turnstile_enabled: false, geetest_captcha_enabled: false, tencent_captcha_enabled: false, aliyun_captcha_enabled: false } })
|
||||
if (req.method === 'GET' && path === '/api/v1/settings/public') return json(res, 200, { code: 0, message: 'success', data: {
|
||||
turnstile_enabled: captchaProvider === 'turnstile', turnstile_site_key: captchaProvider === 'turnstile' ? 'mock-site-key' : '',
|
||||
geetest_captcha_enabled: false,
|
||||
tencent_captcha_enabled: captchaProvider === 'tencent', tencent_captcha_app_id: captchaProvider === 'tencent' ? '123456' : '', tencent_captcha_region: 'cn',
|
||||
aliyun_captcha_enabled: captchaProvider === 'aliyun', aliyun_captcha_scene_id: captchaProvider === 'aliyun' ? 'mock-scene' : '', aliyun_captcha_prefix: captchaProvider === 'aliyun' ? 'mock-prefix' : '', aliyun_captcha_region: 'cn'
|
||||
} })
|
||||
if (!authorized(req)) return json(res, 401, { code: 401, message: 'unauthorized' })
|
||||
if (req.method === 'GET' && path === '/api/v1/auth/me') return json(res, 200, { code: 0, message: 'success', data: users.get('admin@example.com') })
|
||||
if (req.method === 'GET' && path === '/api/v1/admin/payment/plans') {
|
||||
|
||||
@@ -8,14 +8,22 @@ cleanup() {
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
MOCK_CORE_PORT=18080 node "$ROOT/test/mock-core.mjs" >/tmp/subscription-admin-mock-core.log 2>&1 & CORE_PID=$!
|
||||
CORE_BASE_URL=http://127.0.0.1:18080 \
|
||||
PLUGIN_HOST=127.0.0.1 \
|
||||
PLUGIN_PORT=18082 \
|
||||
PLUGIN_PUBLIC_BASE_PATH=/extensions/qiu.subscription-admin \
|
||||
PLUGIN_COOKIE_PATH=/extensions/qiu.subscription-admin/ \
|
||||
PLUGIN_COOKIE_SECURE=false \
|
||||
go run "$ROOT" >/tmp/subscription-admin-plugin.log 2>&1 & PLUGIN_PID=$!
|
||||
(
|
||||
export CORE_BASE_URL=http://127.0.0.1:18080
|
||||
export PLUGIN_HOST=127.0.0.1
|
||||
export PLUGIN_PORT=18082
|
||||
export PLUGIN_PUBLIC_BASE_PATH=/extensions/qiu.subscription-admin
|
||||
export PLUGIN_COOKIE_PATH=/extensions/qiu.subscription-admin/
|
||||
export PLUGIN_COOKIE_SECURE=false
|
||||
cd "$ROOT"
|
||||
go run .
|
||||
) >/tmp/subscription-admin-plugin.log 2>&1 & PLUGIN_PID=$!
|
||||
PLUGIN_TARGET_PORT=18082 MOCK_PROXY_PORT=18081 node "$ROOT/test/mock-proxy.mjs" >/tmp/subscription-admin-mock-proxy.log 2>&1 & PROXY_PID=$!
|
||||
|
||||
sleep 2
|
||||
for attempt in $(seq 1 30); do
|
||||
if curl -fsS http://127.0.0.1:18081/extensions/qiu.subscription-admin/healthz >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
PLUGIN_BROWSER_ORIGIN=http://127.0.0.1:18081 PLUGIN_SCREENSHOT_DIR="$ROOT/.screenshots/subscription-admin" node "$ROOT/test/browser-check.mjs"
|
||||
|
||||
@@ -1,170 +1,5 @@
|
||||
// The subscription browser UI is mounted by Plugin Admin. This compatibility
|
||||
// entry intentionally contains no login form, cookie, or token handling.
|
||||
(() => {
|
||||
'use strict'
|
||||
|
||||
const state = {
|
||||
csrf: '',
|
||||
plans: [],
|
||||
subscriptions: null,
|
||||
subscriptionPage: 1,
|
||||
subscriptionPageSize: 50,
|
||||
captcha: { enabled: false, provider: '' }
|
||||
}
|
||||
const $ = (selector) => document.querySelector(selector)
|
||||
const loginView = $('#login-view')
|
||||
const appView = $('#app-view')
|
||||
const loginForm = $('#login-form')
|
||||
const basePath = (document.body.dataset.basePath || '').replace(/\/$/, '')
|
||||
const route = (path) => `${basePath}${path}`
|
||||
|
||||
function showError(message, target = $('#app-error')) {
|
||||
target.textContent = message || '请求失败'
|
||||
target.hidden = !message
|
||||
}
|
||||
function clearError(target = $('#app-error')) { target.textContent = ''; target.hidden = true }
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const headers = new Headers(options.headers || {})
|
||||
headers.set('Accept', 'application/json')
|
||||
if (options.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json')
|
||||
if (state.csrf && options.method && options.method !== 'GET') headers.set('X-CSRF-Token', state.csrf)
|
||||
const response = await fetch(route(path), { ...options, headers, credentials: 'same-origin' })
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) { state.csrf = ''; loginView.hidden = false; appView.hidden = true }
|
||||
throw new Error(payload.error || '请求失败')
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
function unwrap(payload) { return payload && payload.code === 0 && Object.prototype.hasOwnProperty.call(payload, 'data') ? payload.data : payload }
|
||||
function formatNumber(value) {
|
||||
if (value === null || value === undefined || value === '') return '-'
|
||||
const number = Number(value)
|
||||
return Number.isFinite(number) ? number.toLocaleString('zh-CN', { maximumFractionDigits: 6 }) : String(value)
|
||||
}
|
||||
function formatDate(value) {
|
||||
if (!value) return '-'
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('zh-CN', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
}
|
||||
function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, (character) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[character])) }
|
||||
function statusLabel(value) { const safe = String(value || 'unknown'); return `<span class="pill pill-${safe.toLowerCase()}">${escapeHTML(safe)}</span>` }
|
||||
function setView(name) {
|
||||
document.querySelectorAll('.tab').forEach((button) => button.classList.toggle('active', button.dataset.view === name))
|
||||
document.querySelectorAll('.view').forEach((view) => { view.hidden = view.id !== `view-${name}` })
|
||||
if (name === 'plans' && !state.plans.length) loadPlans()
|
||||
if (name === 'subscriptions' && !state.subscriptions) loadSubscriptions()
|
||||
if (name === 'audit') loadAudit()
|
||||
}
|
||||
|
||||
async function login(event) {
|
||||
event.preventDefault(); clearError($('#login-error'))
|
||||
const form = new FormData(loginForm); const button = loginForm.querySelector('button[type="submit"]'); button.disabled = true
|
||||
try {
|
||||
const captchaToken = String(form.get('captcha_token') || '').trim()
|
||||
if (state.captcha.enabled && !captchaToken) throw new Error('请先完成安全验证并填写验证结果')
|
||||
let payload = await request('/login', { method: 'POST', body: JSON.stringify({
|
||||
email: form.get('email'),
|
||||
password: form.get('password'),
|
||||
turnstile_token: captchaToken || undefined,
|
||||
tencent_captcha_ticket: state.captcha.provider === 'tencent' ? captchaToken || undefined : undefined,
|
||||
tencent_captcha_randstr: state.captcha.provider === 'tencent' ? String(form.get('captcha_randstr') || '').trim() || undefined : undefined
|
||||
}) })
|
||||
if (payload.requires_2fa) {
|
||||
const code = window.prompt('请输入管理员 2FA 验证码')
|
||||
if (!code) throw new Error('需要 2FA 验证码')
|
||||
payload = await request('/login/2fa', { method: 'POST', body: JSON.stringify({ pending_token: payload.pending_token, totp_code: code }) })
|
||||
}
|
||||
if (!payload.ok || !payload.csrf_token) throw new Error('登录响应无效')
|
||||
state.csrf = payload.csrf_token; loginView.hidden = true; appView.hidden = false
|
||||
const user = payload.user || {}; $('#operator').textContent = user.email || user.username || `管理员 #${user.id || '-'}`
|
||||
await Promise.all([loadStatus(), loadOverview()])
|
||||
} catch (error) { showError(error.message, $('#login-error')) } finally { button.disabled = false }
|
||||
}
|
||||
|
||||
async function loadPlans() {
|
||||
try { const payload = unwrap(await request('/api/plans')); state.plans = Array.isArray(payload) ? payload : []; $('#plan-count').textContent = formatNumber(state.plans.length); renderPlans(); return true }
|
||||
catch (error) { showError(error.message); return false }
|
||||
}
|
||||
async function loadSubscriptions() {
|
||||
try {
|
||||
const params = new URLSearchParams(); const form = new FormData($('#subscription-filter'))
|
||||
const userID = String(form.get('user_id') || '').trim(); const status = String(form.get('status') || '')
|
||||
if (userID && !/^[1-9][0-9]*$/.test(userID)) throw new Error('用户 ID 必须是正整数')
|
||||
let endpoint = '/api/subscriptions'
|
||||
if (userID && !status) endpoint = `/api/users/${encodeURIComponent(userID)}/subscriptions`
|
||||
if (endpoint === '/api/subscriptions') { if (userID) params.set('user_id', userID); if (status) params.set('status', status); params.set('page', String(state.subscriptionPage)); params.set('page_size', String(state.subscriptionPageSize)) }
|
||||
const payload = unwrap(await request(`${endpoint}${params.toString() ? `?${params.toString()}` : ''}`)) || {}; state.subscriptions = payload
|
||||
$('#subscription-count').textContent = formatNumber(Array.isArray(payload) ? payload.length : (payload.total ?? payload.items?.length ?? 0)); renderSubscriptions(payload); return true
|
||||
} catch (error) { showError(error.message); return false }
|
||||
}
|
||||
async function loadHealth() { try { const response = await fetch(route('/healthz'), { credentials: 'same-origin', headers: { Accept: 'application/json' } }); return response.ok } catch { return false } }
|
||||
async function loadOverview() {
|
||||
clearError(); $('#sync-time').textContent = '同步中'
|
||||
const results = await Promise.all([loadHealth(), loadPlans(), loadSubscriptions()])
|
||||
const healthy = results.every(Boolean); const degraded = results.some(Boolean)
|
||||
setCoreStatus(healthy ? 'ok' : degraded ? 'degraded' : 'bad')
|
||||
$('#sync-time').textContent = degraded ? `最近同步:${formatDate(new Date().toISOString())}` : '同步失败'
|
||||
}
|
||||
async function loadAudit() { try { const payload = await request('/api/audit'); renderAudit(payload.items || []) } catch (error) { showError(error.message) } }
|
||||
async function loadStatus() {
|
||||
try { const payload = await request('/api/status'); $('#credential-status').textContent = payload.credential_state === 'server_managed' ? '服务端托管(不回显)' : '不可用' }
|
||||
catch { $('#credential-status').textContent = '不可用' }
|
||||
}
|
||||
async function loadCaptchaConfig() {
|
||||
try {
|
||||
const payload = await request('/api/captcha-config'); state.captcha = payload
|
||||
const panel = $('#captcha-panel')
|
||||
if (!payload.enabled) { panel.hidden = true; return }
|
||||
panel.hidden = false; $('#captcha-label').textContent = `${payload.provider || '安全'}验证`
|
||||
const descriptions = { geetest: 'Core 已启用 GeeTest。请在官方验证组件完成挑战后,将返回的 JSON 验证结果粘贴到此处。', turnstile: 'Core 已启用 Cloudflare Turnstile。请完成挑战后填写返回的验证结果。', tencent: 'Core 已启用腾讯验证码。请填写 ticket,并在下方填写 randstr。', aliyun: 'Core 已启用阿里云验证码。请填写 captchaVerifyParam。' }
|
||||
$('#captcha-help').textContent = descriptions[payload.provider] || '请完成 Core 配置的验证码后填写验证结果。'
|
||||
$('#captcha-randstr-row').hidden = payload.provider !== 'tencent'
|
||||
} catch { $('#captcha-panel').hidden = true }
|
||||
}
|
||||
async function loadBalance(userID) {
|
||||
if (!/^[1-9][0-9]*$/.test(userID)) throw new Error('用户 ID 必须是正整数')
|
||||
const payload = unwrap(await request(`/api/users/${encodeURIComponent(userID)}`)) || {}; $('#user-balance').textContent = formatNumber(payload.balance)
|
||||
}
|
||||
async function bootstrap() {
|
||||
try {
|
||||
const payload = await request('/api/me'); state.csrf = payload.csrf_token || ''
|
||||
if (!state.csrf || !payload.user) throw new Error('session unavailable')
|
||||
loginView.hidden = true; appView.hidden = false
|
||||
const user = payload.user; $('#operator').textContent = user.email || user.username || `管理员 #${user.id || '-'}`
|
||||
await Promise.all([loadCaptchaConfig(), loadStatus(), loadOverview()])
|
||||
} catch { loginView.hidden = false; appView.hidden = true; await loadCaptchaConfig() }
|
||||
}
|
||||
function setCoreStatus(stateName) { const element = $('#core-status'); const labels = { ok: '已连接', degraded: '部分可用', bad: '不可用' }; element.textContent = labels[stateName] || '检查中'; element.className = `status ${stateName === 'ok' ? 'ok' : stateName === 'bad' ? 'bad' : ''}` }
|
||||
function parseFeatures(features) { if (!features) return []; if (Array.isArray(features)) return features; if (typeof features !== 'string') return []; try { const parsed = JSON.parse(features); return Array.isArray(parsed) ? parsed : [] } catch { return features.split(/[,\n]/).map((item) => item.trim()).filter(Boolean) } }
|
||||
function renderPlans() {
|
||||
const container = $('#plans-list'); if (!state.plans.length) { container.innerHTML = '<p class="empty">暂无套餐数据</p>'; return }
|
||||
container.innerHTML = state.plans.map((plan) => {
|
||||
const groups = (plan.included_groups || []).map((group) => `<span class="tag">${escapeHTML(group.name || group.id)}</span>`).join('') || '<span class="muted">未返回分组</span>'
|
||||
const features = parseFeatures(plan.features)
|
||||
return `<article class="plan-card"><div class="card-heading"><div><h3>${escapeHTML(plan.name || plan.product_name || `套餐 #${plan.id}`)}</h3><p class="muted">${escapeHTML(plan.description || '')}</p></div>${plan.for_sale ? '<span class="pill pill-active">可售</span>' : '<span class="pill">下架</span>'}</div><div class="plan-price">${formatNumber(plan.price)} <small>${escapeHTML(plan.currency || '')}</small></div><dl class="facts"><div><dt>有效期</dt><dd>${formatNumber(plan.validity_days)} ${escapeHTML(plan.validity_unit || '天')}</dd></div><div><dt>周期额度</dt><dd>${formatNumber(plan.cycle_quota_usd)}</dd></div><div><dt>总额度</dt><dd>${formatNumber(plan.total_quota_usd)}</dd></div><div><dt>实例上限</dt><dd>${formatNumber(plan.max_subscriptions_per_user)}</dd></div></dl><div class="tags">${groups}</div>${features.length ? `<ul class="feature-list">${features.map((feature) => `<li>${escapeHTML(feature)}</li>`).join('')}</ul>` : ''}</article>`
|
||||
}).join('')
|
||||
}
|
||||
function renderSubscriptions(payload) {
|
||||
const container = $('#subscriptions-list'); const items = Array.isArray(payload) ? payload : (payload.items || []); const pagination = $('#subscription-pagination')
|
||||
if (Array.isArray(payload) || !payload.pages) pagination.hidden = true
|
||||
else { pagination.hidden = false; $('#subscription-page-info').textContent = `第 ${payload.page || state.subscriptionPage} / ${payload.pages} 页,共 ${formatNumber(payload.total)} 条`; $('#subscription-prev').disabled = (payload.page || state.subscriptionPage) <= 1; $('#subscription-next').disabled = (payload.page || state.subscriptionPage) >= payload.pages }
|
||||
if (!items.length) { container.innerHTML = '<p class="empty">暂无订阅数据</p>'; return }
|
||||
container.innerHTML = `<table><thead><tr><th>订阅实例</th><th>用户</th><th>套餐</th><th>状态</th><th>有效期</th><th>周期用量</th><th></th></tr></thead><tbody>${items.map((item) => `<tr><td><code>#${escapeHTML(item.id)}</code></td><td>${escapeHTML(item.user?.username || item.user?.email || item.user_id || '-')}</td><td>${escapeHTML(item.plan_name || item.plan_id || '-')}</td><td>${statusLabel(item.status)}</td><td>${formatDate(item.starts_at)}<br><span class="muted">至 ${formatDate(item.expires_at)}</span></td><td>${formatNumber(item.cycle_usage_usd)} / ${formatNumber(item.cycle_quota_usd)}</td><td><button class="link-button detail-button" data-id="${escapeHTML(item.id)}" type="button">详情</button></td></tr>`).join('')}</tbody></table>`
|
||||
container.querySelectorAll('.detail-button').forEach((button) => button.addEventListener('click', () => showDetail(button.dataset.id)))
|
||||
}
|
||||
async function showDetail(id) { try { const payload = unwrap(await request(`/api/subscriptions/${encodeURIComponent(id)}`)); $('#detail-content').textContent = JSON.stringify(payload, null, 2); $('#detail-dialog').showModal() } catch (error) { showError(error.message) } }
|
||||
function renderAudit(items) { const container = $('#audit-list'); if (!items.length) { container.innerHTML = '<p class="empty">暂无操作记录</p>'; return }; container.innerHTML = `<table><thead><tr><th>时间</th><th>动作</th><th>结果</th><th>用户 ID</th><th>请求 ID</th></tr></thead><tbody>${items.slice().reverse().map((item) => `<tr><td>${formatDate(item.time)}</td><td><code>${escapeHTML(item.action)}</code></td><td>${statusLabel(item.result)}</td><td>${escapeHTML(item.user_id ?? '-')}</td><td><code>${escapeHTML(item.request_id || '-')}</code></td></tr>`).join('')}</tbody></table>` }
|
||||
|
||||
loginForm.addEventListener('submit', login)
|
||||
$('#logout').addEventListener('click', async () => { try { await request('/logout', { method: 'POST' }) } catch { /* session is cleared locally */ } state.csrf = ''; loginView.hidden = false; appView.hidden = true; loginForm.reset() })
|
||||
$('#refresh-all').addEventListener('click', loadOverview)
|
||||
$('#subscription-filter').addEventListener('submit', (event) => { event.preventDefault(); state.subscriptionPage = 1; state.subscriptions = null; loadSubscriptions() })
|
||||
$('#subscription-prev').addEventListener('click', () => { if (state.subscriptionPage > 1) { state.subscriptionPage -= 1; loadSubscriptions() } })
|
||||
$('#subscription-next').addEventListener('click', () => { const pages = Number(state.subscriptions?.pages || 0); if (state.subscriptionPage < pages) { state.subscriptionPage += 1; loadSubscriptions() } })
|
||||
$('#balance-form').addEventListener('submit', async (event) => { event.preventDefault(); clearError(); const userID = String(new FormData(event.currentTarget).get('user_id') || '').trim(); try { await loadBalance(userID) } catch (error) { showError(error.message) } })
|
||||
$('#close-detail').addEventListener('click', () => $('#detail-dialog').close())
|
||||
document.querySelectorAll('.tab').forEach((button) => button.addEventListener('click', () => setView(button.dataset.view)))
|
||||
document.querySelectorAll('.reload').forEach((button) => button.addEventListener('click', () => { if (button.dataset.target === 'plans') loadPlans(); if (button.dataset.target === 'subscriptions') { state.subscriptions = null; loadSubscriptions() }; if (button.dataset.target === 'audit') loadAudit() }))
|
||||
void bootstrap()
|
||||
})()
|
||||
|
||||
@@ -3,120 +3,18 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>订阅管理插件</title>
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>订阅管理模块</title>
|
||||
<link rel="stylesheet" href="__PLUGIN_BASE_PATH__/styles.css">
|
||||
</head>
|
||||
<body data-base-path="__PLUGIN_BASE_PATH__">
|
||||
<main class="shell">
|
||||
<section id="login-view" class="auth-panel" aria-labelledby="login-title">
|
||||
<div class="eyebrow">SUB2API EXTENSION</div>
|
||||
<h1 id="login-title">订阅管理</h1>
|
||||
<p class="muted">使用 Sub2API 管理员账号登录。普通用户没有访问权限。</p>
|
||||
<form id="login-form" class="stack" autocomplete="on">
|
||||
<label>
|
||||
<span>管理员邮箱</span>
|
||||
<input name="email" type="email" autocomplete="username" required maxlength="254">
|
||||
</label>
|
||||
<label>
|
||||
<span>密码</span>
|
||||
<input name="password" type="password" autocomplete="current-password" required maxlength="200">
|
||||
</label>
|
||||
<fieldset id="captcha-panel" class="captcha-panel" hidden>
|
||||
<legend id="captcha-label">安全验证</legend>
|
||||
<p id="captcha-help" class="muted"></p>
|
||||
<label>
|
||||
<span>验证结果</span>
|
||||
<textarea name="captcha_token" rows="3" maxlength="8192" spellcheck="false" autocomplete="off"></textarea>
|
||||
</label>
|
||||
<label id="captcha-randstr-row" hidden>
|
||||
<span>验证随机串</span>
|
||||
<input name="captcha_randstr" maxlength="512" autocomplete="off">
|
||||
</label>
|
||||
</fieldset>
|
||||
<button class="primary" type="submit">登录</button>
|
||||
<p id="login-error" class="error" role="alert" hidden></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="app-view" hidden>
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<div class="eyebrow">SUB2API EXTENSION</div>
|
||||
<h1>订阅管理</h1>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<span id="operator" class="operator"></span>
|
||||
<button id="logout" class="secondary" type="button">退出</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="tabs" aria-label="插件页面">
|
||||
<button class="tab active" data-view="overview" type="button">概览</button>
|
||||
<button class="tab" data-view="plans" type="button">套餐</button>
|
||||
<button class="tab" data-view="subscriptions" type="button">用户订阅</button>
|
||||
<button class="tab" data-view="audit" type="button">操作记录</button>
|
||||
<button class="tab" data-view="settings" type="button">设置</button>
|
||||
</nav>
|
||||
|
||||
<p id="app-error" class="error" role="alert" hidden></p>
|
||||
|
||||
<section id="view-overview" class="view stack">
|
||||
<div class="section-heading"><div><h2>连接概览</h2><p class="muted">Core 是套餐、余额和订阅账本的唯一来源。</p></div><button id="refresh-all" class="secondary" type="button">刷新数据</button></div>
|
||||
<div class="metric-grid">
|
||||
<article class="metric"><span>插件状态</span><strong class="status ok">运行中</strong><small>独立服务 · 只读模式</small></article>
|
||||
<article class="metric"><span>Core 连接</span><strong id="core-status" class="status">检查中</strong><small id="sync-time">尚未同步</small></article>
|
||||
<article class="metric"><span>可售套餐</span><strong id="plan-count">-</strong><small>来自 Core 套餐目录</small></article>
|
||||
<article class="metric"><span>订阅实例</span><strong id="subscription-count">-</strong><small>当前分页结果</small></article>
|
||||
<article class="metric"><span>用户余额</span><strong id="user-balance">-</strong><small>选择用户后显示 Core 余额</small></article>
|
||||
</div>
|
||||
<form id="balance-form" class="filter-row balance-form">
|
||||
<label><span>查询用户余额</span><input name="user_id" inputmode="numeric" pattern="[1-9][0-9]*" placeholder="用户 ID" required></label>
|
||||
<button class="secondary" type="submit">查询余额</button>
|
||||
</form>
|
||||
<div class="notice"><strong>只读试验版</strong><span>余额购买、续费、撤销和外部支付尚未启用。页面不会提交任何写操作。</span></div>
|
||||
</section>
|
||||
|
||||
<section id="view-plans" class="view" hidden>
|
||||
<div class="section-heading"><div><h2>套餐目录</h2><p class="muted">展示 Core 当前返回的价格、额度和覆盖分组。</p></div><button class="secondary reload" data-target="plans" type="button">刷新</button></div>
|
||||
<div id="plans-list" class="card-list"></div>
|
||||
</section>
|
||||
|
||||
<section id="view-subscriptions" class="view" hidden>
|
||||
<div class="section-heading"><div><h2>用户订阅</h2><p class="muted">每个订阅实例独立展示,支持同档位多实例。</p></div><button class="secondary reload" data-target="subscriptions" type="button">刷新</button></div>
|
||||
<form id="subscription-filter" class="filter-row">
|
||||
<label><span>用户 ID</span><input name="user_id" inputmode="numeric" pattern="[0-9]*"></label>
|
||||
<label><span>状态</span><select name="status"><option value="">全部</option><option value="active">active</option><option value="expired">expired</option><option value="revoked">revoked</option></select></label>
|
||||
<button class="secondary" type="submit">筛选</button>
|
||||
</form>
|
||||
<div id="subscriptions-list" class="table-wrap"></div>
|
||||
<div id="subscription-pagination" class="pagination" hidden>
|
||||
<span id="subscription-page-info" class="muted"></span>
|
||||
<div><button id="subscription-prev" class="secondary" type="button">上一页</button><button id="subscription-next" class="secondary" type="button">下一页</button></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="view-audit" class="view" hidden>
|
||||
<div class="section-heading"><div><h2>操作记录</h2><p class="muted">仅记录插件会话和只读查询结果,不记录 token 或密码。</p></div><button class="secondary reload" data-target="audit" type="button">刷新</button></div>
|
||||
<div id="audit-list" class="table-wrap"></div>
|
||||
</section>
|
||||
|
||||
<section id="view-settings" class="view stack" hidden>
|
||||
<div class="section-heading"><div><h2>插件设置</h2><p class="muted">部署参数由服务端环境变量管理,页面不提供 secret 编辑入口。</p></div></div>
|
||||
<div class="settings-list">
|
||||
<div><span>运行模式</span><strong>只读 V1</strong></div>
|
||||
<div><span>Core API allowlist</span><strong>10 个固定端点</strong></div>
|
||||
<div><span>会话存储</span><strong>进程内存(重启后需重新登录)</strong></div>
|
||||
<div><span>Core 凭据</span><strong id="credential-status">检查中</strong></div>
|
||||
<div><span>余额购买 / 续费 / 撤销</span><strong class="status">未启用</strong></div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
<main class="handoff-page">
|
||||
<div class="handoff-mark" aria-hidden="true">S</div>
|
||||
<p class="eyebrow">SUB2API BUSINESS MODULE</p>
|
||||
<h1>订阅管理已纳入统一控制面</h1>
|
||||
<p class="muted">此服务只提供订阅模块的后端能力。管理员请从 Plugin Admin 进入,订阅页面会复用同一个会话,不需要再次登录。</p>
|
||||
<a class="primary" href="__PLUGIN_BASE_PATH__/admin/">打开统一控制面</a>
|
||||
<p class="security-note">Core 凭据由服务端托管,浏览器不会接触访问令牌。</p>
|
||||
</main>
|
||||
<dialog id="detail-dialog" class="detail-dialog">
|
||||
<div class="dialog-heading"><h2>订阅详情</h2><button id="close-detail" class="icon-button" type="button" aria-label="关闭">×</button></div>
|
||||
<pre id="detail-content"></pre>
|
||||
</dialog>
|
||||
<script src="__PLUGIN_BASE_PATH__/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,103 +1,12 @@
|
||||
:root { color-scheme: light dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #17202a; background: #f4f7fa; font-synthesis: none; }
|
||||
@media (prefers-color-scheme: dark) { :root { color: #edf2f7; background: #11161c; } }
|
||||
:root { font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif; color: #1f2937; background: #f3f6fa; font-synthesis: none; }
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
body { margin: 0; min-width: 320px; }
|
||||
button, input, select { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button:disabled { cursor: wait; opacity: .6; }
|
||||
.shell { width: min(1160px, calc(100% - 32px)); margin: 0 auto; padding: 32px 0 64px; }
|
||||
.auth-panel { width: min(440px, 100%); margin: 10vh auto 0; padding: 32px; border: 1px solid #dce4eb; border-radius: 10px; background: #fff; box-shadow: 0 12px 36px rgb(20 38 56 / 8%); }
|
||||
@media (prefers-color-scheme: dark) { .auth-panel, .metric, .plan-card, .notice, table, .detail-dialog { background: #18212b; border-color: #2b3947; } }
|
||||
h1, h2, h3, p { margin: 0; }
|
||||
h1 { margin-top: 6px; font-size: clamp(1.5rem, 3vw, 2.1rem); letter-spacing: 0; }
|
||||
h2 { font-size: 1.2rem; }
|
||||
h3 { font-size: 1rem; }
|
||||
.eyebrow { color: #3977a9; font-size: .7rem; font-weight: 700; letter-spacing: .08em; }
|
||||
.muted { color: #647384; font-size: .86rem; line-height: 1.5; }
|
||||
@media (prefers-color-scheme: dark) { .muted { color: #a8b5c2; } }
|
||||
.stack { display: grid; gap: 18px; }
|
||||
form.stack { margin-top: 26px; }
|
||||
label { display: grid; gap: 7px; color: #4f6070; font-size: .82rem; font-weight: 600; }
|
||||
input, select, textarea { width: 100%; height: 36px; padding: 0 10px; border: 1px solid #cbd7e1; border-radius: 5px; color: inherit; background: transparent; outline: none; }
|
||||
textarea { height: auto; min-height: 72px; padding: 8px 10px; resize: vertical; font: .78rem/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
input:focus, select:focus { border-color: #3977a9; box-shadow: 0 0 0 3px rgb(57 119 169 / 17%); }
|
||||
button { min-height: 36px; border-radius: 5px; border: 1px solid transparent; padding: 0 14px; }
|
||||
.primary { color: white; background: #3977a9; }
|
||||
.primary:hover { background: #2e628e; }
|
||||
.secondary { color: #2d536f; background: transparent; border-color: #b9c9d7; }
|
||||
.secondary:hover { background: rgb(57 119 169 / 8%); }
|
||||
.topbar, .section-heading, .card-heading, .top-actions, .filter-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.topbar { padding-bottom: 24px; border-bottom: 1px solid #d8e1e9; }
|
||||
.top-actions { flex-wrap: wrap; justify-content: flex-end; }
|
||||
.operator { max-width: 260px; overflow: hidden; color: #647384; font-size: .82rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tabs { display: flex; gap: 6px; overflow-x: auto; padding: 18px 0; border-bottom: 1px solid #d8e1e9; }
|
||||
.tab { color: #647384; background: transparent; border: 0; white-space: nowrap; }
|
||||
.tab.active { color: #3977a9; box-shadow: inset 0 -2px #3977a9; }
|
||||
.view { padding-top: 28px; }
|
||||
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }
|
||||
.metric { min-height: 122px; display: grid; align-content: space-between; gap: 8px; padding: 18px; border: 1px solid #dce4eb; border-radius: 8px; background: #fff; }
|
||||
.metric > span { color: #647384; font-size: .82rem; }
|
||||
.metric strong { font-size: 1.55rem; font-variant-numeric: tabular-nums; }
|
||||
.metric small { color: #7b8996; font-size: .75rem; }
|
||||
.status { color: #647384; }
|
||||
.status.ok { color: #2f8b5c; }
|
||||
.status.bad { color: #c14f4f; }
|
||||
.notice { display: flex; gap: 12px; align-items: baseline; padding: 14px 16px; border: 1px solid #c6dce9; border-left: 3px solid #3977a9; border-radius: 6px; background: #fff; font-size: .86rem; }
|
||||
.card-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-top: 18px; }
|
||||
.plan-card { display: grid; gap: 15px; padding: 20px; border: 1px solid #dce4eb; border-radius: 8px; background: #fff; }
|
||||
.plan-price { color: #3977a9; font-size: 1.7rem; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.plan-price small { color: #647384; font-size: .8rem; font-weight: 500; }
|
||||
.facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 0; }
|
||||
.facts div { padding: 10px; border-radius: 5px; background: rgb(100 115 132 / 8%); }
|
||||
.facts dt { color: #647384; font-size: .72rem; }
|
||||
.facts dd { margin: 4px 0 0; font-size: .86rem; font-variant-numeric: tabular-nums; }
|
||||
.tags { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.tag, .pill { display: inline-flex; align-items: center; min-height: 24px; padding: 0 8px; border-radius: 99px; font-size: .72rem; white-space: nowrap; }
|
||||
.tag { color: #3977a9; background: rgb(57 119 169 / 11%); }
|
||||
.pill { color: #647384; background: rgb(100 115 132 / 12%); }
|
||||
.pill-active, .pill-success { color: #2f8b5c; background: rgb(47 139 92 / 13%); }
|
||||
.pill-expired, .pill-revoked, .pill-failed, .pill-bad { color: #bd5050; background: rgb(189 80 80 / 13%); }
|
||||
.feature-list { display: grid; gap: 5px; margin: 0; padding-left: 18px; color: #647384; font-size: .82rem; }
|
||||
.filter-row { justify-content: flex-start; flex-wrap: wrap; margin-top: 18px; }
|
||||
.filter-row label { width: min(220px, 100%); }
|
||||
.balance-form { margin-top: 16px; }
|
||||
.captcha-panel { display: grid; gap: 10px; margin: 2px 0 0; padding: 12px; border: 1px solid #dce4eb; border-radius: 6px; }
|
||||
.captcha-panel legend { padding: 0 4px; color: #4f6070; font-size: .82rem; font-weight: 600; }
|
||||
.pagination { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 12px; }
|
||||
.pagination > div { display: flex; gap: 8px; }
|
||||
.settings-list { display: grid; gap: 1px; overflow: hidden; border: 1px solid #dce4eb; border-radius: 8px; background: #dce4eb; }
|
||||
.settings-list > div { display: flex; justify-content: space-between; gap: 18px; padding: 15px 16px; background: #fff; font-size: .84rem; }
|
||||
.settings-list strong { font-weight: 600; text-align: right; }
|
||||
@media (prefers-color-scheme: dark) { .settings-list { border-color: #2b3947; background: #2b3947; } .settings-list > div { background: #18212b; } }
|
||||
.table-wrap { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; margin-top: 18px; border: 1px solid #dce4eb; border-radius: 8px; }
|
||||
table { width: 100%; min-width: 760px; border-collapse: collapse; background: #fff; }
|
||||
th, td { padding: 13px 14px; border-bottom: 1px solid #e2e8ee; text-align: left; vertical-align: top; font-size: .82rem; white-space: nowrap; }
|
||||
th { color: #647384; font-size: .74rem; font-weight: 600; background: rgb(100 115 132 / 6%); }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
code { color: #3977a9; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .78rem; }
|
||||
.link-button { min-height: 28px; padding: 0; color: #3977a9; background: transparent; border: 0; }
|
||||
.empty { padding: 32px; color: #647384; text-align: center; }
|
||||
.error { color: #b84d4d; font-size: .84rem; }
|
||||
.detail-dialog { width: min(720px, calc(100% - 28px)); max-height: min(700px, calc(100dvh - 28px)); padding: 0; border: 1px solid #dce4eb; border-radius: 8px; color: inherit; background: #fff; }
|
||||
.detail-dialog::backdrop { background: rgb(15 27 39 / 42%); }
|
||||
.dialog-heading { display: flex; justify-content: space-between; align-items: center; padding: 16px 18px; border-bottom: 1px solid #dce4eb; }
|
||||
.icon-button { width: 32px; min-height: 32px; padding: 0; color: #647384; background: transparent; border: 0; font-size: 1.3rem; }
|
||||
pre { max-height: 580px; overflow: auto; margin: 0; padding: 18px; font: .76rem/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
@media (max-width: 760px) {
|
||||
.shell { width: min(100% - 20px, 600px); padding-top: 18px; }
|
||||
.auth-panel { margin-top: 4vh; padding: 22px; }
|
||||
.topbar, .section-heading { align-items: flex-start; flex-direction: column; }
|
||||
.top-actions { width: 100%; justify-content: space-between; }
|
||||
.metric-grid, .card-list { grid-template-columns: 1fr; }
|
||||
.metric { min-height: 104px; }
|
||||
.notice { align-items: flex-start; flex-direction: column; gap: 5px; }
|
||||
.filter-row { align-items: stretch; flex-direction: column; }
|
||||
.filter-row label { width: 100%; }
|
||||
.filter-row button { width: 100%; }
|
||||
.pagination { align-items: stretch; flex-direction: column; }
|
||||
.pagination > div { width: 100%; }
|
||||
.pagination button { flex: 1; }
|
||||
.settings-list > div { align-items: flex-start; flex-direction: column; gap: 5px; }
|
||||
.settings-list strong { text-align: left; }
|
||||
}
|
||||
body { min-width: 320px; min-height: 100vh; margin: 0; }
|
||||
.handoff-page { width: min(560px, calc(100% - 32px)); min-height: 100vh; margin: 0 auto; display: grid; align-content: center; justify-items: start; gap: 16px; }
|
||||
.handoff-mark { display: grid; place-items: center; width: 46px; height: 46px; border-radius: 9px; color: #fff; background: #0052d9; font-size: 22px; font-weight: 700; }
|
||||
.eyebrow { margin: 10px 0 0; color: #7b8794; font-size: 11px; font-weight: 700; letter-spacing: .12em; }
|
||||
h1 { max-width: 500px; margin: 0; color: #111827; font-size: clamp(26px, 5vw, 38px); line-height: 1.2; }
|
||||
.muted { max-width: 510px; margin: 0; color: #687585; font-size: 14px; line-height: 1.75; }
|
||||
.primary { display: inline-flex; align-items: center; min-height: 40px; padding: 0 18px; border-radius: 5px; color: #fff; background: #0052d9; text-decoration: none; font-size: 13px; }
|
||||
.primary:hover { background: #003cab; }
|
||||
.security-note { margin: 8px 0 0; color: #9aa5b4; font-size: 11px; }
|
||||
@media (max-width: 480px) { .handoff-page { width: calc(100% - 24px); } h1 { font-size: 28px; } }
|
||||
|
||||
@@ -46,6 +46,11 @@ done
|
||||
command -v go >/dev/null 2>&1 || die "缺少 Go;请安装 Go 1.23 或更高版本"
|
||||
command -v systemctl >/dev/null 2>&1 || die "缺少 systemd/systemctl"
|
||||
command -v install >/dev/null 2>&1 || die "缺少 install 命令"
|
||||
GO_MAJOR=$(go version | sed -nE 's/.* go([0-9]+)\.([0-9]+).*/\1/p')
|
||||
GO_MINOR=$(go version | sed -nE 's/.* go([0-9]+)\.([0-9]+).*/\2/p')
|
||||
if [[ -z "$GO_MAJOR" || -z "$GO_MINOR" ]] || (( GO_MAJOR < 1 || (GO_MAJOR == 1 && GO_MINOR < 23) )); then
|
||||
die "需要 Go 1.23 或更高版本"
|
||||
fi
|
||||
|
||||
case "$SELECTION" in
|
||||
all) PLUGINS=(plugin-admin subscription-admin) ;;
|
||||
@@ -78,6 +83,27 @@ random_secret() {
|
||||
fi
|
||||
}
|
||||
|
||||
validate_install_root() {
|
||||
local value=$1 label=$2 component current=""
|
||||
[[ -n "$value" && "$value" = /* && "$value" != "/" ]] || die "$label 必须是非根绝对路径"
|
||||
[[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] || die "$label 包含非法换行"
|
||||
local parts=()
|
||||
IFS='/' read -r -a parts <<< "${value#/}"
|
||||
for component in "${parts[@]}"; do
|
||||
[[ -z "$component" ]] && continue
|
||||
[[ "$component" != "." && "$component" != ".." ]] || die "$label 不能包含 . 或 .. 路径组件"
|
||||
current="$current/$component"
|
||||
[[ ! -L "$current" ]] || die "$label 的路径组件不能是符号链接:$current"
|
||||
done
|
||||
if [[ "$value" != */sub2api-add && "${PLUGIN_ALLOW_CUSTOM_PATHS:-false}" != "true" ]]; then
|
||||
die "$label 必须位于受管的 sub2api-add 目录;如确需自定义路径请显式设置 PLUGIN_ALLOW_CUSTOM_PATHS=true"
|
||||
fi
|
||||
}
|
||||
|
||||
validate_install_root "$PREFIX" PLUGIN_INSTALL_PREFIX
|
||||
validate_install_root "$ETC_DIR" PLUGIN_ETC_DIR
|
||||
validate_install_root "$VAR_DIR" PLUGIN_VAR_DIR
|
||||
|
||||
install_one() {
|
||||
local name=$1 source="$ROOT/plugins/$1" env_file="$ETC_DIR/$1.env"
|
||||
local binary_dir="$PREFIX/$1/bin" data_dir="$VAR_DIR/$1"
|
||||
@@ -125,8 +151,21 @@ Restart=on-failure
|
||||
RestartSec=3
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelLogs=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
CapabilityBoundingSet=
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
TasksMax=128
|
||||
MemoryMax=512M
|
||||
CPUQuota=200%
|
||||
ReadWritePaths=${data_dir}
|
||||
|
||||
[Install]
|
||||
|
||||
Reference in New Issue
Block a user