2 Commits
Author SHA1 Message Date
mei 0d89b7fe8f feat(gateway): 新增消息网关及apikey功能
Quality check / Web UI (push) Successful in 9m16s
2026-08-30 21:09:26 +08:00
mei 360169ee64 fix: 修复删除资源时未验证所有者的问题
Quality check / Web UI (push) Successful in 9m11s
2026-08-30 17:47:53 +08:00
22 changed files with 431 additions and 20 deletions
+14
View File
@@ -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)。
+12
View File
@@ -218,6 +218,18 @@ func HandleDeleteAccount(c *gin.Context) {
return
}
// 检查账号是否归属请求用户
ownerUID, err := getAccountOwnerUID(accountID)
if err != nil {
response.ServerError(c, "获取账号所属用户失败")
return
}
if ownerUID != userInfo.ID {
response.Forbidden(c, "无权限操作该账号")
return
}
// 删除相关规则
rules, err := getRulesByAccountID(accountID, userInfo.ID)
if err != nil {
+14
View File
@@ -59,3 +59,17 @@ func maskAccounts(accounts []model.Account) []model.Account {
}
return masked
}
// getAccountOwnerUID 获取账号所属用户的 UID
func getAccountOwnerUID(accountID uint) (uint, error) {
gormDB, err := db.GetGormDB()
if err != nil {
return 0, err
}
var account model.Account
if err := gormDB.Select("uid").Where("id = ?", accountID).First(&account).Error; err != nil {
return 0, err
}
return account.UID, nil
}
+168
View File
@@ -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
}
+3
View File
@@ -40,6 +40,9 @@ func NotFound(c *gin.Context, msg string) { Fail(c, http.StatusNotFound, msg) }
// ServerError 服务端错误
func ServerError(c *gin.Context, msg string) { Fail(c, http.StatusInternalServerError, msg) }
// Forbidden 无权限
func Forbidden(c *gin.Context, msg string) { Fail(c, http.StatusForbidden, msg) }
// FromError 按错误类型选择合适的状态码:
// 参数校验错误返回 400,其余返回 500
func FromError(c *gin.Context, err error, fallback string) {
+12
View File
@@ -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)
// 前端提交的敏感字段可能是掩码占位符,用旧配置补回
@@ -162,6 +164,16 @@ func HandleDeleteRule(c *gin.Context) {
return
}
ownerUID, err := getRuleOwnerUID(ruleID)
if err != nil {
response.ServerError(c, "获取规则所属用户失败")
return
}
if ownerUID != userInfo.ID {
response.Forbidden(c, "无权限操作该规则")
return
}
if err := DeleteRuleByID(ruleID, userInfo.ID); err != nil {
response.ServerError(c, "删除规则失败")
return
+25 -2
View File
@@ -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
@@ -103,3 +111,18 @@ func maskRules(rules []model.Rule) []model.Rule {
}
return masked
}
// getRuleOwnerUID 获取规则所属用户的 UID
func getRuleOwnerUID(ruleID uint) (uint, error) {
gormDB, err := db.GetGormDB()
if err != nil {
return 0, err
}
var rule model.Rule
if err := gormDB.Select("uid").Where("id = ?", ruleID).First(&rule).Error; err != nil {
return 0, err
}
return rule.UID, nil
}
+14
View File
@@ -70,3 +70,17 @@ func signTimer(timer *model.Timer) error {
timer.Triggered = false
return nil
}
// getTimerOwnerUID 获取 Timer 的所属用户 ID
func getTimerOwnerUID(timerID uint) (uint, error) {
gormDB, err := db.GetGormDB()
if err != nil {
return 0, err
}
var timer model.Timer
if err := gormDB.Select("uid").Where("id = ?", timerID).First(&timer).Error; err != nil {
return 0, err
}
return timer.UID, nil
}
+38
View File
@@ -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
}
+21
View File
@@ -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 {
+1
View File
@@ -138,5 +138,6 @@ func AutoMigrate(gormDB *gorm.DB) error {
&model.Rule{},
&model.Account{},
&model.ExecutionLog{},
&model.MessageGateway{},
)
}
+10 -1
View File
@@ -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")
}
+13
View File
@@ -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
View File
@@ -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("规则配置不能为空")
+1
View File
@@ -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
View File
@@ -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),
+5
View File
@@ -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
+2
View File
@@ -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 },
]
+1
View File
@@ -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',
+18
View File
@@ -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>
+22 -6
View File
@@ -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
View File
@@ -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>