Procházet zdrojové kódy

feat(dashboard): 迁移 SentCart 与 NotificationIcon 到 workspace 顶栏

- 新增 notification/NotificationIcon 与 NotificationList(适配 v6 ProList/API 路径)
- 复用 sentence/SentCart 并适配浅色顶栏
- workspace 布局顶栏在语言选择等按钮左侧加入两个按钮,并增大间距
visuddhinanda před 2 dny
rodič
revize
0d6a463d1c

+ 132 - 0
dashboard-v6/src/components/notification/NotificationIcon.tsx

@@ -0,0 +1,132 @@
+import { useEffect, useState } from "react";
+import { Badge, Popover } from "antd";
+
+import { get } from "../../request";
+import type { INotificationListResponse } from "../../api/notification";
+import { NotificationIcon } from "../../assets/icon";
+import NotificationList from "./NotificationList";
+import { useAppSelector } from "../../hooks";
+import { currentUser, type IUser } from "../../reducers/current-user";
+
+const NotificationIconWidget = () => {
+  const [count, setCount] = useState<number>();
+  const currUser = useAppSelector(currentUser);
+  const [mute, setMute] = useState(false);
+
+  const queryNotification = (user?: IUser) => {
+    if (!user) {
+      console.debug("未登录 不查询 notification");
+      return;
+    }
+    const isMute = localStorage.getItem("notification/mute");
+    if (isMute && isMute === "true") {
+      setMute(true);
+    } else {
+      setMute(false);
+    }
+    const now = new Date();
+    const notificationUpdatedAt = localStorage.getItem(
+      "notification/updatedAt"
+    );
+    if (notificationUpdatedAt) {
+      if (now.getTime() - parseInt(notificationUpdatedAt) < 59000) {
+        const notificationCount = localStorage.getItem("notification/count");
+        if (notificationCount !== null) {
+          setCount(parseInt(notificationCount));
+          console.debug("has notification count");
+          return;
+        }
+      }
+    }
+
+    const url = `/api/v2/notification?view=to&limit=1`;
+    console.info("notification api request", url);
+    get<INotificationListResponse>(url).then((json) => {
+      if (json.ok) {
+        console.debug("notification fetch ok ", json.data.unread);
+        localStorage.setItem(
+          "notification/updatedAt",
+          now.getTime().toString()
+        );
+        localStorage.setItem("notification/count", json.data.unread.toString());
+        setCount(json.data.unread);
+        if (json.data.count > 0) {
+          const newMessageTime = json.data.rows[0].created_at;
+          const lastTime = localStorage.getItem("notification/new");
+          if (lastTime === null || lastTime !== newMessageTime) {
+            localStorage.setItem("notification/new", newMessageTime);
+
+            const title = json.data.rows[0].res_type;
+            const content = json.data.rows[0].content;
+            localStorage.setItem(
+              "notification/message",
+              JSON.stringify({ title: title, content: content })
+            );
+            // 发送通知
+            if (!isMute || isMute !== "true") {
+              if (window.Notification && Notification.permission !== "denied") {
+                Notification.requestPermission(function () {
+                  const notification = new Notification(title, {
+                    body: content,
+                    icon: import.meta.env.BASE_URL + "logo192.png",
+                    tag: json.data.rows[0].id,
+                  });
+                  notification.onclick = (event) => {
+                    event.preventDefault(); // 阻止浏览器聚焦于 Notification 的标签页
+                    window.open(json.data.rows[0].url, "_blank");
+                  };
+                });
+              }
+            }
+          }
+        }
+      } else {
+        console.error(json.message);
+      }
+    });
+  };
+
+  useEffect(() => {
+    const timer = setInterval(() => queryNotification(currUser), 1000 * 60);
+    return () => {
+      clearInterval(timer);
+    };
+  }, [currUser]);
+
+  return (
+    <>
+      {currUser ? (
+        <Popover
+          placement="bottomLeft"
+          arrow={{ pointAtCenter: true }}
+          destroyOnHidden
+          content={
+            <div style={{ width: 600 }}>
+              <NotificationList
+                onChange={(unread: number) => setCount(unread)}
+              />
+            </div>
+          }
+          trigger="click"
+        >
+          <Badge count={count} size="small" dot={mute}>
+            <span
+              style={{
+                color: "inherit",
+                cursor: "pointer",
+                fontSize: 18,
+                display: "inline-flex",
+              }}
+            >
+              <NotificationIcon />
+            </span>
+          </Badge>
+        </Popover>
+      ) : (
+        <></>
+      )}
+    </>
+  );
+};
+
+export default NotificationIconWidget;

