refactor: Ansible template list — cards to table with batch delete, edit dialog, new template

- AnsibleManager: card grid → table list with checkbox/批量删除/edit/clone/delete
- AnsibleWorkflow: template step cards → table, add 新建模板 button
- Edit: window.prompt → AnsibleFormDialog (name/YAML/category/tags)
- Delete: window.confirm → _confirm dialog
- Batch delete: window.confirm → _confirm dialog
- Clone: card hover → table row button
This commit is contained in:
attacker committed 2026-06-19 15:28:52 +00:00
1 parent 732974d152
commit 0fea0ccc20
2 files changed
+391 -219

No files matched your search

@@ -1,24 +1,24 @@
import React, { useState, useEffect } from 'react';
import {
RotateCcw, Plus, Search, Trash2, Loader2,
Eye, Pencil, Copy, Play, Tags,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { ansibleApi, taskExecutionApi } from '@/services/api';
import {
mapPlaybook, categories, samplePlaybook,
mapPlaybook, categories, samplePlaybook, formatDate,
type Playbook, type ExecutionRecord,
} from './ansible-helpers';
import AnsibleFormDialog, { type PlaybookFormData } from './AnsibleFormDialog';
import AnsibleDetailDialog from './AnsibleDetailDialog';
import AnsibleLogsDialog from './AnsibleLogsDialog';
import AnsibleStatsCards from './AnsibleStatsCards';
import AnsiblePlaybookCard from './AnsiblePlaybookCard';
import AnsibleExecutionsTable from './AnsibleExecutionsTable';
const AnsibleManager: React.FC = () => {
@@ -40,7 +40,6 @@ const AnsibleManager: React.FC = () => {
const [selectedPlaybook, setSelectedPlaybook] = useState<Playbook | null>(null);
const [selectedExec, setSelectedExec] = useState<ExecutionRecord | null>(null);
// 表单状态
const [formData, setFormData] = useState<PlaybookFormData>({
name: '', description: '', content: '', category: 'deployment', tags: '', timeout: 600,
});
@@ -48,10 +47,10 @@ const AnsibleManager: React.FC = () => {
const fetchPlaybooks = async () => {
setLoading(true);
try {
const res = await ansibleApi.getPlaybooks({ page_size: 100 });
const res = await ansibleApi.getPlaybooks({ page_size: 200 });
const items = (res as any)?.list || [];
setPlaybooks(items.map(mapPlaybook));
} catch (error) {
} catch {
toast.error('获取 Playbook 列表失败');
} finally {
setLoading(false);
@@ -61,10 +60,9 @@ const AnsibleManager: React.FC = () => {
const fetchExecutions = async () => {
try {
const res = await taskExecutionApi.getExecutions({ task_type: 'playbook', page_size: 50 });
const items = (res as any)?.list || [];
setExecutions(items);
} catch (error) {
// 静默失败,避免切换 tab 时弹窗
setExecutions((res as any)?.list || []);
} catch {
// 静默
}
};
@@ -74,7 +72,6 @@ const AnsibleManager: React.FC = () => {
}, []);
// ---- 新建/编辑 ----
const openCreate = () => {
setEditingId(null);
setFormData({ name: '', description: '', content: samplePlaybook, category: 'deployment', tags: '', timeout: 600 });
@@ -84,17 +81,12 @@ const AnsibleManager: React.FC = () => {
const openEdit = (pb: Playbook) => {
setEditingId(pb.id);
setFormData({
name: pb.name,
description: pb.description,
content: pb.content,
category: pb.category,
tags: pb.tags.join(', '),
timeout: pb.timeout,
name: pb.name, description: pb.description, content: pb.content,
category: pb.category, tags: pb.tags.join(', '), timeout: pb.timeout,
});
setEditorOpen(true);
};
// 将逗号分隔的标签字符串转为 JSON 数组字符串 // Logan.Li
const tagsToJson = (raw: string): string => {
const arr = raw.split(',').map(t => t.trim()).filter(Boolean);
return JSON.stringify(arr);
@@ -106,49 +98,36 @@ const AnsibleManager: React.FC = () => {
return;
}
const payload = {
name: formData.name,
description: formData.description,
content: formData.content,
category: formData.category,
tags: tagsToJson(formData.tags),
timeout: Number(formData.timeout) || 600,
name: formData.name, description: formData.description, content: formData.content,
category: formData.category, tags: tagsToJson(formData.tags), timeout: Number(formData.timeout) || 600,
};
try {
if (editingId !== null) {
await ansibleApi.updatePlaybook(editingId, payload);
toast.success('Playbook 更新成功');
toast.success('更新成功');
} else {
await ansibleApi.createPlaybook(payload);
toast.success('Playbook 创建成功');
toast.success('创建成功');
}
setEditorOpen(false);
fetchPlaybooks();
} catch (error) {
} catch {
toast.error(editingId !== null ? '更新失败' : '创建失败');
}
};
// ---- 复制 ----
// ---- 克隆 ----
const handleClone = async (pb: Playbook) => {
try {
await ansibleApi.createPlaybook({
name: `${pb.name} (副本)`,
description: pb.description,
content: pb.content,
category: pb.category,
tags: JSON.stringify(pb.tags),
timeout: pb.timeout,
});
toast.success('复制成功');
await ansibleApi.clonePlaybook(pb.id);
toast.success('克隆成功');
fetchPlaybooks();
} catch (error) {
toast.error('复制失败');
} catch {
toast.error('克隆失败');
}
};
// ---- 删除 ----
// ---- 单个删除 ----
const handleDelete = async (pb: Playbook) => {
const ok = await _confirm({
title: '删除 Playbook',
@@ -159,54 +138,50 @@ const AnsibleManager: React.FC = () => {
if (!ok) return;
try {
await ansibleApi.deletePlaybook(pb.id);
toast.success('删除成功');
toast.success('已删除');
fetchPlaybooks();
} catch (error) {
} catch {
toast.error('删除失败');
}
};
// ---- 批量选择 / 删除 ---- // Logan.Li
// ---- 批量删除 ----
const toggleSelect = (id: number) => {
setSelectedIds((prev) =>
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id]
);
setSelectedIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]);
};
const toggleSelectAll = () => {
if (selectedIds.length === filteredPlaybooks.length && filteredPlaybooks.length > 0) {
if (selectedIds.length === filtered.length && filtered.length > 0) {
setSelectedIds([]);
} else {
setSelectedIds(filteredPlaybooks.map((pb) => pb.id));
setSelectedIds(filtered.map(p => p.id));
}
};
const handleBatchDelete = async () => {
if (selectedIds.length === 0) return;
const ok = await _confirm({
title: '批量删除 Playbook',
description: `确定删除选中的 ${selectedIds.length} 个 Playbook 吗?此操作不可恢复。`,
title: '批量删除',
description: `确定删除选中的 ${selectedIds.length} 个 Playbook 吗?`,
confirmText: '批量删除',
variant: 'destructive',
});
if (!ok) return;
try {
await ansibleApi.batchDeletePlaybooks(selectedIds);
toast.success(`已删除 ${selectedIds.length} 个 Playbook`);
toast.success(`已删除 ${selectedIds.length} 个`);
setSelectedIds([]);
fetchPlaybooks();
} catch (error) {
} catch {
toast.error('批量删除失败');
}
};
// ---- 执行 ----
const handleExecute = async (pb: Playbook) => {
const ok = await _confirm({
title: '执行 Playbook',
description: `将在 AIOps 主机上本地执行「${pb.name}」(ansible-playbook --connection=local)。`,
description: `将在 AIOps 主机本地执行「${pb.name}」(ansible-playbook --connection=local)。`,
confirmText: '开始执行',
});
if (!ok) return;
@@ -217,67 +192,50 @@ const AnsibleManager: React.FC = () => {
if (exec?.status === 'success') {
toast.success(`执行成功,耗时 ${exec.duration ?? 0}ms`);
} else {
toast.error(`执行失败(退出码异常),耗时 ${exec?.duration ?? 0}ms`);
toast.error(`执行失败,耗时 ${exec?.duration ?? 0}ms`);
}
fetchPlaybooks();
fetchExecutions();
} catch (error) {
} catch {
toast.error('执行请求失败');
} finally {
setExecuting(false);
}
};
// ---- 辅助 ----
const filteredPlaybooks = playbooks.filter(pb => {
// ---- 过滤 ----
const filtered = playbooks.filter(pb => {
const q = searchQuery.toLowerCase();
const matchSearch = pb.name.toLowerCase().includes(q) ||
pb.description.toLowerCase().includes(q) ||
return pb.name.toLowerCase().includes(q) || pb.description.toLowerCase().includes(q) ||
pb.tags.some(t => t.toLowerCase().includes(q));
const matchCategory = categoryFilter === 'all' || pb.category === categoryFilter;
return matchSearch && matchCategory;
});
}).filter(pb => categoryFilter === 'all' || pb.category === categoryFilter);
const runningCount = executions.filter(e => e.status === 'running').length;
const todaySuccess = executions.filter(e =>
e.status === 'success' && new Date(e.created_at).toDateString() === new Date().toDateString()
).length;
const todayFailed = executions.filter(e =>
e.status === 'failed' && new Date(e.created_at).toDateString() === new Date().toDateString()
).length;
const catLabel = (v: string) => categories.find(c => c.value === v)?.label || v;
return (
<div className="space-y-6">
{/* 统计卡片 */}
<AnsibleStatsCards
total={playbooks.length}
running={runningCount}
todaySuccess={todaySuccess}
todayFailed={todayFailed}
/>
<Tabs value={activeTab} onValueChange={(v) => { setActiveTab(v); if (v === 'jobs') fetchExecutions(); }}>
<div className="space-y-4">
<Tabs value={activeTab} onValueChange={v => { setActiveTab(v); if (v === 'jobs') fetchExecutions(); }}>
<TabsList className="grid w-full grid-cols-2 lg:w-[360px]">
<TabsTrigger value="playbooks">Playbook 管理</TabsTrigger>
<TabsTrigger value="playbooks">模板管理</TabsTrigger>
<TabsTrigger value="jobs">执行记录</TabsTrigger>
</TabsList>
{/* ============ Playbook 管理 ============ */}
{/* ============ 模板管理 ============ */}
<TabsContent value="playbooks" className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-4">
{/* 搜索栏 + 新建按钮 */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 flex-1 min-w-[300px]">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="搜索名称、标签..."
placeholder="搜索名称、描述、标签..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onChange={e => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="w-[140px]">
<SelectTrigger className="w-[130px]">
<SelectValue placeholder="分类" />
</SelectTrigger>
<SelectContent>
@@ -289,72 +247,129 @@ const AnsibleManager: React.FC = () => {
</Select>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={fetchPlaybooks}>
<RotateCcw className="w-4 h-4 mr-2" />
刷新
{selectedIds.length > 0 && (
<Button variant="destructive" size="sm" onClick={handleBatchDelete}>
<Trash2 className="w-4 h-4 mr-1" />
删除 ({selectedIds.length})
</Button>
)}
<Button variant="outline" size="sm" onClick={fetchPlaybooks}>
<RotateCcw className="w-4 h-4" />
</Button>
<Button onClick={openCreate}>
<Plus className="w-4 h-4 mr-2" />
新建 Playbook
<Plus className="w-4 h-4 mr-1" />
新建模板
</Button>
</div>
</div>
{/* 批量操作栏 */}
{filteredPlaybooks.length > 0 && (
<div className="flex items-center justify-between gap-2 rounded-lg border bg-muted/40 px-4 py-2">
<div className="flex items-center gap-3">
<Checkbox
checked={
selectedIds.length === filteredPlaybooks.length && filteredPlaybooks.length > 0
}
onCheckedChange={toggleSelectAll}
/>
<span className="text-sm text-muted-foreground">
{selectedIds.length > 0
? `已选择 ${selectedIds.length} / ${filteredPlaybooks.length} 项`
: `共 ${filteredPlaybooks.length} 项`}
</span>
</div>
{selectedIds.length > 0 && (
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => setSelectedIds([])}>
取消选择
</Button>
<Button variant="destructive" size="sm" onClick={handleBatchDelete}>
<Trash2 className="w-4 h-4 mr-2" />
批量删除
</Button>
</div>
)}
</div>
)}
{/* Playbook 卡片列表 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{loading ? (
<div className="col-span-full text-center py-8 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin inline mr-2" />
加载中...
</div>
) : filteredPlaybooks.length === 0 ? (
<div className="col-span-full text-center py-8 text-muted-foreground">暂无 Playbook</div>
) : (
filteredPlaybooks.map((playbook) => (
<AnsiblePlaybookCard
key={playbook.id}
playbook={playbook}
selected={selectedIds.includes(playbook.id)}
executing={executing}
onToggleSelect={toggleSelect}
onView={(pb) => { setSelectedPlaybook(pb); setDetailOpen(true); }}
onExecute={handleExecute}
onEdit={openEdit}
onClone={handleClone}
onDelete={handleDelete}
/>
))
)}
{/* 表格 */}
<div className="rounded-md border">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b">
<tr>
<th className="w-10 px-3 py-3 text-left">
<Checkbox
checked={selectedIds.length === filtered.length && filtered.length > 0}
onCheckedChange={toggleSelectAll}
/>
</th>
<th className="px-3 py-3 text-left font-medium">名称</th>
<th className="px-3 py-3 text-left font-medium hidden md:table-cell">分类</th>
<th className="px-3 py-3 text-left font-medium hidden lg:table-cell">标签</th>
<th className="px-3 py-3 text-left font-medium hidden xl:table-cell">执行次数</th>
<th className="px-3 py-3 text-left font-medium hidden xl:table-cell">更新时间</th>
<th className="px-3 py-3 text-right font-medium">操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="text-center py-12 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin inline mr-2" />
加载中...
</td>
</tr>
) : filtered.length === 0 ? (
<tr>
<td colSpan={7} className="text-center py-12 text-muted-foreground">
暂无模板,点击"新建模板"创建
</td>
</tr>
) : (
filtered.map(pb => (
<tr key={pb.id} className="border-b hover:bg-muted/30 transition-colors">
<td className="px-3 py-3">
<Checkbox
checked={selectedIds.includes(pb.id)}
onCheckedChange={() => toggleSelect(pb.id)}
/>
</td>
<td className="px-3 py-3">
<div className="font-medium truncate max-w-[200px]">{pb.name}</div>
{pb.description && (
<div className="text-xs text-muted-foreground truncate max-w-[200px]">{pb.description}</div>
)}
</td>
<td className="px-3 py-3 hidden md:table-cell">
<Badge variant="secondary" className="text-xs">{catLabel(pb.category)}</Badge>
</td>
<td className="px-3 py-3 hidden lg:table-cell">
<div className="flex flex-wrap gap-1">
{pb.tags.length > 0 ? pb.tags.map(t => (
<Badge key={t} variant="outline" className="text-xs"><Tags className="w-3 h-3 mr-0.5" />{t}</Badge>
)) : <span className="text-muted-foreground">-</span>}
</div>
</td>
<td className="px-3 py-3 hidden xl:table-cell text-muted-foreground">{pb.run_count}</td>
<td className="px-3 py-3 hidden xl:table-cell text-muted-foreground text-xs">
{formatDate(pb.updated_at)}
</td>
<td className="px-3 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost" size="icon" className="h-8 w-8"
title="查看内容"
onClick={() => { setSelectedPlaybook(pb); setDetailOpen(true); }}
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost" size="icon" className="h-8 w-8"
title="编辑"
onClick={() => openEdit(pb)}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost" size="icon" className="h-8 w-8"
title="克隆"
onClick={() => handleClone(pb)}
>
<Copy className="w-4 h-4" />
</Button>
<Button
variant="ghost" size="icon" className="h-8 w-8 text-green-600"
title="执行"
disabled={executing}
onClick={() => handleExecute(pb)}
>
<Play className="w-4 h-4" />
</Button>
<Button
variant="ghost" size="icon" className="h-8 w-8 text-destructive"
title="删除"
onClick={() => handleDelete(pb)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</TabsContent>
@@ -363,12 +378,12 @@ const AnsibleManager: React.FC = () => {
<AnsibleExecutionsTable
executions={executions}
onRefresh={fetchExecutions}
onViewLogs={(exec) => { setSelectedExec(exec); setLogsOpen(true); }}
onViewLogs={exec => { setSelectedExec(exec); setLogsOpen(true); }}
/>
</TabsContent>
</Tabs>
{/* ===== 新建/编辑 Playbook 对话框 ===== */}
{/* 新建/编辑对话框 */}
<AnsibleFormDialog
open={editorOpen}
onOpenChange={setEditorOpen}
@@ -378,15 +393,15 @@ const AnsibleManager: React.FC = () => {
onSave={handleSave}
/>
{/* ===== 查看 Playbook 内容 ===== */}
{/* 查看内容对话框 */}
<AnsibleDetailDialog
open={detailOpen}
onOpenChange={setDetailOpen}
playbook={selectedPlaybook}
onEdit={(pb) => { setDetailOpen(false); openEdit(pb); }}
onEdit={pb => { setDetailOpen(false); openEdit(pb); }}
/>
{/* ===== 执行日志 ===== */}
{/* 执行日志对话框 */}
<AnsibleLogsDialog
open={logsOpen}
onOpenChange={setLogsOpen}
@@ -20,13 +20,14 @@ import {
Play, Server, ChevronRight, ChevronLeft, CheckCircle, Loader2,
Search, RefreshCw, Terminal, Clock, XCircle, FileText, Zap,
Boxes, Shield, Database, Activity, Wrench, Network,
Copy, Plus, ExternalLink,
Copy, Plus, Trash2, Pencil, Eye,
} from 'lucide-react';
import {
mapPlaybook, categories, getStatusBadge, formatDate,
mapPlaybook, categories, getStatusBadge, formatDate, samplePlaybook,
type Playbook, type ExecutionRecord,
} from './ansible-helpers';
import { useConfirm } from '@/components/ui/confirm-dialog';
import AnsibleFormDialog, { type PlaybookFormData } from './AnsibleFormDialog';
// ============ Types ============
@@ -68,6 +69,60 @@ export default function AnsibleWorkflow() {
const [searchPB, setSearchPB] = useState('');
const [catFilter, setCatFilter] = useState('all');
const [selectedPB, setSelectedPB] = useState<Playbook | null>(null);
const [selectedPBs, setSelectedPBs] = useState<Set<number>>(new Set()); // 批量选择
// 模板编辑弹窗
const [editorOpen, setEditorOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [formData, setFormData] = useState<PlaybookFormData>({
name: '', description: '', content: '', category: 'deployment', tags: '', timeout: 600,
});
// ---- 新建模板 ----
const openCreate = () => {
setEditingId(null);
setFormData({ name: '', description: '', content: samplePlaybook, category: 'deployment', tags: '', timeout: 600 });
setEditorOpen(true);
};
// ---- 编辑模板 ----
const openEdit = (pb: Playbook) => {
setEditingId(pb.id);
setFormData({
name: pb.name, description: pb.description, content: pb.content,
category: pb.category, tags: pb.tags.join(', '), timeout: pb.timeout,
});
setEditorOpen(true);
};
const tagsToJson = (raw: string): string => {
const arr = raw.split(',').map(t => t.trim()).filter(Boolean);
return JSON.stringify(arr);
};
const handleSave = async () => {
if (!formData.name || !formData.content) {
toast.error('请填写名称和 YAML 内容');
return;
}
const payload = {
name: formData.name, description: formData.description, content: formData.content,
category: formData.category, tags: tagsToJson(formData.tags), timeout: Number(formData.timeout) || 600,
};
try {
if (editingId !== null) {
await ansibleApi.updatePlaybook(editingId, payload);
toast.success('更新成功');
} else {
await ansibleApi.createPlaybook(payload);
toast.success('创建成功');
}
setEditorOpen(false);
fetchPlaybooks();
} catch {
toast.error(editingId !== null ? '更新失败' : '创建失败');
}
};
// Hosts
const [hostPattern, setHostPattern] = useState('');
@@ -102,8 +157,8 @@ export default function AnsibleWorkflow() {
}, []);
// 克隆 Playbook 为自定义模板 // Logan.Li
const clonePlaybook = async (pb: Playbook, e: React.MouseEvent) => {
e.stopPropagation();
const clonePlaybook = async (pb: Playbook, e?: React.MouseEvent) => {
e?.stopPropagation();
try {
const res = await ansibleApi.clonePlaybook(pb.id);
const cloned = mapPlaybook((res as any)?.data ?? res);
@@ -278,16 +333,17 @@ export default function AnsibleWorkflow() {
{/* ============ Workflow Content ============ */}
{activeTab === 'workflow' && (
<>
{/* Step 1: Template Selection */}
{/* Step 1: Template Selection — 表格列表 */}
{step === 'template' && (
<Card className="bg-slate-900 border-slate-800">
<CardHeader>
<CardHeader className="pb-2">
<CardTitle className="text-slate-100 flex items-center gap-2">
<FileText className="h-5 w-5 text-blue-400" />
选择 Playbook 模板
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="space-y-3">
{/* 搜索/过滤栏 */}
<div className="flex gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
@@ -312,73 +368,164 @@ export default function AnsibleWorkflow() {
<Button variant="outline" onClick={fetchPlaybooks} className="border-slate-700">
<RefreshCw className="h-4 w-4 mr-2" />刷新
</Button>
</div>
{/* 快捷操作栏 */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => window.open('/ansible/manage', '_blank')}
className="border-slate-700 text-slate-400 hover:text-slate-200 text-xs h-8"
>
<ExternalLink className="h-3 w-3 mr-1" />
模板管理
<Button onClick={openCreate} size="sm" className="gap-1">
<Plus className="h-4 w-4" />新建模板
</Button>
<span className="text-xs text-slate-600">
点击卡片选择模板执行 · 悬停卡片可克隆为自定义模板
</span>
</div>
{/* 批量操作栏 */}
{selectedPBs.size > 0 && (
<div className="flex items-center gap-2 px-3 py-2 rounded bg-slate-800/50 border border-slate-700">
<span className="text-sm text-slate-400">
已选 {selectedPBs.size} 项
</span>
<Button
variant="destructive"
size="sm"
onClick={async () => {
const ids = Array.from(selectedPBs);
const ok = await _confirm({
title: '批量删除',
description: `确定删除选中的 ${ids.length} 个模板吗?此操作不可恢复。`,
confirmText: '批量删除',
variant: 'destructive',
});
if (!ok) return;
try {
await ansibleApi.batchDeletePlaybooks(ids);
toast.success(`已删除 ${ids.length} 个`);
setSelectedPBs(new Set());
fetchPlaybooks();
} catch { toast.error('删除失败'); }
}}
>
<Trash2 className="h-3.5 w-3.5 mr-1" />批量删除
</Button>
</div>
)}
{/* 表格 */}
{loadingPB ? (
<div className="text-center py-12 text-slate-500">
<Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" />加载中…
</div>
) : filteredPlaybooks.length === 0 ? (
<div className="text-center py-12 text-slate-500">暂无模板</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{filteredPlaybooks.map(pb => {
const Icon = catIcons[pb.category] || FileText;
const selected = selectedPB?.id === pb.id;
return (
<div
key={pb.id}
onClick={() => setSelectedPB(selected ? null : pb)}
className={`group relative p-4 rounded-lg border cursor-pointer transition-all ${
selected
? 'border-blue-500 bg-blue-500/10 ring-1 ring-blue-500/30'
: 'border-slate-700 bg-slate-800/50 hover:border-slate-600'
}`}
>
{/* 悬浮克隆按钮 */}
<button
onClick={(e) => clonePlaybook(pb, e)}
title="克隆为自定义模板"
className="absolute top-2 right-2 p-1.5 rounded-md opacity-0 group-hover:opacity-100 transition-opacity bg-slate-700 hover:bg-blue-600 text-slate-400 hover:text-white"
>
<Copy className="h-3.5 w-3.5" />
</button>
<div className="flex items-start gap-3">
<div className={`p-2 rounded-lg ${selected ? 'bg-blue-500/20' : 'bg-slate-700/50'}`}>
<Icon className={`h-5 w-5 ${selected ? 'text-blue-400' : 'text-slate-400'}`} />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-slate-100 text-sm truncate">{pb.name}</h3>
<p className="text-xs text-slate-500 mt-1 line-clamp-2">{pb.description}</p>
<div className="flex flex-wrap gap-1 mt-2">
{pb.tags.slice(0, 3).map(t => (
<Badge key={t} variant="outline" className="text-[10px] border-slate-700 text-slate-500 px-1.5 py-0">
{t}
</Badge>
))}
<Badge className="text-[10px] bg-slate-700/50 text-slate-400 px-1.5 py-0">
<div className="border border-slate-800 rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="w-10 text-slate-500">
<Checkbox
checked={selectedPBs.size === filteredPlaybooks.length && filteredPlaybooks.length > 0}
onCheckedChange={() => {
if (selectedPBs.size === filteredPlaybooks.length) {
setSelectedPBs(new Set());
} else {
setSelectedPBs(new Set(filteredPlaybooks.map(p => p.id)));
}
}}
/>
</TableHead>
<TableHead className="text-slate-400">名称</TableHead>
<TableHead className="text-slate-400 hidden md:table-cell">分类</TableHead>
<TableHead className="text-slate-400 hidden lg:table-cell">标签</TableHead>
<TableHead className="text-slate-400 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredPlaybooks.map(pb => {
const selected = selectedPB?.id === pb.id;
const batchChecked = selectedPBs.has(pb.id);
return (
<TableRow
key={pb.id}
onClick={() => setSelectedPB(selected ? null : pb)}
className={`border-slate-800 cursor-pointer transition-colors ${
selected
? 'bg-blue-500/10 hover:bg-blue-500/15'
: 'hover:bg-slate-800/40'
}`}
>
<TableCell onClick={e => e.stopPropagation()}>
<Checkbox
checked={batchChecked}
onCheckedChange={() => {
const next = new Set(selectedPBs);
batchChecked ? next.delete(pb.id) : next.add(pb.id);
setSelectedPBs(next);
}}
/>
</TableCell>
<TableCell>
<div className="font-medium text-slate-100 text-sm truncate max-w-[220px]">
{pb.name}
</div>
{pb.description && (
<div className="text-xs text-slate-500 truncate max-w-[220px] mt-0.5">
{pb.description}
</div>
)}
</TableCell>
<TableCell className="hidden md:table-cell">
<Badge variant="secondary" className="text-xs bg-slate-700/50 text-slate-400">
{categories.find(c => c.value === pb.category)?.label || pb.category}
</Badge>
</div>
</div>
</div>
</div>
);
})}
</TableCell>
<TableCell className="hidden lg:table-cell">
<div className="flex flex-wrap gap-1">
{pb.tags.slice(0, 3).map(t => (
<Badge key={t} variant="outline" className="text-[10px] border-slate-700 text-slate-500 px-1.5 py-0">
{t}
</Badge>
))}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1" onClick={e => e.stopPropagation()}>
<Button
variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-slate-200"
title="编辑(修改名称/分类/YAML)"
onClick={() => openEdit(pb)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400"
title="克隆"
onClick={() => clonePlaybook(pb)}
>
<Copy className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-red-400"
title="删除"
onClick={async () => {
const ok = await _confirm({
title: '删除模板',
description: `确定删除「${pb.name}」吗?此操作不可恢复。`,
confirmText: '删除',
variant: 'destructive',
});
if (!ok) return;
try {
await ansibleApi.deletePlaybook(pb.id);
toast.success('已删除');
if (selectedPB?.id === pb.id) setSelectedPB(null);
fetchPlaybooks();
} catch { toast.error('删除失败'); }
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
@@ -724,6 +871,16 @@ export default function AnsibleWorkflow() {
</CardContent>
</Card>
)}
{/* 新建/编辑模板弹窗 */}
<AnsibleFormDialog
open={editorOpen}
onOpenChange={setEditorOpen}
editingId={editingId}
formData={formData}
setFormData={setFormData}
onSave={handleSave}
/>
</div>
);
}