Bläddra i källkod

feat: able to view messages on dashboard (close #32)

JustSong 2 år sedan
förälder
incheckning
26a3c76f3f

+ 7 - 0
common/constants.go

@@ -92,3 +92,10 @@ const (
 	SendEmailToOthersAllowed    = 1
 	SendEmailToOthersDisallowed = 2
 )
+
+const (
+	MessageSendStatusUnknown = 0
+	MessageSendStatusPending = 1
+	MessageSendStatusSent    = 2
+	MessageSendStatusFailed  = 3
+)

+ 33 - 0
controller/message.go

@@ -134,12 +134,26 @@ func pushMessageHelper(c *gin.Context, message *model.Message) {
 			"success": false,
 			"message": err.Error(),
 		})
+		// Update the status of the message
+		if common.MessagePersistenceEnabled {
+			err := message.UpdateStatus(common.MessageSendStatusFailed)
+			if err != nil {
+				common.SysError("failed to update the status of the message: " + err.Error())
+			}
+		}
 		return
 	}
 	c.JSON(http.StatusOK, gin.H{
 		"success": true,
 		"message": "ok",
 	})
+	// Update the status of the message
+	if common.MessagePersistenceEnabled {
+		err := message.UpdateStatus(common.MessageSendStatusSent)
+		if err != nil {
+			common.SysError("failed to update the status of the message: " + err.Error())
+		}
+	}
 	return
 }
 
@@ -235,6 +249,25 @@ func GetMessage(c *gin.Context) {
 	return
 }
 
