Files
creator-hub/web/src/DraftPage.jsx
T
rogee 7ecad93821 refactor(web): migrate frontend to React 19 + Refine v5 + shadcn/ui stack
技术栈切换(按 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 通过。
2026-09-02 13:40:34 +08:00

165 lines
8.8 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }
}