feat: complete unified plugin admin v1.1.0
This commit is contained in:
@@ -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
Reference in New Issue
Block a user