账号状态
-
-
+
+
-
-
+
+
@@ -271,21 +562,48 @@ export function AccountDetail() {
固定资源
{browsersError ? (
- 运行环境与网络出口状态未知;重试成功后再执行依赖资源状态的操作。
+
+ 运行环境与网络出口状态未知;重试成功后再执行依赖资源状态的操作。
+
) : binding ? (
<>
-
- 前往运行环境
+
+
+ 前往运行环境
+
>
) : (
<>
- 尚未绑定运行环境。账号暂停且资源就绪后,可在运行环境页创建绑定。
- 前往运行环境
+
+ 尚未绑定运行环境。账号暂停且资源就绪后,可在运行环境页创建绑定。
+
+
+ 前往运行环境
+
>
)}
@@ -293,24 +611,62 @@ export function AccountDetail() {
文本草稿
- 仅支持单用户 Phase A Mock 文本;内部 ID 与版本由系统生成。
+
+ 仅支持单用户 Phase A Mock 文本;内部 ID 与版本由系统生成。
+
- 尚无草稿时无需处理;创建后请进入草稿核对。
+
+ 尚无草稿时无需处理;创建后请进入草稿核对。
+
{drafts.length > 0 ? (
- {drafts.map(draft => (
-
+ {drafts.map((draft) => (
+
-
草稿版本 {draft.version}
-
{draft.content}
+
+ 草稿版本 {draft.version}
+
+
+ {draft.content}
+
-
核对草稿
+
+ 核对草稿
+
))}
@@ -319,9 +675,17 @@ export function AccountDetail() {
-
setPauseOpen(false)} onConfirm={() => { setPauseOpen(false); runAction('pause') }}
- title="暂停账号" confirmLabel="确认暂停"
- body={`暂停账号 ${account.name}?待领取任务将进入 hold,恢复账号不会自动恢复既有 hold。`} />
+ setPauseOpen(false)}
+ onConfirm={() => {
+ setPauseOpen(false);
+ runAction("pause");
+ }}
+ title="暂停账号"
+ confirmLabel="确认暂停"
+ body={`暂停账号 ${account.name}?待领取任务将进入 hold,恢复账号不会自动恢复既有 hold。`}
+ />
- )
+ );
}
diff --git a/web/src/AccountsPage.test.jsx b/web/src/AccountsPage.test.jsx
index 04a1a40..3483a15 100644
--- a/web/src/AccountsPage.test.jsx
+++ b/web/src/AccountsPage.test.jsx
@@ -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(
-
+
+
+
,
- )
+ );
}
function renderDetail(dataProvider) {
return render(
-
- } />
+
+
+ } />
+
,
- )
+ );
}
-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();
+ });
+});