+ 260 - 0
dashboard-v6/src/components/notification/NotificationList.tsx

@@ -0,0 +1,260 @@
+import { useRef, useState, type Key } from "react";
+import { type ActionType, ProList } from "@ant-design/pro-components";
+import { Avatar, Button, Space, Switch, Tag, Typography } from "antd";
+import { ReloadOutlined } from "@ant-design/icons";
+
+import { get, put } from "../../request";
+import type {
+  INotificationListResponse,
+  INotificationPutResponse,
+  INotificationRequest,
+} from "../../api/notification";
+import type { IUser } from "../../api/Auth";
+import type { IChannel } from "../../api/channel";
+import TimeShow from "../general/TimeShow";
+import Marked from "../general/Marked";
+
+const { Text } = Typography;
+
+interface INotification {
+  id: string;
+  from: IUser;
+  to: IUser;
+  channel: IChannel;
+  url?: string;
+  title?: string;
+  book_title?: string;
+  content?: string;
+  content_type: string;
+  res_type: string;
+  res_id: string;
+  status: string;
+  deleted_at?: string;
+  created_at: string;
+  updated_at: string;
+}
+interface IWidget {
+  onChange?: (unread: number) => void;
+}
+
+const NotificationListWidget = ({ onChange }: IWidget) => {
+  const ref = useRef<ActionType | null>(null);
+  const [activeKey, setActiveKey] = useState<Key | undefined>("inbox");
+  const [mute, setMute] = useState<boolean>(() => {
+    const stored = localStorage.getItem("notification/mute");
+    return stored === "true";
+  });
+
+  const putStatus = (id: string, status: string) => {
+    const url = `/api/v2/notification/${id}`;
+    console.info("api request", url);
+    put<INotificationRequest, INotificationPutResponse>(url, {
+      status: status,
+    }).then((json) => {
+      console.info("api response", json);
+      if (json.ok) {
+        ref.current?.reload();
+        if (typeof onChange !== "undefined") {
+          onChange(json.data.unread);
+        }
+      }
+    });
+  };
+
+  return (
+    <ProList<INotification>
+      rowKey="id"
+      actionRef={ref}
+      onRow={(record) => {
+        return {
+          onClick: () => {
+            // 点击行
+            if (record.status === "unread") {
+              putStatus(record.id, "read");
+            }
+          },
+        };
+      }}
+      toolBarRender={() => {
+        return [
+          <>
+            {"免打扰"}
+            <Switch
+              size="small"
+              checked={mute}
+              onChange={(checked: boolean) => {
+                setMute(checked);
+                if (checked) {
+                  localStorage.setItem("notification/mute", "true");
+                } else {
+                  localStorage.setItem("notification/mute", "false");
+                }
+              }}
+            />
+          </>,
+          <Button
+            key="4"
+            type="link"
+            icon={<ReloadOutlined />}
+            onClick={() => {
+              ref.current?.reload();
+            }}
+          />,
+        ];
+      }}
+      search={{
+        filterType: "light",
+      }}
+      request={async (params = {}, sorter, filter) => {
+        console.log(params, sorter, filter);
+        let queryStatus = activeKey;
+        if (activeKey === "inbox") {
+          queryStatus = "read,unread";
+        }
+        let url = `/api/v2/notification?view=to&status=${queryStatus}`;
+        const offset =
+          ((params.current ? params.current : 1) - 1) *
+          (params.pageSize ? params.pageSize : 5);
+        url += `&limit=${params.pageSize}&offset=${offset}`;
+        console.info("api request", url);
+        const res = await get<INotificationListResponse>(url);
+        console.info("api response", res);
+        let items: INotification[] = [];
+        if (res.ok) {
+          items = res.data.rows.map((item) => {
+            return {
+              id: item.id,
+              from: item.from,
+              to: item.to,
+              channel: item.channel,
+              url: item.url,
+              title: item.title,
+              book_title: item.book_title,
+              content: item.content,
+              content_type: item.content_type,
+              res_type: item.res_type,
+              res_id: item.res_id,
+              status: item.status,
+              deleted_at: item.deleted_at,
+              created_at: item.created_at,
+              updated_at: item.updated_at,
+            };
+          });
+          if (typeof onChange !== "undefined") {
+            onChange(res.data.unread);
+          }
+        }
+
+        console.debug(items);
+        return {
+          total: res.data.count,
+          success: true,
+          data: items,
+        };
+      }}
+      pagination={{
+        pageSize: 5,
+      }}
+      metas={{
+        title: {
+          dataIndex: "user",
+          search: false,
+          render: (_, row) => {
+            return (
+              <Text strong={row.status === "unread"}>{row.from.nickName}</Text>
+            );
+          },
+        },
+        avatar: {
+          dataIndex: "avatar",
+          search: false,
+          render: (_, row) => {
+            return (
+              <Avatar size={"small"}>{row.from.nickName.slice(0, 1)}</Avatar>
+            );
+          },
+        },
+        description: {
+          dataIndex: "title",
+          search: false,
+          render: (_, row) => {
+            return (
+              <Text
+                style={{
+                  cursor: "pointer",
+                  opacity: row.status === "unread" ? 1 : 0.7,
+                }}
+                onClick={() => {
+                  window.open(row.url, "_blank");
+                }}
+              >
+                <div>
+                  <Text type="secondary">{row.book_title}</Text>
+                </div>
+                <Text style={{ fontWeight: 700 }}>{row.title}</Text>
+                <Marked style={{}} text={row.content} />
+              </Text>
+            );
+          },
+        },
+        subTitle: {
+          dataIndex: "labels",
+          render: (_, row) => {
+            return (
+              <Space>
+                <TimeShow createdAt={row.created_at} />
+                <Tag color="#87d068">{row.channel?.name}</Tag>
+                <Tag color="blue">{row.res_type}</Tag>
+              </Space>
+            );
+          },
+          search: false,
+        },
+        status: {
+          // 自己扩展的字段,主要用于筛选,不在列表中显示
+          title: "类型筛选",
+          valueType: "select",
+          valueEnum: {
+            all: { text: "全部", status: "Default" },
+            pr: {
+              text: "修改建议",
+              status: "Error",
+            },
+            discussion: {
+              text: "讨论",
+              status: "Success",
+            },
+          },
+        },
+      }}
+      toolbar={{
+        menu: {
+          activeKey,
+          items: [
+            {
+              key: "inbox",
+              label: "Inbox",
+            },
+            {
+              key: "unread",
+              label: "Unread",
+            },
+            {
+              key: "archived",
+              label: "Archived",
+            },
+          ],
+          onChange(key) {
+            setActiveKey(key);
+            ref.current?.reload();
+            if (ref.current?.setPageInfo) {
+              ref.current?.setPageInfo({ current: 1 });
+            }
+          },
+        },
+      }}
+    />
+  );
+};
+
+export default NotificationListWidget;

