refactor(web): extract components and refactor Agents.vue
- Extracted AgentListTab and TokenListTab - Extracted Dialog components for Agents and Tokens - Refactored Agents.vue into a lightweight container
This commit is contained in:
+49
-706
@@ -2,30 +2,18 @@
|
|||||||
import { ref, onMounted, computed, onUnmounted } from 'vue'
|
import { ref, onMounted, computed, onUnmounted } from 'vue'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import {
|
import { RefreshCw, Server, Search, Download, Ticket } from 'lucide-vue-next'
|
||||||
RefreshCw, Trash2, Pencil, Copy, Server, Search, Download, RotateCw,
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
|
||||||
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 { api, type Agent, type AgentToken } from '@/api'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { AGENT_STATUS } from '@/constants'
|
|
||||||
import StatusDot from '@/components/StatusDot.vue'
|
|
||||||
|
|
||||||
const router = useRouter()
|
import AgentListTab from './components/AgentListTab.vue'
|
||||||
|
import TokenListTab from './components/TokenListTab.vue'
|
||||||
|
import AgentDetailDialog from './components/AgentDetailDialog.vue'
|
||||||
|
import EditAgentDialog from './components/EditAgentDialog.vue'
|
||||||
|
import DownloadAgentDialog from './components/DownloadAgentDialog.vue'
|
||||||
|
import EditTokenDialog from './components/EditTokenDialog.vue'
|
||||||
|
|
||||||
const agents = ref<Agent[]>([])
|
const agents = ref<Agent[]>([])
|
||||||
const tokens = ref<AgentToken[]>([])
|
const tokens = ref<AgentToken[]>([])
|
||||||
@@ -34,30 +22,17 @@ const searchQuery = ref('')
|
|||||||
const activeTab = ref('agents')
|
const activeTab = ref('agents')
|
||||||
const agentVersion = ref('')
|
const agentVersion = ref('')
|
||||||
const platforms = ref<{ os: string; arch: string; filename: string }[]>([])
|
const platforms = ref<{ os: string; arch: string; filename: string }[]>([])
|
||||||
const showEditDialog = ref(false)
|
|
||||||
|
// Dialog refs
|
||||||
|
const agentDetailDialogRef = ref<InstanceType<typeof AgentDetailDialog> | null>(null)
|
||||||
|
const editAgentDialogRef = ref<InstanceType<typeof EditAgentDialog> | null>(null)
|
||||||
|
const downloadAgentDialogRef = ref<InstanceType<typeof DownloadAgentDialog> | null>(null)
|
||||||
|
const editTokenDialogRef = ref<InstanceType<typeof EditTokenDialog> | null>(null)
|
||||||
|
|
||||||
|
// Delete Dialog state
|
||||||
const showDeleteDialog = 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 deletingAgent = ref<Agent | null>(null)
|
||||||
const viewingAgent = ref<Agent | null>(null)
|
|
||||||
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
const filteredAgents = computed(() => {
|
const filteredAgents = computed(() => {
|
||||||
@@ -70,10 +45,6 @@ const filteredAgents = computed(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
function isOnline(agent: Agent): boolean {
|
|
||||||
return agent.status === AGENT_STATUS.ONLINE
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAgents() {
|
async function loadAgents() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -93,86 +64,20 @@ async function loadAgents() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handler functions for Agent
|
||||||
function viewDetail(agent: Agent) {
|
function viewDetail(agent: Agent) {
|
||||||
;(document.activeElement as HTMLElement)?.blur()
|
agentDetailDialogRef.value?.openDialog(agent)
|
||||||
viewingAgent.value = agent
|
|
||||||
showDetailDialog.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEditDialog(agent: Agent) {
|
function openEditDialog(agent: Agent) {
|
||||||
;(document.activeElement as HTMLElement)?.blur()
|
editAgentDialogRef.value?.openDialog(agent)
|
||||||
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) {
|
function confirmDelete(agent: Agent) {
|
||||||
;(document.activeElement as HTMLElement)?.blur()
|
|
||||||
deletingAgent.value = agent
|
deletingAgent.value = agent
|
||||||
showDeleteDialog.value = true
|
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() {
|
async function deleteAgent() {
|
||||||
if (!deletingAgent.value) return
|
if (!deletingAgent.value) return
|
||||||
try {
|
try {
|
||||||
@@ -185,103 +90,17 @@ async function deleteAgent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function forceUpdate(agent: Agent) {
|
function openDownloadDialog() {
|
||||||
try {
|
downloadAgentDialogRef.value?.openDialog()
|
||||||
await api.agents.forceUpdate(agent.id)
|
|
||||||
toast.success('已标记强制更新')
|
|
||||||
} catch (e: unknown) {
|
|
||||||
toast.error((e as Error).message || '操作失败')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function viewTasks(agent: Agent) {
|
// Handler functions for Token
|
||||||
router.push({ path: '/tasks', query: { agent_id: String(agent.id) } })
|
function openCreateToken() {
|
||||||
}
|
editTokenDialogRef.value?.openCreate()
|
||||||
|
|
||||||
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) {
|
function openEditToken(token: AgentToken) {
|
||||||
;(document.activeElement as HTMLElement)?.blur()
|
editTokenDialogRef.value?.openEdit(token)
|
||||||
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(() => {
|
onMounted(() => {
|
||||||
@@ -294,7 +113,6 @@ onUnmounted(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Tabs v-model="activeTab" class="space-y-6">
|
<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 md:flex-row md:items-center justify-between gap-4">
|
||||||
@@ -334,396 +152,32 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TabsContent value="agents" class="mt-0">
|
<TabsContent value="agents" class="mt-0">
|
||||||
<div class="rounded-lg border bg-card overflow-hidden">
|
<AgentListTab
|
||||||
<!-- ========== 1. 大屏布局 (Large >= 1280px) ========== -->
|
:agents="filteredAgents"
|
||||||
<div class="hidden xl:block">
|
:searchQuery="searchQuery"
|
||||||
<!-- 表头 -->
|
@view-detail="viewDetail"
|
||||||
<div class="flex items-center gap-4 px-4 py-1.5 border-b bg-muted/20 text-xs text-muted-foreground font-medium">
|
@edit-agent="openEditDialog"
|
||||||
<span class="w-12 shrink-0 pl-1">序号</span>
|
@delete-agent="confirmDelete"
|
||||||
<span class="w-48 shrink-0">名称</span>
|
@refresh="loadAgents"
|
||||||
<span class="w-32 shrink-0">IP 地址</span>
|
/>
|
||||||
<span class="w-32 shrink-0">主机名</span>
|
</TabsContent>
|
||||||
<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) ========== -->
|
<TabsContent value="regcodes" class="mt-0">
|
||||||
<div class="hidden sm:block xl:hidden">
|
<TokenListTab
|
||||||
<!-- 表头 -->
|
:tokens="tokens"
|
||||||
<div class="flex items-center gap-4 px-4 py-1.5 border-b bg-muted/20 text-xs text-muted-foreground font-medium">
|
@create-token="openCreateToken"
|
||||||
<span class="w-12 shrink-0 pl-1">序号</span>
|
@edit-token="openEditToken"
|
||||||
<span class="w-48 shrink-0">名称</span>
|
@refresh="loadAgents"
|
||||||
<span class="flex-1 min-w-0">IP 地址</span>
|
/>
|
||||||
<span class="w-28 shrink-0 text-center">操作</span>
|
</TabsContent>
|
||||||
</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-28 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">
|
|
||||||
<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">
|
<AgentDetailDialog ref="agentDetailDialogRef" />
|
||||||
<div v-if="filteredAgents.length === 0" class="text-sm text-muted-foreground text-center py-12">暂无 Agent</div>
|
<EditAgentDialog ref="editAgentDialogRef" @updated="loadAgents" />
|
||||||
<div v-for="(agent, index) in filteredAgents" :key="`small-${agent.id}`" class="p-3 hover:bg-muted/50 transition-colors">
|
<DownloadAgentDialog ref="downloadAgentDialogRef" :agentVersion="agentVersion" :platforms="platforms" />
|
||||||
<div class="flex items-start justify-between mb-3 border-b border-border/40 pb-2">
|
<EditTokenDialog ref="editTokenDialogRef" @updated="loadAgents" />
|
||||||
<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">
|
<!-- 删除确认对话框保持在主组件,因为它比较简单,且关联了直接的 API 删除请求 -->
|
||||||
<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">
|
<AlertDialog v-model:open="showDeleteDialog">
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
@@ -739,116 +193,5 @@ onUnmounted(() => {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</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>
|
</Tabs>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Wifi, WifiOff } from 'lucide-vue-next'
|
||||||
|
import { type Agent } from '@/api'
|
||||||
|
import { AGENT_STATUS } from '@/constants'
|
||||||
|
|
||||||
|
const isOpen = ref(false)
|
||||||
|
const viewingAgent = ref<Agent | null>(null)
|
||||||
|
|
||||||
|
function isOnline(agent: Agent): boolean {
|
||||||
|
return agent.status === AGENT_STATUS.ONLINE
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDialog(agent: Agent) {
|
||||||
|
viewingAgent.value = agent
|
||||||
|
isOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ openDialog })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog v-model:open="isOpen">
|
||||||
|
<DialogContent class="sm:max-w-md md:max-w-lg">
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import {
|
||||||
|
Trash2, Pencil, Eye, ListTodo,
|
||||||
|
Zap, ZapOff, RotateCw, MoreHorizontal, Server
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import StatusDot from '@/components/StatusDot.vue'
|
||||||
|
import { type Agent, api } from '@/api'
|
||||||
|
import { AGENT_STATUS } from '@/constants'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
agents: Agent[]
|
||||||
|
searchQuery: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'view-detail', agent: Agent): void
|
||||||
|
(e: 'edit-agent', agent: Agent): void
|
||||||
|
(e: 'delete-agent', agent: Agent): void
|
||||||
|
(e: 'refresh'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function isOnline(agent: Agent): boolean {
|
||||||
|
return agent.status === AGENT_STATUS.ONLINE
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewDetail(agent: Agent) {
|
||||||
|
emit('view-detail', agent)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(agent: Agent) {
|
||||||
|
emit('edit-agent', agent)
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(agent: Agent) {
|
||||||
|
emit('delete-agent', agent)
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewTasks(agent: Agent) {
|
||||||
|
router.push({ path: '/tasks', query: { agent_id: String(agent.id) } })
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
emit('refresh')
|
||||||
|
toast.success(`${agent.name} 已${newEnabled ? '启用' : '禁用'}`)
|
||||||
|
} 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 || '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<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="agents.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 agents" :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">#{{ agents.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-28 shrink-0 text-center">操作</span>
|
||||||
|
</div>
|
||||||
|
<!-- 列表 -->
|
||||||
|
<div class="divide-y text-sm">
|
||||||
|
<div v-for="(agent, index) in agents" :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">#{{ agents.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-28 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">
|
||||||
|
<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="agents.length === 0" class="text-sm text-muted-foreground text-center py-12">暂无 Agent</div>
|
||||||
|
<div v-for="(agent, index) in agents" :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">#{{ agents.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>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Download } from 'lucide-vue-next'
|
||||||
|
import { api } from '@/api'
|
||||||
|
|
||||||
|
const isOpen = ref(false)
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
agentVersion: string
|
||||||
|
platforms: { os: string; arch: string; filename: string }[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function openDialog() {
|
||||||
|
isOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ openDialog })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog v-model:open="isOpen">
|
||||||
|
<DialogContent class="sm:max-w-lg">
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { api, type Agent } from '@/api'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'updated'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isOpen = ref(false)
|
||||||
|
const editingAgent = ref<Agent | null>(null)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function openDialog(agent: Agent) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isOpen.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)
|
||||||
|
isOpen.value = false
|
||||||
|
emit('updated')
|
||||||
|
toast.success('更新成功')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toast.error((e as Error).message || '更新失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ openDialog })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog v-model:open="isOpen">
|
||||||
|
<DialogContent>
|
||||||
|
<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="isOpen = false">取消</Button>
|
||||||
|
<Button @click="updateAgent">保存</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { api, type AgentToken } from '@/api'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'updated'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isOpen = ref(false)
|
||||||
|
const isEdit = ref(false)
|
||||||
|
const editingToken = ref<AgentToken | null>(null)
|
||||||
|
const formData = ref({ remark: '', max_uses: 0, expires_at: '' })
|
||||||
|
|
||||||
|
const title = computed(() => isEdit.value ? '编辑令牌' : '生成令牌')
|
||||||
|
const description = computed(() => isEdit.value ? '修改令牌的备注、使用次数和过期时间' : '创建一个新的注册令牌,用于 Agent 认证')
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
isEdit.value = false
|
||||||
|
editingToken.value = null
|
||||||
|
formData.value = { remark: '', max_uses: 0, expires_at: '' }
|
||||||
|
isOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(token: AgentToken) {
|
||||||
|
isEdit.value = true
|
||||||
|
editingToken.value = token
|
||||||
|
const rawExpires = token.expires_at?.replace(' ', 'T').slice(0, 16) || ''
|
||||||
|
formData.value = { remark: token.remark || '', max_uses: token.max_uses, expires_at: rawExpires }
|
||||||
|
isOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
try {
|
||||||
|
let expiresAt = formData.value.expires_at
|
||||||
|
if (expiresAt) {
|
||||||
|
expiresAt = expiresAt.replace('T', ' ') + ':00'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEdit.value && editingToken.value) {
|
||||||
|
await api.agents.updateToken(editingToken.value.id, {
|
||||||
|
remark: formData.value.remark,
|
||||||
|
max_uses: formData.value.max_uses,
|
||||||
|
expires_at: expiresAt || undefined
|
||||||
|
})
|
||||||
|
toast.success('更新成功')
|
||||||
|
} else {
|
||||||
|
await api.agents.createToken({
|
||||||
|
remark: formData.value.remark,
|
||||||
|
max_uses: formData.value.max_uses,
|
||||||
|
expires_at: expiresAt || undefined
|
||||||
|
})
|
||||||
|
toast.success('创建成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
isOpen.value = false
|
||||||
|
emit('updated')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toast.error((e as Error).message || (isEdit.value ? '更新失败' : '创建失败'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ openCreate, openEdit })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog v-model:open="isOpen">
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{ title }}</DialogTitle>
|
||||||
|
<DialogDescription class="sr-only">{{ description }}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label>备注</Label>
|
||||||
|
<Input v-model="formData.remark" placeholder="备注信息(可选)" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>最大使用次数</Label>
|
||||||
|
<Input v-model.number="formData.max_uses" type="number" placeholder="0 表示无限制" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>过期时间</Label>
|
||||||
|
<Input v-model="formData.expires_at" type="datetime-local" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" @click="isOpen = false">取消</Button>
|
||||||
|
<Button @click="handleSave">{{ isEdit ? '保存' : '生成' }}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Plus, Ticket, Check, X, Copy, Pencil, Trash2 } from 'lucide-vue-next'
|
||||||
|
import { type AgentToken, api } from '@/api'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
tokens: AgentToken[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'create-token'): void
|
||||||
|
(e: 'edit-token', token: AgentToken): void
|
||||||
|
(e: 'refresh'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function isTokenExpired(token: AgentToken) {
|
||||||
|
if (!token.expires_at) return false
|
||||||
|
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 copyToken(token: string) {
|
||||||
|
navigator.clipboard.writeText(token)
|
||||||
|
toast.success('已复制')
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTokenDialog() {
|
||||||
|
emit('create-token')
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditToken(token: AgentToken) {
|
||||||
|
emit('edit-token', token)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteToken(id: string) {
|
||||||
|
try {
|
||||||
|
await api.agents.deleteToken(id)
|
||||||
|
emit('refresh')
|
||||||
|
toast.success('删除成功')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toast.error((e as Error).message || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user