Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d89b7fe8f |
@@ -101,6 +101,20 @@ cd web/frontend && npm run dev
|
||||
|
||||
**第一个注册的用户自动成为管理员**。
|
||||
|
||||
### API Key
|
||||
|
||||
每个用户在注册时会自动生成 API Key,可在 WebUI 的“设置”页面查看。调用需要认证的 API 时,在请求头中任选其一:
|
||||
|
||||
```http
|
||||
X-API-Key: gb_<your-api-key>
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```http
|
||||
Authorization: Bearer gb_<your-api-key>
|
||||
```
|
||||
|
||||
## 数据库
|
||||
|
||||
默认 SQLite,可选 PostgreSQL,详见 [docs/database.md](docs/database.md)。
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/ssdomei232/goodBaby/api/response"
|
||||
"github.com/ssdomei232/goodBaby/api/user"
|
||||
"github.com/ssdomei232/goodBaby/handler/db"
|
||||
"github.com/ssdomei232/goodBaby/handler/runner"
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
type webhookRequest struct {
|
||||
Message string `json:"message"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
func token() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "gw_" + hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func HandleList(c *gin.Context) {
|
||||
u, err := user.GetUserInfoByGinCtx(c)
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "未登录")
|
||||
return
|
||||
}
|
||||
dbConn, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取网关失败")
|
||||
return
|
||||
}
|
||||
var items []model.MessageGateway
|
||||
if err := dbConn.Where("uid = ?", u.ID).Order("id DESC").Find(&items).Error; err != nil {
|
||||
response.ServerError(c, "获取网关失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, items)
|
||||
}
|
||||
|
||||
func HandleCreate(c *gin.Context) {
|
||||
u, err := user.GetUserInfoByGinCtx(c)
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "未登录")
|
||||
return
|
||||
}
|
||||
var req model.MessageGatewayRequest
|
||||
if c.ShouldBindJSON(&req) != nil || strings.TrimSpace(req.Name) == "" {
|
||||
response.BadRequest(c, "网关名称和规则不能为空")
|
||||
return
|
||||
}
|
||||
dbConn, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
response.ServerError(c, "创建网关失败")
|
||||
return
|
||||
}
|
||||
key, err := token()
|
||||
if err != nil {
|
||||
response.ServerError(c, "生成网关 Token 失败")
|
||||
return
|
||||
}
|
||||
item := model.MessageGateway{UID: u.ID, Name: strings.TrimSpace(req.Name), Token: key, CreateAt: time.Now().Unix()}
|
||||
if err := dbConn.Create(&item).Error; err != nil {
|
||||
response.ServerError(c, "创建网关失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func HandleDelete(c *gin.Context) {
|
||||
u, err := user.GetUserInfoByGinCtx(c)
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "未登录")
|
||||
return
|
||||
}
|
||||
dbConn, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
response.ServerError(c, "删除网关失败")
|
||||
return
|
||||
}
|
||||
if err := dbConn.Where("id = ? AND uid = ?", c.Param("gatewayID"), u.ID).Delete(&model.MessageGateway{}).Error; err != nil {
|
||||
response.ServerError(c, "删除网关失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, "网关已删除")
|
||||
}
|
||||
|
||||
func HandleWebhook(c *gin.Context) {
|
||||
dbConn, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
response.ServerError(c, "网关不可用")
|
||||
return
|
||||
}
|
||||
var gw model.MessageGateway
|
||||
if err := dbConn.Where("token = ?", c.Param("token")).First(&gw).Error; err != nil {
|
||||
response.Fail(c, http.StatusNotFound, "网关不存在")
|
||||
return
|
||||
}
|
||||
var req webhookRequest
|
||||
if c.ShouldBindJSON(&req) != nil || strings.TrimSpace(req.Message) == "" {
|
||||
response.BadRequest(c, "message 不能为空")
|
||||
return
|
||||
}
|
||||
var rules []model.Rule
|
||||
if err := dbConn.Where("uid = ? AND gateway_id = ? AND enabled = ?", gw.UID, gw.ID, true).Find(&rules).Error; err != nil {
|
||||
response.ServerError(c, "读取网关规则失败")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), retry.TestTimeout)
|
||||
defer cancel()
|
||||
fails := make([]string, 0)
|
||||
for i := range rules {
|
||||
rule := rules[i]
|
||||
cfg, err := overrideMessage(rule.ConfigJson, req.Title, req.Message)
|
||||
if err != nil {
|
||||
fails = append(fails, rule.Name+": 规则不支持消息网关")
|
||||
continue
|
||||
}
|
||||
rule.ConfigJson = cfg
|
||||
if err := runner.ExecuteRuleWithContext(ctx, &rule, "webhook"); err != nil {
|
||||
fails = append(fails, rule.Name+": "+err.Error())
|
||||
}
|
||||
}
|
||||
response.OK(c, gin.H{"total": len(rules), "failed": fails})
|
||||
}
|
||||
|
||||
func overrideMessage(raw, title, message string) (string, error) {
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &obj); err != nil {
|
||||
return raw, err
|
||||
}
|
||||
matched := false
|
||||
if _, ok := obj["msg"]; ok {
|
||||
obj["msg"] = message
|
||||
matched = true
|
||||
}
|
||||
if _, ok := obj["message"]; ok {
|
||||
obj["message"] = message
|
||||
matched = true
|
||||
}
|
||||
if _, ok := obj["body"]; ok {
|
||||
obj["body"] = message
|
||||
matched = true
|
||||
}
|
||||
if !matched {
|
||||
return raw, fmt.Errorf("message field not found")
|
||||
}
|
||||
if title != "" {
|
||||
if _, ok := obj["title"]; ok {
|
||||
obj["title"] = title
|
||||
}
|
||||
}
|
||||
b, err := json.Marshal(obj)
|
||||
return string(b), err
|
||||
}
|
||||
@@ -67,6 +67,7 @@ func HandleCreateRule(c *gin.Context) {
|
||||
UID: userInfo.ID,
|
||||
Name: req.Name,
|
||||
TimerID: req.TimerID,
|
||||
GatewayID: req.GatewayID,
|
||||
AccountID: req.AccountID,
|
||||
Type: req.Type,
|
||||
ConfigJson: req.ConfigJson,
|
||||
@@ -123,6 +124,7 @@ func HandleEditRule(c *gin.Context) {
|
||||
updated.Name = req.Name
|
||||
updated.Type = req.Type
|
||||
updated.TimerID = req.TimerID
|
||||
updated.GatewayID = req.GatewayID
|
||||
updated.AccountID = req.AccountID
|
||||
updated.Enabled = boolOr(req.Enabled, existing.Enabled)
|
||||
// 前端提交的敏感字段可能是掩码占位符,用旧配置补回
|
||||
|
||||
+10
-2
@@ -59,13 +59,21 @@ func checkRuleConfigAccountAndTimerExist(rule *model.Rule, requiredAccountType s
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := gormDB.Model(&model.Timer{}).
|
||||
Where("id = ? AND uid = ?", rule.TimerID, rule.UID).Count(&count).Error; err != nil {
|
||||
if rule.TimerID != 0 {
|
||||
if err := gormDB.Model(&model.Timer{}).Where("id = ? AND uid = ?", rule.TimerID, rule.UID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return model.ErrValidation("关联的 Timer 不存在")
|
||||
}
|
||||
} else {
|
||||
if err := gormDB.Model(&model.MessageGateway{}).Where("id = ? AND uid = ?", rule.GatewayID, rule.UID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return model.ErrValidation("关联的消息网关不存在")
|
||||
}
|
||||
}
|
||||
|
||||
if requiredAccountType == "" {
|
||||
// 该类型不需要账号,忽略前端可能传来的 account_id
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -48,11 +50,44 @@ func createUser(u *model.User) error {
|
||||
return err
|
||||
}
|
||||
u.Password = hashedPassword
|
||||
if u.APIKey == "" {
|
||||
u.APIKey, err = generateAPIKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
u.CreateAt = time.Now().Unix()
|
||||
|
||||
return gormDB.Create(u).Error
|
||||
}
|
||||
|
||||
func generateAPIKey() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "gb_" + hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func ensureAPIKey(user *model.User) error {
|
||||
if user.APIKey != "" {
|
||||
return nil
|
||||
}
|
||||
key, err := generateAPIKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := gormDB.Model(&model.User{}).Where("id = ? AND (api_key IS NULL OR api_key = '')", user.ID).Update("api_key", key).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
user.APIKey = key
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsUsernameTaken 用户名是否已被占用
|
||||
func IsUsernameTaken(username string) (bool, error) {
|
||||
gormDB, err := db.GetGormDB()
|
||||
@@ -134,6 +169,9 @@ func GetUserByID(id uint) (*model.User, error) {
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := ensureAPIKey(&user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -210,6 +210,27 @@ func HandleUpdateNotifyConfig(c *gin.Context) {
|
||||
// AuthMiddleware 认证中间件,同时把当前用户放进 context 供后续 handler 复用
|
||||
func AuthMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
apiKey := c.GetHeader("X-API-Key")
|
||||
if apiKey == "" {
|
||||
auth := c.GetHeader("Authorization")
|
||||
const prefix = "Bearer "
|
||||
if len(auth) > len(prefix) && auth[:len(prefix)] == prefix {
|
||||
apiKey = auth[len(prefix):]
|
||||
}
|
||||
}
|
||||
if apiKey != "" {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err == nil {
|
||||
var user model.User
|
||||
if gormDB.Where("api_key = ?", apiKey).First(&user).Error == nil {
|
||||
c.Set(contextKey, &user)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
response.AbortWith(c, http.StatusUnauthorized, "API Key 无效")
|
||||
return
|
||||
}
|
||||
session := sessions.Default(c)
|
||||
uid, ok := session.Get("uid").(uint)
|
||||
if !ok {
|
||||
|
||||
@@ -138,5 +138,6 @@ func AutoMigrate(gormDB *gorm.DB) error {
|
||||
&model.Rule{},
|
||||
&model.Account{},
|
||||
&model.ExecutionLog{},
|
||||
&model.MessageGateway{},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/ssdomei232/goodBaby/api/account"
|
||||
"github.com/ssdomei232/goodBaby/api/admin"
|
||||
"github.com/ssdomei232/goodBaby/api/dashboard"
|
||||
"github.com/ssdomei232/goodBaby/api/gateway"
|
||||
apilog "github.com/ssdomei232/goodBaby/api/log"
|
||||
apimeta "github.com/ssdomei232/goodBaby/api/meta"
|
||||
"github.com/ssdomei232/goodBaby/api/rule"
|
||||
@@ -65,6 +66,7 @@ func main() {
|
||||
v1.GET("/site", apimeta.HandleGetSiteInfo)
|
||||
v1.POST("/user/registry", user.HandleRegistry)
|
||||
v1.POST("/user/login", user.HandleLogin)
|
||||
v1.POST("/gateways/:token/webhook", gateway.HandleWebhook)
|
||||
}
|
||||
|
||||
// 需要认证的路由组
|
||||
@@ -114,6 +116,13 @@ func main() {
|
||||
accounts.DELETE("/:accountID", account.HandleDeleteAccount)
|
||||
}
|
||||
|
||||
gateways := authorized.Group("/gateways")
|
||||
{
|
||||
gateways.GET("/", gateway.HandleList)
|
||||
gateways.POST("/", gateway.HandleCreate)
|
||||
gateways.DELETE("/:gatewayID", gateway.HandleDelete)
|
||||
}
|
||||
|
||||
logs := authorized.Group("/logs")
|
||||
{
|
||||
logs.GET("/", apilog.HandleGetLogs)
|
||||
@@ -151,7 +160,7 @@ func corsMiddleware(allowedOrigins []string) gin.HandlerFunc {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, X-API-Key, Authorization")
|
||||
c.Header("Vary", "Origin")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package model
|
||||
|
||||
type MessageGateway struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UID uint `gorm:"index" json:"uid"`
|
||||
Name string `json:"name"`
|
||||
Token string `gorm:"uniqueIndex;size:67" json:"token"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
}
|
||||
|
||||
type MessageGatewayRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
+7
-2
@@ -5,6 +5,7 @@ type Rule struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UID uint `gorm:"index" json:"uid"`
|
||||
TimerID uint `gorm:"index" json:"timer_id"`
|
||||
GatewayID uint `gorm:"index" json:"gateway_id"`
|
||||
Name string `json:"name"`
|
||||
// 关联账号,部分规则类型(如钉钉)不需要账号,此时为 0
|
||||
AccountID uint `json:"account_id"`
|
||||
@@ -20,6 +21,7 @@ type Rule struct {
|
||||
type RuleRequest struct {
|
||||
Name string `json:"name"`
|
||||
TimerID uint `json:"timer_id"`
|
||||
GatewayID uint `json:"gateway_id"`
|
||||
AccountID uint `json:"account_id"`
|
||||
Type string `json:"type"`
|
||||
ConfigJson string `json:"config_json"`
|
||||
@@ -37,8 +39,11 @@ func (r *RuleRequest) Validate() error {
|
||||
if r.Type == "" {
|
||||
return ErrValidation("规则类型不能为空")
|
||||
}
|
||||
if r.TimerID == 0 {
|
||||
return ErrValidation("必须关联一个 Timer")
|
||||
if r.TimerID == 0 && r.GatewayID == 0 {
|
||||
return ErrValidation("必须关联一个定时器或消息网关")
|
||||
}
|
||||
if r.TimerID != 0 && r.GatewayID != 0 {
|
||||
return ErrValidation("定时器和消息网关只能选择一个")
|
||||
}
|
||||
if r.ConfigJson == "" {
|
||||
return ErrValidation("规则配置不能为空")
|
||||
|
||||
@@ -23,6 +23,7 @@ type User struct {
|
||||
CreateAt int64 `json:"create_at"`
|
||||
Username string `gorm:"uniqueIndex" json:"username"`
|
||||
Password string `json:"-"`
|
||||
APIKey string `gorm:"index;size:67" json:"api_key"`
|
||||
// 管理员可以修改系统配置。第一个注册的用户自动成为管理员。
|
||||
IsAdmin bool `gorm:"default:false" json:"is_admin"`
|
||||
// 钉钉机器人配置(JSON 字符串),用于接收提醒
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
DashboardOverview,
|
||||
ExecutionLog,
|
||||
LogPage,
|
||||
MessageGateway,
|
||||
Providers,
|
||||
Rule,
|
||||
RuleRequest,
|
||||
@@ -81,6 +82,12 @@ export const logApi = {
|
||||
clear: () => api.delete<string>('/logs/'),
|
||||
}
|
||||
|
||||
export const gatewayApi = {
|
||||
list: () => api.get<MessageGateway[]>('/gateways/'),
|
||||
create: (name: string) => api.post<MessageGateway>('/gateways/', { name }),
|
||||
remove: (id: number) => api.delete<string>(`/gateways/${id}`),
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
getConfig: () => api.get<AdminConfigResponse>('/admin/config'),
|
||||
updateConfig: (body: AdminConfig) => api.put<AdminConfig>('/admin/config', body),
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface UserInfo {
|
||||
username: string
|
||||
is_admin: boolean
|
||||
dingtalk_config: string | null
|
||||
api_key: string
|
||||
}
|
||||
|
||||
export interface Timer {
|
||||
@@ -40,6 +41,7 @@ export interface Rule {
|
||||
id: number
|
||||
uid: number
|
||||
timer_id: number
|
||||
gateway_id: number
|
||||
name: string
|
||||
account_id: number
|
||||
type: string
|
||||
@@ -48,9 +50,12 @@ export interface Rule {
|
||||
create_at: number
|
||||
}
|
||||
|
||||
export interface MessageGateway { id: number; uid: number; name: string; token: string; create_at: number }
|
||||
|
||||
export interface RuleRequest {
|
||||
name: string
|
||||
timer_id: number
|
||||
gateway_id?: number
|
||||
account_id: number
|
||||
type: string
|
||||
config_json: string
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Timer as TimerIcon,
|
||||
Operation,
|
||||
User,
|
||||
Connection,
|
||||
Document,
|
||||
Setting,
|
||||
SwitchButton,
|
||||
@@ -38,6 +39,7 @@ const menus = [
|
||||
{ path: '/timers', title: '定时器', icon: TimerIcon },
|
||||
{ path: '/rules', title: '规则', icon: Operation },
|
||||
{ path: '/accounts', title: '账号', icon: User },
|
||||
{ path: '/gateways', title: '消息网关', icon: Connection },
|
||||
{ path: '/logs', title: '执行日志', icon: Document },
|
||||
{ path: '/settings', title: '设置', icon: Setting },
|
||||
]
|
||||
|
||||
@@ -40,6 +40,7 @@ const router = createRouter({
|
||||
component: () => import('@/views/AccountsView.vue'),
|
||||
meta: { title: '账号' },
|
||||
},
|
||||
{ path: 'gateways', name: 'gateways', component: () => import('@/views/GatewaysView.vue'), meta: { title: '消息网关' } },
|
||||
{
|
||||
path: 'logs',
|
||||
name: 'logs',
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { CopyDocument, Delete, Link, Plus } from '@element-plus/icons-vue'
|
||||
import { gatewayApi, ruleApi } from '@/api'
|
||||
import type { MessageGateway, Rule } from '@/api/types'
|
||||
const gateways = ref<MessageGateway[]>([]), rules = ref<Rule[]>([]), name = ref(''), loading = ref(false), saving = ref(false)
|
||||
const origin = window.location.origin
|
||||
async function refresh() { loading.value = true; try { ;[gateways.value, rules.value] = await Promise.all([gatewayApi.list(), ruleApi.list()]) } catch { ElMessage.error('加载消息网关失败') } finally { loading.value = false } }
|
||||
async function create() { if (!name.value.trim()) { ElMessage.warning('请输入网关名称'); return }; saving.value = true; try { await gatewayApi.create(name.value.trim()); name.value = ''; await refresh(); ElMessage.success('消息网关已创建') } catch (e) { ElMessage.error(e instanceof Error ? e.message : '创建失败') } finally { saving.value = false } }
|
||||
async function remove(item: MessageGateway) { try { await ElMessageBox.confirm(`删除“${item.name}”?关联规则将无法继续通过此网关触发。`, '删除消息网关', { type: 'warning' }); await gatewayApi.remove(item.id); await refresh() } catch {} }
|
||||
async function copy(url: string) { await navigator.clipboard?.writeText(url); ElMessage.success('Webhook 地址已复制') }
|
||||
function endpoint(item: MessageGateway) { return `${origin}/api/v1/gateways/${item.token}/webhook` }
|
||||
function ruleCount(item: MessageGateway) { return rules.value.filter(r => r.gateway_id === item.id).length }
|
||||
onMounted(refresh)
|
||||
</script>
|
||||
<template><div class="gateway-page" v-loading="loading"><div class="page-header"><div><h2>消息网关</h2><div class="muted">创建入口,让外部系统通过 Webhook 触发通知规则</div></div><el-button type="primary" :icon="Plus">新建网关</el-button></div><el-card class="create-card"><div class="create-copy"><div class="create-icon"><el-icon :size="20"><Link /></el-icon></div><div><strong>新建 Webhook 入口</strong><div class="muted">创建后,在规则编辑页将通知规则关联到此网关</div></div></div><el-form class="create-form" @submit.prevent="create"><el-input v-model="name" size="large" placeholder="例如:生产环境告警" maxlength="64" clearable /><el-button type="primary" size="large" :loading="saving" @click="create">创建网关</el-button></el-form></el-card><div v-if="gateways.length" class="gateway-grid"><el-card v-for="item in gateways" :key="item.id" class="gateway-card"><div class="gateway-top"><div><div class="gateway-name">{{ item.name }}</div><div class="gateway-meta">{{ ruleCount(item) }} 条关联通知规则</div></div><el-button circle text type="danger" :icon="Delete" title="删除网关" @click="remove(item)" /></div><div class="endpoint-label">Webhook 地址</div><div class="endpoint"><code>{{ endpoint(item) }}</code><el-button text :icon="CopyDocument" title="复制地址" @click="copy(endpoint(item))" /></div><div class="payload-hint">POST JSON:<code>{ "message": "...", "title": "..." }</code></div></el-card></div><el-empty v-else-if="!loading" description="还没有消息网关" /></div></template>
|
||||
<style scoped>.create-card{margin-bottom:20px}.create-copy{display:flex;align-items:center;gap:12px;margin-bottom:16px}.create-copy strong{font-size:15px}.create-icon{width:40px;height:40px;display:grid;place-items:center;border-radius:10px;color:var(--gb-primary-deep);background:var(--el-color-primary-light-9)}.create-form{display:flex;gap:10px;max-width:620px}.create-form .el-input{flex:1}.gateway-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px}.gateway-card{min-width:0}.gateway-top{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:22px}.gateway-name{font-size:17px;font-weight:700}.gateway-meta,.endpoint-label,.payload-hint{color:var(--el-text-color-secondary);font-size:12px}.endpoint-label{margin-bottom:6px}.endpoint{display:flex;align-items:center;gap:6px;min-width:0;padding:10px 12px;border-radius:8px;background:var(--gb-bg)}.endpoint code{flex:1;min-width:0;overflow-wrap:anywhere;color:var(--el-text-color-primary);font-size:12px}.payload-hint{margin-top:12px}.payload-hint code{color:var(--el-text-color-primary)}@media(max-width:600px){.create-form{flex-direction:column}.gateway-grid{grid-template-columns:1fr}}</style>
|
||||
@@ -2,9 +2,9 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, VideoPlay } from '@element-plus/icons-vue'
|
||||
import { accountApi, ruleApi, timerApi } from '@/api'
|
||||
import { accountApi, gatewayApi, ruleApi, timerApi } from '@/api'
|
||||
import { ApiError } from '@/api/client'
|
||||
import type { Account, Rule, Timer } from '@/api/types'
|
||||
import type { Account, MessageGateway, Rule, Timer } from '@/api/types'
|
||||
import { useMetaStore } from '@/stores/meta'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import ConfigForm from '@/components/ConfigForm.vue'
|
||||
@@ -16,6 +16,7 @@ const metaStore = useMetaStore()
|
||||
|
||||
const rules = ref<Rule[]>([])
|
||||
const timers = ref<Timer[]>([])
|
||||
const gateways = ref<MessageGateway[]>([])
|
||||
const accounts = ref<Account[]>([])
|
||||
const loading = ref(false)
|
||||
const testingId = ref<number | null>(null)
|
||||
@@ -30,6 +31,7 @@ const form = ref<{
|
||||
name: string
|
||||
type: string
|
||||
timer_id: number
|
||||
gateway_id: number
|
||||
account_id: number | undefined
|
||||
enabled: boolean
|
||||
config_json: string
|
||||
@@ -37,6 +39,7 @@ const form = ref<{
|
||||
name: '',
|
||||
type: '',
|
||||
timer_id: 0,
|
||||
gateway_id: 0,
|
||||
account_id: undefined,
|
||||
enabled: true,
|
||||
config_json: '',
|
||||
@@ -69,14 +72,16 @@ function accountName(id: number): string {
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [ruleList, timerList, accountList] = await Promise.all([
|
||||
const [ruleList, timerList, accountList, gatewayList] = await Promise.all([
|
||||
ruleApi.list(),
|
||||
timerApi.list(),
|
||||
accountApi.list(),
|
||||
gatewayApi.list(),
|
||||
])
|
||||
rules.value = ruleList
|
||||
timers.value = timerList
|
||||
accounts.value = accountList
|
||||
gateways.value = gatewayList
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof ApiError ? error.message : '加载失败')
|
||||
} finally {
|
||||
@@ -85,7 +90,7 @@ async function refresh() {
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
if (timers.value.length === 0) {
|
||||
if (timers.value.length === 0 && gateways.value.length === 0) {
|
||||
ElMessage.warning('请先在“定时器”页面创建一个定时器')
|
||||
return
|
||||
}
|
||||
@@ -94,6 +99,7 @@ function openCreate() {
|
||||
name: '',
|
||||
type: metaStore.ruleMetas[0]?.type ?? '',
|
||||
timer_id: timers.value[0]?.id ?? 0,
|
||||
gateway_id: 0,
|
||||
account_id: undefined,
|
||||
enabled: true,
|
||||
config_json: '',
|
||||
@@ -107,6 +113,7 @@ function openEdit(rule: Rule) {
|
||||
name: rule.name,
|
||||
type: rule.type,
|
||||
timer_id: rule.timer_id,
|
||||
gateway_id: rule.gateway_id || 0,
|
||||
account_id: rule.account_id || undefined,
|
||||
enabled: rule.enabled,
|
||||
config_json: rule.config_json,
|
||||
@@ -133,6 +140,7 @@ async function save() {
|
||||
name: form.value.name,
|
||||
type: form.value.type,
|
||||
timer_id: form.value.timer_id,
|
||||
gateway_id: form.value.gateway_id,
|
||||
account_id: form.value.account_id ?? 0,
|
||||
enabled: form.value.enabled,
|
||||
config_json: form.value.config_json,
|
||||
@@ -162,6 +170,7 @@ async function toggleEnabled(rule: Rule) {
|
||||
name: rule.name,
|
||||
type: rule.type,
|
||||
timer_id: rule.timer_id,
|
||||
gateway_id: rule.gateway_id,
|
||||
account_id: rule.account_id,
|
||||
enabled: rule.enabled,
|
||||
config_json: rule.config_json,
|
||||
@@ -308,10 +317,14 @@ onMounted(async () => {
|
||||
</el-select>
|
||||
<div v-if="currentMeta?.description" class="muted">{{ currentMeta.description }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="关联定时器" required>
|
||||
<el-select v-model="form.timer_id" style="width: 100%">
|
||||
<el-form-item label="关联定时器/消息网关" required>
|
||||
<div class="source-hint muted">规则只能选择一种触发方式</div>
|
||||
<el-select v-model="form.timer_id" class="source-select" clearable placeholder="定时器触发">
|
||||
<el-option v-for="t in timers" :key="t.id" :label="t.name" :value="t.id" />
|
||||
</el-select>
|
||||
<el-select v-model="form.gateway_id" clearable placeholder="消息网关(可选)" style="width: 100%; margin-top: 8px" @change="form.gateway_id && (form.timer_id = 0)">
|
||||
<el-option v-for="g in gateways" :key="g.id" :label="g.name" :value="g.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="currentMeta?.account_type" label="关联账号" required>
|
||||
<el-select
|
||||
@@ -363,6 +376,9 @@ onMounted(async () => {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.source-select { width: 100%; margin-top: 8px; }
|
||||
.source-hint { margin-bottom: 2px; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-tools {
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -14,6 +14,13 @@ const userStore = useUserStore()
|
||||
|
||||
const isAdmin = computed(() => userStore.user?.is_admin === true)
|
||||
|
||||
async function copyApiKey() {
|
||||
const key = userStore.user?.api_key
|
||||
if (!key) return
|
||||
await navigator.clipboard?.writeText(key)
|
||||
ElMessage.success('API Key 已复制')
|
||||
}
|
||||
|
||||
// ---- 系统配置(仅管理员) ----
|
||||
const adminLoading = ref(false)
|
||||
const adminSaving = ref(false)
|
||||
@@ -166,6 +173,13 @@ onMounted(async () => {
|
||||
<el-descriptions-item label="注册时间">
|
||||
{{ formatDateTime(userStore.user?.create_at ?? 0) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="API Key">
|
||||
<el-input :model-value="userStore.user?.api_key ?? ''" readonly style="max-width: 380px">
|
||||
<template #append>
|
||||
<el-button title="复制 API Key" @click="copyApiKey">复制</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user