visuddhinanda преди 1 месец
родител
ревизия
c8dce5913d

+ 16 - 12
dashboard-v6/documents/development/v6-todo-list.md

@@ -5,17 +5,17 @@
 ### 2️⃣ Basic 模块
 ### 2️⃣ Basic 模块
 
 
 - [x] `/palicanon`=>`workgroup/tipitaka`
 - [x] `/palicanon`=>`workgroup/tipitaka`
-- [ ] `/recent/list`=>`workgroup/recent`
+- [x] `/recent/list`=>`workgroup/recent`
 - [x] `/channel/list`=>`workgroup/channel`
 - [x] `/channel/list`=>`workgroup/channel`
-- [ ] `/exp/list`
-- [ ] `/setting`
-- [ ] `/ai/models/list`=>`resources/ai-models`
+- [ ] `/exp/list` [3]
+- [ ] `/setting` [3]
+- [ ] `/ai/models/list`=>`resources/ai-models` [3]
 
 
 ---
 ---
 
 
 ### 3️⃣ Advance 模块
 ### 3️⃣ Advance 模块
 
 
-#### Task 子模块
+#### Task 子模块 [2]
 
 
 - [ ] `/task/hall`=>`workgroup/task`
 - [ ] `/task/hall`=>`workgroup/task`
 - [ ] `/task/list`=>`workgroup/task`
 - [ ] `/task/list`=>`workgroup/task`
@@ -24,20 +24,24 @@
 
 
 #### 内容模块
 #### 内容模块
 
 
-- [ ] `/course/list`=>`workgroup/course`
-- [ ] `/dict/list`=>`resources/dict`
+- [ ] `/course/list`=>`workgroup/course` [2]
+- [ ] `/dict/list`=>`resources/dict` [1]
 - [x] `/term/list`=>`workgroup/term`
 - [x] `/term/list`=>`workgroup/term`
-- [ ] `/article/list`=>`workgroup/article`
+- [x] `/article/list`=>`workgroup/article`
 - [x] `/anthology/list`=>`workgroup/anthology`
 - [x] `/anthology/list`=>`workgroup/anthology`
 - [ ] `/attachment/list`=>`resources/attachment`
 - [ ] `/attachment/list`=>`resources/attachment`
-- [ ] `/tags/list`=>`resources/tags`
+- [ ] `/tags/list`=>`resources/tags` [3]
 
 
 ---
 ---
 
 
 ### 4️⃣ Collaboration 模块
 ### 4️⃣ Collaboration 模块
 
 
-- [ ] `/group/list`=>`collaboration/team`
-- [ ] `/invite/list`=>`collaboration/invite`
-- [ ] `/transfer/list`=>`collaboration/transfer`
+- [ ] `/group/list`=>`collaboration/team` [3]
+- [ ] `/invite/list`=>`collaboration/invite` [3]
+- [ ] `/transfer/list`=>`collaboration/transfer` [3]
 
 
 ---
 ---
+
+### 新增
+
+- [ ] `/discussion`=> [1]

+ 127 - 102
dashboard-v6/src/components/navigation/MainMenu.tsx

@@ -16,6 +16,9 @@ import {
   TipitakaIcon,
   TipitakaIcon,
 } from "../../assets/icon";
 } from "../../assets/icon";
 import React from "react";
 import React from "react";
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
+import { useRecent } from "../../hooks/useRecent.ts";
 
 
 /* ================= 类型 ================= */
 /* ================= 类型 ================= */
 
 
