feat: add message push function call
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { api, type NotifyChannel, type ChannelType, type EventType, type NotifyBinding } from '@/api'
|
||||
import { toast } from 'vue-sonner'
|
||||
import ChannelList from './components/ChannelList.vue'
|
||||
import EventBinding from './components/EventBinding.vue'
|
||||
import ApiUsage from './components/ApiUsage.vue'
|
||||
import ChannelDialog from './components/ChannelDialog.vue'
|
||||
|
||||
const activeTab = ref('channels')
|
||||
|
||||
// 渠道数据
|
||||
const channels = ref<NotifyChannel[]>([])
|
||||
const channelTypes = ref<ChannelType[]>([])
|
||||
const eventTypes = ref<EventType[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
// API Token
|
||||
const apiToken = ref('')
|
||||
|
||||
// 编辑弹窗
|
||||
const showDialog = ref(false)
|
||||
const editingChannel = ref<Partial<NotifyChannel>>({
|
||||
name: '',
|
||||
type: '',
|
||||
enabled: true,
|
||||
config: {}
|
||||
})
|
||||
const isEditing = ref(false)
|
||||
|
||||
// 删除确认
|
||||
const showDeleteConfirm = ref(false)
|
||||
const deletingChannelId = ref('')
|
||||
|
||||
// 事件绑定
|
||||
const bindings = ref<NotifyBinding[]>([])
|
||||
const allTasks = ref<Task[]>([])
|
||||
|
||||
// 渠道配置模板
|
||||
const channelConfigFields: Record<string, { key: string; label: string; required: boolean; placeholder?: string; type?: string }[]> = {
|
||||
Telegram: [
|
||||
{ key: 'bot_token', label: 'Bot Token', required: true, placeholder: '从 @BotFather 获取' },
|
||||
{ key: 'chat_id', label: 'Chat ID', required: true, placeholder: '聊天/群组 ID' },
|
||||
{ key: 'api_host', label: 'API 地址', required: false, placeholder: '自定义 API 地址,留空使用官方' },
|
||||
{ key: 'proxy_url', label: '代理地址', required: false, placeholder: 'http/https/socks5 代理' },
|
||||
],
|
||||
Bark: [
|
||||
{ key: 'push_key', label: 'Push Key', required: true, placeholder: 'Bark Push Key' },
|
||||
{ key: 'sound', label: '推送声音', required: false, placeholder: '留空使用默认' },
|
||||
{ key: 'group', label: '推送分组', required: false },
|
||||
{ key: 'icon', label: '推送图标', required: false, placeholder: '图标 URL' },
|
||||
{ key: 'level', label: '时效性', required: false, placeholder: 'active / timeSensitive / passive' },
|
||||
{ key: 'url', label: '跳转URL', required: false },
|
||||
],
|
||||
Dtalk: [
|
||||
{ key: 'access_token', label: 'Access Token', required: true, placeholder: '钉钉机器人 access_token' },
|
||||
{ key: 'secret', label: '加签秘钥', required: false, placeholder: '可选' },
|
||||
],
|
||||
QyWeiXin: [
|
||||
{ key: 'access_token', label: 'Access Token', required: true, placeholder: '企业微信机器人 Key' },
|
||||
],
|
||||
Feishu: [
|
||||
{ key: 'access_token', label: 'Access Token', required: true, placeholder: '飞书机器人 access_token' },
|
||||
{ key: 'secret', label: '加签秘钥', required: false, placeholder: '可选' },
|
||||
],
|
||||
Custom: [
|
||||
{ key: 'webhook', label: 'Webhook URL', required: true, placeholder: 'https://...' },
|
||||
{ key: 'body', label: '请求体模板', required: false, placeholder: '使用 TEXT 作为消息内容占位符', type: 'textarea' },
|
||||
],
|
||||
Ntfy: [
|
||||
{ key: 'topic', label: 'Topic', required: true },
|
||||
{ key: 'url', label: 'API 地址', required: false, placeholder: '默认 https://ntfy.sh' },
|
||||
{ key: 'priority', label: '优先级', required: false, placeholder: '1-5' },
|
||||
{ key: 'icon', label: '图标 URL', required: false },
|
||||
{ key: 'token', label: 'Token', required: false },
|
||||
{ key: 'username', label: '用户名', required: false },
|
||||
{ key: 'password', label: '密码', required: false },
|
||||
],
|
||||
Gotify: [
|
||||
{ key: 'url', label: '服务地址', required: true, placeholder: 'https://gotify.example.com' },
|
||||
{ key: 'token', label: 'Token', required: true },
|
||||
{ key: 'priority', label: '优先级', required: false, placeholder: '0-10' },
|
||||
],
|
||||
PushMe: [
|
||||
{ key: 'push_key', label: 'Push Key', required: true },
|
||||
{ key: 'url', label: 'API 地址', required: false, placeholder: '默认 https://push.i-i.me' },
|
||||
{ key: 'type', label: '类型', required: false },
|
||||
],
|
||||
Email: [
|
||||
{ key: 'server', label: 'SMTP 服务器', required: true, placeholder: 'smtp.example.com' },
|
||||
{ key: 'port', label: '端口', required: true, placeholder: '465' },
|
||||
{ key: 'account', label: '邮箱账号', required: true },
|
||||
{ key: 'passwd', label: '邮箱密码', required: true },
|
||||
{ key: 'from_name', label: '发信人名称', required: false },
|
||||
{ key: 'to_account', label: '收件邮箱', required: true },
|
||||
],
|
||||
AliyunSMS: [
|
||||
{ key: 'access_key_id', label: 'AccessKeyId', required: true },
|
||||
{ key: 'access_key_secret', label: 'AccessKeySecret', required: true },
|
||||
{ key: 'sign_name', label: '短信签名', required: true },
|
||||
{ key: 'region_id', label: '区域ID', required: false, placeholder: '默认 cn-hangzhou' },
|
||||
{ key: 'phone_number', label: '手机号码', required: true },
|
||||
{ key: 'template_code', label: '短信模板 CODE', required: true },
|
||||
],
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [typesRes, channelsRes, tasksRes] = await Promise.all([
|
||||
api.notify.getTypes(),
|
||||
api.notify.getChannels(),
|
||||
api.tasks.list({ page: 1, page_size: 1000 })
|
||||
])
|
||||
channelTypes.value = typesRes.channel_types
|
||||
eventTypes.value = typesRes.event_types
|
||||
channels.value = channelsRes
|
||||
allTasks.value = tasksRes.data
|
||||
} catch (e: any) {
|
||||
toast.error('加载失败: ' + e.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEvents() {
|
||||
try {
|
||||
bindings.value = await api.notify.getBindings()
|
||||
} catch (e: any) {
|
||||
toast.error('加载事件绑定失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 渠道操作
|
||||
function openNewChannel() {
|
||||
editingChannel.value = { name: '', type: '', enabled: true, config: {} }
|
||||
isEditing.value = false
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
function openEditChannel(ch: NotifyChannel) {
|
||||
editingChannel.value = { ...ch, config: { ...ch.config } }
|
||||
isEditing.value = true
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
function onTypeChange(val: string) {
|
||||
editingChannel.value.type = val
|
||||
const existing = editingChannel.value.config || {}
|
||||
const fields = channelConfigFields[val] || []
|
||||
const newConfig: Record<string, string> = {}
|
||||
for (const f of fields) {
|
||||
newConfig[f.key] = existing[f.key] || ''
|
||||
}
|
||||
editingChannel.value.config = newConfig
|
||||
}
|
||||
|
||||
async function saveChannel() {
|
||||
if (!editingChannel.value.name || !editingChannel.value.type) {
|
||||
toast.error('请填写渠道名称和类型')
|
||||
return
|
||||
}
|
||||
|
||||
// 确保 enabled 字段有值
|
||||
const channelData = {
|
||||
...editingChannel.value,
|
||||
enabled: editingChannel.value.enabled ?? true
|
||||
}
|
||||
|
||||
try {
|
||||
await api.notify.saveChannel(channelData)
|
||||
toast.success('保存成功')
|
||||
showDialog.value = false
|
||||
await loadData()
|
||||
} catch (e: any) {
|
||||
toast.error('保存失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(id: string) {
|
||||
deletingChannelId.value = id
|
||||
showDeleteConfirm.value = true
|
||||
}
|
||||
|
||||
async function deleteChannel() {
|
||||
showDeleteConfirm.value = false
|
||||
try {
|
||||
await api.notify.deleteChannel(deletingChannelId.value)
|
||||
toast.success('删除成功')
|
||||
await loadData()
|
||||
} catch (e: any) {
|
||||
toast.error('删除失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function testChannel(ch: NotifyChannel) {
|
||||
try {
|
||||
const result = await api.notify.testChannel(ch)
|
||||
if (result.success) {
|
||||
toast.success('测试发送成功!')
|
||||
} else {
|
||||
toast.error('测试发送失败: ' + (result.error || '未知错误'))
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error('测试失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 事件绑定
|
||||
async function saveBindings(newBindings: Partial<NotifyBinding>[]) {
|
||||
try {
|
||||
for (const binding of newBindings) {
|
||||
await api.notify.saveBinding(binding)
|
||||
}
|
||||
toast.success('绑定保存成功')
|
||||
await loadEvents()
|
||||
} catch (e: any) {
|
||||
toast.error('保存失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBinding(id: string) {
|
||||
try {
|
||||
await api.notify.deleteBinding(id)
|
||||
toast.success('绑定已删除')
|
||||
await loadEvents()
|
||||
} catch (e: any) {
|
||||
toast.error('删除失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// API Token
|
||||
async function loadApiToken() {
|
||||
try {
|
||||
const token = await api.settings.get('notify', 'notify_token')
|
||||
console.log('Loaded API Token:', token, 'Type:', typeof token, 'Length:', token?.length)
|
||||
apiToken.value = token || ''
|
||||
} catch (e: any) {
|
||||
console.error('加载 API Token 失败:', e)
|
||||
// 如果是404或者其他错误,token保持为空字符串
|
||||
apiToken.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function generateApiToken() {
|
||||
try {
|
||||
const newToken = await api.settings.generateToken('notify', 'notify_token')
|
||||
apiToken.value = newToken
|
||||
toast.success('API Token 已生成')
|
||||
} catch (e: any) {
|
||||
toast.error('生成失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function copyApiToken() {
|
||||
if (!apiToken.value) {
|
||||
toast.error('请先生成 Token')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(apiToken.value)
|
||||
toast.success('Token 已复制到剪贴板')
|
||||
} catch {
|
||||
toast.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function copyApiExample() {
|
||||
if (channels.value.length === 0) {
|
||||
toast.error('请先添加一个通知渠道')
|
||||
return
|
||||
}
|
||||
const ch = channels.value[0]
|
||||
if (!ch) return
|
||||
const token = apiToken.value || 'YOUR_API_TOKEN'
|
||||
const example = `curl -X POST "{{API_URL}}/api/v1/notify/send" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "notify-token: ${token}" \\
|
||||
-d '{"channel_id": "${ch.id}", "title": "测试通知", "text": "来自脚本的通知"}'`
|
||||
try {
|
||||
await navigator.clipboard.writeText(example)
|
||||
toast.success('API 调用示例已复制到剪贴板')
|
||||
} catch {
|
||||
toast.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
loadEvents()
|
||||
loadApiToken()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<Tabs v-model="activeTab" class="w-full">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h2 class="text-xl sm:text-2xl font-bold tracking-tight">消息推送</h2>
|
||||
<p class="text-muted-foreground text-sm">配置通知渠道,绑定系统事件实现自动推送</p>
|
||||
</div>
|
||||
<TabsList class="w-full sm:w-auto grid grid-cols-3 sm:inline-flex h-9 gap-1 p-1">
|
||||
<TabsTrigger value="channels" class="text-xs px-3 py-1">渠道管理</TabsTrigger>
|
||||
<TabsTrigger value="events" class="text-xs px-3 py-1">事件绑定</TabsTrigger>
|
||||
<TabsTrigger value="api" class="text-xs px-3 py-1">脚本调用</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<!-- 渠道管理 -->
|
||||
<TabsContent value="channels">
|
||||
<ChannelList
|
||||
:channels="channels"
|
||||
:channel-types="channelTypes"
|
||||
@add="openNewChannel"
|
||||
@edit="openEditChannel"
|
||||
@delete="confirmDelete"
|
||||
@test="testChannel"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<!-- 事件绑定 -->
|
||||
<TabsContent value="events">
|
||||
<EventBinding
|
||||
:channels="channels"
|
||||
:channel-types="channelTypes"
|
||||
:event-types="eventTypes"
|
||||
:bindings="bindings"
|
||||
:tasks="allTasks"
|
||||
@save="saveBindings"
|
||||
@delete="deleteBinding"
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<!-- 脚本调用 -->
|
||||
<TabsContent value="api">
|
||||
<ApiUsage
|
||||
:channels="channels"
|
||||
:channel-types="channelTypes"
|
||||
:api-token="apiToken"
|
||||
@generate-token="generateApiToken"
|
||||
@copy-token="copyApiToken"
|
||||
@copy-example="copyApiExample"
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<!-- 添加/编辑渠道弹窗 -->
|
||||
<ChannelDialog
|
||||
v-model:open="showDialog"
|
||||
:is-editing="isEditing"
|
||||
v-model:channel="editingChannel"
|
||||
:channel-types="channelTypes"
|
||||
:config-fields="channelConfigFields"
|
||||
@type-change="onTypeChange"
|
||||
@save="saveChannel"
|
||||
/>
|
||||
|
||||
<!-- 删除确认 -->
|
||||
<AlertDialog :open="showDeleteConfirm" @update:open="showDeleteConfirm = $event">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
删除后将无法恢复,同时会取消该渠道的所有事件绑定。确定要删除吗?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction @click="deleteChannel">确认删除</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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"><TOKEN></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>
|
||||
Reference in New Issue
Block a user