+ 9 - 4
dashboard-v6/src/components/sentence/SentCart.tsx

@@ -50,9 +50,7 @@ const SentCartWidget = () => {
         placement="bottomRight"
         arrow={{ pointAtCenter: true }}
         destroyOnHidden
-        getTooltipContainer={() =>
-          document.getElementsByClassName("toolbar_center")[0] as HTMLElement
-        }
+        getTooltipContainer={() => document.body}
         content={
           <div>
             <div style={{ display: "flex", justifyContent: "space-between" }}>
@@ -102,7 +100,14 @@ const SentCartWidget = () => {
         trigger="click"
       >
         <Badge style={{ cursor: "pointer" }} count={count} size="small">
-          <span style={{ color: "white", cursor: "pointer" }}>
+          <span
+            style={{
+              color: "inherit",
+              cursor: "pointer",
+              fontSize: 18,
+              display: "inline-flex",
+            }}
+          >
             <ShoppingCartOutlined />
           </span>
         </Badge>

+ 5 - 1
dashboard-v6/src/layouts/workspace/index.tsx

@@ -9,6 +9,8 @@ import ThemeSwitch from "../../components/theme/ThemeSwitch";
 import { NetworkStatus } from "../../components/general/NetworkStatus";
 import { useAuth } from "../../hooks/useAuth";
 import UiLangSelect from "../../components/general/UiLangSelect";
+import SentCart from "../../components/sentence/SentCart";
+import NotificationIcon from "../../components/notification/NotificationIcon";
 
 const { Sider, Content } = Layout;
 const Widget = () => {
@@ -54,7 +56,9 @@ const Widget = () => {
           }}
         >
           <HeaderBreadcrumb />
-          <Space>
+          <Space size="middle">
+            <SentCart />
+            <NotificationIcon />
             <NetworkStatus />
             <ThemeSwitch />
             <UiLangSelect />