HH-847 / HH-799: add draft review and idempotent enqueue (#24)

This commit is contained in:
2026-08-31 09:09:47 +08:00
parent 14789e9401
commit 59c30d887c
12 changed files with 989 additions and 53 deletions
+28
View File
@@ -188,8 +188,11 @@ export function AccountDetail() {
const dataProvider = useDataProvider()
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState(null)
const [draftContent, setDraftContent] = useState('')
const [draftBusy, setDraftBusy] = useState(false)
const { data: account, error, isPending, refetch } = useGetOne('accounts', { id })
const { data: browsers = [], error: browsersError, refetch: refetchBrowsers } = useGetList('browsers', undefined, { retry: false })
const { data: drafts = [], error: draftsError, refetch: refetchDrafts } = useGetList('drafts', { filter: { account_id: id } }, { retry: false })
const binding = browsersError ? undefined : browsers.find(browser => browser.account_id === id)
const readiness = account ? accountReadiness(account, binding, browsersError) : null
@@ -207,6 +210,20 @@ export function AccountDetail() {
} finally { setBusy(false) }
}
async function createDraft(event) {
event.preventDefault()
if (!draftContent.trim() || !readiness?.ready) return
setDraftBusy(true); setMessage(null)
try {
const draft = await dataProvider.createDraft(account.id, draftContent)
await refetchDrafts()
setDraftContent('')
setMessage({ severity: 'success', text: `草稿版本 ${draft.version} 已创建,请进入只读快照核对。` })
} catch (reason) {
setMessage({ severity: 'error', text: actionError(reason, '账号或草稿状态已变化;输入内容已保留。') })
} finally { setDraftBusy(false) }
}
if (isPending) return <Box sx={{ display: 'grid', placeItems: 'center', minHeight: 300 }}><CircularProgress aria-label="正在加载账号详情" /></Box>
if (error || !account) return <Alert severity="error">{error?.message || '账号不存在'}</Alert>
return (
@@ -219,6 +236,17 @@ export function AccountDetail() {
<Paper variant="outlined" sx={{ p: 3, minWidth: 0 }}><Stack spacing={2} sx={{ minWidth: 0 }}><Typography variant="h6">账号状态</Typography><AccountState account={account} binding={binding} bindingError={browsersError} /><Typography>授权{account.authorization_status === 'authorized' ? '已授权' : '已撤销'}{account.authorization_kind}</Typography><Typography>运行{account.runtime_status === 'active' ? '启用' : '暂停'} · 版本 {account.version}</Typography><Typography sx={wrapAnywhere}>凭据引用{account.credential_reference?.id} · {account.credential_reference?.provider}</Typography><Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}><Button variant="outlined" color="warning" disabled={busy || account.runtime_status === 'paused'} onClick={() => act('pause')}>暂停账号</Button><Button variant="contained" disabled={busy || !readiness.canResume} onClick={() => act('resume')}>恢复账号</Button></Stack></Stack></Paper>
<Paper variant="outlined" sx={{ p: 3, minWidth: 0 }}><Stack spacing={2} sx={{ minWidth: 0 }}><Typography variant="h6">固定资源</Typography>{browsersError ? <Typography color="text.secondary">运行环境固定出口与 readiness 状态未知重试成功后再执行依赖资源状态的操作</Typography> : binding ? <><Typography sx={wrapAnywhere}>运行环境{binding.name}{binding.alias}</Typography><Typography sx={wrapAnywhere}>固定出口{binding.network_exit_id || '未绑定'} · {binding.network_exit_health || '未知状态'}</Typography><Typography>绑定版本{binding.binding_version}</Typography><Typography sx={wrapAnywhere}>不可调度原因{binding.schedule_block_reason ? (reasonLabels[binding.schedule_block_reason] || binding.schedule_block_reason) : '无'}</Typography><Button component={RouterLink} to="/browsers" variant="outlined">查看运行环境</Button></> : <><Typography color="text.secondary">尚未绑定运行环境与固定出口因此不能恢复或排队</Typography><Button component={RouterLink} to="/browsers" variant="contained">绑定运行环境与出口</Button></>}</Stack></Paper>
</Box>
<Paper component="section" variant="outlined" sx={{ p: { xs: 2.5, md: 3 }, mt: 2.5, minWidth: 0 }}>
<Typography variant="h6">文本草稿</Typography>
<Typography color="text.secondary" sx={{ mt: 0.5, mb: 2 }}>仅支持单用户 Phase A Mock 文本内部 ID 与版本由系统生成</Typography>
<Box component="form" onSubmit={createDraft} sx={{ display: 'grid', gap: 1.5 }}>
<TextField multiline minRows={4} label="草稿内容" value={draftContent} onChange={event => setDraftContent(event.target.value)} disabled={!readiness?.ready || draftBusy} helperText={readiness?.ready ? '创建后进入只读快照核对并显式确认' : `资源未就绪:${readiness?.label || '正在加载'}`} />
<Button type="submit" variant="contained" disabled={!readiness?.ready || !draftContent.trim() || draftBusy} sx={{ justifySelf: { sm: 'start' }, minHeight: 44 }}>{draftBusy ? '创建中…' : '创建草稿'}</Button>
</Box>
{draftsError ? <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => refetchDrafts()}>重试草稿</Button>} sx={{ mt: 2 }}>{draftsError.message}</Alert> : null}
{!draftsError && drafts.length === 0 ? <Typography color="text.secondary" sx={{ mt: 2.5 }}>尚无草稿</Typography> : null}
{drafts.length > 0 ? <Stack spacing={1.5} sx={{ mt: 2.5 }}>{drafts.map(draft => <Paper key={draft.id} variant="outlined" sx={{ p: 2, minWidth: 0 }}><Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5} sx={{ justifyContent: 'space-between', alignItems: { sm: 'center' }, minWidth: 0 }}><Box sx={{ minWidth: 0 }}><Typography fontWeight={700}>草稿版本 {draft.version}</Typography><Typography variant="body2" color="text.secondary" sx={{ ...wrapAnywhere, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{draft.content}</Typography></Box><Button component={RouterLink} to={`/drafts/${draft.id}`} variant="outlined">核对草稿</Button></Stack></Paper>)}</Stack> : null}
</Paper>
</>
)
}
+19
View File
@@ -82,4 +82,23 @@ describe('AccountDetail', () => {
fireEvent.click(screen.getByRole('button', { name: '重试环境状态' }))
await waitFor(() => expect(dataProvider.getList.mock.calls.filter(([resource]) => resource === 'browsers').length).toBeGreaterThan(1))
})
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(new HttpError('unavailable', 503, { reason_code: 'runtime_missing' })),
})
render(<MemoryRouter initialEntries={['/accounts/account-a']}><CoreAdminContext dataProvider={dataProvider}><Routes><Route path="/accounts/:id" element={<AccountDetail />} /></Routes></CoreAdminContext></MemoryRouter>)
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):unavailable')).toBeTruthy()
})
})
+168
View File
@@ -0,0 +1,168 @@
import { useEffect, useRef, useState } from 'react'
import { useDataProvider, useGetList, useGetOne } from 'ra-core'
import { Link as RouterLink, useParams } from 'react-router-dom'
import {
Alert,
Box,
Button,
Checkbox,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
FormControlLabel,
Paper,
Stack,
Typography,
} from '@mui/material'
import CheckCircleOutlined from '@mui/icons-material/CheckCircleOutlined'
import DescriptionOutlined from '@mui/icons-material/DescriptionOutlined'
import ReportProblemOutlined from '@mui/icons-material/ReportProblemOutlined'
import { accountReadiness } from './AccountList'
const wrapAnywhere = { overflowWrap: 'anywhere', minWidth: 0 }
const taskLabels = {
queued: '已排队',
executing: '执行中',
succeeded: '已成功',
failed: '失败',
needs_confirmation: '需要重新确认',
policy_hold: '策略暂停',
cancelled: '已取消',
}
function requestMessage(error) {
const reason = error?.body?.reason_code
if (error?.status === 409) return `版本或账号状态冲突(409):${reason || error.message}`
if (error?.status === 503) return `资源未就绪(503):${reason || error.message}`
return error?.message || '操作失败'
}
export function DraftDetail() {
const { id } = useParams()
const dataProvider = useDataProvider()
const [checked, setChecked] = useState(false)
const [dialogOpen, setDialogOpen] = useState(false)
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState(null)
const confirmTrigger = useRef(null)
const { data: draft, error, isPending, refetch } = useGetOne('drafts', { id }, { retry: false })
const { data: browsers = [], error: browsersError, refetch: refetchBrowsers } = useGetList('browsers', undefined, { retry: false })
useEffect(() => { document.title = 'CreatorHub · 草稿核对' }, [])
if (isPending) return <Box sx={{ display: 'grid', placeItems: 'center', minHeight: 300 }}><CircularProgress aria-label="正在加载草稿" /></Box>
if (error || !draft) return <Alert severity="error">{error?.message || '草稿不存在'}</Alert>
const versions = draft.versions || []
const confirmations = draft.confirmations || []
const tasks = draft.tasks || []
const latestVersion = versions[0]?.version || draft.version
const snapshotCurrent = draft.version === latestVersion
const currentConfirmation = confirmations.find(confirmation =>
confirmation.account_version === draft.account.version && confirmation.draft_version === draft.version)
const binding = browsersError ? undefined : browsers.find(browser => browser.account_id === draft.account_id)
const readiness = accountReadiness(draft.account, binding, Boolean(browsersError))
const canConfirm = snapshotCurrent && Boolean(binding?.network_exit_id) && !browsersError
const canEnqueue = Boolean(currentConfirmation) && snapshotCurrent && readiness.ready
function openDialog() {
confirmTrigger.current?.blur()
setDialogOpen(true)
}
function closeDialog() {
setDialogOpen(false)
}
async function confirm() {
setBusy(true); setMessage(null)
try {
await dataProvider.confirmDraft(draft.id, draft.account.version, draft.version)
await refetch()
closeDialog()
setMessage({ severity: 'success', text: '确认快照已保存,可回溯账号、草稿与当前固定资源版本。' })
} catch (reason) {
closeDialog()
setMessage({ severity: 'error', text: requestMessage(reason) })
await Promise.all([refetch(), refetchBrowsers()])
} finally { setBusy(false) }
}
async function enqueue() {
if (!currentConfirmation) return
setBusy(true); setMessage(null)
try {
const task = await dataProvider.enqueueConfirmation(currentConfirmation.id)
await refetch()
setMessage({ severity: 'success', text: `任务已排队:${task.id}。重复提交会返回同一任务。` })
} catch (reason) {
setMessage({ severity: 'error', text: requestMessage(reason) })
await Promise.all([refetch(), refetchBrowsers()])
} finally { setBusy(false) }
}
return (
<>
<Box component="header" sx={{ mb: 4, minWidth: 0 }}>
<Button component={RouterLink} to={`/accounts/${draft.account_id}`} sx={{ mb: 1 }}> 返回账号详情</Button>
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}><DescriptionOutlined color="primary" fontSize="large" /><Typography variant="h1">草稿核对</Typography></Stack>
<Typography color="text.secondary" sx={{ ...wrapAnywhere, mt: 1 }}>{draft.account.platform_account_key} · 草稿版本 {draft.version}</Typography>
</Box>
{message ? <Alert severity={message.severity} aria-live="polite" sx={{ mb: 2.5 }}>{message.text}</Alert> : null}
{browsersError ? <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => refetchBrowsers()}>重试环境状态</Button>} sx={{ mb: 2.5 }}>环境不可用当前运行环境与固定出口状态未知不能确认或排队</Alert> : null}
{!snapshotCurrent ? <Alert severity="warning" action={<Button component={RouterLink} to={`/drafts/${versions[0]?.id}`}>打开最新版本</Button>} sx={{ mb: 2.5 }}>当前只读快照已不是最新草稿版本请刷新到版本 {latestVersion} 后重新核对</Alert> : null}
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'minmax(0, 1fr)', lg: 'minmax(0, 1.5fr) minmax(0, 1fr)' }, gap: 2.5 }}>
<Paper component="section" variant="outlined" sx={{ p: { xs: 2.5, md: 3 }, minWidth: 0 }}>
<Typography variant="h6">只读内容快照</Typography>
<Typography component="p" sx={{ ...wrapAnywhere, whiteSpace: 'pre-wrap', mt: 2, mb: 0 }}>{draft.content}</Typography>
</Paper>
<Stack spacing={2.5} sx={{ minWidth: 0 }}>
<Paper component="section" variant="outlined" sx={{ p: 3, minWidth: 0 }}>
<Typography variant="h6">版本与当前资源</Typography>
<Stack spacing={1.25} sx={{ mt: 2, minWidth: 0 }}>
<Typography>账号版本{draft.account.version}</Typography>
<Typography>草稿版本{draft.version}</Typography>
<Typography sx={wrapAnywhere}>运行环境{browsersError ? '状态未知' : (binding?.name || '未绑定')}</Typography>
<Typography sx={wrapAnywhere}>固定出口{browsersError ? '状态未知' : (binding?.network_exit_id || '未绑定')}</Typography>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', color: readiness.ready ? 'success.main' : 'warning.main' }}>{readiness.ready ? <CheckCircleOutlined /> : <ReportProblemOutlined />}<Typography fontWeight={650}>{readiness.label}</Typography></Stack>
</Stack>
</Paper>
<Paper component="section" variant="outlined" sx={{ p: 3, minWidth: 0 }}>
<Typography variant="h6">版本链</Typography>
<Stack spacing={1} sx={{ mt: 1.5 }}>{versions.map(version => version.id === draft.id
? <Typography key={version.id} fontWeight={700}>版本 {version.version}当前快照</Typography>
: <Button key={version.id} component={RouterLink} to={`/drafts/${version.id}`} variant="text" sx={{ justifyContent: 'flex-start' }}>查看版本 {version.version}</Button>)}</Stack>
</Paper>
</Stack>
</Box>
<Paper component="section" variant="outlined" sx={{ p: { xs: 2.5, md: 3 }, mt: 2.5, minWidth: 0 }}>
<Typography variant="h6">显式核对与入队</Typography>
<FormControlLabel sx={{ mt: 1.5, alignItems: 'flex-start' }} control={<Checkbox checked={checked} onChange={event => setChecked(event.target.checked)} />} label="我已核对当前账号、草稿内容、运行环境和固定出口" />
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5} sx={{ mt: 2 }}>
<Button ref={confirmTrigger} variant="outlined" disabled={!checked || !canConfirm || busy} onClick={openDialog}>确认当前快照</Button>
<Button variant="contained" disabled={!canEnqueue || busy} onClick={enqueue}>{busy ? '处理中…' : '加入队列'}</Button>
</Stack>
{!currentConfirmation ? <Typography color="text.secondary" sx={{ mt: 1.5 }}>保存有效确认后才可加入队列</Typography> : null}
{currentConfirmation ? <Paper variant="outlined" sx={{ p: 2, mt: 2.5, minWidth: 0 }}><Typography fontWeight={700}>确认快照 v{currentConfirmation.version}</Typography><Typography variant="body2">账号版本 {currentConfirmation.account_version} · 草稿版本 {currentConfirmation.draft_version}</Typography><Typography variant="body2" sx={wrapAnywhere}>环境{currentConfirmation.browser_env_alias || '未记录'} · 出口{currentConfirmation.network_exit_id || '未记录'} · 绑定版本{currentConfirmation.binding_version || '未记录'}</Typography></Paper> : null}
</Paper>
<Paper component="section" variant="outlined" sx={{ p: { xs: 2.5, md: 3 }, mt: 2.5, minWidth: 0 }}>
<Typography variant="h6">关联任务</Typography>
{tasks.length === 0 ? <Typography color="text.secondary" sx={{ mt: 1.5 }}>尚未入队</Typography> : <Stack spacing={1.25} sx={{ mt: 1.5 }}>{tasks.map(task => <Paper key={task.id} variant="outlined" sx={{ p: 2, minWidth: 0 }}><Typography fontWeight={700} sx={wrapAnywhere}>{task.id}</Typography><Typography variant="body2">{taskLabels[task.state] || `未知状态:${task.state}`}</Typography></Paper>)}</Stack>}
</Paper>
<Dialog open={dialogOpen} disableRestoreFocus onClose={() => busy ? undefined : closeDialog()} aria-labelledby="confirm-draft-title" slotProps={{ transition: { onExited: () => confirmTrigger.current?.focus() } }}>
<DialogTitle id="confirm-draft-title">确认草稿版本</DialogTitle>
<DialogContent><DialogContentText>将保存账号版本 {draft.account.version}草稿版本 {draft.version}运行环境 {binding?.alias} 与固定出口 {binding?.network_exit_id} 的只读确认快照版本变化后必须重新确认</DialogContentText></DialogContent>
<DialogActions><Button autoFocus disabled={busy} onClick={closeDialog}>返回核对</Button><Button variant="contained" disabled={busy} onClick={confirm}>{busy ? '确认中…' : '保存确认'}</Button></DialogActions>
</Dialog>
</>
)
}
+72
View File
@@ -0,0 +1,72 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { CoreAdminContext } from 'ra-core'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { DraftDetail } from './DraftDetail'
afterEach(() => { cleanup(); vi.restoreAllMocks() })
const account = { id: 'account-a', platform: 'mock', platform_account_key: 'shop-a', authorization_status: 'authorized', runtime_status: 'active', version: 2 }
const draft = {
id: 'draft-a', account_id: 'account-a', version: 1, content: 'checked content', account,
versions: [{ id: 'draft-a', account_id: 'account-a', version: 1, content: 'checked content' }],
confirmations: [], tasks: [],
}
const binding = { id: 'env-a', alias: 'env-a', name: '店铺环境', account_id: 'account-a', network_exit_id: 'exit-a', network_exit_health: 'healthy', binding_version: 4, schedule_status: 'ready', schedule_block_reason: '' }
function provider(record = draft, overrides = {}) {
return {
getOne: vi.fn().mockResolvedValue({ data: record }),
getList: vi.fn().mockResolvedValue({ data: [binding], total: 1 }),
confirmDraft: vi.fn().mockResolvedValue({ id: 'confirmation-a' }),
enqueueConfirmation: vi.fn().mockResolvedValue({ id: 'task-a', state: 'queued' }),
getMany: vi.fn(), getManyReference: vi.fn(), create: vi.fn(), update: vi.fn(), updateMany: vi.fn(), delete: vi.fn(), deleteMany: vi.fn(),
...overrides,
}
}
function renderDraft(dataProvider) {
return render(<MemoryRouter initialEntries={['/drafts/draft-a']}><CoreAdminContext dataProvider={dataProvider}><Routes><Route path="/drafts/:id" element={<DraftDetail />} /></Routes></CoreAdminContext></MemoryRouter>)
}
describe('DraftDetail', () => {
it('opens a keyboard-accessible confirmation dialog with the reviewed versions', async () => {
const dataProvider = provider()
renderDraft(dataProvider)
fireEvent.click(await screen.findByRole('checkbox', { name: /我已核对当前账号/ }))
fireEvent.click(screen.getByRole('button', { name: '确认当前快照' }))
const dialog = screen.getByRole('dialog', { name: '确认草稿版本' })
expect(dialog.textContent).toContain('账号版本 2、草稿版本 1')
await waitFor(() => expect(screen.getByRole('button', { name: '返回核对' })).toBe(document.activeElement))
fireEvent.click(screen.getByRole('button', { name: '保存确认' }))
await waitFor(() => expect(dataProvider.confirmDraft).toHaveBeenCalledWith('draft-a', 2, 1))
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
expect(screen.getByRole('button', { name: '确认当前快照' })).toBe(document.activeElement)
})
it('submits only once while a repeated enqueue click is in flight', async () => {
let finish
const pending = new Promise(resolve => { finish = resolve })
const confirmation = { id: 'confirmation-a', account_version: 2, draft_version: 1, version: 1, browser_env_alias: 'env-a', network_exit_id: 'exit-a', binding_version: 4 }
const dataProvider = provider({ ...draft, confirmations: [confirmation] }, { enqueueConfirmation: vi.fn().mockReturnValue(pending) })
renderDraft(dataProvider)
const button = await screen.findByRole('button', { name: '加入队列' })
fireEvent.click(button)
fireEvent.click(button)
expect(dataProvider.enqueueConfirmation).toHaveBeenCalledTimes(1)
finish({ id: 'task-a', state: 'queued' })
await screen.findByText(/重复提交会返回同一任务/)
})
it('disables confirmation for a stale snapshot and links to the latest version', async () => {
const stale = { ...draft, versions: [{ id: 'draft-b', version: 2, content: 'latest' }, ...draft.versions] }
renderDraft(provider(stale))
expect(await screen.findByText(/当前只读快照已不是最新草稿版本/)).toBeTruthy()
expect(screen.getByRole('button', { name: '确认当前快照' }).disabled).toBe(true)
expect(screen.getByRole('link', { name: '打开最新版本' }).getAttribute('href')).toBe('/drafts/draft-b')
})
})
+21 -3
View File
@@ -22,14 +22,19 @@ const resourcePaths = {
'browser-images': '/browser-images',
gateways: '/gateways',
accounts: '/phase-a/accounts',
drafts: '/phase-a/drafts',
confirmations: '/phase-a/confirmations',
tasks: '/phase-a/tasks',
'network-exits': '/network-exits',
}
export const dataProvider = {
async getList(resource) {
async getList(resource, params = {}) {
const path = resourcePaths[resource]
if (!path) return unsupported(resource, 'getList')
const records = await request(path)
const filterKeys = { drafts: ['account_id'], confirmations: ['draft_id'], tasks: ['account_id', 'draft_id'] }[resource] || []
const query = new URLSearchParams(filterKeys.flatMap(key => params.filter?.[key] ? [[key, params.filter[key]]] : []))
const records = await request(`${path}${query.size ? `?${query}` : ''}`)
return { data: records.map(record => ({ ...record, id: record.id ?? record.alias ?? record.version ?? record.name })), total: records.length }
},
async create(resource, { data }) {
@@ -54,7 +59,7 @@ export const dataProvider = {
},
async getOne(resource, { id }) {
const path = resourcePaths[resource]
if (!path || (resource !== 'accounts' && resource !== 'network-exits')) return unsupported(resource, 'getOne')
if (!path || !['accounts', 'network-exits', 'drafts', 'confirmations', 'tasks'].includes(resource)) return unsupported(resource, 'getOne')
const record = await request(`${path}/${encodeURIComponent(id)}`)
return { data: { ...record, id: record.id ?? id } }
},
@@ -81,6 +86,19 @@ export const dataProvider = {
if (action !== 'pause' && action !== 'resume') throw new Error(`未知账号操作: ${action}`)
await request(`/phase-a/accounts/${encodeURIComponent(id)}/${action}`, { method: 'POST' })
},
createDraft(accountID, content) {
return request('/phase-a/drafts', jsonOptions('POST', { account_id: accountID, content }))
},
confirmDraft(draftID, accountVersion, draftVersion) {
return request('/phase-a/confirmations', jsonOptions('POST', {
draft_id: draftID,
account_version: accountVersion,
draft_version: draftVersion,
}))
},
enqueueConfirmation(confirmationID) {
return request('/phase-a/tasks', jsonOptions('POST', { confirmation_id: confirmationID }))
},
async networkExitAction(id, action) {
if (action !== 'check' && action !== 'disable') throw new Error(`未知网络出口操作: ${action}`)
return request(`/network-exits/${encodeURIComponent(id)}/${action}`, { method: 'POST' })
+26
View File
@@ -29,6 +29,9 @@ describe('dataProvider', () => {
it.each([
['accounts', 'account-a', '/api/phase-a/accounts/account-a'],
['network-exits', 'exit/one', '/api/network-exits/exit%2Fone'],
['drafts', 'draft/one', '/api/phase-a/drafts/draft%2Fone'],
['confirmations', 'confirmation/one', '/api/phase-a/confirmations/confirmation%2Fone'],
['tasks', 'task/one', '/api/phase-a/tasks/task%2Fone'],
])('loads %s detail through its stable API path', async (resource, id, path) => {
const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id }), { status: 200 }))
vi.stubGlobal('fetch', fetch)
@@ -46,6 +49,29 @@ describe('dataProvider', () => {
expect(fetch).toHaveBeenCalledWith('/api/phase-a/accounts', expect.objectContaining({ method: 'POST' }))
})
it('filters drafts by account through the server list contract', async () => {
const fetch = vi.fn().mockResolvedValue(new Response('[]', { status: 200 }))
vi.stubGlobal('fetch', fetch)
await dataProvider.getList('drafts', { filter: { account_id: 'account-a' } })
expect(fetch).toHaveBeenCalledWith('/api/phase-a/drafts?account_id=account-a', undefined)
})
it('keeps generated draft, confirmation and enqueue identifiers out of user input', async () => {
const fetch = vi.fn().mockImplementation(() => Promise.resolve(new Response('{"id":"generated"}', { status: 201 })))
vi.stubGlobal('fetch', fetch)
await dataProvider.createDraft('account-a', 'hello')
await dataProvider.confirmDraft('draft-a', 2, 3)
await dataProvider.enqueueConfirmation('confirmation-a')
expect(fetch.mock.calls.map(([path, options]) => [path, JSON.parse(options.body)])).toEqual([
['/api/phase-a/drafts', { account_id: 'account-a', content: 'hello' }],
['/api/phase-a/confirmations', { draft_id: 'draft-a', account_version: 2, draft_version: 3 }],
['/api/phase-a/tasks', { confirmation_id: 'confirmation-a' }],
])
})
it.each([
['start', '/api/browsers/account-a/start', 'POST'],
['stop', '/api/browsers/account-a/stop', 'POST'],
+5 -1
View File
@@ -8,6 +8,7 @@ import { AccountDetail, AccountList } from './AccountList'
import { BrowserImageList } from './BrowserImageList'
import { BrowserList } from './BrowserList'
import { dataProvider } from './dataProvider'
import { DraftDetail } from './DraftDetail'
import { GatewayList } from './GatewayList'
import { CreatorHubLayout } from './layout'
import { NetworkExitList } from './NetworkExitList'
@@ -24,7 +25,10 @@ createRoot(document.getElementById('root')).render(
<Resource name="browsers" list={BrowserList} options={{ label: '运行环境' }} />
<Resource name="browser-images" list={BrowserImageList} options={{ label: '镜像版本' }} />
<Resource name="gateways" list={GatewayList} options={{ label: '网关管理' }} />
<CustomRoutes><Route path="/accounts/:id" element={<AccountDetail />} /></CustomRoutes>
<CustomRoutes>
<Route path="/accounts/:id" element={<AccountDetail />} />
<Route path="/drafts/:id" element={<DraftDetail />} />
</CustomRoutes>
</CoreAdmin>
</ThemeProvider>
</StrictMode>,
+36
View File
@@ -57,6 +57,7 @@ test('keeps account and network-exit pages inside 599px, 900px and 1280px', asyn
const networkExit = { id: 'exit-a', protocol: 'socks5', host: hostname, port: 1080, health_status: 'healthy', credential_reference: { id: credentialID, provider: 'os_keyring' } }
await page.route('**/api/phase-a/accounts', route => route.fulfill({ json: [account] }))
await page.route('**/api/phase-a/accounts/account-a', route => route.fulfill({ json: account }))
await page.route('**/api/phase-a/drafts?account_id=account-a', route => route.fulfill({ json: [] }))
await page.route('**/api/browsers', route => route.fulfill({ json: [binding] }))
await page.route('**/api/network-exits', route => route.fulfill({ json: [networkExit] }))
@@ -75,6 +76,7 @@ test('opens account detail at the phase A route', async ({ page }) => {
await page.route('**/api/phase-a/accounts', route => route.fulfill({ json: [account] }))
await page.route('**/api/phase-a/accounts/account-a', route => route.fulfill({ json: account }))
await page.route('**/api/browsers', route => route.fulfill({ json: [] }))
await page.route('**/api/phase-a/drafts?account_id=account-a', route => route.fulfill({ json: [] }))
await page.setViewportSize({ width: 599, height: 900 })
await page.goto('/#/accounts')
@@ -83,3 +85,37 @@ test('opens account detail at the phase A route', async ({ page }) => {
await expect(page.getByRole('heading', { level: 1, name: 'shop-a' })).toBeVisible()
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(599)
})
test('keeps draft review responsive and restores focus after dialog close and successful save', async ({ page }) => {
const account = { id: 'account-a', platform: 'mock', platform_account_key: 'shop-a', authorization_status: 'authorized', runtime_status: 'active', version: 2 }
const draft = {
id: 'draft-a', account_id: 'account-a', version: 1, content: 'x'.repeat(1000), account,
versions: [{ id: 'draft-a', account_id: 'account-a', version: 1, content: 'x'.repeat(1000) }],
confirmations: [], tasks: [],
}
const binding = { alias: 'env-a', name: '店铺环境', account_id: 'account-a', network_exit_id: 'exit-a', network_exit_health: 'healthy', binding_version: 4, schedule_status: 'ready', schedule_block_reason: '' }
await page.route('**/api/phase-a/drafts/draft-a', route => route.fulfill({ json: draft }))
await page.route('**/api/browsers', route => route.fulfill({ json: [binding] }))
await page.route('**/api/phase-a/confirmations', route => route.fulfill({ json: { id: 'confirmation-a' } }))
for (const width of [599, 900, 1280]) {
await page.setViewportSize({ width, height: 900 })
await page.goto('/#/drafts/draft-a')
await expect(page.getByRole('heading', { level: 1, name: '草稿核对' })).toBeVisible()
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width)
}
await page.setViewportSize({ width: 599, height: 900 })
await page.getByRole('checkbox', { name: /我已核对当前账号/ }).check()
const trigger = page.getByRole('button', { name: '确认当前快照' })
await trigger.click()
await expect(page.getByRole('dialog', { name: '确认草稿版本' })).toBeVisible()
await page.keyboard.press('Escape')
await expect(page.getByRole('dialog')).toBeHidden()
await expect(trigger).toBeFocused()
await trigger.click()
await page.getByRole('button', { name: '保存确认' }).click()
await expect(page.getByRole('dialog')).toBeHidden()
await expect(trigger).toBeFocused()
})