@@ -32,6 +35,7 @@ interface MenuItem {
 
 
 interface Props {
 interface Props {
   onSearch?: () => void;
   onSearch?: () => void;
+  onRecent?: () => void;
 }
 }
 
 
 export interface RouteHandle {
 export interface RouteHandle {
@@ -94,112 +98,130 @@ function findOpenKeys(
   return [];
   return [];
 }
 }
 
 
-/* ================= 菜单配置 ================= */
-
-const items: MenuItem[] = [
-  {
-    key: "search",
-    icon: <SearchOutlined />,
-    label: "搜索",
-  },
-  {
-    key: "/workspace",
-    icon: <HomeOutlined />,
-    label: "主页",
-    activeId: "workspace.home",
-  },
-  {
-    key: "/workspace/ai",
-    icon: <RobotIcon />,
-    label: "AI",
-    activeId: "workspace.ai",
-  },
-  {
-    key: "/workspace/tipitaka",
-    icon: <TipitakaIcon />,
-    label: "巴利三藏",
-    activeId: "workspace.tipitaka",
-  },
-
-  { type: "divider", key: "d1" },
-
-  {
-    key: "/workspace/recent",
-    icon: <FieldTimeOutlined />,
-    label: "最近打开",
-  },
-
-  {
-    key: "/workspace/articles",
-    icon: <DocumentIcon />,
-    label: "文章",
-    children: [
-      {
-        key: "/workspace/article",
-        label: "全部文章",
-        activeId: "workspace.article",
-        icon: <FileOutlined />,
-      },
-      {
-        key: "/workspace/anthology",
-        label: "文集",
-        activeId: "workspace.anthology",
-        icon: <FolderOutlined />,
-      },
-    ],
-  },
-
-  {
-    key: "/workspace/channel",
-    icon: <ChannelIcon />,
-    label: "频道",
-    activeId: "workspace.channel",
-  },
-
-  {
-    key: "/workspace/term",
-    icon: <ChannelIcon />,
-    label: "Term",
-    activeId: "workspace.term",
-  },
-
-  {
-    key: "/workspace/course",
-    icon: <CourseOutLinedIcon />,
-    label: "Course",
-  },
-
-  {
-    key: "/workspace/task",
-    icon: <TaskIcon />,
-    label: "Task",
-    activeId: "workspace.task",
-    children: [
-      {
-        key: "/workspace/task/pending",
-        label: "Pending",
-        activeId: "workspace.task.pending",
-      },
-      {
-        key: "/workspace/task/to-do-list",
-        label: "To-Do List",
-        activeId: "workspace.task.todo",
-      },
-      {
-        key: "/workspace/task/hell",
-        label: "Task Hell",
-        activeId: "workspace.task.hell",
-      },
-    ],
-  },
-];
-
 /* ================= 组件 ================= */
 /* ================= 组件 ================= */
 
 
-const Widget = ({ onSearch }: Props) => {
+const Widget = ({ onSearch, onRecent }: Props) => {
   const navigate = useNavigate();
   const navigate = useNavigate();
   const routeId = useCurrentRouteId();
   const routeId = useCurrentRouteId();
-
+  const currUser = useAppSelector(currentUser);
+
+  const { data } = useRecent(currUser?.id, 5, 0);
+
+  const recentList: MenuItem[] = data
+    ? data?.data.rows.map((item) => {
+        return {
+          key: item.id,
+          label: item.title,
+        };
+      })
+    : [];
+
+  /* ================= 菜单配置 ================= */
+
+  const items: MenuItem[] = [
+    {
+      key: "search",
+      icon: <SearchOutlined />,
+      label: "搜索",
+    },
+    {
+      key: "/workspace",
+      icon: <HomeOutlined />,
+      label: "主页",
+      activeId: "workspace.home",
+    },
+    {
+      key: "/workspace/ai",
+      icon: <RobotIcon />,
+      label: "AI",
+      activeId: "workspace.ai",
+    },
+    {
+      key: "/workspace/tipitaka",
+      icon: <TipitakaIcon />,
+      label: "巴利三藏",
+      activeId: "workspace.tipitaka",
+    },
+
+    { type: "divider", key: "d1" },
+
+    {
+      key: "/workspace/recent",
+      icon: <FieldTimeOutlined />,
+      label: "最近打开",
+      children: [
+        ...recentList,
+        {
+          key: "/workspace/recent/list",
+          label: "更多……",
+        },
+      ],
+    },
+
+    {
+      key: "/workspace/doc",
+      icon: <DocumentIcon />,
+      label: "文档",
+      children: [
+        {
+          key: "/workspace/article",
+          label: "文章",
+          activeId: "workspace.article",
+          icon: <FileOutlined />,
+        },
+        {
+          key: "/workspace/anthology",
+          label: "文集",
+          activeId: "workspace.anthology",
+          icon: <FolderOutlined />,
+        },
+      ],
+    },
+
+    {
+      key: "/workspace/channel",
+      icon: <ChannelIcon />,
+      label: "频道",
+      activeId: "workspace.channel",
+    },
+
+    {
+      key: "/workspace/term",
+      icon: <ChannelIcon />,
+      label: "Term",
+      activeId: "workspace.term",
+    },
+
+    {
+      key: "/workspace/course",
+      icon: <CourseOutLinedIcon />,
+      label: "Course",
+    },
+
+    {
+      key: "/workspace/task",
+      icon: <TaskIcon />,
+      label: "Task",
+      activeId: "workspace.task",
+      children: [
+        {
+          key: "/workspace/task/pending",
+          label: "Pending",
+          activeId: "workspace.task.pending",
+        },
+        {
+          key: "/workspace/task/to-do-list",
+          label: "To-Do List",
+          activeId: "workspace.task.todo",
+        },
+        {
+          key: "/workspace/task/hell",
+          label: "Task Hell",
+          activeId: "workspace.task.hell",
+        },
+      ],
+    },
+  ];
   console.log("nav", routeId);
   console.log("nav", routeId);
   /** 当前选中 */
   /** 当前选中 */
   const selectedKey = findSelectedKey(items, routeId);
   const selectedKey = findSelectedKey(items, routeId);
@@ -212,6 +234,9 @@ const Widget = ({ onSearch }: Props) => {
     if (key === "search") {
     if (key === "search") {
       onSearch?.();
       onSearch?.();
       return;
       return;
+    } else if (key === "/workspace/recent/list") {
+      onRecent?.();
+      return;
     }
     }
     navigate(key);
     navigate(key);
   };
   };

+ 253 - 0
dashboard-v6/src/components/recent/RecentList.tsx

@@ -0,0 +1,253 @@
+import { useIntl } from "react-intl";
+import { useEffect, useRef } from "react";
+import { Dropdown, Space, Typography } from "antd";
+import { SearchOutlined } from "@ant-design/icons";
+import { type ActionType, ProTable } from "@ant-design/pro-components";
+
+import { get } from "../../request";
+
+import { useAppSelector } from "../../hooks";
+import { currentUser as _currentUser } from "../../reducers/current-user";
+
+import {
+  ArticleOutlinedIcon,
+  ChapterOutlinedIcon,
+  ParagraphOutlinedIcon,
+} from "../../assets/icon";
+import type { ArticleType } from "../../api/Article";
+
+export interface IRecentRequest {
+  type: ArticleType;
+  article_id: string;
+  param?: string;
+}
+interface IParam {
+  book?: string;
+  para?: string;
+  channel?: string;
+  mode?: string;
+}
+interface IRecentData {
+  id: string;
+  title: string;
+  type: ArticleType;
+  article_id: string;
+  param: string | null;
+  updated_at: string;
+}
+
+export interface IRecentResponse {
+  ok: boolean;
+  message: string;
+  data: IRecentData;
+}
+interface IRecentListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: IRecentData[];
+    count: number;
+  };
+}
+
+export interface IRecent {
+  id: string;
+  title: string;
+  type: ArticleType;
+  articleId: string;
+  updatedAt: string;
+  param?: IParam;
+}
+
+interface IWidget {
+  onSelect?: (
+    event: React.MouseEvent<HTMLElement, MouseEvent>,
+    row: IRecent
+  ) => void;
+}
+const RecentWidget = ({ onSelect }: IWidget) => {
+  const intl = useIntl();
+  const user = useAppSelector(_currentUser);
+  const ref = useRef<ActionType | null>(null);
+
+  useEffect(() => {
+    ref.current?.reload();
+  }, [user]);
+  return (
+    <>
+      <ProTable<IRecent>
+        actionRef={ref}
+        columns={[
+          {
+            title: intl.formatMessage({
+              id: "dict.fields.sn.label",
+            }),
+            dataIndex: "sn",
+            key: "sn",
+            width: 50,
+            search: false,
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.title.label",
+            }),
+            dataIndex: "title",
+            key: "title",
+            tooltip: "过长会自动收缩",
+            ellipsis: true,
+            render: (_text, row, index) => {
+              let icon = <></>;
+              switch (row.type) {
+                case "article":
+                  icon = <ArticleOutlinedIcon />;
+                  break;
+                case "chapter":
+                  icon = <ChapterOutlinedIcon />;
+                  break;
+                case "para":
+                  icon = <ParagraphOutlinedIcon />;
+                  break;
+                default:
+                  break;
+              }
+              return (
+                <Space>
+                  {icon}
+                  <Typography.Link
+                    key={index}
+                    onClick={(event) => {
+                      if (typeof onSelect !== "undefined") {
+                        onSelect(event, row);
+                      }
+                    }}
+                  >
+                    {row.title}
+                  </Typography.Link>
+                </Space>
+              );
+            },
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.type.label",
+            }),
+            dataIndex: "type",
+            key: "type",
+            width: 100,
+            search: false,
+            filters: true,
+            onFilter: true,
+            valueEnum: {
+              all: { text: "全部", status: "Default" },
+              chapter: { text: "章节", status: "Success" },
+              article: { text: "文章", status: "Success" },
+              para: { text: "段落", status: "Success" },
+              sent: { text: "句子", status: "Success" },
+            },
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.updated-at.label",
+            }),
+            key: "updated-at",
+            width: 100,
+            search: false,
+            dataIndex: "updatedAt",
+            valueType: "date",
+          },
+          {
+            title: intl.formatMessage({ id: "buttons.option" }),
+            key: "option",
+            width: 120,
+            valueType: "option",
+            render: (_text, _row, index) => [
+              <Dropdown.Button
+                type="link"
+                key={index}
+                trigger={["click", "contextMenu"]}
+                menu={{
+                  items: [
+                    {
+                      key: "open",
+                      label: "在藏经阁中打开",
+                      icon: <SearchOutlined />,
+                    },
+                    {
+                      key: "share",
+                      label: "分享",
+                      icon: <SearchOutlined />,
+                    },
+                    {
+                      key: "delete",
+                      label: "删除",
+                      icon: <SearchOutlined />,
+                    },
+                  ],
+                  onClick: (e) => {
+                    switch (e.key) {
+                      case "share":
+                        break;
+                      case "delete":
+                        break;
+                      default:
+                        break;
+                    }
+                  },
+                }}
+              >
+                {intl.formatMessage({ id: "buttons.edit" })}
+              </Dropdown.Button>,
+            ],
+          },
+        ]}
+        request={async (params = {}, sorter, filter) => {
+          console.log(params, sorter, filter);
+          if (typeof user === "undefined") {
+            return {
+              total: 0,
+              succcess: false,
+              data: [],
+            };
+          }
+          let url = `/api/v2/recent?view=user&id=${user?.id}`;
+          const offset =
+            ((params.current ? params.current : 1) - 1) *
+            (params.pageSize ? params.pageSize : 10);
+          url += `&limit=${params.pageSize}&offset=${offset}`;
+          url += params.keyword ? "&search=" + params.keyword : "";
+          console.log("url", url);
+          const res = await get<IRecentListResponse>(url);
+          console.log("article list", res);
+          const items: IRecent[] = res.data.rows.map((item, id) => {
+            return {
+              sn: id + 1,
+              id: item.id,
+              title: item.title,
+              type: item.type,
+              articleId: item.article_id,
+              param: item.param ? JSON.parse(item.param) : undefined,
+              updatedAt: item.updated_at,
+            };
+          });
+          return {
+            total: res.data.count,
+            succcess: true,
+            data: items,
+          };
+        }}
+        rowKey="id"
+        bordered
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+        }}
+        search={false}
+        options={{
+          search: true,
+        }}
+      />
+    </>
+  );
+};
+
+export default RecentWidget;

