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:
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user