fix(account): close create modal on success and make cookies optional

- AccountList.createAccount 成功后关闭创建弹窗(此前只提示不关闭)
- Cookies 前端改为可选:留空代表扫码登录场景,提交时不携带该字段
- 后端 validAccount 允许空凭据(此前 http.ParseCookie("") 直接拒绝)
- CreateAccount 空凭据时跳过 keyring 写入,不产生空值记录;回滚补偿仅在实际写入过凭据时执行

验证:go test ./... / go vet ./... / 双端构建通过;web vitest 66 用例通过(新增无 cookies 创建+弹窗关闭用例);npm run build 通过
This commit is contained in:
2026-09-02 17:00:19 +08:00
parent f921a2e835
commit 1e92e16ed3
4 changed files with 872 additions and 283 deletions
+18 -8
View File
@@ -273,15 +273,20 @@ func (s *Store) CreateAccount(ctx context.Context, account Account, credentials
if !validAccount(account) || credentials == nil {
return ErrInvalid
}
if err := credentials.Store(ctx, account.CredentialReference, account.CredentialKey, account.Cookies); err != nil {
storeErr := errors.New("store account credential")
if cleanupErr := credentials.Delete(context.WithoutCancel(ctx), account.CredentialReference, account.CredentialKey); cleanupErr != nil {
storeErr = errors.Join(storeErr, errors.New("delete incomplete account credential"))
// 空凭据(扫码登录场景)不写 keyring:凭据留待后续登录/同步链路补齐
stored := false
if account.Cookies != "" {
if err := credentials.Store(ctx, account.CredentialReference, account.CredentialKey, account.Cookies); err != nil {
storeErr := errors.New("store account credential")
if cleanupErr := credentials.Delete(context.WithoutCancel(ctx), account.CredentialReference, account.CredentialKey); cleanupErr != nil {
storeErr = errors.Join(storeErr, errors.New("delete incomplete account credential"))
}
return storeErr
}
return storeErr
stored = true
}
defer func() {
if err != nil && !errors.Is(err, ErrAccountCreationUnknown) {
if stored && err != nil && !errors.Is(err, ErrAccountCreationUnknown) {
if cleanupErr := credentials.Delete(context.WithoutCancel(ctx), account.CredentialReference, account.CredentialKey); cleanupErr != nil {
err = errors.Join(err, errors.New("delete orphaned account credential"))
}
@@ -393,8 +398,13 @@ func validAccount(account Account) bool {
return false
}
}
_, err := http.ParseCookie(account.Cookies)
return err == nil
// 空凭据合法(扫码登录场景);非空时才校验 Cookie Header 格式
if account.Cookies != "" {
if _, err := http.ParseCookie(account.Cookies); err != nil {
return false
}
}
return true
}
func (s *Store) CreateDraft(ctx context.Context, draft Draft) error {
+37
View File
@@ -79,6 +79,11 @@ func TestValidationRejectsInvalidInputsBeforePersistence(t *testing.T) {
t.Fatalf("supported platform rejected: %s", platform)
}
}
empty := valid
empty.Cookies = ""
if !validAccount(empty) {
t.Fatal("empty cookies must stay valid (scan-to-login account)")
}
for name, mutate := range map[string]func(*Account){
"id": func(account *Account) { account.ID = "INVALID" },
"name": func(account *Account) { account.Name = " " },
@@ -725,6 +730,38 @@ func TestPhaseAOfflineWorkflow(t *testing.T) {
}
}
func TestCreateAccountWithoutCookiesSkipsCredentialStore(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage")
}
ctx := context.Background()
store, err := Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
applyHubMigrationsForPhaseATest(t, store)
if _, err := store.db.ExecContext(ctx, `
TRUNCATE audit_event, execution_attempt, operation_task, confirmation, content_draft,
runtime_instance, environment_binding, network_exit, social_account, credential_reference,
browser_env, browser_image, gateway RESTART IDENTITY CASCADE`); err != nil {
t.Fatal(err)
}
credentials := &testCredentialBridge{values: map[string]string{}}
account := Account{ID: "account-no-cookies", Name: "扫码账号", Platform: "douyin", PlatformAccountKey: "qr-login",
Tags: []string{}, Cookies: "",
CredentialReference: CredentialReference{ID: "account-no-cookies-cookies", Provider: "os_keyring"}, CredentialKey: "creatorhub/account-no-cookies/cookies"}
if err := store.CreateAccount(ctx, account, credentials); err != nil {
t.Fatalf("creating an account without cookies failed: %v", err)
}
if _, stored := credentials.values[account.CredentialKey]; stored {
t.Fatal("empty cookies must not be written to the credential provider")
}
assertCount(t, store, `SELECT count(*) FROM social_account WHERE id = $1`, 1, account.ID)
assertCount(t, store, `SELECT count(*) FROM credential_reference WHERE id = $1`, 1, account.CredentialReference.ID)
}
func TestAccountCredentialCommitResult(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
+549 -185
View File
@@ -1,269 +1,560 @@
import { useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router'
import { useDataProvider, useList, useOne } from '@refinedev/core'
import { useMemo, useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { useDataProvider, useList, useOne } from "@refinedev/core";
import {
Alert, Button, Card, CardContent, ConfirmDialog, DetailList, Field, Input, Modal, PageHeader,
PageState, Select, StatusPill, Textarea, conflictMessage,
} from './lib/ui.jsx'
import { useTitle } from './lib/hooks.js'
Alert,
Button,
Card,
CardContent,
ConfirmDialog,
DetailList,
Field,
Input,
Modal,
PageHeader,
PageState,
Select,
StatusPill,
Textarea,
conflictMessage,
} from "./lib/ui.jsx";
import { useTitle } from "./lib/hooks.js";
const createInitial = { name: '', platform: '', platform_account_key: '', tags: '', cookies: '' }
const createInitial = {
name: "",
platform: "",
platform_account_key: "",
tags: "",
cookies: "",
};
const platforms = [
{ value: 'douyin', label: '抖音' },
{ value: 'xiaohongshu', label: '小红书' },
{ value: 'wechat-official', label: '公众号' },
{ value: 'kuaishou', label: '快手' },
]
{ value: "douyin", label: "抖音" },
{ value: "xiaohongshu", label: "小红书" },
{ value: "wechat-official", label: "公众号" },
{ value: "kuaishou", label: "快手" },
];
const reasonText = {
account_revoked: '授权已撤销',
account_paused: '账号已暂停',
binding_missing: '未绑定运行环境',
network_exit_missing: '未绑定固定出口',
network_exit_unhealthy: '固定出口不健康',
runtime_stop_pending: '运行环境停止结果待确认',
runtime_missing: '运行环境未启动',
runtime_active: '仍有活动运行实例',
account_conflict: '账号状态已变化,请刷新后重试',
environment_unavailable: '环境状态未知',
}
account_revoked: "授权已撤销",
account_paused: "账号已暂停",
binding_missing: "未绑定运行环境",
network_exit_missing: "未绑定固定出口",
network_exit_unhealthy: "固定出口不健康",
runtime_stop_pending: "运行环境停止结果待确认",
runtime_missing: "运行环境未启动",
runtime_active: "仍有活动运行实例",
account_conflict: "账号状态已变化,请刷新后重试",
environment_unavailable: "环境状态未知",
};
// 账号可恢复性判定:授权、绑定、出口健康、账号暂停四层都要看。
export function accountReadiness(account, binding, bindingsError = false) {
if (account.authorization_status !== 'authorized') return { label: '授权已撤销', reason: 'account_revoked', canResume: false, ready: false }
if (bindingsError) return { label: '环境状态未知', reason: 'environment_unavailable', canResume: false, ready: false }
if (!binding) return { label: '未绑定运行环境', reason: 'binding_missing', canResume: false, ready: false }
if (account.authorization_status !== "authorized")
return {
label: "授权已撤销",
reason: "account_revoked",
canResume: false,
ready: false,
};
if (bindingsError)
return {
label: "环境状态未知",
reason: "environment_unavailable",
canResume: false,
ready: false,
};
if (!binding)
return {
label: "未绑定运行环境",
reason: "binding_missing",
canResume: false,
ready: false,
};
const blocked = binding.cleanup_pending
? 'runtime_stop_pending'
: binding.network_exit_id && binding.network_exit_health !== 'healthy'
? 'network_exit_unhealthy'
: ''
if (blocked) return { label: reasonText[blocked], reason: blocked, canResume: false, ready: false }
if (account.runtime_status === 'paused') {
const stopped = !binding.runtime_instance_id
return { label: stopped ? '资源就绪,可恢复' : '等待运行环境停止', reason: stopped ? '' : 'runtime_stop_pending', canResume: stopped, ready: false }
? "runtime_stop_pending"
: binding.network_exit_id && binding.network_exit_health !== "healthy"
? "network_exit_unhealthy"
: "";
if (blocked)
return {
label: reasonText[blocked],
reason: blocked,
canResume: false,
ready: false,
};
if (account.runtime_status === "paused") {
const stopped = !binding.runtime_instance_id;
return {
label: stopped ? "资源就绪,可恢复" : "等待运行环境停止",
reason: stopped ? "" : "runtime_stop_pending",
canResume: stopped,
ready: false,
};
}
const schedule = binding.schedule_block_reason || ''
return { label: schedule ? reasonText[schedule] || `不可调度:${schedule}` : '可调度', reason: schedule, canResume: false, ready: !schedule }
const schedule = binding.schedule_block_reason || "";
return {
label: schedule
? reasonText[schedule] || `不可调度:${schedule}`
: "可调度",
reason: schedule,
canResume: false,
ready: !schedule,
};
}
function ReadinessPill({ readiness, paused }) {
const tone = readiness.ready ? 'success' : paused && readiness.canResume ? 'warning' : 'danger'
return <StatusPill tone={tone} label={readiness.label} />
const tone = readiness.ready
? "success"
: paused && readiness.canResume
? "warning"
: "danger";
return <StatusPill tone={tone} label={readiness.label} />;
}
function AccountCreateModal({ open, onClose, onSubmit, busy, error }) {
const [form, setForm] = useState(createInitial)
const update = (key, value) => setForm(current => ({ ...current, [key]: value }))
const valid = form.name.trim() && form.platform && form.platform_account_key.trim() && form.cookies.trim()
const [form, setForm] = useState(createInitial);
const update = (key, value) =>
setForm((current) => ({ ...current, [key]: value }));
const valid =
form.name.trim() && form.platform && form.platform_account_key.trim();
async function submit(event) {
event.preventDefault()
if (!valid) return
const created = await onSubmit({
event.preventDefault();
if (!valid) return;
const data = {
name: form.name.trim(),
platform: form.platform.trim(),
platform_account_key: form.platform_account_key.trim(),
tags: form.tags.split(/[,]/).map(tag => tag.trim()).filter(Boolean),
cookies: form.cookies.trim(),
})
if (created) setForm(createInitial)
tags: form.tags
.split(/[,]/)
.map((tag) => tag.trim())
.filter(Boolean),
};
// cookies 非必填:留空代表创建后走扫码登录,凭据由后续同步链路补齐
if (form.cookies.trim()) data.cookies = form.cookies.trim();
const created = await onSubmit(data);
if (created) setForm(createInitial);
}
return (
<Modal open={open} onClose={onClose} title="创建社媒账号" labelledBy="account-create-title" size="lg"
footer={<>
<Button onClick={onClose} disabled={busy}>取消</Button>
<Button variant="primary" type="submit" form="account-create-form" busy={busy} busyText="创建中…" disabled={!valid}>创建账号</Button>
</>}>
<Modal
open={open}
onClose={onClose}
title="创建社媒账号"
labelledBy="account-create-title"
size="lg"
footer={
<>
<Button onClick={onClose} disabled={busy}>
取消
</Button>
<Button
variant="primary"
type="submit"
form="account-create-form"
busy={busy}
busyText="创建中…"
disabled={!valid}
>
创建账号
</Button>
</>
}
>
<form id="account-create-form" onSubmit={submit} noValidate>
{error ? <Alert variant="destructive" className="mb-4">{conflictMessage(error, '该平台的账号 ID 已存在;表单内容已保留。')}</Alert> : null}
{error ? (
<Alert variant="destructive" className="mb-4">
{conflictMessage(error, "该平台的账号 ID 已存在;表单内容已保留。")}
</Alert>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<Field id="account-name" label="账号名称" required helper="展示名称,支持中文">
<Input id="account-name" required maxLength={128} value={form.name} onChange={event => update('name', event.target.value)} placeholder="如:店铺一号" />
<Field
id="account-name"
label="账号名称"
required
helper="展示名称,支持中文"
>
<Input
id="account-name"
required
maxLength={128}
value={form.name}
onChange={(event) => update("name", event.target.value)}
placeholder="如:店铺一号"
/>
</Field>
<Field id="account-platform" label="平台类型" required error={form.platform === '' ? '请选择平台' : undefined} helper="账号所属平台">
<Select id="account-platform" value={form.platform} invalid={form.platform === ''} onChange={event => update('platform', event.target.value)} options={platforms} placeholder="选择平台" />
<Field
id="account-platform"
label="平台类型"
required
error={form.platform === "" ? "请选择平台" : undefined}
helper="账号所属平台"
>
<Select
id="account-platform"
value={form.platform}
invalid={form.platform === ""}
onChange={(event) => update("platform", event.target.value)}
options={platforms}
placeholder="选择平台"
/>
</Field>
<Field id="account-key" label="账号 ID" required helper="平台内唯一标识,同平台不可重复">
<Input id="account-key" required maxLength={128} value={form.platform_account_key} onChange={event => update('platform_account_key', event.target.value)} />
<Field
id="account-key"
label="账号 ID"
required
helper="平台内唯一标识,同平台不可重复"
>
<Input
id="account-key"
required
maxLength={128}
value={form.platform_account_key}
onChange={(event) =>
update("platform_account_key", event.target.value)
}
/>
</Field>
<Field id="account-tags" label="TAGS" helper="多个标签用逗号分隔">
<Input id="account-tags" value={form.tags} onChange={event => update('tags', event.target.value)} placeholder="如:主账号,直播" />
<Input
id="account-tags"
value={form.tags}
onChange={(event) => update("tags", event.target.value)}
placeholder="如:主账号,直播"
/>
</Field>
<Field id="account-cookies" label="Cookies" required className="sm:col-span-2" helper="仅支持浏览器 Cookie Header 格式,如 name=value; token=value">
<Textarea id="account-cookies" required maxLength={8192} rows={3} value={form.cookies} onChange={event => update('cookies', event.target.value)} />
<Field
id="account-cookies"
label="Cookies"
className="sm:col-span-2"
helper="可选。留空则创建后扫码登录;仅支持浏览器 Cookie Header 格式,如 name=value; token=value"
>
<Textarea
id="account-cookies"
maxLength={8192}
rows={3}
value={form.cookies}
onChange={(event) => update("cookies", event.target.value)}
placeholder="可选,如:sessionid=value; token=value"
/>
</Field>
</div>
</form>
</Modal>
)
);
}
function AccountCard({ account, binding, bindingsError }) {
const readiness = accountReadiness(account, binding, bindingsError)
const readiness = accountReadiness(account, binding, bindingsError);
return (
<Card>
<CardContent className="space-y-3">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<Link to={`/accounts/${account.id}`} className="anywhere font-semibold text-ink hover:text-primary">{account.name}</Link>
<p className="anywhere text-xs text-muted">{account.platform_account_key} · {account.platform}</p>
<Link
to={`/accounts/${account.id}`}
className="anywhere font-semibold text-ink hover:text-primary"
>
{account.name}
</Link>
<p className="anywhere text-xs text-muted">
{account.platform_account_key} · {account.platform}
</p>
</div>
<ReadinessPill readiness={readiness} paused={account.runtime_status === 'paused'} />
<ReadinessPill
readiness={readiness}
paused={account.runtime_status === "paused"}
/>
</div>
<div className="text-sm">
<span className="text-muted">授权</span>{account.authorization_status === 'authorized' ? '已授权' : '已撤销'}
<span className="text-muted"> · 账号</span>{account.runtime_status === 'active' ? '启用' : '暂停'}
<span className="text-muted">授权</span>
{account.authorization_status === "authorized" ? "已授权" : "已撤销"}
<span className="text-muted"> · 账号</span>
{account.runtime_status === "active" ? "启用" : "暂停"}
</div>
<div className="text-sm">
<span className="text-muted">固定资源</span>
{bindingsError ? '状态未知(环境数据不可用)' : (
<span className="anywhere">{binding?.name || '未绑定运行环境'} · {binding?.network_exit_id || '未绑定出口'}</span>
{bindingsError ? (
"状态未知(环境数据不可用)"
) : (
<span className="anywhere">
{binding?.name || "未绑定运行环境"} ·{" "}
{binding?.network_exit_id || "未绑定出口"}
</span>
)}
</div>
<Link to={`/accounts/${account.id}`} className="inline-flex text-sm font-medium text-primary hover:underline">查看账号 </Link>
<Link
to={`/accounts/${account.id}`}
className="inline-flex text-sm font-medium text-primary hover:underline"
>
查看账号
</Link>
</CardContent>
</Card>
)
);
}
export function AccountList() {
const [createOpen, setCreateOpen] = useState(false)
const [busy, setBusy] = useState(false)
const [createError, setCreateError] = useState(null)
const [notice, setNotice] = useState(null)
const dataProvider = useDataProvider()('default')
const { result, query } = useList({ resource: 'accounts' })
const error = query.error
const isPending = query.isPending
const accounts = result.data ?? []
const { result: bindingResult, query: bindingsQuery } = useList({ resource: 'browsers', queryOptions: { retry: false } })
const bindings = bindingResult.data ?? []
const bindingsError = bindingsQuery.error
const refetchBindings = bindingsQuery.refetch
const bindingByAccount = useMemo(() => new Map(bindings.filter(item => item.account_id).map(item => [item.account_id, item])), [bindings])
useTitle('CreatorHub · 社媒账号')
const [createOpen, setCreateOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [createError, setCreateError] = useState(null);
const [notice, setNotice] = useState(null);
const dataProvider = useDataProvider()("default");
const { result, query } = useList({ resource: "accounts" });
const error = query.error;
const isPending = query.isPending;
const accounts = result.data ?? [];
const { result: bindingResult, query: bindingsQuery } = useList({
resource: "browsers",
queryOptions: { retry: false },
});
const bindings = bindingResult.data ?? [];
const bindingsError = bindingsQuery.error;
const refetchBindings = bindingsQuery.refetch;
const bindingByAccount = useMemo(
() =>
new Map(
bindings
.filter((item) => item.account_id)
.map((item) => [item.account_id, item]),
),
[bindings],
);
useTitle("CreatorHub · 社媒账号");
async function createAccount(data) {
setBusy(true)
setCreateError(null)
setBusy(true);
setCreateError(null);
try {
await dataProvider.create({ resource: 'accounts', variables: data })
await query.refetch()
setNotice({ variant: 'success', text: '账号已创建;绑定健康出口和运行环境后方可恢复。' })
return true
await dataProvider.create({ resource: "accounts", variables: data });
await query.refetch();
setNotice({
variant: "success",
text: "账号已创建;绑定健康出口和运行环境后方可恢复。",
});
setCreateOpen(false);
return true;
} catch (reason) {
setCreateError(reason)
return false
setCreateError(reason);
return false;
} finally {
setBusy(false)
setBusy(false);
}
}
return (
<div>
<PageHeader icon="ri-account-circle-line" title="社媒账号" description="管理账号授权、暂停状态与固定运行资源">
<Button variant="primary" icon="ri-add-line" onClick={() => setCreateOpen(true)}>创建账号</Button>
<PageHeader
icon="ri-account-circle-line"
title="社媒账号"
description="管理账号授权、暂停状态与固定运行资源"
>
<Button
variant="primary"
icon="ri-add-line"
onClick={() => setCreateOpen(true)}
>
创建账号
</Button>
</PageHeader>
{notice ? <Alert variant={notice.variant} className="mb-4">{notice.text}</Alert> : null}
{bindingsError ? (
<Alert variant="destructive" className="mb-4">
环境不可用{bindingsError.status ? `${bindingsError.status}` : ''}无法读取运行环境绑定与 readiness状态暂时未知
<Button size="sm" onClick={() => refetchBindings()}>重试环境状态</Button>
{notice ? (
<Alert variant={notice.variant} className="mb-4">
{notice.text}
</Alert>
) : null}
<PageState pending={isPending} error={error} empty={accounts.length === 0} emptyText="暂无账号。创建账号后,再前往运行环境绑定健康网络出口。">
{bindingsError ? (
<Alert variant="destructive" className="mb-4">
环境不可用{bindingsError.status ? `${bindingsError.status}` : ""}
无法读取运行环境绑定与 readiness状态暂时未知
<Button size="sm" onClick={() => refetchBindings()}>
重试环境状态
</Button>
</Alert>
) : null}
<PageState
pending={isPending}
error={error}
empty={accounts.length === 0}
emptyText="暂无账号。创建账号后,再前往运行环境绑定健康网络出口。"
>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{accounts.map(account => <AccountCard key={account.id} account={account} binding={bindingByAccount.get(account.id)} bindingsError={!!bindingsError} />)}
{accounts.map((account) => (
<AccountCard
key={account.id}
account={account}
binding={bindingByAccount.get(account.id)}
bindingsError={!!bindingsError}
/>
))}
</div>
</PageState>
<AccountCreateModal open={createOpen} onClose={() => { setCreateOpen(false); setCreateError(null) }} onSubmit={createAccount} busy={busy} error={createError} />
<AccountCreateModal
open={createOpen}
onClose={() => {
setCreateOpen(false);
setCreateError(null);
}}
onSubmit={createAccount}
busy={busy}
error={createError}
/>
</div>
)
);
}
export function AccountDetail() {
const { id } = useParams()
const navigate = useNavigate()
const dataProvider = useDataProvider()('default')
const [pauseOpen, setPauseOpen] = useState(false)
const [actionBusy, setActionBusy] = useState(false)
const [notice, setNotice] = useState(null)
const [draftText, setDraftText] = useState('')
const [draftBusy, setDraftBusy] = useState(false)
const { result: account, error, isPending, query } = useOne({ resource: 'accounts', id, queryOptions: { retry: false } })
const { result: bindingResult, query: browsersQuery } = useList({ resource: 'browsers', queryOptions: { retry: false } })
const browsers = bindingResult.data ?? []
const browsersError = browsersQuery.error
const refetchBrowsers = browsersQuery.refetch
const { result: draftResult, query: draftQuery } = useList({ resource: 'drafts', filters: [{ field: 'account_id', value: id }], queryOptions: { retry: false } })
const drafts = draftResult.data ?? []
const refetchDrafts = draftQuery.refetch
const binding = browsersError ? undefined : browsers.find(item => item.account_id === id)
const readiness = account ? accountReadiness(account, binding, !!browsersError) : null
useTitle('CreatorHub · 账号详情')
const { id } = useParams();
const navigate = useNavigate();
const dataProvider = useDataProvider()("default");
const [pauseOpen, setPauseOpen] = useState(false);
const [actionBusy, setActionBusy] = useState(false);
const [notice, setNotice] = useState(null);
const [draftText, setDraftText] = useState("");
const [draftBusy, setDraftBusy] = useState(false);
const {
result: account,
error,
isPending,
query,
} = useOne({ resource: "accounts", id, queryOptions: { retry: false } });
const { result: bindingResult, query: browsersQuery } = useList({
resource: "browsers",
queryOptions: { retry: false },
});
const browsers = bindingResult.data ?? [];
const browsersError = browsersQuery.error;
const refetchBrowsers = browsersQuery.refetch;
const { result: draftResult, query: draftQuery } = useList({
resource: "drafts",
filters: [{ field: "account_id", value: id }],
queryOptions: { retry: false },
});
const drafts = draftResult.data ?? [];
const refetchDrafts = draftQuery.refetch;
const binding = browsersError
? undefined
: browsers.find((item) => item.account_id === id);
const readiness = account
? accountReadiness(account, binding, !!browsersError)
: null;
useTitle("CreatorHub · 账号详情");
async function runAction(action) {
setActionBusy(true)
setNotice(null)
setActionBusy(true);
setNotice(null);
try {
await dataProvider.accountAction(account.id, action)
await Promise.all([query.refetch(), refetchBrowsers()])
setNotice({ variant: 'success', text: action === 'pause' ? '账号已暂停;既有 hold 任务需后续逐条核验。' : '账号已恢复;既有 hold 任务没有自动恢复。' })
await dataProvider.accountAction(account.id, action);
await Promise.all([query.refetch(), refetchBrowsers()]);
setNotice({
variant: "success",
text:
action === "pause"
? "账号已暂停;既有 hold 任务需后续逐条核验。"
: "账号已恢复;既有 hold 任务没有自动恢复。",
});
} catch (reason) {
setNotice({ variant: 'destructive', text: conflictMessage(reason) })
setNotice({ variant: "destructive", text: conflictMessage(reason) });
} finally {
setActionBusy(false)
setActionBusy(false);
}
}
async function createDraft(event) {
event.preventDefault()
if (!draftText.trim() || !readiness?.ready) return
setDraftBusy(true)
setNotice(null)
event.preventDefault();
if (!draftText.trim() || !readiness?.ready) return;
setDraftBusy(true);
setNotice(null);
try {
const draft = await dataProvider.createDraft(account.id, draftText)
await refetchDrafts()
setDraftText('')
setNotice({ variant: 'success', text: `草稿版本 ${draft.version} 已创建,请进入只读快照核对。` })
const draft = await dataProvider.createDraft(account.id, draftText);
await refetchDrafts();
setDraftText("");
setNotice({
variant: "success",
text: `草稿版本 ${draft.version} 已创建,请进入只读快照核对。`,
});
} catch (reason) {
setNotice({ variant: 'destructive', text: conflictMessage(reason) })
setNotice({ variant: "destructive", text: conflictMessage(reason) });
} finally {
setDraftBusy(false)
setDraftBusy(false);
}
}
if (isPending) return <PageState pending />
if (error || !account) return <Alert variant="destructive">{error?.message || '账号不存在'}</Alert>
if (isPending) return <PageState pending />;
if (error || !account)
return (
<Alert variant="destructive">{error?.message || "账号不存在"}</Alert>
);
return (
<div>
<PageHeader title={<span className="anywhere">{account.name}</span>} description={<span className="anywhere">{account.platform_account_key} · {account.platform}</span>}>
<Button icon="ri-arrow-left-line" onClick={() => navigate('/accounts')}>返回社媒账号</Button>
<PageHeader
title={<span className="anywhere">{account.name}</span>}
description={
<span className="anywhere">
{account.platform_account_key} · {account.platform}
</span>
}
>
<Button icon="ri-arrow-left-line" onClick={() => navigate("/accounts")}>
返回社媒账号
</Button>
</PageHeader>
{notice ? <Alert variant={notice.variant} className="mb-4">{notice.text}</Alert> : null}
{notice ? (
<Alert variant={notice.variant} className="mb-4">
{notice.text}
</Alert>
) : null}
{browsersError ? (
<Alert variant="destructive" className="mb-4">
环境不可用运行环境与网络出口状态未知依赖资源状态的操作暂不可用
<Button size="sm" onClick={() => refetchBrowsers()}>重试环境状态</Button>
<Button size="sm" onClick={() => refetchBrowsers()}>
重试环境状态
</Button>
</Alert>
) : null}
<Alert variant="warning" className="mb-6">暂停会把待领取任务置为 hold恢复账号只恢复账号可用性不会自动恢复既有 hold</Alert>
<Alert variant="warning" className="mb-6">
暂停会把待领取任务置为 hold恢复账号只恢复账号可用性不会自动恢复既有
hold
</Alert>
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardContent className="space-y-4">
<h2 className="text-base font-semibold">账号状态</h2>
<ReadinessPill readiness={readiness} paused={account.runtime_status === 'paused'} />
<DetailList rows={[
['授权', account.authorization_status === 'authorized' ? '已授权' : '已撤销'],
['运行', `${account.runtime_status === 'active' ? '启用' : '暂停'} · 版本 ${account.version}`],
['TAGS', account.tags?.join('、') || '无'],
]} />
<ReadinessPill
readiness={readiness}
paused={account.runtime_status === "paused"}
/>
<DetailList
rows={[
[
"授权",
account.authorization_status === "authorized"
? "已授权"
: "已撤销",
],
[
"运行",
`${account.runtime_status === "active" ? "启用" : "暂停"} · 版本 ${account.version}`,
],
["TAGS", account.tags?.join("、") || "无"],
]}
/>
<div className="flex flex-wrap gap-3 pt-1">
<Button variant="danger" disabled={actionBusy || account.runtime_status === 'paused'} onClick={() => setPauseOpen(true)}>暂停账号</Button>
<Button variant="primary" disabled={actionBusy || !readiness.canResume} busy={actionBusy} onClick={() => runAction('resume')}>恢复账号</Button>
<Button
variant="danger"
disabled={actionBusy || account.runtime_status === "paused"}
onClick={() => setPauseOpen(true)}
>
暂停账号
</Button>
<Button
variant="primary"
disabled={actionBusy || !readiness.canResume}
busy={actionBusy}
onClick={() => runAction("resume")}
>
恢复账号
</Button>
</div>
</CardContent>
</Card>
@@ -271,21 +562,48 @@ export function AccountDetail() {
<CardContent className="space-y-4">
<h2 className="text-base font-semibold">固定资源</h2>
{browsersError ? (
<p className="text-sm text-muted">运行环境与网络出口状态未知重试成功后再执行依赖资源状态的操作</p>
<p className="text-sm text-muted">
运行环境与网络出口状态未知重试成功后再执行依赖资源状态的操作
</p>
) : binding ? (
<>
<DetailList rows={[
['运行环境', `${binding.name}${binding.alias}`],
['网络出口', binding.network_exit_id ? `${binding.network_exit_id} · ${binding.network_exit_health || '未知状态'}` : '当前机器直连'],
['绑定版本', binding.binding_version],
['不可调度原因', binding.schedule_block_reason ? reasonText[binding.schedule_block_reason] || binding.schedule_block_reason : '无'],
]} />
<Link to="/browsers" className="inline-flex text-sm font-medium text-primary hover:underline">前往运行环境</Link>
<DetailList
rows={[
["运行环境", `${binding.name}${binding.alias}`],
[
"网络出口",
binding.network_exit_id
? `${binding.network_exit_id} · ${binding.network_exit_health || "未知状态"}`
: "当前机器直连",
],
["绑定版本", binding.binding_version],
[
"不可调度原因",
binding.schedule_block_reason
? reasonText[binding.schedule_block_reason] ||
binding.schedule_block_reason
: "无",
],
]}
/>
<Link
to="/browsers"
className="inline-flex text-sm font-medium text-primary hover:underline"
>
前往运行环境
</Link>
</>
) : (
<>
<p className="text-sm text-muted">尚未绑定运行环境账号暂停且资源就绪后可在运行环境页创建绑定</p>
<Link to="/browsers" className="inline-flex text-sm font-medium text-primary hover:underline">前往运行环境</Link>
<p className="text-sm text-muted">
尚未绑定运行环境账号暂停且资源就绪后可在运行环境页创建绑定
</p>
<Link
to="/browsers"
className="inline-flex text-sm font-medium text-primary hover:underline"
>
前往运行环境
</Link>
</>
)}
</CardContent>
@@ -293,24 +611,62 @@ export function AccountDetail() {
<Card>
<CardContent className="space-y-4">
<h2 className="text-base font-semibold">文本草稿</h2>
<p className="text-sm text-muted">仅支持单用户 Phase A Mock 文本内部 ID 与版本由系统生成</p>
<p className="text-sm text-muted">
仅支持单用户 Phase A Mock 文本内部 ID 与版本由系统生成
</p>
<form onSubmit={createDraft} className="space-y-3">
<Field id="draft-content" label="草稿内容" helper={readiness?.ready ? '创建后进入只读快照核对并显式确认' : `资源未就绪:${readiness?.label || '正在加载'}`}>
<Textarea id="draft-content" rows={4} value={draftText} onChange={event => setDraftText(event.target.value)} disabled={!readiness?.ready || draftBusy} />
<Field
id="draft-content"
label="草稿内容"
helper={
readiness?.ready
? "创建后进入只读快照核对并显式确认"
: `资源未就绪:${readiness?.label || "正在加载"}`
}
>
<Textarea
id="draft-content"
rows={4}
value={draftText}
onChange={(event) => setDraftText(event.target.value)}
disabled={!readiness?.ready || draftBusy}
/>
</Field>
<Button variant="primary" type="submit" busy={draftBusy} busyText="创建中…" disabled={!readiness?.ready || !draftText.trim()}>创建草稿</Button>
<Button
variant="primary"
type="submit"
busy={draftBusy}
busyText="创建中…"
disabled={!readiness?.ready || !draftText.trim()}
>
创建草稿
</Button>
</form>
<p className="text-sm text-muted">尚无草稿时无需处理创建后请进入草稿核对</p>
<p className="text-sm text-muted">
尚无草稿时无需处理创建后请进入草稿核对
</p>
{drafts.length > 0 ? (
<div className="space-y-3">
{drafts.map(draft => (
<div key={draft.id} className="rounded-md border border-hairline p-3">
{drafts.map((draft) => (
<div
key={draft.id}
className="rounded-md border border-hairline p-3"
>
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<p className="font-semibold">草稿版本 {draft.version}</p>
<p className="anywhere mt-0.5 text-xs text-muted line-clamp-2">{draft.content}</p>
<p className="font-semibold">
草稿版本 {draft.version}
</p>
<p className="anywhere mt-0.5 text-xs text-muted line-clamp-2">
{draft.content}
</p>
</div>
<Link to={`/drafts/${draft.id}`} className="shrink-0 text-sm font-medium text-primary hover:underline">核对草稿</Link>
<Link
to={`/drafts/${draft.id}`}
className="shrink-0 text-sm font-medium text-primary hover:underline"
>
核对草稿
</Link>
</div>
</div>
))}
@@ -319,9 +675,17 @@ export function AccountDetail() {
</CardContent>
</Card>
</div>
<ConfirmDialog open={pauseOpen} onClose={() => setPauseOpen(false)} onConfirm={() => { setPauseOpen(false); runAction('pause') }}
title="暂停账号" confirmLabel="确认暂停"
body={`暂停账号 ${account.name}?待领取任务将进入 hold,恢复账号不会自动恢复既有 hold。`} />
<ConfirmDialog
open={pauseOpen}
onClose={() => setPauseOpen(false)}
onConfirm={() => {
setPauseOpen(false);
runAction("pause");
}}
title="暂停账号"
confirmLabel="确认暂停"
body={`暂停账号 ${account.name}?待领取任务将进入 hold,恢复账号不会自动恢复既有 hold。`}
/>
</div>
)
);
}
+268 -90
View File
@@ -1,134 +1,312 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Refine } from '@refinedev/core'
import { MemoryRouter, Route, Routes } from 'react-router'
import { AccountDetail, AccountList, accountReadiness } from './AccountsPage'
import { afterEach, describe, expect, it, vi } from "vitest";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Refine } from "@refinedev/core";
import { MemoryRouter, Route, Routes } from "react-router";
import { AccountDetail, AccountList, accountReadiness } from "./AccountsPage";
afterEach(() => { cleanup(); vi.restoreAllMocks() })
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const httpError = (message, status, body) => Object.assign(new Error(message), { status, body })
const httpError = (message, status, body) =>
Object.assign(new Error(message), { status, body });
const account = { id: 'account-a', name: '店铺一号', platform: 'douyin', platform_account_key: 'shop-a', tags: ['主账号'], authorization_status: 'authorized', runtime_status: 'paused', version: 1 }
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' }
const account = {
id: "account-a",
name: "店铺一号",
platform: "douyin",
platform_account_key: "shop-a",
tags: ["主账号"],
authorization_status: "authorized",
runtime_status: "paused",
version: 1,
};
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 })),
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(),
getMany: vi.fn(),
getManyReference: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
delete: vi.fn(),
deleteMany: vi.fn(),
...overrides,
}
};
}
const queryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } })
const queryClient = () =>
new QueryClient({ defaultOptions: { queries: { retry: false } } });
function renderList(dataProvider) {
return render(
<QueryClientProvider client={queryClient()}>
<Refine dataProvider={dataProvider}>
<MemoryRouter><AccountList /></MemoryRouter>
<MemoryRouter>
<AccountList />
</MemoryRouter>
</Refine>
</QueryClientProvider>,
)
);
}
function renderDetail(dataProvider) {
return render(
<QueryClientProvider client={queryClient()}>
<Refine dataProvider={dataProvider}>
<MemoryRouter initialEntries={['/accounts/account-a']}>
<Routes><Route path="/accounts/:id" element={<AccountDetail />} /></Routes>
<MemoryRouter initialEntries={["/accounts/account-a"]}>
<Routes>
<Route path="/accounts/:id" element={<AccountDetail />} />
</Routes>
</MemoryRouter>
</Refine>
</QueryClientProvider>,
)
);
}
describe('AccountList', () => {
it('offers four platforms and submits only the account creation fields', async () => {
const dataProvider = provider({ create: vi.fn().mockRejectedValue(httpError('conflict', 409, { reason_code: 'duplicate_platform_account' })) })
renderList(dataProvider)
await screen.findAllByText('店铺一号')
describe("AccountList", () => {
it("offers four platforms and submits only the account creation fields", async () => {
const dataProvider = provider({
create: vi
.fn()
.mockRejectedValue(
httpError("conflict", 409, {
reason_code: "duplicate_platform_account",
}),
),
});
renderList(dataProvider);
await screen.findAllByText("店铺一号");
const openButton = screen.getAllByRole('button', { name: '创建账号' })[0]
fireEvent.click(openButton)
const dialog = screen.getByRole('dialog')
fireEvent.click(within(dialog).getByRole('button', { name: '创建账号' }))
fireEvent.change(within(dialog).getByRole('textbox', { name: '账号名称' }), { target: { value: '新店铺' } })
fireEvent.click(within(dialog).getByRole('combobox', { name: '平台类型' }))
for (const platform of ['抖音', '小红书', '公众号', '快手']) expect(await screen.findByRole('option', { name: platform })).toBeTruthy()
fireEvent.click(screen.getByRole('option', { name: '抖音' }))
fireEvent.change(within(dialog).getByRole('textbox', { name: '账号 ID' }), { target: { value: 'shop-new' } })
fireEvent.change(within(dialog).getByRole('textbox', { name: 'TAGS' }), { target: { value: '主账号,直播' } })
fireEvent.change(within(dialog).getByRole('textbox', { name: 'Cookies' }), { target: { value: 'sessionid=value; token=second' } })
fireEvent.click(within(dialog).getByRole('button', { name: '创建账号' }))
const openButton = screen.getAllByRole("button", { name: "创建账号" })[0];
fireEvent.click(openButton);
const dialog = screen.getByRole("dialog");
fireEvent.click(within(dialog).getByRole("button", { name: "创建账号" }));
fireEvent.change(
within(dialog).getByRole("textbox", { name: "账号名称" }),
{ target: { value: "新店铺" } },
);
fireEvent.click(within(dialog).getByRole("combobox", { name: "平台类型" }));
for (const platform of ["抖音", "小红书", "公众号", "快手"])
expect(
await screen.findByRole("option", { name: platform }),
).toBeTruthy();
fireEvent.click(screen.getByRole("option", { name: "抖音" }));
fireEvent.change(within(dialog).getByRole("textbox", { name: "账号 ID" }), {
target: { value: "shop-new" },
});
fireEvent.change(within(dialog).getByRole("textbox", { name: "TAGS" }), {
target: { value: "主账号,直播" },
});
fireEvent.change(within(dialog).getByRole("textbox", { name: "Cookies" }), {
target: { value: "sessionid=value; token=second" },
});
fireEvent.click(within(dialog).getByRole("button", { name: "创建账号" }));
await waitFor(() => expect(dataProvider.create).toHaveBeenCalledWith({ resource: 'accounts', variables: {
name: '新店铺', platform: 'douyin', platform_account_key: 'shop-new', tags: ['主账号', '直播'], cookies: 'sessionid=value; token=second',
} }))
expect((await screen.findByRole('alert')).textContent).toContain('冲突(409')
expect(screen.getByRole('textbox', { name: '账号 ID' }).value).toBe('shop-new')
expect(screen.queryByLabelText(/授权类型|凭据引用/)).toBeNull()
})
await waitFor(() =>
expect(dataProvider.create).toHaveBeenCalledWith({
resource: "accounts",
variables: {
name: "新店铺",
platform: "douyin",
platform_account_key: "shop-new",
tags: ["主账号", "直播"],
cookies: "sessionid=value; token=second",
},
}),
);
expect((await screen.findByRole("alert")).textContent).toContain(
"冲突(409",
);
expect(screen.getByRole("textbox", { name: "账号 ID" }).value).toBe(
"shop-new",
);
expect(screen.queryByLabelText(/授权类型|凭据引用/)).toBeNull();
});
it('distinguishes resumable bindings from missing resources', () => {
expect(accountReadiness(account, binding)).toMatchObject({ label: '资源就绪,可恢复', canResume: true })
expect(accountReadiness(account, { ...binding, network_exit_id: '', network_exit_health: 'unchecked' })).toMatchObject({ label: '资源就绪,可恢复', canResume: true })
expect(accountReadiness(account, undefined)).toMatchObject({ label: '未绑定运行环境', canResume: false })
expect(accountReadiness(account, undefined, true)).toMatchObject({ label: '环境状态未知', canResume: false })
})
})
it("allows creating without cookies and closes the modal on success", async () => {
const dataProvider = provider({
create: vi.fn().mockResolvedValue({ data: {} }),
});
renderList(dataProvider);
await screen.findAllByText("店铺一号");
describe('AccountDetail', () => {
it('pauses explicitly and explains that hold tasks stay held', async () => {
const active = { ...account, runtime_status: 'active' }
const dataProvider = provider({ getOne: vi.fn().mockResolvedValue({ data: active }) })
renderDetail(dataProvider)
fireEvent.click(screen.getAllByRole("button", { name: "创建账号" })[0]);
const dialog = screen.getByRole("dialog");
fireEvent.change(
within(dialog).getByRole("textbox", { name: "账号名称" }),
{ target: { value: "扫码账号" } },
);
fireEvent.click(within(dialog).getByRole("combobox", { name: "平台类型" }));
fireEvent.click(await screen.findByRole("option", { name: "抖音" }));
fireEvent.change(within(dialog).getByRole("textbox", { name: "账号 ID" }), {
target: { value: "qr-login" },
});
// Cookies 留空:提交按钮应可用,且载荷不含 cookies 字段
fireEvent.click(within(dialog).getByRole("button", { name: "创建账号" }));
fireEvent.click(await screen.findByRole('button', { name: '暂停账号' }))
fireEvent.click(await screen.findByRole('button', { name: '确认暂停' }))
await waitFor(() => expect(dataProvider.accountAction).toHaveBeenCalledWith('account-a', 'pause'))
expect((await screen.findAllByText(/不会自动恢复既有 hold/)).length).toBeGreaterThan(0)
})
await waitFor(() =>
expect(dataProvider.create).toHaveBeenCalledWith({
resource: "accounts",
variables: {
name: "扫码账号",
platform: "douyin",
platform_account_key: "qr-login",
tags: [],
},
}),
);
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect((await screen.findByRole("alert")).textContent).toContain(
"账号已创建",
);
});
it('keeps environment-dependent actions fail-safe when browsers return 502', async () => {
const active = { ...account, runtime_status: 'active' }
it("distinguishes resumable bindings from missing resources", () => {
expect(accountReadiness(account, binding)).toMatchObject({
label: "资源就绪,可恢复",
canResume: true,
});
expect(
accountReadiness(account, {
...binding,
network_exit_id: "",
network_exit_health: "unchecked",
}),
).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 () => {
const active = { ...account, runtime_status: "active" };
const dataProvider = provider({
getOne: vi.fn().mockResolvedValue({ data: active }),
getList: vi.fn(({ resource }) => resource === 'browsers'
? Promise.reject(httpError('bad gateway', 502))
: Promise.resolve({ data: [], total: 0 })),
})
renderDetail(dataProvider)
});
renderDetail(dataProvider);
expect(await screen.findByText(/环境不可用/)).toBeTruthy()
expect((await screen.findAllByText(/运行环境与网络出口状态未知/)).length).toBeGreaterThan(0)
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(([call]) => call.resource === 'browsers').length).toBeGreaterThan(1))
})
fireEvent.click(await screen.findByRole("button", { name: "暂停账号" }));
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('preserves draft text when creation returns 503', async () => {
const active = { ...account, runtime_status: 'active' }
const readyBinding = { ...binding, schedule_status: 'ready', schedule_block_reason: '' }
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 }) => Promise.resolve(resource === 'browsers' ? { data: [readyBinding], total: 1 } : { data: [], total: 0 })),
createDraft: vi.fn().mockRejectedValue(httpError('unavailable', 503, { reason_code: 'runtime_missing' })),
})
renderDetail(dataProvider)
getList: vi.fn(({ resource }) =>
resource === "browsers"
? Promise.reject(httpError("bad gateway", 502))
: Promise.resolve({ data: [], total: 0 }),
),
});
renderDetail(dataProvider);
const input = await screen.findByRole('textbox', { name: '草稿内容' })
fireEvent.change(input, { target: { value: 'keep this text' } })
fireEvent.click(screen.getByRole('button', { name: '创建草稿' }))
expect(await screen.findByText(/环境不可用/)).toBeTruthy();
expect(
(await screen.findAllByText(/运行环境与网络出口状态未知/)).length,
).toBeGreaterThan(0);
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(
([call]) => call.resource === "browsers",
).length,
).toBeGreaterThan(1),
);
});
await waitFor(() => expect(dataProvider.createDraft).toHaveBeenCalledWith('account-a', 'keep this text'))
expect(screen.getByRole('textbox', { name: '草稿内容' }).value).toBe('keep this text')
expect(await screen.findByText(/503/)).toBeTruthy()
})
})
it("preserves draft text when creation returns 503", async () => {
const active = { ...account, runtime_status: "active" };
const readyBinding = {
...binding,
schedule_status: "ready",
schedule_block_reason: "",
};
const dataProvider = provider({
getOne: vi.fn().mockResolvedValue({ data: active }),
getList: vi.fn(({ resource }) =>
Promise.resolve(
resource === "browsers"
? { data: [readyBinding], total: 1 }
: { data: [], total: 0 },
),
),
createDraft: vi
.fn()
.mockRejectedValue(
httpError("unavailable", 503, { reason_code: "runtime_missing" }),
),
});
renderDetail(dataProvider);
const input = await screen.findByRole("textbox", { name: "草稿内容" });
fireEvent.change(input, { target: { value: "keep this text" } });
fireEvent.click(screen.getByRole("button", { name: "创建草稿" }));
await waitFor(() =>
expect(dataProvider.createDraft).toHaveBeenCalledWith(
"account-a",
"keep this text",
),
);
expect(screen.getByRole("textbox", { name: "草稿内容" }).value).toBe(
"keep this text",
);
expect(await screen.findByText(/503/)).toBeTruthy();
});
});