+ 61 - 0
dashboard-v6/src/components/recent/RecentModal.tsx

@@ -0,0 +1,61 @@
+import React, { useState } from "react";
+import { Modal } from "antd";
+import RecentList, { type IRecent } from "./RecentList";
+import { useIntl } from "react-intl";
+
+interface IWidget {
+  trigger?: React.ReactNode;
+  open?: boolean;
+  onSelect?: (
+    event: React.MouseEvent<HTMLElement, MouseEvent>,
+    row: IRecent
+  ) => void;
+  onOpenChange?: (open: boolean) => void;
+}
+const RecentModal = ({
+  trigger,
+  open = false,
+  onSelect,
+  onOpenChange,
+}: IWidget) => {
+  const [innerOpen, setInnerOpen] = useState<boolean>();
+  const intl = useIntl();
+
+  const isModalOpen = open !== undefined ? open : innerOpen;
+
+  const showModal = () => {
+    setInnerOpen(true);
+    onOpenChange?.(true);
+  };
+
+  const handleOk = () => {
+    setInnerOpen(false);
+    onOpenChange?.(false);
+  };
+
+  const handleCancel = () => {
+    setInnerOpen(false);
+    onOpenChange?.(false);
+  };
+
+  return (
+    <>
+      <span onClick={showModal}>{trigger}</span>
+      <Modal
+        width={"80%"}
+        title={intl.formatMessage({
+          id: `labels.recent-scan`,
+        })}
+        footer={false}
+        open={isModalOpen}
+        onOk={handleOk}
+        onCancel={handleCancel}
+        destroyOnHidden
+      >
+        <RecentList onSelect={onSelect} />
+      </Modal>
+    </>
+  );
+};
+
+export default RecentModal;

