功能: Wizard Step3 命令预览 + BatchCommandTable 批量表

This commit is contained in:
Awuqing
2026-04-19 16:44:46 +08:00
parent b26d5cd7d5
commit 4f20056de6
2 changed files with 219 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
import React, { useEffect, useState } from 'react'
import { Table, Button, Space, Message, Typography } from '@arco-design/web-react'
import { IconCopy, IconDownload } from '@arco-design/web-react/icon'
const { Text } = Typography
export interface BatchCommandRow {
nodeId: number
nodeName: string
command: string
expiresAt: string
}
interface Props {
rows: BatchCommandRow[]
}
export function BatchCommandTable({ rows }: Props) {
const [remaining, setRemaining] = useState<Record<number, number>>({})
useEffect(() => {
const tick = () => {
const next: Record<number, number> = {}
rows.forEach((r) => {
const exp = new Date(r.expiresAt).getTime()
next[r.nodeId] = Math.max(0, Math.floor((exp - Date.now()) / 1000))
})
setRemaining(next)
}
tick()
const id = setInterval(tick, 1000)
return () => clearInterval(id)
}, [rows])
const copy = async (s: string) => {
await navigator.clipboard.writeText(s)
Message.success('已复制')
}
const exportAll = () => {
const content = [
'#!/bin/sh',
'# BackupX Agent 批量部署脚本',
'# 使用方法:在目标机逐个执行下面对应节点命令',
'',
...rows.map((r) => `# --- ${r.nodeName} ---\n${r.command}`),
].join('\n\n')
const blob = new Blob([content], { type: 'text/x-shellscript' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `backupx-batch-install-${new Date().toISOString().slice(0, 10)}.sh`
a.click()
URL.revokeObjectURL(url)
}
return (
<div>
<Table
size="small"
pagination={false}
columns={[
{ title: '节点', dataIndex: 'nodeName', width: 140 },
{
title: '安装命令',
dataIndex: 'command',
render: (cmd: unknown, row: BatchCommandRow) => {
const left = remaining[row.nodeId] ?? 0
return (
<Text style={{
fontFamily: 'monospace', fontSize: 12, wordBreak: 'break-all',
opacity: left === 0 ? 0.4 : 1,
}}>
{cmd as string}
</Text>
)
},
},
{
title: '剩余', dataIndex: 'expiresAt', width: 90,
render: (_v: unknown, row: BatchCommandRow) => {
const left = remaining[row.nodeId] ?? 0
return (
<Text type={left === 0 ? 'secondary' : 'primary'} style={{ fontSize: 12 }}>
{left === 0 ? '已过期' : `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`}
</Text>
)
},
},
{
title: '操作', width: 80,
render: (_v: unknown, row: BatchCommandRow) => (
<Button size="small" icon={<IconCopy />} onClick={() => copy(row.command)}
disabled={(remaining[row.nodeId] ?? 0) === 0}></Button>
),
},
]}
data={rows}
rowKey="nodeId"
/>
<div style={{ marginTop: 12, textAlign: 'right' }}>
<Space>
<Button icon={<IconDownload />} onClick={exportAll}> .sh</Button>
</Space>
</div>
</div>
)
}

View File

@@ -0,0 +1,111 @@
import React, { useEffect, useState } from 'react'
import { Typography, Button, Space, Collapse, Spin, Message, Tag } from '@arco-design/web-react'
import { IconCopy, IconRefresh } from '@arco-design/web-react/icon'
import { fetchScriptPreview } from '../../../services/nodes'
import type { InstallTokenResult, InstallMode } from '../../../types/nodes'
const { Text } = Typography
interface Props {
nodeId: number
nodeName: string
token: InstallTokenResult
mode: InstallMode
previewParams: { mode: string; arch: string; agentVersion: string; downloadSrc: string }
onRegenerate: () => void
}
export function Step3CommandPreview({ nodeId, nodeName, token, mode, previewParams, onRegenerate }: Props) {
const [remaining, setRemaining] = useState(0)
const [preview, setPreview] = useState<string>('')
const [loadingPreview, setLoadingPreview] = useState(false)
useEffect(() => {
const expires = new Date(token.expiresAt).getTime()
const tick = () => setRemaining(Math.max(0, Math.floor((expires - Date.now()) / 1000)))
tick()
const id = setInterval(tick, 1000)
return () => clearInterval(id)
}, [token.expiresAt])
const expired = remaining === 0
const command = `curl -fsSL ${token.url} | sudo sh`
const dockerComposeCmd = mode === 'docker' && token.composeUrl
? `curl -fsSL ${token.composeUrl} -o docker-compose.yml && docker-compose up -d`
: null
const copy = async (s: string) => {
await navigator.clipboard.writeText(s)
Message.success('已复制')
}
const loadPreview = async () => {
setLoadingPreview(true)
try {
const text = await fetchScriptPreview(nodeId, previewParams)
setPreview(text)
} catch {
Message.error('预览加载失败')
} finally {
setLoadingPreview(false)
}
}
return (
<div>
<Space style={{ marginBottom: 12 }}>
<Text bold></Text>
<Tag>{nodeName}</Tag>
<Tag color={expired ? 'gray' : 'green'}>
{expired ? '已过期' : `有效期 ${Math.floor(remaining / 60)}:${String(remaining % 60).padStart(2, '0')}`}
</Tag>
</Space>
<div style={{ background: 'var(--color-fill-2)', padding: '12px 14px', borderRadius: 6, marginBottom: 12 }}>
<Text style={{
fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all',
opacity: expired ? 0.4 : 1, userSelect: 'all',
}}>
{command}
</Text>
<div style={{ marginTop: 8 }}>
<Space>
<Button size="small" icon={<IconCopy />} disabled={expired} onClick={() => copy(command)}></Button>
{expired && <Button size="small" type="primary" icon={<IconRefresh />} onClick={onRegenerate}></Button>}
</Space>
</div>
</div>
{dockerComposeCmd && (
<div style={{ background: 'var(--color-fill-2)', padding: '12px 14px', borderRadius: 6, marginBottom: 12 }}>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
使 docker-compose
</Text>
<Text style={{ fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all', opacity: expired ? 0.4 : 1 }}>
{dockerComposeCmd}
</Text>
<div style={{ marginTop: 8 }}>
<Button size="small" icon={<IconCopy />} disabled={expired} onClick={() => copy(dockerComposeCmd)}></Button>
</div>
</div>
)}
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 8 }}>
token
</Text>
<Collapse bordered={false} onChange={(_key, keys) => {
if (keys.includes('preview') && !preview) loadPreview()
}}>
<Collapse.Item name="preview" header="展开脚本预览">
{loadingPreview ? <Spin /> : (
<pre style={{
background: 'var(--color-fill-2)', padding: 12, borderRadius: 4,
fontSize: 12, maxHeight: 400, overflow: 'auto', whiteSpace: 'pre-wrap',
}}>{preview}</pre>
)}
</Collapse.Item>
</Collapse>
</div>
)
}