fix(accounts): restore gateway selection and use creation modal

This commit is contained in:
2026-10-06 10:28:58 +08:00
parent 58e9a73cc4
commit 94df412579
11 changed files with 353 additions and 70 deletions
+2
View File
@@ -41,6 +41,8 @@
环境先行:创建时只创建抖音浏览器环境,不填写昵称、UID 或 Cookie,不创建占位账号。未登录环境在账号列表独立显示。首次浏览器身份核验成功后,同一事务创建/关联账号并同步真实 UID、昵称、头像、抖音号与 secUID;同一 UID 只能绑定一个账号,已有绑定禁止换绑或自动覆盖。后续同 UID 核验更新平台资料,不覆盖本地备注和业务设置。浏览器 profile_id 与指纹 seed 在环境创建时确定,账号绑定不得改变它们;旧环境保持原 profile_id 和 seed,不重建浏览器或清空 Cookie。指纹 seed 由环境独立序列分配,不再依赖账号 ID。
账号创建交互:在“我的账号”列表通过 Modal 创建,网关必选,浏览器指纹为默认折叠的可选配置;创建成功关闭弹窗并刷新列表,不保留独立新增页面。未登录环境及空昵称仅在前端显示“待登录”,不将此文案写入数据库。
前端框架:Umi Max 4.7 + React 19;
组件库:antd 6.6.5 + @ant-design/pro-components 3.x(beta 线)+ @ant-design/icons;仅使用 antd/pro 默认组件原样实现,禁止自定义封装与样式魔改;组件不满足业务时改交互逻辑适配组件;
@@ -48,14 +48,14 @@ func TestEnvironmentCreationDoesNotCreatePlatformAccount(t *testing.T) {
t.Cleanup(func() { _ = db.Close() })
app := fiber.New()
registerEnvironmentLoginRoutes(app, cs, hs)
response := do(app, http.MethodPost, "/api/creator/environments", `{}`)
response := do(app, http.MethodPost, "/api/creator/environments", `{"gateway":"gw-main"}`)
if response.Code != http.StatusNotFound {
t.Fatalf("missing gateway: %d %s", response.Code, response.Body.String())
}
if _, err := hs.CreateGateway(ctx, "gw-main", "http://127.0.0.1:28187", "test-token-environment"); err != nil {
t.Fatal(err)
}
response = do(app, http.MethodPost, "/api/creator/environments", `{"fingerprint":{"platform":"windows","lang":"zh-CN","timezone":"Asia/Shanghai"}}`)
response = do(app, http.MethodPost, "/api/creator/environments", `{"gateway":"gw-main","fingerprint":{"platform":"windows","lang":"zh-CN","timezone":"Asia/Shanghai"}}`)
if response.Code != http.StatusCreated {
t.Fatalf("create: %d %s", response.Code, response.Body.String())
}
@@ -74,7 +74,7 @@ func TestEnvironmentCreationDoesNotCreatePlatformAccount(t *testing.T) {
if err != nil || len(pending) != 1 {
t.Fatalf("pending: %v %v", pending, err)
}
for _, body := range []string{`{"name":"手填昵称"}`, `{"platform_account_key":"123"}`, `{"fingerprint":{"platform":"android"}}`, `{"fingerprint":{"seed":42}}`, `{"fingerprint":{"proxy_server":"http://invalid:8080"}}`} {
for _, body := range []string{`{"gateway":"gw-main","name":"手填昵称"}`, `{"gateway":"gw-main","platform_account_key":"123"}`, `{"gateway":"gw-main","fingerprint":{"platform":"android"}}`, `{"gateway":"gw-main","fingerprint":{"seed":42}}`, `{"gateway":"gw-main","fingerprint":{"proxy_server":"http://invalid:8080"}}`} {
if response := do(app, http.MethodPost, "/api/creator/environments", body); response.Code != http.StatusBadRequest {
t.Fatalf("invalid input accepted: %s %d %s", body, response.Code, response.Body.String())
}
@@ -0,0 +1,93 @@
package api
import (
"context"
"encoding/json"
"net/http"
"os"
"testing"
"git.ipao.vip/rogee/creator-hub/internal/creator"
hub "git.ipao.vip/rogee/creator-hub/internal/environment"
"github.com/gofiber/fiber/v3"
)
func TestCreatePendingEnvironmentUsesSelectedGateway(t *testing.T) {
raw := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if raw == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL")
}
ctx := context.Background()
dbURL := isolatedControlPlaneDatabaseURL(t, raw)
hs, err := hub.Open(ctx, dbURL)
if err != nil {
t.Fatal(err)
}
defer hs.Close()
cs, err := creator.Open(ctx, dbURL)
if err != nil {
t.Fatal(err)
}
defer cs.Close()
for _, name := range []string{"gw-a", "gw-b"} {
if _, err := hs.CreateGateway(ctx, name, "http://"+name+":9100", "test-gateway-token-"+name); err != nil {
t.Fatal(err)
}
}
app := fiber.New()
registerEnvironmentLoginRoutes(app, cs, hs)
for _, input := range []struct {
body string
status int
}{
{`{"gateway":"gw-b","fingerprint":{"timezone":"Asia/Shanghai"}}`, http.StatusCreated},
{`{"gateway":"gw-a"}`, http.StatusCreated},
{`{}`, http.StatusBadRequest},
{`{"gateway":""}`, http.StatusBadRequest},
{`{"gateway":"missing"}`, http.StatusNotFound},
{`{"gateway":"bad gateway"}`, http.StatusBadRequest},
{`{"gateway":"gw-a","fingerprint":{"seed":123}}`, http.StatusBadRequest},
{`{"gateway":"gw-a","fingerprint":{"proxy_server":"http://example.com"}}`, http.StatusBadRequest},
{`{"gateway":"gw-a","fingerprint":{"hardware_concurrency":-1}}`, http.StatusBadRequest},
{`{`, http.StatusBadRequest},
} {
t.Run(input.body, func(t *testing.T) {
response := do(app, http.MethodPost, "/api/creator/environments", input.body)
if response.Code != input.status {
t.Fatalf("status=%d want=%d: %s", response.Code, input.status, response.Body.String())
}
if input.status != http.StatusCreated {
return
}
var env hub.EnvironmentContext
if err := json.Unmarshal(response.Body.Bytes(), &env); err != nil {
t.Fatal(err)
}
var payload struct {
Gateway string `json:"gateway"`
Fingerprint hub.Fingerprint `json:"fingerprint"`
}
if err := json.Unmarshal([]byte(input.body), &payload); err != nil {
t.Fatal(err)
}
if env.Gateway != payload.Gateway || env.Alias == "" || env.ProfileID != env.Alias || env.Fingerprint.Seed == 0 || env.AccountID != "" || env.Fingerprint.Timezone != payload.Fingerprint.Timezone {
t.Fatalf("invalid pending environment: %#v", env)
}
stored, err := hs.GetEnvironmentContext(ctx, env.Alias)
if err != nil || stored.Gateway != payload.Gateway {
t.Fatalf("selected gateway not stored: %#v %v", stored, err)
}
})
}
pending, err := hs.ListPendingEnvironments(ctx)
if err != nil || len(pending) != 2 {
t.Fatalf("invalid pending list: %#v %v", pending, err)
}
if pending[0].Fingerprint.Seed == pending[1].Fingerprint.Seed {
t.Fatal("environments must have independent fingerprint seeds")
}
profiles, err := cs.ListAccountProfiles(ctx)
if err != nil || len(profiles) != 0 {
t.Fatalf("creation wrote placeholder account: %#v %v", profiles, err)
}
}
@@ -22,20 +22,18 @@ func registerEnvironmentLoginRoutes(app *fiber.App, store *creator.Store, hubSto
})
app.Post("/api/creator/environments", func(c fiber.Ctx) error {
var input struct {
Gateway string `json:"gateway"`
Fingerprint hub.Fingerprint `json:"fingerprint"`
}
if err := decodePhaseA(c, &input); err != nil {
return phaseAError(c, err)
}
gateway, err := soleGateway(c.Context(), hubStore)
env, err := hubStore.CreateStandaloneEnv(c.Context(), input.Gateway, input.Fingerprint)
if err != nil {
logrus.WithError(err).WithFields(logrus.Fields{"event_type": "login_environment_create_failed", "gateway": input.Gateway}).Error("browser environment creation failed")
return hubError(c, err)
}
env, err := hubStore.CreateStandaloneEnv(c.Context(), gateway.Name, input.Fingerprint)
if err != nil {
return hubError(c, err)
}
logrus.WithFields(logrus.Fields{"event_type": "login_environment_created", "alias": env.Alias, "gateway": gateway.Name}).Info("browser environment created without platform account")
logrus.WithFields(logrus.Fields{"event_type": "login_environment_created", "alias": env.Alias, "gateway": env.Gateway}).Info("browser environment created without platform account")
return c.Status(fiber.StatusCreated).JSON(env)
})
app.Post("/api/creator/environments/:alias/verify", func(c fiber.Ctx) error {
@@ -0,0 +1,82 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Collapse, Form, Modal, Select, Typography } from 'antd';
import { creatorCreate, creatorGet } from '@/services/api';
import { conflictMessage } from '@/utils/helpers';
import FingerprintFields, { fingerprintPayload, type FingerprintValues } from '@/components/accounts/FingerprintFields';
interface Values {
gateway: string;
fingerprint?: FingerprintValues;
}
export default function AccountCreateModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
const [form] = Form.useForm<Values>();
const [gateways, setGateways] = useState<{ name: string }[]>([]);
const [loading, setLoading] = useState(true);
const [gatewayError, setGatewayError] = useState<unknown>(null);
const [gatewayAttempt, setGatewayAttempt] = useState(0);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<unknown>(null);
useEffect(() => {
let active = true;
async function load() {
setLoading(true);
setGatewayError(null);
try {
const gateways = await creatorGet('/gateways');
if (active) setGateways(gateways);
} catch (error) {
if (active) setGatewayError(error);
} finally {
if (active) setLoading(false);
}
}
void load();
return () => { active = false; };
}, [gatewayAttempt]);
async function onFinish(values: Values) {
setBusy(true);
setError(null);
try {
const environment = await creatorCreate('/creator/environments', {
gateway: values.gateway,
fingerprint: fingerprintPayload(values.fingerprint),
});
if (!environment?.alias) throw new Error('创建环境未返回环境标识');
} catch (error) {
setError(error);
return;
} finally {
setBusy(false);
}
onSuccess();
}
return (
<Modal open title="新增账号" onCancel={() => { if (!busy) onClose(); }}
onOk={() => form.submit()} confirmLoading={busy} okText="创建" cancelText="取消"
okButtonProps={{ disabled: loading || Boolean(gatewayError) || gateways.length === 0 }}
cancelButtonProps={{ disabled: busy }}
closable={!busy} keyboard={!busy} mask={{ closable: !busy }}>
{gatewayError ? <Alert type="error" showIcon title={conflictMessage(gatewayError, '网关加载失败')}
action={<Button size="small" onClick={() => setGatewayAttempt(attempt => attempt + 1)} loading={loading}>重试</Button>} /> : null}
{!loading && !gatewayError && gateways.length === 0 ? <Alert type="info" showIcon title="暂无网关,请添加网关后再创建账号。" /> : null}
{error ? <Alert type="error" showIcon title={conflictMessage(error, '创建环境失败,配置已保留。')} /> : null}
<Form form={form} layout="vertical" onFinish={onFinish} disabled={busy}>
<Typography.Paragraph type="secondary">
创建抖音浏览器环境后扫码登录。昵称、UID 和头像自动同步;登录前不创建账号记录。
</Typography.Paragraph>
<Form.Item name="gateway" label="网关" rules={[{ required: true, message: '请选择网关' }]}>
<Select loading={loading} disabled={busy || loading || Boolean(gatewayError)} placeholder="请选择网关"
options={gateways.map(gateway => ({ value: gateway.name, label: gateway.name }))} />
</Form.Item>
<Collapse items={[{ key: 'fingerprint', label: '浏览器指纹(可选)', children: <>
<Typography.Paragraph type="secondary">留空使用默认配置。登录绑定不会改变浏览器指纹。</Typography.Paragraph>
<FingerprintFields />
</> }]} />
</Form>
</Modal>
);
}
@@ -12,6 +12,7 @@ import { remove, creatorUpdate, creatorGet, creatorAction } from '@/services/api
import { accountReadiness, conflictMessage, dateTime } from '@/utils/helpers';
import { fixedLeft, fixedRight, tablePagination, tableScroll, useOverflowGrid } from '@/utils/table';
import AccountLoginModal from '@/components/accounts/AccountLoginModal';
import AccountCreateModal from '@/components/accounts/AccountCreateModal';
// 仅用 antd 默认组件:Table/Tag/Modal/Popconfirm/Select(tags)。
interface EnvironmentView {
@@ -52,7 +53,8 @@ function formatCount(value?: number | null): string {
export default function AccountManagementList() {
const [rows, setRows] = useState<Row[]>([]);
const [environments, setEnvironments] = useState<{ alias: string; runtime_id?: string }[]>([]);
const [createOpen, setCreateOpen] = useState(false);
const [environments, setEnvironments] = useState<{ alias: string; gateway: string; runtime_id?: string }[]>([]);
const [environmentTarget, setEnvironmentTarget] = useState<string | null>(null);
const [pending, setPending] = useState(true);
const [error, setError] = useState<any>(null);
@@ -144,7 +146,7 @@ export default function AccountManagementList() {
...fixedLeft<Row>({}),
render: (_, account) => (
<div>
<Space><Avatar size="small" src={account.avatar_url} /><Typography.Link onClick={() => history.push(`/accounts/${account.id}`)}>{account.name}</Typography.Link></Space>
<Space><Avatar size="small" src={account.avatar_url} /><Typography.Link onClick={() => history.push(`/accounts/${account.id}`)}>{account.name || '待登录'}</Typography.Link></Space>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }} ellipsis={{ tooltip: true }}>
{account.platform_account_key}
</Typography.Paragraph>
@@ -252,7 +254,7 @@ export default function AccountManagementList() {
编辑账号
</Button>
<Popconfirm
title={`确定删除自有账号“${account.name}”吗?相关专属资源也会被清理。`}
title={`确定删除自有账号“${account.name || '待登录'}”吗?相关专属资源也会被清理。`}
onConfirm={() => deleteAccount(account)}
disabled={actionBusy === account.id}
>
@@ -270,11 +272,13 @@ export default function AccountManagementList() {
<Typography.Text type="secondary">共 {rows.length} 个自有账号</Typography.Text>
<Space>
<Button icon={<ReloadOutlined />} onClick={load} disabled={pending} aria-label="刷新" />
<Button type="primary" icon={<PlusOutlined />} onClick={() => history.push('/accounts/new')}>
创建浏览器环境
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
新增账号
</Button>
</Space>
</Flex>
{createOpen ? <AccountCreateModal onClose={() => setCreateOpen(false)}
onSuccess={() => { setCreateOpen(false); void load(); }} /> : null}
{environmentTarget ? (
<AccountLoginModal accountID={environmentTarget} accountName="待登录环境" resource="environments"
onClose={() => setEnvironmentTarget(null)} onSuccess={() => { setEnvironmentTarget(null); void load(); }} />
@@ -282,6 +286,8 @@ export default function AccountManagementList() {
{environments.length > 0 ? (
<Card title="待登录环境">
<Table rowKey="alias" dataSource={environments} pagination={tablePagination()} sticky columns={[
{ title: '账号', render: () => <Typography.Text>待登录</Typography.Text> },
{ title: '网关', dataIndex: 'gateway' },
{ title: '环境标识', dataIndex: 'alias', render: (alias: string) => <Typography.Text copyable>{alias}</Typography.Text> },
{ title: '状态', render: () => <Tag>待登录</Tag> },
{ title: '操作', render: (_, env) => <Button type="link" onClick={() => setEnvironmentTarget(env.alias)}>登录并同步账号</Button> },
@@ -303,7 +309,7 @@ export default function AccountManagementList() {
/>
<Modal
open={Boolean(tagTarget)}
title={`编辑标签 · ${tagTarget?.name || '账号'}`}
title={`编辑标签 · ${tagTarget?.name || '待登录'}`}
onCancel={() => {
if (!tagBusy) setTagTarget(null);
}}
@@ -318,7 +324,7 @@ export default function AccountManagementList() {
</Form>
</Modal>
{loginTarget ? (
<AccountLoginModal accountID={loginTarget.id} accountName={loginTarget.name}
<AccountLoginModal accountID={loginTarget.id} accountName={loginTarget.name || '待登录'}
onClose={() => setLoginTarget(null)} onSuccess={() => { void load(); }} />
) : null}
</Card>
@@ -1,5 +1,5 @@
// 指纹浏览器环境表单字段 + 提交载荷构建:创建页(new.tsx)与编辑页(edit.tsx)共用。
// 留空项不提交,后端使用浏览器默认值;seed 由系统按账号派生并保证唯一,代理由网络出口统一管理,均不在表单内。
// 指纹浏览器环境表单字段 + 提交载荷构建:创建弹窗与账号编辑页共用。
// 留空项不提交,后端使用浏览器默认值;seed 由环境独立序列分配,代理由网络出口统一管理,均不在表单内。
// 字段契约来源:internal/environment/fingerprint.go(fingerprint-chromium 启动参数)。
import { Col, Form, Input, InputNumber, Row, Select } from 'antd';
-45
View File
@@ -1,45 +0,0 @@
import { useState } from 'react';
import { history } from '@umijs/max';
import { Alert, Button, Card, Collapse, Flex, Form, Typography } from 'antd';
import { creatorCreate } from '@/services/api';
import { conflictMessage } from '@/utils/helpers';
import FingerprintFields, { fingerprintPayload, type FingerprintValues } from '@/components/accounts/FingerprintFields';
export default function Page() {
const [form] = Form.useForm<{ fingerprint?: FingerprintValues }>();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<any>(null);
async function onFinish(values: { fingerprint?: FingerprintValues }) {
setBusy(true);
setError(null);
try {
const environment = await creatorCreate('/creator/environments', { fingerprint: fingerprintPayload(values.fingerprint) });
if (!environment?.alias) throw new Error('创建环境未返回环境标识');
history.replace('/accounts');
} catch (error) {
setError(error);
} finally {
setBusy(false);
}
}
return (
<Card variant="outlined">
{error ? <Alert type="error" showIcon title={conflictMessage(error, '创建环境失败,配置已保留。')} /> : null}
<Form form={form} layout="vertical" onFinish={onFinish} style={{ maxWidth: 720 }}>
<Typography.Paragraph type="secondary">
创建抖音浏览器环境后扫码登录。昵称、UID、头像和抖音号从登录结果同步,无需填写;登录成功前不会创建账号。
</Typography.Paragraph>
<Collapse style={{ marginBottom: 24 }} items={[{
key: 'fingerprint', label: '浏览器配置(可选)', children: <>
<Typography.Paragraph type="secondary">留空使用浏览器默认值。指纹标识由系统生成,登录绑定不会改变浏览器配置。</Typography.Paragraph>
<FingerprintFields />
</>,
}]} />
<Form.Item><Flex gap={8}>
<Button onClick={() => history.push('/accounts')}>取消</Button>
<Button type="primary" htmlType="submit" loading={busy}>创建浏览器环境</Button>
</Flex></Form.Item>
</Form>
</Card>
);
}
+1 -2
View File
@@ -19,7 +19,6 @@ const rules: Array<{ test: (path: string) => boolean; title: string; subtitle: s
{ test: (p) => p === '/accounts', title: '我的账号', subtitle: '创建和管理自己维护的账号。' },
{ test: (p) => p === '/accounts/monitoring', title: '监控账号', subtitle: '管理需要持续跟踪的竞品账号及采集状态。' },
{ test: (p) => /^\/accounts\/monitoring\/.+/.test(p), title: '监控账号详情', subtitle: '查看监控账号画像与作品列表,点击作品可追溯指标统计。' },
{ test: (p) => p === '/accounts/new', title: '创建浏览器环境', subtitle: '登录成功后自动同步抖音昵称、UID、头像和抖音号。' },
{ test: (p) => /\/edit$/.test(p) && p.startsWith('/accounts/'), title: '编辑社媒账号', subtitle: '维护账号资料、登录核验与自动响应策略。' },
{ test: (p) => p.startsWith('/accounts/'), title: '账号详情', subtitle: '查看账号状态、登录身份与运行环境绑定。' },
{ test: (p) => p === '/creator/competitors', title: '竞品分析', subtitle: '查看竞品作品与指标,分享链接导入请前往账号导入。' },
@@ -75,4 +74,4 @@ export function menuSelectedKey(pathname: string): string {
// 侧栏选中态:ProLayout 默认按路径前缀高亮(/accounts 是 /accounts/import 的前缀,两项会同时高亮)。
// 这里改为受控 selectedKeys:从完整路径逐段向前回退,仅精确命中菜单叶路径;
// 详情页(如 /accounts/123、/accounts/new)回落到父级菜单项。
// 详情页(如 /accounts/123)回落到父级菜单项。
+148
View File
@@ -0,0 +1,148 @@
const assert = require('node:assert/strict');
const { readFileSync, existsSync } = require('node:fs');
const { test } = require('node:test');
const { Module, createRequire } = require('node:module');
const { resolve } = require('node:path');
const ts = require('typescript');
const requireWeb = createRequire(resolve(__dirname, '../package.json'));
const file = resolve(__dirname, '../src/components/accounts/AccountCreateModal.tsx');
function setup({ gateways = [{ name: 'gw-a' }, { name: 'gw-b' }], loadError, result = { alias: 'env-new' }, createError } = {}) {
const states = [], effects = [], calls = [], successes = [], closes = [];
let cursor = 0, effectCursor = 0;
const marker = name => Object.assign(() => null, { displayName: name });
const Form = Object.assign(marker('Form'), { Item: marker('Form.Item'), useForm: () => [{ submit() { calls.push('submit'); } }] });
const antd = { Form };
for (const name of ['Modal', 'Alert', 'Button', 'Collapse', 'Select']) antd[name] = marker(name);
antd.Typography = { Paragraph: marker('Paragraph'), Link: marker('Link') };
const fingerprint = new Module(resolve(__dirname, '../src/components/accounts/FingerprintFields.tsx'), module);
fingerprint.require = id => id === 'antd' ? antd : requireWeb(id);
fingerprint._compile(ts.transpileModule(readFileSync(fingerprint.id, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX } }).outputText, fingerprint.id);
const loaded = new Module(file, module);
loaded.require = id => {
if (id === 'react') return {
useState(value) { const index = cursor++; if (!(index in states)) states[index] = value; return [states[index], next => { states[index] = typeof next === 'function' ? next(states[index]) : next; }]; },
useEffect(effect, deps) {
const index = effectCursor++;
if (!effects[index] || deps.some((value, i) => value !== effects[index].deps[i])) {
effects[index]?.cleanup();
effects[index] = { deps, effect };
}
},
};
if (id === 'antd') return antd;
if (id === '@/services/api') return {
async creatorGet(path) { calls.push({ load: path }); if (loadError) throw loadError; return gateways; },
async creatorCreate(path, data) { calls.push({ path, data }); if (createError) throw createError; return result; },
};
if (id === '@/utils/helpers') return { conflictMessage: (error, fallback) => error.message || fallback };
if (id === '@/components/accounts/FingerprintFields') return fingerprint.exports;
return requireWeb(id);
};
loaded._compile(ts.transpileModule(readFileSync(file, 'utf8'), { fileName: file, compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX, inlineSourceMap: true, inlineSources: true } }).outputText, file);
const render = () => {
cursor = 0; effectCursor = 0;
const node = loaded.exports.default({ onClose: () => closes.push(true), onSuccess: () => successes.push(true) });
for (const effect of effects) if (!effect.cleanup) effect.cleanup = effect.effect();
return node;
};
let tree = render();
const cleanup = () => effects.forEach(effect => effect.cleanup());
const find = (name, node = tree) => {
if (!node || typeof node !== 'object') return;
if (node.type?.displayName === name) return node;
for (const child of [node.props?.children].flat(Infinity)) { if (!child) continue; const found = find(name, child); if (found) return found; }
};
return { calls, successes, closes, cleanup, find, render() { tree = render(); return tree; } };
}
const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve(); };
test('网关必选、指纹默认折叠;创建只提交网关和可选指纹', async () => {
const s = setup(); await flush(); s.render();
assert.deepEqual(s.calls[0], { load: '/gateways' });
assert.deepEqual(s.find('Select').props.options.map(x => x.value), ['gw-a', 'gw-b']);
assert.equal(s.find('Form.Item').props.name, 'gateway');
assert.ok(s.find('Form.Item').props.rules.some(rule => rule.required));
assert.equal(s.find('Collapse').props.defaultActiveKey, undefined);
s.find('Modal').props.onOk();
assert.equal(s.calls.at(-1), 'submit');
await s.find('Form').props.onFinish({ gateway: 'gw-b', fingerprint: { timezone: ' Asia/Shanghai ', disable_spoofing: ['font', 'audio'] } });
assert.deepEqual(s.calls.at(-1), { path: '/creator/environments', data: { gateway: 'gw-b', fingerprint: { timezone: 'Asia/Shanghai', disable_spoofing: 'font,audio' } } });
assert.equal(s.successes.length, 1);
assert.equal(s.closes.length, 0);
});
test('不填写指纹时使用默认配置;提交期间不能关闭', async () => {
const s = setup(); await flush(); s.render();
const save = s.find('Form').props.onFinish({ gateway: 'gw-a' });
const busy = s.render();
assert.equal(busy.props.confirmLoading, true);
assert.equal(busy.props.closable, false);
assert.equal(busy.props.keyboard, false);
assert.equal(busy.props.mask.closable, false);
assert.equal(busy.props.cancelButtonProps.disabled, true);
assert.equal(s.find('Select').props.disabled, true);
busy.props.onCancel();
assert.equal(s.closes.length, 0);
await save;
assert.deepEqual(s.calls.at(-1).data, { gateway: 'gw-a', fingerprint: {} });
});
test('创建失败显示原因,不关闭弹窗也不触发列表刷新', async () => {
const s = setup({ createError: new Error('网关不存在') }); await flush(); s.render();
await s.find('Form').props.onFinish({ gateway: 'gw-a' });
s.render();
assert.equal(s.find('Alert').props.title, '网关不存在');
assert.equal(s.successes.length, 0);
assert.equal(s.closes.length, 0);
assert.equal(s.find('Modal').props.confirmLoading, false);
s.find('Modal').props.onCancel();
assert.equal(s.closes.length, 1);
});
test('缺失环境标识不能当作创建成功', async () => {
const s = setup({ result: {} }); await flush(); s.render();
await s.find('Form').props.onFinish({ gateway: 'gw-a' }); s.render();
assert.match(s.find('Alert').props.title, /环境标识/);
assert.equal(s.successes.length, 0);
});
test('网关列表加载失败显示错误并允许重试,不冒充空列表', async () => {
const s = setup({ loadError: new Error('网关加载失败') }); await flush(); s.render();
const alert = s.find('Alert');
assert.equal(alert.props.type, 'error');
assert.match(alert.props.title, /网关加载失败/);
alert.props.action.props.onClick(); s.render(); await flush(); s.render();
assert.equal(s.calls.length, 2);
assert.equal(s.find('Modal').props.okButtonProps.disabled, true);
});
test('无网关时提示配置网关且不能创建;卸载后忽略晚到的加载结果', async () => {
const s = setup({ gateways: [] }); await flush(); s.render();
assert.equal(s.find('Alert').props.type, 'info');
assert.equal(s.find('Modal').props.okButtonProps.disabled, true);
const pending = setup(); pending.cleanup(); await flush();
assert.equal(pending.render().props.okButtonProps.disabled, true);
});
test('账号列表新增打开弹窗,创建成功关闭并刷新;独立创建页面已移除', () => {
const list = readFileSync(resolve(__dirname, '../src/components/accounts/AccountManagementList.tsx'), 'utf8');
assert.match(list, /onClick=\{\(\) => setCreateOpen\(true\)\}/);
assert.match(list, /onSuccess=\{\(\) => \{ setCreateOpen\(false\); void load\(\); \}\}/);
assert.doesNotMatch(list, /\/accounts\/new/);
assert.equal(existsSync(resolve(__dirname, '../src/pages/accounts/new.tsx')), false);
const source = ts.createSourceFile('AccountManagementList.tsx', list, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
let callback;
function visit(node) {
if (ts.isJsxSelfClosingElement(node) && node.tagName.getText(source) === 'AccountCreateModal') {
callback = node.attributes.properties.find(prop => prop.name?.getText(source) === 'onSuccess').initializer.expression.getText(source);
}
ts.forEachChild(node, visit);
}
visit(source);
let open = true, refreshes = 0;
const success = new Function('setCreateOpen', 'load', `return (${callback});`)(value => { open = value; }, () => { refreshes++; });
success();
assert.equal(open, false);
assert.equal(refreshes, 1);
});
+5 -5
View File
@@ -16,7 +16,7 @@ function loadSource(relativePath) {
const editPage = loadSource('pages/accounts/$id/edit.tsx');
const detailPage = loadSource('pages/accounts/$id/index.tsx');
const fingerprintFields = loadSource('components/accounts/FingerprintFields.tsx');
const createPage = loadSource('pages/accounts/new.tsx');
const createPage = loadSource('components/accounts/AccountCreateModal.tsx');
test('账号资料表单按三行分组:登录用户名/更新密码/业务状态、实名三项、账号备注', () => {
// 第一行:登录用户名、更新密码、业务状态在同一个 Row 中按序出现
@@ -82,9 +82,9 @@ for (const [pageName, page] of [['详情页', detailPage], ['编辑页', editPag
});
}
test('账号创建成功后回到账号列表,不自动进入编辑页', () => {
assert.match(createPage, /history\.replace\('\/accounts'\)/);
assert.doesNotMatch(createPage, /history\.replace\(`\/accounts\/\$\{encodeURIComponent\(id\)\}\/edit`\)/);
test('账号创建成功通知列表,不跳转页面', () => {
assert.match(createPage, /onSuccess\(\)/);
assert.doesNotMatch(createPage, /history\./);
});
test('资料保存不承载独立登录弹窗的处理中状态', () => {
@@ -104,7 +104,7 @@ test('创建账号不填写 UID,UID 仅在详情和编辑页只读显示', ()
assert.match(detailPage, /label="UID"/);
});
test('创建页与编辑页共用同一指纹字段组件', () => {
test('创建弹窗与编辑页共用同一指纹字段组件', () => {
assert.ok(createPage.includes("from '@/components/accounts/FingerprintFields'"));
assert.ok(createPage.includes('<FingerprintFields />'));
assert.match(createPage, /fingerprintPayload\(values\.fingerprint\)/);