+ 88 - 0
dashboard-v6/src/hooks/useRecent.ts.ts

@@ -0,0 +1,88 @@
+// hooks/useRecent.ts
+/**
+ * useRecent
+ *
+ * 获取用户最近访问记录列表
+ *
+ * @param userId   用户 ID(undefined 时不发请求)
+ * @param pageSize 每页条数
+ * @param page     页码,默认 0
+ * @returns
+ *   - data      原始响应数据 IRecentListResponse,未请求或失败时为 null
+ *   - loading   请求进行中
+ *   - errorCode 请求失败时的错误码,无错误时为 null
+ *   - refresh   手动重新请求
+ *
+ * @example
+ * const { data, loading, errorCode, refresh } = useRecent(userId, 10, 0);
+ *
+ * if (loading) return <Skeleton />;
+ * if (errorCode) return <ErrorResult code={errorCode} />;
+ * if (data) return <List rows={data.data.rows} />;
+ */
+import { useState, useEffect, useCallback } from "react";
+import { getRecentByUser, type IRecentListResponse } from "../api/recent";
+import { HttpError } from "../request";
+
+interface UseRecentResult {
+  data: IRecentListResponse | null;
+  loading: boolean;
+  errorCode: number | null;
+  refresh: () => void;
+}
+
+export const useRecent = (
+  userId?: string,
+  pageSize: number = 20,
+  page: number = 0
+): UseRecentResult => {
+  const [data, setData] = useState<IRecentListResponse | null>(null);
+  const [loading, setLoading] = useState(false);
+  const [errorCode, setErrorCode] = useState<number | null>(null);
+  const [tick, setTick] = useState(0);
+
+  const refresh = useCallback(() => setTick((t) => t + 1), []);
+
+  useEffect(() => {
+    if (!userId) return;
+
+    let active = true;
+
+    const fetchData = async () => {
+      setLoading(true);
+      setErrorCode(null);
+
+      try {
+        const res = await getRecentByUser(userId, pageSize, page);
+        if (!active) return;
+
+        if (!res.ok) {
+          setErrorCode(-1);
+          return;
+        }
+
+        setData(res);
+      } catch (e) {
+        console.error("recent fetch", e);
+
+        if (active) {
+          if (e instanceof HttpError) {
+            setErrorCode(e.status);
+          } else {
+            setErrorCode(0);
+          }
+        }
+      } finally {
+        if (active) setLoading(false);
+      }
+    };
+
+    fetchData();
+
+    return () => {
+      active = false;
+    };
+  }, [userId, pageSize, page, tick]);
+
+  return { data, loading, errorCode, refresh };
+};

