HH-845 / HH-799: add phase A account resource pages (#23)

This commit is contained in:
2026-08-31 00:58:49 +08:00
parent 00d044d440
commit 14789e9401
17 changed files with 809 additions and 32 deletions
+51 -11
View File
@@ -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)
}
+30
View File
@@ -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())
+47 -1
View File
@@ -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()
+2
View File
@@ -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")
+7
View File
@@ -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{
+224
View File
@@ -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 <Stack direction="row" spacing={1} sx={{ alignItems: 'center', color }}><Icon fontSize="small" /><Typography component="span" variant="body2" fontWeight={650}>{readiness.label}</Typography></Stack>
}
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 (
<Paper component="form" onSubmit={submit} variant="outlined" sx={{ display: 'grid', gridTemplateColumns: { xs: 'minmax(0, 1fr)', md: 'repeat(2, minmax(0, 1fr))', lg: 'repeat(3, minmax(0, 1fr))' }, gap: 2.5, p: { xs: 2.5, md: 3 }, mb: 3.5, alignItems: 'start' }}>
<TextField required label="平台" value={form.platform} onChange={event => update('platform', event.target.value)} slotProps={{ htmlInput: { pattern: '[a-z0-9][a-z0-9-]{0,31}', 'aria-label': '平台' } }} helperText="阶段 A 使用虚拟平台标识,如 mock" />
<TextField required label="平台账号标识" value={form.platform_account_key} onChange={event => update('platform_account_key', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '平台账号标识', maxLength: 128 } }} />
<TextField select label="授权类型" value={form.authorization_kind} onChange={event => update('authorization_kind', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '授权类型' } }}>
<MenuItem value="owned">自有账号</MenuItem><MenuItem value="authorized">已获授权</MenuItem>
</TextField>
<TextField required label="凭据引用 ID" value={form.credential_id} onChange={event => update('credential_id', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '凭据引用 ID', maxLength: 128 } }} />
<TextField select label="凭据引用来源" value={form.credential_provider} onChange={event => update('credential_provider', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '凭据引用来源' } }}>
<MenuItem value="os_keyring">系统密钥环</MenuItem><MenuItem value="secret_manager">Secret Manager</MenuItem>
</TextField>
<TextField required label="凭据引用键" value={form.credential_key} onChange={event => update('credential_key', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '凭据引用键', maxLength: 127 } }} helperText="仅填写引用路径,不填写密码、Cookie 或 token" />
<Button type="submit" variant="contained" size="large" disabled={!valid || busy} sx={{ minHeight: 52, gridColumn: { md: '1 / -1', lg: 'auto' } }}>{busy ? '创建中…' : '创建账号'}</Button>
</Paper>
)
}
function AccountTable({ accounts, bindings, bindingsError }) {
return (
<TableContainer component={Paper} variant="outlined" sx={{ display: { xs: 'none', md: 'block' } }}>
<Table sx={{ tableLayout: 'fixed' }}>
<TableHead><TableRow><TableCell width="24%">账号</TableCell><TableCell width="16%">授权</TableCell><TableCell width="26%">资源就绪度</TableCell><TableCell width="22%">固定资源</TableCell><TableCell width="12%">操作</TableCell></TableRow></TableHead>
<TableBody>{accounts.map(account => {
const binding = bindings.get(account.id)
return <TableRow key={account.id}><TableCell><Typography fontWeight={700} sx={wrapAnywhere}>{account.platform_account_key}</Typography><Typography variant="caption" color="text.secondary" sx={wrapAnywhere}>{account.id} · {account.platform}</Typography></TableCell><TableCell>{account.authorization_status === 'authorized' ? '已授权' : '已撤销'} · {account.runtime_status === 'active' ? '启用' : '暂停'}</TableCell><TableCell><AccountState account={account} binding={binding} bindingError={bindingsError} /></TableCell><TableCell><Typography variant="body2" sx={wrapAnywhere}>{bindingsError ? '状态未知' : (binding?.name || '—')}</Typography><Typography variant="caption" color="text.secondary" sx={wrapAnywhere}>{bindingsError ? '环境数据不可用' : (binding?.network_exit_id || '未绑定出口')}</Typography></TableCell><TableCell><Button component={RouterLink} to={`/accounts/${account.id}`} size="small">查看</Button></TableCell></TableRow>
})}</TableBody>
</Table>
</TableContainer>
)
}
function AccountCards({ accounts, bindings, bindingsError }) {
return <Stack spacing={2} sx={{ display: { xs: 'flex', md: 'none' } }}>{accounts.map(account => {
const binding = bindings.get(account.id)
return <Paper key={account.id} variant="outlined" sx={{ p: 2.5, minWidth: 0 }}><Stack spacing={1.5} sx={{ minWidth: 0 }}><Box sx={{ minWidth: 0 }}><Typography fontWeight={700} sx={wrapAnywhere}>{account.platform_account_key}</Typography><Typography variant="caption" color="text.secondary" sx={wrapAnywhere}>{account.id} · {account.platform}</Typography></Box><Typography variant="body2">授权:{account.authorization_status === 'authorized' ? '已授权' : '已撤销'} · 账号:{account.runtime_status === 'active' ? '启用' : '暂停'}</Typography><AccountState account={account} binding={binding} bindingError={bindingsError} /><Typography variant="body2" sx={wrapAnywhere}>{bindingsError ? '运行环境与固定出口:状态未知' : `运行环境:${binding?.name || '未绑定'} · 固定出口:${binding?.network_exit_id || '未绑定'}`}</Typography><Button component={RouterLink} to={`/accounts/${account.id}`} variant="outlined">查看账号</Button></Stack></Paper>
})}</Stack>
}
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 (
<>
<Box component="header" sx={{ mb: 4 }}><Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}><AccountCircleOutlined color="primary" fontSize="large" /><Typography variant="h1">账号池</Typography></Stack><Typography color="text.secondary" sx={{ mt: 1 }}>管理账号授权、暂停状态与固定运行资源</Typography></Box>
<AccountForm busy={busy} onSubmit={createAccount} />
{message ? <Alert severity={message.severity} aria-live="polite" sx={{ mb: 2.5 }}>{message.text}</Alert> : null}
{error ? <Alert severity="error" sx={{ mb: 2.5 }}>{actionError(error, '账号列表冲突')}</Alert> : null}
{browsersError ? <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => refetchBrowsers()}>重试环境状态</Button>} sx={{ mb: 2.5 }}>{environmentError(browsersError)}</Alert> : null}
{accounts.length > 0 ? <TextField label="筛选账号" size="small" value={filter} onChange={event => setFilter(event.target.value)} sx={{ mb: 2.5, width: { xs: '100%', sm: 320 } }} /> : null}
{isPending ? <Box sx={{ display: 'grid', placeItems: 'center', minHeight: 220 }}><CircularProgress aria-label="正在加载账号" /></Box> : null}
{!isPending && accounts.length === 0 ? <Paper variant="outlined" sx={{ py: 7, px: 2, textAlign: 'center', color: 'text.secondary' }}>暂无账号。创建账号后,再前往运行环境绑定健康网络出口。</Paper> : null}
{!isPending && accounts.length > 0 && visible.length === 0 ? <Paper variant="outlined" sx={{ py: 6, textAlign: 'center' }}><Typography color="text.secondary">没有符合筛选条件的账号</Typography><Button onClick={() => setFilter('')} sx={{ mt: 1 }}>清除筛选</Button></Paper> : null}
{!isPending && visible.length > 0 ? <><AccountTable accounts={visible} bindings={bindings} bindingsError={browsersError} /><AccountCards accounts={visible} bindings={bindings} bindingsError={browsersError} /></> : 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 <Box sx={{ display: 'grid', placeItems: 'center', minHeight: 300 }}><CircularProgress aria-label="正在加载账号详情" /></Box>
if (error || !account) return <Alert severity="error">{error?.message || '账号不存在'}</Alert>
return (
<>
<Box component="header" sx={{ mb: 4, minWidth: 0 }}><Button component={RouterLink} to="/accounts" sx={{ mb: 1 }}>← 返回账号池</Button><Typography variant="h1" sx={wrapAnywhere}>{account.platform_account_key}</Typography><Typography color="text.secondary" sx={{ ...wrapAnywhere, mt: 1 }}>{account.id} · {account.platform}</Typography></Box>
{message ? <Alert severity={message.severity} aria-live="polite" sx={{ mb: 2.5 }}>{message.text}</Alert> : null}
{browsersError ? <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => refetchBrowsers()}>重试环境状态</Button>} sx={{ mb: 2.5 }}>{environmentError(browsersError)}</Alert> : null}
<Alert severity="warning" icon={<PauseCircleOutlined />} sx={{ mb: 2.5 }}>暂停会把待领取任务置为 hold;恢复账号只恢复账号可用性,不会自动恢复既有 hold。</Alert>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'minmax(0, 1fr)', lg: 'repeat(2, minmax(0, 1fr))' }, gap: 2.5 }}>
<Paper variant="outlined" sx={{ p: 3, minWidth: 0 }}><Stack spacing={2} sx={{ minWidth: 0 }}><Typography variant="h6">账号状态</Typography><AccountState account={account} binding={binding} bindingError={browsersError} /><Typography>授权:{account.authorization_status === 'authorized' ? '已授权' : '已撤销'}({account.authorization_kind})</Typography><Typography>运行:{account.runtime_status === 'active' ? '启用' : '暂停'} · 版本 {account.version}</Typography><Typography sx={wrapAnywhere}>凭据引用:{account.credential_reference?.id} · {account.credential_reference?.provider}</Typography><Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}><Button variant="outlined" color="warning" disabled={busy || account.runtime_status === 'paused'} onClick={() => act('pause')}>暂停账号</Button><Button variant="contained" disabled={busy || !readiness.canResume} onClick={() => act('resume')}>恢复账号</Button></Stack></Stack></Paper>
<Paper variant="outlined" sx={{ p: 3, minWidth: 0 }}><Stack spacing={2} sx={{ minWidth: 0 }}><Typography variant="h6">固定资源</Typography>{browsersError ? <Typography color="text.secondary">运行环境、固定出口与 readiness 状态未知;重试成功后再执行依赖资源状态的操作。</Typography> : binding ? <><Typography sx={wrapAnywhere}>运行环境:{binding.name}({binding.alias})</Typography><Typography sx={wrapAnywhere}>固定出口:{binding.network_exit_id || '未绑定'} · {binding.network_exit_health || '未知状态'}</Typography><Typography>绑定版本:{binding.binding_version}</Typography><Typography sx={wrapAnywhere}>不可调度原因:{binding.schedule_block_reason ? (reasonLabels[binding.schedule_block_reason] || binding.schedule_block_reason) : '无'}</Typography><Button component={RouterLink} to="/browsers" variant="outlined">查看运行环境</Button></> : <><Typography color="text.secondary">尚未绑定运行环境与固定出口,因此不能恢复或排队。</Typography><Button component={RouterLink} to="/browsers" variant="contained">绑定运行环境与出口</Button></>}</Stack></Paper>
</Box>
</>
)
}
+85
View File
@@ -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(<MemoryRouter><CoreAdminContext dataProvider={dataProvider}><AccountList /></CoreAdminContext></MemoryRouter>)
}
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(<MemoryRouter initialEntries={['/accounts/account-a']}><CoreAdminContext dataProvider={dataProvider}><Routes><Route path="/accounts/:id" element={<AccountDetail />} /></Routes></CoreAdminContext></MemoryRouter>)
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(<MemoryRouter initialEntries={['/accounts/account-a']}><CoreAdminContext dataProvider={dataProvider}><Routes><Route path="/accounts/:id" element={<AccountDetail />} /></Routes></CoreAdminContext></MemoryRouter>)
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))
})
})
+27 -7
View File
@@ -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 <Stack direction="row" spacing={0.75} sx={{ alignItems: 'center', color: ready ? 'success.main' : 'warning.main' }}>{ready ? <CheckCircleOutlined fontSize="small" /> : <ReportProblemOutlined fontSize="small" />}<Typography variant="caption" fontWeight={650}>{ready ? '可调度' : (scheduleReasonLabels[reason] || reason || '不可调度')}</Typography></Stack>
}
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 (
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 1, display: { xs: 'none', md: 'block' } }}>
<Table sx={{ tableLayout: 'fixed', '& th, & td': { py: 2.25 } }}>
<TableHead><TableRow><TableCell width="18%">环境</TableCell><TableCell width="11%">状态</TableCell><TableCell width="13%">镜像版本</TableCell><TableCell width="22%">CDP</TableCell><TableCell width="36%">操作</TableCell></TableRow></TableHead>
<TableHead><TableRow><TableCell width="19%">环境</TableCell><TableCell width="12%">状态</TableCell><TableCell width="23%">固定绑定</TableCell><TableCell width="16%">CDP</TableCell><TableCell width="30%">操作</TableCell></TableRow></TableHead>
<TableBody>
{runtimes.map(runtime => (
<TableRow key={runtime.alias} sx={{ '&:last-child td': { borderBottom: 0 } }}>
<TableCell><Typography fontWeight={650}>{runtime.name}</Typography><Typography variant="caption" color="text.secondary">{runtime.alias} · seed {runtime.fingerprint?.seed}</Typography></TableCell>
<TableCell><Status state={runtime.state} /></TableCell>
<TableCell><Typography variant="body2">{runtime.image_version}</Typography></TableCell>
<TableCell><Typography variant="body2">{runtime.account_id || '未绑定账号'}</Typography><Typography variant="caption" color="text.secondary">{runtime.network_exit_id || '未绑定出口'} · {runtime.image_version}</Typography><BindingReadiness runtime={runtime} /></TableCell>
<TableCell>{runtime.endpoint ? <Copyable value={runtime.endpoint} /> : <Typography variant="body2" color="text.secondary">—</Typography>}</TableCell>
<TableCell><RuntimeActions runtime={runtime} busy={busy === runtime.alias} onAction={onAction} /></TableCell>
</TableRow>
@@ -292,7 +310,7 @@ function RuntimeTable({ runtimes, busy, onAction }) {
}
function RuntimeCards({ runtimes, busy, onAction }) {
return <Stack spacing={2} sx={{ display: { xs: 'flex', md: 'none' } }}>{runtimes.map(runtime => <Paper key={runtime.alias} variant="outlined" sx={{ p: 2.5 }}><Stack spacing={1.5}><Box><Typography fontWeight={700}>{runtime.name}</Typography><Typography variant="caption" color="text.secondary">{runtime.alias} · {runtime.image_version} · seed {runtime.fingerprint?.seed}</Typography></Box><Status state={runtime.state} />{runtime.endpoint ? <Copyable value={runtime.endpoint} /> : null}<RuntimeActions runtime={runtime} busy={busy === runtime.alias} onAction={onAction} /></Stack></Paper>)}</Stack>
return <Stack spacing={2} sx={{ display: { xs: 'flex', md: 'none' } }}>{runtimes.map(runtime => <Paper key={runtime.alias} variant="outlined" sx={{ p: 2.5 }}><Stack spacing={1.5}><Box><Typography fontWeight={700}>{runtime.name}</Typography><Typography variant="caption" color="text.secondary">{runtime.alias} · {runtime.image_version} · seed {runtime.fingerprint?.seed}</Typography></Box><Status state={runtime.state} /><Typography variant="body2">账号:{runtime.account_id || '未绑定'} · 固定出口:{runtime.network_exit_id || '未绑定'}</Typography><BindingReadiness runtime={runtime} />{runtime.endpoint ? <Copyable value={runtime.endpoint} /> : null}<RuntimeActions runtime={runtime} busy={busy === runtime.alias} onAction={onAction} /></Stack></Paper>)}</Stack>
}
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('')
}
+15 -2
View File
@@ -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(<CoreAdminContext dataProvider={dataProvider}><BrowserList /></CoreAdminContext>)
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(<CoreAdminContext dataProvider={dataProvider}><BrowserList /></CoreAdminContext>)
+152
View File
@@ -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 <Stack direction="row" spacing={1} sx={{ alignItems: 'center', color }}><Icon fontSize="small" /><Box><Typography component="span" variant="body2" fontWeight={650}>{label}</Typography>{known ? null : <Typography variant="caption" display="block" color="text.secondary">{exit.health_status}</Typography>}</Box></Stack>
}
function ExitActions({ exit, busy, onAction }) {
return <Stack direction="row" spacing={1} useFlexGap sx={{ flexWrap: 'wrap' }}><Button size="small" variant="outlined" startIcon={<RefreshOutlined />} disabled={busy || exit.health_status === 'disabled'} onClick={() => onAction(exit, 'check')}>检测</Button><Button size="small" variant="outlined" color="error" startIcon={<BlockOutlined />} disabled={busy || exit.health_status === 'disabled'} onClick={() => onAction(exit, 'disable')}>停用</Button></Stack>
}
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 (
<Paper component="form" onSubmit={submit} variant="outlined" sx={{ display: 'grid', gridTemplateColumns: { xs: 'minmax(0, 1fr)', md: 'repeat(2, minmax(0, 1fr))', lg: 'repeat(3, minmax(0, 1fr))' }, gap: 2.5, p: { xs: 2.5, md: 3 }, mb: 3.5, alignItems: 'start' }}>
<TextField select label="协议" value={form.protocol} onChange={event => update('protocol', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '出口协议' } }}>{['http', 'https', 'socks4', 'socks5'].map(protocol => <MenuItem key={protocol} value={protocol}>{protocol}</MenuItem>)}</TextField>
<TextField required label="主机" value={form.host} onChange={event => update('host', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '出口主机', maxLength: 253 } }} />
<TextField required type="number" label="端口" value={form.port} onChange={event => update('port', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '出口端口', min: 1, max: 65535 } }} />
<TextField label="凭据引用 ID(可选)" value={form.credential_reference_id} onChange={event => update('credential_reference_id', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '出口凭据引用 ID', maxLength: 128 } }} helperText="只填写已保存的引用,不填写认证秘密" />
<TextField label="预期公网 IP(可选)" value={form.expected_public_ip} onChange={event => update('expected_public_ip', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '预期公网 IP' } }} />
<TextField label="预期地区(可选)" value={form.expected_region} onChange={event => update('expected_region', event.target.value)} slotProps={{ htmlInput: { 'aria-label': '预期地区', maxLength: 64 } }} />
<Button type="submit" variant="contained" size="large" disabled={!valid || busy} sx={{ minHeight: 52, gridColumn: { md: '1 / -1', lg: 'auto' } }}>{busy ? '创建中…' : '创建网络出口'}</Button>
</Paper>
)
}
function ExitTable({ exits, bindings, bindingsError, busy, onAction }) {
return <TableContainer component={Paper} variant="outlined" sx={{ display: { xs: 'none', md: 'block' } }}><Table sx={{ tableLayout: 'fixed' }}><TableHead><TableRow><TableCell width="24%">出口</TableCell><TableCell width="16%">健康</TableCell><TableCell width="22%">观测</TableCell><TableCell width="20%">绑定账号</TableCell><TableCell width="18%">操作</TableCell></TableRow></TableHead><TableBody>{exits.map(exit => <TableRow key={exit.id}><TableCell><Typography fontWeight={700} sx={wrapAnywhere}>{exit.protocol}://{exit.host}:{exit.port}</Typography><Typography variant="caption" color="text.secondary" sx={wrapAnywhere}>{exit.id} · 凭据 {exit.credential_reference?.id || '无'}</Typography></TableCell><TableCell><ExitHealth exit={exit} /></TableCell><TableCell><Typography variant="body2">{exit.observed_public_ip || '尚无观测'}</Typography><Typography variant="caption" color="text.secondary">{exit.last_checked_at ? new Date(exit.last_checked_at).toLocaleString('zh-CN') : '未检测'}</Typography></TableCell><TableCell><Typography variant="body2" sx={wrapAnywhere}>{bindingsError ? '状态未知' : (bindings.get(exit.id)?.join('、') || '未绑定')}</Typography></TableCell><TableCell><ExitActions exit={exit} busy={busy === exit.id} onAction={onAction} /></TableCell></TableRow>)}</TableBody></Table></TableContainer>
}
function ExitCards({ exits, bindings, bindingsError, busy, onAction }) {
return <Stack spacing={2} sx={{ display: { xs: 'flex', md: 'none' } }}>{exits.map(exit => <Paper key={exit.id} variant="outlined" sx={{ p: 2.5, minWidth: 0 }}><Stack spacing={1.5} sx={{ minWidth: 0 }}><Box sx={{ minWidth: 0 }}><Typography fontWeight={700} sx={wrapAnywhere}>{exit.protocol}://{exit.host}:{exit.port}</Typography><Typography variant="caption" color="text.secondary" sx={wrapAnywhere}>{exit.id} · 凭据 {exit.credential_reference?.id || '无'}</Typography></Box><ExitHealth exit={exit} /><Typography variant="body2">观测公网 IP:{exit.observed_public_ip || '尚无观测'}</Typography><Typography variant="body2">最近检测:{exit.last_checked_at ? new Date(exit.last_checked_at).toLocaleString('zh-CN') : '未检测'}</Typography><Typography variant="body2" sx={wrapAnywhere}>绑定账号:{bindingsError ? '状态未知' : (bindings.get(exit.id)?.join('、') || '未绑定')}</Typography><ExitActions exit={exit} busy={busy === exit.id} onAction={onAction} /></Stack></Paper>)}</Stack>
}
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 (
<>
<Box component="header" sx={{ mb: 4 }}><Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}><PublicOutlined color="primary" fontSize="large" /><Typography variant="h1">网络出口</Typography></Stack><Typography color="text.secondary" sx={{ mt: 1 }}>维护固定出口、健康观测与账号绑定</Typography></Box>
<ExitForm busy={busy === 'create'} onSubmit={createExit} />
{message ? <Alert severity={message.severity} aria-live="polite" sx={{ mb: 2.5 }}>{message.text}</Alert> : null}
{error ? <Alert severity="error" sx={{ mb: 2.5 }}>{error.message}</Alert> : null}
{browsersError ? <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => refetchBrowsers()}>重试环境状态</Button>} sx={{ mb: 2.5 }}>{environmentError(browsersError)}</Alert> : null}
{exits.length > 0 ? <TextField label="筛选网络出口" size="small" value={filter} onChange={event => setFilter(event.target.value)} sx={{ mb: 2.5, width: { xs: '100%', sm: 320 } }} /> : null}
{isPending ? <Box sx={{ display: 'grid', placeItems: 'center', minHeight: 220 }}><CircularProgress aria-label="正在加载网络出口" /></Box> : null}
{!isPending && exits.length === 0 ? <Paper variant="outlined" sx={{ py: 7, px: 2, textAlign: 'center', color: 'text.secondary' }}>暂无网络出口。创建并检测健康后,才能绑定运行环境。</Paper> : null}
{!isPending && exits.length > 0 && visible.length === 0 ? <Paper variant="outlined" sx={{ py: 6, textAlign: 'center' }}><Typography color="text.secondary">没有符合筛选条件的网络出口</Typography><Button onClick={() => setFilter('')} sx={{ mt: 1 }}>清除筛选</Button></Paper> : null}
{!isPending && visible.length > 0 ? <><ExitTable exits={visible} bindings={bindings} bindingsError={browsersError} busy={busy} onAction={act} /><ExitCards exits={visible} bindings={bindings} bindingsError={browsersError} busy={busy} onAction={act} /></> : null}
</>
)
}
+52
View File
@@ -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(<CoreAdminContext dataProvider={provider()}><NetworkExitList /></CoreAdminContext>)
expect(await screen.findByText(/创建并检测健康后/)).toBeTruthy()
})
it('shows only credential references and runs an explicit health check', async () => {
const dataProvider = provider([networkExit])
render(<CoreAdminContext dataProvider={dataProvider}><NetworkExitList /></CoreAdminContext>)
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(<CoreAdminContext dataProvider={dataProvider}><NetworkExitList /></CoreAdminContext>)
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))
})
})
+15 -2
View File
@@ -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' })
},
}
+39
View File
@@ -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' })
})
})
+17 -5
View File
@@ -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 }) {
<WidgetsOutlined sx={{ color: '#1680ff', fontSize: 30 }} />
{collapsed ? null : <Typography variant="h6" sx={{ color: '#fff', fontWeight: 760 }}>CreatorHub</Typography>}
</Box>
<Box component="nav" aria-label="主导航" sx={{ px: 1.5, pt: 4 }}>
<Box component="nav" aria-label="主导航" sx={{ px: 1.5, pt: 2 }}>
{collapsed ? null : <Typography variant="overline" sx={{ display: 'block', px: 1.25, mb: 0.75, color: '#7f94b2', fontWeight: 700 }}>运营</Typography>}
<ListItemButton component={NavLink} to="/accounts" aria-label="账号池" onClick={onNavigate} sx={{ minHeight: 52, mb: 1.25, borderRadius: 1, color: '#a9b7cc', justifyContent: collapsed ? 'center' : 'flex-start', '&.active': { color: '#fff', bgcolor: '#0866ef' }, '&:hover': { bgcolor: '#0b2a54' }, '&.active:hover': { bgcolor: '#0866ef' } }}>
<ListItemIcon sx={{ minWidth: collapsed ? 0 : 42, color: 'inherit' }}><AccountCircleOutlined /></ListItemIcon>
{collapsed ? null : <ListItemText primary="账号池" />}
</ListItemButton>
<ListItemButton disabled aria-label="审计记录" sx={{ minHeight: 52, mb: 2, borderRadius: 1, color: '#a9b7cc', justifyContent: collapsed ? 'center' : 'flex-start', '&.Mui-disabled': { opacity: 0.58 } }}>
<ListItemIcon sx={{ minWidth: collapsed ? 0 : 42, color: 'inherit' }}><HistoryOutlined /></ListItemIcon>
{collapsed ? null : <ListItemText primary="审计记录" />}
</ListItemButton>
{collapsed ? null : <Typography variant="overline" sx={{ display: 'block', px: 1.25, mb: 0.75, color: '#7f94b2', fontWeight: 700 }}>资源</Typography>}
<ListItemButton component={NavLink} to="/browsers" aria-label="运行环境" onClick={onNavigate} sx={{ minHeight: 52, mb: 1.25, borderRadius: 1, color: '#a9b7cc', justifyContent: collapsed ? 'center' : 'flex-start', '&.active': { color: '#fff', bgcolor: '#0866ef' }, '&:hover': { bgcolor: '#0b2a54' }, '&.active:hover': { bgcolor: '#0866ef' } }}>
<ListItemIcon sx={{ minWidth: collapsed ? 0 : 42, color: 'inherit' }}><DnsOutlined /></ListItemIcon>
{collapsed ? null : <ListItemText primary="运行环境" />}
</ListItemButton>
<ListItemButton component={NavLink} to="/network-exits" aria-label="网络出口" onClick={onNavigate} sx={{ minHeight: 52, mb: 1.25, borderRadius: 1, color: '#a9b7cc', justifyContent: collapsed ? 'center' : 'flex-start', '&.active': { color: '#fff', bgcolor: '#0866ef' }, '&:hover': { bgcolor: '#0b2a54' }, '&.active:hover': { bgcolor: '#0866ef' } }}>
<ListItemIcon sx={{ minWidth: collapsed ? 0 : 42, color: 'inherit' }}><PublicOutlined /></ListItemIcon>
{collapsed ? null : <ListItemText primary="网络出口" />}
</ListItemButton>
<ListItemButton component={NavLink} to="/browser-images" aria-label="镜像版本" onClick={onNavigate} sx={{ minHeight: 52, mb: 1.25, borderRadius: 1, color: '#a9b7cc', justifyContent: collapsed ? 'center' : 'flex-start', '&.active': { color: '#fff', bgcolor: '#0866ef' }, '&:hover': { bgcolor: '#0b2a54' }, '&.active:hover': { bgcolor: '#0866ef' } }}>
<ListItemIcon sx={{ minWidth: collapsed ? 0 : 42, color: 'inherit' }}><Inventory2Outlined /></ListItemIcon>
{collapsed ? null : <ListItemText primary="镜像版本" />}
@@ -45,10 +61,6 @@ export function CreatorHubMenu({ collapsed, onNavigate }) {
<ListItemIcon sx={{ minWidth: collapsed ? 0 : 42, color: 'inherit' }}><HubOutlined /></ListItemIcon>
{collapsed ? null : <ListItemText primary="网关管理" />}
</ListItemButton>
<ListItemButton disabled aria-label="审计记录" sx={{ minHeight: 52, borderRadius: 1, color: '#a9b7cc', justifyContent: collapsed ? 'center' : 'flex-start', '&.Mui-disabled': { opacity: 0.58 } }}>
<ListItemIcon sx={{ minWidth: collapsed ? 0 : 42, color: 'inherit' }}><HistoryOutlined /></ListItemIcon>
{collapsed ? null : <ListItemText primary="审计记录" />}
</ListItemButton>
</Box>
</>
)
+7 -1
View File
@@ -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(
<ThemeProvider theme={theme}>
<CssBaseline />
<CoreAdmin dataProvider={dataProvider} layout={CreatorHubLayout} title="CreatorHub" disableTelemetry>
<Resource name="accounts" list={AccountList} options={{ label: '账号池' }} />
<Resource name="network-exits" list={NetworkExitList} options={{ label: '网络出口' }} />
<Resource name="browsers" list={BrowserList} options={{ label: '运行环境' }} />
<Resource name="browser-images" list={BrowserImageList} options={{ label: '镜像版本' }} />
<Resource name="gateways" list={GatewayList} options={{ label: '网关管理' }} />
<CustomRoutes><Route path="/accounts/:id" element={<AccountDetail />} /></CustomRoutes>
</CoreAdmin>
</ThemeProvider>
</StrictMode>,
+1 -1
View File
@@ -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 } },
+38 -2
View File
@@ -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)
})