feat: add message push function call

This commit is contained in:
engigu
2026-03-04 21:45:43 +08:00
parent d4663cb492
commit 81b8d2a93d
53 changed files with 4161 additions and 33 deletions
@@ -0,0 +1,200 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Copy, Terminal, Key, FileJson, RefreshCw, Check, Hash, Info } from 'lucide-vue-next'
import type { NotifyChannel, ChannelType } from '@/api'
import { ref, computed } from 'vue'
import { toast } from 'vue-sonner'
const props = defineProps<{
channels: NotifyChannel[]
channelTypes: ChannelType[]
apiToken: string
}>()
const emit = defineEmits<{
generateToken: []
copyToken: []
copyExample: []
}>()
const copiedBlock = ref<string | null>(null)
const host = ref(window.location.host)
function copyToClipboard(text: string, blockId: string) {
navigator.clipboard.writeText(text).then(() => {
copiedBlock.value = blockId
toast.success('已复制到剪贴板')
setTimeout(() => {
copiedBlock.value = null
}, 2000)
})
}
const apiExample = `POST /api/v1/notify/send
Content-Type: application/json
notify-token: <你的API Token>
{
"channel_id": "渠道ID",
"title": "标题",
"text": "内容"
}`
const shellExample = computed(() => `curl -s -X POST "http://${host.value}/api/v1/notify/send" \\
-H "Content-Type: application/json" \\
-H "notify-token: ${props.apiToken || 'YOUR_TOKEN'}" \\
-d '{"channel_id":"YOUR_CHANNEL_ID","title":"标题","text":"通知内容"}'`)
</script>
<template>
<div class="space-y-6">
<!-- API Token 管理卡片 -->
<Card class="border bg-card shadow-sm overflow-hidden">
<CardHeader class="pb-4">
<div class="flex items-center gap-2 mb-1">
<div class="p-1.5 rounded-md bg-primary/10 text-primary">
<Key class="w-4 h-4" />
</div>
<CardTitle class="text-base font-semibold">身份验证 (API Token)</CardTitle>
</div>
<CardDescription>用于外部脚本或工具调用 API 时的安全凭证</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex items-center gap-3">
<div class="relative flex-1 group">
<Input
:model-value="apiToken"
readonly
placeholder="尚未生成 Token"
class="h-10 pr-10 bg-muted/30 border-muted-foreground/20 focus-visible:ring-primary/30 font-code text-sm tracking-tight"
/>
<div
v-if="apiToken"
@click="copyToClipboard(apiToken, 'token')"
class="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-primary cursor-pointer transition-colors p-1 rounded-md hover:bg-muted"
title="复制 Token"
>
<Check v-if="copiedBlock === 'token'" class="w-4 h-4 text-emerald-500 animate-in zoom-in" />
<Copy v-else class="w-4 h-4" />
</div>
</div>
<Button variant="default" @click="emit('generateToken')" class="h-10 px-4 shrink-0 transition-all active:scale-95">
<RefreshCw class="w-3.5 h-3.5 mr-2" />
{{ apiToken ? '重新生成' : '生成 Token' }}
</Button>
</div>
<div class="flex items-start gap-2 p-3 rounded-lg bg-amber-500/5 border border-amber-500/10 text-[13px] text-amber-700 dark:text-amber-400">
<Info class="w-4 h-4 mt-0.5 shrink-0" />
<p>请妥善保管您的 Token一旦丢失需通过上方按钮重新生成令牌将作为请求头中的 <code>notify-token</code> 字段发送</p>
</div>
</CardContent>
</Card>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- API 接口规格 -->
<Card class="border bg-card shadow-sm flex flex-col overflow-hidden">
<CardHeader class="pb-3 shrink-0">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="p-1.5 rounded-md bg-emerald-500/10 text-emerald-600">
<FileJson class="w-4 h-4" />
</div>
<CardTitle class="text-sm font-bold uppercase tracking-wider">API 接口说明</CardTitle>
</div>
</div>
</CardHeader>
<CardContent class="p-0 flex-1">
<div class="bg-zinc-950 dark:bg-black/40 p-5 font-code text-xs sm:text-sm leading-relaxed text-zinc-300 relative group h-full">
<div class="flex items-center justify-between mb-4 border-b border-zinc-700/50 pb-2">
<div class="flex items-center gap-2">
<Badge class="bg-emerald-600 text-white border-none py-0 px-2 text-[10px]">POST</Badge>
<code class="text-zinc-400">/api/v1/notify/send</code>
</div>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-zinc-500 hover:text-white hover:bg-zinc-800 transition-all"
@click="copyToClipboard(apiExample, 'api')"
>
<Check v-if="copiedBlock === 'api'" class="w-3.5 h-3.5 text-emerald-500" />
<Copy v-else class="w-3.5 h-3.5" />
</Button>
</div>
<div class="space-y-4">
<div>
<span class="text-zinc-500 block mb-1 uppercase text-[10px] font-bold tracking-widest">Headers</span>
<div class="pl-2 space-y-1">
<p><span class="text-zinc-500">Content-Type:</span> application/json</p>
<p><span class="text-zinc-500">notify-token:</span> <span class="text-primary">&lt;TOKEN&gt;</span></p>
</div>
</div>
<div>
<span class="text-zinc-500 block mb-1 uppercase text-[10px] font-bold tracking-widest">Body (JSON)</span>
<div class="pl-2">
<p class="text-zinc-400">{</p>
<p class="pl-4">"channel_id": <span class="text-orange-400">"ID"</span>, <span class="text-zinc-500 font-sans italic text-[11px]">// 渠道唯一标识</span></p>
<p class="pl-4">"title": <span class="text-orange-400">"标题"</span>, <span class="text-zinc-500 font-sans italic text-[11px]">// 可选</span></p>
<p class="pl-4">"text": <span class="text-orange-400">"内容"</span> <span class="text-zinc-500 font-sans italic text-[11px]">// 必填</span></p>
<p class="text-zinc-400">}</p>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- Shell 示例 -->
<Card class="border bg-card shadow-sm flex flex-col overflow-hidden">
<CardHeader class="pb-3 shrink-0">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="p-1.5 rounded-md bg-sky-500/10 text-sky-600">
<Terminal class="w-4 h-4" />
</div>
<CardTitle class="text-sm font-bold uppercase tracking-wider">Shell 脚本示例</CardTitle>
</div>
<Button
variant="outline"
size="sm"
class="h-7 px-2 text-[10px] border-muted-foreground/30 hover:bg-muted transition-all"
@click="copyToClipboard(shellExample, 'shell')"
>
<Check v-if="copiedBlock === 'shell'" class="w-3 h-3 text-emerald-500 mr-1.5" />
<Copy v-else class="w-3 h-3 mr-1.5" />
一键复制
</Button>
</div>
</CardHeader>
<CardContent class="p-0 flex-1">
<div class="bg-zinc-950 dark:bg-black/40 p-5 font-code text-[12px] sm:text-[13px] leading-relaxed text-zinc-300 h-full">
<div class="space-y-1">
<p><span class="text-zinc-600"># 使用 CURL 调用推送接口</span></p>
<p>curl -s -X POST <span class="text-emerald-400">"http://{{ host }}/api/v1/notify/send"</span> \</p>
<p class="pl-4"> -H <span class="text-orange-400">"Content-Type: application/json"</span> \</p>
<p class="pl-4"> -H <span class="text-orange-400">"notify-token: {{ apiToken || 'YOUR_TOKEN' }}"</span> \</p>
<p class="pl-4"> -d <span class="text-orange-400">'{"channel_id":"ID","title":"任务完成","text":"脚本执行完毕"}'</span></p>
</div>
<div class="mt-8 pt-4 border-t border-zinc-800">
<span class="text-zinc-500 block mb-2 uppercase text-[10px] font-bold tracking-widest flex items-center gap-1.5">
<Hash class="w-3 h-3" /> 渠道 ID 快速查找
</span>
<div v-if="channels.length === 0" class="text-xs text-zinc-600 italic">暂无活跃渠道</div>
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div v-for="ch in channels" :key="ch.id" class="flex items-center gap-2 text-xs bg-zinc-900 px-2 py-1.5 rounded border border-zinc-800 hover:border-zinc-700 transition-colors group">
<code class="text-primary font-bold tracking-tighter font-code">{{ ch.id.slice(0, 8) }}</code>
<span class="text-zinc-500 truncate max-w-[100px]">{{ ch.name }}</span>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</template>
@@ -0,0 +1,137 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { NotifyChannel, ChannelType } from '@/api'
const props = defineProps<{
open: boolean
isEditing: boolean
channel: Partial<NotifyChannel>
channelTypes: ChannelType[]
configFields: Record<string, { key: string; label: string; required: boolean; placeholder?: string; type?: string }[]>
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
'update:channel': [channel: Partial<NotifyChannel>]
'type-change': [type: string]
save: []
}>()
const currentConfigFields = computed(() => {
return props.configFields[props.channel.type || ''] || []
})
const enabledModel = computed({
get: () => props.channel.enabled ?? true,
set: (value) => updateChannelField('enabled', value)
})
function updateChannelField(field: string, value: any) {
emit('update:channel', { ...props.channel, [field]: value })
}
function updateConfigField(key: string, value: string) {
const newConfig = { ...props.channel.config, [key]: value }
emit('update:channel', { ...props.channel, config: newConfig })
}
</script>
<template>
<Dialog :open="open" @update:open="emit('update:open', $event)">
<DialogContent class="sm:max-w-lg max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{{ isEditing ? '编辑渠道' : '添加渠道' }}</DialogTitle>
<DialogDescription>配置消息推送渠道</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="grid grid-cols-4 items-center gap-3">
<Label class="text-right text-sm">名称</Label>
<Input
:model-value="channel.name"
@update:model-value="updateChannelField('name', $event)"
placeholder="给渠道起个名字"
class="col-span-3"
/>
</div>
<div class="grid grid-cols-4 items-center gap-3">
<Label class="text-right text-sm">类型</Label>
<div class="col-span-3">
<Select
:model-value="channel.type"
@update:model-value="(val: any) => emit('type-change', String(val))"
:disabled="isEditing"
>
<SelectTrigger>
<SelectValue placeholder="选择渠道类型" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="ct in channelTypes" :key="ct.type" :value="ct.type">
{{ ct.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-3">
<Label class="text-right text-sm">启用</Label>
<Switch v-model="enabledModel" />
</div>
<div v-if="currentConfigFields.length > 0" class="border-t pt-4 mt-4">
<h4 class="text-sm font-medium mb-3 text-muted-foreground">渠道配置</h4>
<div class="space-y-3">
<div v-for="field in currentConfigFields" :key="field.key" class="grid grid-cols-4 items-start gap-3">
<Label class="text-right text-sm pt-2">
{{ field.label }}
<span v-if="field.required" class="text-destructive">*</span>
</Label>
<div class="col-span-3">
<Input
v-if="!field.type || field.type !== 'textarea'"
:model-value="channel.config?.[field.key] || ''"
@update:model-value="updateConfigField(field.key, String($event))"
:placeholder="field.placeholder || ''"
class="text-sm"
/>
<textarea
v-else
:value="channel.config?.[field.key] || ''"
@input="(e: Event) => updateConfigField(field.key, (e.target as HTMLTextAreaElement).value)"
:placeholder="field.placeholder || ''"
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring min-h-[80px]"
/>
</div>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="emit('update:open', false)">取消</Button>
<Button @click="emit('save')">保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Plus, Trash2, TestTube, Pencil, Bell } from 'lucide-vue-next'
import type { NotifyChannel, ChannelType } from '@/api'
defineProps<{
channels: NotifyChannel[]
channelTypes: ChannelType[]
}>()
const emit = defineEmits<{
add: []
edit: [channel: NotifyChannel]
delete: [id: string]
test: [channel: NotifyChannel]
}>()
function getChannelTypeName(type: string, channelTypes: ChannelType[]): string {
const found = channelTypes.find(t => t.type === type)
return found ? found.label : type
}
</script>
<template>
<Card>
<CardHeader>
<div class="flex items-center justify-between">
<div>
<CardTitle>通知渠道</CardTitle>
<CardDescription>管理消息推送渠道配置</CardDescription>
</div>
<Button size="sm" @click="emit('add')">
<Plus class="w-4 h-4 mr-1" />
添加渠道
</Button>
</div>
</CardHeader>
<CardContent>
<div v-if="channels.length === 0" class="text-center py-12 text-muted-foreground">
<Bell class="w-12 h-12 mx-auto mb-3 opacity-30" />
<p class="text-sm">暂无通知渠道</p>
<p class="text-xs mt-1">点击"添加渠道"开始配置</p>
</div>
<div v-else class="space-y-3">
<div
v-for="ch in channels"
:key="ch.id"
class="flex items-center justify-between p-3 rounded-lg border bg-card hover:bg-accent/30 transition-colors"
>
<div class="flex items-center gap-3 min-w-0 flex-1">
<div class="flex flex-col min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-sm truncate">{{ ch.name }}</span>
<Badge variant="secondary" class="text-[10px] shrink-0">{{ getChannelTypeName(ch.type, channelTypes) }}</Badge>
<Badge
:class="ch.enabled ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20' : 'bg-zinc-500/10 text-zinc-500 border-zinc-500/20'"
variant="secondary"
class="text-[10px] shrink-0"
>
{{ ch.enabled ? '启用' : '禁用' }}
</Badge>
</div>
</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="icon" class="h-7 w-7" @click="emit('test', ch)" title="测试发送">
<TestTube class="w-3.5 h-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="emit('edit', ch)" title="编辑">
<Pencil class="w-3.5 h-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7 text-destructive hover:text-destructive" @click="emit('delete', ch.id)" title="删除">
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
</template>
@@ -0,0 +1,296 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { X, Plus, Shield, Terminal, Search } from 'lucide-vue-next'
import type { NotifyChannel, ChannelType, EventType, NotifyBinding, Task } from '@/api'
const props = defineProps<{
channels: NotifyChannel[]
channelTypes: ChannelType[]
eventTypes: EventType[]
bindings: NotifyBinding[]
tasks: Task[]
}>()
const emit = defineEmits<{
save: [bindings: Partial<NotifyBinding>[]]
delete: [id: string]
}>()
// --- 状态管理 ---
const selectedTaskId = ref<string>('') // 默认不选中任何任务,强制用户选择
const selectedChannels = ref<Record<string, string>>({})
const taskSearchQuery = ref('')
const isTaskDropdownOpen = ref(false)
// 任务过滤逻辑
const filteredTasks = computed(() => {
if (!taskSearchQuery.value) return props.tasks
const q = taskSearchQuery.value.toLowerCase()
return props.tasks.filter(t => t.name.toLowerCase().includes(q))
})
// 获取当前选择的任务显示名称
const selectedTaskDisplay = computed(() => {
const task = props.tasks.find(t => t.id === selectedTaskId.value)
return task ? task.name : '请选择任务'
})
function selectTask(id: string) {
selectedTaskId.value = id
isTaskDropdownOpen.value = false
taskSearchQuery.value = '' // 清空搜索词
}
// 分组事件
const systemEvents = computed(() => props.eventTypes.filter(e => e.binding_type === 'system'))
const taskEvents = computed(() => props.eventTypes.filter(e => e.binding_type === 'task'))
// 获取事件的绑定渠道
function getBindings(eventType: string, isSystem: boolean): NotifyBinding[] {
if (isSystem) {
return props.bindings.filter(b => b.event === eventType && b.type === 'system')
}
return props.bindings.filter(b => b.event === eventType && b.type === 'task' && b.data_id === selectedTaskId.value)
}
// 获取渠道名称与类型标签
function getChannelName(wayId: string): string {
const channel = props.channels.find(c => c.id === wayId)
return channel ? channel.name : '未知渠道'
}
function getChannelTypeLabel(wayId: string): string {
const channel = props.channels.find(c => c.id === wayId)
if (!channel) return ''
const type = props.channelTypes.find(t => t.type === channel.type)
return type ? type.label : channel.type
}
// 添加渠道到事件
function addChannelToEvent(eventType: string, bindingType: 'system' | 'task') {
const dataId = bindingType === 'task' ? selectedTaskId.value : ''
const key = `${eventType}-${bindingType}-${dataId}`
const channelId = selectedChannels.value[key]
if (!channelId) return
const newBinding: Partial<NotifyBinding> = {
type: bindingType,
event: eventType,
way_id: channelId,
data_id: dataId
}
emit('save', [newBinding])
selectedChannels.value[key] = ''
}
// 获取可选的渠道
function getAvailableChannels(eventType: string, bindingType: 'system' | 'task') {
const isSystem = bindingType === 'system'
const boundChannelIds = getBindings(eventType, isSystem).map(b => b.way_id)
return props.channels.filter(c => !boundChannelIds.includes(c.id) && c.enabled)
}
// 删除绑定
function removeBinding(binding: NotifyBinding) {
emit('delete', binding.id)
}
</script>
<template>
<div class="space-y-6">
<!-- 系统事件卡片 -->
<Card>
<CardHeader>
<div class="flex items-center gap-2">
<Shield class="w-5 h-5 text-primary" />
<div>
<CardTitle>系统事件</CardTitle>
<CardDescription>配置帐号登录安全告警等系统级事件的通知推送</CardDescription>
</div>
</div>
</CardHeader>
<CardContent class="space-y-4">
<div v-if="channels.length === 0"
class="text-center py-8 text-muted-foreground border-2 border-dashed rounded-lg">
<p class="text-sm">请先在渠道管理中添加通知渠道</p>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div v-for="event in systemEvents" :key="event.type"
class="flex flex-col p-4 rounded-lg border bg-card hover:bg-accent/10 transition-colors">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span class="font-bold text-sm">{{ event.label }}</span>
<Badge variant="outline" class="text-[10px] font-mono opacity-50">{{ event.type }}</Badge>
</div>
</div>
<!-- 已绑定渠道 -->
<div class="flex flex-wrap gap-2 mb-3 min-h-[32px] items-center">
<template v-if="getBindings(event.type, true).length > 0">
<div v-for="binding in getBindings(event.type, true)" :key="binding.id"
class="inline-flex items-center gap-1.5 px-2 py-1 rounded bg-secondary/50 border text-[11px] font-medium">
<span class="truncate max-w-[100px]">{{ getChannelName(binding.way_id) }}</span>
<button @click="removeBinding(binding)"
class="hover:text-destructive p-0.5 rounded-sm transition-colors">
<X class="w-3 h-3" />
</button>
</div>
</template>
<span v-else class="text-[11px] text-muted-foreground italic">未绑定渠道</span>
</div>
<!-- 添加绑定 -->
<div class="flex gap-2">
<Select v-model="selectedChannels[`${event.type}-system-`]">
<SelectTrigger class="h-8 text-xs flex-1">
<SelectValue placeholder="添加通知渠道" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="ch in getAvailableChannels(event.type, 'system')" :key="ch.id" :value="ch.id">
<div class="flex items-center gap-2">
<span class="text-xs">{{ ch.name }}</span>
<Badge variant="outline" class="text-[9px]">{{ getChannelTypeLabel(ch.id) }}</Badge>
</div>
</SelectItem>
</SelectContent>
</Select>
<Button size="sm" variant="outline" class="h-8 px-2" @click="addChannelToEvent(event.type, 'system')"
:disabled="!selectedChannels[`${event.type}-system-`]">
<Plus class="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- 任务事件卡片 -->
<Card>
<CardHeader>
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div class="flex items-center gap-2">
<Terminal class="w-5 h-5 text-primary" />
<div>
<CardTitle>任务事件</CardTitle>
<CardDescription>配置定时任务执行状态的通知行为需选择具体任务</CardDescription>
</div>
</div>
<div class="relative w-full sm:w-auto min-w-[240px]">
<div
class="flex items-center gap-2 bg-transparent border border-input rounded-md px-3 h-9 focus-within:ring-1 focus-within:ring-ring/30 transition-all cursor-pointer"
@click="isTaskDropdownOpen = !isTaskDropdownOpen">
<Search class="w-4 h-4 text-muted-foreground shrink-0" />
<div class="flex-1 text-xs truncate">
<span v-if="!isTaskDropdownOpen" class="font-medium"
:class="{ 'text-muted-foreground': !selectedTaskId }">{{ selectedTaskDisplay }}</span>
<input v-else v-model="taskSearchQuery"
class="w-full bg-transparent border-none outline-none p-0 text-xs" placeholder="搜索任务名..." @click.stop
autofocus />
</div>
<X v-if="taskSearchQuery || isTaskDropdownOpen"
class="w-3 h-3 text-muted-foreground hover:text-destructive"
@click.stop="taskSearchQuery = ''; isTaskDropdownOpen = false" />
</div>
<!-- 自定义下拉列表 -->
<div v-if="isTaskDropdownOpen"
class="absolute top-full left-0 w-full mt-1 bg-card border border-border rounded-md shadow-xl z-50 max-h-[250px] overflow-auto py-1 animate-in fade-in zoom-in-95 duration-150">
<div v-if="filteredTasks.length === 0"
class="px-3 py-4 text-center text-[10px] text-muted-foreground italic">
未找到相关任务
</div>
<div v-for="task in filteredTasks" :key="task.id"
class="px-3 py-1.5 text-xs hover:bg-accent cursor-pointer flex items-center justify-between"
:class="{ 'bg-accent/50 font-medium': selectedTaskId === task.id }" @click="selectTask(task.id)">
<span class="truncate">{{ task.name }}</span>
<Badge v-if="selectedTaskId === task.id" variant="outline" class="h-4 p-0 px-1 text-[8px]">当前</Badge>
</div>
</div>
</div>
</div>
</CardHeader>
<CardContent class="space-y-4">
<!-- 未选择任务时的提示 -->
<div v-if="!selectedTaskId"
class="flex flex-col items-center justify-center py-12 text-muted-foreground border-2 border-dashed rounded-lg bg-accent/5">
<Terminal class="w-10 h-10 mb-3 opacity-20" />
<p class="text-sm font-medium">请在右上角选择需要配置的任务</p>
<p class="text-[11px] opacity-70 mt-1">每个任务可以独立配置通知渠道</p>
</div>
<template v-else>
<!-- 任务特定模式提示 -->
<div class="flex items-center justify-between p-3 rounded-lg bg-primary/5 border border-primary/20">
<div class="flex items-center gap-2">
<Badge class="bg-primary/20 text-primary border-none text-[10px]">当前任务</Badge>
<span class="text-xs font-bold truncate max-w-[200px]">{{tasks.find(t => t.id === selectedTaskId)?.name
}}</span>
</div>
</div>
<div v-if="channels.length === 0"
class="text-center py-8 text-muted-foreground border-2 border-dashed rounded-lg">
<p class="text-sm">请先添加通知渠道</p>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-for="event in taskEvents" :key="event.type"
class="flex flex-col p-4 rounded-lg border bg-card hover:bg-accent/10 transition-colors">
<div class="flex items-center gap-2 mb-3">
<span class="font-bold text-sm">{{ event.label }}</span>
<Badge variant="outline" class="text-[10px] font-mono opacity-50">{{ event.type }}</Badge>
</div>
<!-- 已绑定渠道 -->
<div class="flex flex-wrap gap-2 mb-3 min-h-[32px] items-center">
<template v-if="getBindings(event.type, false).length > 0">
<div v-for="binding in getBindings(event.type, false)" :key="binding.id"
class="inline-flex items-center gap-1.5 px-2 py-1 rounded bg-secondary/50 border text-[11px] font-medium">
<span class="truncate max-w-[100px]">{{ getChannelName(binding.way_id) }}</span>
<button @click="removeBinding(binding)"
class="hover:text-destructive p-0.5 rounded-sm transition-colors">
<X class="w-3 h-3" />
</button>
</div>
</template>
<span v-else class="text-[11px] text-muted-foreground italic">未绑定渠道</span>
</div>
<!-- 添加绑定 -->
<div class="flex gap-2 font-sans">
<Select v-model="selectedChannels[`${event.type}-task-${selectedTaskId}`]">
<SelectTrigger class="h-8 text-xs flex-1">
<SelectValue placeholder="添加通知渠道" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="ch in getAvailableChannels(event.type, 'task')" :key="ch.id" :value="ch.id">
<div class="flex items-center gap-2">
<span class="text-xs">{{ ch.name }}</span>
<Badge variant="outline" class="text-[9px]">{{ getChannelTypeLabel(ch.id) }}</Badge>
</div>
</SelectItem>
</SelectContent>
</Select>
<Button size="sm" variant="outline" class="h-8 px-2" @click="addChannelToEvent(event.type, 'task')"
:disabled="!selectedChannels[`${event.type}-task-${selectedTaskId}`]">
<Plus class="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
</template>
</CardContent>
</Card>
</div>
</template>