Files
TaskPool/web/src/views/agents/Agents.vue
T

847 lines
41 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, onMounted, computed, onUnmounted } from 'vue'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog'
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
RefreshCw, Trash2, Pencil, Copy, Server, Search, Download, RotateCw,
Plus, Ticket, ListTodo, Eye, Wifi, WifiOff,
Zap, ZapOff, Check, X, MoreHorizontal
} from 'lucide-vue-next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu'
import { api, type Agent, type AgentToken } from '@/api'
import { toast } from 'vue-sonner'
import { useRouter } from 'vue-router'
import { AGENT_STATUS } from '@/constants'
import StatusDot from '@/components/StatusDot.vue'
const router = useRouter()
const agents = ref<Agent[]>([])
const tokens = ref<AgentToken[]>([])
const loading = ref(false)
const searchQuery = ref('')
const activeTab = ref('agents')
const agentVersion = ref('')
const platforms = ref<{ os: string; arch: string; filename: string }[]>([])
const showEditDialog = ref(false)
const showDeleteDialog = ref(false)
const showDownloadDialog = ref(false)
const showTokenDialog = ref(false)
const showEditTokenDialog = ref(false)
const showDetailDialog = ref(false)
const customScheduler = ref(false)
const formData = ref({
name: '',
description: '',
scheduler_config: {
worker_count: 1,
queue_size: 100,
rate_interval: 200,
verbose: false,
strict_queue: false
}
})
const tokenForm = ref({ remark: '', max_uses: 0, expires_at: '' })
const editingToken = ref<AgentToken | null>(null)
const editTokenForm = ref({ remark: '', max_uses: 0, expires_at: '' })
const editingAgent = ref<Agent | null>(null)
const deletingAgent = ref<Agent | null>(null)
const viewingAgent = ref<Agent | null>(null)
let refreshTimer: ReturnType<typeof setInterval> | null = null
const filteredAgents = computed(() => {
if (!searchQuery.value) return agents.value
const q = searchQuery.value.toLowerCase()
return agents.value.filter(a =>
a.name.toLowerCase().includes(q) ||
a.hostname?.toLowerCase().includes(q) ||
a.ip?.toLowerCase().includes(q)
)
})
function isOnline(agent: Agent): boolean {
return agent.status === AGENT_STATUS.ONLINE
}
async function loadAgents() {
loading.value = true
try {
const [agentList, versionInfo, tokenList] = await Promise.all([
api.agents.list(),
api.agents.getVersion(),
api.agents.listTokens()
])
agents.value = agentList
agentVersion.value = versionInfo.version || ''
platforms.value = versionInfo.platforms || []
tokens.value = tokenList
} catch {
toast.error('加载失败')
} finally {
loading.value = false
}
}
function viewDetail(agent: Agent) {
;(document.activeElement as HTMLElement)?.blur()
viewingAgent.value = agent
showDetailDialog.value = true
}
function openEditDialog(agent: Agent) {
;(document.activeElement as HTMLElement)?.blur()
editingAgent.value = agent
customScheduler.value = !!agent.scheduler_config
formData.value = {
name: agent.name,
description: agent.description,
scheduler_config: agent.scheduler_config ? {
worker_count: agent.scheduler_config.worker_count,
queue_size: agent.scheduler_config.queue_size,
rate_interval: agent.scheduler_config.rate_interval,
verbose: agent.scheduler_config.verbose,
strict_queue: agent.scheduler_config.strict_queue
} : {
worker_count: 1,
queue_size: 100,
rate_interval: 200,
verbose: false,
strict_queue: false
}
}
showEditDialog.value = true
}
async function updateAgent() {
if (!editingAgent.value || !formData.value.name.trim()) return
try {
const payload = {
name: formData.value.name,
description: formData.value.description,
enabled: editingAgent.value.enabled,
scheduler_config: customScheduler.value ? formData.value.scheduler_config : null
}
await api.agents.update(editingAgent.value.id, payload)
showEditDialog.value = false
await loadAgents()
toast.success('更新成功')
} catch (e: unknown) {
toast.error((e as Error).message || '更新失败')
}
}
async function toggleEnabled(agent: Agent) {
try {
const newEnabled = !agent.enabled
await api.agents.update(agent.id, {
name: agent.name,
description: agent.description,
enabled: newEnabled,
scheduler_config: agent.scheduler_config
})
await loadAgents()
toast.success(`${agent.name}${newEnabled ? '启用' : '禁用'}`)
} catch (e: unknown) {
toast.error((e as Error).message || '操作失败')
}
}
function confirmDelete(agent: Agent) {
;(document.activeElement as HTMLElement)?.blur()
deletingAgent.value = agent
showDeleteDialog.value = true
}
function openDownloadDialog() {
;(document.activeElement as HTMLElement)?.blur()
showDownloadDialog.value = true
}
function openTokenDialog() {
;(document.activeElement as HTMLElement)?.blur()
showTokenDialog.value = true
}
async function deleteAgent() {
if (!deletingAgent.value) return
try {
await api.agents.delete(deletingAgent.value.id)
showDeleteDialog.value = false
await loadAgents()
toast.success('删除成功')
} catch (e: unknown) {
toast.error((e as Error).message || '删除失败')
}
}
async function forceUpdate(agent: Agent) {
try {
await api.agents.forceUpdate(agent.id)
toast.success('已标记强制更新')
} catch (e: unknown) {
toast.error((e as Error).message || '操作失败')
}
}
function viewTasks(agent: Agent) {
router.push({ path: '/tasks', query: { agent_id: String(agent.id) } })
}
function copyToken(token: string) {
navigator.clipboard.writeText(token)
toast.success('已复制')
}
async function createToken() {
try {
let expiresAt = tokenForm.value.expires_at
if (expiresAt) {
// 适配后端格式: 2006-01-02 15:04:05
expiresAt = expiresAt.replace('T', ' ') + ':00'
}
await api.agents.createToken({
remark: tokenForm.value.remark,
max_uses: tokenForm.value.max_uses,
expires_at: expiresAt || undefined
})
showTokenDialog.value = false
tokenForm.value = { remark: '', max_uses: 0, expires_at: '' }
await loadAgents()
toast.success('创建成功')
} catch (e: unknown) {
toast.error((e as Error).message || '创建失败')
}
}
async function deleteToken(id: string) {
try {
await api.agents.deleteToken(id)
await loadAgents()
toast.success('删除成功')
} catch (e: unknown) {
toast.error((e as Error).message || '删除失败')
}
}
function openEditToken(token: AgentToken) {
;(document.activeElement as HTMLElement)?.blur()
editingToken.value = token
// expires_at 后端格式 "2006-01-02 15:04:05",转回 datetime-local 格式
const rawExpires = token.expires_at?.replace(' ', 'T').slice(0, 16) || ''
editTokenForm.value = { remark: token.remark || '', max_uses: token.max_uses, expires_at: rawExpires }
showEditTokenDialog.value = true
}
async function updateToken() {
if (!editingToken.value) return
try {
let expiresAt = editTokenForm.value.expires_at
if (expiresAt) {
expiresAt = expiresAt.replace('T', ' ') + ':00'
}
await api.agents.updateToken(editingToken.value.id, {
remark: editTokenForm.value.remark,
max_uses: editTokenForm.value.max_uses,
expires_at: expiresAt || undefined
})
showEditTokenDialog.value = false
await loadAgents()
toast.success('更新成功')
} catch (e: unknown) {
toast.error((e as Error).message || '更新失败')
}
}
function isTokenExpired(token: AgentToken) {
if (!token.expires_at) return false
// 将 "YYYY-MM-DD HH:mm:ss" 转换为 ISO 格式 "YYYY-MM-DDTHH:mm:ss" 以提高浏览器兼容性
const dateStr = token.expires_at.replace(' ', 'T')
return new Date(dateStr) < new Date()
}
function isTokenExhausted(token: AgentToken) {
return token.max_uses > 0 && token.used_count >= token.max_uses
}
function downloadAgent(os: string, arch: string) {
window.open(api.agents.downloadUrl(os, arch), '_blank')
}
function getPlatformLabel(os: string, arch: string) {
const osLabels: Record<string, string> = { linux: 'Linux', windows: 'Windows', darwin: 'macOS' }
const archLabels: Record<string, string> = { amd64: 'x64', arm64: 'ARM64', '386': 'x86' }
return `${osLabels[os] || os} ${archLabels[arch] || arch}`
}
onMounted(() => {
loadAgents()
refreshTimer = setInterval(loadAgents, 10000)
})
onUnmounted(() => {
if (refreshTimer) clearInterval(refreshTimer)
})
</script>
<template>
<Tabs v-model="activeTab" class="space-y-6">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div class="flex flex-col shrink-0">
<h2 class="text-xl sm:text-2xl font-bold tracking-tight">Agent 管理</h2>
<p class="text-muted-foreground text-xs mt-0.5 ml-0.5">管理远程执行代理</p>
</div>
<div class="flex flex-row items-center flex-wrap gap-2 w-full md:w-auto md:ml-auto md:justify-end">
<!-- 搜索与操作 -->
<div class="flex flex-row items-center gap-2 w-full sm:flex-1 md:flex-none md:w-auto text-sm">
<div class="relative flex-1 md:flex-none md:w-[200px] group">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground group-focus-within:text-primary transition-colors" />
<Input v-model="searchQuery" placeholder="搜索 Agent..." class="h-9 pl-9 w-full bg-muted/20 border-muted-foreground/10 focus:bg-background text-sm" />
</div>
<Button variant="outline" size="icon" class="h-9 w-9 shrink-0" @click="loadAgents" :disabled="loading" title="刷新">
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': loading }" />
</Button>
<Button variant="outline" class="h-9 px-3 shrink-0 shadow-sm" @click="openDownloadDialog">
<Download class="h-4 w-4 md:mr-2" /> <span class="hidden md:inline">下载 Agent</span>
</Button>
</div>
<TabsList class="h-9 p-0.5 bg-muted/20 border border-border/40 rounded-lg w-full sm:w-auto">
<TabsTrigger value="agents" class="px-3 h-8 text-xs gap-1.5 font-medium transition-all flex-1 sm:flex-none">
<Server class="w-3.5 h-3.5 opacity-70" />
<span>列表</span>
</TabsTrigger>
<TabsTrigger value="regcodes" class="px-3 h-8 text-xs gap-1.5 font-medium transition-all flex-1 sm:flex-none">
<Ticket class="w-3.5 h-3.5 opacity-70" />
<span>令牌</span>
</TabsTrigger>
</TabsList>
</div>
</div>
<TabsContent value="agents" class="mt-0">
<div class="rounded-lg border bg-card overflow-hidden">
<!-- ========== 1. 大屏布局 (Large >= 1280px) ========== -->
<div class="hidden xl:block">
<!-- 表头 -->
<div class="flex items-center gap-4 px-4 py-1.5 border-b bg-muted/20 text-xs text-muted-foreground font-medium">
<span class="w-12 shrink-0 pl-1">序号</span>
<span class="w-48 shrink-0">名称</span>
<span class="w-32 shrink-0">IP 地址</span>
<span class="w-32 shrink-0">主机名</span>
<span class="w-28 shrink-0">版本</span>
<span class="flex-1 min-w-0">心跳时间</span>
<span class="w-24 shrink-0 text-center">操作</span>
</div>
<!-- 列表 -->
<div class="divide-y text-sm">
<div v-if="filteredAgents.length === 0" class="text-center py-12 text-muted-foreground">
<Server class="h-8 w-8 mx-auto mb-2 opacity-50" />
{{ searchQuery ? '无匹配结果' : '暂无 Agent' }}
</div>
<div v-for="(agent, index) in filteredAgents" :key="`large-${agent.id}`"
class="flex items-center gap-2 px-4 py-1.5 hover:bg-muted/30 transition-colors">
<StatusDot :state="isOnline(agent) ? 'online' : 'offline'" :title="isOnline(agent) ? '在线' : '离线'" />
<div class="w-12 shrink-0 pl-1 text-muted-foreground tabular-nums text-sm">#{{ filteredAgents.length - index }}</div>
<div class="w-48 shrink-0 flex flex-col justify-center gap-0.5 overflow-hidden">
<span class="font-medium truncate cursor-pointer hover:text-primary transition-colors" @click="viewDetail(agent)">{{ agent.name }}</span>
<div v-if="agent.description" class="text-[10px] text-muted-foreground truncate">{{ agent.description }}</div>
</div>
<span class="w-32 shrink-0 text-xs text-muted-foreground truncate">{{ agent.ip || '-' }}</span>
<span class="w-32 shrink-0 text-xs text-muted-foreground truncate">{{ agent.hostname || '-' }}</span>
<span class="w-28 shrink-0 text-xs text-muted-foreground truncate">{{ agent.version || '-' }}</span>
<span class="flex-1 min-w-0 text-[11px] text-muted-foreground tabular-nums truncate">
{{ agent.last_seen || '-' }}
</span>
<span class="w-24 shrink-0 flex justify-center items-center">
<span class="cursor-pointer group mr-1" @click="toggleEnabled(agent)" :title="agent.enabled ? '点击禁用' : '点击启用'">
<div v-if="agent.enabled" class="h-6 w-6 rounded-md bg-green-500/5 flex items-center justify-center group-hover:bg-green-500/10">
<Zap class="h-3 w-3 text-green-500 fill-green-500" />
</div>
<div v-else class="h-6 w-6 rounded-md bg-muted flex items-center justify-center group-hover:bg-muted/80">
<ZapOff class="h-3 w-3 text-muted-foreground" />
</div>
</span>
<Button variant="ghost" size="icon" class="h-6 w-6" @click="viewDetail(agent)" title="详情"><Eye class="h-3 w-3" /></Button>
<Button variant="ghost" size="icon" class="h-6 w-6" @click="viewTasks(agent)" title="查看任务"><ListTodo class="h-3 w-3" /></Button>
<Button variant="ghost" size="icon" class="h-6 w-6" @click="openEditDialog(agent)" title="编辑"><Pencil class="h-3 w-3" /></Button>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6"><MoreHorizontal class="h-3 w-3" /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-32">
<DropdownMenuItem @click="forceUpdate(agent)">
<RotateCw class="h-3.5 w-3.5 mr-2" />
<span>强制更新</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem class="text-destructive focus:text-destructive" @click="confirmDelete(agent)">
<Trash2 class="h-3.5 w-3.5 mr-2" />
<span>删除 Agent</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</span>
</div>
</div>
</div>
<!-- ========== 2. 中屏布局 (Medium 640px - 1280px) ========== -->
<div class="hidden sm:block xl:hidden">
<!-- 表头 -->
<div class="flex items-center gap-4 px-4 py-1.5 border-b bg-muted/20 text-xs text-muted-foreground font-medium">
<span class="w-12 shrink-0 pl-1">序号</span>
<span class="w-48 shrink-0">名称</span>
<span class="flex-1 min-w-0">IP 地址</span>
<span class="w-24 shrink-0 text-center">操作</span>
</div>
<!-- 列表 -->
<div class="divide-y text-sm">
<div v-for="(agent, index) in filteredAgents" :key="`medium-${agent.id}`"
class="flex items-center gap-2 px-4 py-2.5 hover:bg-muted/30 transition-colors">
<StatusDot :state="isOnline(agent) ? 'online' : 'offline'" :title="isOnline(agent) ? '在线' : '离线'" />
<div class="w-12 shrink-0 pl-1 text-muted-foreground tabular-nums text-xs">#{{ filteredAgents.length - index }}</div>
<div class="w-48 shrink-0 flex flex-col justify-center gap-0.5 overflow-hidden">
<span class="font-medium truncate">{{ agent.name }}</span>
<div v-if="agent.description" class="text-[10px] text-muted-foreground truncate">{{ agent.description }}</div>
</div>
<span class="flex-1 min-w-0 text-xs text-muted-foreground truncate">{{ agent.ip || '-' }}</span>
<div class="w-24 shrink-0 flex justify-center">
<Button variant="ghost" size="icon" class="h-6 w-6" @click="viewDetail(agent)"><Eye class="h-3 w-3" /></Button>
<Button variant="ghost" size="icon" class="h-6 w-6" @click="viewTasks(agent)"><ListTodo class="h-3 w-3" /></Button>
<Button variant="ghost" size="icon" class="h-6 w-6" @click="openEditDialog(agent)"><Pencil class="h-3 w-3" /></Button>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6"><MoreHorizontal class="h-3 w-3" /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="forceUpdate(agent)">
<RotateCw class="h-3.5 w-3.5 mr-2" />更新
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem class="text-destructive" @click="confirmDelete(agent)">
<Trash2 class="h-3.5 w-3.5 mr-2" />删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</div>
<!-- ========== 3. 小屏布局 (Small < 640px) ========== -->
<div class="divide-y sm:hidden">
<div v-if="filteredAgents.length === 0" class="text-sm text-muted-foreground text-center py-12">暂无 Agent</div>
<div v-for="(agent, index) in filteredAgents" :key="`small-${agent.id}`" class="p-3 hover:bg-muted/50 transition-colors">
<div class="flex items-start justify-between mb-3 border-b border-border/40 pb-2">
<div class="flex items-center gap-2 flex-1 min-w-0 pr-2">
<StatusDot :state="isOnline(agent) ? 'online' : 'offline'" :title="isOnline(agent) ? '在线' : '离线'" />
<span class="text-xs text-muted-foreground tabular-nums flex-shrink-0">#{{ filteredAgents.length - index }}</span>
<div class="flex items-center gap-1.5 min-w-0 flex-1">
<span class="font-bold text-sm truncate" @click="viewDetail(agent)">{{ agent.name }}</span>
</div>
</div>
<span @click="toggleEnabled(agent)" class="cursor-pointer">
<div v-if="agent.enabled" class="h-6 w-6 rounded-md bg-green-500/10 flex items-center justify-center">
<Zap class="h-3.5 w-3.5 text-green-500 fill-green-500" />
</div>
<div v-else class="h-6 w-6 rounded-md bg-muted flex items-center justify-center">
<ZapOff class="h-3.5 w-3.5 text-muted-foreground" />
</div>
</span>
</div>
<!-- 详情信息 -->
<div class="space-y-1.5 text-xs text-muted-foreground mb-3 px-1">
<div class="flex items-center gap-3">
<span class="w-10 shrink-0 font-medium opacity-70">IP:</span>
<span class="flex-1 truncate text-foreground">{{ agent.ip || '-' }}</span>
</div>
<div class="flex items-center gap-3">
<span class="w-10 shrink-0 font-medium opacity-70">主机:</span>
<span class="flex-1 truncate">{{ agent.hostname || '-' }}</span>
</div>
<div v-if="agent.description" class="flex items-start gap-3">
<span class="w-10 shrink-0 font-medium mt-0.5 opacity-70">描述:</span>
<span class="flex-1 text-[11px] line-clamp-1">{{ agent.description }}</span>
</div>
</div>
<div class="grid grid-cols-4 items-center pt-2 mt-2 border-t border-border/40 -mx-1">
<Button variant="ghost" class="h-9 px-0 text-xs gap-1.5 hover:bg-primary/5 rounded-none" @click="viewDetail(agent)">
<Eye class="h-3.5 w-3.5" />详情
</Button>
<Button variant="ghost" class="h-9 px-0 text-xs gap-1.5 hover:bg-primary/5 rounded-none border-l border-border/10" @click="viewTasks(agent)">
<ListTodo class="h-3.5 w-3.5" />任务
</Button>
<Button variant="ghost" class="h-9 px-0 text-xs gap-1.5 hover:bg-primary/5 rounded-none border-l border-border/10" @click="openEditDialog(agent)">
<Pencil class="h-3.5 w-3.5" />编辑
</Button>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" class="h-9 px-0 text-xs gap-1.5 hover:bg-primary/5 rounded-none border-l border-border/10 w-full">
<MoreHorizontal class="h-3.5 w-3.5" />更多
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-40">
<DropdownMenuItem @click="forceUpdate(agent)">
<RotateCw class="h-4 w-4 mr-2" />更新 Agent
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem class="text-destructive" @click="confirmDelete(agent)">
<Trash2 class="h-4 w-4 mr-2" />删除 Agent
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="regcodes" class="mt-0">
<div class="rounded-lg border bg-card overflow-hidden">
<!-- 表头 -->
<div class="flex items-center gap-4 px-4 py-1.5 border-b bg-muted/20 text-xs text-muted-foreground font-medium">
<span class="w-8 shrink-0">状态</span>
<span class="flex-1 min-w-0">令牌</span>
<span class="w-32 shrink-0 hidden sm:block">备注</span>
<span class="w-16 shrink-0 text-center hidden sm:block">次数</span>
<span class="w-32 shrink-0 hidden md:block">过期时间</span>
<span class="w-24 shrink-0 flex justify-end">
<Button size="sm" class="h-6 px-2 text-[10px]" @click="openTokenDialog">
<Plus class="h-3 w-3 mr-1" />生成
</Button>
</span>
</div>
<!-- 数据行 -->
<div class="divide-y text-sm">
<div v-if="tokens.length === 0" class="text-center py-12 text-muted-foreground">
<Ticket class="h-8 w-8 mx-auto mb-2 opacity-50" />暂无令牌
</div>
<div v-for="token in tokens" :key="token.id"
class="flex items-center gap-4 px-4 py-1.5 hover:bg-muted/30 transition-colors">
<!-- 状态 -->
<span class="w-8 shrink-0 flex justify-center">
<div v-if="!isTokenExpired(token) && !isTokenExhausted(token)"
class="h-5 w-5 rounded-full bg-green-500/10 flex items-center justify-center">
<Check class="h-3 w-3 text-green-500 stroke-[3]" />
</div>
<div v-else class="h-5 w-5 rounded-full bg-red-500/10 flex items-center justify-center">
<X class="h-3 w-3 text-red-500 stroke-[3]" />
</div>
</span>
<!-- Token -->
<code class="flex-1 min-w-0 font-mono text-xs bg-muted/40 px-2 py-0.5 rounded truncate text-muted-foreground">{{ token.token }}</code>
<!-- 备注 -->
<span class="w-32 shrink-0 text-xs text-muted-foreground truncate hidden sm:block">{{ token.remark || '-' }}</span>
<!-- 使用次数 -->
<span class="w-16 shrink-0 text-xs text-muted-foreground text-center hidden sm:block tabular-nums">
{{ token.used_count }}/{{ token.max_uses === 0 ? '∞' : token.max_uses }}
</span>
<!-- 过期时间 -->
<span class="w-32 shrink-0 text-[11px] text-muted-foreground truncate hidden md:block tabular-nums">
{{ token.expires_at || '永不过期' }}
</span>
<!-- 操作 -->
<span class="w-24 shrink-0 flex justify-end items-center">
<Button variant="ghost" size="icon" class="h-6 w-6" @click="copyToken(token.token)" title="复制">
<Copy class="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" class="h-6 w-6" @click="openEditToken(token)" title="编辑">
<Pencil class="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" class="h-6 w-6 text-destructive" @click="deleteToken(token.id)" title="删除">
<Trash2 class="h-3 w-3" />
</Button>
</span>
</div>
</div>
</div>
</TabsContent>
<!-- 详情对话框 -->
<Dialog v-model:open="showDetailDialog">
<DialogContent class="sm:max-w-md md:max-w-lg" @openAutoFocus.prevent>
<DialogHeader>
<DialogTitle>Agent 详情</DialogTitle>
<DialogDescription class="sr-only">显示 Agent 的详细配置和状态信息</DialogDescription>
</DialogHeader>
<div v-if="viewingAgent" class="space-y-3">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="flex items-center justify-between sm:block">
<Label class="text-muted-foreground text-xs">ID</Label>
<div class="text-sm font-medium">#{{ viewingAgent.id }}</div>
</div>
<div class="flex items-center justify-between sm:block">
<Label class="text-muted-foreground text-xs">名称</Label>
<div class="text-sm font-medium">{{ viewingAgent.name }}</div>
</div>
<div class="flex items-center justify-between sm:block">
<Label class="text-muted-foreground text-xs">IP 地址</Label>
<div class="text-sm">{{ viewingAgent.ip || '-' }}</div>
</div>
<div class="flex items-center justify-between sm:block">
<Label class="text-muted-foreground text-xs">主机名</Label>
<div class="text-sm">{{ viewingAgent.hostname || '-' }}</div>
</div>
<div class="flex items-center justify-between sm:block">
<Label class="text-muted-foreground text-xs">操作系统</Label>
<div class="text-sm">{{ viewingAgent.os || '-' }}</div>
</div>
<div class="flex items-center justify-between sm:block">
<Label class="text-muted-foreground text-xs">架构</Label>
<div class="text-sm">{{ viewingAgent.arch || '-' }}</div>
</div>
<div class="flex items-center justify-between sm:block">
<Label class="text-muted-foreground text-xs">版本</Label>
<div class="text-sm">{{ viewingAgent.version || '-' }}</div>
</div>
<div class="flex items-center justify-between sm:block">
<Label class="text-muted-foreground text-xs">构建时间</Label>
<div class="text-sm">{{ viewingAgent.build_time || '-' }}</div>
</div>
<div class="flex items-center justify-between sm:block">
<Label class="text-muted-foreground text-xs">在线状态</Label>
<div class="flex items-center gap-2">
<Wifi v-if="isOnline(viewingAgent)" class="h-4 w-4 text-green-500" />
<WifiOff v-else class="h-4 w-4 text-muted-foreground" />
<span class="text-sm">{{ isOnline(viewingAgent) ? '在线' : '离线' }}</span>
</div>
</div>
<div class="flex items-center justify-between sm:block">
<Label class="text-muted-foreground text-xs">启用状态</Label>
<div class="text-sm">{{ viewingAgent.enabled ? '已启用' : '已禁用' }}</div>
</div>
<div class="flex items-center justify-between sm:block">
<Label class="text-muted-foreground text-xs">最后心跳</Label>
<div class="text-sm">{{ viewingAgent.last_seen || '-' }}</div>
</div>
<div class="flex items-center justify-between sm:block">
<Label class="text-muted-foreground text-xs">注册时间</Label>
<div class="text-sm">{{ viewingAgent.created_at || '-' }}</div>
</div>
<div class="flex items-center justify-between sm:block">
<Label class="text-muted-foreground text-xs">任务排队</Label>
<div class="text-sm">
{{ viewingAgent.scheduler_config ? `自定义 (并发: ${viewingAgent.scheduler_config.worker_count}, 队列: ${viewingAgent.scheduler_config.queue_size}, 严格排队: ${viewingAgent.scheduler_config.strict_queue ? '是' : '否'})` : '继承全局' }}
</div>
</div>
</div>
<div v-if="viewingAgent.description" class="pt-2 border-t">
<Label class="text-muted-foreground text-xs">描述</Label>
<div class="text-sm mt-1">{{ viewingAgent.description }}</div>
</div>
</div>
</DialogContent>
</Dialog>
<!-- 编辑对话框 -->
<Dialog v-model:open="showEditDialog">
<DialogContent @openAutoFocus.prevent>
<DialogHeader>
<DialogTitle>编辑 Agent</DialogTitle>
<DialogDescription class="sr-only">修改 Agent 的名称和描述信息</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<div class="space-y-1.5">
<Label class="text-xs font-medium text-foreground">名称</Label>
<Input v-model="formData.name" placeholder="Agent 名称" class="h-9" />
</div>
<div class="space-y-1.5">
<Label class="text-xs font-medium text-foreground">描述</Label>
<Input v-model="formData.description" placeholder="描述信息(可选)" class="h-9" />
</div>
<div class="flex items-center justify-between rounded-lg border p-3 shadow-sm">
<div class="space-y-0.5">
<Label class="text-sm font-medium">自定义调度配置</Label>
<div class="text-xs text-muted-foreground">开启后可独立配置该 Agent 的并发限制与任务排队参数</div>
</div>
<Switch v-model="customScheduler" />
</div>
<div v-if="customScheduler" class="p-4 rounded-lg border border-border bg-muted/20 space-y-4 mt-2">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label class="text-xs font-medium text-foreground">并发限制数 (Workers)</Label>
<Input type="number" v-model.number="formData.scheduler_config.worker_count" :min="1" class="h-9" />
<p class="text-[10px] text-muted-foreground">同一时间最大并行任务数</p>
</div>
<div class="space-y-1.5">
<Label class="text-xs font-medium text-foreground">最大队列数 (Queue Size)</Label>
<Input type="number" v-model.number="formData.scheduler_config.queue_size" :min="1" class="h-9" />
<p class="text-[10px] text-muted-foreground">并发满时等待排队的任务数</p>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label class="text-xs font-medium text-foreground">调度频率限制 (Rate Interval)</Label>
<div class="relative">
<Input type="number" v-model.number="formData.scheduler_config.rate_interval" :min="0" class="h-9 pr-10" />
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">ms</span>
</div>
<p class="text-[10px] text-muted-foreground">两次调度启动的最小间隔时间</p>
</div>
<div class="space-y-1.5">
<Label class="text-xs font-medium text-foreground">执行降级策略</Label>
<div class="flex items-center justify-between rounded-md border border-input bg-card px-3 h-9 shadow-sm">
<span class="text-xs text-muted-foreground">队列满时拒绝执行</span>
<Switch v-model="formData.scheduler_config.strict_queue" class="scale-90" />
</div>
<p class="text-[10px] text-muted-foreground">开启后拒绝执行关闭则同步直接执行</p>
</div>
</div>
<div class="rounded-md bg-yellow-500/10 border border-yellow-500/20 p-2.5 text-[10px] text-yellow-600 dark:text-yellow-400 leading-relaxed">
<strong>提示</strong>开启严格排队后服务端将不再拦截此 Agent 上已运行任务的并行触发而是交由 Agent 本地队列调度若要严格保证任务不并行请将 <strong>并发限制数 (Workers)</strong> 设为 1
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="showEditDialog = false">取消</Button>
<Button @click="updateAgent">保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- 删除确认对话框 -->
<AlertDialog v-model:open="showDeleteDialog">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除</AlertDialogTitle>
<AlertDialogDescription>
确定要删除 Agent "{{ deletingAgent?.name }}" 此操作无法撤销
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction class="bg-destructive text-white hover:bg-destructive/90" @click="deleteAgent">删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<!-- 下载对话框 -->
<Dialog v-model:open="showDownloadDialog">
<DialogContent class="sm:max-w-lg" @openAutoFocus.prevent>
<DialogHeader>
<DialogTitle>下载 Agent</DialogTitle>
<DialogDescription>当前版本: {{ agentVersion }}</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<div class="bg-blue-500/10 text-blue-600 dark:text-blue-400 p-3 rounded-md text-sm border border-blue-500/20">
<p class="font-medium mb-1">💡 下载说明</p>
<ul class="list-disc list-inside space-y-1 text-xs opacity-90">
<li>若主程序为 <strong class="font-semibold">Docker 部署</strong>支持直接在此处下载包含配置的自动打包程序</li>
<li>若主程序为 <strong class="font-semibold">单文件二进制部署</strong>面板无法直接提供完整打包下载请前往 <a href="https://github.com/engigu/baihu-panel/releases" target="_blank" class="underline font-medium hover:text-blue-500 transition-colors">GitHub Releases</a> 手动下载对应的 Agent</li>
</ul>
</div>
<div class="space-y-2">
<div v-for="platform in platforms" :key="`${platform.os}-${platform.arch}`"
class="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors">
<span class="font-medium">{{ getPlatformLabel(platform.os, platform.arch) }}</span>
<Button size="sm" @click="downloadAgent(platform.os, platform.arch)">
<Download class="h-4 w-4 mr-1.5" />下载
</Button>
</div>
</div>
<div class="border-t pt-4">
<h4 class="font-medium mb-2">使用说明</h4>
<ol class="text-sm text-muted-foreground space-y-1.5 list-decimal list-inside">
<li>下载对应平台的 Agent 压缩包并解压</li>
<li>复制 <code class="bg-muted px-1.5 py-0.5 rounded text-foreground">config.example.ini</code> <code
class="bg-muted px-1.5 py-0.5 rounded text-foreground">config.ini</code></li>
<li>编辑 <code class="bg-muted px-1.5 py-0.5 rounded text-foreground">config.ini</code>填写服务器地址和注册令牌</li>
<li>运行 <code class="bg-muted px-1.5 py-0.5 rounded text-foreground">./baihu-agent start</code> 启动后台运行
</li>
</ol>
<div class="mt-3 text-sm text-muted-foreground">
<p class="font-medium text-foreground mb-1.5">常用命令</p>
<div class="space-y-1">
<div><code class="bg-muted px-1.5 py-0.5 rounded text-foreground text-xs">baihu-agent start</code> <span
class="text-xs">- 后台启动</span></div>
<div><code class="bg-muted px-1.5 py-0.5 rounded text-foreground text-xs">baihu-agent stop</code> <span
class="text-xs">- 停止运行</span></div>
<div><code class="bg-muted px-1.5 py-0.5 rounded text-foreground text-xs">baihu-agent status</code>
<span class="text-xs">- 查看状态</span>
</div>
<div><code class="bg-muted px-1.5 py-0.5 rounded text-foreground text-xs">baihu-agent logs</code> <span
class="text-xs">- 查看日志</span></div>
<div><code class="bg-muted px-1.5 py-0.5 rounded text-foreground text-xs">baihu-agent run</code> <span
class="text-xs">- 前台运行</span></div>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
<!-- 创建令牌对话框 -->
<Dialog v-model:open="showTokenDialog">
<DialogContent @openAutoFocus.prevent>
<DialogHeader>
<DialogTitle>生成令牌</DialogTitle>
<DialogDescription class="sr-only">创建一个新的注册令牌用于 Agent 认证</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<div>
<Label>备注</Label>
<Input v-model="tokenForm.remark" placeholder="备注信息(可选)" />
</div>
<div>
<Label>最大使用次数</Label>
<Input v-model.number="tokenForm.max_uses" type="number" placeholder="0 表示无限制" />
</div>
<div>
<Label>过期时间</Label>
<Input v-model="tokenForm.expires_at" type="datetime-local" />
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="showTokenDialog = false">取消</Button>
<Button @click="createToken">生成</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- 编辑令牌对话框 -->
<Dialog v-model:open="showEditTokenDialog">
<DialogContent @openAutoFocus.prevent>
<DialogHeader>
<DialogTitle>编辑令牌</DialogTitle>
<DialogDescription class="sr-only">修改令牌的备注使用次数和过期时间</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<div>
<Label>备注</Label>
<Input v-model="editTokenForm.remark" placeholder="备注信息(可选)" />
</div>
<div>
<Label>最大使用次数</Label>
<Input v-model.number="editTokenForm.max_uses" type="number" placeholder="0 表示无限制" />
</div>
<div>
<Label>过期时间</Label>
<Input v-model="editTokenForm.expires_at" type="datetime-local" />
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="showEditTokenDialog = false">取消</Button>
<Button @click="updateToken">保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Tabs>
</template>