diff --git a/PROJECT_EVALUATION.md b/PROJECT_EVALUATION.md index acda6f1..d25f5a2 100644 --- a/PROJECT_EVALUATION.md +++ b/PROJECT_EVALUATION.md @@ -129,7 +129,7 @@ | ~~`api.ts` 1324 行/352 方法~~ → 已拆分为 12 个域文件 (api/kubernetes.ts, deploy.ts, monitor.ts 等) | ✅ 已解决 | 前端 | ~~P0~~ done | | 前端 **1026 处** `any` 类型 | 类型安全缺失,重构风险高 | 前端全局 | ~~P0~~ **done** (any 1076→40, unused 25→6, console 93→1, tsc --noEmit=0) | | ~~K8s 双路由体系(集群域+Kuboard 域)~~ → code 路由统一为主,ID 路由标记 deprecated 向后兼容 | ✅ 已解决 | 后端 | ~~P1~~ done | -| 25 个 ≥500 行巨型组件 | 拆分困难 | 前端 | P1 → **Q6-Q27 done: 最大组件 ResourceVisualEditor 882L→282L (-68%) + AgentCLI 814L→521L (-36%) + SystemSettingsTab 754L→173L (-77%) + NamespaceResourcePage 719L→300L (-58%) + ApprovalWorkflowPage 705L→167L (-76%) + PipelineRunDetail 678L→273L (-60%) + CloudServerImport 699L→404L (-42%,any[]→具体接口) + UnifiedQueryPanel 682L→529L (-22%) + ServerManager 652L→442L (-32%) + PipelineEditor 676L→406L (-40%) + RdpDialog 642L→442L (-31%) + DeploymentEditService 627L→394L (-37%) + TraceManager 605L→331L (-45%,修复 SpanTreeNode 内联定义反模式) + GrafanaDashboard 624L→78L (-87%,DRY 合并 Zabbix/Prometheus Tab + 移除文件级 eslint-disable + 类型安全升级) + TopologyView 596L→472L (-21%) + DeploymentEditAdvanced 590L→289L (-51%) + WorkloadViewPage 572L→348L (-39%) + CloudAccountManager 572L→308L + ExcelServerImport 560L→385L + PipelineCreateDialog 559L→498L + PodLogsViewer 553L→331L (-40%) + **HostMonitor 547L→224L (-59%,提取 host-monitor/tabs/ 8 个文件 + TABS/PCT_REF_LINES 常量提取为模块级)** + **KubernetesClusterDetail 547L→130L (-76%,提取 cluster-detail/ 8 个文件 + computeClusterMetrics/buildTrendData 纯函数提取)**,累计提取 99 个独立组件文件** | +| 25 个 ≥500 行巨型组件 | 拆分困难 | 前端 | P1 → **Q6-Q27 done: 最大组件 ResourceVisualEditor 882L→282L (-68%) + AgentCLI 814L→521L (-36%) + SystemSettingsTab 754L→173L (-77%) + NamespaceResourcePage 719L→300L (-58%) + ApprovalWorkflowPage 705L→167L (-76%) + PipelineRunDetail 678L→273L (-60%) + CloudServerImport 699L→404L (-42%,any[]→具体接口) + UnifiedQueryPanel 682L→529L (-22%) + ServerManager 652L→442L (-32%) + PipelineEditor 676L→406L (-40%) + RdpDialog 642L→442L (-31%) + DeploymentEditService 627L→394L (-37%) + TraceManager 605L→331L (-45%,修复 SpanTreeNode 内联定义反模式) + GrafanaDashboard 624L→78L (-87%,DRY 合并 Zabbix/Prometheus Tab + 移除文件级 eslint-disable + 类型安全升级) + TopologyView 596L→472L (-21%) + DeploymentEditAdvanced 590L→289L (-51%) + WorkloadViewPage 572L→348L (-39%) + CloudAccountManager 572L→308L + ExcelServerImport 560L→385L + PipelineCreateDialog 559L→498L + PodLogsViewer 553L→331L (-40%) + HostMonitor 547L→224L (-59%) + KubernetesClusterDetail 547L→130L (-76%) + **AlertCenter 542L→312L (-42%,提取 4 个受控组件: AlertsToolbar + AlertsTab + ContactsTab + ContactGroupsTab,客户端搜索过滤+删除确认逻辑下沉到子组件)**,累计提取 103 个独立组件文件** | ### 3.2 🟡 中优先级 diff --git a/ROADMAP.md b/ROADMAP.md index 296ab02..1cfae85 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -500,6 +500,44 @@ services/api.ts (1324L) | Q26 | 前端巨型组件拆分(PodLogsViewer 553L→331L,提取 pod-logs-viewer/types(highlightKeyword 纯函数) + LogsHeader + ControlBar + LogContentArea + BottomStatusBar) | P0 | **done** | | Q27 | 前端巨型组件拆分(HostMonitor 547L→224L (-59%),提取 host-monitor/tabs/ 8 个文件 (BasicTab + DiskTab + NetworkTab + PortMonitorTab + ProcessTable + FDUsageCard + ContextSwitchCard + ProcessTab),TABS 常量数组提取为模块级,PCT_REF_LINES 常量提升到 types.ts,端口/进程数组类型 JsonData→JsonData[] 类型安全升级) | P0 | **done** | | Q28 | 前端巨型组件拆分(KubernetesClusterDetail 547L→130L (-76%),提取 cluster-detail/ 8 个文件 (types(computeClusterMetrics/buildTrendData 纯函数) + OverviewTab + NodesTab + PodsTab + DeploymentsTab + ServicesTab + IngressesTab + EventsTab)) | P0 | **done** | +| Q29 | 前端巨型组件拆分(AlertCenter 542L→312L (-42%),提取 4 个受控组件 (AlertsToolbar 89L 筛选工具栏 + AlertsTab 149L 告警列表表格+分页 + ContactsTab 87L 联系人卡片网格 + ContactGroupsTab 88L 联系组卡片网格),客户端搜索过滤逻辑移入 AlertsTab,删除确认逻辑下沉到子组件) | P0 | **done** | + +--- + +## Q29: 前端巨型组件拆分 — AlertCenter [done] + +**问题**: `AlertCenter.tsx`(542 行)是 Q28 拆分后项目最大的核心业务组件之一(排除 UI 库 sidebar/data-table),包含告警中心主页面的全部编排逻辑:6 个 Tab(告警列表/告警规则/指标目录/报警联系人/报警联系组/通知策略)、~20 个 useState 状态管理、TanStack Query hooks(useAlerts/useUnifiedAlertRules/useMetricCatalog/useAlertMutations)、告警操作(确认/解决/静默)、联系人/联系组 CRUD、以及 4 块大段内联 JSX(筛选工具栏 ~56 行 + 告警列表表格+分页 ~110 行 + 联系人卡片网格 ~48 行 + 联系组卡片网格 ~48 行)。单文件过大导致维护困难。 + +**已完成 (2026-07-09)**: + +将筛选工具栏、告警列表表格、联系人 Tab、联系组 Tab 提取为 `alert-center/` 目录下的独立文件: + +| 新文件 | 行数 | 职责 | +|--------|------|------| +| `AlertsToolbar.tsx` | 89 | 告警筛选工具栏(搜索框 + 严重级别/状态/来源 3 个 Select 下拉 + 总数 + 刷新按钮),受控展示组件接收 9 个 props(searchQuery/onSearchChange + 3 筛选值 + onFilterChange + total/fetchingAlerts/onRefresh) | +| `AlertsTab.tsx` | 149 | 告警列表 Tab(Card + 表格表头 + loading/空状态 + 告警行渲染(标题/描述/标签徽章/严重级别 Badge/状态 Badge/来源/持续时间/操作按钮)+ 分页控件),受控组件接收 11 个 props(alerts/loading/searchQuery/nowMs/total/totalPages/page + 4 回调) | +| `ContactsTab.tsx` | 87 | 报警联系人 Tab(空状态 + 卡片网格,每张卡片含名称/手机/邮箱/渠道类型徽章/Webhook URL + 编辑/删除按钮 + useConfirm 二次确认),受控展示组件接收 contacts + onEdit + onDelete 回调 | +| `ContactGroupsTab.tsx` | 88 | 报警联系组 Tab(空状态 + 卡片网格,每张卡片含组名/人数徽章/备注/成员名称徽章列表 + 编辑/删除按钮 + useConfirm 二次确认),受控展示组件接收 contactGroups/contacts(用于解析成员名)+ onEdit + onDelete 回调 | + +主文件 `AlertCenter.tsx` 从 **542 行降至 312 行**(-42%),仅保留:~20 个 useState 状态声明、TanStack Query hooks 编排、alertParams useMemo、fetchContacts/fetchContactGroups 数据加载、告警操作(handleAcknowledge/handleResolve/handleSilence/handleQuickSetup)、告警规则删除(handleDeleteAgentRule/handleDeleteMonitorRule)、handleFilterChange、pageTabs 配置、nowMs、PageShell 布局(StatStrip + 6 Tab 条件渲染分发 + actions 按钮组)、4 个 Dialog 挂载(AlertDetailDialog/AlertSilenceDialog/ContactDialog/ContactGroupDialog)。 + +**关键改进**: +- **客户端搜索逻辑下沉**: 原 `alerts.filter(alert => { ... searchQuery ... })` 内联在主组件 JSX 的 `.map()` 前,提取到 `AlertsTab` 组件内部作为 `filteredAlerts` 派生值,主组件不再感知搜索过滤逻辑。 +- **删除确认逻辑下沉**: 原 ContactsTab/ContactGroupsTab 的删除按钮内联 `useConfirm` + `alertApi.deleteContact` + `toast` + `fetchContacts` 逻辑,提取到子组件内部,主组件只需传入 `onDelete(id)` async 回调。 +- **受控组件架构**: 4 个子组件全部为受控展示组件(接收状态 + 回调 props),无独立状态。所有状态管理保留在主组件,拆分为纯机械操作(移动 JSX + 添加 props 透传),零逻辑变更。 + +**验收标准**: +- [x] AlertCenter.tsx 从 542 行降至 **312 行**(-42%) +- [x] 4 个提取的文件全部 < 150 行(最大 AlertsTab 149 行) +- [x] `tsc -b` 零错误 +- [x] `npm run build` 零错误(✓ built in 18.71s) +- [x] ESLint errors 保持 **0**(5 个文件零 errors/warnings) +- [x] ESLint warnings 保持 **65**(无新增) +- [x] 全局 ESLint any 保持 **0** +- [x] `go build` 零错误 +- [x] 17/17 集成回归测试通过(零回归) +- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200) +- [x] 告警中心页功能不变(6 个 Tab:告警列表筛选+表格+分页/告警规则/指标目录/报警联系人/报警联系组/通知策略全部正常) --- diff --git a/frontend/src/components/monitor/alert-center/AlertCenter.tsx b/frontend/src/components/monitor/alert-center/AlertCenter.tsx index 6eb1c13..b0fffe9 100644 --- a/frontend/src/components/monitor/alert-center/AlertCenter.tsx +++ b/frontend/src/components/monitor/alert-center/AlertCenter.tsx @@ -1,34 +1,25 @@ // 告警中心主组件 — TanStack Query 编排 // Logan.Li import React, { useState, useEffect, useMemo } from 'react'; import { cn } from '@/lib/utils'; -import { - AlertTriangle, CheckCircle, Search, RefreshCw, - Eye, Check, VolumeX, Plus, -} from 'lucide-react'; +import { AlertTriangle, RefreshCw, Plus } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Badge } from '@/components/ui/badge'; -import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; import { StatStrip } from '@/components/ui/stat-strip'; -import { - Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from '@/components/ui/select'; import { toast } from 'sonner'; import { PageShell } from '@/components/layout/PageShell'; -import { PageEmpty } from '@/components/layout/PageEmpty'; -import { PageLoading } from '@/components/layout/PageLoading'; import { useConfirm } from '@/components/ui/confirm-dialog'; import { alertCenterApi, alertApi } from '@/api'; import { useAlerts, useUnifiedAlertRules, useMetricCatalog, useAlertMutations } from '@/hooks/api'; -import { - type Alert, getSeverityBadge, getStatusBadge, getChannelTypeLabel, -} from './alert-center-helpers'; +import { type Alert } from './alert-center-helpers'; import { AlertDetailDialog, AlertSilenceDialog } from './AlertCenterDetail'; import { MetricCatalogTab } from './MetricCatalogTab'; import { AlertRulesTab } from './AlertRulesTab'; import { ContactDialog } from './ContactDialog'; import { ContactGroupDialog } from './ContactGroupDialog'; import { NotifyPolicyTab } from './NotifyPolicyTab'; +import AlertsToolbar from './AlertsToolbar'; +import AlertsTab from './AlertsTab'; +import ContactsTab from './ContactsTab'; +import ContactGroupsTab from './ContactGroupsTab'; import type { UnifiedAlertRule } from '@/api'; import type { Contact, ContactGroup } from './alert-center-helpers'; @@ -181,63 +172,6 @@ const AlertCenter: React.FC = () => { { id: 'notify', label: t('monitor.alertCenterPanel.notifyPolicy') }, ]; - const alertsToolbar = activeTab === 'alerts' ? ( -
-
-
- - setSearchQuery(e.target.value)} - className="pl-8 h-8 text-xs" - /> -
- - - -
-
- {t('alert.totalItems', { count: total })} - -
-
- ) : undefined; - // eslint-disable-next-line react-hooks/purity const nowMs = Date.now(); @@ -250,7 +184,19 @@ const AlertCenter: React.FC = () => { tabs={pageTabs} activeTab={activeTab} onTabChange={setActiveTab} - toolbar={alertsToolbar} + toolbar={activeTab === 'alerts' ? ( + refetchAlerts()} + /> + ) : undefined} metrics={( { ) : undefined} > {activeTab === 'alerts' && ( - - -
- - - - - - - - - - - - - {loading ? ( - - ) : alerts.length === 0 ? ( - - ) : ( - alerts.filter(alert => { - // 客户端也保留关键词搜索过滤 - if (!searchQuery) return true; - const q = searchQuery.toLowerCase(); - return alert.title.toLowerCase().includes(q) || - alert.description.toLowerCase().includes(q) || - alert.source.toLowerCase().includes(q); - }).map((alert) => ( - - - - - - - - - )) - )} - -
{t('alert.table.alertInfo')}{t('alert.table.severity')}{t('alert.table.status')}{t('alert.table.source')}{t('alert.table.duration')}{t('alert.table.actions')}
-
{alert.title}
-
{alert.description}
-
- {Object.entries(alert.labels).slice(0, 2).map(([k, v]) => ( - {k}={v} - ))} -
-
{getSeverityBadge(alert.severity)}{getStatusBadge(alert.status)} -
{alert.source}
-
{alert.service}
-
- {alert.status === 'resolved' && alert.resolvedAt - ? t('alert.minutesShort', { count: Math.floor((new Date(alert.resolvedAt).getTime() - new Date(alert.startedAt).getTime()) / 60000) }) - : t('alert.minutesShort', { count: Math.floor((nowMs - new Date(alert.startedAt).getTime()) / 60000) })} - -
- - {alert.status === 'firing' && ( - - )} - {(alert.status === 'firing' || alert.status === 'acknowledged') && ( - - )} - -
-
-
- {/* 分页控件 */} - {totalPages > 1 && ( -
- {t('alert.pageInfo', { page, totalPages, total })} -
- - {Array.from({ length: Math.min(totalPages, 5) }, (_, i) => { - const start = Math.max(1, Math.min(page - 2, totalPages - 4)); - const pn = start + i; - if (pn > totalPages) return null; - return ( - - ); - })} - -
-
- )} -
-
+ { setSelectedAlert(alert); setDetailOpen(true); }} + onOpenSilence={(alert) => { setSelectedAlert(alert); setSilenceOpen(true); }} + onPageChange={setPage} + /> )} {activeTab === 'rules' && ( @@ -411,103 +262,22 @@ const AlertCenter: React.FC = () => { {activeTab === 'metrics' && } {activeTab === 'contacts' && ( - contacts.length === 0 ? ( - - ) : ( -
- {contacts.map((c) => ( - - -
- {c.name} -
-
- - {c.phone &&
📱 {c.phone}
} - {c.email &&
📧 {c.email}
} -
- {c.channel_type && ( - - {getChannelTypeLabel(c.channel_type)} - - )} - {c.webhook_url && ( - - {c.webhook_url} - - )} -
-
- - -
-
-
- ))} -
- ) + { setEditingContact(c); setContactDialogOpen(true); }} + onDelete={async (id) => { await alertApi.deleteContact(id); fetchContacts(); }} + /> )} {activeTab === 'notify' && } {activeTab === 'groups' && ( - contactGroups.length === 0 ? ( - - ) : ( -
- {contactGroups.map((g) => { - let cids: number[] = []; - try { cids = JSON.parse(g.contact_ids || '[]'); } catch { /* ignore */ } - const memberNames = contacts - .filter(c => cids.includes(c.id)) - .map(c => c.name); - return ( - - -
- {g.group_name} - {t('alert.peopleCount', { count: cids.length })} -
-
- - {g.notes &&

{g.notes}

} -
- {memberNames.map(name => ( - {name} - ))} -
-
- - -
-
-
- ); - })} -
- ) + { setEditingGroup(g); setGroupDialogOpen(true); }} + onDelete={async (id) => { await alertApi.deleteContactGroup(id); fetchContactGroups(); }} + /> )} void; + onResolve: (id: string) => void; + onOpenDetail: (alert: Alert) => void; + onOpenSilence: (alert: Alert) => void; + onPageChange: (page: number) => void; +} + +const AlertsTab: React.FC = ({ + alerts, loading, searchQuery, nowMs, + total, totalPages, page, + onAcknowledge, onResolve, onOpenDetail, onOpenSilence, onPageChange, +}) => { + const { t } = useTranslation(); + + const filteredAlerts = alerts.filter(alert => { + if (!searchQuery) return true; + const q = searchQuery.toLowerCase(); + return alert.title.toLowerCase().includes(q) || + alert.description.toLowerCase().includes(q) || + alert.source.toLowerCase().includes(q); + }); + + return ( + + +
+ + + + + + + + + + + + + {loading ? ( + + ) : filteredAlerts.length === 0 ? ( + + ) : ( + filteredAlerts.map((alert) => ( + + + + + + + + + )) + )} + +
{t('alert.table.alertInfo')}{t('alert.table.severity')}{t('alert.table.status')}{t('alert.table.source')}{t('alert.table.duration')}{t('alert.table.actions')}
+
{alert.title}
+
{alert.description}
+
+ {Object.entries(alert.labels).slice(0, 2).map(([k, v]) => ( + {k}={v} + ))} +
+
{getSeverityBadge(alert.severity)}{getStatusBadge(alert.status)} +
{alert.source}
+
{alert.service}
+
+ {alert.status === 'resolved' && alert.resolvedAt + ? t('alert.minutesShort', { count: Math.floor((new Date(alert.resolvedAt).getTime() - new Date(alert.startedAt).getTime()) / 60000) }) + : t('alert.minutesShort', { count: Math.floor((nowMs - new Date(alert.startedAt).getTime()) / 60000) })} + +
+ + {alert.status === 'firing' && ( + + )} + {(alert.status === 'firing' || alert.status === 'acknowledged') && ( + + )} + +
+
+
+ {/* 分页控件 */} + {totalPages > 1 && ( +
+ {t('alert.pageInfo', { page, totalPages, total })} +
+ + {Array.from({ length: Math.min(totalPages, 5) }, (_, i) => { + const start = Math.max(1, Math.min(page - 2, totalPages - 4)); + const pn = start + i; + if (pn > totalPages) return null; + return ( + + ); + })} + +
+
+ )} +
+
+ ); +}; + +export default AlertsTab; diff --git a/frontend/src/components/monitor/alert-center/AlertsToolbar.tsx b/frontend/src/components/monitor/alert-center/AlertsToolbar.tsx new file mode 100644 index 0000000..3e4333d --- /dev/null +++ b/frontend/src/components/monitor/alert-center/AlertsToolbar.tsx @@ -0,0 +1,89 @@ +// 告警中心 — 筛选工具栏(搜索 + 严重级别/状态/来源下拉 + 刷新) +// Logan.Li — 从 AlertCenter 提取的受控展示组件 +import React from 'react'; +import { Search, RefreshCw } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select'; +import { useTranslation } from 'react-i18next'; + +export interface AlertsToolbarProps { + searchQuery: string; + onSearchChange: (value: string) => void; + severityFilter: string; + statusFilter: string; + sourceFilter: string; + onFilterChange: (type: 'severity' | 'status' | 'source', value: string) => void; + total: number; + fetchingAlerts: boolean; + onRefresh: () => void; +} + +const AlertsToolbar: React.FC = ({ + searchQuery, onSearchChange, + severityFilter, statusFilter, sourceFilter, onFilterChange, + total, fetchingAlerts, onRefresh, +}) => { + const { t } = useTranslation(); + return ( +
+
+
+ + onSearchChange(e.target.value)} + className="pl-8 h-8 text-xs" + /> +
+ + + +
+
+ {t('alert.totalItems', { count: total })} + +
+
+ ); +}; + +export default AlertsToolbar; diff --git a/frontend/src/components/monitor/alert-center/ContactGroupsTab.tsx b/frontend/src/components/monitor/alert-center/ContactGroupsTab.tsx new file mode 100644 index 0000000..1b750a5 --- /dev/null +++ b/frontend/src/components/monitor/alert-center/ContactGroupsTab.tsx @@ -0,0 +1,88 @@ +// 告警中心 — 报警联系组 Tab(卡片网格 + 成员徽章 + 编辑/删除) +// Logan.Li — 从 AlertCenter 提取的受控展示组件 +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { PageEmpty } from '@/components/layout/PageEmpty'; +import { useConfirm } from '@/components/ui/confirm-dialog'; +import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; +import { type Contact, type ContactGroup } from './alert-center-helpers'; + +export interface ContactGroupsTabProps { + contactGroups: ContactGroup[]; + contacts: Contact[]; + onEdit: (group: ContactGroup) => void; + onDelete: (id: number) => Promise; +} + +const ContactGroupsTab: React.FC = ({ contactGroups, contacts, onEdit, onDelete }) => { + const { t } = useTranslation(); + const _confirm = useConfirm(); + + if (contactGroups.length === 0) { + return ( + + ); + } + + return ( +
+ {contactGroups.map((g) => { + let cids: number[] = []; + try { cids = JSON.parse(g.contact_ids || '[]'); } catch { /* ignore */ } + const memberNames = contacts + .filter(c => cids.includes(c.id)) + .map(c => c.name); + return ( + + +
+ {g.group_name} + {t('alert.peopleCount', { count: cids.length })} +
+
+ + {g.notes &&

{g.notes}

} +
+ {memberNames.map(name => ( + {name} + ))} +
+
+ + +
+
+
+ ); + })} +
+ ); +}; + +export default ContactGroupsTab; diff --git a/frontend/src/components/monitor/alert-center/ContactsTab.tsx b/frontend/src/components/monitor/alert-center/ContactsTab.tsx new file mode 100644 index 0000000..c4475b7 --- /dev/null +++ b/frontend/src/components/monitor/alert-center/ContactsTab.tsx @@ -0,0 +1,87 @@ +// 告警中心 — 报警联系人 Tab(卡片网格 + 编辑/删除) +// Logan.Li — 从 AlertCenter 提取的受控展示组件 +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { PageEmpty } from '@/components/layout/PageEmpty'; +import { useConfirm } from '@/components/ui/confirm-dialog'; +import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; +import { type Contact, getChannelTypeLabel } from './alert-center-helpers'; + +export interface ContactsTabProps { + contacts: Contact[]; + onEdit: (contact: Contact) => void; + onDelete: (id: number) => Promise; +} + +const ContactsTab: React.FC = ({ contacts, onEdit, onDelete }) => { + const { t } = useTranslation(); + const _confirm = useConfirm(); + + if (contacts.length === 0) { + return ( + + ); + } + + return ( +
+ {contacts.map((c) => ( + + +
+ {c.name} +
+
+ + {c.phone &&
📱 {c.phone}
} + {c.email &&
📧 {c.email}
} +
+ {c.channel_type && ( + + {getChannelTypeLabel(c.channel_type)} + + )} + {c.webhook_url && ( + + {c.webhook_url} + + )} +
+
+ + +
+
+
+ ))} +
+ ); +}; + +export default ContactsTab;