feat: implement data import/export and optimize terminal UX

This commit is contained in:
duorameng
2026-06-28 20:46:00 +08:00
parent 33ea2fda7b
commit 81e921fa73
13 changed files with 856 additions and 102 deletions
+4
View File
@@ -438,6 +438,9 @@ export const api = {
},
setActive: (name: string) => request<{ message: string }>('/webui/active', { method: 'PUT', body: JSON.stringify({ name }) }),
delete: (name: string) => request<{ message: string }>(`/webui/${name}`, { method: 'DELETE' })
},
system: {
export: (data: { task_ids?: string[], env_ids?: string[] }) => request<any>('/system/export', { method: 'POST', body: JSON.stringify(data) })
}
}
@@ -483,6 +486,7 @@ export interface Task {
last_run: string
next_run: string
running_status?: string
repo_task_id?: string
created_at?: string
updated_at?: string
}
+20
View File
@@ -59,6 +59,24 @@ function initTerminal() {
terminal.loadAddon(fitAddon)
terminal.open(terminalRef.value)
// 禁用手机端点击弹出键盘
const textarea = terminalRef.value.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement
if (textarea) {
textarea.readOnly = true
}
// 支持 Ctrl+C 复制选中内容
terminal.attachCustomKeyEventHandler((e) => {
if (e.ctrlKey && e.code === 'KeyC' && e.type === 'keydown') {
const selection = terminal?.getSelection()
if (selection) {
navigator.clipboard.writeText(selection)
return false
}
}
return true
})
requestAnimationFrame(() => {
try {
fitAddon?.fit()
@@ -183,6 +201,8 @@ watch(
.log-terminal :deep(.xterm-viewport) {
scrollbar-width: thin;
scrollbar-color: rgba(150, 150, 150, 0.3) transparent;
-webkit-overflow-scrolling: touch;
overscroll-behavior-y: contain;
}
.log-terminal :deep(.xterm-viewport::-webkit-scrollbar) {
+14
View File
@@ -94,6 +94,18 @@ function initTerminal(forceConnect = false) {
}
}, 50)
// 支持 Ctrl+C 复制选中内容
terminal.attachCustomKeyEventHandler((e) => {
if (e.ctrlKey && e.code === 'KeyC' && e.type === 'keydown') {
const selection = terminal?.getSelection()
if (selection) {
navigator.clipboard.writeText(selection)
return false // 阻止默认的终端 Ctrl+C 行为
}
}
return true
})
terminal.focus()
// autoConnect 或者强制连接时才连接
@@ -302,6 +314,8 @@ onUnmounted(() => {
scrollbar-width: thin;
scrollbar-color: #4a4a4a #1e1e1e;
background: #1e1e1e !important;
-webkit-overflow-scrolling: touch;
overscroll-behavior-y: contain;
}
.terminal-container :deep(.xterm-screen) {
@@ -5,7 +5,15 @@ import { Input } from '@/components/ui/input'
import { toast } from 'vue-sonner'
import * as interconnectApi from '@/api/interconnect'
import { api, type EnvVar, type Task } from '@/api'
import { Network, Search, HardDrive, Check } from 'lucide-vue-next'
import { Network, Search, HardDrive, Check, AlertCircle, RefreshCw, ChevronDown, ChevronRight } from 'lucide-vue-next'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
const props = defineProps<{
nodes: interconnectApi.InterconnectNode[]
@@ -49,10 +57,69 @@ onMounted(() => {
loadLocalData()
})
const filteredTasks = computed(() => {
if (!taskSearch.value) return localTasks.value
const kw = taskSearch.value.toLowerCase()
return localTasks.value.filter((t: Task) => t.name.toLowerCase().includes(kw) || t.command.toLowerCase().includes(kw))
const expandedRepos = ref<Record<string, boolean>>({})
function toggleRepoExpand(repoId: string) {
const idStr = String(repoId)
if (expandedRepos.value[idStr] === false) {
expandedRepos.value[idStr] = true
} else {
expandedRepos.value[idStr] = false
}
}
const taskTree = computed(() => {
const kw = taskSearch.value.toLowerCase().trim()
const childrenMap: Record<string, Task[]> = {}
const rootTasks: Task[] = []
localTasks.value.forEach(t => {
if (t.type === 'repo') {
rootTasks.push(t)
} else if (t.repo_task_id) {
const repoId = t.repo_task_id
if (!childrenMap[repoId]) {
childrenMap[repoId] = []
}
childrenMap[repoId]!.push(t)
} else {
rootTasks.push(t)
}
})
const result: { parent: Task; children: Task[] }[] = []
rootTasks.forEach(parent => {
const children = childrenMap[parent.id] || []
const parentMatches = !kw || parent.name.toLowerCase().includes(kw) || parent.command.toLowerCase().includes(kw)
const matchingChildren = children.filter(c => !kw || c.name.toLowerCase().includes(kw) || c.command.toLowerCase().includes(kw))
if (parentMatches || matchingChildren.length > 0) {
const childrenToDisplay = parentMatches ? children : matchingChildren
result.push({
parent,
children: childrenToDisplay
})
// If parent didn't match but child did, auto-expand
if (kw && !parentMatches && matchingChildren.length > 0) {
expandedRepos.value[String(parent.id)] = true
}
}
})
return result
})
const totalVisibleTasks = computed(() => {
const list: Task[] = []
taskTree.value.forEach(item => {
list.push(item.parent)
item.children.forEach(c => {
list.push(c)
})
})
return list
})
const filteredEnvs = computed(() => {
@@ -71,6 +138,13 @@ function toggleNode(nodeId: any) {
}
}
// Check if all visible tasks are selected
const isAllTasksSelected = computed(() => {
const visible = totalVisibleTasks.value
if (visible.length === 0) return false
return visible.every(t => selectedTasks.value.includes(String(t.id)))
})
function toggleAllNodes() {
if (selectedNodes.value.length === props.nodes.length && props.nodes.length > 0) {
selectedNodes.value = []
@@ -81,19 +155,59 @@ function toggleAllNodes() {
function toggleTask(id: any) {
const idStr = String(id)
const index = selectedTasks.value.indexOf(idStr)
if (index > -1) {
selectedTasks.value.splice(index, 1)
const task = localTasks.value.find(t => String(t.id) === idStr)
if (!task) return
const isCurrentlySelected = selectedTasks.value.includes(idStr)
if (task.type === 'repo') {
if (isCurrentlySelected) {
selectedTasks.value = selectedTasks.value.filter(x => x !== idStr)
const children = localTasks.value.filter(t => t.repo_task_id === task.id)
const childIds = children.map(c => String(c.id))
selectedTasks.value = selectedTasks.value.filter(x => !childIds.includes(x))
} else {
selectedTasks.value.push(idStr)
const children = localTasks.value.filter(t => t.repo_task_id === task.id)
children.forEach(c => {
const cid = String(c.id)
if (!selectedTasks.value.includes(cid)) {
selectedTasks.value.push(cid)
}
})
}
} else {
selectedTasks.value.push(idStr)
if (isCurrentlySelected) {
selectedTasks.value = selectedTasks.value.filter(x => x !== idStr)
if (task.repo_task_id) {
selectedTasks.value = selectedTasks.value.filter(x => x !== String(task.repo_task_id))
}
} else {
selectedTasks.value.push(idStr)
if (task.repo_task_id) {
const parentIdStr = String(task.repo_task_id)
const siblings = localTasks.value.filter(t => t.repo_task_id === task.repo_task_id)
const allSiblingsChecked = siblings.every(s => selectedTasks.value.includes(String(s.id)))
if (allSiblingsChecked && !selectedTasks.value.includes(parentIdStr)) {
selectedTasks.value.push(parentIdStr)
}
}
}
}
}
function toggleAllTasks() {
if (selectedTasks.value.length === filteredTasks.value.length && filteredTasks.value.length > 0) {
selectedTasks.value = []
const visible = totalVisibleTasks.value
if (isAllTasksSelected.value) {
const visibleIds = visible.map(t => String(t.id))
selectedTasks.value = selectedTasks.value.filter(id => !visibleIds.includes(id))
} else {
selectedTasks.value = filteredTasks.value.map((t: Task) => String(t.id))
visible.forEach(t => {
const idStr = String(t.id)
if (!selectedTasks.value.includes(idStr)) {
selectedTasks.value.push(idStr)
}
})
}
}
@@ -115,30 +229,54 @@ function toggleAllEnvs() {
}
}
const showConfirmDialog = ref(false)
const previewLoading = ref(false)
const previewData = ref<any>(null)
async function handleSync() {
if (selectedNodes.value.length === 0) {
toast.warning('请先选择目标节点')
return
}
const targetNodeIds = selectedNodes.value
if (activeSyncType.value === 'task' && selectedTasks.value.length === 0) {
toast.warning('请先选择要同步的任务')
return
}
if (activeSyncType.value === 'env' && selectedEnvsIds.value.length === 0) {
toast.warning('请先选择要同步的变量')
return
}
previewLoading.value = true
showConfirmDialog.value = true
previewData.value = null
try {
const reqData = activeSyncType.value === 'task'
? { task_ids: selectedTasks.value }
: { env_ids: selectedEnvsIds.value }
const res = await api.system.export(reqData)
previewData.value = res
} catch (error) {
toast.error('获取预览数据失败')
showConfirmDialog.value = false
} finally {
previewLoading.value = false
}
}
async function confirmSync() {
const targetNodeIds = selectedNodes.value
syncing.value = true
try {
if (activeSyncType.value === 'task') {
if (selectedTasks.value.length === 0) {
toast.warning('请先选择要同步的任务')
return
}
const tasksToSync = localTasks.value.filter((t: Task) => selectedTasks.value.includes(t.id))
const res = await interconnectApi.syncTask(targetNodeIds, tasksToSync)
showSyncResult(res)
} else {
if (selectedEnvsIds.value.length === 0) {
toast.warning('请先选择要同步的变量')
return
}
const allSelectedEnvs = localEnvs.value.filter((e: EnvVar) => selectedEnvsIds.value.includes(e.id))
const envsToSync = allSelectedEnvs.filter((e: EnvVar) => e.type !== 'secret')
@@ -146,18 +284,16 @@ async function handleSync() {
toast.info('已自动过滤机密变量,因下发无意义')
}
if (envsToSync.length === 0) {
syncing.value = false
return
if (envsToSync.length > 0) {
const res = await interconnectApi.syncEnv(targetNodeIds, envsToSync)
showSyncResult(res)
}
const res = await interconnectApi.syncEnv(targetNodeIds, envsToSync)
showSyncResult(res)
}
} catch (error) {
toast.error('下发请求失败')
} finally {
syncing.value = false
showConfirmDialog.value = false
}
}
@@ -190,16 +326,16 @@ function showSyncResult(res: any[]) {
暂无子节点
</div>
<div v-else class="space-y-1">
<div v-for="node in nodes" :key="node.id" class="flex items-center gap-3 p-2 hover:bg-muted/50 rounded-md cursor-pointer select-none group/node" @click="toggleNode(node.id)">
<div v-for="node in nodes" :key="node.id" class="flex items-center gap-2 py-1 px-1.5 hover:bg-muted/50 rounded-md cursor-pointer select-none group/node" @click="toggleNode(node.id)">
<div
class="size-4 rounded-[4px] border flex items-center justify-center shrink-0 transition-all duration-200 shadow-xs"
class="size-3.5 rounded-[4px] border flex items-center justify-center shrink-0 transition-all duration-200 shadow-xs"
:class="selectedNodes.includes(String(node.id)) ? 'bg-primary border-primary text-primary-foreground scale-100' : 'bg-transparent border-input group-hover/node:border-muted-foreground/50 scale-95'"
>
<Check v-if="selectedNodes.includes(String(node.id))" class="size-3 stroke-[3.5] animate-in zoom-in-75 fade-in duration-150" />
<Check v-if="selectedNodes.includes(String(node.id))" class="size-2.5 stroke-[3.5] animate-in zoom-in-75 fade-in duration-150" />
</div>
<div class="flex flex-col flex-1 min-w-0">
<span class="text-sm font-medium truncate">{{ node.name }}</span>
<span class="text-xs text-muted-foreground truncate">{{ node.url }}</span>
<div class="flex items-center flex-1 min-w-0 justify-between gap-3">
<span class="text-xs font-medium truncate">{{ node.name }}</span>
<span class="text-[10px] text-muted-foreground truncate font-mono max-w-[50%]">{{ node.url }}</span>
</div>
</div>
</div>
@@ -245,24 +381,53 @@ function showSyncResult(res: any[]) {
<div class="flex items-center justify-between px-2 py-1 mb-2">
<span class="text-xs text-muted-foreground">已选 {{ selectedTasks.length }} </span>
<Button variant="ghost" size="sm" class="h-6 text-xs px-2" @click="toggleAllTasks">
{{ selectedTasks.length === filteredTasks.length && filteredTasks.length > 0 ? '全不选' : '全选' }}
{{ isAllTasksSelected && totalVisibleTasks.length > 0 ? '全不选' : '全选' }}
</Button>
</div>
<div v-if="loading" class="text-center py-8 text-sm text-muted-foreground">加载中...</div>
<div v-else-if="filteredTasks.length === 0" class="text-center py-8 text-sm text-muted-foreground">暂无任务</div>
<div v-for="task in filteredTasks" :key="task.id" class="flex items-start gap-3 p-2 hover:bg-muted/50 rounded-md cursor-pointer select-none group/task" @click="toggleTask(task.id)">
<div
class="mt-1 size-4 rounded-[4px] border flex items-center justify-center shrink-0 transition-all duration-200 shadow-xs"
:class="selectedTasks.includes(String(task.id)) ? 'bg-primary border-primary text-primary-foreground scale-100' : 'bg-transparent border-input group-hover/task:border-muted-foreground/50 scale-95'"
>
<Check v-if="selectedTasks.includes(String(task.id))" class="size-3 stroke-[3.5] animate-in zoom-in-75 fade-in duration-150" />
</div>
<div class="flex flex-col flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium truncate">{{ task.name }}</span>
<span class="text-[10px] bg-muted px-1.5 py-0.5 rounded text-muted-foreground">{{ task.type === 'task' ? '任务' : (task.type === 'repo' ? '仓库' : task.type) }}</span>
<div v-else-if="taskTree.length === 0" class="text-center py-8 text-sm text-muted-foreground">暂无任务</div>
<div v-for="item in taskTree" :key="item.parent.id" class="space-y-1">
<!-- 父节点 (仓库或独立任务) -->
<div class="flex items-center gap-2 py-1 px-1.5 hover:bg-muted/50 rounded-md cursor-pointer select-none group/task" @click="toggleTask(item.parent.id)">
<!-- 展开/折叠按钮 -->
<div v-if="item.parent.type === 'repo'" class="w-3.5 h-3.5 flex items-center justify-center shrink-0" @click.stop="toggleRepoExpand(item.parent.id)">
<ChevronDown v-if="expandedRepos[item.parent.id] !== false" class="w-3 h-3 text-muted-foreground hover:text-foreground" />
<ChevronRight v-else class="w-3 h-3 text-muted-foreground hover:text-foreground" />
</div>
<div v-else class="w-3.5 h-3.5 shrink-0"></div>
<div
class="size-3.5 rounded-[4px] border flex items-center justify-center shrink-0 transition-all duration-200 shadow-xs"
:class="selectedTasks.includes(String(item.parent.id)) ? 'bg-primary border-primary text-primary-foreground scale-100' : 'bg-transparent border-input group-hover/task:border-muted-foreground/50 scale-95'"
>
<Check v-if="selectedTasks.includes(String(item.parent.id))" class="size-2.5 stroke-[3.5] animate-in zoom-in-75 fade-in duration-150" />
</div>
<div class="flex items-center flex-1 min-w-0 justify-between gap-4">
<div class="flex items-center gap-1.5 min-w-0">
<span class="text-xs font-medium truncate">{{ item.parent.name }}</span>
<span class="text-[9px] bg-muted px-1.5 py-0.2 rounded text-muted-foreground shrink-0">{{ item.parent.type === 'task' ? '任务' : '仓库' }}</span>
</div>
<span class="text-[10px] text-muted-foreground truncate font-mono max-w-[60%]">{{ item.parent.command }}</span>
</div>
</div>
<!-- 子节点 (归属于该仓库的任务) -->
<div v-if="item.parent.type === 'repo' && expandedRepos[item.parent.id] !== false && item.children.length > 0" class="pl-5 space-y-1 border-l ml-3.5 pb-0.5">
<div v-for="child in item.children" :key="child.id" class="flex items-center gap-2 py-0.5 px-1.5 hover:bg-muted/50 rounded-md cursor-pointer select-none group/subtask" @click="toggleTask(child.id)">
<div
class="size-3.5 rounded-[4px] border flex items-center justify-center shrink-0 transition-all duration-200 shadow-xs"
:class="selectedTasks.includes(String(child.id)) ? 'bg-primary border-primary text-primary-foreground scale-100' : 'bg-transparent border-input group-hover/subtask:border-muted-foreground/50 scale-95'"
>
<Check v-if="selectedTasks.includes(String(child.id))" class="size-2.5 stroke-[3.5] animate-in zoom-in-75 fade-in duration-150" />
</div>
<div class="flex items-center flex-1 min-w-0 justify-between gap-4">
<div class="flex items-center gap-1.5 min-w-0">
<span class="text-xs font-medium truncate">{{ child.name }}</span>
<span class="text-[9px] bg-muted px-1.5 py-0.2 rounded text-muted-foreground shrink-0">任务</span>
</div>
<span class="text-[10px] text-muted-foreground truncate font-mono max-w-[60%]">{{ child.command }}</span>
</div>
</div>
<span class="text-xs text-muted-foreground truncate font-mono mt-0.5">{{ task.command }}</span>
</div>
</div>
</div>
@@ -278,28 +443,116 @@ function showSyncResult(res: any[]) {
<div v-if="loading" class="text-center py-8 text-sm text-muted-foreground">加载中...</div>
<div v-else-if="filteredEnvs.length === 0" class="text-center py-8 text-sm text-muted-foreground">暂无变量</div>
<div v-for="env in filteredEnvs" :key="env.id"
class="flex items-start gap-3 p-2 rounded-md select-none group/env"
class="flex items-center gap-2 py-1 px-1.5 rounded-md select-none group/env"
:class="env.type === 'secret' ? 'opacity-50 cursor-not-allowed bg-muted/20' : 'hover:bg-muted/50 cursor-pointer'"
@click="env.type === 'secret' ? null : toggleEnv(env.id)">
<div
class="mt-1 size-4 rounded-[4px] border flex items-center justify-center shrink-0 transition-all duration-200 shadow-xs"
class="size-3.5 rounded-[4px] border flex items-center justify-center shrink-0 transition-all duration-200 shadow-xs"
:class="[
selectedEnvsIds.includes(String(env.id)) ? 'bg-primary border-primary text-primary-foreground scale-100' : 'bg-transparent border-input group-hover/env:border-muted-foreground/50 scale-95',
env.type === 'secret' ? 'opacity-50 bg-muted border-muted-foreground/30' : ''
]"
>
<Check v-if="selectedEnvsIds.includes(String(env.id))" class="size-3 stroke-[3.5] animate-in zoom-in-75 fade-in duration-150" />
<Check v-if="selectedEnvsIds.includes(String(env.id))" class="size-2.5 stroke-[3.5] animate-in zoom-in-75 fade-in duration-150" />
</div>
<div class="flex flex-col flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium truncate">{{ env.name }}</span>
<span v-if="env.type" class="text-[10px] bg-muted px-1.5 py-0.5 rounded" :class="env.type === 'secret' ? 'text-amber-500 bg-amber-500/10' : 'text-muted-foreground'">{{ env.type === 'secret' ? '机密' : (env.type === 'normal' ? '常量' : env.type) }}</span>
<div class="flex items-center flex-1 min-w-0 justify-between gap-4">
<div class="flex items-center gap-1.5 min-w-0">
<span class="text-xs font-medium truncate">{{ env.name }}</span>
<span v-if="env.type" class="text-[9px] bg-muted px-1.5 py-0.2 rounded shrink-0" :class="env.type === 'secret' ? 'text-amber-500 bg-amber-500/10' : 'text-muted-foreground'">{{ env.type === 'secret' ? '机密' : '常量' }}</span>
</div>
<span class="text-xs text-muted-foreground truncate font-mono mt-0.5">{{ env.type === 'secret' ? '********' : env.value }}</span>
<span class="text-[10px] text-muted-foreground truncate font-mono max-w-[60%]">{{ env.type === 'secret' ? '********' : env.value }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 确认同步弹窗 -->
<Dialog v-model:open="showConfirmDialog">
<DialogContent class="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>确认同步数据</DialogTitle>
<DialogDescription>
以下是系统自动解析后即将全量同步到目标节点的所有关联数据
</DialogDescription>
</DialogHeader>
<div class="py-4">
<div v-if="previewLoading" class="flex flex-col items-center justify-center py-8 text-muted-foreground">
<RefreshCw class="w-8 h-8 animate-spin mb-4" />
<p>正在智能解析关联数据...</p>
</div>
<div v-else-if="previewData" class="space-y-4">
<div class="rounded-lg border bg-card text-card-foreground p-3">
<div class="flex items-center gap-2 font-medium tracking-tight mb-2 text-xs">
<AlertCircle class="h-4 w-4 text-primary" />
即将同步以下数据
</div>
<div class="max-h-[220px] overflow-y-auto space-y-3 pr-1 text-xs">
<!-- 任务列表 -->
<div v-if="previewData.tasks && previewData.tasks.length > 0">
<div class="font-semibold text-muted-foreground mb-1">任务 ({{ previewData.tasks.length }})</div>
<div class="space-y-1 pl-2 border-l-2 border-primary/30">
<div v-for="t in previewData.tasks" :key="t.id" class="flex justify-between items-center py-1 border-b border-muted/30 last:border-0 pb-1 mb-1 last:pb-0 last:mb-0">
<div class="flex flex-col min-w-0 max-w-[60%]">
<span class="font-medium truncate">{{ t.name }}</span>
<div v-if="t.tags" class="flex flex-wrap gap-1 mt-0.5">
<span v-for="tag in t.tags.split(',')" :key="tag" class="text-[9px] bg-primary/10 text-primary px-1 py-0.2 rounded">{{ tag }}</span>
</div>
</div>
<span class="text-[10px] text-muted-foreground font-mono truncate max-w-[38%]">{{ t.command }}</span>
</div>
</div>
</div>
<!-- 变量列表 -->
<div v-if="previewData.envs && previewData.envs.length > 0">
<div class="font-semibold text-muted-foreground mb-1">环境变量 ({{ previewData.envs.length }})</div>
<div class="space-y-1 pl-2 border-l-2 border-amber-500/30">
<div v-for="e in previewData.envs" :key="e.id" class="flex justify-between items-center py-0.5">
<span class="font-medium truncate max-w-[50%]">{{ e.name }}</span>
<span class="text-[10px] text-muted-foreground font-mono truncate max-w-[45%]">{{ e.type === 'secret' ? '********' : e.value }}</span>
</div>
</div>
</div>
<!-- 标签列表 -->
<div v-if="previewData.tags && previewData.tags.length > 0">
<div class="font-semibold text-muted-foreground mb-1">标签 ({{ previewData.tags.length }})</div>
<div class="space-y-1 pl-2 border-l-2 border-teal-500/30 flex flex-wrap gap-1.5 py-0.5">
<span v-for="tag in previewData.tags" :key="tag.id" class="text-[10px] bg-teal-500/10 text-teal-600 dark:text-teal-400 px-1.5 py-0.5 rounded font-medium">
{{ tag.name }}
</span>
</div>
</div>
<!-- 通知绑定 -->
<div v-if="previewData.bindings && previewData.bindings.length > 0">
<div class="font-semibold text-muted-foreground mb-1">通知规则 ({{ previewData.bindings.length }})</div>
<div class="space-y-1 pl-2 border-l-2 border-emerald-500/30">
<div v-for="b in previewData.bindings" :key="b.id" class="flex justify-between items-center py-0.5">
<span class="font-medium truncate max-w-[50%]">事件: {{ b.event_type }}</span>
<span class="text-[10px] text-muted-foreground truncate max-w-[45%]">通道: {{ b.channel_id }}</span>
</div>
</div>
</div>
</div>
</div>
<p class="text-[11px] text-muted-foreground mt-4 leading-normal">
* 同步将会覆盖目标节点上的同名数据并且机密变量的实际值不会被下发
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="showConfirmDialog = false" :disabled="syncing">取消</Button>
<Button @click="confirmSync" :disabled="previewLoading || syncing">
<RefreshCw v-if="syncing" class="w-4 h-4 mr-2 animate-spin" />
确认下发
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>