diff --git a/cmd/control-plane/hub.go b/cmd/control-plane/hub.go
index 5fc91b6..48237ae 100644
--- a/cmd/control-plane/hub.go
+++ b/cmd/control-plane/hub.go
@@ -295,14 +295,40 @@ type containerStatus struct {
type envView struct {
hub.Env
- State string `json:"state"`
- Status string `json:"status"`
- ContainerID string `json:"container_id"`
- Endpoint string `json:"endpoint"`
- NetworkExitID string `json:"network_exit_id"`
- BindingVersion int64 `json:"binding_version"`
- RecoveryRequired bool `json:"recovery_required"`
- CleanupPending bool `json:"cleanup_pending"`
+ State string `json:"state"`
+ Status string `json:"status"`
+ ContainerID string `json:"container_id"`
+ Endpoint string `json:"endpoint"`
+ AccountID string `json:"account_id"`
+ AccountStatus string `json:"account_status"`
+ AuthorizationStatus string `json:"authorization_status"`
+ NetworkExitID string `json:"network_exit_id"`
+ NetworkExitHealth string `json:"network_exit_health"`
+ BindingVersion int64 `json:"binding_version"`
+ RuntimeInstanceID string `json:"runtime_instance_id"`
+ ScheduleStatus string `json:"schedule_status"`
+ ScheduleBlockReason string `json:"schedule_block_reason,omitempty"`
+ RecoveryRequired bool `json:"recovery_required"`
+ CleanupPending bool `json:"cleanup_pending"`
+}
+
+func environmentScheduleReadiness(environment hub.EnvironmentContext) (string, string) {
+ switch {
+ case environment.AuthorizationStatus != "authorized":
+ return "blocked", "account_revoked"
+ case environment.AccountStatus != "active":
+ return "blocked", "account_paused"
+ case environment.Exit.ID == "":
+ return "blocked", "network_exit_missing"
+ case environment.Exit.HealthStatus != "healthy":
+ return "blocked", "network_exit_unhealthy"
+ case environment.RuntimeCleanupPending:
+ return "blocked", "runtime_stop_pending"
+ case environment.RuntimeInstanceID == "":
+ return "blocked", "runtime_missing"
+ default:
+ return "ready", ""
+ }
}
func containerMatchesBinding(container containerStatus, environment hub.EnvironmentContext) bool {
@@ -363,6 +389,7 @@ func registerHubWithNetwork(app *fiber.App, store hubStore, probe networkExitPro
app.Post("/api/browsers/:alias/:action", serialized(browserAction(store, probe, resolve)))
app.Delete("/api/browsers/:alias", serialized(deleteBrowser(store)))
app.Get("/api/network-exits", listNetworkExits(store))
+ app.Get("/api/network-exits/:id", getNetworkExit(store))
app.Post("/api/network-exits", serialized(createNetworkExit(store)))
app.Post("/api/network-exits/:id/check", serialized(checkNetworkExit(store, probe)))
app.Post("/api/network-exits/:id/disable", serialized(disableNetworkExit(store, probe, resolve)))
@@ -460,6 +487,16 @@ func listNetworkExits(store hubStore) fiber.Handler {
}
}
+func getNetworkExit(store hubStore) fiber.Handler {
+ return func(c fiber.Ctx) error {
+ exit, err := store.GetNetworkExit(c.Context(), c.Params("id"))
+ if err != nil {
+ return hubError(c, err)
+ }
+ return c.JSON(exit)
+ }
+}
+
func createNetworkExit(store hubStore) fiber.Handler {
return func(c fiber.Ctx) error {
var input struct {
@@ -552,11 +589,14 @@ func listBrowsers(store hubStore, probe networkExitProbe, resolve func(hub.Netwo
}
views := make([]envView, 0, len(envs))
for _, env := range envs {
- view := envView{Env: env, State: "missing", Status: "网关上不存在容器"}
+ view := envView{Env: env, State: "missing", Status: "网关上不存在容器", ScheduleStatus: "blocked", ScheduleBlockReason: "binding_missing", RecoveryRequired: true}
if environment, contextErr := store.GetEnvironmentContext(c.Context(), env.Alias); contextErr == nil {
- view.NetworkExitID, view.BindingVersion = environment.Exit.ID, environment.BindingVersion
+ view.AccountID, view.AccountStatus, view.AuthorizationStatus = environment.AccountID, environment.AccountStatus, environment.AuthorizationStatus
+ view.NetworkExitID, view.NetworkExitHealth, view.BindingVersion = environment.Exit.ID, environment.Exit.HealthStatus, environment.BindingVersion
+ view.RuntimeInstanceID = environment.RuntimeInstanceID
+ view.ScheduleStatus, view.ScheduleBlockReason = environmentScheduleReadiness(environment)
view.CleanupPending = environment.RuntimeCleanupPending
- view.RecoveryRequired = environment.Exit.ID == "" || environment.RuntimeCleanupPending
+ view.RecoveryRequired = environment.Exit.ID == "" || environment.Exit.HealthStatus != "healthy" || environment.RuntimeCleanupPending
} else if !errors.Is(contextErr, hub.ErrNotFound) {
return hubError(c, contextErr)
}
diff --git a/cmd/control-plane/hub_test.go b/cmd/control-plane/hub_test.go
index 43133ca..dc887e8 100644
--- a/cmd/control-plane/hub_test.go
+++ b/cmd/control-plane/hub_test.go
@@ -64,6 +64,29 @@ func newMemoryStore() *memoryStore {
}
}
+func TestResumeBlockReasonIsStable(t *testing.T) {
+ account := phasea.Account{AuthorizationStatus: "authorized"}
+ healthy := hub.EnvironmentContext{Exit: hub.NetworkExit{ID: "exit-a", HealthStatus: "healthy"}}
+ for name, test := range map[string]struct {
+ account phasea.Account
+ env hub.EnvironmentContext
+ found bool
+ want string
+ }{
+ "revoked": {phasea.Account{AuthorizationStatus: "revoked"}, healthy, true, "account_revoked"},
+ "missing binding": {account, hub.EnvironmentContext{}, false, "binding_missing"},
+ "unhealthy exit": {account, hub.EnvironmentContext{Exit: hub.NetworkExit{ID: "exit-a", HealthStatus: "unhealthy"}}, true, "network_exit_unhealthy"},
+ "cleanup pending": {account, hub.EnvironmentContext{Exit: healthy.Exit, RuntimeCleanupPending: true}, true, "runtime_stop_pending"},
+ "runtime active": {account, hub.EnvironmentContext{Exit: healthy.Exit, RuntimeInstanceID: "runtime-a"}, true, "runtime_active"},
+ } {
+ t.Run(name, func(t *testing.T) {
+ if got := resumeBlockReason(test.account, test.env, test.found); got != test.want {
+ t.Fatalf("got %q, want %q", got, test.want)
+ }
+ })
+ }
+}
+
func (s *memoryStore) CreateGateway(_ context.Context, _, _, _ string) (hub.Gateway, error) {
return hub.Gateway{}, nil
}
@@ -1286,6 +1309,9 @@ func TestListBrowsersMergesLiveGatewayState(t *testing.T) {
if byAlias["account-a"].State != "running" || byAlias["account-a"].ContainerID != "id-1" {
t.Fatalf("running container state must be merged: %#v", byAlias["account-a"])
}
+ if view := byAlias["account-a"]; view.AccountID != "account-a" || view.NetworkExitHealth != "healthy" || view.ScheduleStatus != "ready" || view.ScheduleBlockReason != "" {
+ t.Fatalf("binding readiness must be exposed without credentials: %#v", view)
+ }
if byAlias["account-b"].State != "missing" {
t.Fatalf("env without container must report missing: %#v", byAlias["account-b"])
}
@@ -4165,6 +4191,10 @@ func TestNetworkExitRoutesAreStrictAndSecretFree(t *testing.T) {
if created.Code != http.StatusCreated || strings.Contains(created.Body.String(), "raw-value") {
t.Fatalf("unexpected secret-bearing network exit response: status=%d body=%s", created.Code, created.Body.String())
}
+ detail := do(app, http.MethodGet, "/api/network-exits/exit-created", "")
+ if detail.Code != http.StatusOK || !strings.Contains(detail.Body.String(), `"credential_reference":{"id":"credential-a"`) || strings.Contains(detail.Body.String(), "raw-value") {
+ t.Fatalf("network exit detail must expose only the credential reference: status=%d body=%s", detail.Code, detail.Body.String())
+ }
checked := do(app, http.MethodPost, "/api/network-exits/exit-created/check", "")
if checked.Code != http.StatusOK || !strings.Contains(checked.Body.String(), `"health_status":"healthy"`) {
t.Fatalf("network exit check did not record health: status=%d body=%s", checked.Code, checked.Body.String())
diff --git a/cmd/control-plane/phasea.go b/cmd/control-plane/phasea.go
index 41f4242..6fb1ad2 100644
--- a/cmd/control-plane/phasea.go
+++ b/cmd/control-plane/phasea.go
@@ -6,6 +6,7 @@ import (
"errors"
"io"
+ "git.ipao.vip/rogee/creator-hub/internal/hub"
"git.ipao.vip/rogee/creator-hub/internal/phasea"
"github.com/gofiber/fiber/v3"
)
@@ -28,8 +29,12 @@ func registerPhaseA(app *fiber.App, store *phasea.Store, runtimeStore runtimeSto
if err := decodePhaseA(c, &input); err != nil {
return phaseAError(c, err)
}
+ accountID := input.ID
+ if accountID == "" {
+ accountID = phasea.NewAccountID()
+ }
account := phasea.Account{
- ID: input.ID, Platform: input.Platform, PlatformAccountKey: input.PlatformAccountKey,
+ ID: accountID, Platform: input.Platform, PlatformAccountKey: input.PlatformAccountKey,
AuthorizationKind: input.AuthorizationKind, CredentialKey: input.CredentialReference.Key,
CredentialReference: phasea.CredentialReference{ID: input.CredentialReference.ID, Provider: input.CredentialReference.Provider},
AuthorizationStatus: "authorized", RuntimeStatus: "paused", Version: 1,
@@ -74,6 +79,9 @@ func registerPhaseA(app *fiber.App, store *phasea.Store, runtimeStore runtimeSto
runtimeOperations.Lock()
defer runtimeOperations.Unlock()
if err := store.ResumeAccount(c.Context(), c.Params("id")); err != nil {
+ if errors.Is(err, phasea.ErrConflict) {
+ return accountResumeConflict(c, store, runtimeStore, c.Params("id"))
+ }
return phaseAError(c, err)
}
return c.SendStatus(fiber.StatusNoContent)
@@ -162,6 +170,44 @@ func registerPhaseA(app *fiber.App, store *phasea.Store, runtimeStore runtimeSto
})
}
+func resumeBlockReason(account phasea.Account, environment hub.EnvironmentContext, bindingFound bool) string {
+ switch {
+ case account.AuthorizationStatus != "authorized":
+ return "account_revoked"
+ case !bindingFound:
+ return "binding_missing"
+ case environment.Exit.ID == "":
+ return "network_exit_missing"
+ case environment.Exit.HealthStatus != "healthy":
+ return "network_exit_unhealthy"
+ case environment.RuntimeCleanupPending:
+ return "runtime_stop_pending"
+ case environment.RuntimeInstanceID != "":
+ return "runtime_active"
+ default:
+ return "account_conflict"
+ }
+}
+
+func accountResumeConflict(c fiber.Ctx, store *phasea.Store, runtimeStore runtimeStopStore, accountID string) error {
+ account, err := store.GetAccount(c.Context(), accountID)
+ if err != nil {
+ return phaseAError(c, err)
+ }
+ var environment hub.EnvironmentContext
+ bindingFound := false
+ if runtimeStore != nil {
+ environment, err = runtimeStore.GetEnvironmentContextForAccount(c.Context(), accountID)
+ bindingFound = err == nil
+ if err != nil && !errors.Is(err, hub.ErrNotFound) {
+ return phaseAError(c, err)
+ }
+ }
+ return c.Status(fiber.StatusConflict).JSON(map[string]string{
+ "error": phasea.ErrConflict.Error(), "reason_code": resumeBlockReason(account, environment, bindingFound), "readiness": "blocked",
+ })
+}
+
func decodePhaseA(c fiber.Ctx, destination any) error {
decoder := json.NewDecoder(bytes.NewReader(c.Body()))
decoder.DisallowUnknownFields()
diff --git a/internal/phasea/store.go b/internal/phasea/store.go
index 0ab385e..8e69788 100644
--- a/internal/phasea/store.go
+++ b/internal/phasea/store.go
@@ -844,6 +844,8 @@ func newID() string {
return hex.EncodeToString(value[:])
}
+func NewAccountID() string { return "account-" + newID()[:24] }
+
func commit(tx *sql.Tx) error {
if err := tx.Commit(); err != nil {
return errors.New("commit transaction")
diff --git a/internal/phasea/store_test.go b/internal/phasea/store_test.go
index 79eba80..22c3014 100644
--- a/internal/phasea/store_test.go
+++ b/internal/phasea/store_test.go
@@ -14,6 +14,13 @@ import (
"time"
)
+func TestNewAccountIDIsStoreValidAndUnique(t *testing.T) {
+ first, second := NewAccountID(), NewAccountID()
+ if first == second || !idPattern.MatchString(first) || !idPattern.MatchString(second) {
+ t.Fatalf("invalid generated account ids: %q %q", first, second)
+ }
+}
+
func TestValidationRejectsInvalidInputsBeforePersistence(t *testing.T) {
store := &Store{}
valid := Account{
diff --git a/web/src/AccountList.jsx b/web/src/AccountList.jsx
new file mode 100644
index 0000000..8939934
--- /dev/null
+++ b/web/src/AccountList.jsx
@@ -0,0 +1,224 @@
+import { useEffect, useMemo, useState } from 'react'
+import { useDataProvider, useGetList, useGetOne } from 'ra-core'
+import { Link as RouterLink, useParams } from 'react-router-dom'
+import {
+ Alert,
+ Box,
+ Button,
+ CircularProgress,
+ MenuItem,
+ Paper,
+ Stack,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ TextField,
+ Typography,
+} from '@mui/material'
+import AccountCircleOutlined from '@mui/icons-material/AccountCircleOutlined'
+import CheckCircleOutlined from '@mui/icons-material/CheckCircleOutlined'
+import PauseCircleOutlined from '@mui/icons-material/PauseCircleOutlined'
+import ReportProblemOutlined from '@mui/icons-material/ReportProblemOutlined'
+
+const emptyAccount = {
+ platform: '',
+ platform_account_key: '',
+ authorization_kind: 'owned',
+ credential_id: '',
+ credential_provider: 'os_keyring',
+ credential_key: '',
+}
+
+const wrapAnywhere = { overflowWrap: 'anywhere', minWidth: 0 }
+
+const reasonLabels = {
+ account_revoked: '授权已撤销',
+ account_paused: '账号已暂停',
+ binding_missing: '未绑定运行环境',
+ network_exit_missing: '未绑定固定出口',
+ network_exit_unhealthy: '固定出口不健康',
+ runtime_stop_pending: '运行环境停止结果待确认',
+ runtime_missing: '运行环境未启动',
+ runtime_active: '仍有活动运行实例',
+ account_conflict: '账号状态已变化,请刷新后重试',
+ environment_unavailable: '环境状态未知',
+}
+
+function actionError(error, conflictMessage) {
+ if (error?.status === 409) return `冲突(409):${reasonLabels[error.body?.reason_code] || conflictMessage}`
+ if (error?.status === 503) return `环境不可用(503):${error.message}`
+ return error?.message || '操作失败'
+}
+
+function environmentError(error) {
+ return `环境不可用${error?.status ? `(${error.status})` : ''}:无法读取运行环境绑定与 readiness,状态暂时未知。`
+}
+
+export function accountReadiness(account, binding, bindingError = false) {
+ if (account.authorization_status !== 'authorized') return { label: '授权已撤销', reason: 'account_revoked', canResume: false, ready: false }
+ if (bindingError) return { label: '环境状态未知', reason: 'environment_unavailable', canResume: false, ready: false }
+ if (!binding) return { label: '未绑定运行环境', reason: 'binding_missing', canResume: false, ready: false }
+ const resourceReason = binding.cleanup_pending
+ ? 'runtime_stop_pending'
+ : binding.network_exit_health !== 'healthy'
+ ? (binding.network_exit_id ? 'network_exit_unhealthy' : 'network_exit_missing')
+ : ''
+ if (resourceReason) return { label: reasonLabels[resourceReason], reason: resourceReason, canResume: false, ready: false }
+ if (account.runtime_status === 'paused') {
+ const canResume = !binding.runtime_instance_id
+ return { label: canResume ? '资源就绪,可恢复' : '等待运行环境停止', reason: canResume ? '' : 'runtime_stop_pending', canResume, ready: false }
+ }
+ const reason = binding.schedule_block_reason || ''
+ return { label: reason ? (reasonLabels[reason] || `不可调度:${reason}`) : '可调度', reason, canResume: false, ready: !reason }
+}
+
+function AccountState({ account, binding, bindingError }) {
+ const readiness = accountReadiness(account, binding, bindingError)
+ const Icon = readiness.ready ? CheckCircleOutlined : account.runtime_status === 'paused' && readiness.canResume ? PauseCircleOutlined : ReportProblemOutlined
+ const color = readiness.ready ? 'success.main' : readiness.canResume ? 'warning.main' : 'error.main'
+ return {readiness.label}
+}
+
+function AccountForm({ busy, onSubmit }) {
+ const [form, setForm] = useState(emptyAccount)
+ const update = (key, value) => setForm(current => ({ ...current, [key]: value }))
+ const valid = form.platform.trim() && form.platform_account_key.trim() && form.credential_id.trim() && form.credential_key.trim()
+
+ async function submit(event) {
+ event.preventDefault()
+ if (!valid) return
+ const created = await onSubmit({
+ platform: form.platform.trim(),
+ platform_account_key: form.platform_account_key.trim(),
+ authorization_kind: form.authorization_kind,
+ credential_reference: {
+ id: form.credential_id.trim(),
+ provider: form.credential_provider,
+ key: form.credential_key.trim(),
+ },
+ })
+ if (created) setForm(emptyAccount)
+ }
+
+ return (
+
+ update('platform', event.target.value)} slotProps={{ htmlInput: { pattern: '[a-z0-9][a-z0-9-]{0,31}', 'aria-label': '平台' } }} helperText="阶段 A 使用虚拟平台标识,如 mock" />
+ update('platform_account_key', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '平台账号标识', maxLength: 128 } }} />
+ update('authorization_kind', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '授权类型' } }}>
+
+
+ update('credential_id', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '凭据引用 ID', maxLength: 128 } }} />
+ update('credential_provider', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '凭据引用来源' } }}>
+
+
+ update('credential_key', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '凭据引用键', maxLength: 127 } }} helperText="仅填写引用路径,不填写密码、Cookie 或 token" />
+
+
+ )
+}
+
+function AccountTable({ accounts, bindings, bindingsError }) {
+ return (
+
+
+ 账号授权资源就绪度固定资源操作
+ {accounts.map(account => {
+ const binding = bindings.get(account.id)
+ return {account.platform_account_key}{account.id} · {account.platform}{account.authorization_status === 'authorized' ? '已授权' : '已撤销'} · {account.runtime_status === 'active' ? '启用' : '暂停'}{bindingsError ? '状态未知' : (binding?.name || '—')}{bindingsError ? '环境数据不可用' : (binding?.network_exit_id || '未绑定出口')}
+ })}
+
+
+ )
+}
+
+function AccountCards({ accounts, bindings, bindingsError }) {
+ return {accounts.map(account => {
+ const binding = bindings.get(account.id)
+ return {account.platform_account_key}{account.id} · {account.platform}授权:{account.authorization_status === 'authorized' ? '已授权' : '已撤销'} · 账号:{account.runtime_status === 'active' ? '启用' : '暂停'}{bindingsError ? '运行环境与固定出口:状态未知' : `运行环境:${binding?.name || '未绑定'} · 固定出口:${binding?.network_exit_id || '未绑定'}`}
+ })}
+}
+
+export function AccountList() {
+ const dataProvider = useDataProvider()
+ const [busy, setBusy] = useState(false)
+ const [message, setMessage] = useState(null)
+ const [filter, setFilter] = useState('')
+ const { data: accounts = [], error, isPending, refetch } = useGetList('accounts')
+ const { data: browsers = [], error: browsersError, refetch: refetchBrowsers } = useGetList('browsers', undefined, { retry: false })
+ const bindings = useMemo(() => new Map(browsers.filter(browser => browser.account_id).map(browser => [browser.account_id, browser])), [browsers])
+ const needle = filter.trim().toLowerCase()
+ const visible = needle ? accounts.filter(account => `${account.platform} ${account.platform_account_key} ${account.id}`.toLowerCase().includes(needle)) : accounts
+
+ useEffect(() => { document.title = 'CreatorHub · 账号池' }, [])
+
+ async function createAccount(data) {
+ setBusy(true); setMessage(null)
+ try {
+ await dataProvider.create('accounts', { data })
+ await refetch()
+ setMessage({ severity: 'success', text: '账号已创建;绑定健康出口和运行环境后方可恢复。' })
+ return true
+ } catch (reason) {
+ setMessage({ severity: 'error', text: actionError(reason, '平台账号标识或凭据引用已存在;表单内容已保留。') })
+ return false
+ } finally { setBusy(false) }
+ }
+
+ return (
+ <>
+ 账号池管理账号授权、暂停状态与固定运行资源
+
+ {message ? {message.text} : null}
+ {error ? {actionError(error, '账号列表冲突')} : null}
+ {browsersError ? refetchBrowsers()}>重试环境状态} sx={{ mb: 2.5 }}>{environmentError(browsersError)} : null}
+ {accounts.length > 0 ? setFilter(event.target.value)} sx={{ mb: 2.5, width: { xs: '100%', sm: 320 } }} /> : null}
+ {isPending ? : null}
+ {!isPending && accounts.length === 0 ? 暂无账号。创建账号后,再前往运行环境绑定健康网络出口。 : null}
+ {!isPending && accounts.length > 0 && visible.length === 0 ? 没有符合筛选条件的账号 : null}
+ {!isPending && visible.length > 0 ? <>> : null}
+ >
+ )
+}
+
+export function AccountDetail() {
+ const { id } = useParams()
+ const dataProvider = useDataProvider()
+ const [busy, setBusy] = useState(false)
+ const [message, setMessage] = useState(null)
+ const { data: account, error, isPending, refetch } = useGetOne('accounts', { id })
+ const { data: browsers = [], error: browsersError, refetch: refetchBrowsers } = useGetList('browsers', undefined, { retry: false })
+ const binding = browsersError ? undefined : browsers.find(browser => browser.account_id === id)
+ const readiness = account ? accountReadiness(account, binding, browsersError) : null
+
+ useEffect(() => { document.title = 'CreatorHub · 账号详情' }, [])
+
+ async function act(action) {
+ if (action === 'pause' && !window.confirm(`暂停账号 ${account.platform_account_key}?待领取任务将进入 hold,恢复账号不会自动恢复既有 hold。`)) return
+ setBusy(true); setMessage(null)
+ try {
+ await dataProvider.accountAction(account.id, action)
+ await Promise.all([refetch(), refetchBrowsers()])
+ setMessage({ severity: 'success', text: action === 'pause' ? '账号已暂停;既有 hold 任务需后续逐条核验。' : '账号已恢复;既有 hold 任务没有自动恢复。' })
+ } catch (reason) {
+ setMessage({ severity: 'error', text: actionError(reason, readiness?.label || '账号当前资源不满足恢复条件。') })
+ } finally { setBusy(false) }
+ }
+
+ if (isPending) return
+ if (error || !account) return {error?.message || '账号不存在'}
+ return (
+ <>
+ {account.platform_account_key}{account.id} · {account.platform}
+ {message ? {message.text} : null}
+ {browsersError ? refetchBrowsers()}>重试环境状态} sx={{ mb: 2.5 }}>{environmentError(browsersError)} : null}
+ } sx={{ mb: 2.5 }}>暂停会把待领取任务置为 hold;恢复账号只恢复账号可用性,不会自动恢复既有 hold。
+
+ 账号状态授权:{account.authorization_status === 'authorized' ? '已授权' : '已撤销'}({account.authorization_kind})运行:{account.runtime_status === 'active' ? '启用' : '暂停'} · 版本 {account.version}凭据引用:{account.credential_reference?.id} · {account.credential_reference?.provider}
+ 固定资源{browsersError ? 运行环境、固定出口与 readiness 状态未知;重试成功后再执行依赖资源状态的操作。 : binding ? <>运行环境:{binding.name}({binding.alias})固定出口:{binding.network_exit_id || '未绑定'} · {binding.network_exit_health || '未知状态'}绑定版本:{binding.binding_version}不可调度原因:{binding.schedule_block_reason ? (reasonLabels[binding.schedule_block_reason] || binding.schedule_block_reason) : '无'}> : <>尚未绑定运行环境与固定出口,因此不能恢复或排队。>}
+
+ >
+ )
+}
diff --git a/web/src/AccountList.test.jsx b/web/src/AccountList.test.jsx
new file mode 100644
index 0000000..b27c503
--- /dev/null
+++ b/web/src/AccountList.test.jsx
@@ -0,0 +1,85 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { CoreAdminContext, HttpError } from 'ra-core'
+import { MemoryRouter, Route, Routes } from 'react-router-dom'
+import { AccountDetail, AccountList, accountReadiness } from './AccountList'
+
+afterEach(() => { cleanup(); vi.restoreAllMocks() })
+
+const account = { id: 'account-a', platform: 'mock', platform_account_key: 'shop-a', authorization_kind: 'owned', authorization_status: 'authorized', runtime_status: 'paused', version: 1, credential_reference: { id: 'credential-a', provider: 'os_keyring' } }
+const binding = { id: 'env-a', alias: 'env-a', name: '店铺环境', account_id: 'account-a', network_exit_id: 'exit-a', network_exit_health: 'healthy', binding_version: 1, schedule_status: 'blocked', schedule_block_reason: 'account_paused' }
+
+function provider(overrides = {}) {
+ return {
+ getList: vi.fn(resource => Promise.resolve(resource === 'accounts' ? { data: [account], total: 1 } : resource === 'browsers' ? { data: [binding], total: 1 } : { data: [], total: 0 })),
+ getOne: vi.fn().mockResolvedValue({ data: account }),
+ create: vi.fn().mockResolvedValue({ data: account }),
+ accountAction: vi.fn().mockResolvedValue(undefined),
+ getMany: vi.fn(), getManyReference: vi.fn(), update: vi.fn(), updateMany: vi.fn(), delete: vi.fn(), deleteMany: vi.fn(),
+ ...overrides,
+ }
+}
+
+function renderList(dataProvider) {
+ return render()
+}
+
+describe('AccountList', () => {
+ it('keeps account input after a 409 and never submits a secret or client id', async () => {
+ const dataProvider = provider({ create: vi.fn().mockRejectedValue(new HttpError('conflict', 409, { reason_code: 'duplicate_platform_account' })) })
+ renderList(dataProvider)
+ await screen.findAllByText('shop-a')
+
+ fireEvent.change(screen.getByRole('textbox', { name: '平台' }), { target: { value: 'mock' } })
+ fireEvent.change(screen.getByRole('textbox', { name: '平台账号标识' }), { target: { value: 'shop-new' } })
+ fireEvent.change(screen.getByRole('textbox', { name: '凭据引用 ID' }), { target: { value: 'credential-new' } })
+ fireEvent.change(screen.getByRole('textbox', { name: '凭据引用键' }), { target: { value: 'creatorhub/shop-new' } })
+ fireEvent.click(screen.getByRole('button', { name: '创建账号' }))
+
+ await waitFor(() => expect(dataProvider.create).toHaveBeenCalledWith('accounts', { data: {
+ platform: 'mock', platform_account_key: 'shop-new', authorization_kind: 'owned',
+ credential_reference: { id: 'credential-new', provider: 'os_keyring', key: 'creatorhub/shop-new' },
+ } }))
+ expect((await screen.findByRole('alert')).textContent).toContain('冲突(409)')
+ expect(screen.getByRole('textbox', { name: '平台账号标识' }).value).toBe('shop-new')
+ expect(screen.queryByLabelText(/密码|Cookie|token/i)).toBeNull()
+ })
+
+ it('distinguishes resumable bindings from missing resources', () => {
+ expect(accountReadiness(account, binding)).toMatchObject({ label: '资源就绪,可恢复', canResume: true })
+ expect(accountReadiness(account, undefined)).toMatchObject({ label: '未绑定运行环境', canResume: false })
+ expect(accountReadiness(account, undefined, true)).toMatchObject({ label: '环境状态未知', canResume: false })
+ })
+})
+
+describe('AccountDetail', () => {
+ it('pauses explicitly and explains that hold tasks stay held', async () => {
+ vi.spyOn(window, 'confirm').mockReturnValue(true)
+ const active = { ...account, runtime_status: 'active' }
+ const dataProvider = provider({ getOne: vi.fn().mockResolvedValue({ data: active }) })
+ render(} />)
+
+ fireEvent.click(await screen.findByRole('button', { name: '暂停账号' }))
+ await waitFor(() => expect(dataProvider.accountAction).toHaveBeenCalledWith('account-a', 'pause'))
+ expect((await screen.findAllByText(/不会自动恢复既有 hold/)).length).toBeGreaterThan(0)
+ })
+
+ it('keeps environment-dependent actions fail-safe when browsers return 502', async () => {
+ const active = { ...account, runtime_status: 'active' }
+ const dataProvider = provider({
+ getOne: vi.fn().mockResolvedValue({ data: active }),
+ getList: vi.fn(resource => resource === 'browsers'
+ ? Promise.reject(new HttpError('bad gateway', 502))
+ : Promise.resolve({ data: [], total: 0 })),
+ })
+ render(} />)
+
+ expect(await screen.findByText(/环境不可用(502)/)).toBeTruthy()
+ expect(screen.getByText(/运行环境、固定出口与 readiness 状态未知/)).toBeTruthy()
+ expect(screen.queryByText(/尚未绑定运行环境与固定出口/)).toBeNull()
+ expect(screen.getByRole('button', { name: '恢复账号' }).disabled).toBe(true)
+ expect(screen.getByRole('button', { name: '暂停账号' }).disabled).toBe(false)
+ fireEvent.click(screen.getByRole('button', { name: '重试环境状态' }))
+ await waitFor(() => expect(dataProvider.getList.mock.calls.filter(([resource]) => resource === 'browsers').length).toBeGreaterThan(1))
+ })
+})
diff --git a/web/src/BrowserList.jsx b/web/src/BrowserList.jsx
index 2883675..09844d1 100644
--- a/web/src/BrowserList.jsx
+++ b/web/src/BrowserList.jsx
@@ -27,9 +27,11 @@ import {
Typography,
} from '@mui/material'
import ContentCopyOutlined from '@mui/icons-material/ContentCopyOutlined'
+import CheckCircleOutlined from '@mui/icons-material/CheckCircleOutlined'
import DeleteOutlined from '@mui/icons-material/DeleteOutlined'
import ExpandMoreOutlined from '@mui/icons-material/ExpandMoreOutlined'
import PlayArrowOutlined from '@mui/icons-material/PlayArrowOutlined'
+import ReportProblemOutlined from '@mui/icons-material/ReportProblemOutlined'
import SecurityOutlined from '@mui/icons-material/SecurityOutlined'
import StopOutlined from '@mui/icons-material/StopOutlined'
import UpgradeOutlined from '@mui/icons-material/UpgradeOutlined'
@@ -48,6 +50,16 @@ const statusLabels = {
missing: '未部署',
}
+const scheduleReasonLabels = {
+ account_revoked: '授权已撤销',
+ account_paused: '账号已暂停',
+ binding_missing: '绑定缺失',
+ network_exit_missing: '固定出口缺失',
+ network_exit_unhealthy: '固定出口不健康',
+ runtime_stop_pending: '停止结果待确认',
+ runtime_missing: '运行实例缺失',
+}
+
const emptyForm = {
name: '',
alias: '',
@@ -148,10 +160,10 @@ function CreateForm({ gateways, images, accounts, networkExits, onSubmit, busy }
form.image_version !== '' && form.account_id !== '' && form.network_exit_id !== '' &&
Number.isInteger(seedNumber) && seedNumber >= 1 && seedNumber <= 2147483647
- const submit = event => {
+ const submit = async event => {
event.preventDefault()
if (!valid) return
- onSubmit({
+ const created = await onSubmit({
alias: form.alias,
name: form.name.trim(),
gateway: form.gateway,
@@ -160,7 +172,7 @@ function CreateForm({ gateways, images, accounts, networkExits, onSubmit, busy }
network_exit_id: form.network_exit_id,
fingerprint: buildFingerprint(form),
})
- setForm(current => ({ ...emptyForm, gateway: current.gateway, image_version: current.image_version, account_id: current.account_id, network_exit_id: current.network_exit_id }))
+ if (created) setForm(current => ({ ...emptyForm, gateway: current.gateway, image_version: current.image_version, account_id: current.account_id, network_exit_id: current.network_exit_id }))
}
const label = (htmlFor, text, required = false) => (
@@ -245,6 +257,12 @@ function CreateForm({ gateways, images, accounts, networkExits, onSubmit, busy }
)
}
+function BindingReadiness({ runtime }) {
+ const ready = runtime.schedule_status === 'ready'
+ const reason = runtime.schedule_block_reason
+ return {ready ? : }{ready ? '可调度' : (scheduleReasonLabels[reason] || reason || '不可调度')}
+}
+
function UpgradeDialog({ target, images, busy, onClose, onConfirm }) {
const [version, setVersion] = useState('')
const enabledImages = images.filter(image => image.enabled)
@@ -274,13 +292,13 @@ function RuntimeTable({ runtimes, busy, onAction }) {
return (
- 环境状态镜像版本CDP操作
+ 环境状态固定绑定CDP操作
{runtimes.map(runtime => (
{runtime.name}{runtime.alias} · seed {runtime.fingerprint?.seed}
- {runtime.image_version}
+ {runtime.account_id || '未绑定账号'}{runtime.network_exit_id || '未绑定出口'} · {runtime.image_version}
{runtime.endpoint ? : —}
@@ -292,7 +310,7 @@ function RuntimeTable({ runtimes, busy, onAction }) {
}
function RuntimeCards({ runtimes, busy, onAction }) {
- return {runtimes.map(runtime => {runtime.name}{runtime.alias} · {runtime.image_version} · seed {runtime.fingerprint?.seed}{runtime.endpoint ? : null})}
+ return {runtimes.map(runtime => {runtime.name}{runtime.alias} · {runtime.image_version} · seed {runtime.fingerprint?.seed}账号:{runtime.account_id || '未绑定'} · 固定出口:{runtime.network_exit_id || '未绑定'}{runtime.endpoint ? : null})}
}
export function BrowserList() {
@@ -316,8 +334,10 @@ export function BrowserList() {
try {
await dataProvider.create('browsers', { data })
await refetch()
+ return true
} catch (reason) {
- setLocalError(reason.message)
+ setLocalError(reason.status === 409 ? '冲突(409):账号或运行环境已有活动绑定;表单内容已保留。' : reason.status === 503 ? `环境不可用(503):${reason.message}` : reason.message)
+ return false
} finally {
setBusy('')
}
diff --git a/web/src/BrowserList.test.jsx b/web/src/BrowserList.test.jsx
index 0c42e69..16db3e4 100644
--- a/web/src/BrowserList.test.jsx
+++ b/web/src/BrowserList.test.jsx
@@ -1,13 +1,13 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
-import { CoreAdminContext } from 'ra-core'
+import { CoreAdminContext, HttpError } from 'ra-core'
import { BrowserList } from './BrowserList'
import { theme } from './theme'
afterEach(cleanup)
const runtimes = [
- { id: 'account-a', alias: 'account-a', name: '店铺一号', state: 'running', status: 'Up', image_version: '148.0.0.1', fingerprint: { seed: 1000 }, endpoint: 'http://account-a:9222' },
+ { id: 'account-a', alias: 'account-a', name: '店铺一号', state: 'running', status: 'Up', image_version: '148.0.0.1', fingerprint: { seed: 1000 }, endpoint: 'http://account-a:9222', account_id: 'social-a', network_exit_id: 'exit-1', network_exit_health: 'healthy', schedule_status: 'ready' },
{ id: 'account-b', alias: 'account-b', name: '店铺二号', state: 'exited', status: 'Exited', image_version: '148.0.0.1', fingerprint: { seed: 2000 }, endpoint: 'http://account-b:9222' },
]
const gateways = [{ id: 'gw-1', name: 'gw-1', endpoint: 'http://gw:8081', token: 't' }]
@@ -103,6 +103,19 @@ describe('BrowserList', () => {
expect(screen.queryByLabelText('禁用非代理 UDP')).toBeNull()
})
+ it('keeps binding input after a 409 conflict', async () => {
+ const dataProvider = provider({ create: vi.fn().mockRejectedValue(new HttpError('conflict', 409)) })
+ render()
+ await screen.findAllByText('店铺一号')
+
+ fireEvent.change(screen.getByRole('textbox', { name: /环境名称/ }), { target: { value: '冲突环境' } })
+ fireEvent.change(screen.getByRole('textbox', { name: /别名/ }), { target: { value: 'conflict-env' } })
+ fireEvent.click(screen.getByRole('button', { name: '创建环境' }))
+
+ expect((await screen.findByRole('alert')).textContent).toContain('冲突(409)')
+ expect(screen.getByRole('textbox', { name: /环境名称/ }).value).toBe('冲突环境')
+ })
+
it('upgrades an env through the version dialog', async () => {
const dataProvider = provider()
render()
diff --git a/web/src/NetworkExitList.jsx b/web/src/NetworkExitList.jsx
new file mode 100644
index 0000000..f0eb2d6
--- /dev/null
+++ b/web/src/NetworkExitList.jsx
@@ -0,0 +1,152 @@
+import { useEffect, useMemo, useState } from 'react'
+import { useDataProvider, useGetList } from 'ra-core'
+import {
+ Alert,
+ Box,
+ Button,
+ CircularProgress,
+ MenuItem,
+ Paper,
+ Stack,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ TextField,
+ Typography,
+} from '@mui/material'
+import BlockOutlined from '@mui/icons-material/BlockOutlined'
+import CheckCircleOutlined from '@mui/icons-material/CheckCircleOutlined'
+import ErrorOutlined from '@mui/icons-material/ErrorOutlined'
+import PublicOutlined from '@mui/icons-material/PublicOutlined'
+import RefreshOutlined from '@mui/icons-material/RefreshOutlined'
+
+const emptyExit = { protocol: 'socks5', host: '', port: '', credential_reference_id: '', expected_public_ip: '', expected_region: '' }
+const healthLabels = { unchecked: '未检测', healthy: '健康', unhealthy: '不健康', disabled: '已停用' }
+const wrapAnywhere = { overflowWrap: 'anywhere', minWidth: 0 }
+
+function actionError(error, conflictMessage) {
+ if (error?.status === 409) return `冲突(409):${conflictMessage}`
+ if (error?.status === 503) return `环境不可用(503):${error.message}`
+ return error?.message || '操作失败'
+}
+
+function environmentError(error) {
+ return `环境不可用${error?.status ? `(${error.status})` : ''}:无法读取账号绑定,状态暂时未知。`
+}
+
+function ExitHealth({ exit }) {
+ const known = Object.hasOwn(healthLabels, exit.health_status)
+ const label = known ? healthLabels[exit.health_status] : '未知状态'
+ const Icon = exit.health_status === 'healthy' ? CheckCircleOutlined : exit.health_status === 'disabled' ? BlockOutlined : ErrorOutlined
+ const color = exit.health_status === 'healthy' ? 'success.main' : exit.health_status === 'unchecked' ? 'warning.main' : 'error.main'
+ return {label}{known ? null : {exit.health_status}}
+}
+
+function ExitActions({ exit, busy, onAction }) {
+ return } disabled={busy || exit.health_status === 'disabled'} onClick={() => onAction(exit, 'check')}>检测} disabled={busy || exit.health_status === 'disabled'} onClick={() => onAction(exit, 'disable')}>停用
+}
+
+function ExitForm({ busy, onSubmit }) {
+ const [form, setForm] = useState(emptyExit)
+ const update = (key, value) => setForm(current => ({ ...current, [key]: value }))
+ const port = Number(form.port)
+ const valid = form.host.trim() && Number.isInteger(port) && port > 0 && port <= 65535
+
+ async function submit(event) {
+ event.preventDefault()
+ if (!valid) return
+ const created = await onSubmit({
+ protocol: form.protocol,
+ host: form.host.trim(),
+ port,
+ credential_reference: { id: form.credential_reference_id.trim() },
+ expected_public_ip: form.expected_public_ip.trim(),
+ expected_region: form.expected_region.trim(),
+ })
+ if (created) setForm(emptyExit)
+ }
+
+ return (
+
+ update('protocol', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '出口协议' } }}>{['http', 'https', 'socks4', 'socks5'].map(protocol => )}
+ update('host', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '出口主机', maxLength: 253 } }} />
+ update('port', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '出口端口', min: 1, max: 65535 } }} />
+ update('credential_reference_id', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '出口凭据引用 ID', maxLength: 128 } }} helperText="只填写已保存的引用,不填写认证秘密" />
+ update('expected_public_ip', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '预期公网 IP' } }} />
+ update('expected_region', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '预期地区', maxLength: 64 } }} />
+
+
+ )
+}
+
+function ExitTable({ exits, bindings, bindingsError, busy, onAction }) {
+ return 出口健康观测绑定账号操作{exits.map(exit => {exit.protocol}://{exit.host}:{exit.port}{exit.id} · 凭据 {exit.credential_reference?.id || '无'}{exit.observed_public_ip || '尚无观测'}{exit.last_checked_at ? new Date(exit.last_checked_at).toLocaleString('zh-CN') : '未检测'}{bindingsError ? '状态未知' : (bindings.get(exit.id)?.join('、') || '未绑定')})}
+}
+
+function ExitCards({ exits, bindings, bindingsError, busy, onAction }) {
+ return {exits.map(exit => {exit.protocol}://{exit.host}:{exit.port}{exit.id} · 凭据 {exit.credential_reference?.id || '无'}观测公网 IP:{exit.observed_public_ip || '尚无观测'}最近检测:{exit.last_checked_at ? new Date(exit.last_checked_at).toLocaleString('zh-CN') : '未检测'}绑定账号:{bindingsError ? '状态未知' : (bindings.get(exit.id)?.join('、') || '未绑定')})}
+}
+
+export function NetworkExitList() {
+ const dataProvider = useDataProvider()
+ const [busy, setBusy] = useState('')
+ const [message, setMessage] = useState(null)
+ const [filter, setFilter] = useState('')
+ const { data: exits = [], error, isPending, refetch } = useGetList('network-exits')
+ const { data: browsers = [], error: browsersError, refetch: refetchBrowsers } = useGetList('browsers', undefined, { retry: false })
+ const bindings = useMemo(() => {
+ const result = new Map()
+ for (const browser of browsers) {
+ if (!browser.network_exit_id || !browser.account_id) continue
+ result.set(browser.network_exit_id, [...(result.get(browser.network_exit_id) || []), browser.account_id])
+ }
+ return result
+ }, [browsers])
+ const needle = filter.trim().toLowerCase()
+ const visible = needle ? exits.filter(exit => `${exit.id} ${exit.protocol} ${exit.host} ${exit.observed_public_ip || ''}`.toLowerCase().includes(needle)) : exits
+
+ useEffect(() => { document.title = 'CreatorHub · 网络出口' }, [])
+
+ async function createExit(data) {
+ setBusy('create'); setMessage(null)
+ try {
+ await dataProvider.create('network-exits', { data })
+ await refetch()
+ setMessage({ severity: 'success', text: '网络出口已创建;检测通过后才可用于账号绑定。' })
+ return true
+ } catch (reason) {
+ setMessage({ severity: 'error', text: actionError(reason, '出口或凭据引用与现有资源冲突;表单内容已保留。') })
+ return false
+ } finally { setBusy('') }
+ }
+
+ async function act(exit, action) {
+ if (action === 'disable' && !window.confirm(`停用网络出口 ${exit.id}?绑定账号将暂停,排队任务进入 hold。`)) return
+ setBusy(exit.id); setMessage(null)
+ try {
+ await dataProvider.networkExitAction(exit.id, action)
+ await Promise.all([refetch(), refetchBrowsers()])
+ setMessage({ severity: 'success', text: action === 'check' ? '出口检测完成。' : '出口已停用;绑定账号不会自动恢复。' })
+ } catch (reason) {
+ setMessage({ severity: 'error', text: actionError(reason, action === 'check' ? '已停用的出口不能检测。' : '出口当前不能停用。') })
+ } finally { setBusy('') }
+ }
+
+ return (
+ <>
+ 网络出口维护固定出口、健康观测与账号绑定
+
+ {message ? {message.text} : null}
+ {error ? {error.message} : null}
+ {browsersError ? refetchBrowsers()}>重试环境状态} sx={{ mb: 2.5 }}>{environmentError(browsersError)} : null}
+ {exits.length > 0 ? setFilter(event.target.value)} sx={{ mb: 2.5, width: { xs: '100%', sm: 320 } }} /> : null}
+ {isPending ? : null}
+ {!isPending && exits.length === 0 ? 暂无网络出口。创建并检测健康后,才能绑定运行环境。 : null}
+ {!isPending && exits.length > 0 && visible.length === 0 ? 没有符合筛选条件的网络出口 : null}
+ {!isPending && visible.length > 0 ? <>> : null}
+ >
+ )
+}
diff --git a/web/src/NetworkExitList.test.jsx b/web/src/NetworkExitList.test.jsx
new file mode 100644
index 0000000..ec96680
--- /dev/null
+++ b/web/src/NetworkExitList.test.jsx
@@ -0,0 +1,52 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { CoreAdminContext, HttpError } from 'ra-core'
+import { NetworkExitList } from './NetworkExitList'
+
+afterEach(() => { cleanup(); vi.restoreAllMocks() })
+
+const networkExit = { id: 'exit-a', protocol: 'socks5', host: 'proxy.example', port: 1080, health_status: 'healthy', observed_public_ip: '203.0.113.1', credential_reference: { id: 'credential-a', provider: 'os_keyring' } }
+
+function provider(exits = [], overrides = {}) {
+ return {
+ getList: vi.fn(resource => Promise.resolve(resource === 'network-exits' ? { data: exits, total: exits.length } : { data: [], total: 0 })),
+ create: vi.fn().mockResolvedValue({ data: networkExit }),
+ networkExitAction: vi.fn().mockResolvedValue(networkExit),
+ getOne: vi.fn(), getMany: vi.fn(), getManyReference: vi.fn(), update: vi.fn(), updateMany: vi.fn(), delete: vi.fn(), deleteMany: vi.fn(),
+ ...overrides,
+ }
+}
+
+describe('NetworkExitList', () => {
+ it('shows the ordered empty-state instruction', async () => {
+ render()
+ expect(await screen.findByText(/创建并检测健康后/)).toBeTruthy()
+ })
+
+ it('shows only credential references and runs an explicit health check', async () => {
+ const dataProvider = provider([networkExit])
+ render()
+
+ expect((await screen.findAllByText(/凭据 credential-a/)).length).toBeGreaterThan(0)
+ expect(screen.queryByText(/password|token|raw-value/i)).toBeNull()
+ fireEvent.click(screen.getAllByRole('button', { name: '检测' })[0])
+ await waitFor(() => expect(dataProvider.networkExitAction).toHaveBeenCalledWith('exit-a', 'check'))
+ })
+
+ it('shows unknown bindings and retries when browsers return 502', async () => {
+ const dataProvider = provider([networkExit], {
+ getList: vi.fn(resource => resource === 'browsers'
+ ? Promise.reject(new HttpError('bad gateway', 502))
+ : Promise.resolve({ data: [networkExit], total: 1 })),
+ })
+ render()
+
+ expect(await screen.findByText(/环境不可用(502)/)).toBeTruthy()
+ expect((await screen.findAllByText(/绑定账号:状态未知|^状态未知$/)).length).toBeGreaterThan(0)
+ expect(screen.queryByText(/绑定账号:未绑定/)).toBeNull()
+ expect(screen.queryByText(/^未绑定$/)).toBeNull()
+ expect(screen.getAllByRole('button', { name: '检测' })[0].disabled).toBe(false)
+ fireEvent.click(screen.getByRole('button', { name: '重试环境状态' }))
+ await waitFor(() => expect(dataProvider.getList.mock.calls.filter(([resource]) => resource === 'browsers').length).toBeGreaterThan(1))
+ })
+})
diff --git a/web/src/dataProvider.js b/web/src/dataProvider.js
index 8ff39cb..63d743e 100644
--- a/web/src/dataProvider.js
+++ b/web/src/dataProvider.js
@@ -36,7 +36,7 @@ export const dataProvider = {
const path = resourcePaths[resource]
if (!path) return unsupported(resource, 'create')
const created = await request(path, jsonOptions('POST', data))
- return { data: { ...data, ...created, id: created?.alias ?? created?.version ?? created?.name ?? data.alias } }
+ return { data: { ...data, ...created, id: created?.id ?? created?.alias ?? created?.version ?? created?.name ?? data.id ?? data.alias } }
},
async update(resource, { id, data }) {
const path = resourcePaths[resource]
@@ -52,7 +52,12 @@ export const dataProvider = {
await request(`${path}/${encodeURIComponent(id)}`, { method: 'DELETE' })
return { data: { id } }
},
- getOne: resource => unsupported(resource, 'getOne'),
+ async getOne(resource, { id }) {
+ const path = resourcePaths[resource]
+ if (!path || (resource !== 'accounts' && resource !== 'network-exits')) return unsupported(resource, 'getOne')
+ const record = await request(`${path}/${encodeURIComponent(id)}`)
+ return { data: { ...record, id: record.id ?? id } }
+ },
getMany: resource => unsupported(resource, 'getMany'),
getManyReference: resource => unsupported(resource, 'getManyReference'),
updateMany: resource => unsupported(resource, 'updateMany'),
@@ -72,4 +77,12 @@ export const dataProvider = {
if (!target) throw new Error(`未知运行环境操作: ${action}`)
await request(target[0], { method: target[1] })
},
+ async accountAction(id, action) {
+ if (action !== 'pause' && action !== 'resume') throw new Error(`未知账号操作: ${action}`)
+ await request(`/phase-a/accounts/${encodeURIComponent(id)}/${action}`, { method: 'POST' })
+ },
+ async networkExitAction(id, action) {
+ if (action !== 'check' && action !== 'disable') throw new Error(`未知网络出口操作: ${action}`)
+ return request(`/network-exits/${encodeURIComponent(id)}/${action}`, { method: 'POST' })
+ },
}
diff --git a/web/src/dataProvider.test.js b/web/src/dataProvider.test.js
index 3a22f7f..ef31b55 100644
--- a/web/src/dataProvider.test.js
+++ b/web/src/dataProvider.test.js
@@ -26,6 +26,26 @@ describe('dataProvider', () => {
expect(fetch).toHaveBeenCalledWith(path, undefined)
})
+ it.each([
+ ['accounts', 'account-a', '/api/phase-a/accounts/account-a'],
+ ['network-exits', 'exit/one', '/api/network-exits/exit%2Fone'],
+ ])('loads %s detail through its stable API path', async (resource, id, path) => {
+ const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id }), { status: 200 }))
+ vi.stubGlobal('fetch', fetch)
+
+ await expect(dataProvider.getOne(resource, { id })).resolves.toEqual({ data: { id } })
+ expect(fetch).toHaveBeenCalledWith(path, undefined)
+ })
+
+ it('creates an account without a client-generated technical id', async () => {
+ const fetch = vi.fn().mockResolvedValue(new Response('{"id":"account-generated"}', { status: 201 }))
+ vi.stubGlobal('fetch', fetch)
+ const data = { platform: 'mock', platform_account_key: 'shop-a' }
+
+ await expect(dataProvider.create('accounts', { data })).resolves.toEqual({ data: { ...data, id: 'account-generated' } })
+ expect(fetch).toHaveBeenCalledWith('/api/phase-a/accounts', expect.objectContaining({ method: 'POST' }))
+ })
+
it.each([
['start', '/api/browsers/account-a/start', 'POST'],
['stop', '/api/browsers/account-a/stop', 'POST'],
@@ -43,4 +63,23 @@ describe('dataProvider', () => {
await expect(dataProvider.getList('browsers')).rejects.toMatchObject({ message: '网关不可用', status: 502 })
})
+
+ it.each([409, 503])('preserves structured action errors for status %s', async status => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: 'blocked', reason_code: 'network_exit_unhealthy' }), { status })))
+
+ await expect(dataProvider.accountAction('account-a', 'resume')).rejects.toMatchObject({
+ message: 'blocked', status, body: { error: 'blocked', reason_code: 'network_exit_unhealthy' },
+ })
+ })
+
+ it.each([
+ ['pause', '/api/phase-a/accounts/account-a/pause'],
+ ['resume', '/api/phase-a/accounts/account-a/resume'],
+ ])('sends explicit account action %s', async (action, path) => {
+ const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 204 }))
+ vi.stubGlobal('fetch', fetch)
+
+ await dataProvider.accountAction('account-a', action)
+ expect(fetch).toHaveBeenCalledWith(path, { method: 'POST' })
+ })
})
diff --git a/web/src/layout.jsx b/web/src/layout.jsx
index 12fea15..d0738ef 100644
--- a/web/src/layout.jsx
+++ b/web/src/layout.jsx
@@ -14,11 +14,13 @@ import {
} from '@mui/material'
import ChevronLeft from '@mui/icons-material/ChevronLeft'
import ChevronRight from '@mui/icons-material/ChevronRight'
+import AccountCircleOutlined from '@mui/icons-material/AccountCircleOutlined'
import DnsOutlined from '@mui/icons-material/DnsOutlined'
import HistoryOutlined from '@mui/icons-material/HistoryOutlined'
import HubOutlined from '@mui/icons-material/HubOutlined'
import Inventory2Outlined from '@mui/icons-material/Inventory2Outlined'
import MenuIcon from '@mui/icons-material/Menu'
+import PublicOutlined from '@mui/icons-material/PublicOutlined'
import WidgetsOutlined from '@mui/icons-material/WidgetsOutlined'
import { useTheme } from '@mui/material/styles'
@@ -32,11 +34,25 @@ export function CreatorHubMenu({ collapsed, onNavigate }) {
{collapsed ? null : CreatorHub}
-
+
+ {collapsed ? null : 运营}
+
+
+ {collapsed ? null : }
+
+
+
+ {collapsed ? null : }
+
+ {collapsed ? null : 资源}
{collapsed ? null : }
+
+
+ {collapsed ? null : }
+
{collapsed ? null : }
@@ -45,10 +61,6 @@ export function CreatorHubMenu({ collapsed, onNavigate }) {
{collapsed ? null : }
-
-
- {collapsed ? null : }
-
>
)
diff --git a/web/src/main.jsx b/web/src/main.jsx
index 69cb527..281a897 100644
--- a/web/src/main.jsx
+++ b/web/src/main.jsx
@@ -1,13 +1,16 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
// react-admin 5.15.1 publishes an incomplete MUI barrel; ra-core is its supported headless entry point.
-import { CoreAdmin, Resource } from 'ra-core'
+import { CoreAdmin, CustomRoutes, Resource } from 'ra-core'
import { CssBaseline, ThemeProvider } from '@mui/material'
+import { Route } from 'react-router-dom'
+import { AccountDetail, AccountList } from './AccountList'
import { BrowserImageList } from './BrowserImageList'
import { BrowserList } from './BrowserList'
import { dataProvider } from './dataProvider'
import { GatewayList } from './GatewayList'
import { CreatorHubLayout } from './layout'
+import { NetworkExitList } from './NetworkExitList'
import { theme } from './theme'
import './styles.css'
@@ -16,9 +19,12 @@ createRoot(document.getElementById('root')).render(
+
+
+ } />
,
diff --git a/web/src/theme.js b/web/src/theme.js
index dd91f65..17f4ea3 100644
--- a/web/src/theme.js
+++ b/web/src/theme.js
@@ -21,7 +21,7 @@ export const theme = createTheme({
components: {
MuiButton: {
defaultProps: { disableElevation: true },
- styleOverrides: { root: { borderRadius: 7, minHeight: 40 } },
+ styleOverrides: { root: { borderRadius: 7, minHeight: 44 } },
},
MuiOutlinedInput: {
styleOverrides: { root: { borderRadius: 7 } },
diff --git a/web/tests/responsive.e2e.js b/web/tests/responsive.e2e.js
index e28c11b..e1b5839 100644
--- a/web/tests/responsive.e2e.js
+++ b/web/tests/responsive.e2e.js
@@ -19,7 +19,7 @@ test('keeps the create form inside a 900px viewport', async ({ page }) => {
await page.route('**/api/phase-a/accounts', route => route.fulfill({ json: [] }))
await page.route('**/api/network-exits', route => route.fulfill({ json: [] }))
await page.setViewportSize({ width: 900, height: 800 })
- await page.goto('/')
+ await page.goto('/#/browsers')
await expect(page.getByRole('button', { name: '收起导航' }).first()).toBeVisible()
const createButton = page.getByRole('button', { name: '创建环境' })
@@ -39,7 +39,7 @@ test('shows the CDP endpoint only in the active branch at 900px and 599px', asyn
const endpoint = page.getByText('http://account-a:9222')
await page.setViewportSize({ width: 900, height: 800 })
- await page.goto('/')
+ await page.goto('/#/browsers')
await expect(page.getByRole('cell', { name: 'http://account-a:9222' })).toBeVisible()
await expect(endpoint.filter({ visible: true })).toHaveCount(1)
@@ -47,3 +47,39 @@ test('shows the CDP endpoint only in the active branch at 900px and 599px', asyn
await expect(page.getByRole('cell', { name: 'http://account-a:9222' })).toBeHidden()
await expect(endpoint.filter({ visible: true })).toHaveCount(1)
})
+
+test('keeps account and network-exit pages inside 599px, 900px and 1280px', async ({ page }) => {
+ const accountKey = 'a'.repeat(128)
+ const credentialID = 'c'.repeat(128)
+ const hostname = [63, 63, 63, 61].map(length => 'h'.repeat(length)).join('.')
+ const account = { id: 'account-a', platform: 'mock', platform_account_key: accountKey, authorization_kind: 'owned', authorization_status: 'authorized', runtime_status: 'paused', version: 1, credential_reference: { id: credentialID, provider: 'os_keyring' } }
+ const binding = { alias: 'env-a', name: '店铺环境', account_id: 'account-a', network_exit_id: 'exit-a', network_exit_health: 'healthy', binding_version: 1, schedule_status: 'blocked', schedule_block_reason: 'account_paused' }
+ const networkExit = { id: 'exit-a', protocol: 'socks5', host: hostname, port: 1080, health_status: 'healthy', credential_reference: { id: credentialID, provider: 'os_keyring' } }
+ await page.route('**/api/phase-a/accounts', route => route.fulfill({ json: [account] }))
+ await page.route('**/api/phase-a/accounts/account-a', route => route.fulfill({ json: account }))
+ await page.route('**/api/browsers', route => route.fulfill({ json: [binding] }))
+ await page.route('**/api/network-exits', route => route.fulfill({ json: [networkExit] }))
+
+ for (const path of ['/#/accounts', '/#/accounts/account-a', '/#/network-exits']) {
+ for (const width of [599, 900, 1280]) {
+ await page.setViewportSize({ width, height: 900 })
+ await page.goto(path)
+ await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
+ expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width)
+ }
+ }
+})
+
+test('opens account detail at the phase A route', async ({ page }) => {
+ const account = { id: 'account-a', platform: 'mock', platform_account_key: 'shop-a', authorization_kind: 'owned', authorization_status: 'authorized', runtime_status: 'paused', version: 1, credential_reference: { id: 'credential-a', provider: 'os_keyring' } }
+ await page.route('**/api/phase-a/accounts', route => route.fulfill({ json: [account] }))
+ await page.route('**/api/phase-a/accounts/account-a', route => route.fulfill({ json: account }))
+ await page.route('**/api/browsers', route => route.fulfill({ json: [] }))
+ await page.setViewportSize({ width: 599, height: 900 })
+ await page.goto('/#/accounts')
+
+ await page.getByRole('link', { name: '查看账号' }).click()
+ await expect(page).toHaveURL(/#\/accounts\/account-a$/)
+ await expect(page.getByRole('heading', { level: 1, name: 'shop-a' })).toBeVisible()
+ expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(599)
+})