diff --git a/web/src/pages/nodes/BatchCommandTable.tsx b/web/src/pages/nodes/BatchCommandTable.tsx new file mode 100644 index 0000000..d6701ed --- /dev/null +++ b/web/src/pages/nodes/BatchCommandTable.tsx @@ -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>({}) + + useEffect(() => { + const tick = () => { + const next: Record = {} + 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 ( +
+ { + const left = remaining[row.nodeId] ?? 0 + return ( + + {cmd as string} + + ) + }, + }, + { + title: '剩余', dataIndex: 'expiresAt', width: 90, + render: (_v: unknown, row: BatchCommandRow) => { + const left = remaining[row.nodeId] ?? 0 + return ( + + {left === 0 ? '已过期' : `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`} + + ) + }, + }, + { + title: '操作', width: 80, + render: (_v: unknown, row: BatchCommandRow) => ( + + ), + }, + ]} + data={rows} + rowKey="nodeId" + /> +
+ + + +
+ + ) +} diff --git a/web/src/pages/nodes/wizard/Step3CommandPreview.tsx b/web/src/pages/nodes/wizard/Step3CommandPreview.tsx new file mode 100644 index 0000000..22f91bd --- /dev/null +++ b/web/src/pages/nodes/wizard/Step3CommandPreview.tsx @@ -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('') + 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 ( +
+ + 节点: + {nodeName} + + {expired ? '已过期' : `有效期 ${Math.floor(remaining / 60)}:${String(remaining % 60).padStart(2, '0')}`} + + + +
+ + {command} + +
+ + + {expired && } + +
+
+ + {dockerComposeCmd && ( +
+ + 或使用 docker-compose: + + + {dockerComposeCmd} + +
+ +
+
+ )} + + + 命令仅显示一次,复制后请尽快在目标机执行。token 一经消费立即作废。 + + + { + if (keys.includes('preview') && !preview) loadPreview() + }}> + + {loadingPreview ? : ( +
{preview}
+ )} +
+
+
+ ) +}