+func SearchMessages(c *gin.Context) {
+	// TODO: improve the search algorithm
+	keyword := c.Query("keyword")
+	messages, err := model.SearchMessages(keyword)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data":    messages,
+	})
+	return
+}
+
 func DeleteMessage(c *gin.Context) {
 	messageId, _ := strconv.Atoi(c.Param("id"))
 	userId := c.GetInt("id")

+ 13 - 1
model/message.go

@@ -19,7 +19,8 @@ type Message struct {
 	HTMLContent string `json:"html_content"  gorm:"-:all"`
 	Timestamp   int64  `json:"timestamp" gorm:"type:int64"`
 	Link        string `json:"link" gorm:"unique;index"`
-	To          string `json:"to" gorm:"column:to"` // if specified, will send to this user(s)
+	To          string `json:"to" gorm:"column:to"`     // if specified, will send to this user(s)
+	Status      int    `json:"status" gorm:"default:0"` // pending, sent, failed
 }
 
 func GetMessageById(id int, userId int) (*Message, error) {
@@ -45,6 +46,11 @@ func GetMessagesByUserId(userId int, startIdx int, num int) (messages []*Message
 	return messages, err
 }
 
+func SearchMessages(keyword string) (messages []*Message, err error) {
+	err = DB.Select([]string{"id", "title", "channel", "status"}).Where("id = ? or title LIKE ? or channel = ? or status = ?", keyword, keyword+"%", keyword, keyword).Find(&messages).Error
+	return messages, err
+}
+
 func DeleteMessageById(id int, userId int) (err error) {
 	// Why we need userId here? In case user want to delete other's message.
 	if id == 0 || userId == 0 {
@@ -66,11 +72,17 @@ func (message *Message) UpdateAndInsert(userId int) error {
 	message.Link = common.GetUUID()
 	message.Timestamp = time.Now().Unix()
 	message.UserId = userId
+	message.Status = common.MessageSendStatusPending
 	var err error
 	err = DB.Create(message).Error
 	return err
 }
 
+func (message *Message) UpdateStatus(status int) error {
+	err := DB.Model(message).Update("status", status).Error
+	return err
+}
+
 func (message *Message) Delete() error {
 	err := DB.Delete(message).Error
 	return err

+ 3 - 2
router/api-router.go

@@ -57,9 +57,10 @@ func SetApiRouter(router *gin.Engine) {
 		}
 		messageRoute := apiRouter.Group("/message")
 		{
-			messageRoute.GET("/all", middleware.UserAuth(), controller.GetUserMessages)
+			messageRoute.GET("/", middleware.UserAuth(), controller.GetUserMessages)
+			messageRoute.GET("/search", middleware.UserAuth(), controller.SearchMessages)
 			messageRoute.GET("/:id", middleware.UserAuth(), controller.GetMessage)
-			messageRoute.DELETE("/all", middleware.RootAuth(), controller.DeleteAllMessages)
+			messageRoute.DELETE("/", middleware.RootAuth(), controller.DeleteAllMessages)
 			messageRoute.DELETE("/:id", middleware.UserAuth(), controller.DeleteMessage)
 		}
 	}

+ 9 - 0
web/src/App.js

@@ -15,6 +15,7 @@ import GitHubOAuth from './components/GitHubOAuth';
 import PasswordResetConfirm from './components/PasswordResetConfirm';
 import { UserContext } from './context/User';
 import { StatusContext } from './context/Status';
+import Message from './pages/Message';
 
 const Home = lazy(() => import('./pages/Home'));
 const About = lazy(() => import('./pages/About'));
@@ -106,6 +107,14 @@ function App() {
           </Suspense>
         }
       />
+      <Route
+        path='/message'
+        element={
+          <PrivateRoute>
+            <Message />
+          </PrivateRoute>
+        }
+      />
       <Route
         path='/login'
         element={

+ 5 - 0
web/src/components/Header.js

@@ -13,6 +13,11 @@ const headerButtons = [
     to: '/',
     icon: 'home',
   },
+  {
+    name: '消息',
+    to: '/message',
+    icon: 'mail',
+  },
   {
     name: '用户',
     to: '/user',

+ 342 - 0
web/src/components/MessagesTable.js

@@ -0,0 +1,342 @@
+import React, { useEffect, useState } from 'react';
+import { Button, Form, Label, Pagination, Table } from 'semantic-ui-react';
+import { API, showError } from '../helpers';
+
+import { ITEMS_PER_PAGE } from '../constants';
+
+function renderChannel(channel) {
+  switch (channel) {
+    case 'email':
+      return <Label color='green'>邮件</Label>;
+    case 'test':
+      return (
+        <Label style={{ backgroundColor: '#2cbb00', color: 'white' }}>
+          微信测试号
+        </Label>
+      );
+    case 'corp_app':
+      return (
+        <Label style={{ backgroundColor: '#5fc9ec', color: 'white' }}>
+          企业微信应用号
+        </Label>
+      );
+    case 'corp':
+      return (
+        <Label style={{ backgroundColor: '#019d82', color: 'white' }}>
+          企业微信群机器人
+        </Label>
+      );
+    case 'lark':
+      return (
+        <Label style={{ backgroundColor: '#00d6b9', color: 'white' }}>
+          飞书群机器人
+        </Label>
+      );
+    case 'ding':
+      return (
+        <Label style={{ backgroundColor: '#007fff', color: 'white' }}>
+          钉钉群机器人
+        </Label>
+      );
+    case 'bark':
+      return (
+        <Label style={{ backgroundColor: '#ff3b30', color: 'white' }}>
+          Bark App
+        </Label>
+      );
+    case 'client':
+      return (
+        <Label style={{ backgroundColor: '#121212', color: 'white' }}>
+          WebSocket 客户端
+        </Label>
+      );
+    case 'telegram':
+      return (
+        <Label style={{ backgroundColor: '#29a9ea', color: 'white' }}>
+          Telegram 机器人
+        </Label>
+      );
+    case 'discord':
+      return (
+        <Label style={{ backgroundColor: '#404eed', color: 'white' }}>
+          Discord 群机器人
+        </Label>
+      );
+    case 'none':
+      return <Label>无</Label>;
+    default:
+      return <Label color='grey'>未知通道</Label>;
+  }
+}
+
+function renderTimestamp(timestamp) {
+  const date = new Date(timestamp * 1000);
+  return (
+    <>
+      {date.getFullYear()}-{date.getMonth() + 1}-{date.getDate()}{' '}
+      {date.getHours()}:{date.getMinutes()}:{date.getSeconds()}
+    </>
+  );
+}
+
+function renderStatus(status) {
+  switch (status) {
+    case 1:
+      return (
+        <Label basic color='olive'>
+          投递中...
+        </Label>
+      );
+    case 2:
+      return (
+        <Label basic color='green'>
+          发送成功
+        </Label>
+      );
+    case 3:
+      return (
+        <Label basic color='red'>
+          发送失败
+        </Label>
+      );
+    default:
+      return (
+        <Label basic color='grey'>
+          未知状态
+        </Label>
+      );
+  }
+}
+
+const MessagesTable = () => {
+  const [messages, setMessages] = useState([]);
+  const [loading, setLoading] = useState(true);
+  const [activePage, setActivePage] = useState(1);
+  const [searchKeyword, setSearchKeyword] = useState('');
+  const [searching, setSearching] = useState(false);
+
+  const loadMessages = async (startIdx) => {
+    const res = await API.get(`/api/message/?p=${startIdx}`);
+    const { success, message, data } = res.data;
+    if (success) {
+      if (startIdx === 0) {
+        setMessages(data);
+      } else {
+        let newMessages = messages;
+        newMessages.push(...data);
+        setMessages(newMessages);
+      }
+    } else {
+      showError(message);
+    }
+    setLoading(false);
+  };
+
+  const onPaginationChange = (e, { activePage }) => {
+    (async () => {
+      if (activePage === Math.ceil(messages.length / ITEMS_PER_PAGE) + 1) {
+        // In this case we have to load more data and then append them.
+        await loadMessages(activePage - 1);
+      }
+      setActivePage(activePage);
+    })();
+  };
+
+  useEffect(() => {
+    // TODO: Prompt the user if message persistence is disabled
+    // TODO: Allow set persistence permission for each user
+    loadMessages(0)
+      .then()
+      .catch((reason) => {
+        showError(reason);
+      });
+  }, []);
+
+  const viewMessage = (id) => {
+    // TODO: Implement viewMessage
+    console.log('viewMessage', id);
+  };
+
+  const resendMessage = (id) => {
+    // TODO: Implement resendMessage
+    console.log('resendMessage', id);
+  };
+
+  const deleteMessage = (id) => {
+    // TODO: Implement deleteMessage
+    console.log('deleteMessage', id);
+  };
+
+  const searchMessages = async () => {
+    if (searchKeyword === '') {
+      // if keyword is blank, load files instead.
+      await loadMessages(0);
+      setActivePage(1);
+      return;
+    }
+    setSearching(true);
+    const res = await API.get(`/api/message/search?keyword=${searchKeyword}`);
+    const { success, message, data } = res.data;
+    if (success) {
+      setMessages(data);
+      setActivePage(1);
+    } else {
+      showError(message);
+    }
+    setSearching(false);
+  };
+
+  const handleKeywordChange = async (e, { value }) => {
+    setSearchKeyword(value.trim());
+  };
+
+  const sortMessage = (key) => {
+    if (messages.length === 0) return;
+    setLoading(true);
+    let sortedMessages = [...messages];
+    sortedMessages.sort((a, b) => {
+      return ('' + a[key]).localeCompare(b[key]);
+    });
+    if (sortedMessages[0].id === messages[0].id) {
+      sortedMessages.reverse();
+    }
+    setMessages(sortedMessages);
+    setLoading(false);
+  };
+
+  return (
+    <>
+      <Form onSubmit={searchMessages}>
+        <Form.Input
+          icon='search'
+          fluid
+          iconPosition='left'
+          placeholder='搜索消息的 ID,标题,通道,以及发送状态 ...'
+          value={searchKeyword}
+          loading={searching}
+          onChange={handleKeywordChange}
+        />
+      </Form>
+      <Table basic loading={loading}>
+        <Table.Header>
+          <Table.Row>
+            <Table.HeaderCell
+              style={{ cursor: 'pointer' }}
+              onClick={() => {
+                sortMessage('id');
+              }}
+            >
+              消息 ID
+            </Table.HeaderCell>
+            <Table.HeaderCell
+              style={{ cursor: 'pointer' }}
+              onClick={() => {
+                sortMessage('title');
+              }}
+            >
+              标题
+            </Table.HeaderCell>
+            <Table.HeaderCell
+              style={{ cursor: 'pointer' }}
+              onClick={() => {
+                sortMessage('channel');
+              }}
+            >
+              通道
+            </Table.HeaderCell>
+            <Table.HeaderCell
+              style={{ cursor: 'pointer' }}
+              onClick={() => {
+                sortMessage('timestamp');
+              }}
+            >
+              发送时间
+            </Table.HeaderCell>
+            <Table.HeaderCell
+              style={{ cursor: 'pointer' }}
+              onClick={() => {
+                sortMessage('status');
+              }}
+            >
+              状态
+            </Table.HeaderCell>
+            <Table.HeaderCell>操作</Table.HeaderCell>
+          </Table.Row>
+        </Table.Header>
+
+        <Table.Body>
+          {messages
+            .slice(
+              (activePage - 1) * ITEMS_PER_PAGE,
+              activePage * ITEMS_PER_PAGE
+            )
+            .map((message, idx) => {
+              if (message.deleted) return <></>;
+              return (
+                <Table.Row key={message.id}>
+                  <Table.Cell>{'#' + message.id}</Table.Cell>
+                  <Table.Cell>
+                    {message.title ? message.title : '无标题'}
+                  </Table.Cell>
+                  <Table.Cell>{renderChannel(message.channel)}</Table.Cell>
+                  <Table.Cell>{renderTimestamp(message.timestamp)}</Table.Cell>
+                  <Table.Cell>{renderStatus(message.status)}</Table.Cell>
+                  <Table.Cell>
+                    <div>
+                      <Button
+                        size={'small'}
+                        positive
+                        onClick={() => {
+                          viewMessage(message.id);
+                        }}
+                      >
+                        查看
+                      </Button>
+                      <Button
+                        size={'small'}
+                        color={'yellow'}
+                        onClick={() => {
+                          resendMessage(message.id);
+                        }}
+                      >
+                        重发
+                      </Button>
+                      <Button
+                        size={'small'}
+                        negative
+                        onClick={() => {
+                          deleteMessage(message.id);
+                        }}
+                      >
+                        删除
+                      </Button>
+                    </div>
+                  </Table.Cell>
+                </Table.Row>
+              );
+            })}
+        </Table.Body>
+
+        <Table.Footer>
+          <Table.Row>
+            <Table.HeaderCell colSpan='6'>
+              <Pagination
+                floated='right'
+                activePage={activePage}
+                onPageChange={onPaginationChange}
+                size='small'
+                siblingRange={1}
+                totalPages={
+                  Math.ceil(messages.length / ITEMS_PER_PAGE) +
+                  (messages.length % ITEMS_PER_PAGE === 0 ? 1 : 0)
+                }
+              />
+            </Table.HeaderCell>
+          </Table.Row>
+        </Table.Footer>
+      </Table>
+    </>
+  );
+};
+
+export default MessagesTable;

+ 14 - 0
web/src/pages/Message/index.js

@@ -0,0 +1,14 @@
+import React from 'react';
+import { Header, Segment } from 'semantic-ui-react';
+import MessagesTable from '../../components/MessagesTable';
+
+const Message = () => (
+  <>
+    <Segment>
+      <Header as='h3'>我的消息</Header>
+      <MessagesTable />
+    </Segment>
+  </>
+);
+
+export default Message;