@@ -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-x s 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-[10 px] 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-x s font-medium truncate" > { { env . name } } < / span >
< span v-if = "env.type" class="text-[9 px] 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 >