技术栈切换(按 AGENTS.md 批准栈):react-admin/MUI → Refine v5 + shadcn/ui
(Tailwind CSS v4),Vite 8 + React 19 + react-router v7 hash 路由。
- 全部 9 个页面重写:AccountsPage/BrowsersPage(BrowserList/CreatePage/Detail)/
BrowserImagesPage/GatewaysPage/NetworkExitsPage/TasksPage/TracePages/
DraftPage/AuditPage;自建 lib/ui.jsx 组件库与 Layout(侧导航 + Outlet)
- 简单创建/编辑改 modal(账号/网关/镜像/出口),环境创建走独立页
/browsers/new;列表按信息密度选表格或移动卡片双形态
- 图标统一 RemixIcon;领域动作保持显式动词(start/stop/upgrade/recycle
等,不伪装成 CRUD update)
- LoginPage:Basic 凭证存 localStorage;401 清凭证并跳 #/login;
AuthGate 改为渲染期读 localStorage(react-router v7 的 pushState 不触发
hashchange,事件订阅会在导航后拿到过期状态导致登录后卡死)
- dataProvider 适配 Refine v5 契约:所有方法收单个参数对象
({resource, pagination, filters, meta, variables}),create/update/
delete/deleteOne 语义对齐
- Modal 关闭时焦点还给触发元素;Select 保持原生 button/combobox 语义
- e2e 补登录前置;账号创建流程同步 modal 交互
验证:npm test 65/65 通过;覆盖率 71.5% stmts / 76.3% lines(门槛 65%);
vite build 成功;playwright e2e 9/9 通过。
165 lines
8.8 KiB
React
165 lines
8.8 KiB
React
import { useState } from 'react'
|
||
import { useNavigate, useParams } from 'react-router'
|
||
import { useDataProvider, useList, useOne } from '@refinedev/core'
|
||
import {
|
||
Alert, Button, Card, CardContent, Checkbox, ConfirmDialog, DetailList, Field, Input, Modal,
|
||
PageHeader, PageState, StatusPill, conflictMessage,
|
||
} from './lib/ui.jsx'
|
||
import { useTitle } from './lib/hooks.js'
|
||
|
||
export function DraftPage() {
|
||
const { id } = useParams()
|
||
const navigate = useNavigate()
|
||
const dataProvider = useDataProvider()('default')
|
||
const [reviewed, setReviewed] = useState(false)
|
||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||
const [busy, setBusy] = useState(false)
|
||
const [notice, setNotice] = useState(null)
|
||
const { result, error, isPending, query } = useOne({ resource: 'drafts', id, queryOptions: { retry: false } })
|
||
const draft = result
|
||
const { result: bindingResult, query: browsersQuery } = useList({ resource: 'browsers', queryOptions: { retry: false } })
|
||
const browsers = bindingResult.data ?? []
|
||
const browsersError = browsersQuery.error
|
||
const refetchBrowsers = browsersQuery.refetch
|
||
useTitle('CreatorHub · 草稿核对')
|
||
|
||
if (isPending) return <PageState pending />
|
||
if (error || !draft) return <Alert variant="destructive">{error?.message || '草稿不存在'}</Alert>
|
||
|
||
const versions = draft.versions || []
|
||
const confirmations = draft.confirmations || []
|
||
const tasks = draft.tasks || []
|
||
const latestVersion = versions[0]?.version || draft.version
|
||
const isLatest = draft.version === latestVersion
|
||
const confirmation = confirmations.find(item => item.account_version === draft.account.version && item.draft_version === draft.version)
|
||
const binding = browsersError ? undefined : browsers.find(item => item.account_id === draft.account_id)
|
||
const readiness = draftReadiness(draft, binding, !!browsersError)
|
||
const canConfirm = isLatest && !!binding && !browsersError
|
||
const canEnqueue = !!confirmation && isLatest && readiness.ready
|
||
|
||
async function confirmSnapshot() {
|
||
setBusy(true)
|
||
setNotice(null)
|
||
try {
|
||
await dataProvider.confirmDraft(draft.id, draft.account.version, draft.version)
|
||
await query.refetch()
|
||
setConfirmOpen(false)
|
||
setNotice({ variant: 'success', text: '确认快照已保存,可回溯账号、草稿与当前固定资源版本。' })
|
||
} catch (reason) {
|
||
setConfirmOpen(false)
|
||
setNotice({ variant: 'destructive', text: conflictMessage(reason) })
|
||
await Promise.all([query.refetch(), refetchBrowsers()])
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
async function enqueue() {
|
||
if (!confirmation) return
|
||
setBusy(true)
|
||
setNotice(null)
|
||
try {
|
||
const task = await dataProvider.enqueueConfirmation(confirmation.id)
|
||
await query.refetch()
|
||
setNotice({ variant: 'success', text: `任务已排队:${task.id}。重复提交会返回同一任务。` })
|
||
} catch (reason) {
|
||
setNotice({ variant: 'destructive', text: conflictMessage(reason) })
|
||
await Promise.all([query.refetch(), refetchBrowsers()])
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<PageHeader icon="ri-file-text-line" title="草稿核对" description={<span className="anywhere">{draft.account.platform_account_key} · 草稿版本 {draft.version}</span>}>
|
||
<Button icon="ri-arrow-left-line" onClick={() => navigate(`/accounts/${draft.account_id}`)}>返回账号详情</Button>
|
||
</PageHeader>
|
||
{notice ? <Alert variant={notice.variant} className="mb-4">{notice.text}</Alert> : null}
|
||
{browsersError ? <Alert variant="destructive" className="mb-4">环境不可用:当前运行环境与网络出口状态未知,不能确认或排队。</Alert> : null}
|
||
{!isLatest ? (
|
||
<Alert variant="warning" className="mb-6">
|
||
当前只读快照已不是最新草稿版本,请刷新到版本 {latestVersion} 后重新核对。
|
||
<Button size="sm" onClick={() => navigate(`/drafts/${versions[0]?.id}`)}>打开最新版本</Button>
|
||
</Alert>
|
||
) : null}
|
||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)]">
|
||
<Card><CardContent>
|
||
<h2 className="text-base font-semibold">只读内容快照</h2>
|
||
<p className="anywhere mt-4 whitespace-pre-wrap text-sm">{draft.content}</p>
|
||
</CardContent></Card>
|
||
<div className="space-y-4">
|
||
<Card><CardContent className="space-y-4">
|
||
<h2 className="text-base font-semibold">版本与当前资源</h2>
|
||
<DetailList rows={[
|
||
['账号版本', draft.account.version],
|
||
['草稿版本', draft.version],
|
||
['运行环境', browsersError ? '状态未知' : binding?.name || '未绑定'],
|
||
['网络出口', browsersError ? '状态未知' : binding?.network_exit_id || '当前机器直连'],
|
||
]} />
|
||
<StatusPill tone={readiness.ready ? 'success' : 'warning'} label={readiness.label} />
|
||
</CardContent></Card>
|
||
<Card><CardContent>
|
||
<h2 className="text-base font-semibold">版本链</h2>
|
||
<div className="mt-3 space-y-1">
|
||
{versions.map(item => item.id === draft.id ? (
|
||
<p key={item.id} className="py-1 font-semibold">版本 {item.version}(当前快照)</p>
|
||
) : (
|
||
<button key={item.id} type="button" onClick={() => navigate(`/drafts/${item.id}`)} className="block py-1 text-left text-sm text-primary hover:underline">查看版本 {item.version}</button>
|
||
))}
|
||
</div>
|
||
</CardContent></Card>
|
||
</div>
|
||
</div>
|
||
<Card className="mt-4"><CardContent className="space-y-4">
|
||
<h2 className="text-base font-semibold">显式核对与入队</h2>
|
||
<Checkbox id="draft-reviewed" checked={reviewed} onCheckedChange={setReviewed}>我已核对当前账号、草稿内容、运行环境和网络出口</Checkbox>
|
||
<div className="flex flex-wrap gap-3">
|
||
<Button variant="outline" disabled={!reviewed || !canConfirm || busy} onClick={() => setConfirmOpen(true)}>确认当前快照</Button>
|
||
<Button variant="primary" disabled={!canEnqueue || busy} busy={busy} onClick={enqueue}>加入队列</Button>
|
||
</div>
|
||
{confirmation ? null : <p className="text-sm text-muted">保存有效确认后才可加入队列。</p>}
|
||
{confirmation ? (
|
||
<div className="rounded-md border border-hairline p-3">
|
||
<p className="font-semibold">确认快照 v{confirmation.version}</p>
|
||
<p className="text-sm">账号版本 {confirmation.account_version} · 草稿版本 {confirmation.draft_version}</p>
|
||
<p className="anywhere text-sm">环境:{confirmation.browser_env_alias || '未记录'} · 出口:{confirmation.network_exit_id || '未记录'} · 绑定版本:{confirmation.binding_version || '未记录'}</p>
|
||
</div>
|
||
) : null}
|
||
</CardContent></Card>
|
||
<Card className="mt-4"><CardContent>
|
||
<h2 className="text-base font-semibold">关联任务</h2>
|
||
{tasks.length === 0 ? <p className="mt-2 text-sm text-muted">尚未入队。</p> : (
|
||
<div className="mt-3 space-y-2">
|
||
{tasks.map(task => (
|
||
<div key={task.id} className="rounded-md border border-hairline p-3">
|
||
<p className="anywhere font-semibold">{task.id}</p>
|
||
<p className="text-sm">任务状态:{task.state}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</CardContent></Card>
|
||
<Modal open={confirmOpen} onClose={busy ? undefined : () => setConfirmOpen(false)} title="确认草稿版本" size="sm" labelledBy="confirm-draft-title"
|
||
footer={<>
|
||
<Button onClick={() => setConfirmOpen(false)} disabled={busy}>返回核对</Button>
|
||
<Button variant="primary" busy={busy} busyText="确认中…" onClick={confirmSnapshot}>保存确认</Button>
|
||
</>}>
|
||
<p className="text-sm">
|
||
将保存账号版本 {draft.account.version}、草稿版本 {draft.version}、运行环境 {binding?.alias} 与网络出口 {binding?.network_exit_id || '当前机器直连'} 的只读确认快照。版本变化后必须重新确认。
|
||
</p>
|
||
</Modal>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 与账号 readiness 同构的草稿侧判定:只依赖绑定健康与调度状态。
|
||
function draftReadiness(draft, binding, bindingsError = false) {
|
||
if (draft.account.authorization_status !== 'authorized') return { label: '授权已撤销', ready: false }
|
||
if (bindingsError) return { label: '环境状态未知', ready: false }
|
||
if (!binding) return { label: '未绑定运行环境', ready: false }
|
||
if (binding.network_exit_id && binding.network_exit_health !== 'healthy') return { label: '固定出口不健康', ready: false }
|
||
const schedule = binding.schedule_block_reason || ''
|
||
return { label: schedule ? `不可调度:${schedule}` : '可调度', ready: !schedule }
|
||
}
|