Browse Source

Merge pull request #1174 from visuddhinanda/agile

lazy load 支持
visuddhinanda 2 years ago
parent
commit
a300f13208
68 changed files with 1944 additions and 609 deletions
  1. 1 0
      .gitignore
  2. 5 0
      dashboard/src/Router.tsx
  3. 0 95
      dashboard/src/assets/font/Padauk/OFL.txt
  4. BIN
      dashboard/src/assets/font/Padauk/Padauk-Bold.ttf
  5. BIN
      dashboard/src/assets/font/Padauk/Padauk-Regular.ttf
  6. 0 15
      dashboard/src/assets/font/main.css
  7. 75 0
      dashboard/src/assets/icon/index.tsx
  8. 11 0
      dashboard/src/components/admin/LeftSider.tsx
  9. 64 0
      dashboard/src/components/admin/api/ApiDelayHour.tsx
  10. 83 0
      dashboard/src/components/admin/api/ApiGauge.tsx
  11. 1 1
      dashboard/src/components/anthology/AnthologyTocTree.tsx
  12. 5 0
      dashboard/src/components/api/Article.ts
  13. 4 10
      dashboard/src/components/api/Attachments.ts
  14. 2 1
      dashboard/src/components/api/Auth.ts
  15. 1 0
      dashboard/src/components/api/Course.ts
  16. 51 0
      dashboard/src/components/api/view.ts
  17. 41 0
      dashboard/src/components/article/AnchorNav.tsx
  18. 1 1
      dashboard/src/components/article/AnthologyDetail.tsx
  19. 3 4
      dashboard/src/components/article/AnthologyStudioList.tsx
  20. 80 6
      dashboard/src/components/article/Article.tsx
  21. 69 0
      dashboard/src/components/article/ArticleDrawer.tsx
  22. 108 0
      dashboard/src/components/article/ArticleListPublic.tsx
  23. 90 0
      dashboard/src/components/article/ArticlePrevDrawer.tsx
  24. 30 6
      dashboard/src/components/article/ArticleView.tsx
  25. 47 31
      dashboard/src/components/blog/TimeLine.tsx
  26. 1 1
      dashboard/src/components/blog/TopArticleCard.tsx
  27. 2 2
      dashboard/src/components/blog/TopArticles.tsx
  28. 8 2
      dashboard/src/components/corpus/ChapterList.tsx
  29. 28 20
      dashboard/src/components/corpus/ChapterTagList.tsx
  30. 101 0
      dashboard/src/components/corpus/CommunityChapter.tsx
  31. 11 3
      dashboard/src/components/corpus/PaliChapterCard.tsx
  32. 2 0
      dashboard/src/components/corpus/PaliChapterListByPara.tsx
  33. 11 11
      dashboard/src/components/corpus/PaliChapterListByTag.tsx
  34. 2 1
      dashboard/src/components/corpus/Recent.tsx
  35. 65 55
      dashboard/src/components/corpus/RelatedPara.tsx
  36. 111 0
      dashboard/src/components/corpus/SentHistory.tsx
  37. 56 0
      dashboard/src/components/corpus/SentHistoryModal.tsx
  38. 102 0
      dashboard/src/components/corpus/TopChapter.tsx
  39. 6 2
      dashboard/src/components/course/CourseHead.tsx
  40. 16 4
      dashboard/src/components/course/CourseInfoEdit.tsx
  41. 18 3
      dashboard/src/components/course/CourseList.tsx
  42. 15 4
      dashboard/src/components/course/LecturerList.tsx
  43. 1 1
      dashboard/src/components/course/TextBook.tsx
  44. 17 11
      dashboard/src/components/general/TimeShow.tsx
  45. 3 4
      dashboard/src/components/general/VideoModal.tsx
  46. 36 0
      dashboard/src/components/general/VisibleObserver.tsx
  47. 1 1
      dashboard/src/components/home/CourseNewList.tsx
  48. 3 4
      dashboard/src/components/library/FooterBar.tsx
  49. 46 22
      dashboard/src/components/template/Article.tsx
  50. 21 3
      dashboard/src/components/template/SentEdit/SentEditMenu.tsx
  51. 70 68
      dashboard/src/components/template/Wbw/WbwDetail.tsx
  52. 7 2
      dashboard/src/components/template/Wbw/WbwDetailUpload.tsx
  53. 4 4
      dashboard/src/components/template/Wbw/WbwPali.tsx
  54. 19 5
      dashboard/src/components/template/Wbw/WbwVideoButton.tsx
  55. 9 1
      dashboard/src/components/template/Wbw/WbwWord.tsx
  56. 1 0
      dashboard/src/components/template/WbwSent.tsx
  57. 5 0
      dashboard/src/locales/zh-Hans/label.ts
  58. 31 0
      dashboard/src/pages/admin/api/dashboard.tsx
  59. 15 0
      dashboard/src/pages/admin/api/index.tsx
  60. 19 4
      dashboard/src/pages/library/anthology/list.tsx
  61. 18 7
      dashboard/src/pages/library/article/show.tsx
  62. 7 8
      dashboard/src/pages/library/blog/overview.tsx
  63. 2 0
      dashboard/src/pages/library/blog/translation.tsx
  64. 2 2
      dashboard/src/pages/library/course/course.tsx
  65. 6 4
      dashboard/src/pages/library/palicanon/bypath.tsx
  66. 13 3
      dashboard/src/pages/studio/article/edit.tsx
  67. 15 3
      dashboard/src/pages/studio/course/list.tsx
  68. 246 174
      dashboard/src/pages/studio/recent/list.tsx

+ 1 - 0
.gitignore

@@ -1,2 +1,3 @@
 tmp
 *.log
+/.VSCodeCounter

+ 5 - 0
dashboard/src/Router.tsx

@@ -22,6 +22,8 @@ import AdminRelation from "./pages/admin/relation";
 import AdminRelationList from "./pages/admin/relation/list";
 import AdminNissayaEnding from "./pages/admin/nissaya-ending";
 import AdminNissayaEndingList from "./pages/admin/nissaya-ending/list";
+import AdminApi from "./pages/admin/api";
+import AdminApiDashboard from "./pages/admin/api/dashboard";
 
 import LibraryHome from "./pages/library";
 import LibraryCommunity from "./pages/library/community";
@@ -107,6 +109,9 @@ const Widget = () => {
     <ConfigProvider prefixCls={theme}>
       <Routes>
         <Route path="admin" element={<AdminHome />}>
+          <Route path="api" element={<AdminApi />}>
+            <Route path="dashboard" element={<AdminApiDashboard />} />
+          </Route>
           <Route path="relation" element={<AdminRelation />}>
             <Route path="list" element={<AdminRelationList />} />
           </Route>

+ 0 - 95
dashboard/src/assets/font/Padauk/OFL.txt

@@ -1,95 +0,0 @@
-Copyright SIL International, all rights reserved
-Reserved names: "Padauk"
-
-
-This Font Software is licensed under the SIL Open Font License, Version 1.1.
-This license is copied below, and is also available with a FAQ at:
-http://scripts.sil.org/OFL
-
-
------------------------------------------------------------
-SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
------------------------------------------------------------
-
-PREAMBLE
-The goals of the Open Font License (OFL) are to stimulate worldwide
-development of collaborative font projects, to support the font creation
-efforts of academic and linguistic communities, and to provide a free and
-open framework in which fonts may be shared and improved in partnership
-with others.
-
-The OFL allows the licensed fonts to be used, studied, modified and
-redistributed freely as long as they are not sold by themselves. The
-fonts, including any derivative works, can be bundled, embedded, 
-redistributed and/or sold with any software provided that any reserved
-names are not used by derivative works. The fonts and derivatives,
-however, cannot be released under any other type of license. The
-requirement for fonts to remain under this license does not apply
-to any document created using the fonts or their derivatives.
-
-DEFINITIONS
-"Font Software" refers to the set of files released by the Copyright
-Holder(s) under this license and clearly marked as such. This may
-include source files, build scripts and documentation.
-
-"Reserved Font Name" refers to any names specified as such after the
-copyright statement(s).
-
-"Original Version" refers to the collection of Font Software components as
-distributed by the Copyright Holder(s).
-
-"Modified Version" refers to any derivative made by adding to, deleting,
-or substituting -- in part or in whole -- any of the components of the
-Original Version, by changing formats or by porting the Font Software to a
-new environment.
-
-"Author" refers to any designer, engineer, programmer, technical
-writer or other person who contributed to the Font Software.
-
-PERMISSION & CONDITIONS
-Permission is hereby granted, free of charge, to any person obtaining
-a copy of the Font Software, to use, study, copy, merge, embed, modify,
-redistribute, and sell modified and unmodified copies of the Font
-Software, subject to the following conditions:
-
-1) Neither the Font Software nor any of its individual components,
-in Original or Modified Versions, may be sold by itself.
-
-2) Original or Modified Versions of the Font Software may be bundled,
-redistributed and/or sold with any software, provided that each copy
-contains the above copyright notice and this license. These can be
-included either as stand-alone text files, human-readable headers or
-in the appropriate machine-readable metadata fields within text or
-binary files as long as those fields can be easily viewed by the user.
-
-3) No Modified Version of the Font Software may use the Reserved Font
-Name(s) unless explicit written permission is granted by the corresponding
-Copyright Holder. This restriction only applies to the primary font name as
-presented to the users.
-
-4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
-Software shall not be used to promote, endorse or advertise any
-Modified Version, except to acknowledge the contribution(s) of the
-Copyright Holder(s) and the Author(s) or with their explicit written
-permission.
-
-5) The Font Software, modified or unmodified, in part or in whole,
-must be distributed entirely under this license, and must not be
-distributed under any other license. The requirement for fonts to
-remain under this license does not apply to any document created
-using the Font Software.
-
-TERMINATION
-This license becomes null and void if any of the above conditions are
-not met.
-
-DISCLAIMER
-THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
-OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
-COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
-INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
-DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
-OTHER DEALINGS IN THE FONT SOFTWARE.

BIN
dashboard/src/assets/font/Padauk/Padauk-Bold.ttf


BIN
dashboard/src/assets/font/Padauk/Padauk-Regular.ttf


+ 0 - 15
dashboard/src/assets/font/main.css

@@ -241,21 +241,6 @@
   font-display: fallback;
 }
 