+ 20 - 2
dashboard-v6/src/layouts/workspace/index.tsx

@@ -1,5 +1,5 @@
 import { Button, Layout, Space } from "antd";
 import { Button, Layout, Space } from "antd";
-import { Outlet } from "react-router";
+import { Outlet, useNavigate } from "react-router";
 import { useState } from "react";
 import { useState } from "react";
 import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
 import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
 import MainMenu from "../../components/navigation/MainMenu";
 import MainMenu from "../../components/navigation/MainMenu";
@@ -7,10 +7,17 @@ import SignInAvatar from "../../components/auth/SignInAvatar";
 import HeaderBreadcrumb from "../../components/navigation/HeaderBreadcrumb";
 import HeaderBreadcrumb from "../../components/navigation/HeaderBreadcrumb";
 import ThemeSwitch from "../../components/theme/ThemeSwitch";
 import ThemeSwitch from "../../components/theme/ThemeSwitch";
 import { NetworkStatus } from "../../components/general/NetworkStatus";
 import { NetworkStatus } from "../../components/general/NetworkStatus";
+import RecentModal from "../../components/recent/RecentModal";
 
 
 const { Sider, Content } = Layout;
 const { Sider, Content } = Layout;
 const Widget = () => {
 const Widget = () => {
   const [collapsed, setCollapsed] = useState(false);
   const [collapsed, setCollapsed] = useState(false);
+  const [recentOpen, setRecentOpen] = useState(false);
+  const navigate = useNavigate();
+
+  const recent = () => {
+    setRecentOpen(true);
+  };
   return (
   return (
     <Layout style={{ minHeight: "100vh" }}>
     <Layout style={{ minHeight: "100vh" }}>
       <Sider
       <Sider
@@ -27,7 +34,7 @@ const Widget = () => {
             onClick={() => setCollapsed(!collapsed)}
             onClick={() => setCollapsed(!collapsed)}
           />
           />
         </div>
         </div>
-        <MainMenu />
+        <MainMenu onRecent={recent} />
       </Sider>
       </Sider>
       <Layout>
       <Layout>
         <div
         <div
@@ -50,6 +57,17 @@ const Widget = () => {
           <Outlet />
           <Outlet />
         </Content>
         </Content>
       </Layout>
       </Layout>
+      <RecentModal
+        open={recentOpen}
+        onOpenChange={() => setRecentOpen(false)}
+        onSelect={(e, row) => {
+          if (e.ctrlKey || e.metaKey) {
+            window.open("");
+          } else {
+            navigate(`/workspace/${row.type}/${row.articleId}`);
+          }
+        }}
+      />
     </Layout>
     </Layout>
   );
   );
 };
 };