HH-847 / HH-799: add draft review and idempotent enqueue (#24)
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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' })
|
||||
|
||||
@@ -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
@@ -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>,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user