Pārlūkot izejas kodu

feat: now user are able to configure channels

JustSong 2 gadi atpakaļ
vecāks
revīzija
2acc56e03c

+ 14 - 14
channel/token-store.go

@@ -40,9 +40,9 @@ func TokenStoreInit() {
 			}
 			if user.WeChatCorpAccountId != "" {
 				item := &WeChatCorpAccountTokenStoreItem{
-					CorpId:     user.WeChatCorpAccountId,
-					CorpSecret: user.WeChatCorpAccountSecret,
-					AgentId:    user.WeChatCorpAccountAgentId,
+					CorpId:      user.WeChatCorpAccountId,
+					AgentSecret: user.WeChatCorpAccountAgentSecret,
+					AgentId:     user.WeChatCorpAccountAgentId,
 				}
 				items = append(items, item)
 			}
@@ -121,21 +121,21 @@ func TokenStoreUpdateUser(cleanUser *model.User, originUser *model.User) {
 	if cleanUser.WeChatCorpAccountAgentId == originUser.WeChatCorpAccountAgentId {
 		cleanUser.WeChatCorpAccountAgentId = ""
 	}
-	if cleanUser.WeChatCorpAccountSecret == originUser.WeChatCorpAccountSecret {
-		cleanUser.WeChatCorpAccountSecret = ""
+	if cleanUser.WeChatCorpAccountAgentSecret == originUser.WeChatCorpAccountAgentSecret {
+		cleanUser.WeChatCorpAccountAgentSecret = ""
 	}
-	if cleanUser.WeChatCorpAccountId != "" || cleanUser.WeChatCorpAccountAgentId != "" || cleanUser.WeChatCorpAccountSecret != "" {
+	if cleanUser.WeChatCorpAccountId != "" || cleanUser.WeChatCorpAccountAgentId != "" || cleanUser.WeChatCorpAccountAgentSecret != "" {
 		oldWeChatCorpAccountTokenStoreItem := WeChatCorpAccountTokenStoreItem{
-			CorpId:     cleanUser.WeChatCorpAccountId,
-			CorpSecret: cleanUser.WeChatCorpAccountSecret,
-			AgentId:    cleanUser.WeChatCorpAccountAgentId,
+			CorpId:      cleanUser.WeChatCorpAccountId,
+			AgentSecret: cleanUser.WeChatCorpAccountAgentSecret,
+			AgentId:     cleanUser.WeChatCorpAccountAgentId,
 		}
 		newWeChatCorpAccountTokenStoreItem := oldWeChatCorpAccountTokenStoreItem
 		if cleanUser.WeChatCorpAccountId != "" {
 			newWeChatCorpAccountTokenStoreItem.CorpId = cleanUser.WeChatCorpAccountId
 		}
-		if cleanUser.WeChatCorpAccountSecret != "" {
-			newWeChatCorpAccountTokenStoreItem.CorpSecret = cleanUser.WeChatCorpAccountSecret
+		if cleanUser.WeChatCorpAccountAgentSecret != "" {
+			newWeChatCorpAccountTokenStoreItem.AgentSecret = cleanUser.WeChatCorpAccountAgentSecret
 		}
 		if cleanUser.WeChatCorpAccountAgentId != "" {
 			newWeChatCorpAccountTokenStoreItem.AgentId = cleanUser.WeChatCorpAccountAgentId
@@ -157,9 +157,9 @@ func TokenStoreRemoveUser(user *model.User) {
 		TokenStoreRemoveItem(&testAccountTokenStoreItem)
 	}
 	corpAccountTokenStoreItem := WeChatCorpAccountTokenStoreItem{
-		CorpId:     user.WeChatCorpAccountId,
-		CorpSecret: user.WeChatCorpAccountSecret,
-		AgentId:    user.WeChatCorpAccountAgentId,
+		CorpId:      user.WeChatCorpAccountId,
+		AgentSecret: user.WeChatCorpAccountAgentSecret,
+		AgentId:     user.WeChatCorpAccountAgentId,
 	}
 	if !corpAccountTokenStoreItem.IsShared() {
 		TokenStoreRemoveItem(&corpAccountTokenStoreItem)

+ 6 - 6
channel/wechat-corp-account.go

@@ -20,18 +20,18 @@ type wechatCorpAccountResponse struct {
 
 type WeChatCorpAccountTokenStoreItem struct {
 	CorpId      string
-	CorpSecret  string
+	AgentSecret string
 	AgentId     string
 	AccessToken string
 }
 
 func (i *WeChatCorpAccountTokenStoreItem) Key() string {
-	return i.CorpId + i.AgentId + i.CorpSecret
+	return i.CorpId + i.AgentId + i.AgentSecret
 }
 
 func (i *WeChatCorpAccountTokenStoreItem) IsShared() bool {
-	return model.DB.Where("wechat_corp_account_id = ? and wechat_corp_account_secret = ? and wechat_corp_account_agent_id = ?",
-		i.CorpId, i.CorpSecret, i.AgentId).Find(&model.User{}).RowsAffected != 1
+	return model.DB.Where("wechat_corp_account_id = ? and wechat_corp_account_agent_secret = ? and wechat_corp_account_agent_id = ?",
+		i.CorpId, i.AgentSecret, i.AgentId).Find(&model.User{}).RowsAffected != 1
 }
 
 func (i *WeChatCorpAccountTokenStoreItem) Token() string {
@@ -44,7 +44,7 @@ func (i *WeChatCorpAccountTokenStoreItem) Refresh() {
 		Timeout: 5 * time.Second,
 	}
 	req, err := http.NewRequest("GET", fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=%s&corpsecret=%s",
-		i.CorpId, i.CorpSecret), nil)
+		i.CorpId, i.AgentSecret), nil)
 	if err != nil {
 		common.SysError(err.Error())
 		return
@@ -119,7 +119,7 @@ func SendWeChatCorpMessage(message *Message, user *model.User) error {
 	if err != nil {
 		return err
 	}
-	key := fmt.Sprintf("%s%s%s", user.WeChatCorpAccountId, user.WeChatCorpAccountAgentId, user.WeChatCorpAccountSecret)
+	key := fmt.Sprintf("%s%s%s", user.WeChatCorpAccountId, user.WeChatCorpAccountAgentId, user.WeChatCorpAccountAgentSecret)
 	accessToken := TokenStoreGetToken(key)
 	resp, err := http.Post(fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=%s", accessToken), "application/json",
 		bytes.NewBuffer(jsonData))

+ 4 - 0
controller/message.go

@@ -22,6 +22,10 @@ func GetPushMessage(c *gin.Context) {
 		// Keep compatible with ServerChan
 		message.Description = c.Query("desp")
 	}
+	if message.Channel == "" {
+		// Keep compatible with old version
+		message.Channel = c.Query("type")
+	}
 	pushMessageHelper(c, &message)
 }
 

+ 24 - 0
controller/misc.go

@@ -1,12 +1,16 @@
 package controller
 
 import (
+	"crypto/sha1"
+	"encoding/hex"
 	"encoding/json"
 	"fmt"
 	"github.com/gin-gonic/gin"
 	"message-pusher/common"
 	"message-pusher/model"
 	"net/http"
+	"sort"
+	"strings"
 )
 
 func GetStatus(c *gin.Context) {
@@ -167,3 +171,23 @@ func ResetPassword(c *gin.Context) {
 	})
 	return
 }
+
+func WeChatTestAccountVerification(c *gin.Context) {
+	user := model.User{Username: c.Param("username")}
+	user.FillUserByUsername()
+	// https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Access_Overview.html
+	signature := c.Query("signature")
+	timestamp := c.Query("timestamp")
+	nonce := c.Query("nonce")
+	echoStr := c.Query("echostr")
+	arr := []string{user.WeChatTestAccountVerificationToken, timestamp, nonce}
+	sort.Strings(arr)
+	str := strings.Join(arr, "")
+	hash := sha1.Sum([]byte(str))
+	hexStr := hex.EncodeToString(hash[:])
+	if signature == hexStr {
+		c.String(http.StatusOK, echoStr)
+	} else {
+		c.Status(http.StatusForbidden)
+	}
+}

+ 13 - 3
controller/user.go

@@ -342,8 +342,15 @@ func UpdateUser(c *gin.Context) {
 	if updatedUser.Password == "$I_LOVE_U" {
 		updatedUser.Password = "" // rollback to what it should be
 	}
-	updatePassword := updatedUser.Password != ""
-	if err := updatedUser.Update(updatePassword); err != nil {
+	// We only allow admin change those fields.
+	cleanUser := model.User{
+		Id:          updatedUser.Id,
+		Username:    updatedUser.Username,
+		Password:    updatedUser.Password,
+		DisplayName: updatedUser.DisplayName,
+	}
+	updatePassword := cleanUser.Password != ""
+	if err := cleanUser.Update(updatePassword); err != nil {
 		c.JSON(http.StatusOK, gin.H{
 			"success": false,
 			"message": err.Error(),
@@ -389,13 +396,16 @@ func UpdateSelf(c *gin.Context) {
 		Id:                                 c.GetInt("id"),
 		Username:                           user.Username,
 		Password:                           user.Password,
+		DisplayName:                        user.DisplayName,
+		Token:                              user.Token,
+		Channel:                            user.Channel,
 		WeChatTestAccountId:                user.WeChatTestAccountId,
 		WeChatTestAccountSecret:            user.WeChatTestAccountSecret,
 		WeChatTestAccountTemplateId:        user.WeChatTestAccountTemplateId,
 		WeChatTestAccountOpenId:            user.WeChatTestAccountOpenId,
 		WeChatTestAccountVerificationToken: user.WeChatTestAccountVerificationToken,
 		WeChatCorpAccountId:                user.WeChatCorpAccountId,
-		WeChatCorpAccountSecret:            user.WeChatCorpAccountSecret,
+		WeChatCorpAccountAgentSecret:       user.WeChatCorpAccountAgentSecret,
 		WeChatCorpAccountAgentId:           user.WeChatCorpAccountAgentId,
 		WeChatCorpAccountUserId:            user.WeChatCorpAccountUserId,
 		WeChatCorpAccountClientType:        user.WeChatCorpAccountClientType,

+ 10 - 4
model/user.go

@@ -15,7 +15,7 @@ type User struct {
 	DisplayName                        string `json:"display_name" gorm:"index" validate:"max=20"`
 	Role                               int    `json:"role" gorm:"type:int;default:1"`   // admin, common
 	Status                             int    `json:"status" gorm:"type:int;default:1"` // enabled, disabled
-	Token                              string `json:"token" gorm:"index"`
+	Token                              string `json:"token"`
 	Email                              string `json:"email" gorm:"index" validate:"max=50"`
 	GitHubId                           string `json:"github_id" gorm:"column:github_id;index"`
 	WeChatId                           string `json:"wechat_id" gorm:"column:wechat_id;index"`
@@ -27,10 +27,10 @@ type User struct {
 	WeChatTestAccountOpenId            string `json:"wechat_test_account_open_id" gorm:"column:wechat_test_account_open_id"`
 	WeChatTestAccountVerificationToken string `json:"wechat_test_account_verification_token" gorm:"column:wechat_test_account_verification_token"`
 	WeChatCorpAccountId                string `json:"wechat_corp_account_id" gorm:"column:wechat_corp_account_id"`
-	WeChatCorpAccountSecret            string `json:"wechat_corp_account_secret" gorm:"column:wechat_corp_account_secret"`
+	WeChatCorpAccountAgentSecret       string `json:"wechat_corp_account_agent_secret" gorm:"column:wechat_corp_account_agent_secret"`
 	WeChatCorpAccountAgentId           string `json:"wechat_corp_account_agent_id" gorm:"column:wechat_corp_account_agent_id"`
 	WeChatCorpAccountUserId            string `json:"wechat_corp_account_user_id" gorm:"column:wechat_corp_account_user_id"`
-	WeChatCorpAccountClientType        string `json:"wechat_corp_account_client_type" gorm:"wechat_corp_account_client_type;default=plugin"`
+	WeChatCorpAccountClientType        string `json:"wechat_corp_account_client_type" gorm:"column:wechat_corp_account_client_type;default=plugin"`
 	LarkWebhookURL                     string `json:"lark_webhook_url"`
 	LarkWebhookSecret                  string `json:"lark_webhook_secret"`
 	DingWebhookURL                     string `json:"ding_webhook_url"`
@@ -64,7 +64,13 @@ func GetUserById(id int, selectAll bool) (*User, error) {
 	if selectAll {
 		err = DB.First(&user, "id = ?", id).Error
 	} else {
-		err = DB.Select([]string{"id", "username", "display_name", "role", "status", "email", "wechat_id", "github_id"}).First(&user, "id = ?", id).Error
+		err = DB.Select([]string{"id", "username", "display_name", "role", "status", "email", "wechat_id", "github_id",
+			"channel", "token",
+			"wechat_test_account_id", "wechat_test_account_template_id", "wechat_test_account_open_id",
+			"wechat_corp_account_id", "wechat_corp_account_agent_id", "wechat_corp_account_user_id", "wechat_corp_account_client_type",
+			"lark_webhook_url",
+			"ding_webhook_url",
+		}).First(&user, "id = ?", id).Error
 	}
 	return &user, err
 }

+ 1 - 0
router/api-router.go

@@ -13,6 +13,7 @@ func SetApiRouter(router *gin.Engine) {
 		apiRouter.GET("/status", controller.GetStatus)
 		apiRouter.GET("/notice", controller.GetNotice)
 		apiRouter.GET("/about", controller.GetAbout)
+		apiRouter.GET("/wechat_test_account_verification/:username", controller.WeChatTestAccountVerification)
 		apiRouter.GET("/verification", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification)
 		apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail)
 		apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword)

+ 1 - 1
web/src/components/PersonalSetting.js

@@ -106,7 +106,7 @@ const PersonalSetting = () => {
       <Button as={Link} to={`/user/edit/`}>
         更新个人信息
       </Button>
-      <Button onClick={generateToken}>生成访问令牌</Button>
+      {/*<Button onClick={generateToken}>生成访问令牌</Button>*/}
       <Divider />
       <Header as='h3'>账号绑定</Header>
       <Button

+ 384 - 0
web/src/components/PushSetting.js

@@ -0,0 +1,384 @@
+import React, { useEffect, useState } from 'react';
+import {
+  Button,
+  Divider,
+  Form,
+  Grid,
+  Header,
+  Message,
+} from 'semantic-ui-react';
+import { API, showError, showSuccess } from '../helpers';
+
+const PushSetting = () => {
+  let [inputs, setInputs] = useState({
+    id: '',
+    username: '',
+    channel: '',
+    token: '',
+    wechat_test_account_id: '',
+    wechat_test_account_secret: '',
+    wechat_test_account_template_id: '',
+    wechat_test_account_open_id: '',
+    wechat_test_account_verification_token: '',
+    wechat_corp_account_id: '',
+    wechat_corp_account_agent_secret: '',
+    wechat_corp_account_agent_id: '',
+    wechat_corp_account_user_id: '',
+    wechat_corp_account_client_type: '',
+    lark_webhook_url: '',
+    lark_webhook_secret: '',
+    ding_webhook_url: '',
+    ding_webhook_secret: '',
+  });
+  let [loading, setLoading] = useState(false);
+
+  const handleInputChange = (e, { name, value }) => {
+    setInputs((inputs) => ({ ...inputs, [name]: value }));
+  };
+
+  const loadUser = async () => {
+    let res = await API.get(`/api/user/self`);
+    const { success, message, data } = res.data;
+    if (success) {
+      if (data.channel === '') {
+        data.channel = 'email';
+      }
+      if (data.wechat_corp_account_client_type === '') {
+        data.wechat_corp_account_client_type = 'plugin';
+      }
+      if (data.token === ' ') {
+        data.token = '';
+      }
+      setInputs(data);
+    } else {
+      showError(message);
+    }
+    setLoading(false);
+  };
+
+  useEffect(() => {
+    loadUser().then();
+  }, []);
+
+  const submit = async (which) => {
+    let data = {};
+    switch (which) {
+      case 'general':
+        data.channel = inputs.channel;
+        data.token = inputs.token;
+        if (data.token === '') {
+          data.token = ' ';
+        }
+        break;
+      case 'test':
+        data.wechat_test_account_id = inputs.wechat_test_account_id;
+        data.wechat_test_account_secret = inputs.wechat_test_account_secret;
+        data.wechat_test_account_template_id =
+          inputs.wechat_test_account_template_id;
+        data.wechat_test_account_open_id = inputs.wechat_test_account_open_id;
+        data.wechat_test_account_verification_token =
+          inputs.wechat_test_account_verification_token;
+        break;
+      case 'corp':
+        data.wechat_corp_account_id = inputs.wechat_corp_account_id;
+        data.wechat_corp_account_agent_secret =
+          inputs.wechat_corp_account_agent_secret;
+        data.wechat_corp_account_agent_id = inputs.wechat_corp_account_agent_id;
+        data.wechat_corp_account_user_id = inputs.wechat_corp_account_user_id;
+        data.wechat_corp_account_client_type =
+          inputs.wechat_corp_account_client_type;
+        break;
+      case 'lark':
+        data.lark_webhook_url = inputs.lark_webhook_url;
+        data.lark_webhook_secret = inputs.lark_webhook_secret;
+        break;
+      case 'ding':
+        data.ding_webhook_url = inputs.ding_webhook_url;
+        data.ding_webhook_secret = inputs.ding_webhook_secret;
+        break;
+      default:
+        showError(`无效的参数:${which}`);
+        return;
+    }
+    let res = await API.put(`/api/user/self`, data);
+    const { success, message } = res.data;
+    if (success) {
+      showSuccess('设置已更新!');
+    } else {
+      showError(message);
+    }
+  };
+
+  const test = async (type) => {
+    let res = await API.get(
+      `/push/${inputs.username}?token=${inputs.token}&channel=${type}&title=消息推送服务&description=配置成功!`
+    );
+    const { success, message } = res.data;
+    if (success) {
+      showSuccess('测试消息已发送');
+    } else {
+      showError(message);
+    }
+  };
+
+  return (
+    <Grid columns={1}>
+      <Grid.Column>
+        <Form loading={loading}>
+          <Header as='h3'>通用设置</Header>
+          <Message>注意:密钥类配置信息不会发送到前端显示。</Message>
+          <Form.Group widths={3}>
+            <Form.Select
+              label='默认推送方式'
+              name='channel'
+              options={[
+                { key: 'email', text: '邮件', value: 'email' },
+                { key: 'test', text: '微信测试号', value: 'test' },
+                { key: 'corp', text: '企业微信', value: 'corp' },
+                { key: 'lark', text: '飞书群机器人', value: 'lark' },
+                { key: 'ding', text: '钉钉群机器人', value: 'ding' },
+              ]}
+              value={inputs.channel}
+              onChange={handleInputChange}
+            />
+            <Form.Input
+              label='推送 token'
+              placeholder='未设置则不检查 token'
+              value={inputs.token}
+              name='token'
+              onChange={handleInputChange}
+            />
+          </Form.Group>
+          <Button onClick={() => submit('general')} loading={loading}>
+            保存
+          </Button>
+          <Button onClick={() => test('')}>测试</Button>
+          <Divider />
+          <Header as='h3'>
+            邮箱设置(email)
+            <Header.Subheader>通过邮件进行推送</Header.Subheader>
+          </Header>
+          <Message>
+            邮件推送方式(email)需要设置邮箱,请前往个人设置页面绑定邮箱地址。
+          </Message>
+          <Button onClick={() => test('email')}>测试</Button>
+          <Divider />
+          <Header as='h3'>
+            微信测试号设置(test)
+            <Header.Subheader>
+              通过微信测试号进行推送,点击前往配置:
+              <a
+                target='_blank'
+                href='https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index'
+              >
+                微信公众平台接口测试帐号
+              </a>
+            </Header.Subheader>
+          </Header>
+          <Message>
+            接口配置信息中的 URL 填写:
+            <code>{`${window.location.origin}/api/wechat_test_account_verification/${inputs.username}`}</code>
+            <br />
+            Token 填一个随机字符串,然后填入下方的「接口配置验证 Token」中。
+          </Message>
+          <Form.Group widths={3}>
+            <Form.Input
+              label='测试号 ID'
+              name='wechat_test_account_id'
+              onChange={handleInputChange}
+              autoComplete='off'
+              value={inputs.wechat_test_account_id}
+              placeholder='测试号信息 -> appID'
+            />
+            <Form.Input
+              label='测试号密钥'
+              name='wechat_test_account_secret'
+              type='password'
+              onChange={handleInputChange}
+              autoComplete='off'
+              value={inputs.wechat_test_account_secret}
+              placeholder='测试号信息 -> appsecret'
+            />
+            <Form.Input
+              label='测试模板 ID'
+              name='wechat_test_account_template_id'
+              onChange={handleInputChange}
+              autoComplete='off'
+              value={inputs.wechat_test_account_template_id}
+              placeholder='模板消息接口 -> 模板 ID'
+            />
+          </Form.Group>
+          <Form.Group widths={3}>
+            <Form.Input
+              label='用户 Open ID'
+              name='wechat_test_account_open_id'
+              onChange={handleInputChange}
+              autoComplete='off'
+              value={inputs.wechat_test_account_open_id}
+              placeholder='测试号二维码 -> 用户列表 -> 微信号'
+            />
+            <Form.Input
+              label='接口配置验证 Token'
+              name='wechat_test_account_verification_token'
+              onChange={handleInputChange}
+              autoComplete='off'
+              type='password'
+              value={inputs.wechat_test_account_verification_token}
+              placeholder='接口配置信息 -> Token'
+            />
+          </Form.Group>
+          <Button onClick={() => submit('test')} loading={loading}>
+            保存
+          </Button>
+          <Button onClick={() => test('test')}>测试</Button>
+          <Divider />
+          <Header as='h3'>
+            企业微信设置(corp)
+            <Header.Subheader>
+              通过企业微信进行推送,点击前往配置:
+              <a
+                target='_blank'
+                href='https://work.weixin.qq.com/wework_admin/frame#apps'
+              >
+                企业微信应用管理
+              </a>
+            </Header.Subheader>
+          </Header>
+          <Form.Group widths={3}>
+            <Form.Input
+              label='企业 ID'
+              name='wechat_corp_account_id'
+              onChange={handleInputChange}
+              autoComplete='off'
+              value={inputs.wechat_corp_account_id}
+              placeholder='我的企业 -> 企业信息 -> 企业 ID'
+            />
+            <Form.Input
+              label='应用 AgentId'
+              name='wechat_corp_account_agent_id'
+              onChange={handleInputChange}
+              autoComplete='off'
+              value={inputs.wechat_corp_account_agent_id}
+              placeholder='应用管理 -> 自建 -> 创建应用 -> AgentId'
+            />
+            <Form.Input
+              label='应用 Secret'
+              name='wechat_corp_account_agent_secret'
+              type='password'
+              onChange={handleInputChange}
+              autoComplete='off'
+              value={inputs.wechat_corp_account_agent_secret}
+              placeholder='应用管理 -> 自建 -> 创建应用 -> Secret'
+            />
+          </Form.Group>
+          <Form.Group widths={3}>
+            <Form.Input
+              label='用户账号'
+              name='wechat_corp_account_user_id'
+              onChange={handleInputChange}
+              autoComplete='off'
+              value={inputs.wechat_corp_account_user_id}
+              placeholder='通讯录 -> 点击姓名 -> 账号'
+            />
+            <Form.Select
+              label='微信企业号客户端类型'
+              name='wechat_corp_account_client_type'
+              options={[
+                {
+                  key: 'plugin',
+                  text: '微信中的企业微信插件',
+                  value: 'plugin',
+                },
+                { key: 'app', text: '企业微信 APP', value: 'app' },
+              ]}
+              value={inputs.wechat_corp_account_client_type}
+              onChange={handleInputChange}
+            />
+          </Form.Group>
+          <Button onClick={() => submit('corp')} loading={loading}>
+            保存
+          </Button>
+          <Button onClick={() => test('corp')}>测试</Button>
+          <Divider />
+          <Header as='h3'>
+            飞书设置(lark)
+            <Header.Subheader>
+              通过飞书群机器人进行推送,选择一个群聊 -> 设置 -> 群机器人 ->
+              添加机器人 -> 自定义机器人 -> 添加(
+              <strong>注意选中「签名校验」</strong>)。具体参见:
+              <a
+                target='_blank'
+                href='https://open.feishu.cn/document/ukTMukTMukTM/ucTM5YjL3ETO24yNxkjN'
+              >
+                飞书开放文档
+              </a>
+            </Header.Subheader>
+          </Header>
+          <Form.Group widths={2}>
+            <Form.Input
+              label='Webhook 地址'
+              name='lark_webhook_url'
+              onChange={handleInputChange}
+              autoComplete='off'
+              value={inputs.lark_webhook_url}
+              placeholder='在此填写飞书提供的 Webhook 地址'
+            />
+            <Form.Input
+              label='签名校验密钥'
+              name='lark_webhook_secret'
+              type='password'
+              onChange={handleInputChange}
+              autoComplete='off'
+              value={inputs.lark_webhook_secret}
+              placeholder='在此填写飞书提供的签名校验密钥'
+            />
+          </Form.Group>
+          <Button onClick={() => submit('lark')} loading={loading}>
+            保存
+          </Button>
+          <Button onClick={() => test('lark')}>测试</Button>
+          <Divider />
+          <Header as='h3'>
+            钉钉设置(ding)
+            <Header.Subheader>
+              通过钉钉机器人进行推送,选择一个群聊 -> 群设置 -> 智能群助手 ->
+              添加机器人(点击右侧齿轮图标) -> 自定义 -> 添加(
+              <strong>注意选中「加密」</strong>)。具体参见:
+              <a
+                target='_blank'
+                href='https://open.dingtalk.com/document/robots/custom-robot-access'
+              >
+                钉钉开放文档
+              </a>
+            </Header.Subheader>
+          </Header>
+          <Form.Group widths={2}>
+            <Form.Input
+              label='Webhook 地址'
+              name='ding_webhook_url'
+              onChange={handleInputChange}
+              autoComplete='off'
+              value={inputs.ding_webhook_url}
+              placeholder='在此填写钉钉提供的 Webhook 地址'
+            />
+            <Form.Input
+              label='签名校验密钥'
+              name='ding_webhook_secret'
+              type='password'
+              onChange={handleInputChange}
+              autoComplete='off'
+              value={inputs.ding_webhook_secret}
+              placeholder='在此填写钉钉提供的签名校验密钥'
+            />
+          </Form.Group>
+          <Button onClick={() => submit('ding')} loading={loading}>
+            保存
+          </Button>
+          <Button onClick={() => test('ding')}>测试</Button>
+        </Form>
+      </Grid.Column>
+    </Grid>
+  );
+};
+
+export default PushSetting;

+ 13 - 4
web/src/pages/Setting/index.js

@@ -4,6 +4,7 @@ import SystemSetting from '../../components/SystemSetting';
 import { isRoot } from '../../helpers';
 import OtherSetting from '../../components/OtherSetting';
 import PersonalSetting from '../../components/PersonalSetting';
+import PushSetting from '../../components/PushSetting';
 
 const Setting = () => {
   let panes = [
@@ -13,8 +14,16 @@ const Setting = () => {
         <Tab.Pane attached={false}>
           <PersonalSetting />
         </Tab.Pane>
-      )
-    }
+      ),
+    },
+    {
+      menuItem: '推送设置',
+      render: () => (
+        <Tab.Pane attached={false}>
+          <PushSetting />
+        </Tab.Pane>
+      ),
+    },
   ];
 
   if (isRoot()) {
@@ -24,7 +33,7 @@ const Setting = () => {
         <Tab.Pane attached={false}>
           <SystemSetting />
         </Tab.Pane>
-      )
+      ),
     });
     panes.push({
       menuItem: '其他设置',
@@ -32,7 +41,7 @@ const Setting = () => {
         <Tab.Pane attached={false}>
           <OtherSetting />
         </Tab.Pane>
-      )
+      ),
     });
   }