修复: 前端审查发现的 3 项问题

1. masterVersion null 态(NodesPage + Wizard + Step2)
   原 'latest' 默认值会生成 releases/download/latest/... 404 URL。
   改为 null(拉取中)/ 空串(失败)两态:
   - 拉取成功:Select 显示 Master 版本
   - 拉取失败:Input 要求用户手动输入版本号
   - handleGenerate 前校验 agentVersion 非空

2. 批量创建 N+1 串行请求 → Promise.all 并发 + 进度条
   原 for 循环逐个 await createInstallToken,50 节点 N 次串行延迟。
   改为 Promise.all 并发,用 batchProgress state 驱动 Arco Progress 显示
   "已生成 X / N 个令牌",同时 mountedRef 保护 unmount 后不更新 state。

3. 批次内重复节点名前端预提示
   spec §6.2 要求"前端去重",此前依赖后端报错。
   handleGenerate 前扫描 parseBatchNames 检测批次内重复并 Message.warning。
This commit is contained in:
Awuqing
2026-04-19 17:17:08 +08:00
parent 34106556be
commit 79128cb7cc
3 changed files with 115 additions and 35 deletions

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react'
import { Modal, Steps, Button, Space, Message, Spin } from '@arco-design/web-react'
import React, { useEffect, useRef, useState } from 'react'
import { Modal, Steps, Button, Space, Message, Spin, Progress } from '@arco-design/web-react'
import { Step1NodeName, type Mode } from './wizard/Step1NodeName'
import { Step2DeployOptions, type DeployOptions } from './wizard/Step2DeployOptions'
import { Step3CommandPreview } from './wizard/Step3CommandPreview'
@@ -13,7 +13,8 @@ interface Props {
visible: boolean
onClose: () => void
onSuccess: () => void
masterVersion: string
// null = 正在拉取;空字符串 = 拉取失败Step2 将展示手动输入框而非 Select
masterVersion: string | null
// 当从节点列表直接点"生成安装命令"时传入,跳过 Step1
fixedNode?: { id: number; name: string }
}
@@ -24,14 +25,33 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
const [singleName, setSingleName] = useState('')
const [batchText, setBatchText] = useState('')
// 批量进度(已生成 / 总数)
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null)
const [deploy, setDeploy] = useState<DeployOptions>({
mode: 'systemd',
arch: 'auto',
agentVersion: masterVersion,
agentVersion: masterVersion || '',
downloadSrc: 'github',
ttlSeconds: 900,
})
// 当父组件异步拿到 masterVersion 后,同步到 deploy.agentVersion仅初始为空时
useEffect(() => {
if (masterVersion && !deploy.agentVersion) {
setDeploy((prev) => ({ ...prev, agentVersion: masterVersion }))
}
}, [masterVersion]) // eslint-disable-line react-hooks/exhaustive-deps
// unmount 保护:用户关 Modal / 切页时,已发出的请求完成后不再更新 state
const mountedRef = useRef(true)
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
}
}, [])
const [singleToken, setSingleToken] = useState<InstallTokenResult | null>(null)
const [singleNodeInfo, setSingleNodeInfo] = useState<{ id: number; name: string } | null>(null)
const [batchRows, setBatchRows] = useState<BatchCommandRow[]>([])
@@ -45,6 +65,7 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
setSingleToken(null)
setSingleNodeInfo(null)
setBatchRows([])
setBatchProgress(null)
}
const handleClose = () => {
@@ -76,6 +97,24 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
}
const handleGenerate = async () => {
if (!deploy.agentVersion.trim()) {
Message.warning('请填写 Agent 版本号(形如 v1.7.0')
return
}
// 步骤 1 的批次内去重在前端先提示一次,再由后端最终校验
if (mode === 'batch' && !fixedNode) {
const names = parseBatchNames()
const seen = new Set<string>()
const dups: string[] = []
for (const n of names) {
if (seen.has(n)) dups.push(n)
seen.add(n)
}
if (dups.length > 0) {
Message.warning(`批次内有重复节点名:${Array.from(new Set(dups)).join(', ')}`)
return
}
}
setSubmitting(true)
try {
if (fixedNode) {
@@ -103,23 +142,30 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
} else {
const names = parseBatchNames()
const created = await batchCreateNodes(names)
const rows: BatchCommandRow[] = []
for (const c of created) {
const tok = await createInstallToken(c.id, {
mode: deploy.mode,
arch: deploy.arch,
agentVersion: deploy.agentVersion,
downloadSrc: deploy.downloadSrc,
ttlSeconds: deploy.ttlSeconds,
})
rows.push({
nodeId: c.id,
nodeName: c.name,
command: `curl -fsSL ${tok.url} | sudo sh`,
expiresAt: tok.expiresAt,
})
}
setBatchRows(rows)
setBatchProgress({ done: 0, total: created.length })
// 并发生成 install tokenPromise.all每完成一个递增 done 计数
let done = 0
const tokens = await Promise.all(
created.map(async (c) => {
const tok = await createInstallToken(c.id, {
mode: deploy.mode,
arch: deploy.arch,
agentVersion: deploy.agentVersion,
downloadSrc: deploy.downloadSrc,
ttlSeconds: deploy.ttlSeconds,
})
done += 1
if (mountedRef.current) setBatchProgress({ done, total: created.length })
return { c, tok }
}),
)
const rows: BatchCommandRow[] = tokens.map(({ c, tok }) => ({
nodeId: c.id,
nodeName: c.name,
command: `curl -fsSL ${tok.url} | sudo sh`,
expiresAt: tok.expiresAt,
}))
if (mountedRef.current) setBatchRows(rows)
}
setStep(2)
onSuccess()
@@ -178,6 +224,17 @@ export function AgentInstallWizard({ visible, onClose, onSuccess, masterVersion,
{submitting && (
<div style={{ textAlign: 'center', padding: 32 }}>
<Spin />
{batchProgress && (
<div style={{ marginTop: 16, maxWidth: 360, marginLeft: 'auto', marginRight: 'auto' }}>
<div style={{ fontSize: 13, marginBottom: 6 }}>
{batchProgress.done} / {batchProgress.total}
</div>
<Progress
percent={Math.round((batchProgress.done / batchProgress.total) * 100)}
showText
/>
</div>
)}
</div>
)}

View File

@@ -19,7 +19,8 @@ export default function NodesPage() {
const [wizardVisible, setWizardVisible] = useState(false)
const [wizardFixedNode, setWizardFixedNode] = useState<{ id: number; name: string } | undefined>()
const [masterVersion, setMasterVersion] = useState('latest')
// null = 拉取中 / 未知;空字符串 = 拉取失败UI 将要求用户手动输入版本,避免生成无效 URL
const [masterVersion, setMasterVersion] = useState<string | null>(null)
const [editVisible, setEditVisible] = useState(false)
const [editNode, setEditNode] = useState<NodeSummary | null>(null)
@@ -39,10 +40,11 @@ export default function NodesPage() {
useEffect(() => {
fetchNodes()
// 取 Master 版本号作为 Wizard agentVersion 默认值
// 取 Master 版本号作为 Wizard agentVersion 默认值
// 拉取失败或字段缺失时置为空串Wizard 会提示用户手动输入。
fetchSystemInfo().then((info) => {
if (info?.version) setMasterVersion(info.version)
}).catch(() => {})
setMasterVersion(info?.version || '')
}).catch(() => setMasterVersion(''))
}, [fetchNodes])
const handleDelete = async (id: number) => {

View File

@@ -1,5 +1,5 @@
import React from 'react'
import { Form, Radio, Select, Typography } from '@arco-design/web-react'
import { Form, Radio, Select, Input, Typography } from '@arco-design/web-react'
import type { InstallMode, InstallArch, InstallSource } from '../../../types/nodes'
const { Text } = Typography
@@ -13,13 +13,16 @@ export interface DeployOptions {
}
interface Props {
masterVersion: string
// null = 拉取中;空串 = 拉取失败(改为手动输入)
masterVersion: string | null
value: DeployOptions
onChange: (v: DeployOptions) => void
}
export function Step2DeployOptions({ masterVersion, value, onChange }: Props) {
const update = (patch: Partial<DeployOptions>) => onChange({ ...value, ...patch })
const versionKnown = !!masterVersion
const versionLoading = masterVersion === null
return (
<Form layout="vertical" size="default">
@@ -48,14 +51,32 @@ export function Step2DeployOptions({ masterVersion, value, onChange }: Props) {
/>
</Form.Item>
<Form.Item label="Agent 版本">
<Select
value={value.agentVersion}
onChange={(v) => update({ agentVersion: v })}
options={[
{ label: `${masterVersion}(跟随 Master推荐`, value: masterVersion },
]}
/>
<Form.Item
label="Agent 版本"
extra={
!versionKnown && !versionLoading ? (
<Text type="warning" style={{ fontSize: 12 }}>
Master v1.7.0
</Text>
) : undefined
}
>
{versionKnown ? (
<Select
value={value.agentVersion}
onChange={(v) => update({ agentVersion: v })}
options={[
{ label: `${masterVersion}(跟随 Master推荐`, value: masterVersion as string },
]}
/>
) : (
<Input
placeholder={versionLoading ? '加载中...' : 'v1.7.0'}
value={value.agentVersion}
onChange={(v) => update({ agentVersion: v })}
disabled={versionLoading}
/>
)}
</Form.Item>
<Form.Item label="安装命令有效期">