fix(accounts): restore gateway selection and use creation modal
This commit is contained in:
@@ -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';
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)回落到父级菜单项。
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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\)/);
|
||||
|
||||
Reference in New Issue
Block a user