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
+55
View File
@@ -131,6 +131,9 @@ export const api = {
request('/settings/scheduler', { method: 'PUT', body: JSON.stringify(data) }),
getPaths: () => request<{ scripts_dir: string }>('/settings/paths'),
getAbout: () => request<AboutInfo>('/settings/about'),
get: (section: string, key: string) => request<string>(`/settings/${section}/${key}`),
generateToken: (section: string, key: string) =>
request<string>(`/settings/${section}/${key}/generate`, { method: 'POST' }),
getLoginLogs: (params?: { page?: number; page_size?: number; username?: string }) => {
const query = new URLSearchParams()
if (params?.page) query.set('page', String(params.page))
@@ -261,6 +264,22 @@ export const api = {
},
terminal: {
cmds: () => request<{ name: string, description: string }[]>('/terminal/cmds')
},
notify: {
getTypes: () => request<{ channel_types: ChannelType[]; event_types: EventType[] }>('/notify/types'),
getChannels: () => request<NotifyChannel[]>('/notify/channels'),
saveChannel: (data: Partial<NotifyChannel>) =>
request('/notify/channels', { method: 'POST', body: JSON.stringify(data) }),
deleteChannel: (id: string) => request('/notify/channels/' + id, { method: 'DELETE' }),
testChannel: (data: Partial<NotifyChannel>) =>
request<NotifyResult>('/notify/channels/test', { method: 'POST', body: JSON.stringify(data) }),
getBindings: () => request<NotifyBinding[]>('/notify/bindings'),
saveBinding: (data: Partial<NotifyBinding>) =>
request<NotifyBinding>('/notify/bindings', { method: 'POST', body: JSON.stringify(data) }),
deleteBinding: (id: string) => request('/notify/bindings/' + id, { method: 'DELETE' }),
send: (data: { channel_id: string; title: string; text: string }) =>
request<NotifyResult>('/notify/send', { method: 'POST', body: JSON.stringify(data) })
}
}
@@ -496,3 +515,39 @@ export interface MiseLanguage {
install_path?: string
installed_at?: string // 安装日期
}
export interface ChannelType {
type: string
label: string
}
export interface EventType {
type: string
label: string
binding_type?: string
}
export interface NotifyChannel {
id: string
name: string
type: string
enabled: boolean
config: Record<string, string>
}
export interface NotifyBinding {
id: string
type: string
event: string
way_id: string
data_id: string
created_at?: string
updated_at?: string
}
export interface NotifyResult {
success: boolean
error?: string
}
+4
View File
@@ -154,6 +154,10 @@
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
.font-code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
}
/* 高级感增强 - 微妙的渐变背景 */
+2 -1
View File
@@ -2,7 +2,7 @@
import { ref, onMounted, computed } from 'vue'
import { RouterLink, RouterView, useRoute } from 'vue-router'
import { resetAuthCache } from '@/router'
import { LayoutDashboard, ListTodo, FileCode, Settings, LogOut, ScrollText, Terminal, Variable, KeyRound, Menu, X, Server, Globe } from 'lucide-vue-next'
import { LayoutDashboard, ListTodo, FileCode, Settings, LogOut, ScrollText, Terminal, Variable, KeyRound, Menu, X, Server, Globe, Bell } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import ThemeToggle from '@/components/ThemeToggle.vue'
import { api } from '@/api'
@@ -60,6 +60,7 @@ const navItems = [
{ to: '/environments', icon: Variable, label: '环境变量', exact: true },
{ to: '/languages', icon: Globe, label: '语言依赖', exact: true },
{ to: '/terminal', icon: Terminal, label: '终端命令', exact: true },
{ to: '/notify', icon: Bell, label: '消息推送', exact: true },
{ to: '/loginlogs', icon: KeyRound, label: '登录日志', exact: true },
{ to: '/settings', icon: Settings, label: '系统设置', exact: true },
]
+1
View File
@@ -47,6 +47,7 @@ const router = createRouter({
{ path: 'history', name: 'history', component: () => import('@/views/history/History.vue') },
{ path: 'loginlogs', name: 'loginlogs', component: () => import('@/views/loginlogs/LoginLogs.vue') },
{ path: 'terminal', name: 'terminal', component: () => import('@/views/terminal/Terminal.vue') },
{ path: 'notify', name: 'notify', component: () => import('@/views/notify/Notify.vue') },
{ path: 'settings', name: 'settings', component: () => import('@/views/settings/Settings.vue') }
]
}
+387
View File
@@ -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">&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>