mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-12 02:20:36 +08:00
功能: Wizard Step3 命令预览 + BatchCommandTable 批量表
This commit is contained in:
108
web/src/pages/nodes/BatchCommandTable.tsx
Normal file
108
web/src/pages/nodes/BatchCommandTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
111
web/src/pages/nodes/wizard/Step3CommandPreview.tsx
Normal file
111
web/src/pages/nodes/wizard/Step3CommandPreview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user