Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfebcee886 | ||
|
|
9b656cebee | ||
|
|
0d89b7fe8f | ||
|
|
360169ee64 |
@@ -33,3 +33,5 @@ node_modules/
|
||||
# 前端构建产物不入库,仅保留 .gitkeep 以满足 go:embed
|
||||
web/dist/*
|
||||
!web/dist/.gitkeep
|
||||
|
||||
art/
|
||||
@@ -21,6 +21,8 @@
|
||||
* 发送钉钉机器人消息
|
||||
* 公开 GitHub 仓库
|
||||
* **账号 (Account)**:集中管理第三方凭据,支持连通性测试,敏感字段(密码/Cookie/Token)不会回显
|
||||
* **消息网关 (Gateway)**:为外部系统生成 Webhook 地址,投递进来的消息会触发绑定在该网关上的「网关规则」
|
||||
* **网关规则**:与定时器规则分开存储、分开管理,规则里的消息字段(msg / message / body)会被投递内容替换
|
||||
* **执行日志**:每次规则执行与提醒都有记录,规则支持手动测试
|
||||
* **配置测试**:账号可一键测试连通性;规则可手动触发验证
|
||||
|
||||
@@ -101,6 +103,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)。
|
||||
@@ -120,12 +136,25 @@ cd web/frontend && npm run dev
|
||||
* [钉钉机器人](docs/dingtalk-config.md)
|
||||
* [GitHub](docs/github-config.md)
|
||||
* [饭碗警告](docs/fwalert-config.md)
|
||||
* [消息网关](docs/gateway-config.md)
|
||||
|
||||
## 开发:新增一种规则类型
|
||||
|
||||
driver 只负责把动作做出去:规则本体与关联账号由 `handler/runner` 组装成 `model.RuleTask` 后传进来,
|
||||
driver 内部不访问数据库、不写日志,需要账号凭据时从 `task.Account` 里解析。
|
||||
|
||||
1. 在 `drivers/<name>/` 下实现:
|
||||
* `tool.go`:解析规则 / 账号配置的 `ParseXxx` 函数
|
||||
* 规则验证器(实现 `ruleConfigChecker.RuleValidator`,`Meta()` 返回表单元数据)
|
||||
* 执行器(实现 `runner.RuleExecutor`)
|
||||
* 执行器(实现 `runner.RuleExecutor`,方法签名为 `Execute(ctx, *model.RuleTask)`)
|
||||
* 对外动作(底层函数)保持原子:一次调用只做一件事,用 `retry.Do(ctx, ...)` 包裹重试
|
||||
* 如需第三方凭据,再实现账号验证器(`accountConfigChecker.AccountValidator`,可选实现 `AccountTester` 支持连通性测试)
|
||||
2. 在 `internal/ruleConfigChecker/reg.go`、`internal/accountConfigChecker/reg.go`、`handler/runner/interface.go` 中注册
|
||||
2. 在 `internal/ruleConfigChecker/reg.go`、`internal/accountConfigChecker/reg.go`、`handler/runner/reg.go` 中注册
|
||||
3. 前端无需改动 —— WebUI 会根据 `Meta()` 返回的字段描述自动渲染配置表单
|
||||
|
||||
## 开发:新增一种消息网关
|
||||
|
||||
1. 在 `internal/gateway/` 下实现 `Gateway` 接口(`GetType()` / `Meta()` / `Deliver()`),
|
||||
`Deliver` 拿到的 `Task` 里已经包含网关与待触发的规则,实现里不访问数据库
|
||||
2. 在 `internal/gateway/reg.go` 中注册
|
||||
3. 前端无需改动 —— 新建网关时的类型选项与说明来自 `Meta()`
|
||||
|
||||
@@ -181,7 +181,7 @@ func HandleTestAccount(c *gin.Context) {
|
||||
|
||||
// HandleCheckDeleteAccount 检查删除账号请求
|
||||
//
|
||||
// 删除账号会同时删除相关规则,先请求该接口获取受影响的规则
|
||||
// 删除账号会同时删除相关规则(含消息网关规则),先请求该接口获取受影响的规则
|
||||
func HandleCheckDeleteAccount(c *gin.Context) {
|
||||
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||
if err != nil {
|
||||
@@ -201,7 +201,16 @@ func HandleCheckDeleteAccount(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, rules)
|
||||
gatewayRules, err := getGatewayRulesByAccountID(accountID, userInfo.ID)
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取相关网关规则失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, gin.H{
|
||||
"rules": rules,
|
||||
"gateway_rules": gatewayRules,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleDeleteAccount 删除账号及其关联规则
|
||||
@@ -218,6 +227,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 {
|
||||
@@ -231,6 +252,19 @@ func HandleDeleteAccount(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 删除相关的消息网关规则
|
||||
gatewayRules, err := getGatewayRulesByAccountID(accountID, userInfo.ID)
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取相关网关规则失败")
|
||||
return
|
||||
}
|
||||
for _, oneRule := range gatewayRules {
|
||||
if err := rule.DeleteGatewayRuleByID(oneRule.ID, userInfo.ID); err != nil {
|
||||
response.ServerError(c, "删除相关网关规则失败")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 删除账号
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
|
||||
@@ -45,6 +45,20 @@ func getRulesByAccountID(accountID uint, uid uint) ([]*model.Rule, error) {
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// getGatewayRulesByAccountID 获取账号相关的消息网关规则列表
|
||||
func getGatewayRulesByAccountID(accountID uint, uid uint) ([]*model.GatewayRule, error) {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rules := []*model.GatewayRule{}
|
||||
if err := gormDB.Where("account_id = ? AND uid = ?", accountID, uid).Find(&rules).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// maskAccount 掩码账号配置中的敏感字段,避免 Cookie/密码原样返回给前端
|
||||
func maskAccount(account model.Account) model.Account {
|
||||
fields := accountConfigChecker.InitValidatorRegistry().FieldsOf(account.Type)
|
||||
@@ -59,3 +73,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
|
||||
}
|
||||
|
||||
@@ -31,8 +31,10 @@ func HandleGetOverview(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var ruleCount, accountCount int64
|
||||
var ruleCount, gatewayCount, gatewayRuleCount, accountCount int64
|
||||
gormDB.Model(&model.Rule{}).Where("uid = ?", userInfo.ID).Count(&ruleCount)
|
||||
gormDB.Model(&model.MessageGateway{}).Where("uid = ?", userInfo.ID).Count(&gatewayCount)
|
||||
gormDB.Model(&model.GatewayRule{}).Where("uid = ?", userInfo.ID).Count(&gatewayRuleCount)
|
||||
gormDB.Model(&model.Account{}).Where("uid = ?", userInfo.ID).Count(&accountCount)
|
||||
|
||||
recentLogs := []model.ExecutionLog{}
|
||||
@@ -62,13 +64,15 @@ func HandleGetOverview(c *gin.Context) {
|
||||
}
|
||||
|
||||
overview := gin.H{
|
||||
"timer_count": len(timers),
|
||||
"enabled_timers": enabledCount,
|
||||
"triggered_count": triggeredCount,
|
||||
"rule_count": ruleCount,
|
||||
"account_count": accountCount,
|
||||
"server_time": now,
|
||||
"recent_logs": recentLogs,
|
||||
"timer_count": len(timers),
|
||||
"enabled_timers": enabledCount,
|
||||
"triggered_count": triggeredCount,
|
||||
"rule_count": ruleCount,
|
||||
"gateway_count": gatewayCount,
|
||||
"gateway_rule_count": gatewayRuleCount,
|
||||
"account_count": accountCount,
|
||||
"server_time": now,
|
||||
"recent_logs": recentLogs,
|
||||
}
|
||||
|
||||
if urgent != nil {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
// Package gateway 提供消息网关的 HTTP 接口。
|
||||
//
|
||||
// 数据库读写都发生在这一层,真正的投递逻辑在 internal/gateway 里,
|
||||
// 这里只负责鉴权、取数、组装投递任务。
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
gatewaycore "github.com/ssdomei232/goodBaby/internal/gateway"
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// webhookRequest 外部系统投递消息的请求体
|
||||
type webhookRequest struct {
|
||||
Message string `json:"message"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// HandleList 获取当前用户的所有消息网关
|
||||
func HandleList(c *gin.Context) {
|
||||
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "未登录")
|
||||
return
|
||||
}
|
||||
|
||||
dbConn, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取网关失败")
|
||||
return
|
||||
}
|
||||
|
||||
items := []model.MessageGateway{}
|
||||
if err := dbConn.Where("uid = ?", userInfo.ID).Order("id DESC").Find(&items).Error; err != nil {
|
||||
response.ServerError(c, "获取网关失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, items)
|
||||
}
|
||||
|
||||
// HandleCreate 创建消息网关
|
||||
func HandleCreate(c *gin.Context) {
|
||||
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "未登录")
|
||||
return
|
||||
}
|
||||
|
||||
var req model.MessageGatewayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "输入参数错误")
|
||||
return
|
||||
}
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if err := req.Validate(); err != nil {
|
||||
response.FromError(c, err, "创建网关失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 不填类型时用默认网关,填了就必须是已注册的类型
|
||||
if req.Type == "" {
|
||||
req.Type = model.GatewayTypeWebhook
|
||||
}
|
||||
if _, ok := gatewaycore.InitGatewayRegistry().Resolve(req.Type); !ok {
|
||||
response.BadRequest(c, "不支持的消息网关类型: "+req.Type)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := gatewaycore.NewToken()
|
||||
if err != nil {
|
||||
response.ServerError(c, "生成网关 Token 失败")
|
||||
return
|
||||
}
|
||||
|
||||
dbConn, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
response.ServerError(c, "创建网关失败")
|
||||
return
|
||||
}
|
||||
|
||||
item := model.MessageGateway{
|
||||
UID: userInfo.ID,
|
||||
Name: req.Name,
|
||||
Type: req.Type,
|
||||
Token: token,
|
||||
CreateAt: time.Now().Unix(),
|
||||
}
|
||||
if err := dbConn.Create(&item).Error; err != nil {
|
||||
response.ServerError(c, "创建网关失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
// HandleDelete 删除消息网关,绑定在它下面的规则一并删除
|
||||
func HandleDelete(c *gin.Context) {
|
||||
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "未登录")
|
||||
return
|
||||
}
|
||||
|
||||
gatewayID, err := parseID(c.Param("gatewayID"))
|
||||
if err != nil {
|
||||
response.BadRequest(c, "网关 ID 格式错误")
|
||||
return
|
||||
}
|
||||
|
||||
dbConn, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
response.ServerError(c, "删除网关失败")
|
||||
return
|
||||
}
|
||||
|
||||
err = dbConn.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("gateway_id = ? AND uid = ?", gatewayID, userInfo.ID).
|
||||
Delete(&model.GatewayRule{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("id = ? AND uid = ?", gatewayID, userInfo.ID).
|
||||
Delete(&model.MessageGateway{}).Error
|
||||
})
|
||||
if err != nil {
|
||||
response.ServerError(c, "删除网关失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, "网关已删除")
|
||||
}
|
||||
|
||||
// HandleWebhook 接收外部系统投递的消息,触发绑定在该网关上的规则
|
||||
func HandleWebhook(c *gin.Context) {
|
||||
var req webhookRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Message) == "" {
|
||||
response.BadRequest(c, "message 不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
dbConn, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
response.ServerError(c, "网关不可用")
|
||||
return
|
||||
}
|
||||
|
||||
var target model.MessageGateway
|
||||
if err := dbConn.Where("token = ?", c.Param("token")).First(&target).Error; err != nil {
|
||||
response.Fail(c, http.StatusNotFound, "网关不存在")
|
||||
return
|
||||
}
|
||||
|
||||
rules := []model.GatewayRule{}
|
||||
if err := dbConn.Where("uid = ? AND gateway_id = ? AND enabled = ?", target.UID, target.ID, true).
|
||||
Find(&rules).Error; err != nil {
|
||||
response.ServerError(c, "读取网关规则失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 投递需要在 HTTP 请求内返回结果,因此用较短的超时
|
||||
ctx, cancel := context.WithTimeout(context.Background(), retry.TestTimeout)
|
||||
defer cancel()
|
||||
|
||||
result, err := gatewaycore.InitGatewayRegistry().Deliver(ctx, target.Type, &gatewaycore.Task{
|
||||
Gateway: &target,
|
||||
Rules: rules,
|
||||
Message: gatewaycore.Message{
|
||||
Title: strings.TrimSpace(req.Title),
|
||||
Content: strings.TrimSpace(req.Message),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
response.ServerError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, result)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package gateway
|
||||
|
||||
import "strconv"
|
||||
|
||||
// parseID 解析 URL 里的数字 ID
|
||||
func parseID(raw string) (uint, error) {
|
||||
id, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint(id), nil
|
||||
}
|
||||
@@ -37,6 +37,9 @@ func HandleGetLogs(c *gin.Context) {
|
||||
if ruleID := c.Query("rule_id"); ruleID != "" {
|
||||
query = query.Where("rule_id = ?", ruleID)
|
||||
}
|
||||
if gatewayRuleID := c.Query("gateway_rule_id"); gatewayRuleID != "" {
|
||||
query = query.Where("gateway_rule_id = ?", gatewayRuleID)
|
||||
}
|
||||
if success := c.Query("success"); success == "true" || success == "false" {
|
||||
query = query.Where("success = ?", success == "true")
|
||||
}
|
||||
|
||||
@@ -7,16 +7,18 @@ import (
|
||||
"github.com/ssdomei232/goodBaby/api/user"
|
||||
"github.com/ssdomei232/goodBaby/configs"
|
||||
"github.com/ssdomei232/goodBaby/internal/accountConfigChecker"
|
||||
"github.com/ssdomei232/goodBaby/internal/gateway"
|
||||
"github.com/ssdomei232/goodBaby/internal/ruleConfigChecker"
|
||||
)
|
||||
|
||||
// HandleGetProviders 返回所有账号类型与规则类型的元数据
|
||||
// HandleGetProviders 返回所有账号类型、规则类型与消息网关类型的元数据
|
||||
//
|
||||
// 前端据此动态渲染配置表单,新增驱动时前端无需改动。
|
||||
func HandleGetProviders(c *gin.Context) {
|
||||
response.OK(c, gin.H{
|
||||
"accounts": accountConfigChecker.InitValidatorRegistry().Metas(),
|
||||
"rules": ruleConfigChecker.InitValidatorRegistry().Metas(),
|
||||
"gateways": gateway.InitGatewayRegistry().Metas(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"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/gateway"
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// HandleGetAllGatewayRules 获取用户的消息网关规则,支持按 gateway_id 过滤
|
||||
//
|
||||
// 消息网关规则和定时器规则是两张表,互不影响,因此这里单独一组接口。
|
||||
func HandleGetAllGatewayRules(c *gin.Context) {
|
||||
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "获取用户信息失败")
|
||||
return
|
||||
}
|
||||
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
response.ServerError(c, "服务器内部错误")
|
||||
return
|
||||
}
|
||||
|
||||
query := gormDB.Where("uid = ?", userInfo.ID)
|
||||
if raw := c.Query("gateway_id"); raw != "" {
|
||||
gatewayID, err := parseID(raw)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "gateway_id 格式错误")
|
||||
return
|
||||
}
|
||||
query = query.Where("gateway_id = ?", gatewayID)
|
||||
}
|
||||
|
||||
rules := []model.GatewayRule{}
|
||||
if err := query.Order("id DESC").Find(&rules).Error; err != nil {
|
||||
response.ServerError(c, "获取网关规则失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, maskGatewayRules(rules))
|
||||
}
|
||||
|
||||
// HandleCreateGatewayRule 创建消息网关规则
|
||||
func HandleCreateGatewayRule(c *gin.Context) {
|
||||
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "获取用户信息失败")
|
||||
return
|
||||
}
|
||||
|
||||
var req model.GatewayRuleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "输入参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
newRule := model.GatewayRule{
|
||||
UID: userInfo.ID,
|
||||
Name: req.Name,
|
||||
GatewayID: req.GatewayID,
|
||||
AccountID: req.AccountID,
|
||||
Type: req.Type,
|
||||
ConfigJson: req.ConfigJson,
|
||||
Enabled: boolOr(req.Enabled, true),
|
||||
CreateAt: time.Now().Unix(),
|
||||
}
|
||||
|
||||
if err := validateGatewayRule(&req, &newRule); err != nil {
|
||||
response.FromError(c, err, "创建网关规则失败")
|
||||
return
|
||||
}
|
||||
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
response.ServerError(c, "服务器内部错误")
|
||||
return
|
||||
}
|
||||
|
||||
if err := gormDB.Create(&newRule).Error; err != nil {
|
||||
response.ServerError(c, "创建网关规则失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, maskGatewayRule(newRule))
|
||||
}
|
||||
|
||||
// HandleEditGatewayRule 编辑消息网关规则
|
||||
func HandleEditGatewayRule(c *gin.Context) {
|
||||
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "获取用户信息失败")
|
||||
return
|
||||
}
|
||||
|
||||
ruleID, err := parseID(c.Param("ruleID"))
|
||||
if err != nil {
|
||||
response.BadRequest(c, "规则 ID 格式错误")
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := findGatewayRule(ruleID, userInfo.ID)
|
||||
if err != nil {
|
||||
response.NotFound(c, "网关规则不存在")
|
||||
return
|
||||
}
|
||||
|
||||
var req model.GatewayRuleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "输入参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
updated := *existing
|
||||
updated.Name = req.Name
|
||||
updated.Type = req.Type
|
||||
updated.GatewayID = req.GatewayID
|
||||
updated.AccountID = req.AccountID
|
||||
updated.Enabled = boolOr(req.Enabled, existing.Enabled)
|
||||
// 前端提交的敏感字段可能是掩码占位符,用旧配置补回
|
||||
updated.ConfigJson = unmaskRuleConfig(req.Type, req.ConfigJson, existing.ConfigJson)
|
||||
|
||||
req.ConfigJson = updated.ConfigJson
|
||||
if err := validateGatewayRule(&req, &updated); err != nil {
|
||||
response.FromError(c, err, "更新网关规则失败")
|
||||
return
|
||||
}
|
||||
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
response.ServerError(c, "服务器内部错误")
|
||||
return
|
||||
}
|
||||
|
||||
if err := gormDB.Save(&updated).Error; err != nil {
|
||||
response.ServerError(c, "更新网关规则失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, maskGatewayRule(updated))
|
||||
}
|
||||
|
||||
// HandleDeleteGatewayRule 删除消息网关规则
|
||||
func HandleDeleteGatewayRule(c *gin.Context) {
|
||||
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "获取用户信息失败")
|
||||
return
|
||||
}
|
||||
|
||||
ruleID, err := parseID(c.Param("ruleID"))
|
||||
if err != nil {
|
||||
response.BadRequest(c, "规则 ID 格式错误")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := findGatewayRule(ruleID, userInfo.ID); err != nil {
|
||||
response.NotFound(c, "网关规则不存在")
|
||||
return
|
||||
}
|
||||
|
||||
if err := DeleteGatewayRuleByID(ruleID, userInfo.ID); err != nil {
|
||||
response.ServerError(c, "删除网关规则失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, "网关规则删除成功")
|
||||
}
|
||||
|
||||
// HandleTestGatewayRule 立即执行一次网关规则,用于验证配置
|
||||
//
|
||||
// 与投递不同:这里用规则里保存的配置,不覆盖外部消息。
|
||||
func HandleTestGatewayRule(c *gin.Context) {
|
||||
userInfo, err := user.GetUserInfoByGinCtx(c)
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "获取用户信息失败")
|
||||
return
|
||||
}
|
||||
|
||||
ruleID, err := parseID(c.Param("ruleID"))
|
||||
if err != nil {
|
||||
response.BadRequest(c, "规则 ID 格式错误")
|
||||
return
|
||||
}
|
||||
|
||||
target, err := findGatewayRule(ruleID, userInfo.ID)
|
||||
if err != nil {
|
||||
response.NotFound(c, "网关规则不存在")
|
||||
return
|
||||
}
|
||||
|
||||
// 用一条示例消息走一次真实的投递覆盖,让用户看到规则最终会发出什么内容
|
||||
if configJSON, applied, err := gateway.ApplyMessage(target.ConfigJson, gateway.TestMessage); err == nil && applied {
|
||||
target.ConfigJson = configJSON
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), retry.TestTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := runner.ExecuteGatewayRuleWithContext(ctx, target, model.TriggerManual); err != nil {
|
||||
response.BadRequest(c, fmt.Sprintf("规则执行失败: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, "规则执行成功")
|
||||
}
|
||||
|
||||
// validateGatewayRule 校验网关规则的通用字段、关联对象与类型专属配置
|
||||
func validateGatewayRule(req *model.GatewayRuleRequest, rule *model.GatewayRule) error {
|
||||
if err := req.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := checkGatewayExists(rule.GatewayID, rule.UID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ruleMeta, err := ruleMetaOf(req.Type)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 标题/内容由投递请求提供,页面不填,这里补占位值后再校验
|
||||
rule.ConfigJson = meta.FillGatewayMessages(rule.ConfigJson, ruleMeta.Fields)
|
||||
if err := validateRuleConfig(req.Type, rule.ConfigJson); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
accountID, err := checkAccount(rule.AccountID, rule.UID, ruleMeta.AccountType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rule.AccountID = accountID
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -162,6 +162,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
|
||||
@@ -209,19 +219,24 @@ func validateRule(req *model.RuleRequest, rule *model.Rule) error {
|
||||
return err
|
||||
}
|
||||
|
||||
ruleMeta, ok := ruleConfigChecker.InitValidatorRegistry().MetaOf(req.Type)
|
||||
if !ok {
|
||||
return model.ErrValidation(fmt.Sprintf("不支持的规则类型: %s", req.Type))
|
||||
}
|
||||
|
||||
// 检查关联的 Timer 与账号是否存在且属于当前用户
|
||||
if err := checkRuleConfigAccountAndTimerExist(rule, ruleMeta.AccountType); err != nil {
|
||||
// 检查关联的 Timer 是否存在、账号是否可用
|
||||
if err := checkTimerExists(rule.TimerID, rule.UID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ruleConfigChecker.InitValidatorRegistry().Validate(req.Type, rule.ConfigJson); err != nil {
|
||||
return model.ErrValidation(fmt.Sprintf("规则配置验证失败: %s", err.Error()))
|
||||
ruleMeta, err := ruleMetaOf(req.Type)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRuleConfig(req.Type, rule.ConfigJson); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
accountID, err := checkAccount(rule.AccountID, rule.UID, ruleMeta.AccountType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rule.AccountID = accountID
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func boolOr(v *bool, fallback bool) bool {
|
||||
return *v
|
||||
}
|
||||
|
||||
// findRule 查找属于该用户的规则
|
||||
// findRule 查找属于该用户的定时器规则
|
||||
func findRule(ruleID, uid uint) (*model.Rule, error) {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
@@ -39,7 +39,21 @@ func findRule(ruleID, uid uint) (*model.Rule, error) {
|
||||
return &target, nil
|
||||
}
|
||||
|
||||
// DeleteRuleByID 根据 ID 删除规则
|
||||
// findGatewayRule 查找属于该用户的消息网关规则
|
||||
func findGatewayRule(ruleID, uid uint) (*model.GatewayRule, error) {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var target model.GatewayRule
|
||||
if err := gormDB.Where("id = ? AND uid = ?", ruleID, uid).First(&target).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &target, nil
|
||||
}
|
||||
|
||||
// DeleteRuleByID 根据 ID 删除定时器规则
|
||||
func DeleteRuleByID(id uint, uid uint) error {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
@@ -49,50 +63,99 @@ func DeleteRuleByID(id uint, uid uint) error {
|
||||
return gormDB.Where("id = ? AND uid = ?", id, uid).Delete(&model.Rule{}).Error
|
||||
}
|
||||
|
||||
// checkRuleConfigAccountAndTimerExist 检查关联的 Timer 与账号是否存在且归属当前用户
|
||||
//
|
||||
// requiredAccountType 为空表示该规则类型不需要账号。
|
||||
func checkRuleConfigAccountAndTimerExist(rule *model.Rule, requiredAccountType string) error {
|
||||
// DeleteGatewayRuleByID 根据 ID 删除消息网关规则
|
||||
func DeleteGatewayRuleByID(id uint, uid uint) error {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return gormDB.Where("id = ? AND uid = ?", id, uid).Delete(&model.GatewayRule{}).Error
|
||||
}
|
||||
|
||||
// checkTimerExists 检查关联的 Timer 是否存在且归属当前用户
|
||||
func checkTimerExists(timerID, uid uint) error {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := gormDB.Model(&model.Timer{}).
|
||||
Where("id = ? AND uid = ?", rule.TimerID, rule.UID).Count(&count).Error; err != nil {
|
||||
if err := gormDB.Model(&model.Timer{}).Where("id = ? AND uid = ?", timerID, uid).
|
||||
Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return model.ErrValidation("关联的 Timer 不存在")
|
||||
}
|
||||
|
||||
if requiredAccountType == "" {
|
||||
// 该类型不需要账号,忽略前端可能传来的 account_id
|
||||
rule.AccountID = 0
|
||||
return nil
|
||||
}
|
||||
|
||||
if rule.AccountID == 0 {
|
||||
return model.ErrValidation("该规则类型必须关联一个账号")
|
||||
}
|
||||
|
||||
var account model.Account
|
||||
if err := gormDB.Where("id = ? AND uid = ?", rule.AccountID, rule.UID).First(&account).Error; err != nil {
|
||||
return model.ErrValidation("关联的账号不存在")
|
||||
}
|
||||
if account.Type != requiredAccountType {
|
||||
return model.ErrValidation(fmt.Sprintf("规则需要 %s 类型的账号,但关联的是 %s", requiredAccountType, account.Type))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// maskRule 掩码规则配置中的敏感字段
|
||||
func maskRule(target model.Rule) model.Rule {
|
||||
if ruleMeta, ok := ruleConfigChecker.InitValidatorRegistry().MetaOf(target.Type); ok {
|
||||
target.ConfigJson = meta.Mask(target.ConfigJson, ruleMeta.Fields)
|
||||
// checkGatewayExists 检查关联的消息网关是否存在且归属当前用户
|
||||
func checkGatewayExists(gatewayID, uid uint) error {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := gormDB.Model(&model.MessageGateway{}).Where("id = ? AND uid = ?", gatewayID, uid).
|
||||
Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return model.ErrValidation("关联的消息网关不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkAccount 校验规则关联的账号存在、归属当前用户且类型匹配。
|
||||
//
|
||||
// requiredType 为空表示该规则类型不需要账号,此时返回 0,调用方据此清掉 account_id。
|
||||
func checkAccount(accountID, uid uint, requiredType string) (uint, error) {
|
||||
if requiredType == "" {
|
||||
return 0, nil
|
||||
}
|
||||
if accountID == 0 {
|
||||
return 0, model.ErrValidation("该规则类型必须关联一个账号")
|
||||
}
|
||||
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var account model.Account
|
||||
if err := gormDB.Where("id = ? AND uid = ?", accountID, uid).First(&account).Error; err != nil {
|
||||
return 0, model.ErrValidation("关联的账号不存在")
|
||||
}
|
||||
if account.Type != requiredType {
|
||||
return 0, model.ErrValidation(
|
||||
fmt.Sprintf("规则需要 %s 类型的账号,但关联的是 %s", requiredType, account.Type))
|
||||
}
|
||||
return accountID, nil
|
||||
}
|
||||
|
||||
// ruleMetaOf 取规则类型的元数据,不支持的类型返回校验错误
|
||||
func ruleMetaOf(ruleType string) (meta.RuleMeta, error) {
|
||||
ruleMeta, ok := ruleConfigChecker.InitValidatorRegistry().MetaOf(ruleType)
|
||||
if !ok {
|
||||
return meta.RuleMeta{}, model.ErrValidation(fmt.Sprintf("不支持的规则类型: %s", ruleType))
|
||||
}
|
||||
return ruleMeta, nil
|
||||
}
|
||||
|
||||
// validateRuleConfig 校验类型专属的规则配置
|
||||
func validateRuleConfig(ruleType, configJSON string) error {
|
||||
if err := ruleConfigChecker.InitValidatorRegistry().Validate(ruleType, configJSON); err != nil {
|
||||
return model.ErrValidation(fmt.Sprintf("规则配置验证失败: %s", err.Error()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// maskRule 掩码定时器规则配置中的敏感字段
|
||||
func maskRule(target model.Rule) model.Rule {
|
||||
target.ConfigJson = maskConfig(target.Type, target.ConfigJson)
|
||||
return target
|
||||
}
|
||||
|
||||
@@ -103,3 +166,53 @@ func maskRules(rules []model.Rule) []model.Rule {
|
||||
}
|
||||
return masked
|
||||
}
|
||||
|
||||
// maskGatewayRule 掩码消息网关规则配置中的敏感字段
|
||||
func maskGatewayRule(target model.GatewayRule) model.GatewayRule {
|
||||
target.ConfigJson = maskConfig(target.Type, target.ConfigJson)
|
||||
return target
|
||||
}
|
||||
|
||||
func maskGatewayRules(rules []model.GatewayRule) []model.GatewayRule {
|
||||
masked := make([]model.GatewayRule, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
masked = append(masked, maskGatewayRule(r))
|
||||
}
|
||||
return masked
|
||||
}
|
||||
|
||||
// maskConfig 掩码规则配置中的敏感字段
|
||||
func maskConfig(ruleType, configJSON string) string {
|
||||
if ruleMeta, ok := ruleConfigChecker.InitValidatorRegistry().MetaOf(ruleType); ok {
|
||||
return meta.Mask(configJSON, ruleMeta.Fields)
|
||||
}
|
||||
return configJSON
|
||||
}
|
||||
|
||||
// getRuleOwnerUID 获取定时器规则所属用户的 UID
|
||||
func getRuleOwnerUID(ruleID uint) (uint, error) {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var target model.Rule
|
||||
if err := gormDB.Select("uid").Where("id = ?", ruleID).First(&target).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return target.UID, nil
|
||||
}
|
||||
|
||||
// getGatewayRuleOwnerUID 获取消息网关规则所属用户的 UID
|
||||
func getGatewayRuleOwnerUID(ruleID uint) (uint, error) {
|
||||
gormDB, err := db.GetGormDB()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var target model.GatewayRule
|
||||
if err := gormDB.Select("uid").Where("id = ?", ruleID).First(&target).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return target.UID, nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/ssdomei232/goodBaby/drivers/dingtalk"
|
||||
"github.com/ssdomei232/goodBaby/handler/db"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -176,11 +214,10 @@ func validateNotifyConfig(raw *string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var config struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(*raw), &config); err != nil {
|
||||
return fmt.Errorf("钉钉配置 JSON 格式错误: %v", err)
|
||||
// 复用钉钉驱动里的解析逻辑,避免两边对配置格式的理解不一致
|
||||
config, err := dingtalk.ParseAccountConfig(*raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.AccessToken == "" {
|
||||
return fmt.Errorf("钉钉配置中 access_token 不能为空")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -82,8 +82,11 @@ volumes:
|
||||
|
||||
两种数据库之间**没有**自动数据迁移。切换驱动后是一个全新的空库,需要重新注册账号并配置规则。
|
||||
|
||||
如果要搬运已有数据,得自己从 SQLite 导出再导入 PostgreSQL(表结构一致:`users` / `timers` / `rules` / `accounts` / `execution_logs`)。
|
||||
如果要搬运已有数据,得自己从 SQLite 导出再导入 PostgreSQL(表结构一致:`users` / `timers` / `rules` / `gateway_rules` / `message_gateways` / `accounts` / `execution_logs`)。
|
||||
|
||||
## 建表
|
||||
|
||||
首次启动会自动执行 `AutoMigrate` 建表并补齐新增字段,不需要手动执行 SQL。
|
||||
|
||||
升级到 v2.2.0 时,如果 `rules` 表里还有靠 `gateway_id` 标识的历史消息网关规则,
|
||||
启动阶段会自动把它们搬到 `gateway_rules` 表,不需要手工处理。
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# 消息网关配置
|
||||
|
||||
消息网关让外部系统来触发 goodBaby 的动作:为网关生成一个 Webhook 地址,
|
||||
外部系统往这个地址 POST 一条消息,绑定在该网关上的「网关规则」就会被执行。
|
||||
|
||||
## 创建网关
|
||||
|
||||
在 WebUI 的「消息网关」页面点击「新建网关」:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| 网关类型 | 目前只有 `webhook`,新类型由 `internal/gateway` 注册后自动出现在这里 |
|
||||
| 网关名称 | 便于识别的名字,例如「生产环境告警」 |
|
||||
|
||||
创建后会得到一个地址:
|
||||
|
||||
```text
|
||||
https://<你的域名>/api/v1/gateways/gw_xxxxxxxx/webhook
|
||||
```
|
||||
|
||||
Token 由服务端随机生成,无法在页面上修改;删除网关会连同它的网关规则一起删除。
|
||||
|
||||
## 投递消息
|
||||
|
||||
```bash
|
||||
curl -X POST https://<你的域名>/api/v1/gateways/gw_xxxxxxxx/webhook \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"title": "数据库告警", "message": "主库连接数超过 90%"}'
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `message` | 是 | 消息正文,会覆盖网关规则配置里的消息字段 |
|
||||
| `title` | 否 | 消息标题,规则配置里存在 `title` 字段时会被替换 |
|
||||
|
||||
响应里会给出本次触发的规则总数与失败明细:
|
||||
|
||||
```json
|
||||
{ "code": 200, "data": { "total": 2, "failed": ["发送邮件: 连接 SMTP 服务器失败"] } }
|
||||
```
|
||||
|
||||
## 网关规则
|
||||
|
||||
网关规则在「网关规则」页面维护,和定时器规则是两张表、两个页面,互不影响。
|
||||
只需配置「发给谁」这类固定信息:名称 / 类型 / 关联账号 / 类型专属配置 / 启用。
|
||||
|
||||
**消息标题与内容不用在规则里预先写**:邮件标题、邮件正文、QQ 消息内容、钉钉标题与内容、
|
||||
B 站动态与私信内容、饭碗警告内容这些字段在「网关规则」页面上不会让用户填写
|
||||
(页面上只显示一行提示),投递时统一由请求里的 `title` / `message` 填充。
|
||||
|
||||
没有消息字段的规则(例如「公开 GitHub 仓库」「删除阿里云 DNS 记录」)也能挂到网关上,
|
||||
这类规则固定按自己的配置执行,投递的消息只起到触发器的作用。
|
||||
|
||||
规则编辑页的「测试」按钮会用一条示例消息走一次真实的投递覆盖,
|
||||
方便你在执行日志里确认最终发出的内容。
|
||||
@@ -1,7 +1,6 @@
|
||||
package alidns
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/alibabacloud-go/alidns-20150109/v5/client"
|
||||
@@ -13,7 +12,7 @@ type AliDNSAccountConfigValidator struct{}
|
||||
func (v *AliDNSAccountConfigValidator) GetType() string { return AccountType }
|
||||
|
||||
func (v *AliDNSAccountConfigValidator) Validate(config string) error {
|
||||
cfg, err := parseAccount(config)
|
||||
cfg, err := ParseAccountConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -23,21 +22,13 @@ func (v *AliDNSAccountConfigValidator) Validate(config string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseAccount(config string) (*AliDNSAccount, error) {
|
||||
var cfg AliDNSAccount
|
||||
if err := json.Unmarshal([]byte(config), &cfg); err != nil {
|
||||
return nil, fmt.Errorf("解析阿里云账号配置失败: %v", err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (v *AliDNSAccountConfigValidator) Test(config string) error {
|
||||
cfg, err := parseAccount(config)
|
||||
cfg, err := ParseAccountConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aliDNSClient, err := getAliDNSClient(cfg.AK, cfg.SK)
|
||||
aliDNSClient, err := newClient(cfg.AK, cfg.SK)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,25 +4,20 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/alibabacloud-go/alidns-20150109/v5/client"
|
||||
alidns "github.com/alibabacloud-go/alidns-20150109/v5/client"
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// deleteAliDNSRecord deletes a DNS record from Alibaba Cloud DNS based on the provided rule.
|
||||
func deleteAliDNSRecord(ctx context.Context, rule *model.Rule) error {
|
||||
aliDNSClient, err := getAliDNSClientFromRule(rule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deleteRecordConfig, err := getDeleteRecordConfig(rule)
|
||||
// DeleteRecord 用给定账号删除一条阿里云 DNS 解析记录
|
||||
func DeleteRecord(ctx context.Context, account *AliDNSAccount, config *DeleteRecordConfig) error {
|
||||
client, err := newClient(account.AK, account.SK)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := retry.Do(ctx, func() error {
|
||||
_, err := aliDNSClient.DeleteDomainRecord(&client.DeleteDomainRecordRequest{
|
||||
RecordId: &deleteRecordConfig.RecordID,
|
||||
_, err := client.DeleteDomainRecord(&alidns.DeleteDomainRecordRequest{
|
||||
RecordId: &config.RecordID,
|
||||
})
|
||||
return err
|
||||
}); err != nil {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package alidns
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
@@ -15,9 +14,9 @@ func (v *AliDNSDeleteRecordRuleValidator) GetType() string {
|
||||
}
|
||||
|
||||
func (v *AliDNSDeleteRecordRuleValidator) Validate(configJSON string) error {
|
||||
var config DeleteRecordConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return fmt.Errorf("解析阿里云删除记录规则配置失败: %v", err)
|
||||
config, err := ParseDeleteRecordConfig(configJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if config.RecordID == "" {
|
||||
|
||||
@@ -2,7 +2,6 @@ package alidns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
@@ -14,8 +13,16 @@ func (e *DeleteAliDNSRecordExecutor) GetType() string {
|
||||
return RuleTypeDeleteRecord
|
||||
}
|
||||
|
||||
func (e *DeleteAliDNSRecordExecutor) Execute(ctx context.Context, rule *model.Rule) error {
|
||||
log.Printf("执行删除阿里云DNS记录规则: %s (ID: %d)", rule.Name, rule.ID)
|
||||
func (e *DeleteAliDNSRecordExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
account, err := ParseAccountConfig(task.AccountConfig())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return deleteAliDNSRecord(ctx, rule)
|
||||
config, err := ParseDeleteRecordConfig(task.Rule.ConfigJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return DeleteRecord(ctx, account, config)
|
||||
}
|
||||
|
||||
@@ -2,54 +2,50 @@ package alidns
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
alidns "github.com/alibabacloud-go/alidns-20150109/v5/client"
|
||||
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
|
||||
"github.com/alibabacloud-go/tea/tea"
|
||||
"github.com/aliyun/credentials-go/credentials"
|
||||
"github.com/ssdomei232/goodBaby/handler/db"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// getAliDNSClient initializes and returns an Alibaba Cloud DNS client based on the provided rule's account configuration. It retrieves the account configuration from the database, sets up the necessary credentials, and creates a new client instance for interacting with Alibaba Cloud DNS services.
|
||||
func getAliDNSClientFromRule(rule *model.Rule) (client *alidns.Client, err error) {
|
||||
var accountConfig AliDNSAccount
|
||||
|
||||
// get config
|
||||
if err := db.LoadAccountConfig(rule.AccountID, &accountConfig); err != nil {
|
||||
return nil, err
|
||||
// ParseAccountConfig 解析阿里云账号配置
|
||||
func ParseAccountConfig(config string) (*AliDNSAccount, error) {
|
||||
if config == "" {
|
||||
return nil, fmt.Errorf("解析阿里云账号配置失败: 规则没有关联账号")
|
||||
}
|
||||
|
||||
return getAliDNSClient(accountConfig.AK, accountConfig.SK)
|
||||
var account AliDNSAccount
|
||||
if err := json.Unmarshal([]byte(config), &account); err != nil {
|
||||
return nil, fmt.Errorf("解析阿里云账号配置失败: %v", err)
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
func getAliDNSClient(ak string, sk string) (client *alidns.Client, err error) {
|
||||
// init aliyun account config
|
||||
// ParseDeleteRecordConfig 解析删除解析记录规则配置
|
||||
func ParseDeleteRecordConfig(configJSON string) (*DeleteRecordConfig, error) {
|
||||
var config DeleteRecordConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析阿里云删除记录规则配置失败: %v", err)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// newClient 用 AccessKey 创建阿里云 DNS 客户端
|
||||
func newClient(ak string, sk string) (*alidns.Client, error) {
|
||||
credentialsConfig := new(credentials.Config).
|
||||
SetType("access_key").
|
||||
SetAccessKeyId(ak).
|
||||
SetAccessKeySecret(sk)
|
||||
|
||||
akCredential, err := credentials.NewCredential(credentialsConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config := &openapi.Config{}
|
||||
config.Credential = akCredential
|
||||
config.Endpoint = tea.String("alidns.aliyuncs.com")
|
||||
|
||||
// create aliyun account client
|
||||
client, _ = alidns.NewClient(config)
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// getDeleteRecordConfig retrieves the configuration for deleting DNS records from the provided rule. It unmarshals the rule's configuration JSON into a DeleteRecordConfig structure and returns it.
|
||||
func getDeleteRecordConfig(rule *model.Rule) (*DeleteRecordConfig, error) {
|
||||
var deleteRecordConfig DeleteRecordConfig
|
||||
|
||||
if err := json.Unmarshal([]byte(rule.ConfigJson), &deleteRecordConfig); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("创建阿里云凭据失败: %w", err)
|
||||
}
|
||||
|
||||
return &deleteRecordConfig, nil
|
||||
return alidns.NewClient(&openapi.Config{
|
||||
Credential: akCredential,
|
||||
Endpoint: tea.String("alidns.aliyuncs.com"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package bilibili
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
@@ -15,7 +14,7 @@ func (v *BilibiliAccountConfigValidator) GetType() string {
|
||||
}
|
||||
|
||||
func (v *BilibiliAccountConfigValidator) Validate(config string) error {
|
||||
cfg, err := parseAccount(config)
|
||||
cfg, err := ParseAccountConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -29,7 +28,7 @@ func (v *BilibiliAccountConfigValidator) Validate(config string) error {
|
||||
|
||||
// Test 用 cookie 拉一次账号信息,验证 cookie 是否还有效
|
||||
func (v *BilibiliAccountConfigValidator) Test(config string) error {
|
||||
cfg, err := parseAccount(config)
|
||||
cfg, err := ParseAccountConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -64,11 +63,3 @@ func (v *BilibiliAccountConfigValidator) Meta() meta.AccountMeta {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func parseAccount(config string) (*BiliAccount, error) {
|
||||
var cfg BiliAccount
|
||||
if err := json.Unmarshal([]byte(config), &cfg); err != nil {
|
||||
return nil, fmt.Errorf("解析B站账号配置失败: %v", err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
@@ -6,32 +6,22 @@ import (
|
||||
|
||||
"github.com/CuteReimu/bilibili/v2"
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// SendBiliDynamicMsg 在 Bilibili 动态发送消息
|
||||
// SendDynamic 用给定账号发送一条 B 站动态
|
||||
//
|
||||
// 暂时没有处理 429 和 403 的区别对待
|
||||
func SendBiliDynamicMsg(ctx context.Context, rule *model.Rule) error {
|
||||
biliClient, err := getBiliClient(rule)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取B站客户端失败: %w", err)
|
||||
}
|
||||
|
||||
biliDynamicConfig, err := getBiliDynamicConfig(rule)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取B站动态配置失败: %w", err)
|
||||
}
|
||||
|
||||
dynamicParams := bilibili.CreateDynamicParam{
|
||||
func SendDynamic(ctx context.Context, account *BiliAccount, config *BiliDynamicConfig) error {
|
||||
client := newClient(account.RawCookies)
|
||||
params := bilibili.CreateDynamicParam{
|
||||
DynamicId: 0,
|
||||
Type: 4,
|
||||
Rid: 0,
|
||||
Content: biliDynamicConfig.Msg,
|
||||
Content: config.Msg,
|
||||
}
|
||||
|
||||
if err := retry.Do(ctx, func() error {
|
||||
_, err := biliClient.CreateDynamic(dynamicParams)
|
||||
_, err := client.CreateDynamic(params)
|
||||
return err
|
||||
}); err != nil {
|
||||
return fmt.Errorf("发送B站动态失败: %w", err)
|
||||
|
||||
@@ -2,54 +2,34 @@ package bilibili
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/CuteReimu/bilibili/v2"
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// 私信消息类型:1 为文字
|
||||
// privateMsgTypeText 私信消息类型:1 为文字
|
||||
const privateMsgTypeText = 1
|
||||
|
||||
// SendBiliPrivateMessage 向配置中的每个 UID 发送一条 B 站私信
|
||||
func SendBiliPrivateMessage(ctx context.Context, rule *model.Rule) error {
|
||||
client, err := getBiliClient(rule)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取B站客户端失败: %w", err)
|
||||
}
|
||||
// SendPrivateMessage 用给定账号向配置中的每个 UID 发送一条 B 站私信
|
||||
//
|
||||
// 每个接收者单独重试、互不阻塞,最后汇总失败信息交给上层记录。
|
||||
func SendPrivateMessage(ctx context.Context, account *BiliAccount, config *BiliPrivateMessageConfig) error {
|
||||
client := newClient(account.RawCookies)
|
||||
|
||||
config, err := getPrivateMessageConfig(rule)
|
||||
// 发私信需要带上自己的 UID,从账号信息里取
|
||||
selfUID, err := fetchSelfUID(ctx, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 发私信需要带上自己的 UID,从账号信息里取
|
||||
account, err := client.GetAccountInformation()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取B站账号信息失败(cookie 可能已失效): %w", err)
|
||||
}
|
||||
if account == nil || account.Mid == 0 {
|
||||
return fmt.Errorf("B站 cookie 无效或已过期")
|
||||
}
|
||||
|
||||
var fails []string
|
||||
for _, uid := range config.ReceiverUids {
|
||||
err := retry.Do(ctx, func() error {
|
||||
_, err := client.SendPrivateMessage(bilibili.SendPrivateMessageParam{
|
||||
SenderUid: account.Mid,
|
||||
ReceiverId: int(uid),
|
||||
ReceiverType: 1,
|
||||
MsgType: privateMsgTypeText,
|
||||
Timestamp: int(time.Now().Unix()),
|
||||
Content: buildTextContent(config.Msg),
|
||||
})
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
if err := retry.Do(ctx, func() error {
|
||||
return sendPrivateMessage(client, selfUID, uid, config.Msg)
|
||||
}); err != nil {
|
||||
fails = append(fails, fmt.Sprintf("UID %d: %v", uid, err))
|
||||
}
|
||||
}
|
||||
@@ -61,20 +41,34 @@ func SendBiliPrivateMessage(ctx context.Context, rule *model.Rule) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildTextContent 文字私信的 content 是一个 JSON 字符串 {"content":"..."}
|
||||
func buildTextContent(msg string) string {
|
||||
payload, err := json.Marshal(map[string]string{"content": msg})
|
||||
if err != nil {
|
||||
// msg 是普通字符串,序列化不会失败;兜底也返回合法 JSON
|
||||
return `{"content":""}`
|
||||
// fetchSelfUID 取当前账号的 UID,私信接口需要它
|
||||
func fetchSelfUID(ctx context.Context, client *bilibili.Client) (int, error) {
|
||||
var selfUID int
|
||||
if err := retry.Do(ctx, func() error {
|
||||
info, err := client.GetAccountInformation()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info == nil || info.Mid == 0 {
|
||||
return retry.Permanent(fmt.Errorf("B站 cookie 无效或已过期"))
|
||||
}
|
||||
selfUID = info.Mid
|
||||
return nil
|
||||
}); err != nil {
|
||||
return 0, fmt.Errorf("获取B站账号信息失败: %w", err)
|
||||
}
|
||||
return string(payload)
|
||||
return selfUID, nil
|
||||
}
|
||||
|
||||
func getPrivateMessageConfig(rule *model.Rule) (*BiliPrivateMessageConfig, error) {
|
||||
var config BiliPrivateMessageConfig
|
||||
if err := json.Unmarshal([]byte(rule.ConfigJson), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析B站私信规则配置失败: %w", err)
|
||||
}
|
||||
return &config, nil
|
||||
// sendPrivateMessage 发送一条私信
|
||||
func sendPrivateMessage(client *bilibili.Client, senderUID int, receiverUID int64, msg string) error {
|
||||
_, err := client.SendPrivateMessage(bilibili.SendPrivateMessageParam{
|
||||
SenderUid: senderUID,
|
||||
ReceiverId: int(receiverUID),
|
||||
ReceiverType: 1,
|
||||
MsgType: privateMsgTypeText,
|
||||
Timestamp: int(time.Now().Unix()),
|
||||
Content: buildTextContent(msg),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package bilibili
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
@@ -15,9 +14,9 @@ func (v *BilibiliDynamicRuleValidator) GetType() string {
|
||||
}
|
||||
|
||||
func (v *BilibiliDynamicRuleValidator) Validate(configJSON string) error {
|
||||
var config BiliDynamicConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return fmt.Errorf("解析B站动态规则配置失败: %v", err)
|
||||
config, err := ParseDynamicConfig(configJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if config.Msg == "" {
|
||||
@@ -39,9 +38,9 @@ func (v *BilibiliPrivateMessageRuleValidator) GetType() string {
|
||||
}
|
||||
|
||||
func (v *BilibiliPrivateMessageRuleValidator) Validate(configJSON string) error {
|
||||
var config BiliPrivateMessageConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return fmt.Errorf("解析B站私信规则配置失败: %v", err)
|
||||
config, err := ParsePrivateMessageConfig(configJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if config.Msg == "" {
|
||||
@@ -77,7 +76,7 @@ func (v *BilibiliPrivateMessageRuleValidator) Meta() meta.RuleMeta {
|
||||
Placeholder: "2",
|
||||
Help: "对方空间地址 space.bilibili.com/ 后面的那串数字",
|
||||
},
|
||||
{Key: "msg", Label: "私信内容", Type: meta.FieldTextarea, Required: true},
|
||||
{Key: "msg", Label: "私信内容", Type: meta.FieldTextarea, Required: true, GatewayMessage: true},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -91,11 +90,12 @@ func (v *BilibiliDynamicRuleValidator) Meta() meta.RuleMeta {
|
||||
AccountType: AccountType,
|
||||
Fields: []meta.Field{
|
||||
{
|
||||
Key: "msg",
|
||||
Label: "动态内容",
|
||||
Type: meta.FieldTextarea,
|
||||
Required: true,
|
||||
Placeholder: "要发送的动态正文",
|
||||
Key: "msg",
|
||||
Label: "动态内容",
|
||||
Type: meta.FieldTextarea,
|
||||
Required: true,
|
||||
GatewayMessage: true,
|
||||
Placeholder: "要发送的动态正文",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package bilibili
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
@@ -14,10 +13,18 @@ func (e *BilibiliDynamicExecutor) GetType() string {
|
||||
return RuleTypeDynamic
|
||||
}
|
||||
|
||||
func (e *BilibiliDynamicExecutor) Execute(ctx context.Context, rule *model.Rule) error {
|
||||
log.Printf("执行B站动态规则: %s (ID: %d)", rule.Name, rule.ID)
|
||||
func (e *BilibiliDynamicExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
account, err := ParseAccountConfig(task.AccountConfig())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SendBiliDynamicMsg(ctx, rule)
|
||||
config, err := ParseDynamicConfig(task.Rule.ConfigJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SendDynamic(ctx, account, config)
|
||||
}
|
||||
|
||||
// BilibiliPrivateMessageExecutor B站私信执行器
|
||||
@@ -27,8 +34,16 @@ func (e *BilibiliPrivateMessageExecutor) GetType() string {
|
||||
return RuleTypePrivateMessage
|
||||
}
|
||||
|
||||
func (e *BilibiliPrivateMessageExecutor) Execute(ctx context.Context, rule *model.Rule) error {
|
||||
log.Printf("执行B站私信规则: %s (ID: %d)", rule.Name, rule.ID)
|
||||
func (e *BilibiliPrivateMessageExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
account, err := ParseAccountConfig(task.AccountConfig())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SendBiliPrivateMessage(ctx, rule)
|
||||
config, err := ParsePrivateMessageConfig(task.Rule.ConfigJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SendPrivateMessage(ctx, account, config)
|
||||
}
|
||||
|
||||
@@ -2,34 +2,55 @@ package bilibili
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/CuteReimu/bilibili/v2"
|
||||
"github.com/ssdomei232/goodBaby/handler/db"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// ParseAccountConfig 解析 B 站账号配置
|
||||
func ParseAccountConfig(config string) (*BiliAccount, error) {
|
||||
if config == "" {
|
||||
return nil, fmt.Errorf("解析B站账号配置失败: 规则没有关联账号")
|
||||
}
|
||||
|
||||
var account BiliAccount
|
||||
if err := json.Unmarshal([]byte(config), &account); err != nil {
|
||||
return nil, fmt.Errorf("解析B站账号配置失败: %v", err)
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
// ParseDynamicConfig 解析 B 站动态规则配置
|
||||
func ParseDynamicConfig(configJSON string) (*BiliDynamicConfig, error) {
|
||||
var config BiliDynamicConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析B站动态规则配置失败: %v", err)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// ParsePrivateMessageConfig 解析 B 站私信规则配置
|
||||
func ParsePrivateMessageConfig(configJSON string) (*BiliPrivateMessageConfig, error) {
|
||||
var config BiliPrivateMessageConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析B站私信规则配置失败: %v", err)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// newClient 按账号配置创建 B 站客户端
|
||||
func newClient(rawCookies string) *bilibili.Client {
|
||||
client := bilibili.New()
|
||||
client.SetRawCookies(rawCookies)
|
||||
return client
|
||||
}
|
||||
|
||||
// 获取 Bilibili Client
|
||||
func getBiliClient(rule *model.Rule) (*bilibili.Client, error) {
|
||||
var accountConfig BiliAccount
|
||||
if err := db.LoadAccountConfig(rule.AccountID, &accountConfig); err != nil {
|
||||
return nil, err
|
||||
// buildTextContent 文字私信的 content 是一个 JSON 字符串 {"content":"..."}
|
||||
func buildTextContent(msg string) string {
|
||||
payload, err := json.Marshal(map[string]string{"content": msg})
|
||||
if err != nil {
|
||||
// msg 是普通字符串,序列化不会失败;兜底也返回合法 JSON
|
||||
return `{"content":""}`
|
||||
}
|
||||
|
||||
return newClient(accountConfig.RawCookies), nil
|
||||
}
|
||||
|
||||
// 获取 Bilibili Dynamic Config
|
||||
func getBiliDynamicConfig(rule *model.Rule) (*BiliDynamicConfig, error) {
|
||||
var biliDynamicConfig BiliDynamicConfig
|
||||
if err := json.Unmarshal([]byte(rule.ConfigJson), &biliDynamicConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &biliDynamicConfig, nil
|
||||
return string(payload)
|
||||
}
|
||||
|
||||
@@ -2,32 +2,51 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/CatchZeng/dingtalk/pkg/dingtalk"
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// ErrNoUserConfig 表示用户没有配置钉钉提醒
|
||||
var ErrNoUserConfig = fmt.Errorf("用户未配置钉钉机器人")
|
||||
|
||||
// SendDingTalkMsg 使用用户配置的钉钉机器人发送提醒
|
||||
//
|
||||
// 用户没有配置钉钉机器人时返回 ErrNoUserConfig
|
||||
func SendDingTalkMsg(user *model.User, title string, msg string) error {
|
||||
config := getDingTalkConfigFromUser(user)
|
||||
if config == nil {
|
||||
if user.DingTalkConfig == nil {
|
||||
return ErrNoUserConfig
|
||||
}
|
||||
return send(config, title, msg)
|
||||
}
|
||||
|
||||
// ErrNoUserConfig 表示用户没有配置钉钉提醒
|
||||
var ErrNoUserConfig = fmt.Errorf("用户未配置钉钉机器人")
|
||||
config, err := ParseAccountConfig(*user.DingTalkConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
func send(config *DingTalkConfig, title, msg string) error {
|
||||
client := dingtalk.NewClient(config.AccessToken, config.Secret)
|
||||
message := dingtalk.NewMarkdownMessage().SetMarkdown(title, msg)
|
||||
|
||||
if _, _, err := client.Send(message); err != nil {
|
||||
if err := send(config, title, msg); err != nil {
|
||||
return fmt.Errorf("发送钉钉消息失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendMessage 用规则里配置的机器人发送消息
|
||||
func SendMessage(ctx context.Context, config *DingTalkRuleConfig) error {
|
||||
if err := retry.Do(ctx, func() error {
|
||||
return send(&config.DingTalkConfig, config.Title, config.Msg)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("发送钉钉消息失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// send 调用一次机器人接口
|
||||
func send(config *DingTalkConfig, title, msg string) error {
|
||||
client := dingtalk.NewClient(config.AccessToken, config.Secret)
|
||||
message := dingtalk.NewMarkdownMessage().SetMarkdown(title, msg)
|
||||
|
||||
_, _, err := client.Send(message)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package dingtalk
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
@@ -42,17 +41,8 @@ func (v *DingTalkRuleValidator) Meta() meta.RuleMeta {
|
||||
Fields: []meta.Field{
|
||||
{Key: "access_token", Label: "Access Token", Type: meta.FieldPassword, Required: true, Secret: true, Help: "机器人 Webhook 中 access_token 参数的值"},
|
||||
{Key: "secret", Label: "加签 Secret", Type: meta.FieldPassword, Secret: true, Help: "机器人安全设置选择“加签”时填写"},
|
||||
{Key: "title", Label: "消息标题", Type: meta.FieldString, Required: true},
|
||||
{Key: "msg", Label: "消息内容", Type: meta.FieldTextarea, Required: true, Help: "支持 Markdown"},
|
||||
{Key: "title", Label: "消息标题", Type: meta.FieldString, Required: true, GatewayMessage: true},
|
||||
{Key: "msg", Label: "消息内容", Type: meta.FieldTextarea, Required: true, GatewayMessage: true, Help: "支持 Markdown"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ParseRuleConfig 解析钉钉规则配置
|
||||
func ParseRuleConfig(configJSON string) (*DingTalkRuleConfig, error) {
|
||||
var config DingTalkRuleConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析钉钉规则配置失败: %v", err)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@ package dingtalk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
@@ -15,15 +13,12 @@ func (e *DingTalkExecutor) GetType() string {
|
||||
return RuleType
|
||||
}
|
||||
|
||||
func (e *DingTalkExecutor) Execute(ctx context.Context, rule *model.Rule) error {
|
||||
log.Printf("执行钉钉规则: %s (ID: %d)", rule.Name, rule.ID)
|
||||
|
||||
config, err := ParseRuleConfig(rule.ConfigJson)
|
||||
func (e *DingTalkExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
// 钉钉机器人凭据直接写在规则配置里,不需要关联账号
|
||||
config, err := ParseRuleConfig(task.Rule.ConfigJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return retry.Do(ctx, func() error {
|
||||
return send(&config.DingTalkConfig, config.Title, config.Msg)
|
||||
})
|
||||
return SendMessage(ctx, config)
|
||||
}
|
||||
|
||||
@@ -2,19 +2,27 @@ package dingtalk
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func getDingTalkConfigFromUser(user *model.User) *DingTalkConfig {
|
||||
if user.DingTalkConfig == nil {
|
||||
return nil
|
||||
// ParseAccountConfig 解析钉钉机器人凭据配置
|
||||
func ParseAccountConfig(config string) (*DingTalkConfig, error) {
|
||||
if config == "" {
|
||||
return nil, fmt.Errorf("解析钉钉机器人配置失败: 配置为空")
|
||||
}
|
||||
|
||||
var config DingTalkConfig
|
||||
err := json.Unmarshal([]byte(*user.DingTalkConfig), &config)
|
||||
if err != nil {
|
||||
return nil
|
||||
var account DingTalkConfig
|
||||
if err := json.Unmarshal([]byte(config), &account); err != nil {
|
||||
return nil, fmt.Errorf("解析钉钉机器人配置失败: %v", err)
|
||||
}
|
||||
return &config
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
// ParseRuleConfig 解析钉钉规则配置
|
||||
func ParseRuleConfig(configJSON string) (*DingTalkRuleConfig, error) {
|
||||
var config DingTalkRuleConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析钉钉规则配置失败: %v", err)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
|
||||
@@ -18,7 +17,7 @@ func (v *EmailAccountConfigValidator) GetType() string {
|
||||
}
|
||||
|
||||
func (v *EmailAccountConfigValidator) Validate(config string) error {
|
||||
cfg, err := parseAccountConfig(config)
|
||||
cfg, err := ParseAccountConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -56,7 +55,7 @@ func (v *EmailAccountConfigValidator) Validate(config string) error {
|
||||
|
||||
// Test 连接 SMTP 服务器并完成认证;填写了测试收件地址时会真的发一封测试邮件
|
||||
func (v *EmailAccountConfigValidator) Test(config string) error {
|
||||
cfg, err := parseAccountConfig(config)
|
||||
cfg, err := ParseAccountConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -103,11 +102,3 @@ func (v *EmailAccountConfigValidator) Meta() meta.AccountMeta {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func parseAccountConfig(config string) (*EmailAccountConfig, error) {
|
||||
var cfg EmailAccountConfig
|
||||
if err := json.Unmarshal([]byte(config), &cfg); err != nil {
|
||||
return nil, fmt.Errorf("解析邮箱账号配置失败: %v", err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
@@ -3,38 +3,31 @@ package email
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
)
|
||||
|
||||
// SendMail 向规则配置里的所有地址发送邮件。
|
||||
// SendMail 用给定 SMTP 账号向规则配置里的所有地址发送邮件。
|
||||
//
|
||||
// 每个地址单独重试,互不阻塞;全部失败/部分失败都会汇总成错误返回,
|
||||
// 每个地址单独重试、互不阻塞;全部失败/部分失败都会汇总成错误返回,
|
||||
// 由 runner 记录到执行日志中。
|
||||
func SendMail(ctx context.Context, rule *model.Rule) error {
|
||||
emailRule, err := GetEmailRuleFromRule(rule)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取邮件规则配置失败: %w", err)
|
||||
}
|
||||
|
||||
accountConfig, err := GetEmailAccountFromRule(rule)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取邮件账户配置失败: %w", err)
|
||||
}
|
||||
|
||||
func SendMail(ctx context.Context, account *EmailAccountConfig, config *EmailRule) error {
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
fails []string
|
||||
)
|
||||
|
||||
for _, destination := range emailRule.Destinations {
|
||||
for _, destination := range config.Destinations {
|
||||
wg.Add(1)
|
||||
go func(address string) {
|
||||
defer wg.Done()
|
||||
if err := sendMailMsgWithRetry(ctx, accountConfig, emailRule, address); err != nil {
|
||||
if err := retry.Do(ctx, func() error {
|
||||
return sendMail(ctx, account, config, address)
|
||||
}); err != nil {
|
||||
mu.Lock()
|
||||
fails = append(fails, fmt.Sprintf("%s: %v", address, err))
|
||||
mu.Unlock()
|
||||
@@ -44,8 +37,10 @@ func SendMail(ctx context.Context, rule *model.Rule) error {
|
||||
wg.Wait()
|
||||
|
||||
if len(fails) > 0 {
|
||||
// 并发发送的顺序不确定,排序后错误信息才是稳定的
|
||||
sort.Strings(fails)
|
||||
return fmt.Errorf("%d/%d 封邮件发送失败: %s",
|
||||
len(fails), len(emailRule.Destinations), strings.Join(fails, "; "))
|
||||
len(fails), len(config.Destinations), strings.Join(fails, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
|
||||
@@ -16,9 +15,9 @@ func (v *EmailRuleValidator) GetType() string {
|
||||
}
|
||||
|
||||
func (v *EmailRuleValidator) Validate(configJSON string) error {
|
||||
var config EmailRule
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return fmt.Errorf("解析邮件规则配置失败: %v", err)
|
||||
config, err := ParseRuleConfig(configJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if config.Title == "" {
|
||||
@@ -51,8 +50,8 @@ func (v *EmailRuleValidator) Meta() meta.RuleMeta {
|
||||
Docs: "docs/email-config.md",
|
||||
AccountType: AccountType,
|
||||
Fields: []meta.Field{
|
||||
{Key: "title", Label: "邮件标题", Type: meta.FieldString, Required: true},
|
||||
{Key: "msg", Label: "邮件正文", Type: meta.FieldTextarea, Required: true},
|
||||
{Key: "title", Label: "邮件标题", Type: meta.FieldString, Required: true, GatewayMessage: true},
|
||||
{Key: "msg", Label: "邮件正文", Type: meta.FieldTextarea, Required: true, GatewayMessage: true},
|
||||
{Key: "destinations", Label: "收件人", Type: meta.FieldStringList, Required: true, Placeholder: "someone@example.com"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
@@ -14,8 +13,16 @@ func (e *EmailExecutor) GetType() string {
|
||||
return RuleType
|
||||
}
|
||||
|
||||
func (e *EmailExecutor) Execute(ctx context.Context, rule *model.Rule) error {
|
||||
log.Printf("执行邮件规则: %s (ID: %d)", rule.Name, rule.ID)
|
||||
func (e *EmailExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
account, err := ParseAccountConfig(task.AccountConfig())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SendMail(ctx, rule)
|
||||
config, err := ParseRuleConfig(task.Rule.ConfigJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SendMail(ctx, account, config)
|
||||
}
|
||||
|
||||
@@ -5,28 +5,29 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/handler/db"
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
"github.com/wneessen/go-mail"
|
||||
)
|
||||
|
||||
// GetEmailAccountFromRule 从 Rule 中获取 EmailAccount 配置
|
||||
func GetEmailAccountFromRule(rule *model.Rule) (*EmailAccountConfig, error) {
|
||||
var emailAccountConfig EmailAccountConfig
|
||||
if err := db.LoadAccountConfig(rule.AccountID, &emailAccountConfig); err != nil {
|
||||
return nil, err
|
||||
// ParseAccountConfig 解析邮箱账号配置
|
||||
func ParseAccountConfig(config string) (*EmailAccountConfig, error) {
|
||||
if config == "" {
|
||||
return nil, fmt.Errorf("解析邮箱账号配置失败: 规则没有关联账号")
|
||||
}
|
||||
return &emailAccountConfig, nil
|
||||
|
||||
var account EmailAccountConfig
|
||||
if err := json.Unmarshal([]byte(config), &account); err != nil {
|
||||
return nil, fmt.Errorf("解析邮箱账号配置失败: %v", err)
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
// GetEmailRuleFromRule 从 Rule 中获取 EmailRule 配置
|
||||
func GetEmailRuleFromRule(rule *model.Rule) (*EmailRule, error) {
|
||||
var emailRule EmailRule
|
||||
if err := json.Unmarshal([]byte(rule.ConfigJson), &emailRule); err != nil {
|
||||
return nil, err
|
||||
// ParseRuleConfig 解析邮件规则配置
|
||||
func ParseRuleConfig(configJSON string) (*EmailRule, error) {
|
||||
var config EmailRule
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析邮件规则配置失败: %v", err)
|
||||
}
|
||||
return &emailRule, nil
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// newSMTPClient 按账号配置里的加密方式创建 SMTP 客户端
|
||||
@@ -54,6 +55,7 @@ func newSMTPClient(cfg *EmailAccountConfig) (*mail.Client, error) {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// buildMessage 组装一封邮件
|
||||
func buildMessage(cfg *EmailAccountConfig, address, title, body string) (*mail.Msg, error) {
|
||||
message := mail.NewMsg()
|
||||
if err := message.From(cfg.FromOrDefault()); err != nil {
|
||||
@@ -67,19 +69,14 @@ func buildMessage(cfg *EmailAccountConfig, address, title, body string) (*mail.M
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func sendMailMsgWithRetry(ctx context.Context, cfg *EmailAccountConfig, rule *EmailRule, address string) error {
|
||||
return retry.Do(ctx, func() error {
|
||||
return sendMailMsg(ctx, cfg, rule, address)
|
||||
})
|
||||
}
|
||||
|
||||
func sendMailMsg(ctx context.Context, cfg *EmailAccountConfig, rule *EmailRule, address string) error {
|
||||
// sendMail 给一个地址发送一封邮件
|
||||
func sendMail(ctx context.Context, cfg *EmailAccountConfig, config *EmailRule, address string) error {
|
||||
client, err := newSMTPClient(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
message, err := buildMessage(cfg, address, rule.Title, rule.Msg)
|
||||
message, err := buildMessage(cfg, address, config.Title, config.Msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
package fwalert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
)
|
||||
|
||||
// send 发送消息
|
||||
// Send 发送一条饭碗警告
|
||||
func Send(ctx context.Context, config *FwAlertRuleConfig) error {
|
||||
if err := retry.Do(ctx, func() error {
|
||||
return send(config)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("发送饭碗警告失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// send 调用一次 Webhook
|
||||
func send(config *FwAlertRuleConfig) error {
|
||||
restyClient := resty.New()
|
||||
req := restyClient.R()
|
||||
|
||||
req.SetBody(FwAlertRequest{
|
||||
Message: config.Msg,
|
||||
})
|
||||
|
||||
resp, err := req.Execute(resty.MethodPost, config.WebhookURL)
|
||||
resp, err := resty.New().R().
|
||||
SetBody(FwAlertRequest{Message: config.Msg}).
|
||||
Execute(resty.MethodPost, config.WebhookURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode() != 200 {
|
||||
return fmt.Errorf("发送饭碗警告失败: %s", resp.Status())
|
||||
return fmt.Errorf("Webhook 返回状态码 %s", resp.Status())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package fwalert
|
||||
|
||||
const RuleTypeFwalert = "fwalert"
|
||||
// RuleType 饭碗警告规则类型标识
|
||||
const RuleType = "fwalert"
|
||||
|
||||
type FwAlertRuleConfig struct {
|
||||
WebhookURL string `json:"webhook_url"`
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
package fwalert
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
)
|
||||
|
||||
// FwalertRuleValidator 饭碗警告规则验证器
|
||||
type FwalertRuleValidator struct{}
|
||||
|
||||
func (v *FwalertRuleValidator) GetType() string {
|
||||
return RuleTypeFwalert
|
||||
return RuleType
|
||||
}
|
||||
|
||||
func (v *FwalertRuleValidator) Validate(configJSON string) error {
|
||||
var config FwAlertRuleConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return fmt.Errorf("解析饭碗警告规则配置失败: %v", err)
|
||||
config, err := ParseRuleConfig(configJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(config.WebhookURL)
|
||||
@@ -40,23 +40,14 @@ func (v *FwalertRuleValidator) Validate(configJSON string) error {
|
||||
|
||||
func (v *FwalertRuleValidator) Meta() meta.RuleMeta {
|
||||
return meta.RuleMeta{
|
||||
Type: RuleTypeFwalert,
|
||||
Type: RuleType,
|
||||
Label: "发送饭碗警告",
|
||||
Description: "触发时发送饭碗警告消息。",
|
||||
Docs: "docs/fwalert-config.md",
|
||||
AccountType: "",
|
||||
Fields: []meta.Field{
|
||||
{Key: "webhook_url", Label: "Webhook URL", Type: meta.FieldString, Required: true, Help: "饭碗警告的触发地址", Placeholder: "https://fwalert.com/xxx-xxx-xxx-xxx-xxx"},
|
||||
{Key: "msg", Label: "消息内容", Type: meta.FieldTextarea, Required: true, Help: "最大长度 1000 个字符"},
|
||||
{Key: "msg", Label: "消息内容", Type: meta.FieldTextarea, Required: true, GatewayMessage: true, Help: "最大长度 1000 个字符"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ParseRuleConfig 解析饭碗警告规则配置
|
||||
func ParseRuleConfig(configJSON string) (*FwAlertRuleConfig, error) {
|
||||
var config FwAlertRuleConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析饭碗警告规则配置失败: %v", err)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
@@ -2,9 +2,7 @@ package fwalert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
@@ -12,18 +10,15 @@ import (
|
||||
type FwalertExecutor struct{}
|
||||
|
||||
func (e *FwalertExecutor) GetType() string {
|
||||
return RuleTypeFwalert
|
||||
return RuleType
|
||||
}
|
||||
|
||||
func (e *FwalertExecutor) Execute(ctx context.Context, rule *model.Rule) error {
|
||||
log.Printf("执行饭碗警告规则: %s (ID: %d)", rule.Name, rule.ID)
|
||||
|
||||
config, err := ParseRuleConfig(rule.ConfigJson)
|
||||
func (e *FwalertExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
// 饭碗警告的凭据就是 Webhook 地址,直接写在规则配置里
|
||||
config, err := ParseRuleConfig(task.Rule.ConfigJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return retry.Do(ctx, func() error {
|
||||
return send(config)
|
||||
})
|
||||
return Send(ctx, config)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package fwalert
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ParseRuleConfig 解析饭碗警告规则配置
|
||||
func ParseRuleConfig(configJSON string) (*FwAlertRuleConfig, error) {
|
||||
var config FwAlertRuleConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析饭碗警告规则配置失败: %v", err)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/go-github/v84/github"
|
||||
@@ -18,7 +17,7 @@ func (v *GitHubAccountConfigValidator) GetType() string {
|
||||
}
|
||||
|
||||
func (v *GitHubAccountConfigValidator) Validate(config string) error {
|
||||
cfg, err := parseAccount(config)
|
||||
cfg, err := ParseAccountConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -35,7 +34,7 @@ func (v *GitHubAccountConfigValidator) Validate(config string) error {
|
||||
|
||||
// Test 用 token 拉一次当前用户信息,验证 token 是否有效
|
||||
func (v *GitHubAccountConfigValidator) Test(config string) error {
|
||||
cfg, err := parseAccount(config)
|
||||
cfg, err := ParseAccountConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -65,11 +64,3 @@ func (v *GitHubAccountConfigValidator) Meta() meta.AccountMeta {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func parseAccount(config string) (*GithubAccount, error) {
|
||||
var cfg GithubAccount
|
||||
if err := json.Unmarshal([]byte(config), &cfg); err != nil {
|
||||
return nil, fmt.Errorf("解析GitHub账号配置失败: %v", err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
@@ -6,29 +6,24 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// MakeRepositoryPublic 将规则中配置的仓库设置为 public
|
||||
func MakeRepositoryPublic(ctx context.Context, rule *model.Rule) error {
|
||||
reposConfig, account, err := GetGithubReposAndAccountFromRule(rule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// MakeRepositoryPublic 把配置里的仓库逐个设置为 public
|
||||
//
|
||||
// 每个仓库单独重试、互不阻塞,最后汇总失败信息交给上层记录。
|
||||
func MakeRepositoryPublic(ctx context.Context, account *GithubAccount, config *GithubReposConfig) error {
|
||||
var fails []string
|
||||
for _, repo := range reposConfig.Repos {
|
||||
err := retry.Do(ctx, func() error {
|
||||
for _, repo := range config.Repos {
|
||||
if err := retry.Do(ctx, func() error {
|
||||
return SetRepositoryPublic(ctx, account.Token, account.Owner, repo)
|
||||
})
|
||||
if err != nil {
|
||||
fails = append(fails, fmt.Sprintf("%s: %v", repo, err))
|
||||
}); err != nil {
|
||||
fails = append(fails, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if len(fails) > 0 {
|
||||
return fmt.Errorf("%d/%d 个仓库设置为 public 失败: %s",
|
||||
len(fails), len(reposConfig.Repos), strings.Join(fails, "; "))
|
||||
len(fails), len(config.Repos), strings.Join(fails, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -15,9 +14,9 @@ func (v *GithubMakeRepositoryPublicRuleValidator) GetType() string {
|
||||
}
|
||||
|
||||
func (v *GithubMakeRepositoryPublicRuleValidator) Validate(configJSON string) error {
|
||||
var config GithubReposConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return fmt.Errorf("解析 GitHub 规则配置失败: %v", err)
|
||||
config, err := ParseReposConfig(configJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(config.Repos) == 0 {
|
||||
|
||||
@@ -2,7 +2,6 @@ package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
@@ -14,8 +13,16 @@ func (e *GithubMakeRepoPublicExecutor) GetType() string {
|
||||
return RuleTypeMakeRepoPublic
|
||||
}
|
||||
|
||||
func (e *GithubMakeRepoPublicExecutor) Execute(ctx context.Context, rule *model.Rule) error {
|
||||
log.Printf("执行GitHub仓库公开规则: %s (ID: %d)", rule.Name, rule.ID)
|
||||
func (e *GithubMakeRepoPublicExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
account, err := ParseAccountConfig(task.AccountConfig())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return MakeRepositoryPublic(ctx, rule)
|
||||
config, err := ParseReposConfig(task.Rule.ConfigJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return MakeRepositoryPublic(ctx, account, config)
|
||||
}
|
||||
|
||||
@@ -6,33 +6,37 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/google/go-github/v84/github"
|
||||
"github.com/ssdomei232/goodBaby/handler/db"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// GetGithubReposAndAccountFromRule 从 rule 中获取 GithubReposConfig 和 GithubAccount
|
||||
func GetGithubReposAndAccountFromRule(rule *model.Rule) (*GithubReposConfig, *GithubAccount, error) {
|
||||
var githubReposConfig GithubReposConfig
|
||||
if err := json.Unmarshal([]byte(rule.ConfigJson), &githubReposConfig); err != nil {
|
||||
return nil, nil, fmt.Errorf("解析 GitHub 规则配置失败: %w", err)
|
||||
// ParseAccountConfig 解析 GitHub 账号配置
|
||||
func ParseAccountConfig(config string) (*GithubAccount, error) {
|
||||
if config == "" {
|
||||
return nil, fmt.Errorf("解析GitHub账号配置失败: 规则没有关联账号")
|
||||
}
|
||||
|
||||
var githubAccount GithubAccount
|
||||
if err := db.LoadAccountConfig(rule.AccountID, &githubAccount); err != nil {
|
||||
return nil, nil, err
|
||||
var account GithubAccount
|
||||
if err := json.Unmarshal([]byte(config), &account); err != nil {
|
||||
return nil, fmt.Errorf("解析GitHub账号配置失败: %v", err)
|
||||
}
|
||||
|
||||
return &githubReposConfig, &githubAccount, nil
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
// SetRepositoryPublic 将仓库设置为 public
|
||||
// ParseReposConfig 解析仓库规则配置
|
||||
func ParseReposConfig(configJSON string) (*GithubReposConfig, error) {
|
||||
var config GithubReposConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析GitHub规则配置失败: %v", err)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// SetRepositoryPublic 把一个仓库设置为 public
|
||||
func SetRepositoryPublic(ctx context.Context, token, owner, repo string) error {
|
||||
client := github.NewClient(nil).WithAuthToken(token)
|
||||
|
||||
opts := &github.Repository{
|
||||
if _, _, err := client.Repositories.Edit(ctx, owner, repo, &github.Repository{
|
||||
Visibility: github.Ptr("public"),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("设置仓库 %s 为 public 失败: %w", repo, err)
|
||||
}
|
||||
|
||||
_, _, err := client.Repositories.Edit(ctx, owner, repo, opts)
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package onebot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -17,7 +16,7 @@ func (v *OneBotAccountConfigValidator) GetType() string {
|
||||
}
|
||||
|
||||
func (v *OneBotAccountConfigValidator) Validate(config string) error {
|
||||
cfg, err := parseAccount(config)
|
||||
cfg, err := ParseAccountConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -39,7 +38,7 @@ func (v *OneBotAccountConfigValidator) Validate(config string) error {
|
||||
|
||||
// Test 调用 get_login_info 验证地址与 Token
|
||||
func (v *OneBotAccountConfigValidator) Test(config string) error {
|
||||
cfg, err := parseAccount(config)
|
||||
cfg, err := ParseAccountConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -62,11 +61,3 @@ func (v *OneBotAccountConfigValidator) Meta() meta.AccountMeta {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func parseAccount(config string) (*OneBotAccount, error) {
|
||||
var cfg OneBotAccount
|
||||
if err := json.Unmarshal([]byte(config), &cfg); err != nil {
|
||||
return nil, fmt.Errorf("解析OneBot账号配置失败: %v", err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
@@ -6,40 +6,29 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// SendOneBotMsg 向规则中配置的群和好友发送消息
|
||||
func SendOneBotMsg(ctx context.Context, rule *model.Rule) error {
|
||||
oneBotConfig, err := getOneBotConfigFromRule(rule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
oneBotAccount, err := getOneBotAccountFromRule(rule)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取OneBot账户失败: %w", err)
|
||||
}
|
||||
|
||||
// SendMessage 向规则中配置的群和好友发送消息
|
||||
//
|
||||
// 每个目标单独重试、互不阻塞,最后汇总失败信息交给上层记录。
|
||||
func SendMessage(ctx context.Context, account *OneBotAccount, config *OneBotConfig) error {
|
||||
var fails []string
|
||||
total := 0
|
||||
|
||||
for _, groupID := range oneBotConfig.SendGroups {
|
||||
for _, groupID := range config.SendGroups {
|
||||
total++
|
||||
err := retry.Do(ctx, func() error {
|
||||
return sendGroupMsg(oneBotAccount, groupID, oneBotConfig.Msg)
|
||||
})
|
||||
if err != nil {
|
||||
if err := retry.Do(ctx, func() error {
|
||||
return sendGroupMsg(account, groupID, config.Msg)
|
||||
}); err != nil {
|
||||
fails = append(fails, fmt.Sprintf("群 %d: %v", groupID, err))
|
||||
}
|
||||
}
|
||||
|
||||
for _, userID := range oneBotConfig.SendUsers {
|
||||
for _, userID := range config.SendUsers {
|
||||
total++
|
||||
err := retry.Do(ctx, func() error {
|
||||
return sendPrivateMsg(oneBotAccount, userID, oneBotConfig.Msg)
|
||||
})
|
||||
if err != nil {
|
||||
if err := retry.Do(ctx, func() error {
|
||||
return sendPrivateMsg(account, userID, config.Msg)
|
||||
}); err != nil {
|
||||
fails = append(fails, fmt.Sprintf("好友 %d: %v", userID, err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package onebot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
@@ -15,9 +14,9 @@ func (v *OneBotRuleValidator) GetType() string {
|
||||
}
|
||||
|
||||
func (v *OneBotRuleValidator) Validate(configJSON string) error {
|
||||
var config OneBotConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return fmt.Errorf("解析OneBot规则配置失败: %v", err)
|
||||
config, err := ParseRuleConfig(configJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if config.Msg == "" {
|
||||
@@ -44,7 +43,7 @@ func (v *OneBotRuleValidator) Meta() meta.RuleMeta {
|
||||
Description: "触发时通过 OneBot 向指定的群或好友发送消息。",
|
||||
AccountType: AccountType,
|
||||
Fields: []meta.Field{
|
||||
{Key: "msg", Label: "消息内容", Type: meta.FieldTextarea, Required: true},
|
||||
{Key: "msg", Label: "消息内容", Type: meta.FieldTextarea, Required: true, GatewayMessage: true},
|
||||
{Key: "send_groups", Label: "群号", Type: meta.FieldNumberList, Placeholder: "123456789"},
|
||||
{Key: "send_users", Label: "好友 QQ 号", Type: meta.FieldNumberList, Placeholder: "123456789"},
|
||||
},
|
||||
|
||||
@@ -2,7 +2,6 @@ package onebot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
@@ -14,8 +13,16 @@ func (e *OneBotExecutor) GetType() string {
|
||||
return RuleType
|
||||
}
|
||||
|
||||
func (e *OneBotExecutor) Execute(ctx context.Context, rule *model.Rule) error {
|
||||
log.Printf("执行OneBot规则: %s (ID: %d)", rule.Name, rule.ID)
|
||||
func (e *OneBotExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
account, err := ParseAccountConfig(task.AccountConfig())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SendOneBotMsg(ctx, rule)
|
||||
config, err := ParseRuleConfig(task.Rule.ConfigJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SendMessage(ctx, account, config)
|
||||
}
|
||||
|
||||
@@ -4,37 +4,37 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/handler/db"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
napcat "github.com/ssdomei232/napcat-http-go-sdk"
|
||||
)
|
||||
|
||||
// ParseAccountConfig 解析 OneBot 账号配置
|
||||
func ParseAccountConfig(config string) (*OneBotAccount, error) {
|
||||
if config == "" {
|
||||
return nil, fmt.Errorf("解析OneBot账号配置失败: 规则没有关联账号")
|
||||
}
|
||||
|
||||
var account OneBotAccount
|
||||
if err := json.Unmarshal([]byte(config), &account); err != nil {
|
||||
return nil, fmt.Errorf("解析OneBot账号配置失败: %v", err)
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
// ParseRuleConfig 解析 OneBot 规则配置
|
||||
func ParseRuleConfig(configJSON string) (*OneBotConfig, error) {
|
||||
var config OneBotConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析OneBot规则配置失败: %v", err)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// newClient 创建 OneBot HTTP 客户端
|
||||
func newClient(account *OneBotAccount) *napcat.Client {
|
||||
return napcat.NewClient(account.Token, account.URL)
|
||||
}
|
||||
|
||||
// 从 Rule 中获取 OneBotConfig
|
||||
func getOneBotConfigFromRule(rule *model.Rule) (*OneBotConfig, error) {
|
||||
var oneBotConfig OneBotConfig
|
||||
if err := json.Unmarshal([]byte(rule.ConfigJson), &oneBotConfig); err != nil {
|
||||
return nil, fmt.Errorf("解析OneBot规则配置失败: %w", err)
|
||||
}
|
||||
return &oneBotConfig, nil
|
||||
}
|
||||
|
||||
// 从 Rule 中获取 OneBotAccount
|
||||
func getOneBotAccountFromRule(rule *model.Rule) (*OneBotAccount, error) {
|
||||
var oneBotAccount OneBotAccount
|
||||
if err := db.LoadAccountConfig(rule.AccountID, &oneBotAccount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &oneBotAccount, nil
|
||||
}
|
||||
|
||||
// 发送一条群消息
|
||||
//
|
||||
// SDK 只返回传输层错误,这里自己解析 OneBot 的响应信封,
|
||||
// 否则 token 错误、群号不存在这类失败会被当成发送成功。
|
||||
// sendGroupMsg 发送一条群消息
|
||||
func sendGroupMsg(account *OneBotAccount, groupID int64, msg string) error {
|
||||
return call(account, "/send_group_msg", map[string]any{
|
||||
"group_id": groupID,
|
||||
@@ -42,7 +42,7 @@ func sendGroupMsg(account *OneBotAccount, groupID int64, msg string) error {
|
||||
})
|
||||
}
|
||||
|
||||
// 发送一条私聊消息
|
||||
// sendPrivateMsg 发送一条私聊消息
|
||||
func sendPrivateMsg(account *OneBotAccount, userID int64, msg string) error {
|
||||
return call(account, "/send_private_msg", map[string]any{
|
||||
"user_id": userID,
|
||||
@@ -50,6 +50,10 @@ func sendPrivateMsg(account *OneBotAccount, userID int64, msg string) error {
|
||||
})
|
||||
}
|
||||
|
||||
// call 调用一次 OneBot HTTP API。
|
||||
//
|
||||
// SDK 只返回传输层错误,这里自己解析 OneBot 的响应信封,
|
||||
// 否则 token 错误、群号不存在这类失败会被当成发送成功。
|
||||
func call(account *OneBotAccount, endpoint string, payload map[string]any) error {
|
||||
var resp apiResponse
|
||||
if err := newClient(account).DoRequest("POST", endpoint, payload, &resp); err != nil {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package rainyun
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
@@ -16,7 +15,7 @@ func (v *RainyunAccountConfigValidator) GetType() string { return AccountType }
|
||||
|
||||
func (v *RainyunAccountConfigValidator) Validate(config string) error {
|
||||
// 解析配置
|
||||
cfg, err := parseAccount(config)
|
||||
cfg, err := ParseAccountConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -28,16 +27,8 @@ func (v *RainyunAccountConfigValidator) Validate(config string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseAccount(config string) (*RainyunAccount, error) {
|
||||
var cfg RainyunAccount
|
||||
if err := json.Unmarshal([]byte(config), &cfg); err != nil {
|
||||
return nil, fmt.Errorf("解析雨云账号配置失败: %v", err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (v *RainyunAccountConfigValidator) Test(config string) error {
|
||||
cfg, err := parseAccount(config)
|
||||
cfg, err := ParseAccountConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -8,6 +8,12 @@ const (
|
||||
RuleTypeRunAway = "rainyun-runaway"
|
||||
)
|
||||
|
||||
// pageSize 拉取云服务器列表时每页的条数
|
||||
const pageSize = 20
|
||||
|
||||
// runAwayConfirmText 跑路规则要求用户输入的免责声明
|
||||
const runAwayConfirmText = "我已知晓"
|
||||
|
||||
type RainyunWorkOrderRule struct {
|
||||
Title string `json:"title"`
|
||||
Msg string `json:"msg"`
|
||||
|
||||
@@ -6,121 +6,147 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/retry"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
rain "github.com/ssdomei232/rainyun-go-sdk/v2/rainyun/common"
|
||||
"github.com/ssdomei232/rainyun-go-sdk/v2/rainyun/rcs"
|
||||
"github.com/ssdomei232/rainyun-go-sdk/v2/rainyun/rgs"
|
||||
"github.com/ssdomei232/rainyun-go-sdk/v2/rainyun/workorder"
|
||||
)
|
||||
|
||||
func SendRainyunWorkorderMsg(ctx context.Context, rule *model.Rule) error {
|
||||
rainyunConfig, err := getRainyunConfigFromRule(rule)
|
||||
if err != nil {
|
||||
return err
|
||||
// SendWorkOrder 用给定账号发送一条雨云工单
|
||||
func SendWorkOrder(ctx context.Context, account *RainyunAccount, config *RainyunWorkOrderRule) error {
|
||||
if err := retry.Do(ctx, func() error {
|
||||
return createWorkOrder(account, config)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("发送雨云工单失败: %w", err)
|
||||
}
|
||||
|
||||
rainyunAccount, err := getRainyunAccountFromRule(rule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = retry.Do(ctx, func() error {
|
||||
return sendRainyunWorkorderMsg(rainyunConfig, rainyunAccount)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendRainyunWorkorderMsg(rainyunRuleConfig *RainyunWorkOrderRule, rainyunAccount *RainyunAccount) error {
|
||||
client := rain.NewClient(rainyunAccount.APIKey)
|
||||
workorderClient := workorder.Client{Client: client}
|
||||
// createWorkOrder 调用一次工单接口
|
||||
func createWorkOrder(account *RainyunAccount, config *RainyunWorkOrderRule) error {
|
||||
workorderClient := workorder.Client{Client: rain.NewClient(account.APIKey)}
|
||||
|
||||
_, err := workorderClient.CreateWorkOrder(&workorder.CreateWorkerorderRequest{
|
||||
Content: rainyunRuleConfig.Msg,
|
||||
Content: config.Msg,
|
||||
IsAuthed: false,
|
||||
IsUrgent: 1,
|
||||
Title: rainyunRuleConfig.Title,
|
||||
Title: config.Title,
|
||||
Type: "reward",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
|
||||
func RainyunRunAway(ctx context.Context, rule *model.Rule) error {
|
||||
rainyunAccount, err := getRainyunAccountFromRule(rule)
|
||||
// RunAway 重装账号下所有云服务器(一键跑路)
|
||||
func RunAway(ctx context.Context, account *RainyunAccount) error {
|
||||
client := rain.NewClient(account.APIKey)
|
||||
|
||||
rcsFails, rcsTotal, err := reinstallAllRcs(ctx, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client := rain.NewClient(rainyunAccount.APIKey)
|
||||
|
||||
var fails []string
|
||||
total := 0
|
||||
|
||||
// 重装所有RCS
|
||||
rcsClient := rcs.Client{Client: client}
|
||||
options := rain.EncodingStandardQueryParameters(1, 20)
|
||||
rcsList, err := rcsClient.GetRcsList(options)
|
||||
rgsFails, rgsTotal, err := reinstallAllRgs(ctx, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rcsList.Data.TotalRecords == 0 {
|
||||
return nil
|
||||
} else if rcsList.Data.TotalRecords > 20 {
|
||||
rcsTotal := rcsList.Data.TotalRecords
|
||||
options := rain.EncodingStandardQueryParameters(1, rcsTotal)
|
||||
rcsList, err = rcsClient.GetRcsList(options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, rcs := range rcsList.Data.Records {
|
||||
total++
|
||||
err := retry.Do(context.Background(), func() error {
|
||||
return reinstallOneRCS(rcs.ID, &rcsClient)
|
||||
})
|
||||
if err != nil {
|
||||
fails = append(fails, fmt.Sprintf("RCS %d: %v", rcs.ID, err))
|
||||
}
|
||||
}
|
||||
|
||||
// 重装所有RGS
|
||||
rgsClient := rgs.Client{Client: client}
|
||||
rgsOptions := rain.EncodingStandardQueryParameters(1, 20)
|
||||
rgsList, err := rgsClient.GetRgsList(rgsOptions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rgsList.Data.TotalRecords == 0 {
|
||||
return nil
|
||||
} else if rgsList.Data.TotalRecords > 20 {
|
||||
rgsTotal := rgsList.Data.TotalRecords
|
||||
rgsOptions := rain.EncodingStandardQueryParameters(1, rgsTotal)
|
||||
rgsList, err = rgsClient.GetRgsList(rgsOptions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, rgs := range rgsList.Data.Records {
|
||||
total++
|
||||
err := retry.Do(context.Background(), func() error {
|
||||
return reinstallOneRGS(rgs.ID, &rgsClient)
|
||||
})
|
||||
if err != nil {
|
||||
fails = append(fails, fmt.Sprintf("RGS %d: %v", rgs.ID, err))
|
||||
}
|
||||
}
|
||||
|
||||
fails := append(rcsFails, rgsFails...)
|
||||
total := rcsTotal + rgsTotal
|
||||
if len(fails) > 0 {
|
||||
return fmt.Errorf("%d/%d 台云服务器重装失败: %s", len(fails), total, strings.Join(fails, "; "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// reinstallAllRcs 重装全部 RCS,返回失败信息与云服务器总数
|
||||
func reinstallAllRcs(ctx context.Context, client *rain.Client) ([]string, int, error) {
|
||||
rcsClient := rcs.Client{Client: client}
|
||||
|
||||
ids, err := listRcsIDs(&rcsClient)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var fails []string
|
||||
for _, id := range ids {
|
||||
if err := retry.Do(ctx, func() error {
|
||||
return reinstallOneRCS(id, &rcsClient)
|
||||
}); err != nil {
|
||||
fails = append(fails, fmt.Sprintf("RCS %d: %v", id, err))
|
||||
}
|
||||
}
|
||||
return fails, len(ids), nil
|
||||
}
|
||||
|
||||
// reinstallAllRgs 重装全部 RGS,返回失败信息与云服务器总数
|
||||
func reinstallAllRgs(ctx context.Context, client *rain.Client) ([]string, int, error) {
|
||||
rgsClient := rgs.Client{Client: client}
|
||||
|
||||
ids, err := listRgsIDs(&rgsClient)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var fails []string
|
||||
for _, id := range ids {
|
||||
if err := retry.Do(ctx, func() error {
|
||||
return reinstallOneRGS(id, &rgsClient)
|
||||
}); err != nil {
|
||||
fails = append(fails, fmt.Sprintf("RGS %d: %v", id, err))
|
||||
}
|
||||
}
|
||||
return fails, len(ids), nil
|
||||
}
|
||||
|
||||
// listRcsIDs 取账号下全部 RCS 的 ID,超过一页时重新按总数拉取
|
||||
func listRcsIDs(client *rcs.Client) ([]int, error) {
|
||||
first, err := client.GetRcsList(rain.EncodingStandardQueryParameters(1, pageSize))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids := make([]int, 0, len(first.Data.Records))
|
||||
for _, server := range first.Data.Records {
|
||||
ids = append(ids, server.ID)
|
||||
}
|
||||
if first.Data.TotalRecords <= pageSize {
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
all, err := client.GetRcsList(rain.EncodingStandardQueryParameters(1, first.Data.TotalRecords))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids = ids[:0]
|
||||
for _, server := range all.Data.Records {
|
||||
ids = append(ids, server.ID)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// listRgsIDs 取账号下全部 RGS 的 ID,超过一页时重新按总数拉取
|
||||
func listRgsIDs(client *rgs.Client) ([]int, error) {
|
||||
first, err := client.GetRgsList(rain.EncodingStandardQueryParameters(1, pageSize))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids := make([]int, 0, len(first.Data.Records))
|
||||
for _, server := range first.Data.Records {
|
||||
ids = append(ids, server.ID)
|
||||
}
|
||||
if first.Data.TotalRecords <= pageSize {
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
all, err := client.GetRgsList(rain.EncodingStandardQueryParameters(1, first.Data.TotalRecords))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids = ids[:0]
|
||||
for _, server := range all.Data.Records {
|
||||
ids = append(ids, server.ID)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package rainyun
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
@@ -15,9 +14,9 @@ func (v *RainyunWorkorderRuleValidator) GetType() string {
|
||||
}
|
||||
|
||||
func (v *RainyunWorkorderRuleValidator) Validate(configJSON string) error {
|
||||
var config RainyunWorkOrderRule
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return fmt.Errorf("解析雨云规则配置失败: %v", err)
|
||||
config, err := ParseWorkOrderConfig(configJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if config.Title == "" {
|
||||
@@ -51,12 +50,12 @@ func (v *RainyunRunAwayRuleValidator) GetType() string {
|
||||
}
|
||||
|
||||
func (v *RainyunRunAwayRuleValidator) Validate(configJSON string) error {
|
||||
var config RainyunRunAwayRule
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return fmt.Errorf("解析雨云规则配置失败: %v", err)
|
||||
config, err := ParseRunAwayConfig(configJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if config.IK != "我已知晓" {
|
||||
if config.IK != runAwayConfirmText {
|
||||
return fmt.Errorf("请输入正确内容")
|
||||
}
|
||||
|
||||
|
||||
@@ -2,22 +2,30 @@ package rainyun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// RainyunExecutor 雨云执行器
|
||||
// RainyunWorkorderExecutor 雨云工单执行器
|
||||
type RainyunWorkorderExecutor struct{}
|
||||
|
||||
func (e *RainyunWorkorderExecutor) GetType() string {
|
||||
return RuleTypeWorkOrder
|
||||
}
|
||||
|
||||
func (e *RainyunWorkorderExecutor) Execute(ctx context.Context, rule *model.Rule) error {
|
||||
log.Printf("执行雨云工单规则: %s", rule.Name)
|
||||
func (e *RainyunWorkorderExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
account, err := ParseAccountConfig(task.AccountConfig())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SendRainyunWorkorderMsg(ctx, rule)
|
||||
config, err := ParseWorkOrderConfig(task.Rule.ConfigJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SendWorkOrder(ctx, account, config)
|
||||
}
|
||||
|
||||
// RainyunRunAwayExecutor 雨云跑路执行器
|
||||
@@ -27,7 +35,20 @@ func (e *RainyunRunAwayExecutor) GetType() string {
|
||||
return RuleTypeRunAway
|
||||
}
|
||||
|
||||
func (e *RainyunRunAwayExecutor) Execute(ctx context.Context, rule *model.Rule) error {
|
||||
log.Printf("执行雨云跑路规则: %s", rule.Name)
|
||||
return RainyunRunAway(ctx, rule)
|
||||
func (e *RainyunRunAwayExecutor) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
account, err := ParseAccountConfig(task.AccountConfig())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 跑路规则只要求用户填一句免责声明,执行时重新校验一次
|
||||
config, err := ParseRunAwayConfig(task.Rule.ConfigJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if config.IK != runAwayConfirmText {
|
||||
return fmt.Errorf("请输入正确内容")
|
||||
}
|
||||
|
||||
return RunAway(ctx, account)
|
||||
}
|
||||
|
||||
@@ -4,31 +4,43 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/handler/db"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
"github.com/ssdomei232/rainyun-go-sdk/v2/rainyun/public"
|
||||
"github.com/ssdomei232/rainyun-go-sdk/v2/rainyun/rcs"
|
||||
"github.com/ssdomei232/rainyun-go-sdk/v2/rainyun/rgs"
|
||||
)
|
||||
|
||||
// 从 Rule 中获取 RainyunConfig
|
||||
func getRainyunConfigFromRule(rule *model.Rule) (*RainyunWorkOrderRule, error) {
|
||||
var rainyunConfig RainyunWorkOrderRule
|
||||
if err := json.Unmarshal([]byte(rule.ConfigJson), &rainyunConfig); err != nil {
|
||||
return nil, fmt.Errorf("解析Rainyun规则配置失败: %w", err)
|
||||
// ParseAccountConfig 解析雨云账号配置
|
||||
func ParseAccountConfig(config string) (*RainyunAccount, error) {
|
||||
if config == "" {
|
||||
return nil, fmt.Errorf("解析雨云账号配置失败: 规则没有关联账号")
|
||||
}
|
||||
return &rainyunConfig, nil
|
||||
|
||||
var account RainyunAccount
|
||||
if err := json.Unmarshal([]byte(config), &account); err != nil {
|
||||
return nil, fmt.Errorf("解析雨云账号配置失败: %v", err)
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
// 从 Rule 中获取 RainyunAccount
|
||||
func getRainyunAccountFromRule(rule *model.Rule) (*RainyunAccount, error) {
|
||||
var rainyunAccount RainyunAccount
|
||||
if err := db.LoadAccountConfig(rule.AccountID, &rainyunAccount); err != nil {
|
||||
return nil, err
|
||||
// ParseWorkOrderConfig 解析雨云工单规则配置
|
||||
func ParseWorkOrderConfig(configJSON string) (*RainyunWorkOrderRule, error) {
|
||||
var config RainyunWorkOrderRule
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析雨云工单规则配置失败: %v", err)
|
||||
}
|
||||
return &rainyunAccount, nil
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// ParseRunAwayConfig 解析雨云跑路规则配置
|
||||
func ParseRunAwayConfig(configJSON string) (*RainyunRunAwayRule, error) {
|
||||
var config RainyunRunAwayRule
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("解析雨云跑路规则配置失败: %v", err)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// reinstallOneRCS 重装一台 RCS
|
||||
func reinstallOneRCS(rcsID int, rcsClient *rcs.Client) error {
|
||||
rcsDetail, err := rcsClient.GetRcsDetails(rcsID)
|
||||
if err != nil {
|
||||
@@ -42,20 +54,17 @@ func reinstallOneRCS(rcsID int, rcsClient *rcs.Client) error {
|
||||
|
||||
for _, os := range rcsOSList.Data {
|
||||
if os.Region == rcsDetail.Data.Data.Node.Region && os.OsType == "linux" {
|
||||
rcsReinstallRequest := &rcs.ReinstallRcsRequest{
|
||||
_, err := rcsClient.ReinstallRcs(rcsID, &rcs.ReinstallRcsRequest{
|
||||
OsID: os.ID,
|
||||
ResetOsd: true,
|
||||
}
|
||||
_, err := rcsClient.ReinstallRcs(rcsID, rcsReinstallRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
break
|
||||
})
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return fmt.Errorf("没有找到可用的 linux 镜像")
|
||||
}
|
||||
|
||||
// reinstallOneRGS 重装一台 RGS
|
||||
func reinstallOneRGS(rgsID int, rgsClient *rgs.Client) error {
|
||||
rgsDetail, err := rgsClient.GetRgsDetails(rgsID)
|
||||
if err != nil {
|
||||
@@ -69,16 +78,12 @@ func reinstallOneRGS(rgsID int, rgsClient *rgs.Client) error {
|
||||
|
||||
for _, os := range rgsOSList.Data {
|
||||
if os.Region == rgsDetail.Data.Data.Node.Region && os.OsType == "linux" {
|
||||
rgsReinstallRequest := &rcs.ReinstallRcsRequest{
|
||||
_, err := rgsClient.Reinstallgs(rgsID, &rcs.ReinstallRcsRequest{
|
||||
OsID: os.ID,
|
||||
ResetOsd: true,
|
||||
}
|
||||
_, err := rgsClient.Reinstallgs(rgsID, rgsReinstallRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
break
|
||||
})
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return fmt.Errorf("没有找到可用的 linux 镜像")
|
||||
}
|
||||
|
||||
@@ -1,33 +1,28 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// LoadAccountConfig 读取账号并把 Config 字段反序列化到 out。
|
||||
// GetAccount 读取一个账号,返回数据库里的原始记录。
|
||||
//
|
||||
// 各个 driver 之前都各自实现了一遍这段逻辑,其中 github driver 甚至
|
||||
// 直接把驱动内的结构体当成 gorm model 查询,这里统一收敛。
|
||||
func LoadAccountConfig(accountID uint, out any) error {
|
||||
// 规则执行需要的账号凭据由上层(handler/runner)在这里取好再传给 driver,
|
||||
// driver 自己不碰数据库。accountID 为 0 表示该规则不需要账号,返回 (nil, nil)。
|
||||
func GetAccount(accountID uint) (*model.Account, error) {
|
||||
if accountID == 0 {
|
||||
return fmt.Errorf("该规则没有关联账号")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
gormDB, err := GetGormDB()
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var account model.Account
|
||||
if err := gormDB.First(&account, accountID).Error; err != nil {
|
||||
return fmt.Errorf("获取账号(ID: %d)失败: %w", accountID, err)
|
||||
return nil, fmt.Errorf("获取账号(ID: %d)失败: %w", accountID, err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(account.Config), out); err != nil {
|
||||
return fmt.Errorf("解析账号(ID: %d)配置失败: %w", accountID, err)
|
||||
}
|
||||
return nil
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ func MustInit() *gorm.DB {
|
||||
if err := ensureAdmin(gormDB); err != nil {
|
||||
log.Fatalf("初始化管理员失败: %v", err)
|
||||
}
|
||||
if err := migrateGatewayRules(gormDB); err != nil {
|
||||
log.Fatalf("迁移消息网关规则失败: %v", err)
|
||||
}
|
||||
return gormDB
|
||||
}
|
||||
|
||||
@@ -136,7 +139,9 @@ func AutoMigrate(gormDB *gorm.DB) error {
|
||||
&model.User{},
|
||||
&model.Timer{},
|
||||
&model.Rule{},
|
||||
&model.GatewayRule{},
|
||||
&model.Account{},
|
||||
&model.ExecutionLog{},
|
||||
&model.MessageGateway{},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// legacyRule 是迁移前的 rules 表结构,只用来读出已经废弃的 gateway_id
|
||||
type legacyRule struct {
|
||||
ID uint
|
||||
UID uint
|
||||
GatewayID uint
|
||||
Name string
|
||||
AccountID uint
|
||||
Type string
|
||||
ConfigJson string
|
||||
Enabled bool
|
||||
CreateAt int64
|
||||
}
|
||||
|
||||
// migrateGatewayRules 把历史数据里混在 rules 表中的消息网关规则搬到 gateway_rules 表。
|
||||
//
|
||||
// v2.1 之前消息网关规则和定时器规则共用 rules 表,靠 gateway_id 区分;
|
||||
// 现在两者分表存储,这里做一次性搬迁,避免老用户升级后规则凭空消失。
|
||||
func migrateGatewayRules(gormDB *gorm.DB) error {
|
||||
if !gormDB.Migrator().HasColumn("rules", "gateway_id") {
|
||||
return nil
|
||||
}
|
||||
|
||||
var legacy []legacyRule
|
||||
if err := gormDB.Table("rules").Where("gateway_id <> 0").Find(&legacy).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(legacy) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return gormDB.Transaction(func(tx *gorm.DB) error {
|
||||
for _, rule := range legacy {
|
||||
moved := model.GatewayRule{
|
||||
UID: rule.UID,
|
||||
GatewayID: rule.GatewayID,
|
||||
Name: rule.Name,
|
||||
AccountID: rule.AccountID,
|
||||
Type: rule.Type,
|
||||
ConfigJson: rule.ConfigJson,
|
||||
Enabled: rule.Enabled,
|
||||
CreateAt: rule.CreateAt,
|
||||
}
|
||||
if err := tx.Create(&moved).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec("DELETE FROM rules WHERE id = ?", rule.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
log.Printf("已迁移 %d 条消息网关规则到 gateway_rules 表", len(legacy))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -9,10 +9,13 @@ import (
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// RuleExecutor 规则执行器接口
|
||||
// RuleExecutor 规则执行器接口。
|
||||
//
|
||||
// 执行一条规则需要的一切(规则本体、账号凭据)都由上层组装成 model.RuleTask,
|
||||
// driver 只负责把动作做出去:不查数据库、不写日志,因此每个实现都能单独测试。
|
||||
type RuleExecutor interface {
|
||||
// Execute 执行规则,ctx 决定了重试的最长时间
|
||||
Execute(ctx context.Context, rule *model.Rule) error
|
||||
Execute(ctx context.Context, task *model.RuleTask) error
|
||||
// GetType 获取执行器支持的规则类型
|
||||
GetType() string
|
||||
}
|
||||
@@ -35,13 +38,13 @@ func (er *ExecutorRegistry) Register(executor RuleExecutor) {
|
||||
}
|
||||
|
||||
// Execute 根据规则类型执行规则
|
||||
func (er *ExecutorRegistry) Execute(ctx context.Context, rule *model.Rule) error {
|
||||
executor, exists := er.executors[rule.Type]
|
||||
func (er *ExecutorRegistry) Execute(ctx context.Context, task *model.RuleTask) error {
|
||||
executor, exists := er.executors[task.Rule.Type]
|
||||
if !exists {
|
||||
return fmt.Errorf("不支持的规则类型: %s", rule.Type)
|
||||
return fmt.Errorf("不支持的规则类型: %s", task.Rule.Type)
|
||||
}
|
||||
|
||||
return executor.Execute(ctx, rule)
|
||||
return executor.Execute(ctx, task)
|
||||
}
|
||||
|
||||
// GetSupportedTypes 获取所有支持的规则类型
|
||||
|
||||
@@ -53,7 +53,18 @@ func Runner(timer *model.Timer) {
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// ExecuteRule 执行单个规则并记录执行日志
|
||||
// NewTask 组装执行一条规则所需的输入:规则本体 + 从数据库取出的关联账号。
|
||||
//
|
||||
// 数据库访问只发生在这一层,driver 拿到的 task 已经是自包含的。
|
||||
func NewTask(rule *model.Rule) (*model.RuleTask, error) {
|
||||
account, err := db.GetAccount(rule.AccountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &model.RuleTask{Rule: rule, Account: account}, nil
|
||||
}
|
||||
|
||||
// ExecuteRule 执行单条规则并记录执行日志
|
||||
func ExecuteRule(rule *model.Rule, trigger string) error {
|
||||
ctx, cancel := retry.ExecutionContext()
|
||||
defer cancel()
|
||||
@@ -62,24 +73,54 @@ func ExecuteRule(rule *model.Rule, trigger string) error {
|
||||
|
||||
// ExecuteRuleWithContext 使用给定 context 执行规则,手动测试时可传入较短的超时
|
||||
func ExecuteRuleWithContext(ctx context.Context, rule *model.Rule, trigger string) error {
|
||||
err := GetGlobalExecutorRegistry().Execute(ctx, rule)
|
||||
|
||||
message := "执行成功"
|
||||
if err != nil {
|
||||
message = err.Error()
|
||||
log.Printf("执行规则失败 [ID: %d, Type: %s]: %v", rule.ID, rule.Type, err)
|
||||
}
|
||||
|
||||
logstore.Record(&model.ExecutionLog{
|
||||
task, err := NewTask(rule)
|
||||
return execTask(ctx, task, err, &model.ExecutionLog{
|
||||
UID: rule.UID,
|
||||
RuleID: rule.ID,
|
||||
RuleName: rule.Name,
|
||||
RuleType: rule.Type,
|
||||
TimerID: rule.TimerID,
|
||||
Trigger: trigger,
|
||||
Success: err == nil,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
// ExecuteGatewayRuleWithContext 执行一条消息网关规则,日志里带上网关信息。
|
||||
//
|
||||
// 与定时器规则走同一条执行链路,区别只有触发来源与日志字段。
|
||||
func ExecuteGatewayRuleWithContext(ctx context.Context, rule *model.GatewayRule, trigger string) error {
|
||||
task, err := NewTask(rule.AsRule())
|
||||
return execTask(ctx, task, err, &model.ExecutionLog{
|
||||
UID: rule.UID,
|
||||
RuleName: rule.Name,
|
||||
RuleType: rule.Type,
|
||||
GatewayID: rule.GatewayID,
|
||||
GatewayRuleID: rule.ID,
|
||||
Trigger: trigger,
|
||||
})
|
||||
}
|
||||
|
||||
// execTask 执行规则并写入执行日志。
|
||||
//
|
||||
// taskErr 是组装输入时的错误(例如账号已被删除):这时不会走到 driver,
|
||||
// 但同样要留下一条失败日志,方便用户在 WebUI 上看到原因。
|
||||
func execTask(ctx context.Context, task *model.RuleTask, taskErr error, entry *model.ExecutionLog) error {
|
||||
if taskErr != nil {
|
||||
return record(entry, taskErr)
|
||||
}
|
||||
|
||||
log.Printf("执行规则 [%s] %s (触发来源: %s)", entry.RuleType, entry.RuleName, entry.Trigger)
|
||||
return record(entry, GetGlobalExecutorRegistry().Execute(ctx, task))
|
||||
}
|
||||
|
||||
// record 写入执行日志并返回原始错误
|
||||
func record(entry *model.ExecutionLog, err error) error {
|
||||
entry.Success = err == nil
|
||||
entry.Message = "执行成功"
|
||||
if err != nil {
|
||||
entry.Message = err.Error()
|
||||
log.Printf("执行规则失败 [%s] %s: %v", entry.RuleType, entry.RuleName, err)
|
||||
}
|
||||
|
||||
logstore.Record(entry)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// Package gateway 定义消息网关的扩展接口与注册表。
|
||||
//
|
||||
// 与 drivers 的分工:driver 负责把一条规则执行出去(发邮件、发消息……),
|
||||
// gateway 负责把外部系统投递进来的消息派发给绑定在网关上的规则。
|
||||
// 新增一种网关只要实现 Gateway 并在 reg.go 里注册。
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// Message 是外部系统投递进来的一条消息
|
||||
type Message struct {
|
||||
Title string
|
||||
Content string
|
||||
}
|
||||
|
||||
// Result 是一次投递的汇总结果
|
||||
type Result struct {
|
||||
Total int `json:"total"`
|
||||
Failed []string `json:"failed"`
|
||||
}
|
||||
|
||||
// Task 是一次投递所需的全部输入:网关与待触发的规则都由上层从数据库取出
|
||||
type Task struct {
|
||||
Gateway *model.MessageGateway
|
||||
Rules []model.GatewayRule
|
||||
Message Message
|
||||
}
|
||||
|
||||
// Gateway 一种消息网关
|
||||
type Gateway interface {
|
||||
// GetType 获取网关类型标识
|
||||
GetType() string
|
||||
// Meta 返回给 WebUI 展示用的元数据
|
||||
Meta() meta.GatewayMeta
|
||||
// Deliver 把投递进来的消息派发给 task 里的规则
|
||||
Deliver(ctx context.Context, task *Task) (*Result, error)
|
||||
}
|
||||
|
||||
// Registry 网关注册表
|
||||
type Registry struct {
|
||||
gateways map[string]Gateway
|
||||
}
|
||||
|
||||
// NewRegistry 创建新的网关注册表
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{
|
||||
gateways: make(map[string]Gateway),
|
||||
}
|
||||
}
|
||||
|
||||
// Register 注册消息网关
|
||||
func (r *Registry) Register(g Gateway) {
|
||||
r.gateways[g.GetType()] = g
|
||||
}
|
||||
|
||||
// Resolve 按类型取网关实现,类型为空时回退到默认类型
|
||||
func (r *Registry) Resolve(gatewayType string) (Gateway, bool) {
|
||||
if gatewayType == "" {
|
||||
gatewayType = model.GatewayTypeWebhook
|
||||
}
|
||||
|
||||
g, exists := r.gateways[gatewayType]
|
||||
return g, exists
|
||||
}
|
||||
|
||||
// Deliver 按网关类型派发一次投递
|
||||
func (r *Registry) Deliver(ctx context.Context, gatewayType string, task *Task) (*Result, error) {
|
||||
g, exists := r.Resolve(gatewayType)
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("不支持的消息网关类型: %s", gatewayType)
|
||||
}
|
||||
|
||||
return g.Deliver(ctx, task)
|
||||
}
|
||||
|
||||
// GetSupportedTypes 获取所有支持的网关类型
|
||||
func (r *Registry) GetSupportedTypes() []string {
|
||||
types := make([]string, 0, len(r.gateways))
|
||||
for t := range r.gateways {
|
||||
types = append(types, t)
|
||||
}
|
||||
sort.Strings(types)
|
||||
return types
|
||||
}
|
||||
|
||||
// Metas 返回所有网关类型的元数据,按类型名排序
|
||||
func (r *Registry) Metas() []meta.GatewayMeta {
|
||||
metas := make([]meta.GatewayMeta, 0, len(r.gateways))
|
||||
for _, g := range r.gateways {
|
||||
metas = append(metas, g.Meta())
|
||||
}
|
||||
sort.Slice(metas, func(i, j int) bool { return metas[i].Type < metas[j].Type })
|
||||
return metas
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package gateway
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
registry *Registry
|
||||
)
|
||||
|
||||
// InitGatewayRegistry 返回全局唯一的消息网关注册表
|
||||
func InitGatewayRegistry() *Registry {
|
||||
once.Do(func() {
|
||||
r := NewRegistry()
|
||||
|
||||
// 注册所有消息网关
|
||||
r.Register(&WebhookGateway{})
|
||||
// 未来添加新网关类型时,在这里注册即可
|
||||
|
||||
registry = r
|
||||
})
|
||||
return registry
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// NewToken 生成一个消息网关的访问 Token
|
||||
func NewToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "gw_" + hex.EncodeToString(b), nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ssdomei232/goodBaby/handler/runner"
|
||||
"github.com/ssdomei232/goodBaby/internal/meta"
|
||||
"github.com/ssdomei232/goodBaby/model"
|
||||
)
|
||||
|
||||
// messageFields 规则配置里可以被投递消息覆盖的字段名
|
||||
var messageFields = []string{"msg", "message", "body"}
|
||||
|
||||
// titleField 规则配置里可以被投递标题覆盖的字段名
|
||||
const titleField = "title"
|
||||
|
||||
// TestMessage 是「测试」网关规则时使用的示例投递内容,
|
||||
// 让用户在页面上就能看到消息被替换后的效果。
|
||||
var TestMessage = Message{
|
||||
Title: "goodBaby 测试标题",
|
||||
Content: "这是一条来自消息网关的测试消息",
|
||||
}
|
||||
|
||||
// WebhookGateway 默认网关:外部系统 POST 一条消息来触发规则
|
||||
type WebhookGateway struct{}
|
||||
|
||||
func (g *WebhookGateway) GetType() string {
|
||||
return model.GatewayTypeWebhook
|
||||
}
|
||||
|
||||
func (g *WebhookGateway) Meta() meta.GatewayMeta {
|
||||
return meta.GatewayMeta{
|
||||
Type: model.GatewayTypeWebhook,
|
||||
Label: "Webhook",
|
||||
Description: "为外部系统生成一个 Webhook 地址,POST 一条消息即可触发绑定在该网关上的规则。",
|
||||
Docs: "docs/gateway-config.md",
|
||||
PayloadHint: `{"title": "监控告警", "message": "服务已恢复"}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Deliver 逐条执行绑定在网关上的规则
|
||||
//
|
||||
// 单条规则失败不影响其它规则,失败原因汇总在 Result 里返回。
|
||||
func (g *WebhookGateway) Deliver(ctx context.Context, task *Task) (*Result, error) {
|
||||
// 初始化为空切片:nil 切片会被序列化成 JSON null,前端读 failed.length 时会报错
|
||||
failed := []string{}
|
||||
|
||||
for i := range task.Rules {
|
||||
rule := task.Rules[i]
|
||||
|
||||
executed := rule
|
||||
configJSON, applied, err := ApplyMessage(rule.ConfigJson, task.Message)
|
||||
if err != nil {
|
||||
failed = append(failed, rule.Name+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
if applied {
|
||||
// 用投递进来的消息执行,但不改动数据库里的规则配置
|
||||
executed.ConfigJson = configJSON
|
||||
}
|
||||
if err := runner.ExecuteGatewayRuleWithContext(ctx, &executed, model.TriggerGateway); err != nil {
|
||||
failed = append(failed, rule.Name+": "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
return &Result{Total: len(task.Rules), Failed: failed}, nil
|
||||
}
|
||||
|
||||
// ApplyMessage 把投递进来的消息覆盖到规则配置的消息字段上。
|
||||
//
|
||||
// 返回覆盖后的配置;applied 为 false 表示配置里没有可覆盖的消息字段
|
||||
// (例如「公开 GitHub 仓库」「删除 DNS 记录」),此时按规则里保存的配置执行。
|
||||
func ApplyMessage(configJSON string, msg Message) (string, bool, error) {
|
||||
var config map[string]any
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return configJSON, false, fmt.Errorf("解析规则配置失败: %v", err)
|
||||
}
|
||||
|
||||
matched := false
|
||||
for _, key := range messageFields {
|
||||
if _, ok := config[key]; ok {
|
||||
if msg.Content != "" {
|
||||
config[key] = msg.Content
|
||||
}
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
|
||||
if msg.Title != "" {
|
||||
if _, ok := config[titleField]; ok {
|
||||
config[titleField] = msg.Title
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
|
||||
if !matched {
|
||||
return configJSON, false, nil
|
||||
}
|
||||
|
||||
out, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return configJSON, false, fmt.Errorf("生成规则配置失败: %v", err)
|
||||
}
|
||||
return string(out), true, nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package meta
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// GatewayMessagePlaceholder 是「由投递内容提供」的字段占位值。
|
||||
//
|
||||
// 这些字段在「网关规则」页面不由用户填写,真实内容来自 webhook 请求;
|
||||
// 但规则配置里必须保留该字段,投递时才知道要覆盖哪一个。
|
||||
const GatewayMessagePlaceholder = "由消息网关投递内容替换"
|
||||
|
||||
// GatewayMessageFields 返回所有由投递内容提供的字段
|
||||
func GatewayMessageFields(fields []Field) []Field {
|
||||
matched := make([]Field, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
if f.GatewayMessage {
|
||||
matched = append(matched, f)
|
||||
}
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
// FillGatewayMessages 给配置里「由投递内容提供」的字段补上占位值。
|
||||
//
|
||||
// 网关规则提交的配置里通常没有 title / msg 这类字段(前端不让填),
|
||||
// 校验前先补齐,规则才会既通过校验、又能在投递时被覆盖。
|
||||
func FillGatewayMessages(configJSON string, fields []Field) string {
|
||||
messageFields := GatewayMessageFields(fields)
|
||||
if len(messageFields) == 0 || configJSON == "" {
|
||||
return configJSON
|
||||
}
|
||||
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal([]byte(configJSON), &raw); err != nil {
|
||||
return configJSON
|
||||
}
|
||||
|
||||
changed := false
|
||||
for _, f := range messageFields {
|
||||
if value, ok := raw[f.Key].(string); ok && value != "" {
|
||||
continue
|
||||
}
|
||||
raw[f.Key] = GatewayMessagePlaceholder
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return configJSON
|
||||
}
|
||||
|
||||
filled, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return configJSON
|
||||
}
|
||||
return string(filled)
|
||||
}
|
||||
@@ -26,8 +26,11 @@ type Field struct {
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
Help string `json:"help,omitempty"`
|
||||
// Secret 为 true 的字段在读取接口中会被掩码
|
||||
Secret bool `json:"secret,omitempty"`
|
||||
Default any `json:"default,omitempty"`
|
||||
Secret bool `json:"secret,omitempty"`
|
||||
// GatewayMessage 为 true 的字段由消息网关投递的内容提供(msg / title 之类):
|
||||
// 在「网关规则」页面不需要用户填写,投递时会被请求里的 message / title 替换
|
||||
GatewayMessage bool `json:"gateway_message,omitempty"`
|
||||
Default any `json:"default,omitempty"`
|
||||
}
|
||||
|
||||
// AccountMeta 账号类型的元数据
|
||||
@@ -51,6 +54,16 @@ type RuleMeta struct {
|
||||
Fields []Field `json:"fields"`
|
||||
}
|
||||
|
||||
// GatewayMeta 消息网关类型的元数据
|
||||
type GatewayMeta struct {
|
||||
Type string `json:"type"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Docs string `json:"docs,omitempty"`
|
||||
// PayloadHint WebUI 上展示的请求体示例
|
||||
PayloadHint string `json:"payload_hint,omitempty"`
|
||||
}
|
||||
|
||||
// SecretKeys 返回需要掩码的字段名
|
||||
func SecretKeys(fields []Field) []string {
|
||||
keys := make([]string, 0, len(fields))
|
||||
|
||||
@@ -31,6 +31,16 @@ func Do(ctx context.Context, op func() error) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Permanent 标记一个重试没有意义的错误(例如凭据无效、配置错误)
|
||||
//
|
||||
// driver 里凡是重试也救不回来的失败都包一层,避免白白退避几个小时。
|
||||
func Permanent(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return backoff.Permanent(err)
|
||||
}
|
||||
|
||||
// ExecutionTimeout 返回配置中的规则执行超时时间
|
||||
func ExecutionTimeout() time.Duration {
|
||||
config, err := configs.GetConfig()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 需要认证的路由组
|
||||
@@ -104,6 +106,15 @@ func main() {
|
||||
rules.DELETE("/:ruleID", rule.HandleDeleteRule)
|
||||
}
|
||||
|
||||
gatewayRules := authorized.Group("/gateway-rules")
|
||||
{
|
||||
gatewayRules.GET("/", rule.HandleGetAllGatewayRules)
|
||||
gatewayRules.POST("/", rule.HandleCreateGatewayRule)
|
||||
gatewayRules.PUT("/:ruleID", rule.HandleEditGatewayRule)
|
||||
gatewayRules.POST("/:ruleID/test", rule.HandleTestGatewayRule)
|
||||
gatewayRules.DELETE("/:ruleID", rule.HandleDeleteGatewayRule)
|
||||
}
|
||||
|
||||
accounts := authorized.Group("/accounts")
|
||||
{
|
||||
accounts.GET("/", account.HandleGetAllAccounts)
|
||||
@@ -114,6 +125,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 +169,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,63 @@
|
||||
package model
|
||||
|
||||
// GatewayRule 是挂在消息网关上的通知规则。
|
||||
//
|
||||
// 与 Rule 的区别只有触发来源:Rule 由定时器到期触发,
|
||||
// GatewayRule 由外部系统投递到网关的消息触发,两者互不干扰。
|
||||
type GatewayRule struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UID uint `gorm:"index" json:"uid"`
|
||||
GatewayID uint `gorm:"index" json:"gateway_id"`
|
||||
Name string `json:"name"`
|
||||
AccountID uint `json:"account_id"`
|
||||
Type string `json:"type"`
|
||||
ConfigJson string `json:"config_json"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
}
|
||||
|
||||
// AsRule 把网关规则换成统一的执行结构。
|
||||
//
|
||||
// 执行层只认识 Rule:投递消息前把外部消息覆盖到 ConfigJson 上,
|
||||
// 再交给同一个 runner 执行即可。
|
||||
func (r *GatewayRule) AsRule() *Rule {
|
||||
return &Rule{
|
||||
UID: r.UID,
|
||||
Name: r.Name,
|
||||
AccountID: r.AccountID,
|
||||
Type: r.Type,
|
||||
ConfigJson: r.ConfigJson,
|
||||
Enabled: r.Enabled,
|
||||
CreateAt: r.CreateAt,
|
||||
}
|
||||
}
|
||||
|
||||
// GatewayRuleRequest 创建/编辑消息网关规则的请求体
|
||||
type GatewayRuleRequest struct {
|
||||
Name string `json:"name"`
|
||||
GatewayID uint `json:"gateway_id"`
|
||||
AccountID uint `json:"account_id"`
|
||||
Type string `json:"type"`
|
||||
ConfigJson string `json:"config_json"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// Validate 校验网关规则请求中与类型无关的通用部分
|
||||
func (r *GatewayRuleRequest) Validate() error {
|
||||
if r.Name == "" {
|
||||
return ErrValidation("规则名称不能为空")
|
||||
}
|
||||
if len(r.Name) > 64 {
|
||||
return ErrValidation("规则名称过长")
|
||||
}
|
||||
if r.Type == "" {
|
||||
return ErrValidation("规则类型不能为空")
|
||||
}
|
||||
if r.GatewayID == 0 {
|
||||
return ErrValidation("规则必须关联一个消息网关")
|
||||
}
|
||||
if r.ConfigJson == "" {
|
||||
return ErrValidation("规则配置不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -2,9 +2,10 @@ package model
|
||||
|
||||
// 执行日志的触发来源
|
||||
const (
|
||||
TriggerTimer = "timer" // Timer 到期自动触发
|
||||
TriggerManual = "manual" // 用户在 WebUI 手动测试
|
||||
TriggerRemind = "remind" // 提醒通知
|
||||
TriggerTimer = "timer" // Timer 到期自动触发
|
||||
TriggerManual = "manual" // 用户在 WebUI 手动测试
|
||||
TriggerRemind = "remind" // 提醒通知
|
||||
TriggerGateway = "gateway" // 外部系统投递到消息网关触发
|
||||
)
|
||||
|
||||
// ExecutionLog 记录每一次规则执行/提醒的结果,供 WebUI 展示
|
||||
@@ -15,8 +16,11 @@ type ExecutionLog struct {
|
||||
RuleName string `json:"rule_name"`
|
||||
RuleType string `json:"rule_type"`
|
||||
TimerID uint `json:"timer_id"`
|
||||
Trigger string `json:"trigger"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
CreateAt int64 `gorm:"index" json:"create_at"`
|
||||
// GatewayID / GatewayRuleID 只有消息网关投递触发时才有值
|
||||
GatewayID uint `json:"gateway_id"`
|
||||
GatewayRuleID uint `json:"gateway_rule_id"`
|
||||
Trigger string `json:"trigger"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
CreateAt int64 `gorm:"index" json:"create_at"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package model
|
||||
|
||||
// GatewayTypeWebhook 默认的网关类型:外部系统 POST 一条消息来触发规则
|
||||
const GatewayTypeWebhook = "webhook"
|
||||
|
||||
type MessageGateway struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UID uint `gorm:"index" json:"uid"`
|
||||
Name string `json:"name"`
|
||||
// Type 网关类型,见 internal/gateway 注册表
|
||||
Type string `gorm:"default:webhook" json:"type"`
|
||||
Token string `gorm:"uniqueIndex;size:67" json:"token"`
|
||||
CreateAt int64 `json:"create_at"`
|
||||
}
|
||||
|
||||
type MessageGatewayRequest struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// Validate 校验网关请求中与类型无关的通用部分
|
||||
func (r *MessageGatewayRequest) Validate() error {
|
||||
if r.Name == "" {
|
||||
return ErrValidation("网关名称不能为空")
|
||||
}
|
||||
if len(r.Name) > 64 {
|
||||
return ErrValidation("网关名称过长")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -38,7 +38,7 @@ func (r *RuleRequest) Validate() error {
|
||||
return ErrValidation("规则类型不能为空")
|
||||
}
|
||||
if r.TimerID == 0 {
|
||||
return ErrValidation("必须关联一个 Timer")
|
||||
return ErrValidation("规则必须关联一个定时器")
|
||||
}
|
||||
if r.ConfigJson == "" {
|
||||
return ErrValidation("规则配置不能为空")
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package model
|
||||
|
||||
// RuleTask 是一次规则执行所需的全部输入。
|
||||
//
|
||||
// 规则本体与关联账号都由上层(handler/runner、消息网关)从数据库取出后传进来,
|
||||
// driver 只负责把动作做出去,所以底层实现里不会再出现任何数据库操作。
|
||||
type RuleTask struct {
|
||||
// Rule 规则本体,永远不会为空
|
||||
Rule *Rule
|
||||
// Account 规则关联的账号;该规则类型不需要账号时为 nil
|
||||
Account *Account
|
||||
}
|
||||
|
||||
// AccountConfig 返回关联账号的配置 JSON,没有关联账号时返回空字符串
|
||||
func (t *RuleTask) AccountConfig() string {
|
||||
if t == nil || t.Account == nil {
|
||||
return ""
|
||||
}
|
||||
return t.Account.Config
|
||||
}
|
||||
@@ -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 字符串),用于接收提醒
|
||||
|
||||
@@ -6,10 +6,14 @@ import type {
|
||||
AdminConfigResponse,
|
||||
DashboardOverview,
|
||||
ExecutionLog,
|
||||
GatewayRule,
|
||||
GatewayRuleRequest,
|
||||
LogPage,
|
||||
MessageGateway,
|
||||
Providers,
|
||||
Rule,
|
||||
RuleRequest,
|
||||
AccountDeleteImpact,
|
||||
SiteInfo,
|
||||
Timer,
|
||||
TimerRequest,
|
||||
@@ -65,13 +69,22 @@ export const ruleApi = {
|
||||
remove: (id: number) => api.delete<string>(`/rules/${id}`),
|
||||
}
|
||||
|
||||
export const gatewayRuleApi = {
|
||||
list: (gatewayId?: number) =>
|
||||
api.get<GatewayRule[]>('/gateway-rules/', gatewayId ? { gateway_id: gatewayId } : undefined),
|
||||
create: (body: GatewayRuleRequest) => api.post<GatewayRule>('/gateway-rules/', body),
|
||||
update: (id: number, body: GatewayRuleRequest) => api.put<GatewayRule>(`/gateway-rules/${id}`, body),
|
||||
test: (id: number) => api.post<string>(`/gateway-rules/${id}/test`),
|
||||
remove: (id: number) => api.delete<string>(`/gateway-rules/${id}`),
|
||||
}
|
||||
|
||||
export const accountApi = {
|
||||
list: (type?: string) =>
|
||||
api.get<Account[]>('/accounts/', type ? { type } : undefined),
|
||||
create: (body: AccountRequest) => api.post<Account>('/accounts/', body),
|
||||
update: (id: number, body: AccountRequest) => api.put<Account>(`/accounts/${id}`, body),
|
||||
test: (id: number) => api.post<string>(`/accounts/${id}/test`),
|
||||
checkDelete: (id: number) => api.get<Rule[]>(`/accounts/${id}/check`),
|
||||
checkDelete: (id: number) => api.get<AccountDeleteImpact>(`/accounts/${id}/check`),
|
||||
remove: (id: number) => api.delete<string>(`/accounts/${id}`),
|
||||
}
|
||||
|
||||
@@ -81,6 +94,12 @@ export const logApi = {
|
||||
clear: () => api.delete<string>('/logs/'),
|
||||
}
|
||||
|
||||
export const gatewayApi = {
|
||||
list: () => api.get<MessageGateway[]>('/gateways/'),
|
||||
create: (name: string, type: string) => api.post<MessageGateway>('/gateways/', { name, type }),
|
||||
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),
|
||||
|
||||
@@ -10,7 +10,8 @@ export interface UserInfo {
|
||||
create_at: number
|
||||
username: string
|
||||
is_admin: boolean
|
||||
dingtalk_config: string | null
|
||||
dingtalk_config: string | null
|
||||
api_key: string
|
||||
}
|
||||
|
||||
export interface Timer {
|
||||
@@ -48,6 +49,28 @@ export interface Rule {
|
||||
create_at: number
|
||||
}
|
||||
|
||||
/** 消息网关规则:由外部系统投递到网关的消息触发,与定时器规则分开存储 */
|
||||
export interface GatewayRule {
|
||||
id: number
|
||||
uid: number
|
||||
gateway_id: number
|
||||
name: string
|
||||
account_id: number
|
||||
type: string
|
||||
config_json: string
|
||||
enabled: boolean
|
||||
create_at: number
|
||||
}
|
||||
|
||||
export interface MessageGateway {
|
||||
id: number
|
||||
uid: number
|
||||
name: string
|
||||
type: string
|
||||
token: string
|
||||
create_at: number
|
||||
}
|
||||
|
||||
export interface RuleRequest {
|
||||
name: string
|
||||
timer_id: number
|
||||
@@ -57,6 +80,15 @@ export interface RuleRequest {
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export interface GatewayRuleRequest {
|
||||
name: string
|
||||
gateway_id: number
|
||||
account_id: number
|
||||
type: string
|
||||
config_json: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
id: number
|
||||
uid: number
|
||||
@@ -89,6 +121,8 @@ export interface MetaField {
|
||||
placeholder?: string
|
||||
help?: string
|
||||
secret?: boolean
|
||||
/** 由消息网关投递的请求内容提供,网关规则页面不需要用户填写 */
|
||||
gateway_message?: boolean
|
||||
default?: unknown
|
||||
}
|
||||
|
||||
@@ -110,9 +144,18 @@ export interface RuleMeta {
|
||||
fields: MetaField[]
|
||||
}
|
||||
|
||||
export interface GatewayMeta {
|
||||
type: string
|
||||
label: string
|
||||
description?: string
|
||||
docs?: string
|
||||
payload_hint?: string
|
||||
}
|
||||
|
||||
export interface Providers {
|
||||
accounts: AccountMeta[]
|
||||
rules: RuleMeta[]
|
||||
gateways: GatewayMeta[]
|
||||
}
|
||||
|
||||
export interface ExecutionLog {
|
||||
@@ -122,7 +165,9 @@ export interface ExecutionLog {
|
||||
rule_name: string
|
||||
rule_type: string
|
||||
timer_id: number
|
||||
trigger: 'timer' | 'manual' | 'remind'
|
||||
gateway_id: number
|
||||
gateway_rule_id: number
|
||||
trigger: 'timer' | 'manual' | 'remind' | 'gateway'
|
||||
success: boolean
|
||||
message: string
|
||||
create_at: number
|
||||
@@ -140,6 +185,8 @@ export interface DashboardOverview {
|
||||
enabled_timers: number
|
||||
triggered_count: number
|
||||
rule_count: number
|
||||
gateway_count: number
|
||||
gateway_rule_count: number
|
||||
account_count: number
|
||||
server_time: number
|
||||
recent_logs: ExecutionLog[]
|
||||
@@ -147,6 +194,12 @@ export interface DashboardOverview {
|
||||
urgent_seconds_left?: number
|
||||
}
|
||||
|
||||
/** 删除账号时会被一并删除的规则 */
|
||||
export interface AccountDeleteImpact {
|
||||
rules: Rule[]
|
||||
gateway_rules: GatewayRule[]
|
||||
}
|
||||
|
||||
export interface SiteInfo {
|
||||
name: string
|
||||
enable_registry: boolean
|
||||
|
||||
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,569 @@
|
||||
/* ============================================================
|
||||
妄想症 · Paranoia 主题(致敬篇)
|
||||
------------------------------------------------------------
|
||||
在 <html> 上挂 .paranoia 即启用,可与 .dark 叠加:
|
||||
|
||||
html.paranoia 现实 —— 白之章,近乎空白的纸色
|
||||
html.paranoia.dark 妄想 —— 黑之章,石蒜红与深渊
|
||||
|
||||
取色依据原作 PV 的用色习惯:
|
||||
石蒜红 #e0245e 彼岸花与零羽
|
||||
洛天依蓝 #66ccff 泠珞(也是 goodBaby 原本的品牌色)
|
||||
言和青 #7ee8e0 / 言和黄 #f2d16b 颜语的守护者与加害者两面
|
||||
============================================================ */
|
||||
|
||||
html.paranoia {
|
||||
/* 标题用衬线体,制造「档案 / 卷宗」的观感 */
|
||||
--pa-serif: 'Noto Serif SC', 'Source Han Serif SC', 'Songti SC', STSong, SimSun, serif;
|
||||
--pa-mono: ui-monospace, SFMono-Regular, Consolas, 'Courier New', monospace;
|
||||
--pa-crimson: #e0245e;
|
||||
--pa-crimson-soft: #f2568a;
|
||||
--pa-blue: #66ccff;
|
||||
--pa-cyan: #7ee8e0;
|
||||
--pa-yellow: #f2d16b;
|
||||
--pa-violet: #b98cff;
|
||||
/* 卡片不再是圆润的「气泡」,而是切边的纸片 */
|
||||
--gb-radius-card: 6px;
|
||||
}
|
||||
|
||||
/* ---------- 现实:白之章 ---------- */
|
||||
|
||||
html.paranoia:not(.dark) {
|
||||
--gb-primary: #b3163c;
|
||||
--gb-primary-deep: #8c112f;
|
||||
--gb-ink: #17121a;
|
||||
--gb-ink-soft: #2a1c25;
|
||||
--gb-bg: #f5f1ee;
|
||||
--gb-card: #fffdfc;
|
||||
--gb-shadow-card: 0 1px 2px rgb(90 24 48 / 0.05), 0 1px 10px rgb(90 24 48 / 0.05);
|
||||
--gb-shadow-hover: 0 10px 26px rgb(120 20 50 / 0.14);
|
||||
|
||||
--pa-line: rgb(179 22 60 / 0.16);
|
||||
--pa-line-soft: rgb(179 22 60 / 0.08);
|
||||
--pa-line-strong: rgb(179 22 60 / 0.36);
|
||||
--pa-glow: rgb(179 22 60 / 0.16);
|
||||
--pa-ghost: rgb(43 20 32 / 0.055);
|
||||
--pa-grain-opacity: 0.05;
|
||||
--pa-veil: rgb(255 253 252 / 0.72);
|
||||
|
||||
--el-color-primary: #b3163c;
|
||||
--el-color-primary-light-3: #c94f6d;
|
||||
--el-color-primary-light-5: #d98899;
|
||||
--el-color-primary-light-7: #e8b4bf;
|
||||
--el-color-primary-light-8: #f0cad2;
|
||||
--el-color-primary-light-9: #f8e7ea;
|
||||
--el-color-primary-dark-2: #8f1230;
|
||||
|
||||
--el-bg-color: #fffdfc;
|
||||
--el-bg-color-page: #f5f1ee;
|
||||
--el-bg-color-overlay: #fffdfc;
|
||||
--el-text-color-primary: #2b2028;
|
||||
--el-text-color-regular: #4d3f47;
|
||||
--el-text-color-secondary: #8a7880;
|
||||
--el-text-color-placeholder: #a8969e;
|
||||
--el-border-color: rgb(179 22 60 / 0.2);
|
||||
--el-border-color-light: rgb(179 22 60 / 0.14);
|
||||
--el-border-color-lighter: rgb(179 22 60 / 0.1);
|
||||
--el-border-color-extra-light: rgb(179 22 60 / 0.08);
|
||||
--el-fill-color: rgb(179 22 60 / 0.06);
|
||||
--el-fill-color-light: rgb(179 22 60 / 0.04);
|
||||
--el-fill-color-lighter: rgb(179 22 60 / 0.025);
|
||||
--el-fill-color-blank: #fffdfc;
|
||||
--el-mask-color: rgb(43 20 32 / 0.42);
|
||||
}
|
||||
|
||||
/* ---------- 妄想:黑之章 ---------- */
|
||||
|
||||
html.paranoia.dark {
|
||||
--gb-primary: #e0245e;
|
||||
/* 深底之上需要更亮的强调色才读得清 */
|
||||
--gb-primary-deep: #f2568a;
|
||||
--gb-ink: #100a12;
|
||||
--gb-ink-soft: #1d0f1b;
|
||||
--gb-bg: #08070c;
|
||||
--gb-card: #150f19;
|
||||
--gb-shadow-card: 0 1px 2px rgb(0 0 0 / 0.5);
|
||||
--gb-shadow-hover: 0 10px 30px rgb(0 0 0 / 0.55), 0 0 0 1px rgb(224 36 94 / 0.14);
|
||||
|
||||
--pa-line: rgb(224 36 94 / 0.24);
|
||||
--pa-line-soft: rgb(224 36 94 / 0.12);
|
||||
--pa-line-strong: rgb(224 36 94 / 0.5);
|
||||
--pa-glow: rgb(224 36 94 / 0.34);
|
||||
--pa-ghost: rgb(236 230 239 / 0.045);
|
||||
--pa-grain-opacity: 0.075;
|
||||
--pa-veil: rgb(10 8 14 / 0.72);
|
||||
|
||||
--el-color-primary: #e0245e;
|
||||
--el-color-primary-light-3: #e6507e;
|
||||
--el-color-primary-light-5: #ec7c9f;
|
||||
--el-color-primary-light-7: #f2a8bf;
|
||||
--el-color-primary-light-8: #f6bfd0;
|
||||
--el-color-primary-light-9: #2a1622;
|
||||
--el-color-primary-dark-2: #b31d4b;
|
||||
|
||||
--el-bg-color: #150f19;
|
||||
--el-bg-color-page: #08070c;
|
||||
--el-bg-color-overlay: #1b1220;
|
||||
--el-text-color-primary: #ece6ef;
|
||||
--el-text-color-regular: #cabfcb;
|
||||
--el-text-color-secondary: #9a8b98;
|
||||
--el-text-color-placeholder: #6f616d;
|
||||
--el-border-color: rgb(224 36 94 / 0.26);
|
||||
--el-border-color-light: rgb(224 36 94 / 0.18);
|
||||
--el-border-color-lighter: rgb(224 36 94 / 0.14);
|
||||
--el-border-color-extra-light: rgb(224 36 94 / 0.1);
|
||||
--el-fill-color: rgb(224 36 94 / 0.12);
|
||||
--el-fill-color-light: rgb(224 36 94 / 0.08);
|
||||
--el-fill-color-lighter: rgb(224 36 94 / 0.05);
|
||||
--el-fill-color-blank: #150f19;
|
||||
--el-mask-color: rgb(4 3 6 / 0.66);
|
||||
}
|
||||
|
||||
/* ---------- 底色:纸纹与深渊雾气 ---------- */
|
||||
|
||||
html.paranoia,
|
||||
html.paranoia body,
|
||||
html.paranoia #app {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
html.paranoia body {
|
||||
background-color: var(--gb-bg);
|
||||
background-image:
|
||||
radial-gradient(1100px 620px at 8% -12%, rgb(224 36 94 / 0.1), transparent 62%),
|
||||
radial-gradient(900px 520px at 102% 4%, rgb(102 204 255 / 0.07), transparent 58%),
|
||||
radial-gradient(760px 760px at 50% 118%, rgb(224 36 94 / 0.08), transparent 60%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
/* ---------- 排版 ---------- */
|
||||
|
||||
html.paranoia .page-header h2,
|
||||
html.paranoia .el-card__header,
|
||||
html.paranoia .el-dialog__title,
|
||||
html.paranoia h1,
|
||||
html.paranoia h2,
|
||||
html.paranoia h3,
|
||||
html.paranoia .pa-display {
|
||||
font-family: var(--pa-serif);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
html.paranoia .page-header h2 {
|
||||
font-weight: 700;
|
||||
/* 标题下压一道极细的红线,像卷宗上的分割线 */
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--pa-line);
|
||||
}
|
||||
|
||||
html.paranoia .mono,
|
||||
html.paranoia .stat-value,
|
||||
html.paranoia .countdown-num,
|
||||
html.paranoia .ring-pct {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ---------- 卡片:切边的纸片 ---------- */
|
||||
|
||||
html.paranoia .el-card {
|
||||
border: 1px solid var(--pa-line);
|
||||
box-shadow: var(--gb-shadow-card);
|
||||
background: var(--gb-card);
|
||||
}
|
||||
|
||||
html.paranoia .el-card__header {
|
||||
border-bottom: 1px solid var(--pa-line);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
html.paranoia:not(.dark) .el-card {
|
||||
background-image: linear-gradient(180deg, rgb(255 255 255 / 0.6), rgb(255 252 251 / 0.2));
|
||||
}
|
||||
|
||||
html.paranoia.dark .el-card {
|
||||
background-image: linear-gradient(180deg, rgb(224 36 94 / 0.05), transparent 42%);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
/* ---------- 按钮 ---------- */
|
||||
|
||||
html.paranoia .el-button {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
html.paranoia .el-button--primary {
|
||||
--el-button-text-color: #fff7fa;
|
||||
--el-button-hover-text-color: #fff;
|
||||
--el-button-active-text-color: #fff;
|
||||
--el-button-bg-color: var(--gb-primary);
|
||||
--el-button-border-color: var(--gb-primary);
|
||||
--el-button-hover-bg-color: var(--pa-crimson-soft);
|
||||
--el-button-hover-border-color: var(--pa-crimson-soft);
|
||||
letter-spacing: 0.08em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
html.paranoia.dark .el-button--primary {
|
||||
--el-button-text-color: #fff;
|
||||
}
|
||||
|
||||
html.paranoia .el-button--default:not(.is-disabled) {
|
||||
background: transparent;
|
||||
border-color: var(--pa-line);
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
html.paranoia .el-button--default:not(.is-disabled):hover {
|
||||
border-color: var(--pa-line-strong);
|
||||
color: var(--gb-primary-deep);
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
/* ---------- 表单与表格 ---------- */
|
||||
|
||||
html.paranoia .el-input__wrapper,
|
||||
html.paranoia .el-textarea__inner,
|
||||
html.paranoia .el-select__wrapper {
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
html.paranoia .el-table {
|
||||
--el-table-border-color: var(--pa-line-soft);
|
||||
--el-table-header-bg-color: transparent;
|
||||
--el-table-row-hover-bg-color: var(--el-fill-color-light);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
html.paranoia .el-table tr,
|
||||
html.paranoia .el-table th.el-table__cell {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
html.paranoia .el-table th.el-table__cell {
|
||||
font-family: var(--pa-mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
html.paranoia .el-tag {
|
||||
border-radius: 3px;
|
||||
font-family: var(--pa-mono);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
html.paranoia .el-dialog,
|
||||
html.paranoia .el-message-box {
|
||||
border: 1px solid var(--pa-line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
html.paranoia .el-dialog__header {
|
||||
border-bottom: 1px solid var(--pa-line-soft);
|
||||
margin-right: 0;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
html.paranoia .el-alert {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--pa-line);
|
||||
}
|
||||
|
||||
html.paranoia .el-empty__description {
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
html.paranoia .el-loading-mask {
|
||||
background-color: var(--pa-veil);
|
||||
}
|
||||
|
||||
/* 键盘焦点环改用石蒜红 */
|
||||
html.paranoia :focus-visible {
|
||||
outline: 2px solid var(--gb-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ---------- 滚动条 ---------- */
|
||||
|
||||
html.paranoia ::-webkit-scrollbar-thumb {
|
||||
background: rgb(224 36 94 / 0.35);
|
||||
}
|
||||
|
||||
html.paranoia ::-webkit-scrollbar-thumb:hover {
|
||||
background: rgb(224 36 94 / 0.55);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
主题通用零件
|
||||
============================================================ */
|
||||
|
||||
/* 章节记号:b(坠落之章)/ #(上升之章) */
|
||||
.pa-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-family: var(--pa-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 3px 6px;
|
||||
border: 1px solid var(--pa-line);
|
||||
border-radius: 3px;
|
||||
color: var(--el-text-color-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pa-mark em {
|
||||
font-style: normal;
|
||||
color: var(--gb-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pa-mark--plain {
|
||||
border-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 竖排的幽灵字,用作背景里的「妄想」 */
|
||||
.pa-ghost-text {
|
||||
position: absolute;
|
||||
writing-mode: vertical-rl;
|
||||
font-family: var(--pa-serif);
|
||||
font-weight: 700;
|
||||
color: var(--pa-ghost);
|
||||
letter-spacing: 0.3em;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 细红分割线 */
|
||||
.pa-rule {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--pa-line-strong);
|
||||
}
|
||||
|
||||
.pa-rule::before,
|
||||
.pa-rule::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--pa-line);
|
||||
}
|
||||
|
||||
/* 引文块 */
|
||||
.pa-quote {
|
||||
font-family: var(--pa-serif);
|
||||
line-height: 2;
|
||||
color: var(--el-text-color-regular);
|
||||
border-left: 2px solid var(--gb-primary);
|
||||
padding: 2px 0 2px 16px;
|
||||
}
|
||||
|
||||
/* 胶片感的图片:统一降饱和,hover 时回血 */
|
||||
.pa-film {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--gb-ink);
|
||||
}
|
||||
|
||||
.pa-film img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: saturate(0.72) contrast(1.06) brightness(0.94);
|
||||
transition:
|
||||
transform 0.6s var(--gb-ease),
|
||||
filter 0.6s var(--gb-ease);
|
||||
}
|
||||
|
||||
.pa-film::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(180deg, transparent 45%, rgb(8 6 10 / 0.78) 100%),
|
||||
radial-gradient(120% 90% at 50% 10%, transparent 40%, rgb(224 36 94 / 0.16) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pa-film:hover img {
|
||||
transform: scale(1.045);
|
||||
filter: saturate(1) contrast(1.04) brightness(1);
|
||||
}
|
||||
|
||||
/* ---------- 背景层 ---------- */
|
||||
|
||||
.pa-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 噪点:让大面积纯色不至于太平 */
|
||||
.pa-backdrop__grain {
|
||||
position: absolute;
|
||||
inset: -40px;
|
||||
opacity: var(--pa-grain-opacity);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
mix-blend-mode: overlay;
|
||||
}
|
||||
|
||||
/* 暗角 */
|
||||
.pa-backdrop__vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(130% 100% at 50% 40%, transparent 42%, rgb(6 4 9 / 0.5) 100%);
|
||||
}
|
||||
|
||||
html.paranoia:not(.dark) .pa-backdrop__vignette {
|
||||
background: radial-gradient(130% 100% at 50% 40%, transparent 55%, rgb(120 60 80 / 0.16) 100%);
|
||||
}
|
||||
|
||||
/* 缓慢下移的扫描光带 */
|
||||
.pa-backdrop__scan {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 34vh;
|
||||
background: linear-gradient(180deg, transparent, rgb(224 36 94 / 0.07) 45%, transparent);
|
||||
animation: pa-scan 26s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes pa-scan {
|
||||
0% {
|
||||
transform: translate3d(0, -40vh, 0);
|
||||
}
|
||||
100% {
|
||||
transform: translate3d(0, 120vh, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 石蒜花瓣 */
|
||||
.pa-backdrop__petals {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.pa-petal {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: var(--pa-x, 50%);
|
||||
width: calc(14px * var(--pa-scale, 1));
|
||||
height: calc(14px * var(--pa-scale, 1));
|
||||
opacity: 0;
|
||||
animation: pa-fall var(--pa-dur, 22s) linear var(--pa-delay, 0s) infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.pa-petal svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
animation: pa-spin calc(var(--pa-dur, 22s) / 5) ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
/* 深色底上的石蒜红要提亮一点才看得见 */
|
||||
html.paranoia.dark .pa-petal svg {
|
||||
filter: brightness(1.5) saturate(1.15);
|
||||
}
|
||||
|
||||
@keyframes pa-fall {
|
||||
0% {
|
||||
transform: translate3d(0, -14vh, 0);
|
||||
opacity: 0;
|
||||
}
|
||||
10% {
|
||||
opacity: var(--pa-opacity, 0.5);
|
||||
}
|
||||
85% {
|
||||
opacity: var(--pa-opacity, 0.5);
|
||||
}
|
||||
100% {
|
||||
transform: translate3d(var(--pa-drift, 40px), 112vh, 0);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pa-spin {
|
||||
from {
|
||||
transform: rotate(-24deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(24deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 呼吸的红色光晕,浮在角落 */
|
||||
.pa-orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(70px);
|
||||
animation: pa-breathe 16s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pa-breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.42;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1.12);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 皮肤切换时强制让新画面浮到最上层 ---------- */
|
||||
|
||||
html.vt-skin::view-transition-old(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
html.vt-skin::view-transition-new(root) {
|
||||
z-index: 2147483646;
|
||||
}
|
||||
|
||||
/* 不支持 View Transitions 的浏览器退化成整页淡变 */
|
||||
html.no-vt.paranoia body,
|
||||
html.no-vt.paranoia .el-card,
|
||||
html.no-vt.paranoia .el-table tr,
|
||||
html.no-vt.paranoia .aside,
|
||||
html.no-vt.paranoia .header,
|
||||
html.no-vt.paranoia .nav-item {
|
||||
transition:
|
||||
background-color 0.36s var(--gb-ease),
|
||||
border-color 0.36s var(--gb-ease),
|
||||
color 0.36s var(--gb-ease);
|
||||
}
|
||||
|
||||
/* ---------- 响应式 ---------- */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* 小屏只留四片花瓣,省电也省注意力 */
|
||||
.pa-backdrop__petals .pa-petal:nth-child(n + 5) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html.paranoia .page-header h2 {
|
||||
font-size: 19px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pa-petal,
|
||||
.pa-backdrop__scan,
|
||||
.pa-orb {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.pa-petal {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 妄想症主题的背景层:噪点、暗角、缓慢下移的扫描光带,
|
||||
* 以及像石蒜花瓣一样飘落的色片。
|
||||
*
|
||||
* 数值全部由下标推出来,刷新页面时不会跳到别的位置;
|
||||
* 小屏与「减少动效」偏好下会由 CSS 自动收敛。
|
||||
*/
|
||||
const PETAL_COUNT = 14
|
||||
|
||||
const petals = Array.from({ length: PETAL_COUNT }, (_, i) => {
|
||||
const r1 = ((i * 97) % 100) / 100
|
||||
const r2 = ((i * 53 + 17) % 100) / 100
|
||||
const r3 = ((i * 37 + 11) % 100) / 100
|
||||
|
||||
return {
|
||||
id: i,
|
||||
x: `${((i * 7.3 + 3.5) % 100).toFixed(2)}%`,
|
||||
drift: `${Math.round((r1 - 0.5) * 170)}px`,
|
||||
dur: `${(18 + r2 * 16).toFixed(1)}s`,
|
||||
delay: `${(-i * 1.9).toFixed(1)}s`,
|
||||
scale: (0.55 + r3 * 1.05).toFixed(2),
|
||||
opacity: (0.32 + r1 * 0.36).toFixed(2),
|
||||
color: i % 5 === 0 ? 'var(--pa-blue)' : i % 7 === 0 ? 'var(--pa-cyan)' : 'var(--pa-crimson)',
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pa-backdrop" aria-hidden="true">
|
||||
<div
|
||||
class="pa-orb"
|
||||
style="
|
||||
left: -6%;
|
||||
top: 12%;
|
||||
width: 42vw;
|
||||
height: 42vw;
|
||||
background: rgb(224 36 94 / 0.22);
|
||||
"
|
||||
/>
|
||||
<div
|
||||
class="pa-orb"
|
||||
style="
|
||||
right: -8%;
|
||||
bottom: 4%;
|
||||
width: 38vw;
|
||||
height: 38vw;
|
||||
background: rgb(102 204 255 / 0.14);
|
||||
animation-delay: -6s;
|
||||
"
|
||||
/>
|
||||
|
||||
<div class="pa-backdrop__scan" />
|
||||
<div class="pa-backdrop__grain" />
|
||||
|
||||
<span class="pa-ghost-text" style="right: 3%; top: 8%; font-size: 92px">妄想</span>
|
||||
<span class="pa-ghost-text" style="left: 1%; bottom: 6%; font-size: 58px">现实</span>
|
||||
|
||||
<div class="pa-backdrop__petals">
|
||||
<span
|
||||
v-for="p in petals"
|
||||
:key="p.id"
|
||||
class="pa-petal"
|
||||
:style="{
|
||||
'--pa-x': p.x,
|
||||
'--pa-drift': p.drift,
|
||||
'--pa-dur': p.dur,
|
||||
'--pa-delay': p.delay,
|
||||
'--pa-scale': p.scale,
|
||||
'--pa-opacity': p.opacity,
|
||||
}"
|
||||
>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 1.6c3.4 5.2 6.6 8.6 6.6 13.1 0 3.9-3 7.1-6.6 7.1S5.4 18.6 5.4 14.7C5.4 10.2 8.6 6.8 12 1.6z"
|
||||
:fill="p.color"
|
||||
/>
|
||||
<path d="M12 3.6v16.4" stroke="rgb(0 0 0 / 0.22)" stroke-width="0.6" fill="none" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="pa-backdrop__vignette" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,371 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { Check, MagicStick } from '@element-plus/icons-vue'
|
||||
import { skinOptions, useTheme, type ThemeSkin } from '@/composables/useTheme'
|
||||
|
||||
/**
|
||||
* 外观主题选择器。
|
||||
*
|
||||
* 用自绘的浮层而不是 el-popover:这样预览小样可以完全跟着皮肤走,
|
||||
* 也不必和 teleport 出来的 popper 抢样式。
|
||||
*/
|
||||
const { skin, isDark, setSkin } = useTheme()
|
||||
|
||||
const open = ref(false)
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
|
||||
const currentName = computed(() => skinOptions.find((o) => o.id === skin.value)?.name ?? '')
|
||||
|
||||
/** 每个皮肤的迷你预览配色,跟随当前明暗模式 */
|
||||
function previewOf(id: ThemeSkin) {
|
||||
const paranoia = id === 'paranoia'
|
||||
if (isDark.value) {
|
||||
return paranoia
|
||||
? { bg: '#0d0a11', side: '#1d0f1b', card: '#150f19', accent: '#e0245e', line: '#3a2130' }
|
||||
: { bg: '#101216', side: '#17191f', card: '#1a1d23', accent: '#66ccff', line: '#2b303a' }
|
||||
}
|
||||
return paranoia
|
||||
? { bg: '#f5f1ee', side: '#241a22', card: '#fffdfc', accent: '#b3163c', line: '#e4d3d8' }
|
||||
: { bg: '#f4f6f8', side: '#232630', card: '#ffffff', accent: '#3fa9e0', line: '#dde3e9' }
|
||||
}
|
||||
|
||||
async function pick(id: ThemeSkin, event: MouseEvent) {
|
||||
open.value = false
|
||||
if (id === skin.value) return
|
||||
await setSkin(id, event)
|
||||
}
|
||||
|
||||
function onDocumentClick(event: MouseEvent) {
|
||||
if (root.value && !root.value.contains(event.target as Node)) {
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') open.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onDocumentClick, true)
|
||||
document.addEventListener('keydown', onKeydown)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocumentClick, true)
|
||||
document.removeEventListener('keydown', onKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="skin-picker">
|
||||
<button
|
||||
class="icon-btn skin-btn"
|
||||
type="button"
|
||||
:aria-expanded="open"
|
||||
aria-haspopup="dialog"
|
||||
:title="`外观主题:${currentName}`"
|
||||
@click.stop="open = !open"
|
||||
>
|
||||
<el-icon :size="17"><MagicStick /></el-icon>
|
||||
<span class="skin-btn__pip" :class="skin" />
|
||||
</button>
|
||||
|
||||
<Transition name="skin-pop">
|
||||
<div v-if="open" class="skin-panel" role="dialog" aria-label="外观主题">
|
||||
<div class="skin-panel__head">
|
||||
<span class="skin-panel__title">外观主题</span>
|
||||
<span class="skin-panel__hint">Appearance</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-for="option in skinOptions"
|
||||
:key="option.id"
|
||||
type="button"
|
||||
class="skin-option"
|
||||
:class="{ 'is-active': option.id === skin }"
|
||||
@click.stop="pick(option.id, $event)"
|
||||
>
|
||||
<span
|
||||
class="skin-option__preview"
|
||||
:style="{
|
||||
background: previewOf(option.id).bg,
|
||||
borderColor: previewOf(option.id).line,
|
||||
}"
|
||||
>
|
||||
<i class="skin-option__side" :style="{ background: previewOf(option.id).side }" />
|
||||
<i class="skin-option__bar" :style="{ background: previewOf(option.id).accent }" />
|
||||
<i
|
||||
class="skin-option__card"
|
||||
:style="{
|
||||
background: previewOf(option.id).card,
|
||||
borderColor: previewOf(option.id).line,
|
||||
}"
|
||||
/>
|
||||
<i
|
||||
class="skin-option__card skin-option__card--alt"
|
||||
:style="{
|
||||
background: previewOf(option.id).card,
|
||||
borderColor: previewOf(option.id).line,
|
||||
}"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span class="skin-option__meta">
|
||||
<span class="skin-option__name">
|
||||
{{ option.name }}
|
||||
<el-icon v-if="option.id === skin" :size="14" class="skin-option__check">
|
||||
<Check />
|
||||
</el-icon>
|
||||
</span>
|
||||
<span class="skin-option__sub">{{ option.sub }}</span>
|
||||
<span class="skin-option__swatches">
|
||||
<i v-for="color in option.swatches" :key="color" :style="{ background: color }" />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="skin-panel__foot">
|
||||
<span>暗色模式由顶栏的月亮按钮切换</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.skin-picker {
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.skin-btn {
|
||||
position: relative;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--el-text-color-regular);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s var(--gb-ease);
|
||||
}
|
||||
|
||||
.skin-btn:hover {
|
||||
background: var(--el-fill-color);
|
||||
}
|
||||
|
||||
.skin-btn__pip {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
bottom: 5px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 2px;
|
||||
background: var(--gb-primary);
|
||||
box-shadow: 0 0 0 2px var(--gb-card);
|
||||
}
|
||||
|
||||
.skin-btn__pip.paranoia {
|
||||
background: #e0245e;
|
||||
}
|
||||
|
||||
/* ---------- 浮层 ---------- */
|
||||
|
||||
.skin-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
right: 0;
|
||||
z-index: 1600;
|
||||
width: 296px;
|
||||
padding: 14px;
|
||||
box-sizing: border-box;
|
||||
background: var(--el-bg-color-overlay);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 18px 44px rgb(16 12 24 / 0.22);
|
||||
}
|
||||
|
||||
html.paranoia .skin-panel {
|
||||
border-radius: 6px;
|
||||
border-color: var(--pa-line);
|
||||
box-shadow:
|
||||
0 18px 44px rgb(0 0 0 / 0.4),
|
||||
0 0 0 1px rgb(224 36 94 / 0.12);
|
||||
}
|
||||
|
||||
.skin-panel__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 0 2px 10px;
|
||||
}
|
||||
|
||||
.skin-panel__title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
html.paranoia .skin-panel__title {
|
||||
font-family: var(--pa-serif);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.skin-panel__hint {
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.skin-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 9px;
|
||||
margin-bottom: 6px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.2s var(--gb-ease),
|
||||
border-color 0.2s var(--gb-ease);
|
||||
}
|
||||
|
||||
html.paranoia .skin-option {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.skin-option:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.skin-option.is-active {
|
||||
border-color: var(--gb-primary);
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
/* ---------- 迷你预览 ---------- */
|
||||
|
||||
.skin-option__preview {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 62px;
|
||||
height: 44px;
|
||||
border: 1px solid;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html.paranoia .skin-option__preview {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.skin-option__side {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 15px;
|
||||
}
|
||||
|
||||
.skin-option__bar {
|
||||
position: absolute;
|
||||
left: 15px;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 7px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.skin-option__card {
|
||||
position: absolute;
|
||||
left: 22px;
|
||||
top: 13px;
|
||||
width: 32px;
|
||||
height: 11px;
|
||||
border: 1px solid;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.skin-option__card--alt {
|
||||
top: 28px;
|
||||
width: 22px;
|
||||
}
|
||||
|
||||
.skin-option__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.skin-option__name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.skin-option__check {
|
||||
color: var(--gb-primary);
|
||||
}
|
||||
|
||||
.skin-option__sub {
|
||||
font-size: 11px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.skin-option__swatches {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.skin-option__swatches i {
|
||||
width: 14px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.skin-panel__foot {
|
||||
padding: 6px 2px 0;
|
||||
border-top: 1px solid var(--el-border-color-extra-light);
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
line-height: 1.7;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
/* ---------- 浮层进出场 ---------- */
|
||||
|
||||
.skin-pop-enter-active,
|
||||
.skin-pop-leave-active {
|
||||
transition:
|
||||
opacity 0.18s var(--gb-ease),
|
||||
transform 0.22s var(--gb-ease);
|
||||
}
|
||||
|
||||
.skin-pop-enter-from,
|
||||
.skin-pop-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px) scale(0.97);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.skin-panel {
|
||||
width: min(84vw, 296px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,23 +1,85 @@
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'gb-theme'
|
||||
/** 明暗模式的存储键 */
|
||||
const DARK_STORAGE_KEY = 'gb-theme'
|
||||
/** 外观皮肤(主题)的存储键 */
|
||||
const SKIN_STORAGE_KEY = 'gb-skin'
|
||||
|
||||
/**
|
||||
* 可选皮肤:
|
||||
* - default:原有的晴空配色
|
||||
* - paranoia:致敬《妄想症 Paranoia》系列曲的外观主题
|
||||
*/
|
||||
export type ThemeSkin = 'default' | 'paranoia'
|
||||
|
||||
export interface SkinOption {
|
||||
id: ThemeSkin
|
||||
/** 皮肤名 */
|
||||
name: string
|
||||
/** 一句话说明 */
|
||||
sub: string
|
||||
/** 预览用的色板 */
|
||||
swatches: string[]
|
||||
}
|
||||
|
||||
export const skinOptions: SkinOption[] = [
|
||||
{
|
||||
id: 'default',
|
||||
name: '晴空',
|
||||
sub: 'goodBaby 默认外观',
|
||||
swatches: ['#66ccff', '#3fa9e0', '#232630'],
|
||||
},
|
||||
{
|
||||
id: 'paranoia',
|
||||
name: '妄想症',
|
||||
sub: 'Paranoia · 九重档案',
|
||||
swatches: ['#e0245e', '#7ee8e0', '#f2d16b'],
|
||||
},
|
||||
]
|
||||
|
||||
/** 全局共享的主题状态,模块级单例,任何组件读到的都是同一份 */
|
||||
const isDark = ref(false)
|
||||
const skin = ref<ThemeSkin>('default')
|
||||
const isParanoia = computed(() => skin.value === 'paranoia')
|
||||
|
||||
/** 把主题应用到 <html> 上 */
|
||||
function applyTheme(dark: boolean) {
|
||||
function readStorage(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key)
|
||||
} catch {
|
||||
// 隐私模式 / 禁用存储时静默降级为「本次会话内有效」
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeStorage(key: string, value: string) {
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
} catch {
|
||||
// 同上
|
||||
}
|
||||
}
|
||||
|
||||
/** 把明暗模式应用到 <html> 上 */
|
||||
function applyDark(dark: boolean) {
|
||||
document.documentElement.classList.toggle('dark', dark)
|
||||
isDark.value = dark
|
||||
}
|
||||
|
||||
/** 把皮肤应用到 <html> 上,配色覆盖写在 theme-paranoia.css 里 */
|
||||
function applySkin(next: ThemeSkin) {
|
||||
document.documentElement.classList.toggle('paranoia', next === 'paranoia')
|
||||
skin.value = next
|
||||
}
|
||||
|
||||
/**
|
||||
* 在应用启动时调用一次:恢复上次选择,没有记录时跟随系统。
|
||||
*/
|
||||
export function initTheme() {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
const savedDark = readStorage(DARK_STORAGE_KEY)
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
applyTheme(saved ? saved === 'dark' : prefersDark)
|
||||
applyDark(savedDark ? savedDark === 'dark' : prefersDark)
|
||||
|
||||
applySkin(readStorage(SKIN_STORAGE_KEY) === 'paranoia' ? 'paranoia' : 'default')
|
||||
|
||||
// 标记浏览器是否支持圆形揭示,供 CSS 决定要不要退化成淡变
|
||||
if (typeof document.startViewTransition !== 'function') {
|
||||
@@ -26,66 +88,113 @@ export function initTheme() {
|
||||
|
||||
// 用户没有手动选择过时,跟随系统变化
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
if (!localStorage.getItem(STORAGE_KEY)) {
|
||||
applyTheme(e.matches)
|
||||
if (!readStorage(DARK_STORAGE_KEY)) {
|
||||
applyDark(e.matches)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 用一次圆形揭示动画包住外观切换。
|
||||
*
|
||||
* 支持 View Transitions 时以点击处为圆心做圆形揭示;不支持(Firefox / Safari 旧版)
|
||||
* 或用户要求减少动效时直接切换。
|
||||
*
|
||||
* @param mutate 真正改变外观状态的函数
|
||||
* @param event 触发切换的点击事件,用来决定圆心
|
||||
* @param mode dark/light 沿用原有的进/退方向;skin 需要额外把新画面提到最上层
|
||||
*/
|
||||
async function reveal(
|
||||
mutate: () => void,
|
||||
event: MouseEvent | undefined,
|
||||
mode: 'dark' | 'light' | 'skin',
|
||||
) {
|
||||
const startViewTransition = document.startViewTransition?.bind(document)
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
if (!startViewTransition || reduceMotion) {
|
||||
mutate()
|
||||
return
|
||||
}
|
||||
|
||||
// 换皮肤时新旧画面可能同为暗色,靠这个类强制让新画面浮到上面
|
||||
if (mode === 'skin') {
|
||||
document.documentElement.classList.add('vt-skin')
|
||||
}
|
||||
|
||||
// 从点击点向外扩散;没有事件对象时从右上角(切换按钮所在位置)扩散
|
||||
const x = event?.clientX ?? window.innerWidth - 60
|
||||
const y = event?.clientY ?? 30
|
||||
// 半径取到最远的那个角,保证能覆盖整个视口
|
||||
const endRadius = Math.hypot(
|
||||
Math.max(x, window.innerWidth - x),
|
||||
Math.max(y, window.innerHeight - y),
|
||||
)
|
||||
|
||||
const transition = startViewTransition(mutate)
|
||||
|
||||
try {
|
||||
await transition.ready
|
||||
} catch {
|
||||
// 浏览器跳过了这次过渡(页面不可见、连续快速点击等)。
|
||||
// 此时外观已由 mutate 应用好,只是没有揭示动画。
|
||||
document.documentElement.classList.remove('vt-skin')
|
||||
return
|
||||
}
|
||||
|
||||
const clipPath = [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`]
|
||||
|
||||
// 切到深色时让新画面扩散进来;切回浅色时让旧画面收缩出去
|
||||
const pseudoElement =
|
||||
mode === 'light' ? '::view-transition-old(root)' : '::view-transition-new(root)'
|
||||
|
||||
document.documentElement.animate(
|
||||
{ clipPath: mode === 'light' ? [...clipPath].reverse() : clipPath },
|
||||
{
|
||||
duration: 480,
|
||||
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
pseudoElement,
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
await transition.finished
|
||||
} catch {
|
||||
// finished 在过渡被跳过时也会抛错,忽略即可
|
||||
}
|
||||
document.documentElement.classList.remove('vt-skin')
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
/**
|
||||
* 切换明暗主题。
|
||||
*
|
||||
* 支持 View Transitions 时,以点击处为圆心做一次圆形揭示动画;
|
||||
* 不支持(Firefox / Safari 旧版)或用户要求减少动效时直接切换。
|
||||
*/
|
||||
async function toggleTheme(event?: MouseEvent) {
|
||||
const next = !isDark.value
|
||||
const persist = () => {
|
||||
applyTheme(next)
|
||||
localStorage.setItem(STORAGE_KEY, next ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
// 绑定后再判空,TS 才能正确收窄这个可选 API
|
||||
const startViewTransition = document.startViewTransition?.bind(document)
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
if (!startViewTransition || reduceMotion) {
|
||||
persist()
|
||||
return
|
||||
}
|
||||
|
||||
// 以点击点为圆心;没有事件对象时从右上角(切换按钮所在位置)扩散
|
||||
const x = event?.clientX ?? window.innerWidth - 60
|
||||
const y = event?.clientY ?? 30
|
||||
// 半径取到最远的那个角,保证能覆盖整个视口
|
||||
const endRadius = Math.hypot(
|
||||
Math.max(x, window.innerWidth - x),
|
||||
Math.max(y, window.innerHeight - y),
|
||||
)
|
||||
|
||||
const transition = startViewTransition(persist)
|
||||
|
||||
try {
|
||||
await transition.ready
|
||||
} catch {
|
||||
// 浏览器跳过了这次过渡(页面不可见、连续快速点击等)。
|
||||
// 此时主题已经通过 persist() 应用好了,只是没有揭示动画,直接返回。
|
||||
return
|
||||
}
|
||||
|
||||
const clipPath = [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`]
|
||||
|
||||
document.documentElement.animate(
|
||||
// 切到深色时让新画面扩散进来;切回浅色时让旧画面收缩出去
|
||||
{ clipPath: next ? clipPath : [...clipPath].reverse() },
|
||||
{
|
||||
duration: 480,
|
||||
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
pseudoElement: next ? '::view-transition-new(root)' : '::view-transition-old(root)',
|
||||
await reveal(
|
||||
() => {
|
||||
applyDark(next)
|
||||
writeStorage(DARK_STORAGE_KEY, next ? 'dark' : 'light')
|
||||
},
|
||||
event,
|
||||
next ? 'dark' : 'light',
|
||||
)
|
||||
}
|
||||
|
||||
return { isDark, toggleTheme }
|
||||
/**
|
||||
* 切换外观皮肤。传入点击事件时以点击处为圆心做揭示动画。
|
||||
*/
|
||||
async function setSkin(next: ThemeSkin, event?: MouseEvent) {
|
||||
if (next === skin.value) return
|
||||
await reveal(
|
||||
() => {
|
||||
applySkin(next)
|
||||
writeStorage(SKIN_STORAGE_KEY, next)
|
||||
},
|
||||
event,
|
||||
'skin',
|
||||
)
|
||||
}
|
||||
|
||||
return { isDark, isParanoia, skin, toggleTheme, setSkin }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* 《妄想症 Paranoia》系列资料
|
||||
*
|
||||
* 致敬对象:DELA 作曲 / 雨狸作词 / Lune 等人曲绘的 VOCALOID 中文原创系列曲
|
||||
* (2015.11.28《一重加害》— 2017.03.17《九重现实》,共 475 天;
|
||||
* 2025.11.28 发布十周年纪念曲《十重告别》)。
|
||||
*
|
||||
* 曲绘、歌词与设定的著作权均归原作者所有,此处仅作主题化的介绍与引用。
|
||||
*/
|
||||
|
||||
import ch01 from '@/assets/paranoia/ch01.jpg'
|
||||
import ch02 from '@/assets/paranoia/ch02.jpg'
|
||||
import ch03 from '@/assets/paranoia/ch03.jpg'
|
||||
import ch04 from '@/assets/paranoia/ch04.jpg'
|
||||
import ch05 from '@/assets/paranoia/ch05.jpg'
|
||||
import ch06 from '@/assets/paranoia/ch06.jpg'
|
||||
import ch07 from '@/assets/paranoia/ch07.jpg'
|
||||
import ch08 from '@/assets/paranoia/ch08.jpg'
|
||||
import ch09 from '@/assets/paranoia/ch09.jpg'
|
||||
import ch10 from '@/assets/paranoia/ch10.jpg'
|
||||
|
||||
/** 章:乐曲在系列中的位置 */
|
||||
export type ParanoiaArc = '序曲' | '坠落之章' | '上升之章' | '十周年'
|
||||
|
||||
export interface ParanoiaChapter {
|
||||
/** 重数,0 表示序曲 */
|
||||
no: number
|
||||
/** 中文数字,如「一」 */
|
||||
numeral: string
|
||||
/** 曲名 */
|
||||
title: string
|
||||
arc: ParanoiaArc
|
||||
/** 乐谱记号:坠落之章为 b(降号),上升之章为 #(升号) */
|
||||
sigil: string
|
||||
/** 投稿日期 */
|
||||
date: string
|
||||
/** 演唱 */
|
||||
singers: string
|
||||
/** 视角角色 */
|
||||
voices: string
|
||||
/** 一句话剧情 */
|
||||
summary: string
|
||||
/** 章节简介 */
|
||||
detail: string
|
||||
/** 歌词摘录 */
|
||||
lyric: string
|
||||
/** 对应的妄想症条目 */
|
||||
delusion: string
|
||||
/** 临床描述 */
|
||||
note: string
|
||||
image: string
|
||||
}
|
||||
|
||||
/** 三重加害者、守护者、背叛者与第四面墙外之人 */
|
||||
export interface ParanoiaCharacter {
|
||||
name: string
|
||||
/** 虚拟歌手 */
|
||||
vocal: string
|
||||
role: string
|
||||
/** 主题色 */
|
||||
color: string
|
||||
/** 第二主题色,用于言语这种双重身份的角色 */
|
||||
colorAlt?: string
|
||||
/** 一句属于她的话 */
|
||||
line: string
|
||||
}
|
||||
|
||||
/** 妄想档案:系列中逐章出现的临床条目 */
|
||||
export interface ParanoiaDelusion {
|
||||
index: string
|
||||
term: string
|
||||
alias: string
|
||||
note: string
|
||||
chapter: string
|
||||
}
|
||||
|
||||
export const paranoiaChapters: ParanoiaChapter[] = [
|
||||
{
|
||||
no: 1,
|
||||
numeral: '一',
|
||||
title: '一重加害',
|
||||
arc: '坠落之章',
|
||||
sigil: 'b',
|
||||
date: '2015.11.28',
|
||||
singers: '洛天依 / 言和',
|
||||
voices: '被害者泠珞 · 加害者颜语',
|
||||
summary: '熟悉的街道上多了一个影子,被害妄想从此降临。',
|
||||
detail:
|
||||
'系列的开篇。以被害妄想为主题,由被害者泠珞、加害者颜语与背叛者零羽三人展开,剧情扑朔迷离,在留下巨大悬念的同时,也为最终的结局埋下伏笔。',
|
||||
lyric: '别对我说 你身后是谁在揣度\n别靠近我 你这无锁链的恶魔',
|
||||
delusion: '被害妄想',
|
||||
note: '患者往往处于恐惧状态而胡乱推理和分析,坚持自己受到迫害或伤害,如言语上的针对、嘲弄、跟踪及监听等。',
|
||||
image: ch01,
|
||||
},
|
||||
{
|
||||
no: 2,
|
||||
numeral: '二',
|
||||
title: '二重变革',
|
||||
arc: '坠落之章',
|
||||
sigil: 'b',
|
||||
date: '2015.12.12',
|
||||
singers: '洛天依 / 言和',
|
||||
voices: '被害者泠珞 · 加害者颜语',
|
||||
summary: '凶手遵从风声鹤唳者的妄想而来,自虚无中诞生。',
|
||||
detail:
|
||||
'第一作的故事后续与不同视角描述。被害者与加害者之间的斗争正式开始:「我正是接受你『会被杀死』的强烈夙愿而自虚无诞生。」',
|
||||
lyric: '这一重任扭曲泛滥的世界\n它可悲的明天就由我来终结',
|
||||
delusion: '影响妄想 · 逆',
|
||||
note: '患者认为自己的思维、情感与意志行为可以影响并支配外界的现实,即「心想事成」。',
|
||||
image: ch02,
|
||||
},
|
||||
{
|
||||
no: 3,
|
||||
numeral: '三',
|
||||
title: '三重爱恋',
|
||||
arc: '坠落之章',
|
||||
sigil: 'b',
|
||||
date: '2016.01.22',
|
||||
singers: '洛天依 / 言和',
|
||||
voices: '被害者泠珞 · 守护者颜语 · 潜意识泠珞',
|
||||
summary: '杀死幻想怪物之后,她被裁定防卫过当,于是开始了一场妄想中的恋爱。',
|
||||
detail:
|
||||
'盲目生活像陀螺一般旋转不停,动荡的视线引发不安。在梦魇深堕之处,她开始相信自己终于遇到了那个人。',
|
||||
lyric: '约会在吊死鬼开的乐园\n看糖果和孤独成就了饕餮',
|
||||
delusion: '情爱妄想',
|
||||
note: '又称 de Clerambault 症候群。病人会以为自己正和某人恋爱,或另一个人深爱着自己;幻想中的恋人甚至只是一个「魅影」。',
|
||||
image: ch03,
|
||||
},
|
||||
{
|
||||
no: 4,
|
||||
numeral: '四',
|
||||
title: '四重罪孽',
|
||||
arc: '坠落之章',
|
||||
sigil: 'b',
|
||||
date: '2016.03.26',
|
||||
singers: '洛天依 / 言和 / 乐正绫',
|
||||
voices: '潜意识泠珞 · 守护者颜语 · 背叛者零羽',
|
||||
summary: '潜意识泠珞觉醒,察觉一切皆妄想,并宣告自己犯下的重重罪孽。',
|
||||
detail:
|
||||
'表现挣扎的一章。加害者的复活、守护者准备为爱牺牲的觉悟、背叛者扑朔迷离的死因,都替之后的剧情留下伏笔。',
|
||||
lyric: '我犯下了重重罪孽 我到底是什么存在\n我是最恐怖的危害 能否从此逃开',
|
||||
delusion: '自罪妄想',
|
||||
note: '又称罪恶妄想。患者毫无根据地认为自己犯了严重错误和罪行,或将一件小事的错误无限夸大,以至于拒食或要求赎罪。',
|
||||
image: ch04,
|
||||
},
|
||||
{
|
||||
no: 5,
|
||||
numeral: '五',
|
||||
title: '五重空洞',
|
||||
arc: '坠落之章',
|
||||
sigil: 'b',
|
||||
date: '2016.04.28',
|
||||
singers: '洛天依 / 乐正绫',
|
||||
voices: '潜意识泠珞 · 背叛者零羽',
|
||||
summary: '妄想世界的夹缝里,彼岸花田中埋葬着背叛者零羽。',
|
||||
detail:
|
||||
'潜意识泠珞在彼岸花田中见到了埋葬在记忆深处的零羽,过去的种种记忆漫上心头。故事的真相揭起一角,过去与未来依旧扑朔迷离。',
|
||||
lyric: '你停留在我无法追逐的记忆彼岸\n永远不会再回来',
|
||||
delusion: '内心被揭露感',
|
||||
note: '又称被洞悉感。患者认为其内心的想法未经表达,别人就知道了;严重时表层意识可能被穿透,导致潜意识暴露在外。',
|
||||
image: ch05,
|
||||
},
|
||||
{
|
||||
no: 6,
|
||||
numeral: '六',
|
||||
title: '六重不忠',
|
||||
arc: '上升之章',
|
||||
sigil: '#',
|
||||
date: '2016.08.05',
|
||||
singers: '洛天依 / 言和 / 乐正绫',
|
||||
voices: '加害者 · 守护者 · 潜意识泠珞',
|
||||
summary: '守护者先被策反,又因誓言死在加害者刀下,系列由此转入上升之章。',
|
||||
detail:
|
||||
'从本曲开始,系列进入 # 之章(上升之章)。她先期待、她先许愿,身为镜像怎能妥协;而誓言与爱,最终以死亡兑现。',
|
||||
lyric: '再见吧终会相见 莫为此放弃明天\n在绝望之前 将我的誓言默念',
|
||||
delusion: '嫉妒妄想综合征',
|
||||
note: '又称奥赛罗综合征。患者坚信所爱之人不忠,即使有确切证据证明其并未背叛,怀疑的依据也捕风捉影。',
|
||||
image: ch06,
|
||||
},
|
||||
{
|
||||
no: 7,
|
||||
numeral: '七',
|
||||
title: '七重痼病',
|
||||
arc: '上升之章',
|
||||
sigil: '#',
|
||||
date: '2016.12.16',
|
||||
singers: '洛天依 / 言和 / 乐正绫',
|
||||
voices: '被害者泠珞 · 加害者颜语 · 零羽',
|
||||
summary: '加害者与潜意识同归于尽,她在白日的中心失去焦距。',
|
||||
detail:
|
||||
'第六作的后续。红石蒜在无人之地盛开,妄议着未来于是溃败。「如果谎言重复了千遍依然无法成为现实,那谎言应该如何生存下去呢?」',
|
||||
lyric: '世界已发出信号 停止临终前无止境祈祷\n我接受她不在的未来',
|
||||
delusion: '疑病妄想',
|
||||
note: '患者毫无根据地坚信自己患了某种严重躯体疾病或不治之症。严重时称为虚无幻想——「本人已经不存在,只剩下一个躯壳空壳了」。',
|
||||
image: ch07,
|
||||
},
|
||||
{
|
||||
no: 8,
|
||||
numeral: '八',
|
||||
title: '八重回归',
|
||||
arc: '上升之章',
|
||||
sigil: '#',
|
||||
date: '2017.03.10',
|
||||
singers: '洛天依 / 乐正绫',
|
||||
voices: '泠珞 · 零羽',
|
||||
summary: '一切妄想破灭之后回归现实,同时与过往和梦想告别。',
|
||||
detail:
|
||||
'睁开眼重新堕入平凡的世界,辗转枯燥的时间一天又一天。所有羁绊终归于平淡,誓言悉数臣服现实的安排——「无意义乃这个世界存在的唯一意义。」',
|
||||
lyric: '再见 再不见\n无比真实过的一切',
|
||||
delusion: '回归空白',
|
||||
note: '现实本身就需要和妄想区分开来。越痛苦,翅膀越充血,它们就越美丽——然后死亡就突然降临,只是轻飘飘地、轻飘飘地。',
|
||||
image: ch08,
|
||||
},
|
||||
{
|
||||
no: 9,
|
||||
numeral: '九',
|
||||
title: '九重现实',
|
||||
arc: '上升之章',
|
||||
sigil: '#',
|
||||
date: '2017.03.17',
|
||||
singers: '洛天依 / 乐正绫 · 言和和声',
|
||||
voices: '泠珞 · 零羽',
|
||||
summary: '完结作。故事的真正脉络,以及带着希望的未来。',
|
||||
detail:
|
||||
'从「与其生离,不如死别」到「面对凶险的今后」。不是自作多情,也不是阳光明媚,仅仅是空白的现实而已。白色,也许是可以着色的,也许是被擦除了色彩之后的。',
|
||||
lyric: '你的声音击破梦魇一重一重\n自最遥远的彼方呼唤我',
|
||||
delusion: '现实',
|
||||
note: '「我所描述的一切或许并非真实,然而我的感受绝无虚假。」绝无虚假。',
|
||||
image: ch09,
|
||||
},
|
||||
{
|
||||
no: 10,
|
||||
numeral: '十',
|
||||
title: '十重告别',
|
||||
arc: '十周年',
|
||||
sigil: '✳',
|
||||
date: '2025.11.28',
|
||||
singers: '洛天依 / 言和 / 乐正绫 / 双狐座',
|
||||
voices: '第四面墙外之人',
|
||||
summary: '第四面墙外之人回望整个系列,把苦痛与欢愉收纳成歌颂明天的力量。',
|
||||
detail:
|
||||
'十周年纪念曲。打破第四面墙的元叙事视角,带领角色、创作者与听众共同回望这一切:告别或是全新的起点,全在他者的一念之间。',
|
||||
lyric: '无论再见或再也不见 我们心声已然相遇见\n比十年更早之前 我便歌颂着明天',
|
||||
delusion: '告别',
|
||||
note: '载体或许会消失,但我们相遇过的回忆不会。无论是十年前还是十年后,都希望你能拥有属于自己的幸福。',
|
||||
image: ch10,
|
||||
},
|
||||
]
|
||||
|
||||
export const paranoiaCharacters: ParanoiaCharacter[] = [
|
||||
{
|
||||
name: '泠珞',
|
||||
vocal: '洛天依',
|
||||
role: '被害者',
|
||||
color: '#66ccff',
|
||||
line: '不承认那是命运,命运是被神抛弃的棋局。',
|
||||
},
|
||||
{
|
||||
name: '颜语',
|
||||
vocal: '言和',
|
||||
role: '加害者 / 守护者',
|
||||
color: '#f2d16b',
|
||||
colorAlt: '#7ee8e0',
|
||||
line: '如果就此离开,还要怎么加害。',
|
||||
},
|
||||
{
|
||||
name: '零羽',
|
||||
vocal: '乐正绫',
|
||||
role: '背叛者',
|
||||
color: '#e0245e',
|
||||
line: '背对着我一跃而下的女孩,在呼唤。',
|
||||
},
|
||||
{
|
||||
name: '双狐座',
|
||||
vocal: '第四面墙外之人',
|
||||
role: '元叙事视角',
|
||||
color: '#b98cff',
|
||||
line: '告别或是全新的起点,全在他者的一念之间。',
|
||||
},
|
||||
]
|
||||
|
||||
export const paranoiaDelusions: ParanoiaDelusion[] = [
|
||||
{
|
||||
index: '01',
|
||||
term: '被害妄想',
|
||||
alias: 'delusion of persecution',
|
||||
note: '处于恐惧状态而胡乱推理和分析,坚持自己受到迫害或伤害,如言语上的针对、嘲弄、跟踪及监听。',
|
||||
chapter: '一重加害',
|
||||
},
|
||||
{
|
||||
index: '02',
|
||||
term: '影响妄想',
|
||||
alias: 'delusion of influence',
|
||||
note: '认为自己的思维、情感、意志行为活动受到外界某种力量的支配、控制、操纵,不能自主。',
|
||||
chapter: '二重变革',
|
||||
},
|
||||
{
|
||||
index: '03',
|
||||
term: '影响妄想 · 逆',
|
||||
alias: 'reverse influence',
|
||||
note: '认为自己的思维与意志不论多么荒谬,都可以影响并支配外界的现实,即「心想事成」。',
|
||||
chapter: '二重变革',
|
||||
},
|
||||
{
|
||||
index: '04',
|
||||
term: '情爱妄想',
|
||||
alias: "de Clerambault's syndrome",
|
||||
note: '以为自己正和某人恋爱,或另一个人深爱着自己;幻想中的恋人甚至只是一个现实中并不存在的「魅影」。',
|
||||
chapter: '三重爱恋',
|
||||
},
|
||||
{
|
||||
index: '05',
|
||||
term: '自罪妄想',
|
||||
alias: 'delusion of guilt',
|
||||
note: '又称罪恶妄想。毫无根据地认为自己犯了严重错误和罪行,罪大恶极、死有余辜,应该受到惩罚。',
|
||||
chapter: '四重罪孽',
|
||||
},
|
||||
{
|
||||
index: '06',
|
||||
term: '内心被揭露感',
|
||||
alias: 'experience of being revealed',
|
||||
note: '又称被洞悉感。认为内心的想法未经表达别人就知道了;严重时表层意识被穿透,潜意识暴露在外。',
|
||||
chapter: '五重空洞',
|
||||
},
|
||||
{
|
||||
index: '07',
|
||||
term: '投射性认同',
|
||||
alias: 'projective identification',
|
||||
note: '诱导他人以一种限定的方式行动或做出反应的人际行为模式,接受者往往被迫成为投射者感受的储存室。',
|
||||
chapter: '五重空洞',
|
||||
},
|
||||
{
|
||||
index: '08',
|
||||
term: '嫉妒妄想',
|
||||
alias: 'Othello syndrome',
|
||||
note: '坚信所爱之人不贞,即使有确切证据也丝毫不动摇;思维推理荒谬,怀疑的依据捕风捉影。',
|
||||
chapter: '六重不忠',
|
||||
},
|
||||
{
|
||||
index: '09',
|
||||
term: '疑病妄想',
|
||||
alias: 'hypochondriacal delusion',
|
||||
note: '毫无根据地坚信自己患了某种严重躯体疾病或不治之症,反复检查也不能纠正其歪曲的信念。',
|
||||
chapter: '七重痼病',
|
||||
},
|
||||
{
|
||||
index: '10',
|
||||
term: '虚无幻想',
|
||||
alias: 'nihilistic delusion',
|
||||
note: '疑病妄想的重度形态:认为「本人已经不存在,只剩下一个躯壳空壳了」。',
|
||||
chapter: '七重痼病',
|
||||
},
|
||||
]
|
||||
|
||||
/** 《九重现实》中对妄想的五条描述 */
|
||||
export const paranoiaAxioms: string[] = [
|
||||
'妄想是一个根本性障碍。',
|
||||
'是慢性和终身频发的。',
|
||||
'妄想有逻辑构造,并且内部成立。',
|
||||
'妄想和普通逻辑推理不冲突,而且没有一般的行为异常。',
|
||||
'自身经历了对自我参考的高度理解——对别人来说不重要的事件,对他们来说极为重要。',
|
||||
]
|
||||
|
||||
/** 主题化引文,用于顶栏滚动字幕与仪表盘签文 */
|
||||
export const paranoiaQuotes: { text: string; from: string }[] = [
|
||||
{ text: '谎言重复一千次就变成真理。所以,现实也是这样诞生的。', from: '《一重加害》' },
|
||||
{ text: '梦魇中是谁背对着我纵身一跃,没说再见,也没有遗言。', from: '《一重加害》' },
|
||||
{ text: '这一重任扭曲泛滥的世界,它可悲的明天就由我来终结。', from: '《二重变革》' },
|
||||
{ text: '约会在吊死鬼开的乐园,看糖果和孤独成就了饕餮。', from: '《三重爱恋》' },
|
||||
{ text: '我犯下了重重罪孽,我到底是什么存在。', from: '《四重罪孽》' },
|
||||
{ text: '你停留在我无法追逐的记忆彼岸,永远不会再回来。', from: '《五重空洞》' },
|
||||
{ text: '再见吧终会相见,莫为此放弃明天。', from: '《六重不忠》' },
|
||||
{ text: '如果谎言重复了千遍依然无法成为现实,那谎言应该如何生存下去呢?', from: '《七重痼病》' },
|
||||
{ text: '无意义乃这个世界存在的唯一意义。', from: '《八重回归》' },
|
||||
{ text: '我所描述的一切或许并非真实,然而我的感受绝无虚假。', from: '《九重现实》' },
|
||||
{ text: '比十年更早之前,我便歌颂着明天。', from: '《十重告别》' },
|
||||
]
|
||||
|
||||
/** 曲目列表:同系列其他作品 */
|
||||
export const paranoiaSeries: { group: string; items: string[] }[] = [
|
||||
{ group: '序曲', items: ['《NOVA》'] },
|
||||
{
|
||||
group: '正篇',
|
||||
items: [
|
||||
'《一重加害》',
|
||||
'《二重变革》',
|
||||
'《三重爱恋》',
|
||||
'《四重罪孽》',
|
||||
'《五重空洞》',
|
||||
'《六重不忠》',
|
||||
'《七重痼病》',
|
||||
'《八重回归》',
|
||||
'《九重现实》',
|
||||
],
|
||||
},
|
||||
{
|
||||
group: '角色歌',
|
||||
items: ['《三重恋爱》', '《泠重乞愿》', '《零重祈愿》'],
|
||||
},
|
||||
{
|
||||
group: '外篇',
|
||||
items: ['《自攻自受》', '《妄想 Paranoia》', '《妄想 Reality》', '《十重告别》'],
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* 侧边栏章节谱:把应用的每个功能对应到系列的一重上。
|
||||
* 坠落之章用 b(降号),上升之章用 #(升号)——这也是原作分章的方式。
|
||||
*/
|
||||
export interface ParanoiaChapterMark {
|
||||
/** 章节记号 */
|
||||
sigil: string
|
||||
/** 展示用的重数 */
|
||||
label: string
|
||||
/** 曲名 */
|
||||
title: string
|
||||
}
|
||||
|
||||
export const paranoiaNavMarks: Record<string, ParanoiaChapterMark> = {
|
||||
dashboard: { sigil: '✳', label: '序曲', title: 'NOVA' },
|
||||
timers: { sigil: 'b', label: '一重', title: '一重加害' },
|
||||
rules: { sigil: 'b', label: '二重', title: '二重变革' },
|
||||
accounts: { sigil: 'b', label: '三重', title: '三重爱恋' },
|
||||
gateways: { sigil: 'b', label: '四重', title: '四重罪孽' },
|
||||
'gateway-rules': { sigil: 'b', label: '五重', title: '五重空洞' },
|
||||
logs: { sigil: '#', label: '六重', title: '六重不忠' },
|
||||
settings: { sigil: '#', label: '七重', title: '七重痼病' },
|
||||
logout: { sigil: '#', label: '八重', title: '八重回归' },
|
||||
paranoia: { sigil: '#', label: '九重', title: '九重现实' },
|
||||
}
|
||||
|
||||
export const paranoiaCredits = {
|
||||
title: '妄想症 Paranoia',
|
||||
span: '2015.11.28 — 2017.03.17 · 共 475 天',
|
||||
staff: [
|
||||
{ role: '作编曲', name: 'DELA' },
|
||||
{ role: '作词 / 策划', name: '雨狸' },
|
||||
{ role: '曲绘', name: 'Lune、YAL、雨湘雪' },
|
||||
{ role: 'PV', name: '一折起售、owen-z' },
|
||||
{ role: '混音', name: 'POiSON' },
|
||||
{ role: '调校', name: '花儿不哭、Digger、河谐、OQQ、流绪' },
|
||||
],
|
||||
notice:
|
||||
'本主题为界面外观层面的同人致敬,非官方合作。「妄想症 Paranoia」系列曲目、曲绘、歌词与设定的著作权均归原作者所有;此处仅作介绍与引用,不作任何商业用途。',
|
||||
}
|
||||
|
||||
export function chapterByNo(no: number): ParanoiaChapter | undefined {
|
||||
return paranoiaChapters.find((item) => item.no === no)
|
||||
}
|
||||
|
||||
/** 按日期轮换一条引文,让「今日签文」每天都不一样 */
|
||||
export function todaysQuote(date = new Date()) {
|
||||
const start = new Date(date.getFullYear(), 0, 0)
|
||||
const dayOfYear = Math.floor((date.getTime() - start.getTime()) / 86400000)
|
||||
return paranoiaQuotes[dayOfYear % paranoiaQuotes.length]
|
||||
}
|
||||
@@ -7,16 +7,22 @@ import {
|
||||
Timer as TimerIcon,
|
||||
Operation,
|
||||
User,
|
||||
Connection,
|
||||
Promotion,
|
||||
Document,
|
||||
Setting,
|
||||
SwitchButton,
|
||||
Moon,
|
||||
Sunny,
|
||||
Fold,
|
||||
Reading,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import LogoMark from '@/components/LogoMark.vue'
|
||||
import ThemePicker from '@/components/ThemePicker.vue'
|
||||
import ParanoiaBackdrop from '@/components/ParanoiaBackdrop.vue'
|
||||
import { paranoiaNavMarks } from '@/data/paranoia'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -33,11 +39,13 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
const menus = [
|
||||
const baseMenus = [
|
||||
{ path: '/dashboard', title: '仪表盘', icon: Odometer },
|
||||
{ path: '/timers', title: '定时器', icon: TimerIcon },
|
||||
{ path: '/rules', title: '规则', icon: Operation },
|
||||
{ path: '/accounts', title: '账号', icon: User },
|
||||
{ path: '/gateways', title: '消息网关', icon: Connection },
|
||||
{ path: '/gateway-rules', title: '网关规则', icon: Promotion },
|
||||
{ path: '/logs', title: '执行日志', icon: Document },
|
||||
{ path: '/settings', title: '设置', icon: Setting },
|
||||
]
|
||||
@@ -45,7 +53,33 @@ const menus = [
|
||||
const activePath = computed(() => `/${route.path.split('/')[1] ?? ''}`)
|
||||
|
||||
// 主题状态是全局单例,初始化在 main.ts 里完成
|
||||
const { isDark, toggleTheme } = useTheme()
|
||||
const { isDark, isParanoia, toggleTheme } = useTheme()
|
||||
|
||||
/** 妄想症皮肤下多出一个「妄想症」展柜,其余菜单不变 */
|
||||
const menus = computed(() =>
|
||||
isParanoia.value
|
||||
? [...baseMenus, { path: '/paranoia', title: '妄想症', icon: Reading }]
|
||||
: baseMenus,
|
||||
)
|
||||
|
||||
/** 每个功能对应的章节记号,只在妄想症皮肤下显示 */
|
||||
function markOf(path: string) {
|
||||
return paranoiaNavMarks[path.slice(1)]
|
||||
}
|
||||
|
||||
/** 当前所处章节,用于顶栏与侧边栏落款 */
|
||||
const currentMark = computed(() => markOf(activePath.value) ?? paranoiaNavMarks.dashboard)
|
||||
|
||||
// 展柜属于皮肤的一部分:皮肤关掉(含直接输入地址进入)就退回仪表盘
|
||||
watch(
|
||||
isParanoia,
|
||||
(on) => {
|
||||
if (!on && route.path.startsWith('/paranoia')) {
|
||||
router.push('/dashboard')
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function handleLogout() {
|
||||
await userStore.logout()
|
||||
@@ -55,7 +89,10 @@ async function handleLogout() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout">
|
||||
<div class="layout" :class="{ 'skin-paranoia': isParanoia }">
|
||||
<!-- 妄想症皮肤的背景层:噪点、暗角与飘落的石蒜花瓣 -->
|
||||
<ParanoiaBackdrop v-if="isParanoia" />
|
||||
|
||||
<!-- 移动端抽屉遮罩 -->
|
||||
<Transition name="fade">
|
||||
<div v-if="drawerOpen" class="drawer-mask" @click="drawerOpen = false" />
|
||||
@@ -67,7 +104,7 @@ async function handleLogout() {
|
||||
<div class="logo-mark"><LogoMark :size="22" /></div>
|
||||
<div class="logo-name">
|
||||
<span class="logo-text">goodBaby</span>
|
||||
<span class="logo-sub">摇篮系统</span>
|
||||
<span class="logo-sub">{{ isParanoia ? '妄想症 · PARANOIA' : '摇篮系统' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -80,12 +117,22 @@ async function handleLogout() {
|
||||
:class="{ active: activePath === item.path }"
|
||||
>
|
||||
<el-icon :size="17"><component :is="item.icon" /></el-icon>
|
||||
<span>{{ item.title }}</span>
|
||||
<span class="nav-text">{{ item.title }}</span>
|
||||
<span v-if="isParanoia" class="nav-mark mono">
|
||||
{{ markOf(item.path)?.sigil }}{{ markOf(item.path)?.label }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<div class="aside-footer">
|
||||
<div class="footer-quote">goodBaby v2</div>
|
||||
<template v-if="isParanoia">
|
||||
<div class="footer-chapter">
|
||||
<span class="footer-chapter__sigil mono">{{ currentMark?.sigil }} {{ currentMark?.label }}</span>
|
||||
<span class="footer-chapter__title">{{ currentMark?.title }}</span>
|
||||
</div>
|
||||
<div class="footer-quote">九重妄想 · 幸存者 goodBaby v2</div>
|
||||
</template>
|
||||
<div v-else class="footer-quote">goodBaby v2</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -97,8 +144,12 @@ async function handleLogout() {
|
||||
<el-icon :size="18"><Fold /></el-icon>
|
||||
</button>
|
||||
<div class="header-title">{{ route.meta.title ?? '' }}</div>
|
||||
<span v-if="isParanoia" class="pa-mark header-mark">
|
||||
<em>{{ currentMark?.sigil }}</em>{{ currentMark?.title }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<ThemePicker />
|
||||
<button
|
||||
class="icon-btn theme-btn"
|
||||
:title="isDark ? '切换到亮色' : '切换到暗色'"
|
||||
@@ -120,7 +171,8 @@ async function handleLogout() {
|
||||
<el-icon><Setting /></el-icon>设置
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided @click="handleLogout">
|
||||
<el-icon><SwitchButton /></el-icon>退出登录
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
{{ isParanoia ? '八重回归 · 退出' : '退出登录' }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
@@ -395,8 +447,116 @@ async function handleLogout() {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* ---------- 妄想症皮肤:侧边栏变成卷宗封皮 ---------- */
|
||||
|
||||
/* 背景层待在内容之下 */
|
||||
.layout.skin-paranoia {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.layout.skin-paranoia > .content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.layout.skin-paranoia .aside {
|
||||
background:
|
||||
linear-gradient(180deg, rgb(224 36 94 / 0.18) 0%, transparent 36%),
|
||||
linear-gradient(180deg, var(--gb-ink) 0%, var(--gb-ink-soft) 100%);
|
||||
border-right: 1px solid rgb(224 36 94 / 0.18);
|
||||
}
|
||||
|
||||
.layout.skin-paranoia .logo-mark {
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(135deg, #e0245e 0%, #7a0f2c 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 16px rgb(224 36 94 / 0.45);
|
||||
}
|
||||
|
||||
.layout.skin-paranoia .user-avatar {
|
||||
background: linear-gradient(135deg, #e0245e 0%, #7a2ea0 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.layout.skin-paranoia .logo-text {
|
||||
font-family: var(--pa-serif);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.layout.skin-paranoia .logo-sub {
|
||||
color: rgb(242 86 138 / 0.75);
|
||||
font-family: var(--pa-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
.layout.skin-paranoia .nav-item {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.layout.skin-paranoia .nav-item:hover {
|
||||
background: rgb(224 36 94 / 0.1);
|
||||
border-color: rgb(224 36 94 / 0.2);
|
||||
}
|
||||
|
||||
.layout.skin-paranoia .nav-item.active {
|
||||
background: linear-gradient(135deg, #e0245e 0%, #8f1230 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 16px rgb(224 36 94 / 0.4);
|
||||
}
|
||||
|
||||
/* 每一项右侧标出它对应的「重数」 */
|
||||
.layout.skin-paranoia .nav-mark {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.layout.skin-paranoia .nav-item.active .nav-mark {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.layout.skin-paranoia .footer-chapter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 0 0 10px;
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 1px solid rgb(224 36 94 / 0.2);
|
||||
}
|
||||
|
||||
.layout.skin-paranoia .footer-chapter__sigil {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.16em;
|
||||
color: rgb(242 86 138 / 0.85);
|
||||
}
|
||||
|
||||
.layout.skin-paranoia .footer-chapter__title {
|
||||
font-family: var(--pa-serif);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
color: rgb(255 255 255 / 0.88);
|
||||
}
|
||||
|
||||
.layout.skin-paranoia .header-mark {
|
||||
margin-left: 4px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* ---------- 移动端 ---------- */
|
||||
|
||||
@media (min-width: 769px) {
|
||||
/* 桌面端侧边栏常驻,压到背景层上面 */
|
||||
.layout.skin-paranoia > .aside {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* 侧边栏脱离文档流,变成从左侧滑出的抽屉 */
|
||||
.aside {
|
||||
@@ -447,5 +607,10 @@ async function handleLogout() {
|
||||
.main {
|
||||
padding: 16px 14px 32px;
|
||||
}
|
||||
|
||||
/* 窄屏标题优先,章节记号召回抽屉里看 */
|
||||
.layout.skin-paranoia .header-mark {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,6 +9,8 @@ import App from './App.vue'
|
||||
import router from './router'
|
||||
import { initTheme } from './composables/useTheme'
|
||||
import './assets/main.css'
|
||||
// 皮肤样式放在最后,才能压过 Element Plus 与基础样式里的变量
|
||||
import './assets/theme-paranoia.css'
|
||||
|
||||
// 挂载前先定好主题,避免登录页闪一下白底
|
||||
initTheme()
|
||||
|
||||
@@ -40,6 +40,13 @@ const router = createRouter({
|
||||
component: () => import('@/views/AccountsView.vue'),
|
||||
meta: { title: '账号' },
|
||||
},
|
||||
{ path: 'gateways', name: 'gateways', component: () => import('@/views/GatewaysView.vue'), meta: { title: '消息网关' } },
|
||||
{
|
||||
path: 'gateway-rules',
|
||||
name: 'gateway-rules',
|
||||
component: () => import('@/views/GatewayRulesView.vue'),
|
||||
meta: { title: '网关规则' },
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
name: 'logs',
|
||||
@@ -52,6 +59,12 @@ const router = createRouter({
|
||||
component: () => import('@/views/SettingsView.vue'),
|
||||
meta: { title: '设置' },
|
||||
},
|
||||
{
|
||||
path: 'paranoia',
|
||||
name: 'paranoia',
|
||||
component: () => import('@/views/ParanoiaView.vue'),
|
||||
meta: { title: '妄想症' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { providerApi } from '@/api'
|
||||
import type { AccountMeta, Providers, RuleMeta } from '@/api/types'
|
||||
import type { AccountMeta, GatewayMeta, Providers, RuleMeta } from '@/api/types'
|
||||
|
||||
/** 驱动元数据:账号/规则类型的表单描述,登录后拉取一次全局共享 */
|
||||
export const useMetaStore = defineStore('meta', () => {
|
||||
@@ -16,6 +16,7 @@ export const useMetaStore = defineStore('meta', () => {
|
||||
|
||||
const accountMetas = computed<AccountMeta[]>(() => providers.value?.accounts ?? [])
|
||||
const ruleMetas = computed<RuleMeta[]>(() => providers.value?.rules ?? [])
|
||||
const gatewayMetas = computed<GatewayMeta[]>(() => providers.value?.gateways ?? [])
|
||||
|
||||
function accountMeta(type: string): AccountMeta | undefined {
|
||||
return accountMetas.value.find((m) => m.type === type)
|
||||
@@ -33,14 +34,25 @@ export const useMetaStore = defineStore('meta', () => {
|
||||
return ruleMeta(type)?.label ?? type
|
||||
}
|
||||
|
||||
function gatewayMeta(type: string): GatewayMeta | undefined {
|
||||
return gatewayMetas.value.find((m) => m.type === type)
|
||||
}
|
||||
|
||||
function gatewayLabel(type: string): string {
|
||||
return gatewayMeta(type)?.label ?? type
|
||||
}
|
||||
|
||||
return {
|
||||
providers,
|
||||
accountMetas,
|
||||
ruleMetas,
|
||||
gatewayMetas,
|
||||
ensureLoaded,
|
||||
accountMeta,
|
||||
ruleMeta,
|
||||
gatewayMeta,
|
||||
accountLabel,
|
||||
ruleLabel,
|
||||
gatewayLabel,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -94,9 +94,10 @@ async function test(account: Account) {
|
||||
async function remove(account: Account) {
|
||||
try {
|
||||
const affected = await accountApi.checkDelete(account.id)
|
||||
const affectedRules = [...affected.rules, ...affected.gateway_rules]
|
||||
const warning =
|
||||
affected.length > 0
|
||||
? `删除后,使用该账号的 ${affected.length} 条规则也会被一并删除:${affected
|
||||
affectedRules.length > 0
|
||||
? `删除后,使用该账号的 ${affectedRules.length} 条规则也会被一并删除:${affectedRules
|
||||
.map((r) => r.name)
|
||||
.join('、')}`
|
||||
: '没有规则使用该账号。'
|
||||
|
||||