feat: fix message page style
This commit is contained in:
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
### 最近更新
|
### 最近更新
|
||||||
|
|
||||||
|
**2026.03.04** - 新增内置消息推送系统:全新原生支持企业微信、钉钉、飞书、Telegram、Bark、邮件等十余种主流渠道的推送,接入系统级事件通知自动捕获,告别原有必配外部推送服务的繁琐历史
|
||||||
**2026.02.13** - 重构任务执行引擎:深度集成 Mise 运行时管理,支持 Python, Node.js, Go, Rust, PHP 等几乎所有主流语言的动态安装与多版本切换,同步上线跨语言统一依赖管理系统
|
**2026.02.13** - 重构任务执行引擎:深度集成 Mise 运行时管理,支持 Python, Node.js, Go, Rust, PHP 等几乎所有主流语言的动态安装与多版本切换,同步上线跨语言统一依赖管理系统
|
||||||
**2026.02.11** - 增强安全性:首次启动使用随机密码并打印在日志中,登录接口增加防暴力破解,文件系统操作增加路径穿越锁定
|
**2026.02.11** - 增强安全性:首次启动使用随机密码并打印在日志中,登录接口增加防暴力破解,文件系统操作增加路径穿越锁定
|
||||||
**2026.02.10** - 重构任务调度系统,完善并发控制,优化文件树交互体验,支持任务执行实时日志流
|
**2026.02.10** - 重构任务调度系统,完善并发控制,优化文件树交互体验,支持任务执行实时日志流
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
- **任务调度:** 支持标准 Cron 表达式,常用时间规则快捷选择。日志不落文件,没有磁盘频繁io的问题
|
- **任务调度:** 支持标准 Cron 表达式,常用时间规则快捷选择。日志不落文件,没有磁盘频繁io的问题
|
||||||
- **脚本管理:** 在线代码编辑器,支持文件上传、压缩包解压
|
- **脚本管理:** 在线代码编辑器,支持文件上传、压缩包解压
|
||||||
- **在线终端:** WebSocket 实时终端,命令执行结果实时输出
|
- **在线终端:** WebSocket 实时终端,命令执行结果实时输出
|
||||||
|
- **消息推送:** 内置强大消息推送与通知引擎,无缝兼容主流渠道,支持系统级事件告警
|
||||||
- **环境变量:** 安全存储敏感配置,任务执行时自动注入
|
- **环境变量:** 安全存储敏感配置,任务执行时自动注入
|
||||||
- **现代UI:** 响应式设计,深色/浅色主题切换
|
- **现代UI:** 响应式设计,深色/浅色主题切换
|
||||||
- **移动端:** 适配移动小屏样式
|
- **移动端:** 适配移动小屏样式
|
||||||
@@ -79,6 +81,11 @@
|
|||||||
- 日志内容压缩存储
|
- 日志内容压缩存储
|
||||||
- 日志自动清理
|
- 日志自动清理
|
||||||
|
|
||||||
|
### 消息推送与系统通知
|
||||||
|
- 原生内置各大主流平台渠道(钉钉、企业微信、Telegram、Server酱等)
|
||||||
|
- 支持系统级事件条件触发通知(例如任务失败报警、服务下线提醒)
|
||||||
|
- 自动生成跨语言调用 API 示例代码供脚本集成
|
||||||
|
|
||||||
### 环境变量
|
### 环境变量
|
||||||
- 安全存储敏感配置
|
- 安全存储敏感配置
|
||||||
- 变量值脱敏显示
|
- 变量值脱敏显示
|
||||||
@@ -334,9 +341,13 @@ table_prefix = baihu_
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>方式三:一键部署(白虎 + 消息推送)(推荐)</b></summary>
|
<summary><b>方式三:配合独立中心化消息服务部署(非必需/仅供参考)</b></summary>
|
||||||
|
|
||||||
白虎面板本身不内置推送功能,但可以与消息聚合服务配合使用。推荐使用 [Message-Push-Nest](https://github.com/engigu/Message-Push-Nest) 作为统一的消息推送中心。
|
> 🎉 **好消息**:自白虎面板最新版本起,系统已**原生内置**了完整强大的消息推送功能!您可直接在面板「消息推送」菜单内绑定十余种主流渠道和系统通知事件,原配合外置的 `Message-Push-Nest` 部署方式已不再是使用面板的基础要求。您可随时直接使用上方的第一种简单命令开箱即用体验。
|
||||||
|
>
|
||||||
|
> 以下「白虎 + 消息聚合服务」的联合部署内容被予以保留,专为仍然需要「中心化通知网关」的重度企业解耦用户作为参考:
|
||||||
|
|
||||||
|
白虎面板通过系统集成也可轻松连接独立的消息聚合服务。这里推荐使用 [Message-Push-Nest](https://github.com/engigu/Message-Push-Nest) 作为分布式的统一消息推送中心。
|
||||||
|
|
||||||
**使用 SQLite**
|
**使用 SQLite**
|
||||||
|
|
||||||
@@ -464,12 +475,12 @@ docker-compose up -d
|
|||||||
|
|
||||||
**在任务中使用推送**
|
**在任务中使用推送**
|
||||||
|
|
||||||
Message-Push-Nest 提供了便捷的推送代码生成功能:
|
如今你无需依赖任何外部服务,可以通过面板自身完成:
|
||||||
|
1. 进入白虎面板「消息推送」模块,新建你需要通知的渠道。
|
||||||
|
2. 可以在「事件绑定」中直接设定**自动化系统通知**(如监控任务失败或脚本异常中止时进行自动提醒)。
|
||||||
|
3. 如需在自己的脚本逻辑内动态推送,可以点击「脚本调用」页面,立刻获取一键调用的 Shell / API 代码,内嵌进你的业务逻辑中即可完成推信!
|
||||||
|
|
||||||
1. 在 Message-Push-Nest 管理界面中配置推送渠道(如企业微信、钉钉、邮件等)
|
*(如继续使用 `Message-Push-Nest`,你可以通过其管理界面中「消息模板」的「复制推送代码」提取旧有版集成样例。)*
|
||||||
2. 在「消息模板」或「任务」页面,点击「复制推送代码」按钮
|
|
||||||
3. 选择对应的编程语言(Python、Bash、Node.js 等)
|
|
||||||
4. 将生成的代码粘贴到白虎面板的任务脚本中需要推送的位置
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/engigu/baihu-panel/internal/constant"
|
"github.com/engigu/baihu-panel/internal/constant"
|
||||||
"github.com/engigu/baihu-panel/internal/services"
|
"github.com/engigu/baihu-panel/internal/services"
|
||||||
"github.com/engigu/baihu-panel/internal/utils"
|
"github.com/engigu/baihu-panel/internal/utils"
|
||||||
@@ -20,14 +22,14 @@ func NotifyTokenAuth() gin.HandlerFunc {
|
|||||||
// 从 settings 表读取配置的通知 Token
|
// 从 settings 表读取配置的通知 Token
|
||||||
settingsService := services.NewSettingsService()
|
settingsService := services.NewSettingsService()
|
||||||
savedToken := settingsService.Get(constant.SectionNotify, constant.KeyNotifyToken)
|
savedToken := settingsService.Get(constant.SectionNotify, constant.KeyNotifyToken)
|
||||||
|
|
||||||
if savedToken == "" {
|
if savedToken == "" {
|
||||||
utils.Unauthorized(c, "通知 Token 未配置")
|
utils.Unauthorized(c, "通知 Token 未配置")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if token != savedToken {
|
if strings.ToLower(token) != strings.ToLower(savedToken) {
|
||||||
utils.Unauthorized(c, "通知 Token 无效")
|
utils.Unauthorized(c, "通知 Token 无效")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
import { api, type NotifyChannel, type ChannelType, type EventType, type NotifyBinding } from '@/api'
|
import { api, type NotifyChannel, type ChannelType, type EventType, type NotifyBinding, type Task } from '@/api'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import ChannelList from './components/ChannelList.vue'
|
import ChannelList from './components/ChannelList.vue'
|
||||||
import EventBinding from './components/EventBinding.vue'
|
import EventBinding from './components/EventBinding.vue'
|
||||||
@@ -172,13 +172,13 @@ async function saveChannel() {
|
|||||||
toast.error('请填写渠道名称和类型')
|
toast.error('请填写渠道名称和类型')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保 enabled 字段有值
|
// 确保 enabled 字段有值
|
||||||
const channelData = {
|
const channelData = {
|
||||||
...editingChannel.value,
|
...editingChannel.value,
|
||||||
enabled: editingChannel.value.enabled ?? true
|
enabled: editingChannel.value.enabled ?? true
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.notify.saveChannel(channelData)
|
await api.notify.saveChannel(channelData)
|
||||||
toast.success('保存成功')
|
toast.success('保存成功')
|
||||||
@@ -321,52 +321,27 @@ onMounted(() => {
|
|||||||
|
|
||||||
<!-- 渠道管理 -->
|
<!-- 渠道管理 -->
|
||||||
<TabsContent value="channels">
|
<TabsContent value="channels">
|
||||||
<ChannelList
|
<ChannelList :channels="channels" :channel-types="channelTypes" @add="openNewChannel" @edit="openEditChannel"
|
||||||
:channels="channels"
|
@delete="confirmDelete" @test="testChannel" />
|
||||||
:channel-types="channelTypes"
|
|
||||||
@add="openNewChannel"
|
|
||||||
@edit="openEditChannel"
|
|
||||||
@delete="confirmDelete"
|
|
||||||
@test="testChannel"
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<!-- 事件绑定 -->
|
<!-- 事件绑定 -->
|
||||||
<TabsContent value="events">
|
<TabsContent value="events">
|
||||||
<EventBinding
|
<EventBinding :channels="channels" :channel-types="channelTypes" :event-types="eventTypes" :bindings="bindings"
|
||||||
:channels="channels"
|
:tasks="allTasks" @save="saveBindings" @delete="deleteBinding" />
|
||||||
:channel-types="channelTypes"
|
|
||||||
:event-types="eventTypes"
|
|
||||||
:bindings="bindings"
|
|
||||||
:tasks="allTasks"
|
|
||||||
@save="saveBindings"
|
|
||||||
@delete="deleteBinding"
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<!-- 脚本调用 -->
|
<!-- 脚本调用 -->
|
||||||
<TabsContent value="api">
|
<TabsContent value="api">
|
||||||
<ApiUsage
|
<ApiUsage :channels="channels" :channel-types="channelTypes" :api-token="apiToken"
|
||||||
:channels="channels"
|
@generate-token="generateApiToken" @copy-token="copyApiToken" @copy-example="copyApiExample" />
|
||||||
:channel-types="channelTypes"
|
|
||||||
:api-token="apiToken"
|
|
||||||
@generate-token="generateApiToken"
|
|
||||||
@copy-token="copyApiToken"
|
|
||||||
@copy-example="copyApiExample"
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<!-- 添加/编辑渠道弹窗 -->
|
<!-- 添加/编辑渠道弹窗 -->
|
||||||
<ChannelDialog
|
<ChannelDialog v-model:open="showDialog" :is-editing="isEditing" v-model:channel="editingChannel"
|
||||||
v-model:open="showDialog"
|
:channel-types="channelTypes" :config-fields="channelConfigFields" @type-change="onTypeChange"
|
||||||
:is-editing="isEditing"
|
@save="saveChannel" />
|
||||||
v-model:channel="editingChannel"
|
|
||||||
:channel-types="channelTypes"
|
|
||||||
:config-fields="channelConfigFields"
|
|
||||||
@type-change="onTypeChange"
|
|
||||||
@save="saveChannel"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 删除确认 -->
|
<!-- 删除确认 -->
|
||||||
<AlertDialog :open="showDeleteConfirm" @update:open="showDeleteConfirm = $event">
|
<AlertDialog :open="showDeleteConfirm" @update:open="showDeleteConfirm = $event">
|
||||||
|
|||||||
@@ -65,28 +65,23 @@ const shellExample = computed(() => `curl -s -X POST "http://${host.value}/api/v
|
|||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="relative flex-1 group">
|
<div class="relative flex-1 group">
|
||||||
<Input
|
<Input :model-value="apiToken" readonly placeholder="尚未生成 Token"
|
||||||
:model-value="apiToken"
|
class="h-10 pr-10 bg-muted/30 border-muted-foreground/20 focus-visible:ring-primary/30 font-code text-sm tracking-tight" />
|
||||||
readonly
|
<div v-if="apiToken" @click="copyToClipboard(apiToken, 'token')"
|
||||||
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"
|
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"
|
title="复制 Token">
|
||||||
>
|
|
||||||
<Check v-if="copiedBlock === 'token'" class="w-4 h-4 text-emerald-500 animate-in zoom-in" />
|
<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" />
|
<Copy v-else class="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="default" @click="emit('generateToken')" class="h-10 px-4 shrink-0 transition-all active:scale-95">
|
<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" />
|
<RefreshCw class="w-3.5 h-3.5 mr-2" />
|
||||||
{{ apiToken ? '重新生成' : '生成 Token' }}
|
{{ apiToken ? '重新生成' : '生成 Token' }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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">
|
<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" />
|
<Info class="w-4 h-4 mt-0.5 shrink-0" />
|
||||||
<p>请妥善保管您的 Token,一旦丢失需通过上方按钮重新生成。令牌将作为请求头中的 <code>notify-token</code> 字段发送。</p>
|
<p>请妥善保管您的 Token,一旦丢失需通过上方按钮重新生成。令牌将作为请求头中的 <code>notify-token</code> 字段发送。</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -107,40 +102,63 @@ const shellExample = computed(() => `curl -s -X POST "http://${host.value}/api/v
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="p-0 flex-1">
|
<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
|
||||||
<div class="flex items-center justify-between mb-4 border-b border-zinc-700/50 pb-2">
|
class="bg-zinc-50 dark:bg-zinc-950/50 p-5 font-code text-xs sm:text-sm leading-relaxed text-zinc-800 dark:text-zinc-300 relative group h-full">
|
||||||
|
<div class="flex items-center justify-between mb-6 border-b border-zinc-200 dark:border-zinc-800/50 pb-3">
|
||||||
<div class="flex items-center gap-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>
|
<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>
|
<code
|
||||||
|
class="px-2 py-0.5 bg-zinc-200/50 dark:bg-zinc-800/60 border border-zinc-300/50 dark:border-zinc-700/50 rounded-md text-[13px] text-zinc-700 dark:text-zinc-300 font-mono shadow-sm tracking-tight">/api/v1/notify/send</code>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button variant="ghost" size="icon"
|
||||||
variant="ghost"
|
class="h-7 w-7 text-zinc-500 hover:text-zinc-900 dark:hover:text-white hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-all rounded-md"
|
||||||
size="icon"
|
@click="copyToClipboard(apiExample, 'api')">
|
||||||
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" />
|
<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" />
|
<Copy v-else class="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<span class="text-zinc-500 block mb-1 uppercase text-[10px] font-bold tracking-widest">Headers</span>
|
<span class="block mb-2 text-zinc-500 uppercase text-[10px] font-bold tracking-widest">Headers</span>
|
||||||
<div class="pl-2 space-y-1">
|
<div class="space-y-2">
|
||||||
<p><span class="text-zinc-500">Content-Type:</span> application/json</p>
|
<div class="flex items-center gap-2">
|
||||||
<p><span class="text-zinc-500">notify-token:</span> <span class="text-primary"><TOKEN></span></p>
|
<span class="text-zinc-500">Content-Type:</span>
|
||||||
|
<span>application/json</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-zinc-500">notify-token:</span>
|
||||||
|
<span class="text-primary"><TOKEN></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span class="text-zinc-500 block mb-1 uppercase text-[10px] font-bold tracking-widest">Body (JSON)</span>
|
<span class="block mb-2 text-zinc-500 uppercase text-[10px] font-bold tracking-widest">Body
|
||||||
<div class="pl-2">
|
(JSON)</span>
|
||||||
<p class="text-zinc-400">{</p>
|
<div class="pl-2 font-mono">
|
||||||
<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="text-zinc-600 dark:text-zinc-400 mb-1">{</p>
|
||||||
<p class="pl-4">"title": <span class="text-orange-400">"标题"</span>, <span class="text-zinc-500 font-sans italic text-[11px]">// 可选</span></p>
|
<div class="pl-4 space-y-2">
|
||||||
<p class="pl-4">"text": <span class="text-orange-400">"内容"</span> <span class="text-zinc-500 font-sans italic text-[11px]">// 必填</span></p>
|
<div class="flex flex-wrap items-center gap-x-2">
|
||||||
<p class="text-zinc-400">}</p>
|
<span class="text-orange-600 dark:text-orange-400">"channel_id":</span>
|
||||||
|
<span class="text-emerald-600 dark:text-emerald-400">"ID"</span><span
|
||||||
|
class="text-zinc-600 dark:text-zinc-400">,</span>
|
||||||
|
<span class="text-zinc-500 font-sans italic ml-auto whitespace-nowrap">//
|
||||||
|
渠道唯一标识</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-x-2">
|
||||||
|
<span class="text-orange-600 dark:text-orange-400">"title":</span>
|
||||||
|
<span class="text-emerald-600 dark:text-emerald-400">"标题"</span><span
|
||||||
|
class="text-zinc-600 dark:text-zinc-400">,</span>
|
||||||
|
<span class="text-zinc-500 font-sans italic ml-auto whitespace-nowrap">// 可选</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-x-2">
|
||||||
|
<span class="text-orange-600 dark:text-orange-400">"text":</span>
|
||||||
|
<span class="text-emerald-600 dark:text-emerald-400">"内容"</span>
|
||||||
|
<span class="text-zinc-500 font-sans italic ml-auto whitespace-nowrap">// 必填</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-zinc-600 dark:text-zinc-400 mt-1">}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,12 +176,9 @@ const shellExample = computed(() => `curl -s -X POST "http://${host.value}/api/v
|
|||||||
</div>
|
</div>
|
||||||
<CardTitle class="text-sm font-bold uppercase tracking-wider">Shell 脚本示例</CardTitle>
|
<CardTitle class="text-sm font-bold uppercase tracking-wider">Shell 脚本示例</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button variant="outline" size="sm"
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
class="h-7 px-2 text-[10px] border-muted-foreground/30 hover:bg-muted transition-all"
|
class="h-7 px-2 text-[10px] border-muted-foreground/30 hover:bg-muted transition-all"
|
||||||
@click="copyToClipboard(shellExample, 'shell')"
|
@click="copyToClipboard(shellExample, 'shell')">
|
||||||
>
|
|
||||||
<Check v-if="copiedBlock === 'shell'" class="w-3 h-3 text-emerald-500 mr-1.5" />
|
<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" />
|
<Copy v-else class="w-3 h-3 mr-1.5" />
|
||||||
一键复制
|
一键复制
|
||||||
@@ -171,24 +186,40 @@ const shellExample = computed(() => `curl -s -X POST "http://${host.value}/api/v
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="p-0 flex-1">
|
<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="bg-zinc-50 dark:bg-zinc-950/50 p-5 font-code text-[12px] sm:text-[13px] leading-relaxed text-zinc-800 dark:text-zinc-300 h-full">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<p><span class="text-zinc-600"># 使用 CURL 调用推送接口</span></p>
|
<p><span class="text-zinc-500"># 使用 CURL 调用推送接口</span></p>
|
||||||
<p>curl -s -X POST <span class="text-emerald-400">"http://{{ host }}/api/v1/notify/send"</span> \</p>
|
<p>curl -s -X POST <span class="text-emerald-600 dark:text-emerald-400">"http://{{ host
|
||||||
<p class="pl-4"> -H <span class="text-orange-400">"Content-Type: application/json"</span> \</p>
|
}}/api/v1/notify/send"</span> \</p>
|
||||||
<p class="pl-4"> -H <span class="text-orange-400">"notify-token: {{ apiToken || 'YOUR_TOKEN' }}"</span> \</p>
|
<p class="pl-4"> -H <span class="text-orange-600 dark:text-orange-400">"Content-Type:
|
||||||
<p class="pl-4"> -d <span class="text-orange-400">'{"channel_id":"ID","title":"任务完成","text":"脚本执行完毕"}'</span></p>
|
application/json"</span> \</p>
|
||||||
|
<p class="pl-4"> -H <span class="text-orange-600 dark:text-orange-400">"notify-token: {{ apiToken ||
|
||||||
|
'YOUR_TOKEN' }}"</span> \
|
||||||
|
</p>
|
||||||
|
<p class="pl-4"> -d <span
|
||||||
|
class="text-orange-600 dark:text-orange-400">'{"channel_id":"ID","title":"任务完成","text":"脚本执行完毕"}'</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 pt-4 border-t border-zinc-800">
|
<div class="mt-8 pt-4 border-t border-zinc-200 dark:border-zinc-800">
|
||||||
<span class="text-zinc-500 block mb-2 uppercase text-[10px] font-bold tracking-widest flex items-center gap-1.5">
|
<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 快速查找
|
<Hash class="w-3 h-3" /> 渠道 ID 快速查找
|
||||||
</span>
|
</span>
|
||||||
<div v-if="channels.length === 0" class="text-xs text-zinc-600 italic">暂无活跃渠道</div>
|
<div v-if="channels.length === 0" class="text-xs text-zinc-500 italic">暂无活跃渠道</div>
|
||||||
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
<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">
|
<div v-for="ch in channels" :key="ch.id"
|
||||||
<code class="text-primary font-bold tracking-tighter font-code">{{ ch.id.slice(0, 8) }}</code>
|
class="flex items-center gap-2 text-xs bg-zinc-100/80 dark:bg-zinc-900 px-2 py-1.5 rounded border border-zinc-200 dark:border-zinc-800 hover:border-zinc-300 dark:hover:border-zinc-700 transition-colors group">
|
||||||
<span class="text-zinc-500 truncate max-w-[100px]">{{ ch.name }}</span>
|
<code class="text-primary font-bold tracking-tighter font-code"
|
||||||
|
:title="ch.id">{{ ch.id.slice(0, 8) }}</code>
|
||||||
|
<span class="text-zinc-600 dark:text-zinc-500 truncate max-w-[100px]">{{ ch.name }}</span>
|
||||||
|
<Button variant="ghost" size="icon"
|
||||||
|
class="h-5 w-5 ml-auto text-zinc-400 hover:text-zinc-800 dark:hover:text-zinc-200 hover:bg-zinc-200 dark:hover:bg-zinc-800 opacity-0 group-hover:opacity-100 transition-all rounded"
|
||||||
|
@click="copyToClipboard(ch.id, 'channel-' + ch.id)">
|
||||||
|
<Check v-if="copiedBlock === 'channel-' + ch.id" class="w-2.5 h-2.5 text-emerald-500" />
|
||||||
|
<Copy v-else class="w-2.5 h-2.5" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -198,3 +229,38 @@ const shellExample = computed(() => `curl -s -X POST "http://${host.value}/api/v
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.font-code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-code ::selection {
|
||||||
|
background-color: rgba(59, 130, 246, 0.4);
|
||||||
|
/* 鲜亮的蓝色选中背景 */
|
||||||
|
color: #fff;
|
||||||
|
/* 选中时文字为白色 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 兼容 Safari */
|
||||||
|
.font-code ::-moz-selection {
|
||||||
|
background-color: rgba(59, 130, 246, 0.4);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 优化滚动条样式 if needed */
|
||||||
|
.font-code::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-code::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-code::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Plus, Trash2, TestTube, Pencil, Bell, Calendar } from 'lucide-vue-next'
|
import { Plus, Trash2, TestTube, Pencil, Bell, Calendar, Copy, Check } from 'lucide-vue-next'
|
||||||
import type { NotifyChannel, ChannelType } from '@/api'
|
import type { NotifyChannel, ChannelType } from '@/api'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
channels: NotifyChannel[]
|
channels: NotifyChannel[]
|
||||||
@@ -21,6 +23,18 @@ function getChannelTypeName(type: string, channelTypes: ChannelType[]): string {
|
|||||||
const found = channelTypes.find(t => t.type === type)
|
const found = channelTypes.find(t => t.type === type)
|
||||||
return found ? found.label : type
|
return found ? found.label : type
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const copiedBlock = ref<string | null>(null)
|
||||||
|
|
||||||
|
function copyToClipboard(text: string, blockId: string) {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
copiedBlock.value = blockId
|
||||||
|
toast.success('已复制长 ID')
|
||||||
|
setTimeout(() => {
|
||||||
|
copiedBlock.value = null
|
||||||
|
}, 2000)
|
||||||
|
})
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -78,6 +92,12 @@ function getChannelTypeName(type: string, channelTypes: ChannelType[]): string {
|
|||||||
<span>{{ ch.created_at.split(' ')[0] }}</span>
|
<span>{{ ch.created_at.split(' ')[0] }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
|
<Button variant="ghost" size="icon"
|
||||||
|
class="h-8 w-8 rounded-full hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
|
||||||
|
@click="copyToClipboard(ch.id, 'channel-' + ch.id)" title="复制 ID">
|
||||||
|
<Check v-if="copiedBlock === 'channel-' + ch.id" class="w-4 h-4 text-emerald-500" />
|
||||||
|
<Copy v-else class="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
<Button variant="ghost" size="icon"
|
<Button variant="ghost" size="icon"
|
||||||
class="h-8 w-8 rounded-full hover:bg-primary/10 hover:text-primary transition-colors"
|
class="h-8 w-8 rounded-full hover:bg-primary/10 hover:text-primary transition-colors"
|
||||||
@click="emit('test', ch)" title="测试发送">
|
@click="emit('test', ch)" title="测试发送">
|
||||||
|
|||||||
Reference in New Issue
Block a user