-/*缅文*/
-@font-face {
-  font-family: "Padauk";
-  font-style: normal;
-  font-weight: 400;
-  src: local("Padauk"), url(./Padauk/Padauk-Regular.ttf) format("truetype");
-  font-display: fallback;
-}
-@font-face {
-  font-family: "Padauk";
-  font-style: normal;
-  font-weight: 700;
-  src: local("Padauk Bold"), url(./Padauk/Padauk-Bold.ttf) format("truetype");
-  font-display: fallback;
-}
 /*Noto Sans Myanmar*/
 @font-face {
   font-family: "Noto Sans Myanmar";

+ 75 - 0
dashboard/src/assets/icon/index.tsx

@@ -100,6 +100,69 @@ const ColumnOutlined = () => (
     <path d="M546 177v700h-64V177h64z" p-id="868"></path>
   </svg>
 );
+
+const ChapterOutlined = () => (
+  <svg
+    viewBox="0 0 1024 1024"
+    version="1.1"
+    xmlns="http://www.w3.org/2000/svg"
+    p-id="1463"
+    width="1em"
+    height="1em"
+  >
+    <path
+      d="M311.1 630.9c-22.2 0-40.3 18-40.3 40.3s18 40.3 40.3 40.3c22.2 0 40.3-18 40.3-40.3-0.1-22.3-18.1-40.3-40.3-40.3z m0-80.6c22.1-0.2 39.9-18.2 39.9-40.3 0-22.1-17.8-40-39.9-40.3-14.5-0.1-27.9 7.5-35.2 20-7.3 12.5-7.3 28 0 40.5a39.93 39.93 0 0 0 35.2 20.1z m161.1-161.1h241.7c14.4 0 27.7-7.7 34.9-20.1 7.2-12.5 7.2-27.8 0-40.3s-20.5-20.1-34.9-20.1H472.2c-14.4 0-27.7 7.7-34.9 20.1-7.2 12.5-7.2 27.8 0 40.3 7.2 12.4 20.5 20.1 34.9 20.1zM282.556 377.375c15.738 15.738 41.255 15.737 56.993-0.001s15.737-41.255-0.001-56.993-41.255-15.737-56.993 0.001c-15.738 15.739-15.737 41.255 0.001 56.993z"
+      fill="#333333"
+      p-id="1464"
+    ></path>
+    <path
+      d="M834.7 67H190.2c-44.5 0-80.6 36.1-80.6 80.6v725c0 44.5 36.1 80.5 80.6 80.5h644.4c44.5 0 80.5-36.1 80.5-80.5v-725c0.1-44.5-35.9-80.6-80.4-80.6z m0 765.2c0 22.2-18 40.3-40.3 40.3H230.5c-10.7 0-20.9-4.2-28.5-11.8a40.182 40.182 0 0 1-11.8-28.5V187.8c0-22.2 18-40.3 40.3-40.3h563.9c10.7 0 20.9 4.2 28.5 11.8s11.8 17.8 11.8 28.5v644.4z"
+      fill="#333333"
+      p-id="1465"
+    ></path>
+    <path
+      d="M713.8 630.9H472.2c-22.2 0-40.3 18-40.3 40.3s18 40.3 40.3 40.3h241.7c22.2 0 40.3-18 40.3-40.3-0.1-22.3-18.1-40.3-40.4-40.3z m-241.6-80.6h241.7c22.1-0.2 39.9-18.2 39.9-40.3 0-22.1-17.8-40-39.9-40.3H472.2c-14.5-0.1-27.9 7.5-35.2 20-7.3 12.5-7.3 28 0 40.5a39.93 39.93 0 0 0 35.2 20.1z"
+      fill="#333333"
+      p-id="1466"
+    ></path>
+  </svg>
+);
+
+const ArticleOutlined = () => (
+  <svg
+    viewBox="0 0 1024 1024"
+    version="1.1"
+    xmlns="http://www.w3.org/2000/svg"
+    p-id="4245"
+    width="1em"
+    height="1em"
+  >
+    <path
+      d="M258.9696 716.8h411.9552v24.064c0 54.6816 13.2096 79.36 39.6288 83.6608 23.2448-3.8912 40.2432-23.9616 40.2432-47.5136V276.0704c-1.4336-71.9872 29.0816-114.3808 88.8832-114.3808V209.92c-29.0816 0-41.6768 17.5104-40.7552 65.6384v501.4528c0 47.8208-35.0208 88.3712-82.2272 95.3344v1.024H234.9056c-53.248 0-96.3584-43.1104-96.3584-96.3584V716.8h72.2944V246.8864c0-53.248 43.1104-96.3584 96.3584-96.3584h489.472c57.6512 0 88.8832 37.888 88.8832 102.6048v113.8688h-110.592v-48.2304h62.3616v-65.6384c0-39.936-11.9808-54.4768-40.7552-54.4768H307.2c-26.624 0-48.2304 21.6064-48.2304 48.2304V716.8z m99.2256 108.4416h280.8832c-8.192-16.2816-13.312-36.5568-15.2576-60.2112H186.6752v12.0832c0 26.624 21.6064 48.2304 48.2304 48.2304l123.2896-0.1024z m-17.1008-427.7248c-13.312 0-24.064-10.752-24.064-24.064s10.752-24.064 24.064-24.064h325.2224c13.312 0 24.064 10.752 24.064 24.064s-10.752 24.064-24.064 24.064H341.0944z m0.1024 132.608c-13.312 0-24.064-10.752-24.064-24.064s10.752-24.064 24.064-24.064h164.5568c13.312 0 24.064 10.752 24.064 24.064s-10.752 24.064-24.064 24.064H341.1968z"
+      p-id="4246"
+    ></path>
+  </svg>
+);
+
+const ParagraphOutlined = () => (
+  <svg
+    viewBox="0 0 1024 1024"
+    version="1.1"
+    xmlns="http://www.w3.org/2000/svg"
+    p-id="4639"
+    width="1em"
+    height="1em"
+  >
+    <path
+      d="M122.368 165.888h778.24c-9.216 0-16.384-7.168-16.384-16.384v713.728c0-9.216 7.168-16.384 16.384-16.384h-778.24c9.216 0 16.384 7.168 16.384 16.384V150.016c0 8.192-6.656 15.872-16.384 15.872z m-32.768 684.544c0 26.112 20.992 47.104 47.104 47.104h750.08c26.112 0 47.104-20.992 47.104-47.104V162.304c0-26.112-20.992-47.104-47.104-47.104H136.704c-26.112 0-47.104 20.992-47.104 47.104v688.128z"
+      p-id="4640"
+    ></path>
+    <path
+      d="M597.504 300.544h230.912v49.152h-230.912zM596.992 437.76h230.912v49.152h-230.912zM210.432 574.976h617.984v49.152H210.432zM210.432 712.192h617.984v49.152H210.432zM246.784 296.448h88.064V501.76h-29.184v29.184h117.248V501.76h-29.696V296.448H481.28v29.184h29.184V238.08H217.6v87.552h29.184z"
+      p-id="4641"
+    ></path>
+  </svg>
+);
 export const DictIcon = (props: Partial<CustomIconComponentProps>) => (
   <Icon component={DictSvg} {...props} />
 );
@@ -126,3 +189,15 @@ export const HandOutlinedIcon = (props: Partial<CustomIconComponentProps>) => (
 export const ColumnOutlinedIcon = (
   props: Partial<CustomIconComponentProps>
 ) => <Icon component={ColumnOutlined} {...props} />;
+
+export const ChapterOutlinedIcon = (
+  props: Partial<CustomIconComponentProps>
+) => <Icon component={ChapterOutlined} {...props} />;
+
+export const ArticleOutlinedIcon = (
+  props: Partial<CustomIconComponentProps>
+) => <Icon component={ArticleOutlined} {...props} />;
+
+export const ParagraphOutlinedIcon = (
+  props: Partial<CustomIconComponentProps>
+) => <Icon component={ParagraphOutlined} {...props} />;

+ 11 - 0
dashboard/src/components/admin/LeftSider.tsx

@@ -15,6 +15,17 @@ type IWidgetHeadBar = {
 };
 const LeftSiderWidget = ({ selectedKeys = "" }: IWidgetHeadBar) => {
   const items: MenuProps["items"] = [
+    {
+      label: "API",
+      key: "api",
+      icon: <HomeOutlined />,
+      children: [
+        {
+          label: <Link to="/admin/api/dashboard">dashboard</Link>,
+          key: "dashboard",
+        },
+      ],
+    },
     {
       label: "管理",
       key: "manage",

+ 64 - 0
dashboard/src/components/admin/api/ApiDelayHour.tsx

@@ -0,0 +1,64 @@
+import React, { useEffect, useState } from "react";
+import { Column } from "@ant-design/plots";
+import { put } from "../../../request";
+import { StatisticCard } from "@ant-design/pro-components";
+
+interface IApiDelay {
+  date: string;
+  value: number;
+}
+interface IApiDelayResponse {
+  ok: boolean;
+  message: string;
+  data: IApiDelay[];
+}
+interface IApiRequest {
+  api: string;
+  item: string;
+}
+
+interface IWidget {
+  title?: React.ReactNode;
+  type: "average" | "count" | "delay";
+  api?: string;
+}
+
+const ApiDelayHourWidget = ({ title, type, api = "all" }: IWidget) => {
+  const [delayData, setDelayData] = useState<IApiDelay[]>([]);
+
+  useEffect(() => {
+    put<IApiRequest, IApiDelayResponse>("/v2/api/10", {
+      api: api,
+      item: type,
+    }).then((json) => {
+      console.log("data", json.data);
+      setDelayData(json.data);
+    });
+  }, []);
+
+  const config = {
+    data: delayData,
+    xField: "date",
+    yField: "value",
+    seriesField: "",
+    xAxis: {
+      label: {
+        autoHide: true,
+        autoRotate: false,
+      },
+    },
+  };
+
+  return (
+    <StatisticCard
+      statistic={{
+        title: title,
+        value: "",
+        suffix: "/ ms",
+      }}
+      chart={<Column {...config} height={300} />}
+    />
+  );
+};
+
+export default ApiDelayHourWidget;

+ 83 - 0
dashboard/src/components/admin/api/ApiGauge.tsx

@@ -0,0 +1,83 @@
+import { useEffect, useRef, useState } from "react";
+import { Gauge } from "@ant-design/plots";
+import { get } from "../../../request";
+import { StatisticCard } from "@ant-design/pro-components";
+
+interface IApiResponse {
+  ok: boolean;
+  message: string;
+  data: number;
+}
+const ApiGaugeWidget = () => {
+  const min = 0;
+  const max = 1;
+  const [percent, setPercent] = useState<number>(0);
+  const [delay, setDelay] = useState<number>(0);
+  const maxAxis = 5000; //最大量程-毫秒
+
+  useEffect(() => {
+    let timer = setInterval(() => {
+      get<IApiResponse>("/v2/api/10?item=average").then((json) => {
+        setPercent(json.data / maxAxis);
+        setDelay(json.data);
+      });
+    }, 1000 * 5);
+    return () => {
+      clearInterval(timer);
+    };
+  }, []);
+
+  const graphRef: any = useRef(null);
+
+  const config = {
+    percent: percent,
+    range: {
+      ticks: [min, max],
+      color: ["l(0) 0:#30BF78 0.5:#FAAD14 1:#F4664A"],
+    },
+    indicator: {
+      pointer: {
+        style: {
+          stroke: "#D0D0D0",
+        },
+      },
+      pin: {
+        style: {
+          stroke: "#D0D0D0",
+        },
+      },
+    },
+    axis: {
+      label: {
+        formatter(v: any) {
+          return Number(v) * maxAxis;
+        },
+      },
+      subTickLine: {
+        count: 3,
+      },
+    },
+  };
+
+  return (
+    <StatisticCard
+      style={{ width: 400 }}
+      statistic={{
+        title: "平均相应时间",
+        value: delay,
+        suffix: "/ ms",
+      }}
+      chart={
+        <Gauge
+          ref={graphRef}
+          {...config}
+          onReady={(chart) => {
+            graphRef.current = chart;
+          }}
+        />
+      }
+    />
+  );
+};
+
+export default ApiGaugeWidget;

+ 1 - 1
dashboard/src/components/anthology/AnthologyTocTree.tsx

@@ -45,7 +45,7 @@ const AnthologyTocTreeWidget = ({
           if (typeof onArticleSelect !== "undefined") {
             onArticleSelect(keys);
           } else {
-            navigate(`/article/article/${keys[0]}/read`);
+            navigate(`/article/article/${keys[0]}?mode=read`);
           }
         }}
       />

+ 5 - 0
dashboard/src/components/api/Article.ts

@@ -94,6 +94,11 @@ export interface IArticleDataResponse {
   editor?: IUser;
   created_at: string;
   updated_at: string;
+  from?: number;
+  to?: number;
+  mode?: string;
+  paraId?: string;
+  channels?: string;
 }
 export interface IArticleResponse {
   ok: boolean;

+ 4 - 10
dashboard/src/components/api/Attachments.ts

@@ -1,14 +1,8 @@
-/*
-            'name' => $filename,
-            'size' => $file->getSize(),
-            'type' => $file->getMimeType(),
-            'url' => $filename,
-*/
 export interface IAttachmentRequest {
-  uid: string;
-  name?: string;
-  size?: number;
-  type: string;
+  id: string;
+  name: string;
+  size: number;
+  content_type: string;
   url: string;
 }
 export interface IAttachmentResponse {

+ 2 - 1
dashboard/src/components/api/Auth.ts

@@ -39,7 +39,8 @@ export interface IUserApiResponse {
 export interface IStudioApiResponse {
   id: string;
   nickName: string;
-  studioName: string;
+  studioName?: string;
+  realName: string;
   avatar?: string;
   owner: IUserApiResponse;
 }

+ 1 - 0
dashboard/src/components/api/Course.ts

@@ -45,6 +45,7 @@ export interface ICourseDataResponse {
   end_at: string; //课程结束时间
   content: string; //简介
   cover: string; //封面图片文件名
+  cover_url?: string[]; //封面图片文件名
   member_count: number;
   join: TCourseJoinMode;
   request_exp: TCourseExpRequest;

+ 51 - 0
dashboard/src/components/api/view.ts

@@ -0,0 +1,51 @@
+import { ArticleType } from "../article/Article";
+
+export interface IViewRequest {
+  target_type: ArticleType;
+  book: number;
+  para: number;
+  channel: string;
+  mode: string;
+}
+export interface IMetaChapter {
+  book: number;
+  para: number;
+  channel: string;
+  mode: string;
+}
+export interface IViewData {
+  id: string;
+  target_id: string;
+  target_type: ArticleType;
+  updated_at: string;
+  title: string;
+  org_title: string;
+  meta: string;
+}
+export interface IViewStoreResponse {
+  ok: boolean;
+  message: string;
+  data: number;
+}
+export interface IViewResponse {
+  ok: boolean;
+  message: string;
+  data: IViewData;
+}
+export interface IViewListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: IViewData[];
+    count: number;
+  };
+}
+
+export interface IView {
+  id: string;
+  title: string;
+  subtitle: string;
+  type: ArticleType;
+  updatedAt: string;
+  meta: IMetaChapter;
+}

+ 41 - 0
dashboard/src/components/article/AnchorNav.tsx

@@ -0,0 +1,41 @@
+import { Anchor } from "antd";
+import lodash from "lodash";
+import { useEffect, useState } from "react";
+const { Link } = Anchor;
+
+interface IHeadingAnchor {
+  label: string;
+  key: string;
+}
+interface IWidget {
+  content?: string;
+  open?: boolean;
+}
+const AnchorNavWidget = ({ open = false, content }: IWidget) => {
+  const [heading, setHeading] = useState<IHeadingAnchor[]>([]);
+
+  useEffect(() => {
+    let heading = document.querySelectorAll("h1,h2,h3,h4,h5,h6");
+    let headingAnchor: IHeadingAnchor[] = [];
+    for (let index = 0; index < heading.length; index++) {
+      const element = heading[index];
+      const id = lodash
+        .times(20, () => lodash.random(35).toString(36))
+        .join("");
+      heading[index].id = id;
+      headingAnchor.push({ key: `#${id}`, label: element.innerHTML });
+    }
+    setHeading(headingAnchor);
+  }, [open]);
+  return open ? (
+    <Anchor offsetTop={50}>
+      {heading.map((item, index) => {
+        return <Link key={index} href={item.key} title={item.label}></Link>;
+      })}
+    </Anchor>
+  ) : (
+    <></>
+  );
+};
+
+export default AnchorNavWidget;

+ 1 - 1
dashboard/src/components/article/AnthologyDetail.tsx

@@ -83,7 +83,7 @@ const AnthologyDetailWidget = ({
           if (typeof onArticleSelect !== "undefined") {
             onArticleSelect(keys);
           } else {
-            navigate(`/article/article/${keys[0]}/read`);
+            navigate(`/article/article/${keys[0]}?mode=read`);
           }
         }}
       />

+ 3 - 4
dashboard/src/components/article/AnthologyStudioList.tsx

@@ -22,7 +22,6 @@ const AnthologyStudioListWidget = () => {
     console.log("useEffect");
     let url = `/v2/anthology?view=studio_list`;
     get<IAnthologyStudioListApiResponse>(url).then(function (json) {
-      console.log("ajex", json);
       let newTree: IAnthologyStudioData[] = json.data.rows.map((item) => {
         return {
           count: item.count,
@@ -34,14 +33,14 @@ const AnthologyStudioListWidget = () => {
   }, []);
 
   return (
-    <Card title="作者">
+    <Card title="作者" size="small">
       <List
         itemLayout="vertical"
-        size="large"
+        size="small"
         dataSource={tableData}
         renderItem={(item) => (
           <List.Item>
-            <Link to={`/blog/${item.studio.studioName}/anthology`}>
+            <Link to={`/blog/${item.studio.realName}/anthology`}>
               <Space>
                 <StudioName data={item.studio} />
                 <span>({item.count})</span>

+ 80 - 6
dashboard/src/components/article/Article.tsx

@@ -16,11 +16,12 @@ import TocTree from "./TocTree";
 import PaliText from "../template/Wbw/PaliText";
 import ArticleSkeleton from "./ArticleSkeleton";
 
+import { modeChange } from "../../reducers/article-mode";
+import { IViewRequest, IViewStoreResponse } from "../api/view";
 import {
-  IViewRequest,
-  IViewStoreResponse,
+  IRecentRequest,
+  IRecentResponse,
 } from "../../pages/studio/recent/list";
-import { modeChange } from "../../reducers/article-mode";
 
 export type ArticleMode = "read" | "edit" | "wbw";
 export type ArticleType =
@@ -67,6 +68,7 @@ interface IWidgetArticle {
   active?: boolean;
   onArticleChange?: Function;
   onFinal?: Function;
+  onLoad?: Function;
 }
 const ArticleWidget = ({
   type,
@@ -83,12 +85,14 @@ const ArticleWidget = ({
   active = false,
   onArticleChange,
   onFinal,
+  onLoad,
 }: IWidgetArticle) => {
   const [articleData, setArticleData] = useState<IArticleDataResponse>();
-
+  const [articleHtml, setArticleHtml] = useState<string[]>(["<span />"]);
   const [extra, setExtra] = useState(<></>);
   const [showSkeleton, setShowSkeleton] = useState(true);
   const [unauthorized, setUnauthorized] = useState(false);
+  const [remains, setRemains] = useState(false);
 
   const channels = channelId?.split("_");
 
@@ -199,11 +203,33 @@ const ArticleWidget = ({
       }
       console.log("article url", url);
       setShowSkeleton(true);
+      if (typeof articleId !== "undefined") {
+        const param = {
+          mode: srcDataMode,
+          channel: channelId !== null ? channelId : undefined,
+          book: book !== null ? book : undefined,
+          para: para !== null ? para : undefined,
+        };
+        post<IRecentRequest, IRecentResponse>("/v2/recent", {
+          type: type,
+          article_id: articleId,
+          param: JSON.stringify(param),
+        }).then((json) => {
+          console.log("recent", json);
+        });
+      }
+
       get<IArticleResponse>(url)
         .then((json) => {
           console.log("article", json);
           if (json.ok) {
             setArticleData(json.data);
+            if (json.data.content) {
+              setArticleHtml([json.data.content]);
+            }
+            if (json.data.from) {
+              setRemains(true);
+            }
             setShowSkeleton(false);
 
             setExtra(
@@ -251,11 +277,18 @@ const ArticleWidget = ({
                     console.log("view", json.data);
                   });
                 }
-
                 break;
               default:
                 break;
             }
+
+            if (typeof onLoad !== "undefined") {
+              onLoad(json.data);
+            }
+
+            console.log("lazy load begin", json.data);
+            //lazy load
+            //getNextPara(json.data);
           } else {
             setShowSkeleton(false);
             setUnauthorized(true);
@@ -280,6 +313,41 @@ const ArticleWidget = ({
     userName,
   ]);
 
+  const getNextPara = (next: IArticleDataResponse): void => {
+    if (
+      typeof next.paraId === "undefined" ||
+      typeof next.mode === "undefined" ||
+      typeof next.from === "undefined" ||
+      typeof next.to === "undefined"
+    ) {
+      setRemains(false);
+      return;
+    }
+    let url = `/v2/corpus-chapter/${next.paraId}?mode=${next.mode}`;
+    url += `&from=${next.from}`;
+    url += `&to=${next.to}`;
+    url += channels ? `&channels=${channels}` : "";
+    console.log("lazy load", url);
+    get<IArticleResponse>(url).then((json) => {
+      if (json.ok) {
+        if (typeof json.data.content === "string") {
+          const content: string = json.data.content;
+          setArticleData((origin) => {
+            if (origin) {
+              origin.from = json.data.from;
+            }
+            return origin;
+          });
+          setArticleHtml((origin) => {
+            return [...origin, content];
+          });
+        }
+
+        //getNextPara(json.data);
+      }
+    });
+    return;
+  };
   return (
     <div>
       {showSkeleton ? (
@@ -298,13 +366,19 @@ const ArticleWidget = ({
           subTitle={articleData?.subtitle}
           summary={articleData?.summary}
           content={articleData ? articleData.content : ""}
-          html={articleData?.html}
+          html={articleHtml}
           path={articleData?.path}
           created_at={articleData?.created_at}
           updated_at={articleData?.updated_at}
           channels={channels}
           type={type}
           articleId={articleId}
+          remains={remains}
+          onEnd={() => {
+            if (type === "chapter" && articleData) {
+              getNextPara(articleData);
+            }
+          }}
         />
       )}
 

+ 69 - 0
dashboard/src/components/article/ArticleDrawer.tsx

@@ -0,0 +1,69 @@
+import { Drawer } from "antd";
+import React, { useEffect, useState } from "react";
+
+import Article, { ArticleMode, ArticleType } from "./Article";
+
+interface IWidget {
+  trigger?: React.ReactNode;
+  title?: string;
+  type?: ArticleType;
+  book?: string;
+  para?: string;
+  channelId?: string;
+  articleId?: string;
+  mode?: ArticleMode;
+  open?: boolean;
+  onClose?: Function;
+}
+
+const ArticleDrawerWidget = ({
+  trigger,
+  title,
+  type,
+  book,
+  para,
+  channelId,
+  articleId,
+  mode,
+  open,
+  onClose,
+}: IWidget) => {
+  const [openDrawer, setOpenDrawer] = useState(open);
+  useEffect(() => setOpenDrawer(open), [open]);
+  const showDrawer = () => {
+    setOpenDrawer(true);
+  };
+
+  const onDrawerClose = () => {
+    setOpenDrawer(false);
+    if (typeof onClose !== "undefined") {
+      onClose();
+    }
+  };
+
+  return (
+    <>
+      <span onClick={() => showDrawer()}>{trigger}</span>
+      <Drawer
+        title={title}
+        width={1000}
+        placement="right"
+        onClose={onDrawerClose}
+        open={openDrawer}
+        destroyOnClose={true}
+      >
+        <Article
+          active={true}
+          type={type as ArticleType}
+          book={book}
+          para={para}
+          channelId={channelId}
+          articleId={articleId}
+          mode={mode}
+        />
+      </Drawer>
+    </>
+  );
+};
+
+export default ArticleDrawerWidget;

+ 108 - 0
dashboard/src/components/article/ArticleListPublic.tsx

@@ -0,0 +1,108 @@
+import { Link } from "react-router-dom";
+import { useIntl } from "react-intl";
+import { useRef } from "react";
+import { Space } from "antd";
+import { ActionType, ProList } from "@ant-design/pro-components";
+
+import { get } from "../../request";
+import { IArticleListResponse } from "../api/Article";
+
+import { IStudio } from "../auth/StudioName";
+import { IUser } from "../auth/User";
+import TimeShow from "../general/TimeShow";
+
+interface DataItem {
+  sn: number;
+  id: string;
+  title: string;
+  subtitle: string;
+  summary: string;
+  anthologyCount?: number;
+  anthologyTitle?: string;
+  publicity: number;
+  createdAt?: string;
+  updatedAt: string;
+  studio?: IStudio;
+  editor?: IUser;
+}
+
+interface IWidget {
+  search?: string;
+  studioName?: string;
+}
+const ArticleListWidget = ({ search, studioName }: IWidget) => {
+  const intl = useIntl(); //i18n
+
+  const ref = useRef<ActionType>();
+
+  return (
+    <>
+      <ProList<DataItem>
+        rowKey="id"
+        actionRef={ref}
+        metas={{
+          title: {
+            render: (text, row, index, action) => {
+              return <Link to={`/article/article/${row.id}`}>{row.title}</Link>;
+            },
+          },
+          description: {
+            dataIndex: "summary",
+          },
+          subTitle: {
+            render: (text, row, index, action) => {
+              return (
+                <Space>
+                  {row.editor?.nickName}
+                  <TimeShow time={row.updatedAt} />
+                </Space>
+              );
+            },
+          },
+        }}
+        request={async (params = {}, sorter, filter) => {
+          let url = `/v2/article?view=public`;
+          const offset =
+            ((params.current ? params.current : 1) - 1) *
+            (params.pageSize ? params.pageSize : 20);
+          url += `&limit=${params.pageSize}&offset=${offset}`;
+          url += params.keyword ? "&search=" + params.keyword : "";
+          url += studioName ? "&studio=" + studioName : "";
+          const res = await get<IArticleListResponse>(url);
+          const items: DataItem[] = res.data.rows.map((item, id) => {
+            return {
+              sn: id + 1,
+              id: item.uid,
+              title: item.title,
+              subtitle: item.subtitle,
+              summary: item.summary,
+              anthologyCount: item.anthology_count,
+              anthologyTitle: item.anthology_first?.title,
+              publicity: item.status,
+              updatedAt: item.updated_at,
+              studio: item.studio,
+              editor: item.editor,
+            };
+          });
+          return {
+            total: res.data.count,
+            succcess: true,
+            data: items,
+          };
+        }}
+        bordered
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+          pageSize: 20,
+        }}
+        search={false}
+        options={{
+          search: true,
+        }}
+      />
+    </>
+  );
+};
+
+export default ArticleListWidget;

+ 90 - 0
dashboard/src/components/article/ArticlePrevDrawer.tsx

@@ -0,0 +1,90 @@
+import { Drawer, Typography } from "antd";
+import React, { useEffect, useState } from "react";
+import { put } from "../../request";
+import { IArticleDataResponse, IArticleResponse } from "../api/Article";
+import ArticleView from "./ArticleView";
+
+const { Paragraph } = Typography;
+
+interface IArticlePrevRequest {
+  content: string;
+}
+interface IWidget {
+  trigger?: React.ReactNode;
+  title?: React.ReactNode;
+  content?: string;
+  articleId: string;
+}
+
+const ArticlePrevDrawerWidget = ({
+  trigger,
+  title,
+  content,
+  articleId,
+}: IWidget) => {
+  const [articleData, setArticleData] = useState<IArticleDataResponse>();
+  const [open, setOpen] = useState(false);
+  const [errorMsg, setErrorMsg] = useState<string>();
+
+  const showDrawer = () => {
+    setOpen(true);
+  };
+
+  const onClose = () => {
+    setOpen(false);
+  };
+
+  useEffect(() => {
+    put<IArticlePrevRequest, IArticleResponse>(
+      `/v2/article-preview/${articleId}`,
+      {
+        content: content ? content : "",
+      }
+    )
+      .then((res) => {
+        console.log("save response", res);
+        if (res.ok) {
+          setArticleData(res.data);
+        } else {
+          setErrorMsg(res.message);
+        }
+      })
+      .catch((e: IArticleResponse) => {
+        setErrorMsg(e.message);
+      });
+  }, [articleId, content]);
+
+  return (
+    <>
+      <span onClick={() => showDrawer()}>{trigger}</span>
+      <Drawer
+        title={title}
+        width={900}
+        placement="right"
+        onClose={onClose}
+        open={open}
+        destroyOnClose={true}
+      >
+        <Paragraph type="danger">{errorMsg}</Paragraph>
+        {articleData ? (
+          <ArticleView
+            id={articleData.uid}
+            title={articleData.title}
+            subTitle={articleData.subtitle}
+            summary={articleData.summary}
+            content={articleData.content ? articleData.content : ""}
+            html={articleData.html ? [articleData.html] : []}
+            path={articleData.path}
+            created_at={articleData.created_at}
+            updated_at={articleData.updated_at}
+            articleId={articleId}
+          />
+        ) : (
+          <></>
+        )}
+      </Drawer>
+    </>
+  );
+};
+
+export default ArticlePrevDrawerWidget;

+ 30 - 6
dashboard/src/components/article/ArticleView.tsx

@@ -1,10 +1,11 @@
-import { Typography, Divider, Button } from "antd";
+import { Typography, Divider, Button, Skeleton } from "antd";
 import { ReloadOutlined } from "@ant-design/icons";
 
 import MdView from "../template/MdView";
 import TocPath, { ITocPathNode } from "../corpus/TocPath";
 import PaliChapterChannelList from "../corpus/PaliChapterChannelList";
 import { ArticleType } from "./Article";
+import VisibleObserver from "../general/VisibleObserver";
 
 const { Paragraph, Title, Text } = Typography;
 
@@ -14,13 +15,15 @@ export interface IWidgetArticleData {
   subTitle?: string;
   summary?: string;
   content?: string;
-  html?: string;
+  html?: string[];
   path?: ITocPathNode[];
   created_at?: string;
   updated_at?: string;
   channels?: string[];
   type?: ArticleType;
   articleId?: string;
+  remains?: boolean;
+  onEnd?: Function;
 }
 
 const ArticleViewWidget = ({
@@ -29,13 +32,15 @@ const ArticleViewWidget = ({
   subTitle,
   summary,
   content,
-  html,
+  html = [],
   path = [],
   created_at,
   updated_at,
   channels,
   type,
   articleId,
+  onEnd,
+  remains,
 }: IWidgetArticleData) => {
   let currChannelList = <></>;
   switch (type) {
@@ -87,9 +92,28 @@ const ArticleViewWidget = ({
         </Paragraph>
         <Divider />
       </div>
-      <div>
-        <MdView html={html ? html : content} />
-      </div>
+      {html
+        ? html.map((item, id) => {
+            return (
+              <div key={id}>
+                <MdView html={item} />
+              </div>
+            );
+          })
+        : content}
+      {remains ? (
+        <>
+          <VisibleObserver
+            onVisible={(visible: boolean) => {
+              console.log("visible", visible);
+              if (visible && typeof onEnd !== "undefined") {
+                onEnd();
+              }
+            }}
+          />
+          <Skeleton title={{ width: 200 }} paragraph={{ rows: 5 }} active />
+        </>
+      ) : undefined}
     </>
   );
 };

+ 47 - 31
dashboard/src/components/blog/TimeLine.tsx

@@ -1,41 +1,57 @@
 import { Timeline } from "antd";
+import { useEffect, useState } from "react";
+import { useIntl } from "react-intl";
+import { get } from "../../request";
+import TimeShow from "../general/TimeShow";
 
-interface IAuthorTimeLine {
-  label: string;
-  content: string;
-  type: string;
+interface IMilestone {
+  date: string;
+  event: string;
 }
-const TimeLineWidget = () => {
-  const data: IAuthorTimeLine[] = [
-    {
-      label: "2015-09-1",
-      content: "Technical testing",
-      type: "translation",
-    },
-    {
-      label: "2015-09-1",
-      content: "Technical testing",
-      type: "translation",
-    },
-    {
-      label: "2015-09-1",
-      content: "Technical testing",
-      type: "translation",
-    },
-    {
-      label: "2015-09-1",
-      content: "Technical testing",
-      type: "translation",
-    },
-  ];
+interface IMilestoneResponse {
+  ok: boolean;
+  message: string;
+  data: IMilestone[];
+}
+
+interface IWidget {
+  studioName?: string;
+}
+const TimeLineWidget = ({ studioName }: IWidget) => {
+  const [milestone, setMilestone] = useState<IMilestone[]>([]);
+  const intl = useIntl();
+
+  useEffect(() => {
+    if (typeof studioName === "undefined") {
+      return;
+    }
+    get<IMilestoneResponse>(`/v2/milestone/${studioName}`).then((json) => {
+      if (json.ok) {
+        setMilestone(
+          json.data.sort((a, b) => {
+            if (a.date > b.date) {
+              return -1;
+            } else {
+              return 1;
+            }
+          })
+        );
+      }
+    });
+  }, [studioName]);
 
   return (
     <>
-      <Timeline mode={"left"} style={{ width: "100%" }}>
-        {data.map((item, id) => {
+      <Timeline mode="left" style={{ width: "100%" }}>
+        {milestone.map((item, id) => {
           return (
-            <Timeline.Item key={id} label={item.label}>
-              {item.content}
+            <Timeline.Item
+              key={id}
+              label={<TimeShow time={item.date} showIcon={false} />}
+            >
+              {intl.formatMessage({
+                id: `labels.${item.event}`,
+              })}
             </Timeline.Item>
           );
         })}

+ 1 - 1
dashboard/src/components/blog/TopArticleCard.tsx

@@ -21,7 +21,7 @@ const IconParamList = (prop: IWidgetIconParamList) => {
       <Space>
         {prop.data.map((item, id) => {
           return (
-            <Space>
+            <Space key={id}>
               {item.icon} {item.label}
             </Space>
           );

+ 2 - 2
dashboard/src/components/blog/TopArticles.tsx

@@ -39,8 +39,8 @@ const TopArticlesWidget = (prop: IWidgetTopArticles) => {
 
   const list = data.map((item, id) => {
     return (
-      <Col flex="400px">
-        <TopArticleCard data={item} key={id} />
+      <Col flex="400px" key={id}>
+        <TopArticleCard data={item} />
       </Col>
     );
   });

+ 8 - 2
dashboard/src/components/corpus/ChapterList.tsx

@@ -14,6 +14,7 @@ interface IWidget {
   type?: string;
   tags?: string[];
   searchKey?: string;
+  studioName?: string;
   onTagClick?: Function;
 }
 
@@ -23,6 +24,7 @@ const ChapterListWidget = ({
   lang = "zh",
   type = "translation",
   tags = [],
+  studioName,
   onTagClick,
 }: IWidget) => {
   const [tableData, setTableData] = useState<ChapterData[]>([]);
@@ -35,7 +37,11 @@ const ChapterListWidget = ({
     } else {
       const strTags = tags.length > 0 ? "&tags=" + tags.join() : "";
       const offset = (currPage - 1) * 20;
-      url = `/v2/progress?view=chapter${strTags}&offset=${offset}&progress=${progress}&lang=${lang}&channel_type=${type}`;
+      url = `/v2/progress?view=chapter`;
+      if (typeof studioName !== "undefined") {
+        url += `&studio=${studioName}&public=true`;
+      }
+      url += `${strTags}&offset=${offset}&progress=${progress}&lang=${lang}&channel_type=${type}`;
     }
     console.log("url", url);
     get<IChapterListResponse>(url).then((json) => {
@@ -75,7 +81,7 @@ const ChapterListWidget = ({
         setTableData([]);
       }
     });
-  }, [progress, lang, type, tags, currPage, searchKey]);
+  }, [currPage, lang, progress, searchKey, studioName, tags, type]);
 
   return (
     <List

+ 28 - 20
dashboard/src/components/corpus/ChapterTagList.tsx

@@ -4,6 +4,7 @@ import { get } from "../../request";
 import type { ChannelFilterProps } from "../channel/ChannelList";
 import { ITagData } from "./ChapterTag";
 import TagArea from "../tag/TagArea";
+import { Skeleton } from "antd";
 
 interface IAppendTagData {
   id: string;
@@ -38,37 +39,44 @@ const ChapterTagListWidget = ({
   onTagClick,
 }: IWidget) => {
   const [tag, setTag] = useState<ITagData[]>([]);
+  const [load, setLoad] = useState(true);
 
   useEffect(() => {
     const strTags = tags.length > 0 ? "&tags=" + tags.join() : "";
     const url = `/v2/tag?view=chapter${strTags}&progress=${progress}&lang=${lang}&channel_type=${type}`;
     console.log("tag list ajax", url);
-    get<IChapterTagResponse>(url).then((json) => {
-      if (json.ok) {
-        if (json.data.count === 0) {
-          setTag([]);
+    setLoad(true);
+    get<IChapterTagResponse>(url)
+      .then((json) => {
+        if (json.ok) {
+          if (json.data.count === 0) {
+            setTag([]);
+          } else {
+            const max = json.data.rows.sort((a, b) => b.count - a.count)[0]
+              .count;
+            const data: ITagData[] = json.data.rows
+              .filter((value) => value.count < max)
+              .map((item) => {
+                return {
+                  key: item.name,
+                  title: item.name,
+                  count: item.count,
+                };
+              });
+            setTag(data);
+          }
         } else {
-          const max = json.data.rows.sort((a, b) => b.count - a.count)[0].count;
-          const data: ITagData[] = json.data.rows
-            .filter((value) => value.count < max)
-            .map((item) => {
-              return {
-                key: item.name,
-                title: item.name,
-                count: item.count,
-              };
-            });
-          setTag(data);
+          setTag([]);
         }
-      } else {
-        setTag([]);
-      }
-    });
+      })
+      .finally(() => setLoad(false));
   }, [progress, lang, type, tags]);
 
   return (
     <div>
-      {tag.length === 0 ? (
+      {load ? (
+        <Skeleton paragraph={{ rows: 4 }} active />
+      ) : tag.length === 0 ? (
         "无"
       ) : (
         <TagArea

+ 101 - 0
dashboard/src/components/corpus/CommunityChapter.tsx

@@ -0,0 +1,101 @@
+import { useEffect, useState } from "react";
+import { Divider, Space } from "antd";
+import { Typography } from "antd";
+import { TagOutlined } from "@ant-design/icons";
+
+import ChapterFilter from "../../components/corpus/ChapterFilter";
+import ChapterList from "../../components/corpus/ChapterList";
+import ChapterTag from "../../components/corpus/ChapterTag";
+import ChapterAppendTag from "../../components/corpus/ChapterAppendTag";
+
+const { Title } = Typography;
+
+interface IWidget {
+  studioName?: string;
+  channelId?: string;
+  tag?: string[];
+}
+
+const CommunityChapterWidget = ({
+  studioName,
+  channelId,
+  tag = [],
+}: IWidget) => {
+  const [tags, setTags] = useState<string[]>(tag);
+  const [searchKey, setSearchKey] = useState<string>();
+  const [progress, setProgress] = useState(0.9);
+  const [lang, setLang] = useState("zh");
+  const [type, setType] = useState("translation");
+
+  useEffect(() => setTags(tag), [tag]);
+  return (
+    <>
+      <ChapterFilter
+        onSearch={(value: string) => {
+          console.log("search change", value);
+          setSearchKey(value);
+        }}
+        onProgressChange={(value: string) => {
+          console.log("progress change", value);
+          setProgress(parseFloat(value));
+        }}
+        onLangChange={(value: string) => {
+          console.log("lang change", value);
+          setLang(value);
+        }}
+        onTypeChange={(value: string) => {
+          console.log("type change", value);
+          setType(value);
+        }}
+      />
+      <Divider />
+
+      <Title level={3}>
+        <Space>
+          <TagOutlined />
+          {tags.map((item, id) => {
+            return (
+              <ChapterTag
+                data={{
+                  key: item,
+                  title: item,
+                }}
+                key={id}
+                closable={true}
+                onTagClose={() => {
+                  console.log("tag change");
+                  setTags(tags.filter((x) => x !== item));
+                }}
+              />
+            );
+          })}
+          <ChapterAppendTag
+            tags={tags}
+            progress={progress}
+            lang={lang}
+            type={type}
+            onTagClick={(tag: string) => {
+              console.log("tag change");
+              setTags([...tags, tag]);
+            }}
+          />
+        </Space>
+      </Title>
+
+      <ChapterList
+        studioName={studioName}
+        searchKey={searchKey}
+        tags={tags}
+        progress={progress}
+        lang={lang}
+        type={type}
+        onTagClick={(tag: string) => {
+          console.log("tag change");
+          setTags([tag]);
+        }}
+      />
+    </>
+  );
+};
+
+export default CommunityChapterWidget;

+ 11 - 3
dashboard/src/components/corpus/PaliChapterCard.tsx

@@ -1,9 +1,9 @@
-import { Row, Col } from "antd";
+import { Row, Col, Space } from "antd";
 import { Typography } from "antd";
 import { TinyLine } from "@ant-design/plots";
 import TocPath from "./TocPath";
 
-const { Title, Link } = Typography;
+const { Title, Text, Link } = Typography;
 
 export interface IPaliChapterData {
   Title: string;
@@ -12,6 +12,8 @@ export interface IPaliChapterData {
   Path: string;
   Book: number;
   Paragraph: number;
+  chapterStrLen: number;
+  paragraphCount: number;
   progressLine?: number[];
 }
 
@@ -68,7 +70,13 @@ const PaliChapterCardWidget = ({ data, onTitleClick }: IWidget) => {
             </Col>
           </Row>
           <Row>
-            <Col></Col>
+            <Col>
+              <Text type="secondary">
+                <Space>
+                  字符数{data.chapterStrLen} | 段落数{data.paragraphCount}
+                </Space>
+              </Text>
+            </Col>
           </Row>
           <Row>
             <Col span={16}></Col>

+ 2 - 0
dashboard/src/components/corpus/PaliChapterListByPara.tsx

@@ -26,6 +26,8 @@ const PaliChapterListByParaWidget = ({ chapter, onChapterClick }: IWidget) => {
           Path: item.path,
           Book: item.book,
           Paragraph: item.paragraph,
+          chapterStrLen: item.chapter_strlen,
+          paragraphCount: item.chapter_len,
           progressLine: item.progress_line,
         };
       });

+ 11 - 11
dashboard/src/components/corpus/PaliChapterListByTag.tsx

@@ -27,6 +27,8 @@ const PaliChapterListByTagWidget = (prop: IWidgetPaliChapterListByTag) => {
             Path: item.path,
             Book: item.book,
             Paragraph: item.paragraph,
+            chapterStrLen: item.chapter_strlen,
+            paragraphCount: item.chapter_len,
             progressLine: item.progress_line,
           };
         });
@@ -38,17 +40,15 @@ const PaliChapterListByTagWidget = (prop: IWidgetPaliChapterListByTag) => {
   }, [prop.tag]);
 
   return (
-    <>
-      <PaliChapterList
-        data={tableData}
-        maxLevel={1}
-        onChapterClick={(e: IChapterClickEvent) => {
-          if (typeof prop.onChapterClick !== "undefined") {
-            prop.onChapterClick(e);
-          }
-        }}
-      />
-    </>
+    <PaliChapterList
+      data={tableData}
+      maxLevel={1}
+      onChapterClick={(e: IChapterClickEvent) => {
+        if (typeof prop.onChapterClick !== "undefined") {
+          prop.onChapterClick(e);
+        }
+      }}
+    />
   );
 };
 

+ 2 - 1
dashboard/src/components/corpus/Recent.tsx

@@ -1,8 +1,9 @@
 import { Button, List } from "antd";
 import { useEffect, useState } from "react";
 import { Link } from "react-router-dom";
-import { IView, IViewListResponse } from "../../pages/studio/recent/list";
+
 import { get } from "../../request";
+import { IView, IViewListResponse } from "../api/view";
 
 const RecentWidget = () => {
   const [listData, setListData] = useState<IView[]>([]);

+ 65 - 55
dashboard/src/components/corpus/RelatedPara.tsx

@@ -1,5 +1,5 @@
 import { Link } from "react-router-dom";
-import { Badge, Card, List, message, Modal } from "antd";
+import { Badge, Card, List, message, Modal, Skeleton } from "antd";
 
 import { get } from "../../request";
 import { useEffect, useState } from "react";
@@ -36,6 +36,7 @@ interface IWidget {
 const RelatedParaWidget = ({ book, para, trigger, onSelect }: IWidget) => {
   const [isModalOpen, setIsModalOpen] = useState(false);
   const [tableData, setTableData] = useState<IRelatedParaData[]>([]);
+  const [load, setLoad] = useState(true);
 
   const showModal = () => {
     setIsModalOpen(true);
@@ -50,16 +51,19 @@ const RelatedParaWidget = ({ book, para, trigger, onSelect }: IWidget) => {
   };
   useEffect(() => {
     if (typeof book === "number" && typeof para === "number" && isModalOpen) {
+      setLoad(true);
       get<IRelatedParaResponse>(
         `/v2/related-paragraph?book=${book}&para=${para}`
-      ).then((json) => {
-        console.log("import", json);
-        if (json.ok) {
-          setTableData(json.data.rows);
-        } else {
-          message.error(json.message);
-        }
-      });
+      )
+        .then((json) => {
+          console.log("import", json);
+          if (json.ok) {
+            setTableData(json.data.rows);
+          } else {
+            message.error(json.message);
+          }
+        })
+        .finally(() => setLoad(false));
     }
   }, [book, para, isModalOpen]);
 
@@ -72,54 +76,60 @@ const RelatedParaWidget = ({ book, para, trigger, onSelect }: IWidget) => {
         onOk={handleOk}
         onCancel={handleCancel}
       >
-        <List
-          itemLayout="vertical"
-          size="small"
-          split={false}
-          dataSource={tableData}
-          renderItem={(item) => {
-            const isPali = item.tags?.find((tag) => tag.name === "pāḷi");
-            const isAttha = item.tags?.find((tag) => tag.name === "aṭṭhakathā");
-            const isTika = item.tags?.find((tag) => tag.name === "ṭīkā");
-            return (
-              <List.Item>
-                <Badge.Ribbon
-                  text={
-                    isPali
-                      ? "pāḷi"
-                      : isAttha
-                      ? "aṭṭhakathā"
-                      : isTika
-                      ? "ṭīkā"
-                      : undefined
-                  }
-                  color={
-                    isPali
-                      ? "volcano"
-                      : isAttha
-                      ? "green"
-                      : isTika
-                      ? "cyan"
-                      : undefined
-                  }
-                >
-                  <Card
-                    title={
-                      <Link
-                        to={`/article/para?book=${item.book}&par=${item.para}`}
-                      >
-                        {item.book_title_pali}
-                      </Link>
+        {load ? (
+          <Skeleton paragraph={{ rows: 4 }} active />
+        ) : (
+          <List
+            itemLayout="vertical"
+            size="small"
+            split={false}
+            dataSource={tableData}
+            renderItem={(item) => {
+              const isPali = item.tags?.find((tag) => tag.name === "pāḷi");
+              const isAttha = item.tags?.find(
+                (tag) => tag.name === "aṭṭhakathā"
+              );
+              const isTika = item.tags?.find((tag) => tag.name === "ṭīkā");
+              return (
+                <List.Item>
+                  <Badge.Ribbon
+                    text={
+                      isPali
+                        ? "pāḷi"
+                        : isAttha
+                        ? "aṭṭhakathā"
+                        : isTika
+                        ? "ṭīkā"
+                        : undefined
+                    }
+                    color={
+                      isPali
+                        ? "volcano"
+                        : isAttha
+                        ? "green"
+                        : isTika
+                        ? "cyan"
+                        : undefined
                     }
-                    size="small"
                   >
-                    <TocPath data={item.path} />
-                  </Card>
-                </Badge.Ribbon>
-              </List.Item>
-            );
-          }}
-        />
+                    <Card
+                      title={
+                        <Link
+                          to={`/article/para?book=${item.book}&par=${item.para}`}
+                        >
+                          {item.book_title_pali}
+                        </Link>
+                      }
+                      size="small"
+                    >
+                      <TocPath data={item.path} />
+                    </Card>
+                  </Badge.Ribbon>
+                </List.Item>
+              );
+            }}
+          />
+        )}
       </Modal>
     </>
   );

+ 111 - 0
dashboard/src/components/corpus/SentHistory.tsx

@@ -0,0 +1,111 @@
+import { ProList } from "@ant-design/pro-components";
+import { Typography } from "antd";
+
+import { get } from "../../request";
+import { IUser } from "../auth/UserName";
+import TimeShow from "../general/TimeShow";
+
+const { Text } = Typography;
+
+interface ISentHistoryData {
+  content: string;
+  editor: IUser;
+  created_at: string;
+}
+
+interface ISentHistoryListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: ISentHistoryData[]; count: number };
+}
+
+interface ISentHistory {
+  content: string;
+  editor: IUser;
+  createdAt: string;
+}
+interface IWidget {
+  sentId?: string;
+}
+const SentHistoryWidget = ({ sentId }: IWidget) => {
+  return (
+    <ProList<ISentHistory>
+      rowKey="id"
+      headerTitle={"time line"}
+      showActions="hover"
+      request={async (params = {}, sorter, filter) => {
+        if (typeof sentId === "undefined") {
+          return {
+            total: 0,
+            succcess: false,
+            data: [],
+          };
+        }
+        console.log(params, sorter, filter);
+
+        let url = `/v2/sent_history?view=sentence&id=${sentId}`;
+        const offset =
+          ((params.current ? params.current : 1) - 1) *
+          (params.pageSize ? params.pageSize : 20);
+        url += `&limit=${params.pageSize}&offset=${offset}`;
+        if (typeof params.keyword !== "undefined") {
+          url += "&search=" + (params.keyword ? params.keyword : "");
+        }
+        const res = await get<ISentHistoryListResponse>(url);
+        if (res.ok) {
+          console.log(res.data);
+
+          const items: ISentHistory[] = res.data.rows.map((item, id) => {
+            return {
+              content: item.content,
+              editor: item.editor,
+              createdAt: item.created_at,
+            };
+          });
+          console.log(items);
+          return {
+            total: res.data.count,
+            succcess: true,
+            data: items,
+          };
+        } else {
+          console.error(res.message);
+          return {
+            total: 0,
+            succcess: false,
+            data: [],
+          };
+        }
+      }}
+      pagination={{
+        showQuickJumper: true,
+        showSizeChanger: true,
+      }}
+      metas={{
+        title: {
+          dataIndex: "content",
+        },
+        avatar: {
+          dataIndex: "image",
+          editable: false,
+        },
+        description: {
+          render: (text, row, index, action) => {
+            return (
+              <TimeShow
+                type="secondary"
+                time={row.createdAt}
+                title="created at"
+              />
+            );
+          },
+        },
+        actions: {
+          render: (text, row, index, action) => [<></>],
+        },
+      }}
+    />
+  );
+};
+
+export default SentHistoryWidget;

+ 56 - 0
dashboard/src/components/corpus/SentHistoryModal.tsx

@@ -0,0 +1,56 @@
+import React, { useEffect, useState } from "react";
+import { Modal } from "antd";
+import SentHistory from "./SentHistory";
+
+interface IWidget {
+  sentId?: string;
+  trigger?: React.ReactNode;
+  open?: boolean;
+  onClose?: Function;
+}
+const SentHistoryModalWidget = ({
+  open = false,
+  sentId,
+  trigger,
+  onClose,
+}: IWidget) => {
+  const [isModalOpen, setIsModalOpen] = useState(open);
+
+  useEffect(() => setIsModalOpen(open), [open]);
+
+  const showModal = () => {
+    setIsModalOpen(true);
+  };
+
+  const handleOk = () => {
+    setIsModalOpen(false);
+    if (typeof onClose !== "undefined") {
+      onClose();
+    }
+  };
+
+  const handleCancel = () => {
+    setIsModalOpen(false);
+    if (typeof onClose !== "undefined") {
+      onClose();
+    }
+  };
+
+  return (
+    <>
+      <span onClick={showModal}>{trigger}</span>
+      <Modal
+        width={"80%"}
+        title="加入文集"
+        open={isModalOpen}
+        onOk={handleOk}
+        onCancel={handleCancel}
+        destroyOnClose
+      >
+        <SentHistory sentId={sentId} />
+      </Modal>
+    </>
+  );
+};
+
+export default SentHistoryModalWidget;

+ 102 - 0
dashboard/src/components/corpus/TopChapter.tsx

@@ -0,0 +1,102 @@
+import { Typography } from "antd";
+import { ProList } from "@ant-design/pro-components";
+
+import { get } from "../../request";
+import { IChapterListResponse } from "../../components/api/Corpus";
+import { Link } from "react-router-dom";
+
+const { Paragraph } = Typography;
+
+interface IItem {
+  sn: number;
+  title: string;
+  subTitle: string;
+  summary: string;
+  book: number;
+  paragraph: number;
+  path: string;
+  channelId: string;
+  progress: number;
+  view: number;
+  createdAt: number;
+  updatedAt: number;
+}
+interface IWidget {
+  studioName?: string;
+}
+const TopChapterWidget = ({ studioName }: IWidget) => {
+  return (
+    <ProList<IItem>
+      metas={{
+        title: {
+          render: (dom, entity, index, action, schema) => {
+            return (
+              <Link
+                to={`/article/chapter/${entity.book}-${entity.paragraph}?mode=read&channel=${entity.channelId}`}
+              >
+                {entity.title ? entity.title : entity.subTitle}
+              </Link>
+            );
+          },
+        },
+        subTitle: {},
+        description: { dataIndex: "path" },
+        type: {},
+        avatar: {},
+        content: {
+          render: (dom, entity, index, action, schema) => {
+            return (
+              <Paragraph
+                ellipsis={{ rows: 2, expandable: false, symbol: "more" }}
+              >
+                {entity.summary}
+              </Paragraph>
+            );
+          },
+        },
+        actions: {
+          cardActionProps: "extra",
+        },
+      }}
+      showActions="hover"
+      grid={{ gutter: 16, column: 2, md: 1 }}
+      request={async (params = {}, sorter, filter) => {
+        // TODO
+        console.log(params, sorter, filter);
+        const offset = (params.current || 1 - 1) * (params.pageSize || 20);
+        const res = await get<IChapterListResponse>(
+          `/v2/progress?view=chapter&studio=${studioName}&progress=0.9&limit=4`
+        );
+        console.log(res.data.rows);
+        const items: IItem[] = res.data.rows.map((item, id) => {
+          const createdAt = new Date(item.created_at);
+          const updatedAt = new Date(item.updated_at);
+          return {
+            sn: id + offset + 1,
+            book: item.book,
+            paragraph: item.para,
+            view: item.view,
+            title: item.title,
+            subTitle: item.toc,
+            summary: item.summary,
+            channelId: item.channel_id,
+            path: item.path,
+            progress: item.progress,
+            createdAt: createdAt.getTime(),
+            updatedAt: updatedAt.getTime(),
+          };
+        });
+        return {
+          total: res.data.count,
+          succcess: true,
+          data: items,
+        };
+      }}
+      rowKey="id"
+      bordered
+      search={false}
+    />
+  );
+};
+
+export default TopChapterWidget;

+ 6 - 2
dashboard/src/components/course/CourseHead.tsx

@@ -17,7 +17,7 @@ interface IWidget {
   id?: string;
   title?: string;
   subtitle?: string;
-  coverUrl?: string;
+  coverUrl?: string[];
   startAt?: string;
   endAt?: string;
   teacher?: IUser;
@@ -58,7 +58,11 @@ const CourseHeadWidget = ({
               <Image
                 width={200}
                 style={{ borderRadius: 12 }}
-                src={API_HOST + "/" + coverUrl}
+                src={coverUrl && coverUrl.length > 1 ? coverUrl[1] : undefined}
+                preview={{
+                  src:
+                    coverUrl && coverUrl.length > 0 ? coverUrl[0] : undefined,
+                }}
                 fallback={`${API_HOST}/app/course/img/default.jpg`}
               />
               <Space direction="vertical">

+ 16 - 4
dashboard/src/components/course/CourseInfoEdit.tsx

@@ -11,6 +11,7 @@ import {
 } from "@ant-design/pro-components";
 
 import { message, Form } from "antd";
+import { get as getToken } from "../../reducers/current-user";
 
 import { API_HOST, get, put } from "../../request";
 import {
@@ -23,7 +24,7 @@ import PublicitySelect from "../../components/studio/PublicitySelect";
 import { IUserListResponse } from "../../components/api/Auth";
 import MDEditor from "@uiw/react-md-editor";
 import { DefaultOptionType } from "antd/lib/select";
-import { UploadFile } from "antd/es/upload/interface";
+import { UploadChangeParam, UploadFile } from "antd/es/upload/interface";
 import { IAttachmentResponse } from "../../components/api/Attachments";
 
 import { IAnthologyListResponse } from "../../components/api/Article";
@@ -93,7 +94,7 @@ const CourseInfoEditWidget = ({
           } else if (typeof values.cover[0].response === "undefined") {
             _cover = values.cover[0].uid;
           } else {
-            _cover = values.cover[0].response.data.url;
+            _cover = values.cover[0].response.data.id;
           }
 
           const res = await put<ICourseDataRequest, ICourseResponse>(
@@ -164,7 +165,10 @@ const CourseInfoEditWidget = ({
                   {
                     uid: res.data.cover,
                     name: "cover",
-                    thumbUrl: API_HOST + "/" + res.data.cover,
+                    thumbUrl:
+                      res.data.cover_url && res.data.cover_url.length > 1
+                        ? res.data.cover_url[1]
+                        : undefined,
                   },
                 ]
               : [],
@@ -190,9 +194,17 @@ const CourseInfoEditWidget = ({
               name: "file",
               listType: "picture-card",
               className: "avatar-uploader",
+              headers: {
+                Authorization: `Bearer ${getToken()}`,
+              },
+              onRemove: (file: UploadFile<any>): boolean => {
+                console.log("remove", file);
+                return true;
+              },
             }}
-            action={`${API_HOST}/api/v2/attachments`}
+            action={`${API_HOST}/api/v2/attachment`}
             extra="封面必须为正方形。最大512*512"
+            onChange={(info: UploadChangeParam<UploadFile<any>>) => {}}
           />
         </ProForm.Group>
         <ProForm.Group>

+ 18 - 3
dashboard/src/components/course/CourseList.tsx

@@ -2,7 +2,7 @@
 import { Link } from "react-router-dom";
 import { useEffect, useState } from "react";
 
-import { Avatar, List, message, Typography } from "antd";
+import { Avatar, List, message, Typography, Image } from "antd";
 import { ICourse } from "../../pages/library/course/course";
 import { ICourseListResponse } from "../api/Course";
 import { API_HOST, get } from "../../request";
@@ -26,7 +26,7 @@ const CourseListWidget = ({ type }: IWidget) => {
             subtitle: item.subtitle,
             teacher: item.teacher,
             intro: item.content,
-            coverUrl: item.cover,
+            coverUrl: item.cover_url,
           };
         });
         setData(course);
@@ -51,7 +51,22 @@ const CourseListWidget = ({ type }: IWidget) => {
         <List.Item
           key={item.title}
           extra={
-            <img width={128} alt="logo" src={API_HOST + "/" + item.coverUrl} />
+            <Image
+              width={128}
+              style={{ borderRadius: 12 }}
+              src={
+                item.coverUrl && item.coverUrl.length > 1
+                  ? item.coverUrl[1]
+                  : undefined
+              }
+              preview={{
+                src:
+                  item.coverUrl && item.coverUrl.length > 0
+                    ? item.coverUrl[0]
+                    : undefined,
+              }}
+              fallback={`${API_HOST}/app/course/img/default.jpg`}
+            />
           }
         >
           <List.Item.Meta

+ 15 - 4
dashboard/src/components/course/LecturerList.tsx

@@ -1,7 +1,7 @@
 //主讲人列表
 import { useNavigate } from "react-router-dom";
 import { useEffect, useState } from "react";
-import { Card, List, message, Typography } from "antd";
+import { Card, List, message, Typography, Image } from "antd";
 
 import { ICourse } from "../../pages/library/course/course";
 import { ICourseListResponse } from "../api/Course";
@@ -25,7 +25,7 @@ const LecturerListWidget = () => {
             subtitle: item.subtitle,
             teacher: item.teacher,
             intro: item.content,
-            coverUrl: item.cover,
+            coverUrl: item.cover_url,
           };
         });
         setData(course);
@@ -44,11 +44,22 @@ const LecturerListWidget = () => {
             hoverable
             style={{ width: "100%", height: 300 }}
             cover={
-              <img
+              <Image
                 alt="example"
-                src={API_HOST + "/" + item.coverUrl}
+                src={
+                  item.coverUrl && item.coverUrl.length > 1
+                    ? item.coverUrl[1]
+                    : undefined
+                }
+                preview={{
+                  src:
+                    item.coverUrl && item.coverUrl.length > 0
+                      ? item.coverUrl[0]
+                      : undefined,
+                }}
                 width="240"
                 height="200"
+                fallback={`${API_HOST}/app/course/img/default.jpg`}
               />
             }
             onClick={(e) => {

+ 1 - 1
dashboard/src/components/course/TextBook.tsx

@@ -18,7 +18,7 @@ const TextBookWidget = ({ anthologyId, courseId }: IWidget) => {
           <AnthologyDetail
             aid={anthologyId}
             onArticleSelect={(keys: string[]) => {
-              navigate(`/article/textbook/${courseId}_${keys[0]}/read`);
+              navigate(`/article/textbook/${courseId}_${keys[0]}?mode=read`);
             }}
           />
         </Col>

+ 17 - 11
dashboard/src/components/general/TimeShow.tsx

@@ -1,13 +1,16 @@
-import { Space, Tooltip } from "antd";
+import { Space, Tooltip, Typography } from "antd";
 import { useIntl } from "react-intl";
 import { FieldTimeOutlined } from "@ant-design/icons";
 import { useEffect, useState } from "react";
+import { BaseType } from "antd/lib/typography/Base";
+const { Text } = Typography;
 
 interface IWidgetTimeShow {
   showIcon?: boolean;
   showTooltip?: boolean;
   time?: string;
   title?: string;
+  type?: BaseType;
 }
 
 const TimeShowWidget = ({
@@ -15,6 +18,7 @@ const TimeShowWidget = ({
   showTooltip = true,
   time,
   title,
+  type,
 }: IWidgetTimeShow) => {
   const intl = useIntl(); //i18n
   const [passTime, setPassTime] = useState<string>();
@@ -25,7 +29,7 @@ const TimeShowWidget = ({
       return;
     }
     let timer = setInterval(() => {
-      setMTime((mTime) => mTime + 1);
+      setMTime((origin) => origin + 1);
     }, 1000 * 60);
     return () => {
       clearInterval(timer);
@@ -50,17 +54,17 @@ const TimeShowWidget = ({
     const time = new Date(t);
     let pass = currDate.getTime() - time.getTime();
     let strPassTime = "";
-    if (pass < 120 * 1000) {
+    if (pass < 60 * 1000) {
       //二分钟内
       strPassTime =
         Math.floor(pass / 1000) +
         intl.formatMessage({ id: "utilities.time.secs_ago" });
-    } else if (pass < 7200 * 1000) {
+    } else if (pass < 3600 * 1000) {
       //二小时内
       strPassTime =
         Math.floor(pass / 1000 / 60) +
         intl.formatMessage({ id: "utilities.time.mins_ago" });
-    } else if (pass < 3600 * 48 * 1000) {
+    } else if (pass < 3600 * 24 * 1000) {
       //二天内
       strPassTime =
         Math.floor(pass / 1000 / 3600) +
@@ -70,7 +74,7 @@ const TimeShowWidget = ({
       strPassTime =
         Math.floor(pass / 1000 / 3600 / 24) +
         intl.formatMessage({ id: "utilities.time.days_ago" });
-    } else if (pass < 3600 * 24 * 60 * 1000) {
+    } else if (pass < 3600 * 24 * 30 * 1000) {
       //二个月内
       strPassTime =
         Math.floor(pass / 1000 / 3600 / 24 / 7) +
@@ -99,11 +103,13 @@ const TimeShowWidget = ({
 
   return (
     <Tooltip title={tooltip} color={color} key={color}>
-      <Space>
-        {icon}
-        {title}
-        {passTime}
-      </Space>
+      <Text type={type}>
+        <Space>
+          {icon}
+          {title}
+          {passTime}
+        </Space>
+      </Text>
     </Tooltip>
   );
 };

+ 3 - 4
dashboard/src/components/general/VideoModal.tsx

@@ -31,11 +31,10 @@ export const VideoModalWidget = ({ src, type, trigger }: IWidget) => {
         onOk={handleOk}
         onCancel={handleCancel}
         width={1000}
+        destroyOnClose
       >
-        <Video src={src} type={type} />
-        <div>
-          src = {src}
-          type = {type}
+        <div style={{ height: 600 }}>
+          <Video src={src} type={type} />
         </div>
       </Modal>
     </>

+ 36 - 0
dashboard/src/components/general/VisibleObserver.tsx

@@ -0,0 +1,36 @@
+import { useEffect, useRef, useState } from "react";
+
+const useOnScreen = (ref: any) => {
+  const [isIntersecting, setIntersecting] = useState(false);
+  const observer = new IntersectionObserver(([entry]) =>
+    setIntersecting(entry.isIntersecting)
+  );
+  useEffect(() => {
+    observer.observe(ref.current);
+    return () => {
+      observer.disconnect();
+    };
+  }, []);
+
+  return isIntersecting;
+};
+interface IWidget {
+  onVisible?: Function;
+}
+const VisibleObserverWidget = ({ onVisible }: IWidget) => {
+  const ref = useRef<HTMLDivElement>(null);
+  const isVisible = useOnScreen(ref);
+
+  useEffect(() => {
+    if (typeof onVisible !== "undefined") {
+      onVisible(isVisible);
+    }
+  }, [isVisible]);
+  return (
+    <div ref={ref} style={{ height: 20 }}>
+      {" "}
+    </div>
+  );
+};
+
+export default VisibleObserverWidget;

+ 1 - 1
dashboard/src/components/home/CourseNewList.tsx

@@ -24,7 +24,7 @@ const CourseNewListWidget = () => {
             subtitle: item.subtitle,
             teacher: item.teacher,
             intro: item.content,
-            coverUrl: item.cover,
+            coverUrl: item.cover_url,
           };
         });
         setData(course);

+ 3 - 4
dashboard/src/components/library/FooterBar.tsx

@@ -1,9 +1,8 @@
 import { Link } from "react-router-dom";
 import { Layout, Row, Col, Typography } from "antd";
-import CreateFeedback from "../feedback/CreateFeedback";
 
 const { Footer } = Layout;
-const { Title } = Typography;
+const { Paragraph } = Typography;
 
 const FooterBarWidget = () => {
   //Library foot bar
@@ -12,7 +11,7 @@ const FooterBarWidget = () => {
     <Footer>
       <Row>
         <Col span={8}>
-          <Title level={5}>相关链接</Title>
+          <Paragraph strong>相关链接</Paragraph>
           <ul>
             <li>
               <Link to="www.github.com/iapt-platform/mint" target="_blank">
@@ -23,7 +22,7 @@ const FooterBarWidget = () => {
           </ul>
         </Col>
         <Col span={16}>
-          <Title level={5}>问题反馈</Title>
+          <Paragraph strong>问题反馈</Paragraph>
         </Col>
       </Row>
       <Row>

+ 46 - 22
dashboard/src/components/template/Article.tsx

@@ -1,10 +1,10 @@
-import { Modal } from "antd";
+import { Card, Collapse, Modal } from "antd";
 import { Typography } from "antd";
 import { useState } from "react";
 import Article, { ArticleType } from "../article/Article";
 
 const { Link } = Typography;
-export type TDisplayStyle = "modal" | "card";
+export type TDisplayStyle = "modal" | "card" | "toggle";
 interface IWidgetChapterCtl {
   type?: ArticleType;
   id?: string;
@@ -33,27 +33,51 @@ const ArticleCtl = ({
     setIsModalOpen(false);
   };
   const aTitle = title ? title : "chapter" + id;
-  return (
-    <>
-      <Link onClick={showModal}>{aTitle}</Link>
-      <Modal
-        width={"80%"}
-        style={{ maxWidth: 1000 }}
-        title={aTitle}
-        open={isModalOpen}
-        onOk={handleOk}
-        onCancel={handleCancel}
-        footer={[]}
-      >
-        <Article
-          active={true}
-          type={type}
-          articleId={id + (channel ? "_" + channel : "")}
-          mode="read"
-        />
-      </Modal>
-    </>
+  const article = (
+    <Article
+      active={true}
+      type={type}
+      articleId={id}
+      channelId={channel}
+      mode="read"
+    />
   );
+  let output = <></>;
+  switch (style) {
+    case "modal":
+      output = (
+        <>
+          <Link onClick={showModal}>{aTitle}</Link>
+          <Modal
+            width={"80%"}
+            style={{ maxWidth: 1000 }}
+            title={aTitle}
+            open={isModalOpen}
+            onOk={handleOk}
+            onCancel={handleCancel}
+            footer={[]}
+          >
+            {article}
+          </Modal>
+        </>
+      );
+      break;
+    case "card":
+      output = <Card title={aTitle}>{article}</Card>;
+      break;
+    case "toggle":
+      output = (
+        <Collapse bordered={false}>
+          <Collapse.Panel header={aTitle} key="parent2">
+            {article}
+          </Collapse.Panel>
+        </Collapse>
+      );
+      break;
+    default:
+      break;
+  }
+  return output;
 };
 
 interface IWidget {

+ 21 - 3
dashboard/src/components/template/SentEdit/SentEditMenu.tsx

@@ -1,8 +1,15 @@
 import { Button, Dropdown } from "antd";
 import { useState } from "react";
-import { EditOutlined, CopyOutlined, MoreOutlined } from "@ant-design/icons";
+import {
+  EditOutlined,
+  CopyOutlined,
+  MoreOutlined,
+  FieldTimeOutlined,
+  LinkOutlined,
+} from "@ant-design/icons";
 import type { MenuProps } from "antd";
 import { ISentence } from "../SentEdit";
+import SentHistoryModal from "../../corpus/SentHistoryModal";
 
 interface IWidget {
   data: ISentence;
@@ -17,6 +24,7 @@ const SentEditMenuWidget = ({
   onConvert,
 }: IWidget) => {
   const [isHover, setIsHover] = useState(false);
+  const [timelineOpen, setTimelineOpen] = useState(false);
 
   const onClick: MenuProps["onClick"] = (e) => {
     console.log(e);
@@ -31,6 +39,9 @@ const SentEditMenuWidget = ({
           onConvert("markdown");
         }
         break;
+      case "timeline":
+        setTimelineOpen(true);
+        break;
       default:
         break;
     }
@@ -39,6 +50,7 @@ const SentEditMenuWidget = ({
     {
       key: "timeline",
       label: "时间线",
+      icon: <FieldTimeOutlined />,
     },
     {
       type: "divider",
@@ -57,8 +69,9 @@ const SentEditMenuWidget = ({
       type: "divider",
     },
     {
-      key: "share",
-      label: "分享",
+      key: "copy-link",
+      label: "复制链接",
+      icon: <LinkOutlined />,
     },
   ];
 
@@ -71,6 +84,11 @@ const SentEditMenuWidget = ({
         setIsHover(false);
       }}
     >
+      <SentHistoryModal
+        open={timelineOpen}
+        onClose={() => setTimelineOpen(false)}
+        sentId={data.id}
+      />
       <div
         style={{
           marginTop: "-1.2em",

+ 70 - 68
dashboard/src/components/template/Wbw/WbwDetail.tsx

@@ -27,74 +27,75 @@ const WbwDetailWidget = ({
   onCommentCountChange,
 }: IWidget) => {
   const intl = useIntl();
-  const [currWbwData, setCurrWbwData] = useState(data);
+  const [currWbwData, setCurrWbwData] = useState<IWbw>(
+    JSON.parse(JSON.stringify(data))
+  );
   useEffect(() => {
-    setCurrWbwData(data);
+    setCurrWbwData(JSON.parse(JSON.stringify(data)));
   }, [data]);
+
   function fieldChanged(field: TFieldName, value: string) {
     console.log("field", field, "value", value);
-    let mData = currWbwData;
-    switch (field) {
-      case "note":
-        mData.note = { value: value, status: 7 };
-        break;
-      case "bookMarkColor":
-        mData.bookMarkColor = { value: parseInt(value), status: 7 };
-        break;
-      case "bookMarkText":
-        mData.bookMarkText = { value: value, status: 7 };
-        break;
-      case "word":
-        mData.word = { value: value, status: 7 };
-        break;
-      case "real":
-        mData.real = { value: value, status: 7 };
-        break;
-      case "meaning":
-        mData.meaning = { value: value, status: 7 };
-        break;
-      case "factors":
-        mData.factors = { value: value, status: 7 };
-        break;
-      case "factorMeaning":
-        mData.factorMeaning = { value: value, status: 7 };
-        break;
-      case "parent":
-        mData.parent = { value: value, status: 7 };
-        break;
-      case "parent2":
-        mData.parent2 = { value: value, status: 7 };
-        break;
-      case "grammar2":
-        mData.grammar2 = { value: value, status: 7 };
-        break;
-      case "case":
-        const arrCase = value.split("#");
-        mData.case = { value: value, status: 7 };
-        mData.type = { value: arrCase[0] ? arrCase[0] : "", status: 7 };
-        mData.grammar = { value: arrCase[1] ? arrCase[1] : "", status: 7 };
-        break;
-      case "relation":
-        mData.relation = { value: value, status: 7 };
-        break;
-      case "confidence":
-        mData.confidence = parseFloat(value);
-        break;
-      case "locked":
-        mData.locked = JSON.parse(value);
-        break;
-      default:
-        break;
-    }
-    setCurrWbwData(mData);
+    setCurrWbwData((origin) => {
+      switch (field) {
+        case "note":
+          origin.note = { value: value, status: 7 };
+          break;
+        case "bookMarkColor":
+          origin.bookMarkColor = { value: parseInt(value), status: 7 };
+          break;
+        case "bookMarkText":
+          origin.bookMarkText = { value: value, status: 7 };
+          break;
+        case "word":
+          origin.word = { value: value, status: 7 };
+          break;
+        case "real":
+          origin.real = { value: value, status: 7 };
+          break;
+        case "meaning":
+          origin.meaning = { value: value, status: 7 };
+          break;
+        case "factors":
+          origin.factors = { value: value, status: 7 };
+          break;
+        case "factorMeaning":
+          origin.factorMeaning = { value: value, status: 7 };
+          break;
+        case "parent":
+          origin.parent = { value: value, status: 7 };
+          break;
+        case "parent2":
+          origin.parent2 = { value: value, status: 7 };
+          break;
+        case "grammar2":
+          origin.grammar2 = { value: value, status: 7 };
+          break;
+        case "case":
+          const arrCase = value.split("#");
+          origin.case = { value: value, status: 7 };
+          origin.type = { value: arrCase[0] ? arrCase[0] : "", status: 7 };
+          origin.grammar = { value: arrCase[1] ? arrCase[1] : "", status: 7 };
+          break;
+        case "relation":
+          origin.relation = { value: value, status: 7 };
+          break;
+        case "confidence":
+          origin.confidence = parseFloat(value);
+          break;
+        case "locked":
+          origin.locked = JSON.parse(value);
+          break;
+        case "attachments":
+          //mData.attachments = value;
+          break;
+        default:
+          break;
+      }
+      return origin;
+    });
   }
 
-  const items = [
-    {
-      key: "user-dict",
-      label: intl.formatMessage({ id: "buttons.save.publish" }),
-    },
-  ];
   return (
     <div
       style={{
@@ -192,14 +193,15 @@ const WbwDetailWidget = ({
                     fieldChanged(e.field, e.value);
                   }}
                   onUpload={(fileList: UploadFile<IAttachmentResponse>[]) => {
-                    let mData = currWbwData;
+                    let mData = JSON.parse(JSON.stringify(currWbwData));
                     mData.attachments = fileList.map((item) => {
                       return {
-                        uid: item.uid,
-                        name: item.name,
-                        size: item.size,
-                        type: item.type,
-                        url: item.response?.data.url,
+                        id: item.response ? item.response?.data.id : item.uid,
+                        title: item.name,
+                        size: item.size ? item.size : 0,
+                        content_type: item.response
+                          ? item.response?.data.content_type
+                          : "",
                       };
                     });
                     setCurrWbwData(mData);

+ 7 - 2
dashboard/src/components/template/Wbw/WbwDetailUpload.tsx

@@ -16,11 +16,16 @@ const WbwDetailUploadWidget = ({ data, onUpload }: IWidget) => {
 
   const props: UploadProps = {
     name: "file",
-    action: `${API_HOST}/api/v2/attachments`,
+    action: `${API_HOST}/api/v2/attachment`,
     headers: {
       Authorization: `Bearer ${getToken()}`,
     },
-    defaultFileList: data.attachments,
+    defaultFileList: data.attachments?.map((item) => {
+      return {
+        uid: item.id,
+        name: item.title ? item.title : "",
+      };
+    }),
     onChange(info) {
       console.log("onchange", info);
       if (typeof onUpload !== "undefined") {

+ 4 - 4
dashboard/src/components/template/Wbw/WbwPali.tsx

@@ -107,15 +107,15 @@ const WbwPaliWidget = ({ data, display, onSave }: IWidget) => {
 
   //生成视频播放按钮
   const videoList = data.attachments?.filter((item) =>
-    item.type?.includes("video")
+    item.content_type?.includes("video")
   );
   const videoIcon = videoList ? (
     <WbwVideoButton
       video={videoList?.map((item) => {
         return {
-          url: item.url ? item.url : "",
-          type: item.type,
-          title: item.name,
+          videoId: item.id,
+          type: item.content_type,
+          title: item.title,
         };
       })}
     />

+ 19 - 5
dashboard/src/components/template/Wbw/WbwVideoButton.tsx

@@ -1,7 +1,11 @@
 import { VideoCameraOutlined } from "@ant-design/icons";
+import { useEffect, useState } from "react";
+import { get } from "../../../request";
+import { IAttachmentResponse } from "../../api/Attachments";
 import VideoModal from "../../general/VideoModal";
 
 export interface IVideo {
+  videoId: string;
   url?: string;
   type?: string;
   title?: string;
@@ -10,13 +14,23 @@ interface IWidget {
   video: IVideo[];
 }
 const WbwVideoButtonWidget = ({ video }: IWidget) => {
-  const url = video ? video[0].url : "";
-  const src: string = process.env.REACT_APP_WEB_HOST
-    ? process.env.REACT_APP_WEB_HOST
-    : "";
+  const [url, setUrl] = useState<string>();
+  const [curr, setCurr] = useState(0);
+
+  useEffect(() => {
+    get<IAttachmentResponse>(`/v2/attachment/${video[curr].videoId}`).then(
+      (json) => {
+        console.log(json);
+        if (json.ok) {
+          setUrl(json.data.url);
+        }
+      }
+    );
+  }, [curr, video]);
+
   return video ? (
     <VideoModal
-      src={src + "/" + url}
+      src={url}
       type={video[0].type}
       trigger={<VideoCameraOutlined />}
     />

+ 9 - 1
dashboard/src/components/template/Wbw/WbwWord.tsx

@@ -38,6 +38,7 @@ export type TFieldName =
   | "bookMarkColor"
   | "bookMarkText"
   | "locked"
+  | "attachments"
   | "confidence";
 
 export interface IWbwField {
@@ -51,6 +52,13 @@ export enum WbwStatus {
   apply = 5,
   manual = 7,
 }
+
+export interface IWbwAttachment {
+  id: string;
+  content_type: string;
+  size: number;
+  title: string;
+}
 export interface WbwElement<R> {
   value: R;
   status: WbwStatus;
@@ -79,7 +87,7 @@ export interface IWbw {
   bookMarkText?: WbwElement<string | null>;
   locked?: boolean;
   confidence: number;
-  attachments?: UploadFile[];
+  attachments?: IWbwAttachment[];
   hasComment?: boolean;
 }
 export interface IWbwFields {

+ 1 - 0
dashboard/src/components/template/WbwSent.tsx

@@ -246,6 +246,7 @@ export const WbwSentCtl = ({
       note: item.note,
       bmt: item.bookMarkText,
       bmc: item.bookMarkColor,
+      attachments: JSON.stringify(item.attachments),
       cf: item.confidence,
     };
   };

+ 5 - 0
dashboard/src/locales/zh-Hans/label.ts

@@ -9,6 +9,11 @@ const items = {
   "labels.collaborators": "协作者",
   "labels.link": "链接",
   "labels.upload": "上传",
+  "labels.first-term": "第一个术语",
+  "labels.sign-in": "注册",
+  "labels.first-wbw": "第一个逐词解析",
+  "labels.first-translation": "第一个译文",
+  "labels.first-course": "第一个课程",
 };
 
 export default items;

+ 31 - 0
dashboard/src/pages/admin/api/dashboard.tsx

@@ -0,0 +1,31 @@
+import { StatisticCard } from "@ant-design/pro-components";
+import { useState } from "react";
+import RcResizeObserver from "rc-resize-observer";
+import ApiGauge from "../../../components/admin/api/ApiGauge";
+import ApiDelayHour from "../../../components/admin/api/ApiDelayHour";
+const { Divider } = StatisticCard;
+const Widget = () => {
+  const [responsive, setResponsive] = useState(true);
+
+  return (
+    <RcResizeObserver
+      key="resize-observer"
+      onResize={(offset) => {
+        setResponsive(offset.width < 596);
+      }}
+    >
+      <StatisticCard.Group
+        direction={responsive ? "column" : "row"}
+        title="总量"
+      >
+        <ApiDelayHour type="average" title={"平均相应时间"} />
+        <Divider type={responsive ? "horizontal" : "vertical"} />
+        <ApiDelayHour type="count" title={"请求次数"} />
+        <Divider type={responsive ? "horizontal" : "vertical"} />
+        <ApiGauge />
+      </StatisticCard.Group>
+    </RcResizeObserver>
+  );
+};
+
+export default Widget;

+ 15 - 0
dashboard/src/pages/admin/api/index.tsx

@@ -0,0 +1,15 @@
+import { Outlet } from "react-router-dom";
+import { Layout } from "antd";
+import { styleStudioContent } from "../../studio/style";
+
+const { Content } = Layout;
+
+const Widget = () => {
+  return (
+    <Content style={styleStudioContent}>
+      <Outlet />
+    </Content>
+  );
+};
+
+export default Widget;

+ 19 - 4
dashboard/src/pages/library/anthology/list.tsx

@@ -1,9 +1,10 @@
 import { useState } from "react";
-import { Input } from "antd";
+import { Input, Tabs } from "antd";
 import { Layout, Affix, Col, Row } from "antd";
 
 import AnthologyList from "../../../components/article/AnthologyList";
 import AnthologyStudioList from "../../../components/article/AnthologyStudioList";
+import ArticleListPublic from "../../../components/article/ArticleListPublic";
 
 const { Content, Header } = Layout;
 const { Search } = Input;
@@ -48,10 +49,24 @@ const Widget = () => {
           <Col flex="auto"></Col>
           <Col flex="1260px">
             <Row>
-              <Col span="18">
-                <AnthologyList searchKey={searchKey} />
+              <Col span="16">
+                <Tabs
+                  size="small"
+                  items={[
+                    {
+                      label: `Anthology`,
+                      key: "anthology",
+                      children: <AnthologyList searchKey={searchKey} />,
+                    },
+                    {
+                      label: `Article`,
+                      key: "article",
+                      children: <ArticleListPublic search={searchKey} />,
+                    },
+                  ]}
+                />
               </Col>
-              <Col span="6">
+              <Col span="8" style={{ padding: 8 }}>
                 <AnthologyStudioList />
               </Col>
             </Row>

+ 18 - 7
dashboard/src/pages/library/article/show.tsx

@@ -4,7 +4,9 @@ import { Key } from "antd/lib/table/interface";
 import { useEffect, useState } from "react";
 import { useNavigate, useParams, useSearchParams } from "react-router-dom";
 import { ColumnOutlinedIcon } from "../../../assets/icon";
+import { IArticleDataResponse } from "../../../components/api/Article";
 import { IApiResponseDictList } from "../../../components/api/Dict";
+import AnchorNav from "../../../components/article/AnchorNav";
 
 import Article, {
   ArticleMode,
@@ -47,6 +49,7 @@ const Widget = () => {
   console.log("mode", mode);
   const [rightPanel, setRightPanel] = useState<TPanelName>("close");
   const [searchParams, setSearchParams] = useSearchParams();
+  const [anchorNavOpen, setAnchorNavOpen] = useState(false);
   const paraChange = useAppSelector(paraParam);
 
   useEffect(() => {
@@ -82,13 +85,13 @@ const Widget = () => {
     /**
      * 启动时载入格位公式字典
      */
-    get<IApiResponseDictList>(`/v2/userdict?view=word&word=_formula_`).then(
-      (json) => {
-        console.log("_formula_ ok", json.data.count);
-        //存储到redux
-        store.dispatch(add(json.data.rows));
-      }
-    );
+    get<IApiResponseDictList>(
+      `/v2/userdict?view=dict&id=2142c229-8860-4ca5-a82e-1afc7e4f1e5d`
+    ).then((json) => {
+      console.log("_formula_ ok", json.data.count);
+      //存储到redux
+      store.dispatch(add(json.data.rows));
+    });
   }, []);
   const rightBarWidth = "48px";
   const channelId = id?.split("_").slice(1);
@@ -146,6 +149,12 @@ const Widget = () => {
               }}
             />
             <Divider type="vertical" />
+            <Button
+              style={{ display: "block", color: "white" }}
+              icon={<ColumnOutlinedIcon />}
+              type="text"
+              onClick={() => setAnchorNavOpen((value) => !value)}
+            />
             <Button
               style={{ display: "block", color: "white" }}
               icon={<ColumnOutlinedIcon />}
@@ -220,9 +229,11 @@ const Widget = () => {
                 });
                 navigate(url);
               }}
+              onLoad={(article: IArticleDataResponse) => {}}
             />
           </div>
           <div key="RightPanel">
+            <AnchorNav open={anchorNavOpen} />
             <RightPanel
               curr={rightPanel}
               type={type as ArticleType}

+ 7 - 8
dashboard/src/pages/library/blog/overview.tsx

@@ -1,16 +1,17 @@
 import { useParams } from "react-router-dom";
-import { Row, Col } from "antd";
+import { Row, Col, Space } from "antd";
 import { Affix } from "antd";
 
 import BlogNav from "../../../components/blog/BlogNav";
 import Profile from "../../../components/blog/Profile";
 import AuthorTimeLine from "../../../components/blog/TimeLine";
 import TopArticles from "../../../components/blog/TopArticles";
+import TopChapter from "../../../components/corpus/TopChapter";
 
 const Widget = () => {
   // TODO
   const { studio } = useParams(); //url 参数
-
+  //<TopArticles studio={studio ? studio : ""} />
   return (
     <>
       <Affix offsetTop={0}>
@@ -23,12 +24,10 @@ const Widget = () => {
         </Col>
 
         <Col flex="900px">
-          <div>
-            <TopArticles studio={studio ? studio : ""} />
-          </div>
-          <div>
-            <AuthorTimeLine />
-          </div>
+          <Space direction="vertical" size={50}>
+            <TopChapter studioName={studio} />
+            <AuthorTimeLine studioName={studio} />
+          </Space>
         </Col>
       </Row>
     </>

+ 2 - 0
dashboard/src/pages/library/blog/translation.tsx

@@ -1,6 +1,7 @@
 import { useParams } from "react-router-dom";
 
 import BlogNav from "../../../components/blog/BlogNav";
+import CommunityChapter from "../../../components/corpus/CommunityChapter";
 
 const Widget = () => {
   // TODO
@@ -9,6 +10,7 @@ const Widget = () => {
   return (
     <>
       <BlogNav selectedKey="palicanon" studio={studio ? studio : ""} />
+      <CommunityChapter studioName={studio} />
     </>
   );
 };

+ 2 - 2
dashboard/src/pages/library/course/course.tsx

@@ -28,7 +28,7 @@ export interface ICourse {
   startAt?: string; //课程开始时间
   endAt?: string; //课程结束时间
   intro?: string; //简介
-  coverUrl?: string; //封面图片文件名
+  coverUrl?: string[]; //封面图片文件名
   join?: TCourseJoinMode;
   exp?: TCourseExpRequest;
 }
@@ -53,7 +53,7 @@ const Widget = () => {
           startAt: json.data.start_at,
           endAt: json.data.end_at,
           intro: json.data.content,
-          coverUrl: json.data.cover,
+          coverUrl: json.data.cover_url,
           join: json.data.join,
           exp: json.data.request_exp,
         };

+ 6 - 4
dashboard/src/pages/library/palicanon/bypath.tsx

@@ -126,10 +126,12 @@ const Widget = () => {
                 tag={bookTag}
                 onChapterClick={(e: IChapterClickEvent) => {
                   if (e.event.ctrlKey) {
-                    window.open(
-                      `/my/palicanon/chapter/${e.para.Book}-${e.para.Paragraph}`,
-                      "_blank"
-                    );
+                    const url = `/palicanon/chapter/${e.para.Book}-${e.para.Paragraph}`;
+                    const fullUrl =
+                      process.env.REACT_APP_WEB_HOST +
+                      process.env.PUBLIC_URL +
+                      url;
+                    window.open(fullUrl, "_blank");
                   } else {
                     setIsModalOpen(true);
                     setOpenPara({ book: e.para.Book, para: e.para.Paragraph });

+ 13 - 3
dashboard/src/pages/studio/article/edit.tsx

@@ -1,8 +1,9 @@
-import { useState } from "react";
+import { useRef, useState } from "react";
 import { Link, useParams } from "react-router-dom";
 import { useIntl } from "react-intl";
 import {
   ProForm,
+  ProFormInstance,
   ProFormText,
   ProFormTextArea,
 } from "@ant-design/pro-components";
@@ -23,6 +24,7 @@ import ShareModal from "../../../components/share/ShareModal";
 import { EResType } from "../../../components/share/Share";
 import AddToAnthology from "../../../components/article/AddToAnthology";
 import ReadonlyLabel from "../../../components/general/ReadonlyLabel";
+import ArticlePrevDrawer from "../../../components/article/ArticlePrevDrawer";
 
 interface IFormData {
   uid: string;
@@ -41,6 +43,7 @@ const Widget = () => {
   const [title, setTitle] = useState("loading");
   const [unauthorized, setUnauthorized] = useState(false);
   const [readonly, setReadonly] = useState(false);
+  const [content, setContent] = useState<string>();
 
   return (
     <Card
@@ -128,6 +131,7 @@ const Widget = () => {
               const readonly = res.data.role === "editor" ? false : true;
               setReadonly(readonly);
               setTitle(res.data.title);
+              setContent(res.data.content);
             } else {
               setUnauthorized(true);
               setTitle("无权访问");
@@ -206,11 +210,17 @@ const Widget = () => {
                           {intl.formatMessage({
                             id: "forms.fields.content.label",
                           })}
-                          <Button>预览</Button>
+                          {articleid ? (
+                            <ArticlePrevDrawer
+                              trigger={<Button>预览</Button>}
+                              articleId={articleid}
+                              content={content}
+                            />
+                          ) : undefined}
                         </Space>
                       }
                     >
-                      <MDEditor />
+                      <MDEditor onChange={(value) => setContent(value)} />
                     </Form.Item>
                   </ProForm.Group>
                 ),

+ 15 - 3
dashboard/src/pages/studio/course/list.tsx

@@ -44,7 +44,8 @@ interface DataItem {
   course_start_at?: string; //课程开始时间
   course_end_at?: string; //课程结束时间
   intro_markdown?: string; //简介
-  cover_img_name?: string; //封面图片文件名
+  coverId: string;
+  coverUrl?: string[]; //封面图片文件名
   myStatus?: TCourseMemberStatus;
   countProgressing?: number;
 }
@@ -150,7 +151,17 @@ const Widget = () => {
               return (
                 <Space key={index}>
                   <Image
-                    src={`${API_HOST}/${row.cover_img_name}`}
+                    src={
+                      row.coverUrl && row.coverUrl.length > 1
+                        ? row.coverUrl[1]
+                        : ""
+                    }
+                    preview={{
+                      src:
+                        row.coverUrl && row.coverUrl.length > 0
+                          ? row.coverUrl[0]
+                          : "",
+                    }}
                     width={64}
                     fallback={`${API_HOST}/app/course/img/default.jpg`}
                   />
@@ -347,7 +358,8 @@ const Widget = () => {
               title: item.title,
               subtitle: item.subtitle,
               teacher: item.teacher?.nickName,
-              cover_img_name: item.cover,
+              coverId: item.cover,
+              coverUrl: item.cover_url,
               type: item.publicity,
               member_count: item.member_count,
               myStatus: item.my_status,

+ 246 - 174
dashboard/src/pages/studio/recent/list.tsx

@@ -1,210 +1,282 @@
-import { useParams } from "react-router-dom";
 import { useIntl } from "react-intl";
-import { Link } from "react-router-dom";
-import { Dropdown } from "antd";
+import { useEffect, useRef, useState } from "react";
+import { Dropdown, Space, Typography } from "antd";
 import { SearchOutlined } from "@ant-design/icons";
-import { ProTable } from "@ant-design/pro-components";
+import { ActionType, ProTable } from "@ant-design/pro-components";
+
 import { get } from "../../../request";
-import { ArticleType } from "../../../components/article/Article";
+import { ArticleMode, ArticleType } from "../../../components/article/Article";
+import { useAppSelector } from "../../../hooks";
+import { currentUser as _currentUser } from "../../../reducers/current-user";
+import ArticleDrawer from "../../../components/article/ArticleDrawer";
+import {
+  ArticleOutlinedIcon,
+  ChapterOutlinedIcon,
+  ParagraphOutlinedIcon,
+} from "../../../assets/icon";
 
-export interface IViewRequest {
-  target_type: ArticleType;
-  book: number;
-  para: number;
-  channel: string;
-  mode: string;
+export interface IRecentRequest {
+  type: ArticleType;
+  article_id: string;
+  param?: string;
 }
-export interface IMetaChapter {
-  book: number;
-  para: number;
-  channel: string;
-  mode: string;
+interface IParam {
+  book?: string;
+  para?: string;
+  channel?: string;
+  mode?: string;
 }
-interface IViewData {
+interface IRecentData {
   id: string;
-  target_id: string;
-  target_type: ArticleType;
-  updated_at: string;
   title: string;
-  org_title: string;
-  meta: string;
-}
-export interface IViewStoreResponse {
-  ok: boolean;
-  message: string;
-  data: number;
+  type: ArticleType;
+  article_id: string;
+  param: string | null;
+  updated_at: string;
 }
-export interface IViewResponse {
+
+export interface IRecentResponse {
   ok: boolean;
   message: string;
-  data: IViewData;
+  data: IRecentData;
 }
-export interface IViewListResponse {
+interface IRecentListResponse {
   ok: boolean;
   message: string;
   data: {
-    rows: IViewData[];
+    rows: IRecentData[];
     count: number;
   };
 }
 
-export interface IView {
+interface IRecent {
   id: string;
   title: string;
-  subtitle: string;
   type: ArticleType;
+  articleId: string;
   updatedAt: string;
-  meta: IMetaChapter;
+  param?: IParam;
+}
+interface IArticleParam {
+  type: ArticleType;
+  articleId: string;
+  mode?: ArticleMode;
+  channelId?: string;
+  book?: string;
+  para?: string;
 }
-
 const Widget = () => {
   const intl = useIntl();
-  const { studioname } = useParams();
+  const user = useAppSelector(_currentUser);
+  const ref = useRef<ActionType>();
+  const [articleOpen, setArticleOpen] = useState(false);
+  const [param, setParam] = useState<IArticleParam>();
+
+  useEffect(() => {
+    ref.current?.reload();
+  }, [user]);
   return (
-    <ProTable<IView>
-      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",
-          tip: "过长会自动收缩",
-          ellipsis: true,
-          render: (text, row, index, action) => {
-            return (
-              <div key={index}>
-                <div>
-                  <Link
-                    to={`/article/chapter/${row.meta.book}-${row.meta.para}`}
-                    target="_blank"
+    <>
+      <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",
+            tip: "过长会自动收缩",
+            ellipsis: true,
+            render: (text, row, index, action) => {
+              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 (event.ctrlKey || event.metaKey) {
+                        let url = `/article/${row.type}/${row.articleId}?mode=`;
+                        url += row.param?.mode ? row.param?.mode : "read";
+                        url += row.param?.channel
+                          ? `&channel=${row.param?.channel}`
+                          : "";
+                        url += row.param?.book
+                          ? `&book=${row.param?.book}`
+                          : "";
+                        url += row.param?.para ? `&par=${row.param?.para}` : "";
+                        const fullUrl =
+                          process.env.REACT_APP_WEB_HOST +
+                          process.env.PUBLIC_URL +
+                          url;
+                        window.open(fullUrl, "_blank");
+                      } else {
+                        setParam({
+                          type: row.type,
+                          articleId: row.articleId,
+                          mode: row.param?.mode as ArticleMode,
+                          channelId: row.param?.channel,
+                          book: row.param?.book,
+                          para: row.param?.para,
+                        });
+                        setArticleOpen(true);
+                      }
+                    }}
                   >
-                    {row.title ? row.title : row.subtitle}
-                  </Link>
-                </div>
-                <div>{row.title ? row.subtitle : undefined}</div>
-              </div>
-            );
+                    {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" },
+          {
+            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, action) => [
-            <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 />,
+          {
+            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, action) => [
+              <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;
+                    }
                   },
-                ],
-                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);
-        let url = `/v2/view?view=studio&name=${studioname}`;
-        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 : "";
-
-        const res = await get<IViewListResponse>(url);
-        console.log("article list", res);
-        const items: IView[] = res.data.rows.map((item, id) => {
+                }}
+              >
+                {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 = `/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 {
-            sn: id + 1,
-            id: item.id,
-            title: item.title,
-            subtitle: item.org_title,
-            type: item.target_type,
-            meta: JSON.parse(item.meta),
-            updatedAt: item.updated_at,
+            total: res.data.count,
+            succcess: true,
+            data: items,
           };
-        });
-        return {
-          total: res.data.count,
-          succcess: true,
-          data: items,
-        };
-      }}
-      rowKey="id"
-      bordered
-      pagination={{
-        showQuickJumper: true,
-        showSizeChanger: true,
-      }}
-      search={false}
-      options={{
-        search: true,
-      }}
-    />
+        }}
+        rowKey="id"
+        bordered
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+        }}
+        search={false}
+        options={{
+          search: true,
+        }}
+      />
+      <ArticleDrawer
+        {...param}
+        open={articleOpen}
+        onClose={() => setArticleOpen(false)}
+      />
+    </>
   );
 };