Просмотр исходного кода

Merge pull request #1714 from visuddhinanda/agile

完善自定义书编辑
visuddhinanda 2 лет назад
Родитель
Сommit
74310bf45b
36 измененных файлов с 1427 добавлено и 514 удалено
  1. 1 0
      .gitignore
  2. 13 2
      dashboard/src/components/anthology/AnthologyModal.tsx
  3. 5 3
      dashboard/src/components/anthology/AnthologyTocTree.tsx
  4. 2 1
      dashboard/src/components/anthology/EditableTocTree.tsx
  5. 8 0
      dashboard/src/components/api/Article.ts
  6. 14 3
      dashboard/src/components/article/AddToAnthology.tsx
  7. 72 0
      dashboard/src/components/article/AnthologiesAtArticle.tsx
  8. 49 23
      dashboard/src/components/article/AnthologyDetail.tsx
  9. 66 13
      dashboard/src/components/article/AnthologyInfoEdit.tsx
  10. 59 376
      dashboard/src/components/article/Article.tsx
  11. 2 0
      dashboard/src/components/article/ArticleDrawer.tsx
  12. 22 7
      dashboard/src/components/article/ArticleEdit.tsx
  13. 3 10
      dashboard/src/components/article/ArticleEditDrawer.tsx
  14. 5 1
      dashboard/src/components/article/ArticleEditTools.tsx
  15. 2 1
      dashboard/src/components/article/ArticleList.tsx
  16. 6 20
      dashboard/src/components/article/ArticleView.tsx
  17. 4 0
      dashboard/src/components/article/ToolButtonToc.tsx
  18. 59 0
      dashboard/src/components/article/TypeAnthology.tsx
  19. 202 0
      dashboard/src/components/article/TypeArticle.tsx
  20. 269 0
      dashboard/src/components/article/TypeCourse.tsx
  21. 269 0
      dashboard/src/components/article/TypePali.tsx
  22. 110 0
      dashboard/src/components/article/TypeTerm.tsx
  23. 36 5
      dashboard/src/components/corpus/TocPath.tsx
  24. 39 3
      dashboard/src/components/export/ShareButton.tsx
  25. 5 0
      dashboard/src/components/fts/FullTextSearchResult.tsx
  26. 43 0
      dashboard/src/components/general/ErrorResult.tsx
  27. 2 0
      dashboard/src/components/general/TermTextArea.tsx
  28. 21 15
      dashboard/src/components/general/TermTextAreaMenu.tsx
  29. 1 1
      dashboard/src/components/template/MdView.tsx
  30. 2 1
      dashboard/src/components/template/SentEdit.tsx
  31. 1 1
      dashboard/src/components/template/SentEdit/SentCell.tsx
  32. 1 1
      dashboard/src/components/template/SentEdit/SentContent.tsx
  33. 14 23
      dashboard/src/pages/library/article/show.tsx
  34. 1 0
      dashboard/src/pages/studio/anthology/edit.tsx
  35. 3 0
      dashboard/src/request.ts
  36. 16 4
      rpc/tulip/tulip/content_download.php

+ 1 - 0
.gitignore

@@ -2,3 +2,4 @@ tmp
 *.log
 /.VSCodeCounter
 config.toml
+/.vscode/launch.json

+ 13 - 2
dashboard/src/components/anthology/AnthologyModal.tsx

@@ -1,30 +1,41 @@
-import { useState } from "react";
+import { useEffect, useState } from "react";
 import { Modal } from "antd";
 import AnthologyList from "./AnthologyList";
 
 interface IWidget {
   studioName?: string;
   trigger?: React.ReactNode;
+  open?: boolean;
+  onClose?: Function;
   onSelect?: Function;
   onCancel?: Function;
 }
 const AnthologyModalWidget = ({
   studioName,
   trigger,
+  open = false,
+  onClose,
   onSelect,
   onCancel,
 }: IWidget) => {
-  const [isModalOpen, setIsModalOpen] = useState(false);
+  const [isModalOpen, setIsModalOpen] = useState(open);
 
+  useEffect(() => setIsModalOpen(open), [open]);
   const showModal = () => {
     setIsModalOpen(true);
   };
 
   const handleOk = () => {
+    if (typeof onClose !== "undefined") {
+      onClose(false);
+    }
     setIsModalOpen(false);
   };
 
   const handleCancel = () => {
+    if (typeof onClose !== "undefined") {
+      onClose(false);
+    }
     setIsModalOpen(false);
   };
 

+ 5 - 3
dashboard/src/components/anthology/AnthologyTocTree.tsx

@@ -1,5 +1,4 @@
 import { useEffect, useState } from "react";
-import { useNavigate } from "react-router-dom";
 
 import { get } from "../../request";
 import { IArticleMapListResponse } from "../api/Article";
@@ -8,11 +7,13 @@ import TocTree from "../article/TocTree";
 
 interface IWidget {
   anthologyId?: string;
+  channels?: string[];
   onSelect?: Function;
   onArticleSelect?: Function;
 }
 const AnthologyTocTreeWidget = ({
   anthologyId,
+  channels,
   onSelect,
   onArticleSelect,
 }: IWidget) => {
@@ -23,14 +24,15 @@ const AnthologyTocTreeWidget = ({
     if (typeof anthologyId === "undefined") {
       return;
     }
-    const url = `/v2/article-map?view=anthology&id=${anthologyId}`;
+    let url = `/v2/article-map?view=anthology&id=${anthologyId}`;
+    url += channels && channels.length > 0 ? "&channel=" + channels[0] : "";
     console.log("url", url);
     get<IArticleMapListResponse>(url).then((json) => {
       if (json.ok) {
         const toc: ListNodeData[] = json.data.rows.map((item) => {
           return {
             key: item.article_id ? item.article_id : item.title,
-            title: item.title,
+            title: item.title_text ? item.title_text : item.title,
             level: item.level,
             deletedAt: item.deleted_at,
           };

+ 2 - 1
dashboard/src/components/anthology/EditableTocTree.tsx

@@ -85,7 +85,7 @@ const EditableTocTreeWidget = ({
         const toc: ListNodeData[] = json.data.rows.map((item) => {
           return {
             key: item.article_id ? item.article_id : item.title,
-            title: item.title,
+            title: item.title_text ? item.title_text : item.title,
             level: item.level,
             deletedAt: item.deleted_at,
           };
@@ -172,6 +172,7 @@ const EditableTocTreeWidget = ({
         }}
       />
       <ArticleEditDrawer
+        anthologyId={anthologyId}
         articleId={articleId}
         open={openEditor}
         onClose={() => setOpenEditor(false)}

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

@@ -1,5 +1,6 @@
 import { IStudio } from "../auth/StudioName";
 import { IUser } from "../auth/User";
+import { IChannel } from "../channel/Channel";
 import { ITocPathNode } from "../corpus/TocPath";
 import type { IStudioApiResponse, TRole } from "./Auth";
 
@@ -16,6 +17,7 @@ export interface IAnthologyDataRequest {
   article_list?: IArticleListApiResponse[];
   lang: string;
   status: number;
+  default_channel?: string | null;
 }
 export interface IAnthologyDataResponse {
   uid: string;
@@ -24,6 +26,7 @@ export interface IAnthologyDataResponse {
   summary: string;
   article_list: IArticleListApiResponse[];
   studio: IStudioApiResponse;
+  default_channel?: IChannel;
   lang: string;
   status: number;
   childrenNumber: number;
@@ -66,6 +69,8 @@ export interface IArticleDataRequest {
   content_type?: string;
   status: number;
   lang: string;
+  to_tpl?: boolean;
+  anthology_id?: string;
 }
 export interface IChapterToc {
   key?: string;
@@ -79,6 +84,7 @@ export interface IChapterToc {
 export interface IArticleDataResponse {
   uid: string;
   title: string;
+  title_text?: string;
   subtitle: string;
   summary: string | null;
   _summary?: string;
@@ -134,9 +140,11 @@ export interface IAnthologyCreateRequest {
 export interface IArticleMapRequest {
   id?: string;
   collect_id?: string;
+  collection?: { id: string; title: string };
   article_id?: string;
   level: number;
   title: string;
+  title_text?: string;
   editor?: IUser;
   children?: number;
   deleted_at?: string | null;

+ 14 - 3
dashboard/src/components/article/AddToAnthology.tsx

@@ -1,24 +1,35 @@
 import { Button, message } from "antd";
-import React from "react";
+import React, { useEffect, useState } from "react";
 import { post } from "../../request";
 import AnthologyModal from "../anthology/AnthologyModal";
 import { IArticleMapAddRequest, IArticleMapAddResponse } from "../api/Article";
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
 interface IWidget {
   trigger?: React.ReactNode;
   studioName?: string;
   articleIds?: string[];
+  open?: boolean;
+  onClose?: Function;
   onFinally?: Function;
 }
 const AddToAnthologyWidget = ({
   trigger,
   studioName,
+  open = false,
+  onClose,
   articleIds,
   onFinally,
 }: IWidget) => {
+  const [isModalOpen, setIsModalOpen] = useState(open);
+  const user = useAppSelector(currentUser);
+  useEffect(() => setIsModalOpen(open), [open]);
   return (
     <AnthologyModal
-      studioName={studioName}
-      trigger={trigger ? trigger : <Button type="link">加入文集</Button>}
+      studioName={studioName ? studioName : user?.realName}
+      trigger={trigger}
+      open={isModalOpen}
+      onClose={(isOpen: boolean) => setIsModalOpen(isOpen)}
       onSelect={(id: string) => {
         if (typeof articleIds !== "undefined") {
           post<IArticleMapAddRequest, IArticleMapAddResponse>(

+ 72 - 0
dashboard/src/components/article/AnthologiesAtArticle.tsx

@@ -0,0 +1,72 @@
+import { Space, Typography, message } from "antd";
+import { useEffect, useState } from "react";
+import { get } from "../../request";
+import { IArticleMapListResponse } from "../api/Article";
+
+const { Link, Paragraph } = Typography;
+interface IList {
+  key?: string;
+  label?: string;
+}
+interface IWidget {
+  articleId?: string;
+  anthologyId?: string | null;
+  onClick?: Function;
+}
+const AnthologiesAtArticleWidget = ({
+  articleId,
+  anthologyId,
+  onClick,
+}: IWidget) => {
+  const [list, setList] = useState<IList[]>();
+  useEffect(() => {
+    //查询这个article 有多少文集
+    let url = `/v2/article-map?view=article&id=${articleId}`;
+    console.log("url", url);
+    get<IArticleMapListResponse>(url).then((json) => {
+      if (json.ok) {
+        const anthologies: IList[] = json.data.rows.map((item) => {
+          return {
+            key: item.collection?.id,
+            label: item.collection?.title,
+          };
+        });
+        console.log("anthologies", anthologies);
+        setList(anthologies.filter((value) => value.key !== anthologyId));
+      } else {
+        message.error("获取文集列表失败");
+      }
+    });
+  }, [articleId]);
+
+  let title = "";
+  if (anthologyId) {
+    title = "其他文集";
+  } else {
+    title = "文集列表";
+  }
+
+  return (
+    <Paragraph style={{ display: list && list.length > 0 ? "block" : "none" }}>
+      <Space>
+        {title}
+        {list?.map((item, index) => {
+          return (
+            <Link
+              key={index}
+              onClick={(e) => {
+                if (typeof onClick !== "undefined") {
+                  onClick(item.key, e);
+                }
+              }}
+            >
+              {item.label}
+            </Link>
+          );
+        })}
+      </Space>
+    </Paragraph>
+  );
+};
+
+export default AnthologiesAtArticleWidget;

+ 49 - 23
dashboard/src/components/article/AnthologyDetail.tsx

@@ -1,5 +1,5 @@
 import { useState, useEffect } from "react";
-import { Space, Typography } from "antd";
+import { Space, Typography, message } from "antd";
 
 import { get } from "../../request";
 import type {
@@ -17,49 +17,74 @@ const { Title, Text, Paragraph } = Typography;
 interface IWidgetAnthologyDetail {
   aid?: string;
   channels?: string[];
+  visible?: boolean;
   onArticleSelect?: Function;
+  onLoad?: Function;
+  onTitle?: Function;
+  onLoading?: Function;
+  onError?: Function;
 }
 const AnthologyDetailWidget = ({
   aid,
   channels,
+  visible = true,
   onArticleSelect,
+  onLoading,
+  onTitle,
+  onError,
 }: IWidgetAnthologyDetail) => {
   const [tableData, setTableData] = useState<IAnthologyData>();
 
   useEffect(() => {
-    console.log("useEffect");
     fetchData(aid);
   }, [aid]);
 
   function fetchData(id?: string) {
-    get<IAnthologyResponse>(`/v2/anthology/${id}`)
+    const url = `/v2/anthology/${id}`;
+    console.log("url", url);
+    if (typeof onLoading !== "undefined") {
+      onLoading(true);
+    }
+    get<IAnthologyResponse>(url)
       .then((response) => {
-        const item: IAnthologyDataResponse = response.data;
-        let newTree: IAnthologyData = {
-          id: item.uid,
-          title: item.title,
-          subTitle: item.subtitle,
-          summary: item.summary,
-          articles: item.article_list.map((al) => {
-            return {
-              key: al.article,
-              title: al.title,
-              level: parseInt(al.level),
-            };
-          }),
-          studio: item.studio,
-          created_at: item.created_at,
-          updated_at: item.updated_at,
-        };
-        setTableData(newTree);
-        console.log("toc", newTree.articles);
+        if (response.ok) {
+          const item: IAnthologyDataResponse = response.data;
+          let newTree: IAnthologyData = {
+            id: item.uid,
+            title: item.title,
+            subTitle: item.subtitle,
+            summary: item.summary,
+            articles: [],
+            studio: item.studio,
+            created_at: item.created_at,
+            updated_at: item.updated_at,
+          };
+          setTableData(newTree);
+          if (typeof onTitle !== "undefined") {
+            onTitle(item.title);
+          }
+          console.log("toc", newTree.articles);
+        } else {
+          if (typeof onError !== "undefined") {
+            onError(response.data, response.message);
+          }
+          message.error(response.message);
+        }
+      })
+      .finally(() => {
+        if (typeof onLoading !== "undefined") {
+          onLoading(false);
+        }
       })
       .catch((error) => {
         console.error(error);
+        if (typeof onError !== "undefined") {
+          onError(error, "");
+        }
       });
   }
   return (
-    <div style={{ padding: 12 }}>
+    <div style={{ padding: 12, visibility: visible ? "visible" : "hidden" }}>
       <Title level={4}>{tableData?.title}</Title>
       <div>
         <Text type="secondary">{tableData?.subTitle}</Text>
@@ -76,6 +101,7 @@ const AnthologyDetailWidget = ({
       <Title level={5}>目录</Title>
       <AnthologyTocTree
         anthologyId={aid}
+        channels={channels}
         onArticleSelect={(anthologyId: string, keys: string[]) => {
           if (typeof onArticleSelect !== "undefined") {
             onArticleSelect(anthologyId, keys);

+ 66 - 13
dashboard/src/components/article/AnthologyInfoEdit.tsx

@@ -1,12 +1,20 @@
 import { Form, message } from "antd";
 import { useIntl } from "react-intl";
-import { ProForm, ProFormText } from "@ant-design/pro-components";
+import {
+  ProForm,
+  ProFormSelect,
+  ProFormText,
+  RequestOptionsType,
+} from "@ant-design/pro-components";
 import MDEditor from "@uiw/react-md-editor";
 
 import { get, put } from "../../request";
 import { IAnthologyDataRequest, IAnthologyResponse } from "../api/Article";
 import LangSelect from "../general/LangSelect";
 import PublicitySelect from "../studio/PublicitySelect";
+import { useState } from "react";
+import { DefaultOptionType } from "antd/lib/select";
+import { IApiResponseChannelList } from "../api/Channel";
 
 interface IFormData {
   title: string;
@@ -14,29 +22,37 @@ interface IFormData {
   summary?: string;
   lang: string;
   status: number;
+  defaultChannel?: string;
 }
 
 interface IWidget {
   anthologyId?: string;
+  studioName?: string;
   onLoad?: Function;
 }
-const AnthologyInfoEditWidget = ({ anthologyId, onLoad }: IWidget) => {
+const AnthologyInfoEditWidget = ({
+  studioName,
+  anthologyId,
+  onLoad,
+}: IWidget) => {
   const intl = useIntl();
+  const [channelOption, setChannelOption] = useState<DefaultOptionType[]>([]);
+  const [currChannel, setCurrChannel] = useState<RequestOptionsType>();
 
   return anthologyId ? (
     <ProForm<IFormData>
       onFinish={async (values: IFormData) => {
-        console.log(values);
-        const res = await put<IAnthologyDataRequest, IAnthologyResponse>(
-          `/v2/anthology/${anthologyId}`,
-          {
-            title: values.title,
-            subtitle: values.subtitle,
-            summary: values.summary,
-            status: values.status,
-            lang: values.lang,
-          }
-        );
+        const url = `/v2/anthology/${anthologyId}`;
+        console.log("url", url);
+        console.log("values", values);
+        const res = await put<IAnthologyDataRequest, IAnthologyResponse>(url, {
+          title: values.title,
+          subtitle: values.subtitle,
+          summary: values.summary,
+          status: values.status,
+          lang: values.lang,
+          default_channel: values.defaultChannel,
+        });
         console.log(res);
         if (res.ok) {
           if (typeof onLoad !== "undefined") {
@@ -60,6 +76,14 @@ const AnthologyInfoEditWidget = ({ anthologyId, onLoad }: IWidget) => {
           if (typeof onLoad !== "undefined") {
             onLoad(res.data);
           }
+          if (res.data.default_channel) {
+            const channel = {
+              value: res.data.default_channel.id,
+              label: res.data.default_channel.name,
+            };
+            setCurrChannel(channel);
+            setChannelOption([channel]);
+          }
 
           return {
             title: res.data.title,
@@ -67,6 +91,7 @@ const AnthologyInfoEditWidget = ({ anthologyId, onLoad }: IWidget) => {
             summary: res.data.summary ? res.data.summary : undefined,
             lang: res.data.lang,
             status: res.data.status,
+            defaultChannel: res.data.default_channel?.id,
           };
         } else {
           return {
@@ -75,6 +100,7 @@ const AnthologyInfoEditWidget = ({ anthologyId, onLoad }: IWidget) => {
             summary: "",
             lang: "",
             status: 0,
+            defaultChannel: "",
           };
         }
       }}
@@ -109,6 +135,33 @@ const AnthologyInfoEditWidget = ({ anthologyId, onLoad }: IWidget) => {
         <LangSelect width="md" />
         <PublicitySelect width="md" />
       </ProForm.Group>
+      <ProForm.Group>
+        <ProFormSelect
+          options={channelOption}
+          width="md"
+          name="defaultChannel"
+          label={"默认版本"}
+          showSearch
+          debounceTime={300}
+          request={async ({ keyWords }) => {
+            console.log("keyWord", keyWords);
+            if (typeof keyWords === "undefined") {
+              return currChannel ? [currChannel] : [];
+            }
+            const url = `/v2/channel?view=studio&name=${studioName}`;
+            console.log("url", url);
+            const json = await get<IApiResponseChannelList>(url);
+            const textbookList = json.data.rows.map((item) => {
+              return {
+                value: item.uid,
+                label: `${item.studio.nickName}/${item.name}`,
+              };
+            });
+            console.log("json", textbookList);
+            return textbookList;
+          }}
+        />
+      </ProForm.Group>
       <ProForm.Group>
         <Form.Item
           name="summary"

+ 59 - 376
dashboard/src/components/article/Article.tsx

@@ -1,31 +1,15 @@
-import { useEffect, useState } from "react";
-import { Divider, message, Result, Space, Tag } from "antd";
-
-import { get, post } from "../../request";
-import store from "../../store";
-import { IArticleDataResponse, IArticleResponse } from "../api/Article";
-import ArticleView, { IFirstAnthology } from "./ArticleView";
-import { ICourseCurrUserResponse } from "../api/Course";
-import { ICourseUser, signIn } from "../../reducers/course-user";
-import { ITextbook, refresh } from "../../reducers/current-course";
-import ExerciseList from "./ExerciseList";
-import ExerciseAnswer from "../course/ExerciseAnswer";
+import { IArticleDataResponse } from "../api/Article";
+import TypeArticle from "./TypeArticle";
+import TypeAnthology from "./TypeAnthology";
+import TypeTerm from "./TypeTerm";
+import TypePali from "./TypePali";
 import "./article.css";
-import TocTree from "./TocTree";
-import PaliText from "../template/Wbw/PaliText";
-import ArticleSkeleton from "./ArticleSkeleton";
-import { IViewRequest, IViewStoreResponse } from "../api/view";
-import {
-  IRecentRequest,
-  IRecentResponse,
-} from "../../pages/studio/recent/list";
-import { ITocPathNode } from "../corpus/TocPath";
-import { useSearchParams } from "react-router-dom";
-import { ITermResponse } from "../api/Term";
 
 export type ArticleMode = "read" | "edit" | "wbw";
 export type ArticleType =
+  | "anthology"
   | "article"
+  | "series"
   | "chapter"
   | "para"
   | "cs-para"
@@ -56,14 +40,14 @@ export type ArticleType =
 interface IWidget {
   type?: ArticleType;
   articleId?: string;
+  mode?: ArticleMode | null;
+  channelId?: string | null;
   book?: string | null;
   para?: string | null;
-  channelId?: string | null;
   anthologyId?: string | null;
   courseId?: string;
   exerciseId?: string;
   userName?: string;
-  mode?: ArticleMode | null;
   active?: boolean;
   onArticleChange?: Function;
   onFinal?: Function;
@@ -87,360 +71,24 @@ const ArticleWidget = ({
   onLoad,
   onAnthologySelect,
 }: IWidget) => {
-  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 [searchParams] = useSearchParams();
-
-  const channels = channelId?.split("_");
-
-  useEffect(() => {
-    /**
-     * 由课本进入查询当前用户的权限和channel
-     */
-    if (
-      type === "textbook" ||
-      type === "exercise" ||
-      type === "exercise-list"
-    ) {
-      if (typeof articleId !== "undefined") {
-        const id = articleId.split("_");
-        get<ICourseCurrUserResponse>(`/v2/course-curr?course_id=${id[0]}`).then(
-          (response) => {
-            console.log("course user", response);
-            if (response.ok) {
-              const it: ICourseUser = {
-                channelId: response.data.channel_id,
-                role: response.data.role,
-              };
-              store.dispatch(signIn(it));
-              /**
-               * redux发布课程信息
-               */
-              const ic: ITextbook = {
-                courseId: id[0],
-                articleId: id[1],
-              };
-              store.dispatch(refresh(ic));
-            }
-          }
-        );
-      }
-    }
-  }, [articleId, type]);
-
-  const srcDataMode = mode === "edit" || mode === "wbw" ? "edit" : "read";
-  useEffect(() => {
-    console.log("srcDataMode", srcDataMode);
-    if (!active) {
-      return;
-    }
-
-    if (typeof type !== "undefined") {
-      const debug = searchParams.get("debug");
-      let url = "";
-      switch (type) {
-        case "chapter":
-          if (typeof articleId !== "undefined") {
-            url = `/v2/corpus-chapter/${articleId}?mode=${srcDataMode}`;
-            url += channelId ? `&channels=${channelId}` : "";
-          }
-          break;
-        case "para":
-          const _book = book ? book : articleId;
-          url = `/v2/corpus?view=para&book=${_book}&par=${para}&mode=${srcDataMode}`;
-          url += channelId ? `&channels=${channelId}` : "";
-          break;
-        case "article":
-          if (typeof articleId !== "undefined") {
-            url = `/v2/article/${articleId}?mode=${srcDataMode}`;
-            url += channelId ? `&channel=${channelId}` : "";
-            url += anthologyId ? `&anthology=${anthologyId}` : "";
-          }
-          break;
-        case "textbook":
-          if (typeof articleId !== "undefined") {
-            url = `/v2/article/${articleId}?view=textbook&course=${courseId}&mode=${srcDataMode}`;
-          }
-          break;
-        case "exercise":
-          if (typeof articleId !== "undefined") {
-            url = `/v2/article/${articleId}?mode=${srcDataMode}&course=${courseId}&exercise=${exerciseId}&user=${userName}`;
-            setExtra(
-              <ExerciseAnswer
-                courseId={courseId}
-                articleId={articleId}
-                exerciseId={exerciseId}
-              />
-            );
-          }
-          break;
-        case "exercise-list":
-          if (typeof articleId !== "undefined") {
-            url = `/v2/article/${articleId}?mode=${srcDataMode}&course=${courseId}&exercise=${exerciseId}`;
-
-            setExtra(
-              <ExerciseList
-                courseId={courseId}
-                articleId={articleId}
-                exerciseId={exerciseId}
-              />
-            );
-          }
-          break;
-        case "term":
-          if (typeof articleId !== "undefined") {
-            url = `/v2/terms/${articleId}?mode=${srcDataMode}`;
-            url += channelId ? `&channel=${channelId}` : "";
-          }
-          break;
-        default:
-          if (typeof articleId !== "undefined") {
-            url = `/v2/corpus/${type}/${articleId}/${srcDataMode}?mode=${srcDataMode}`;
-            url += channelId ? `&channel=${channelId}` : "";
-          }
-          break;
-      }
-      if (debug) {
-        url += `&debug=${debug}`;
-      }
-      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);
-        });
-      }
-
-      console.log("url", url);
-      if (type === "term") {
-        get<ITermResponse>(url)
-          .then((json) => {
-            if (json.ok) {
-              setArticleData({
-                uid: json.data.guid,
-                title: json.data.meaning,
-                subtitle: json.data.word,
-                summary: json.data.note,
-                content: json.data.note ? json.data.note : "",
-                content_type: "markdown",
-                html: json.data.html,
-                path: [],
-                status: 30,
-                lang: json.data.language,
-                created_at: json.data.created_at,
-                updated_at: json.data.updated_at,
-              });
-              if (json.data.html) {
-                setArticleHtml([json.data.html]);
-              } else if (json.data.note) {
-                setArticleHtml([json.data.note]);
-              }
-              setShowSkeleton(false);
-            }
-          })
-          .catch((error) => {
-            console.error(error);
-          });
-      } else {
-        get<IArticleResponse>(url)
-          .then((json) => {
-            console.log("article", json);
-            if (json.ok) {
-              setArticleData(json.data);
-              if (json.data.html) {
-                setArticleHtml([json.data.html]);
-              } else if (json.data.content) {
-                setArticleHtml([json.data.content]);
-              }
-              if (json.data.from) {
-                setRemains(true);
-              }
-              setShowSkeleton(false);
-
-              setExtra(
-                <TocTree
-                  treeData={json.data.toc?.map((item) => {
-                    const strTitle = item.title ? item.title : item.pali_title;
-                    const key = item.key
-                      ? item.key
-                      : `${item.book}-${item.paragraph}`;
-                    const progress = item.progress?.map((item, id) => (
-                      <Tag key={id}>{Math.round(item * 100) + "%"}</Tag>
-                    ));
-                    return {
-                      key: key,
-                      title: (
-                        <Space>
-                          <PaliText
-                            text={strTitle === "" ? "[unnamed]" : strTitle}
-                          />
-                          {progress}
-                        </Space>
-                      ),
-                      level: item.level,
-                    };
-                  })}
-                  onSelect={(keys: string[]) => {
-                    console.log(keys);
-                    if (
-                      typeof onArticleChange !== "undefined" &&
-                      keys.length > 0
-                    ) {
-                      onArticleChange(keys[0]);
-                    }
-                  }}
-                />
-              );
-
-              switch (type) {
-                case "chapter":
-                  if (typeof articleId === "string" && channelId) {
-                    const [book, para] = articleId?.split("-");
-                    post<IViewRequest, IViewStoreResponse>("/v2/view", {
-                      target_type: type,
-                      book: parseInt(book),
-                      para: parseInt(para),
-                      channel: channelId,
-                      mode: srcDataMode,
-                    }).then((json) => {
-                      console.log("view", json.data);
-                    });
-                  }
-                  break;
-                default:
-                  break;
-              }
-
-              if (typeof onLoad !== "undefined") {
-                onLoad(json.data);
-              }
-            } else {
-              setShowSkeleton(false);
-              setUnauthorized(true);
-              message.error(json.message);
-            }
-          })
-          .catch((e) => {
-            console.error(e);
-          });
-      }
-    }
-  }, [
-    active,
-    type,
-    articleId,
-    srcDataMode,
-    book,
-    para,
-    channelId,
-    anthologyId,
-    courseId,
-    exerciseId,
-    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;
-  };
-
-  //const comment = <CommentListCard resId={articleData?.uid} resType="article" />
-  let anthology: IFirstAnthology | undefined;
-  if (articleData?.anthology_count && articleData.anthology_first) {
-    anthology = {
-      id: articleData.anthology_first.uid,
-      title: articleData.anthology_first.title,
-      count: articleData?.anthology_count,
-    };
-  }
-
   return (
     <div>
-      {showSkeleton ? (
-        <ArticleSkeleton />
-      ) : unauthorized ? (
-        <Result
-          status="403"
-          title="无权访问"
-          subTitle="您无权访问该内容。您可能没有登录,或者内容的所有者没有给您所需的权限。"
-          extra={<></>}
-        />
-      ) : (
-        <ArticleView
-          id={articleData?.uid}
-          title={articleData?.title}
-          subTitle={articleData?.subtitle}
-          summary={articleData?.summary}
-          content={articleData ? articleData.content : ""}
-          html={articleHtml}
-          path={articleData?.path}
-          created_at={articleData?.created_at}
-          updated_at={articleData?.updated_at}
-          channels={channels}
+      {type === "article" ? (
+        <TypeArticle
           type={type}
           articleId={articleId}
-          remains={remains}
-          anthology={anthology}
-          onEnd={() => {
-            if (type === "chapter" && articleData) {
-              getNextPara(articleData);
+          channelId={channelId}
+          mode={mode}
+          anthologyId={anthologyId}
+          active={active}
+          onArticleChange={(type: ArticleType, id: string) => {
+            if (typeof onArticleChange !== "undefined") {
+              onArticleChange(type, id);
             }
           }}
-          onPathChange={(
-            node: ITocPathNode,
-            e: React.MouseEvent<HTMLSpanElement | HTMLAnchorElement, MouseEvent>
-          ) => {
-            if (typeof onArticleChange !== "undefined") {
-              const newArticle = node.key
-                ? node.key
-                : `${node.book}-${node.paragraph}`;
-              const target = e.ctrlKey || e.metaKey ? "_blank" : "self";
-              onArticleChange(newArticle, target);
+          onLoad={(data: IArticleDataResponse) => {
+            if (typeof onLoad !== "undefined") {
+              onLoad(data);
             }
           }}
           onAnthologySelect={(id: string) => {
@@ -449,10 +97,45 @@ const ArticleWidget = ({
             }
           }}
         />
+      ) : type === "anthology" ? (
+        <TypeAnthology
+          articleId={articleId}
+          channelId={channelId}
+          mode={mode}
+          onArticleChange={(type: ArticleType, id: string) => {
+            if (typeof onArticleChange !== "undefined") {
+              onArticleChange(type, id);
+            }
+          }}
+        />
+      ) : type === "term" ? (
+        <TypeTerm
+          articleId={articleId}
+          channelId={channelId}
+          mode={mode}
+          onArticleChange={(type: ArticleType, id: string) => {
+            if (typeof onArticleChange !== "undefined") {
+              onArticleChange(type, id);
+            }
+          }}
+        />
+      ) : type === "chapter" || type === "para" ? (
+        <TypePali
+          type={type}
+          articleId={articleId}
+          channelId={channelId}
+          mode={mode}
+          book={book}
+          para={para}
+          onArticleChange={(type: ArticleType, id: string) => {
+            if (typeof onArticleChange !== "undefined") {
+              onArticleChange(type, id);
+            }
+          }}
+        />
+      ) : (
+        <></>
       )}
-      <Divider />
-      {extra}
-      <Divider />
     </div>
   );
 };

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

@@ -12,6 +12,7 @@ interface IWidget {
   para?: string;
   channelId?: string;
   articleId?: string;
+  anthologyId?: string;
   mode?: ArticleMode;
   open?: boolean;
   onClose?: Function;
@@ -25,6 +26,7 @@ const ArticleDrawerWidget = ({
   para,
   channelId,
   articleId,
+  anthologyId,
   mode,
   open,
   onClose,

+ 22 - 7
dashboard/src/components/article/ArticleEdit.tsx

@@ -1,8 +1,10 @@
-import { useState } from "react";
+import { useRef, useState } from "react";
 
 import { useIntl } from "react-intl";
 import {
   ProForm,
+  ProFormInstance,
+  ProFormSwitch,
   ProFormText,
   ProFormTextArea,
 } from "@ant-design/pro-components";
@@ -30,11 +32,13 @@ interface IFormData {
   content_type?: string;
   status: number;
   lang: string;
+  to_tpl?: boolean;
 }
 
 interface IWidget {
   studioName?: string;
   articleId?: string;
+  anthologyId?: string;
   onReady?: Function;
   onLoad?: Function;
   onChange?: Function;
@@ -43,6 +47,7 @@ interface IWidget {
 const ArticleEditWidget = ({
   studioName,
   articleId,
+  anthologyId,
   onReady,
   onLoad,
   onChange,
@@ -52,6 +57,7 @@ const ArticleEditWidget = ({
   const [readonly, setReadonly] = useState(false);
   const [content, setContent] = useState<string>();
   const [owner, setOwner] = useState<IStudio>();
+  const formRef = useRef<ProFormInstance>();
 
   return unauthorized ? (
     <Result
@@ -75,8 +81,9 @@ const ArticleEditWidget = ({
         />
       ) : undefined}
       <ProForm<IFormData>
+        formRef={formRef}
         onFinish={async (values: IFormData) => {
-          const request = {
+          const request: IArticleDataRequest = {
             uid: articleId ? articleId : "",
             title: values.title,
             subtitle: values.subtitle,
@@ -85,18 +92,19 @@ const ArticleEditWidget = ({
             content_type: "markdown",
             status: values.status,
             lang: values.lang,
+            to_tpl: values.to_tpl,
+            anthology_id: anthologyId,
           };
-          console.log("save", request);
-          put<IArticleDataRequest, IArticleResponse>(
-            `/v2/article/${articleId}`,
-            request
-          )
+          const url = `/v2/article/${articleId}`;
+          console.log("save", url, request);
+          put<IArticleDataRequest, IArticleResponse>(url, request)
             .then((res) => {
               console.log("save response", res);
               if (res.ok) {
                 if (typeof onChange !== "undefined") {
                   onChange(res.data);
                 }
+                formRef.current?.setFieldValue("content", res.data.content);
                 message.success(intl.formatMessage({ id: "flashes.success" }));
               } else {
                 message.error(res.message);
@@ -205,6 +213,13 @@ const ArticleEditWidget = ({
             />
           </Form.Item>
         </ProForm.Group>
+        <ProForm.Group>
+          <ProFormSwitch
+            name="to_tpl"
+            label="转换为模版"
+            disabled={anthologyId ? false : true}
+          />
+        </ProForm.Group>
       </ProForm>
     </>
   );

+ 3 - 10
dashboard/src/components/article/ArticleEditDrawer.tsx

@@ -8,6 +8,7 @@ import ArticleEditTools from "./ArticleEditTools";
 interface IWidget {
   trigger?: React.ReactNode;
   articleId?: string;
+  anthologyId?: string;
   open?: boolean;
   onClose?: Function;
   onChange?: Function;
@@ -16,6 +17,7 @@ interface IWidget {
 const ArticleEditDrawerWidget = ({
   trigger,
   articleId,
+  anthologyId,
   open,
   onClose,
   onChange,
@@ -39,16 +41,6 @@ const ArticleEditDrawerWidget = ({
       onClose();
     }
   };
-  /*
-  const getUrl = (openMode?: string): string => {
-    let url = `/article/${type}/${articleId}?mode=`;
-    url += openMode ? openMode : mode ? mode : "read";
-    url += channelId ? `&channel=${channelId}` : "";
-    url += book ? `&book=${book}` : "";
-    url += para ? `&par=${para}` : "";
-    return url;
-  };
-*/
 
   return (
     <>
@@ -69,6 +61,7 @@ const ArticleEditDrawerWidget = ({
         }
       >
         <ArticleEdit
+          anthologyId={anthologyId}
           articleId={articleId}
           onReady={(title: string, readonly: boolean, studio?: string) => {
             setTitle(title);

+ 5 - 1
dashboard/src/components/article/ArticleEditTools.tsx

@@ -22,7 +22,11 @@ const ArticleEditToolsWidget = ({
   return (
     <Space>
       {articleId ? (
-        <AddToAnthology studioName={studioName} articleIds={[articleId]} />
+        <AddToAnthology
+          trigger={<Button type="link">加入文集</Button>}
+          studioName={studioName}
+          articleIds={[articleId]}
+        />
       ) : undefined}
       {articleId ? (
         <ShareModal

+ 2 - 1
dashboard/src/components/article/ArticleList.tsx

@@ -306,7 +306,7 @@ const ArticleListWidget = ({
                         key: "addToAnthology",
                         label: (
                           <AddToAnthology
-                            trigger="加入文集"
+                            trigger={<Button type="link">加入文集</Button>}
                             studioName={studioName}
                             articleIds={[row.id]}
                           />
@@ -407,6 +407,7 @@ const ArticleListWidget = ({
               </Button>
               <AddToAnthology
                 studioName={studioName}
+                trigger={<Button type="link">加入文集</Button>}
                 articleIds={selectedRowKeys.map((item) => item.toString())}
                 onFinally={() => {
                   onCleanSelected();

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

@@ -6,6 +6,7 @@ import TocPath, { ITocPathNode } from "../corpus/TocPath";
 import PaliChapterChannelList from "../corpus/PaliChapterChannelList";
 import { ArticleType } from "./Article";
 import VisibleObserver from "../general/VisibleObserver";
+import { useEffect, useState } from "react";
 
 const { Paragraph, Title, Text } = Typography;
 export interface IFirstAnthology {
@@ -30,7 +31,6 @@ export interface IWidgetArticleData {
   anthology?: IFirstAnthology;
   onEnd?: Function;
   onPathChange?: Function;
-  onAnthologySelect?: Function;
 }
 
 const ArticleViewWidget = ({
@@ -50,8 +50,11 @@ const ArticleViewWidget = ({
   onEnd,
   remains,
   onPathChange,
-  onAnthologySelect,
 }: IWidgetArticleData) => {
+  const [currPath, setCurrPath] = useState(path);
+
+  useEffect(() => setCurrPath(path), [path]);
+
   let currChannelList = <></>;
   switch (type) {
     case "chapter":
@@ -86,25 +89,8 @@ const ArticleViewWidget = ({
       </div>
 
       <Space direction="vertical">
-        <Text>
-          {path.length === 0 && anthology ? (
-            <>
-              <Text>{"文集:"}</Text>
-              <Button
-                type="link"
-                onClick={() => {
-                  if (typeof onAnthologySelect !== "undefined") {
-                    onAnthologySelect(anthology.id);
-                  }
-                }}
-              >
-                {anthology.title}
-              </Button>
-            </>
-          ) : undefined}
-        </Text>
         <TocPath
-          data={path}
+          data={currPath}
           channel={channels}
           onChange={(
             node: ITocPathNode,

+ 4 - 0
dashboard/src/components/article/ToolButtonToc.tsx

@@ -10,14 +10,17 @@ interface IWidget {
   type?: ArticleType;
   articleId?: string;
   anthologyId?: string | null;
+  channels?: string[];
   onSelect?: Function;
 }
 const ToolButtonTocWidget = ({
   type,
   articleId,
   anthologyId,
+  channels,
   onSelect,
 }: IWidget) => {
+  //TODO 都放return里面
   let tocWidget = <></>;
   if (type === "chapter" || type === "para") {
     if (articleId) {
@@ -41,6 +44,7 @@ const ToolButtonTocWidget = ({
       tocWidget = (
         <AnthologyTocTree
           anthologyId={anthologyId}
+          channels={channels}
           onArticleSelect={(anthologyId: string, keys: string[]) => {
             if (typeof onSelect !== "undefined" && keys.length > 0) {
               onSelect(keys[0]);

+ 59 - 0
dashboard/src/components/article/TypeAnthology.tsx

@@ -0,0 +1,59 @@
+import { ArticleMode, ArticleType } from "./Article";
+import AnthologyDetail from "./AnthologyDetail";
+import "./article.css";
+import { useState } from "react";
+import ErrorResult from "../general/ErrorResult";
+import ArticleSkeleton from "./ArticleSkeleton";
+
+interface IWidget {
+  type?: ArticleType;
+  articleId?: string;
+  mode?: ArticleMode | null;
+  channelId?: string | null;
+  onArticleChange?: Function;
+  onFinal?: Function;
+  onLoad?: Function;
+}
+const TypeAnthologyWidget = ({
+  type,
+  channelId,
+  articleId,
+  mode = "read",
+  onArticleChange,
+}: IWidget) => {
+  const [loading, setLoading] = useState(false);
+  const [errorCode, setErrorCode] = useState<number>();
+
+  const channels = channelId?.split("_");
+  return (
+    <div>
+      {loading ? (
+        <ArticleSkeleton />
+      ) : errorCode ? (
+        <ErrorResult code={errorCode} />
+      ) : (
+        <></>
+      )}
+      <AnthologyDetail
+        visible={!loading}
+        onArticleSelect={(anthologyId: string, keys: string[]) => {
+          if (typeof onArticleChange !== "undefined" && keys.length > 0) {
+            onArticleChange("article", keys[0], {
+              anthologyId: anthologyId,
+            });
+          }
+        }}
+        onLoading={(loading: boolean) => {
+          setLoading(loading);
+        }}
+        onError={(code: number, message: string) => {
+          setErrorCode(code);
+        }}
+        channels={channels}
+        aid={articleId}
+      />
+    </div>
+  );
+};
+
+export default TypeAnthologyWidget;

+ 202 - 0
dashboard/src/components/article/TypeArticle.tsx

@@ -0,0 +1,202 @@
+import { useEffect, useState } from "react";
+import { Divider, message, Space, Tag } from "antd";
+
+import { get } from "../../request";
+import { IArticleDataResponse, IArticleResponse } from "../api/Article";
+import ArticleView, { IFirstAnthology } from "./ArticleView";
+import TocTree from "./TocTree";
+import PaliText from "../template/Wbw/PaliText";
+import { ITocPathNode } from "../corpus/TocPath";
+import { ArticleMode, ArticleType } from "./Article";
+import "./article.css";
+import ArticleSkeleton from "./ArticleSkeleton";
+import ErrorResult from "../general/ErrorResult";
+import AnthologiesAtArticle from "./AnthologiesAtArticle";
+
+interface IWidget {
+  type?: ArticleType;
+  articleId?: string;
+  mode?: ArticleMode | null;
+  channelId?: string | null;
+  anthologyId?: string | null;
+  active?: boolean;
+  onArticleChange?: Function;
+  onFinal?: Function;
+  onLoad?: Function;
+  onAnthologySelect?: Function;
+}
+const TypeArticleWidget = ({
+  type,
+  channelId,
+  articleId,
+  anthologyId,
+  mode = "read",
+  active = false,
+  onArticleChange,
+  onFinal,
+  onLoad,
+  onAnthologySelect,
+}: IWidget) => {
+  const [articleData, setArticleData] = useState<IArticleDataResponse>();
+  const [articleHtml, setArticleHtml] = useState<string[]>(["<span />"]);
+  const [extra, setExtra] = useState(<></>);
+  const [loading, setLoading] = useState(false);
+  const [errorCode, setErrorCode] = useState<number>();
+  const [currPath, setCurrPath] = useState<ITocPathNode[]>();
+
+  const channels = channelId?.split("_");
+
+  const srcDataMode = mode === "edit" || mode === "wbw" ? "edit" : "read";
+  useEffect(() => {
+    console.log("srcDataMode", srcDataMode);
+    if (!active) {
+      return;
+    }
+
+    if (typeof type === "undefined") {
+      return;
+    }
+
+    let url = `/v2/article/${articleId}?mode=${srcDataMode}`;
+    url += channelId ? `&channel=${channelId}` : "";
+    url += anthologyId ? `&anthology=${anthologyId}` : "";
+    console.log("url", url);
+    setLoading(true);
+    console.log("url", url);
+    get<IArticleResponse>(url)
+      .then((json) => {
+        console.log("article", json);
+        if (json.ok) {
+          setArticleData(json.data);
+          setCurrPath(json.data.path);
+          if (json.data.html) {
+            setArticleHtml([json.data.html]);
+          } else if (json.data.content) {
+            setArticleHtml([json.data.content]);
+          }
+          setExtra(
+            <TocTree
+              treeData={json.data.toc?.map((item) => {
+                const strTitle = item.title ? item.title : item.pali_title;
+                const key = item.key
+                  ? item.key
+                  : `${item.book}-${item.paragraph}`;
+                const progress = item.progress?.map((item, id) => (
+                  <Tag key={id}>{Math.round(item * 100) + "%"}</Tag>
+                ));
+                return {
+                  key: key,
+                  title: (
+                    <Space>
+                      <PaliText
+                        text={strTitle === "" ? "[unnamed]" : strTitle}
+                      />
+                      {progress}
+                    </Space>
+                  ),
+                  level: item.level,
+                };
+              })}
+              onSelect={(keys: string[]) => {
+                console.log(keys);
+                if (typeof onArticleChange !== "undefined" && keys.length > 0) {
+                  onArticleChange("article", keys[0]);
+                }
+              }}
+            />
+          );
+
+          if (typeof onLoad !== "undefined") {
+            onLoad(json.data);
+          }
+        } else {
+          console.error("json", json);
+          message.error(json.message);
+        }
+      })
+      .finally(() => {
+        setLoading(false);
+      })
+      .catch((e) => {
+        console.error(e);
+        setErrorCode(e);
+      });
+  }, [active, type, articleId, srcDataMode, channelId, anthologyId]);
+
+  let anthology: IFirstAnthology | undefined;
+  if (articleData?.anthology_count && articleData.anthology_first) {
+    anthology = {
+      id: articleData.anthology_first.uid,
+      title: articleData.anthology_first.title,
+      count: articleData?.anthology_count,
+    };
+  }
+
+  return (
+    <div>
+      {loading ? (
+        <ArticleSkeleton />
+      ) : errorCode ? (
+        <ErrorResult code={errorCode} />
+      ) : (
+        <>
+          <AnthologiesAtArticle
+            articleId={articleId}
+            anthologyId={anthologyId}
+            onClick={(
+              id: string,
+              e: React.MouseEvent<HTMLElement, MouseEvent>
+            ) => {
+              if (typeof onAnthologySelect !== "undefined") {
+                onAnthologySelect(id, e);
+              }
+            }}
+          />
+          <ArticleView
+            id={articleData?.uid}
+            title={
+              articleData?.title_text
+                ? articleData?.title_text
+                : articleData?.title
+            }
+            subTitle={articleData?.subtitle}
+            summary={articleData?.summary}
+            content={articleData ? articleData.content : ""}
+            html={articleHtml}
+            path={currPath}
+            created_at={articleData?.created_at}
+            updated_at={articleData?.updated_at}
+            channels={channels}
+            type={type}
+            articleId={articleId}
+            anthology={anthology}
+            onPathChange={(
+              node: ITocPathNode,
+              e: React.MouseEvent<
+                HTMLSpanElement | HTMLAnchorElement,
+                MouseEvent
+              >
+            ) => {
+              let newType = type;
+              if (node.level === 0) {
+                newType = "anthology";
+              } else {
+                newType = "article";
+              }
+              if (typeof onArticleChange !== "undefined") {
+                const newArticleId = node.key;
+                const target = e.ctrlKey || e.metaKey ? "_blank" : "self";
+                onArticleChange(newType, newArticleId, target);
+              }
+            }}
+          />
+          <Divider />
+          {extra}
+          <Divider />
+        </>
+      )}
+    </div>
+  );
+};
+
+export default TypeArticleWidget;

+ 269 - 0
dashboard/src/components/article/TypeCourse.tsx

@@ -0,0 +1,269 @@
+import { useEffect, useState } from "react";
+import { Divider, message, Result, Space, Tag } from "antd";
+
+import { get } from "../../request";
+import store from "../../store";
+import { IArticleDataResponse, IArticleResponse } from "../api/Article";
+import ArticleView from "./ArticleView";
+import { ICourseCurrUserResponse } from "../api/Course";
+import { ICourseUser, signIn } from "../../reducers/course-user";
+import { ITextbook, refresh } from "../../reducers/current-course";
+import ExerciseList from "./ExerciseList";
+import ExerciseAnswer from "../course/ExerciseAnswer";
+import "./article.css";
+import TocTree from "./TocTree";
+import PaliText from "../template/Wbw/PaliText";
+import { ITocPathNode } from "../corpus/TocPath";
+import { ArticleMode, ArticleType } from "./Article";
+
+/**
+ * 每种article type 对应的路由参数
+ * article/id?anthology=id&channel=id1,id2&mode=ArticleMode
+ * chapter/book-para?channel=id1,id2&mode=ArticleMode
+ * para/book?par=para1,para2&channel=id1,id2&mode=ArticleMode
+ * cs-para/book-para?channel=id1,id2&mode=ArticleMode
+ * sent/id?channel=id1,id2&mode=ArticleMode
+ * sim/id?channel=id1,id2&mode=ArticleMode
+ * textbook/articleId?course=id&mode=ArticleMode
+ * exercise/articleId?course=id&exercise=id&username=name&mode=ArticleMode
+ * exercise-list/articleId?course=id&exercise=id&mode=ArticleMode
+ * sent-original/id
+ */
+interface IWidget {
+  type?: ArticleType;
+  articleId?: string;
+  mode?: ArticleMode | null;
+  channelId?: string | null;
+  book?: string | null;
+  para?: string | null;
+  courseId?: string;
+  exerciseId?: string;
+  userName?: string;
+  active?: boolean;
+  onArticleChange?: Function;
+  onFinal?: Function;
+  onLoad?: Function;
+  onLoading?: Function;
+  onError?: Function;
+}
+const TypeCourseWidget = ({
+  type,
+  book,
+  para,
+  channelId,
+  articleId,
+  courseId,
+  exerciseId,
+  userName,
+  mode = "read",
+  active = false,
+  onArticleChange,
+  onFinal,
+  onLoad,
+  onLoading,
+  onError,
+}: IWidget) => {
+  const [articleData, setArticleData] = useState<IArticleDataResponse>();
+  const [articleHtml, setArticleHtml] = useState<string[]>(["<span />"]);
+  const [extra, setExtra] = useState(<></>);
+
+  const channels = channelId?.split("_");
+
+  useEffect(() => {
+    /**
+     * 由课本进入查询当前用户的权限和channel
+     */
+    if (
+      type === "textbook" ||
+      type === "exercise" ||
+      type === "exercise-list"
+    ) {
+      if (typeof articleId !== "undefined") {
+        const id = articleId.split("_");
+        get<ICourseCurrUserResponse>(`/v2/course-curr?course_id=${id[0]}`).then(
+          (response) => {
+            console.log("course user", response);
+            if (response.ok) {
+              const it: ICourseUser = {
+                channelId: response.data.channel_id,
+                role: response.data.role,
+              };
+              store.dispatch(signIn(it));
+              /**
+               * redux发布课程信息
+               */
+              const ic: ITextbook = {
+                courseId: id[0],
+                articleId: id[1],
+              };
+              store.dispatch(refresh(ic));
+            }
+          }
+        );
+      }
+    }
+  }, [articleId, type]);
+
+  const srcDataMode = mode === "edit" || mode === "wbw" ? "edit" : "read";
+  useEffect(() => {
+    console.log("srcDataMode", srcDataMode);
+    if (!active) {
+      return;
+    }
+
+    if (typeof type !== "undefined") {
+      let url = "";
+      switch (type) {
+        case "textbook":
+          if (typeof articleId !== "undefined") {
+            url = `/v2/article/${articleId}?view=textbook&course=${courseId}&mode=${srcDataMode}`;
+          }
+          break;
+        case "exercise":
+          if (typeof articleId !== "undefined") {
+            url = `/v2/article/${articleId}?mode=${srcDataMode}&course=${courseId}&exercise=${exerciseId}&user=${userName}`;
+            setExtra(
+              <ExerciseAnswer
+                courseId={courseId}
+                articleId={articleId}
+                exerciseId={exerciseId}
+              />
+            );
+          }
+          break;
+        case "exercise-list":
+          if (typeof articleId !== "undefined") {
+            url = `/v2/article/${articleId}?mode=${srcDataMode}&course=${courseId}&exercise=${exerciseId}`;
+
+            setExtra(
+              <ExerciseList
+                courseId={courseId}
+                articleId={articleId}
+                exerciseId={exerciseId}
+              />
+            );
+          }
+          break;
+      }
+
+      console.log("url", url);
+      if (typeof onLoading !== "undefined") {
+        onLoading(true);
+      }
+
+      console.log("url", url);
+
+      get<IArticleResponse>(url)
+        .then((json) => {
+          console.log("article", json);
+          if (json.ok) {
+            setArticleData(json.data);
+            if (json.data.html) {
+              setArticleHtml([json.data.html]);
+            } else if (json.data.content) {
+              setArticleHtml([json.data.content]);
+            }
+            setExtra(
+              <TocTree
+                treeData={json.data.toc?.map((item) => {
+                  const strTitle = item.title ? item.title : item.pali_title;
+                  const key = item.key
+                    ? item.key
+                    : `${item.book}-${item.paragraph}`;
+                  const progress = item.progress?.map((item, id) => (
+                    <Tag key={id}>{Math.round(item * 100) + "%"}</Tag>
+                  ));
+                  return {
+                    key: key,
+                    title: (
+                      <Space>
+                        <PaliText
+                          text={strTitle === "" ? "[unnamed]" : strTitle}
+                        />
+                        {progress}
+                      </Space>
+                    ),
+                    level: item.level,
+                  };
+                })}
+                onSelect={(keys: string[]) => {
+                  console.log(keys);
+                  if (
+                    typeof onArticleChange !== "undefined" &&
+                    keys.length > 0
+                  ) {
+                    onArticleChange(keys[0]);
+                  }
+                }}
+              />
+            );
+
+            if (typeof onLoad !== "undefined") {
+              onLoad(json.data);
+            }
+          } else {
+            if (typeof onError !== "undefined") {
+              onError(json.data, json.message);
+            }
+            message.error(json.message);
+          }
+        })
+        .finally(() => {
+          if (typeof onLoading !== "undefined") {
+            onLoading(false);
+          }
+        })
+        .catch((e) => {
+          console.error(e);
+        });
+    }
+  }, [
+    active,
+    type,
+    articleId,
+    srcDataMode,
+    channelId,
+    courseId,
+    exerciseId,
+    userName,
+  ]);
+
+  return (
+    <div>
+      <ArticleView
+        id={articleData?.uid}
+        title={
+          articleData?.title_text ? articleData?.title_text : articleData?.title
+        }
+        subTitle={articleData?.subtitle}
+        summary={articleData?.summary}
+        content={articleData ? articleData.content : ""}
+        html={articleHtml}
+        path={articleData?.path}
+        created_at={articleData?.created_at}
+        updated_at={articleData?.updated_at}
+        channels={channels}
+        type={type}
+        articleId={articleId}
+        onPathChange={(
+          node: ITocPathNode,
+          e: React.MouseEvent<HTMLSpanElement | HTMLAnchorElement, MouseEvent>
+        ) => {
+          let newType = type;
+          if (typeof onArticleChange !== "undefined") {
+            const newArticleId = node.key
+              ? node.key
+              : `${node.book}-${node.paragraph}`;
+            const target = e.ctrlKey || e.metaKey ? "_blank" : "self";
+            onArticleChange(newType, newArticleId, target);
+          }
+        }}
+      />
+      <Divider />
+      {extra}
+      <Divider />
+    </div>
+  );
+};
+
+export default TypeCourseWidget;

+ 269 - 0
dashboard/src/components/article/TypePali.tsx

@@ -0,0 +1,269 @@
+import { useEffect, useState } from "react";
+import { Divider, message, Result, Space, Tag } from "antd";
+
+import { get, post } from "../../request";
+import { IArticleDataResponse, IArticleResponse } from "../api/Article";
+import ArticleView from "./ArticleView";
+import TocTree from "./TocTree";
+import PaliText from "../template/Wbw/PaliText";
+import { IViewRequest, IViewStoreResponse } from "../api/view";
+import { ITocPathNode } from "../corpus/TocPath";
+import { ArticleMode, ArticleType } from "./Article";
+import "./article.css";
+import ArticleSkeleton from "./ArticleSkeleton";
+import ErrorResult from "../general/ErrorResult";
+
+interface IWidget {
+  type?: ArticleType;
+  articleId?: string;
+  mode?: ArticleMode | null;
+  channelId?: string | null;
+  book?: string | null;
+  para?: string | null;
+  active?: boolean;
+  onArticleChange?: Function;
+  onFinal?: Function;
+  onLoad?: Function;
+}
+const TypePaliWidget = ({
+  type,
+  book,
+  para,
+  channelId,
+  articleId,
+  mode = "read",
+  active = true,
+  onArticleChange,
+  onFinal,
+  onLoad,
+}: IWidget) => {
+  const [articleData, setArticleData] = useState<IArticleDataResponse>();
+  const [articleHtml, setArticleHtml] = useState<string[]>(["<span />"]);
+  const [extra, setExtra] = useState(<></>);
+  const [loading, setLoading] = useState(false);
+  const [errorCode, setErrorCode] = useState<number>();
+
+  const [remains, setRemains] = useState(false);
+
+  const channels = channelId?.split("_");
+
+  const srcDataMode = mode === "edit" || mode === "wbw" ? "edit" : "read";
+  useEffect(() => {
+    console.log("srcDataMode", srcDataMode);
+    if (!active) {
+      return;
+    }
+    if (typeof type === "undefined") {
+      return;
+    }
+
+    let url = "";
+    switch (type) {
+      case "chapter":
+        if (typeof articleId !== "undefined") {
+          url = `/v2/corpus-chapter/${articleId}?mode=${srcDataMode}`;
+          url += channelId ? `&channels=${channelId}` : "";
+        }
+        break;
+      case "para":
+        const _book = book ? book : articleId;
+        url = `/v2/corpus?view=para&book=${_book}&par=${para}&mode=${srcDataMode}`;
+        url += channelId ? `&channels=${channelId}` : "";
+        break;
+      default:
+        if (typeof articleId !== "undefined") {
+          url = `/v2/corpus/${type}/${articleId}/${srcDataMode}?mode=${srcDataMode}`;
+          url += channelId ? `&channel=${channelId}` : "";
+        }
+        break;
+    }
+
+    setLoading(true);
+    console.log("url", url);
+    get<IArticleResponse>(url)
+      .then((json) => {
+        console.log("article", json);
+        if (json.ok) {
+          setArticleData(json.data);
+          if (json.data.html) {
+            setArticleHtml([json.data.html]);
+          } else if (json.data.content) {
+            setArticleHtml([json.data.content]);
+          }
+          if (json.data.from) {
+            setRemains(true);
+          }
+          setExtra(
+            <TocTree
+              treeData={json.data.toc?.map((item) => {
+                const strTitle = item.title ? item.title : item.pali_title;
+                const key = item.key
+                  ? item.key
+                  : `${item.book}-${item.paragraph}`;
+                const progress = item.progress?.map((item, id) => (
+                  <Tag key={id}>{Math.round(item * 100) + "%"}</Tag>
+                ));
+                return {
+                  key: key,
+                  title: (
+                    <Space>
+                      <PaliText
+                        text={strTitle === "" ? "[unnamed]" : strTitle}
+                      />
+                      {progress}
+                    </Space>
+                  ),
+                  level: item.level,
+                };
+              })}
+              onSelect={(keys: string[]) => {
+                console.log(keys);
+                if (typeof onArticleChange !== "undefined" && keys.length > 0) {
+                  onArticleChange(keys[0]);
+                }
+              }}
+            />
+          );
+
+          switch (type) {
+            case "chapter":
+              if (typeof articleId === "string" && channelId) {
+                const [book, para] = articleId?.split("-");
+                post<IViewRequest, IViewStoreResponse>("/v2/view", {
+                  target_type: type,
+                  book: parseInt(book),
+                  para: parseInt(para),
+                  channel: channelId,
+                  mode: srcDataMode,
+                }).then((json) => {
+                  console.log("view", json.data);
+                });
+              }
+              break;
+            default:
+              break;
+          }
+
+          if (typeof onLoad !== "undefined") {
+            onLoad(json.data);
+          }
+        } else {
+          message.error(json.message);
+        }
+      })
+      .finally(() => {
+        setLoading(false);
+      })
+      .catch((e) => {
+        console.error(e);
+        setErrorCode(e);
+      });
+  }, [active, type, articleId, srcDataMode, book, para, channelId]);
+
+  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>
+      {loading ? (
+        <ArticleSkeleton />
+      ) : errorCode ? (
+        <ErrorResult code={errorCode} />
+      ) : (
+        <>
+          <ArticleView
+            id={articleData?.uid}
+            title={
+              articleData?.title_text
+                ? articleData?.title_text
+                : articleData?.title
+            }
+            subTitle={articleData?.subtitle}
+            summary={articleData?.summary}
+            content={articleData ? articleData.content : ""}
+            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);
+              }
+            }}
+            onPathChange={(
+              node: ITocPathNode,
+              e: React.MouseEvent<
+                HTMLSpanElement | HTMLAnchorElement,
+                MouseEvent
+              >
+            ) => {
+              let newType = type;
+              if (node.level === 0) {
+                switch (type) {
+                  case "article":
+                    newType = "anthology";
+                    break;
+                  case "chapter":
+                    newType = "series";
+                    break;
+                  default:
+                    break;
+                }
+              }
+
+              if (typeof onArticleChange !== "undefined") {
+                const newArticle = node.key
+                  ? node.key
+                  : `${node.book}-${node.paragraph}`;
+                const target = e.ctrlKey || e.metaKey ? "_blank" : "self";
+                onArticleChange(newType, newArticle, target);
+              }
+            }}
+          />
+          <Divider />
+          {extra}
+          <Divider />
+        </>
+      )}
+    </div>
+  );
+};
+
+export default TypePaliWidget;

+ 110 - 0
dashboard/src/components/article/TypeTerm.tsx

@@ -0,0 +1,110 @@
+import { useEffect, useState } from "react";
+
+import { get } from "../../request";
+import { IArticleDataResponse } from "../api/Article";
+import ArticleView from "./ArticleView";
+import { ITermResponse } from "../api/Term";
+import { ArticleMode } from "./Article";
+import "./article.css";
+import { message } from "antd";
+import ArticleSkeleton from "./ArticleSkeleton";
+import ErrorResult from "../general/ErrorResult";
+
+interface IWidget {
+  articleId?: string;
+  mode?: ArticleMode | null;
+  channelId?: string | null;
+  onArticleChange?: Function;
+  onFinal?: Function;
+  onLoad?: Function;
+}
+const TypeTermWidget = ({
+  channelId,
+  articleId,
+  mode = "read",
+  onArticleChange,
+}: IWidget) => {
+  const [articleData, setArticleData] = useState<IArticleDataResponse>();
+  const [articleHtml, setArticleHtml] = useState<string[]>(["<span />"]);
+  const [loading, setLoading] = useState(false);
+  const [errorCode, setErrorCode] = useState<number>();
+
+  const channels = channelId?.split("_");
+
+  useEffect(() => {
+    if (typeof articleId === "undefined") {
+      return;
+    }
+    const queryMode = mode === "edit" || mode === "wbw" ? "edit" : "read";
+    let url = "";
+    url = `/v2/terms/${articleId}?mode=${queryMode}`;
+    url += channelId ? `&channel=${channelId}` : "";
+
+    console.log("article url", url);
+    setLoading(true);
+    console.log("url", url);
+    get<ITermResponse>(url)
+      .then((json) => {
+        if (json.ok) {
+          setArticleData({
+            uid: json.data.guid,
+            title: json.data.meaning,
+            subtitle: json.data.word,
+            summary: json.data.note,
+            content: json.data.note ? json.data.note : "",
+            content_type: "markdown",
+            html: json.data.html,
+            path: [],
+            status: 30,
+            lang: json.data.language,
+            created_at: json.data.created_at,
+            updated_at: json.data.updated_at,
+          });
+          if (json.data.html) {
+            setArticleHtml([json.data.html]);
+          } else if (json.data.note) {
+            setArticleHtml([json.data.note]);
+          }
+        } else {
+          message.error(json.message);
+        }
+      })
+      .finally(() => {
+        setLoading(false);
+      })
+      .catch((e) => {
+        console.error(e);
+        setErrorCode(e);
+      });
+  }, [articleId, channelId, mode]);
+
+  return (
+    <div>
+      {loading ? (
+        <ArticleSkeleton />
+      ) : errorCode ? (
+        <ErrorResult code={errorCode} />
+      ) : (
+        <>
+          {" "}
+          <ArticleView
+            id={articleData?.uid}
+            title={articleData?.title}
+            subTitle={articleData?.subtitle}
+            summary={articleData?.summary}
+            content={articleData ? articleData.content : ""}
+            html={articleHtml}
+            path={articleData?.path}
+            created_at={articleData?.created_at}
+            updated_at={articleData?.updated_at}
+            channels={channels}
+            type={"term"}
+            articleId={articleId}
+          />
+        </>
+      )}
+    </div>
+  );
+};
+
+export default TypeTermWidget;

+ 36 - 5
dashboard/src/components/corpus/TocPath.tsx

@@ -1,8 +1,8 @@
 import { useNavigate, useSearchParams } from "react-router-dom";
-import { Breadcrumb, Popover, Tag, Typography } from "antd";
+import { Breadcrumb, MenuProps, Popover, Tag, Typography } from "antd";
 
 import PaliText from "../template/Wbw/PaliText";
-import React from "react";
+import React, { useEffect, useState } from "react";
 import { fullUrl } from "../../utils";
 
 export interface ITocPathNode {
@@ -12,6 +12,7 @@ export interface ITocPathNode {
   title: string;
   paliTitle?: string;
   level: number;
+  menu?: MenuProps["items"];
 }
 
 export declare type ELinkType = "none" | "blank" | "self";
@@ -23,6 +24,7 @@ interface IWidgetTocPath {
   channel?: string[];
   style?: React.CSSProperties;
   onChange?: Function;
+  onMenuClick?: Function;
 }
 const TocPathWidget = ({
   data = [],
@@ -31,16 +33,33 @@ const TocPathWidget = ({
   channel,
   style,
   onChange,
+  onMenuClick,
 }: IWidgetTocPath): JSX.Element => {
+  const [currData, setCurrData] = useState(data);
   const navigate = useNavigate();
-  const [searchParams, setSearchParams] = useSearchParams();
+  const [searchParams] = useSearchParams();
+
+  console.log("data", data);
+  useEffect(() => setCurrData(data), [data]);
   const fullPath = (
     <Breadcrumb
       style={{ whiteSpace: "nowrap", width: "100%", fontSize: style?.fontSize }}
     >
-      {data.map((item, id) => {
+      {currData.map((item, id) => {
         return (
           <Breadcrumb.Item
+            menu={
+              item.menu
+                ? {
+                    items: item.menu,
+                    onClick: (e) => {
+                      if (typeof onMenuClick !== "undefined") {
+                        onMenuClick(e.key);
+                      }
+                    },
+                  }
+                : undefined
+            }
             onClick={(
               e: React.MouseEvent<
                 HTMLSpanElement | HTMLAnchorElement,
@@ -73,7 +92,19 @@ const TocPathWidget = ({
           >
             <Typography.Link>
               {item.level < 99 ? (
-                <PaliText text={item.title} />
+                <span
+                  style={
+                    item.level === 0
+                      ? {
+                          padding: 4,
+                          backgroundColor: "#92880052",
+                          borderRadius: 4,
+                        }
+                      : undefined
+                  }
+                >
+                  <PaliText text={item.title} />
+                </span>
               ) : (
                 <Tag>{item.title}</Tag>
               )}

+ 39 - 3
dashboard/src/components/export/ShareButton.tsx

@@ -1,8 +1,18 @@
+import { useState } from "react";
 import { Button, Dropdown, Space, Typography } from "antd";
-import { ShareAltOutlined, ExportOutlined } from "@ant-design/icons";
+import {
+  ShareAltOutlined,
+  ExportOutlined,
+  ForkOutlined,
+  InboxOutlined,
+} from "@ant-design/icons";
+
 import ExportModal from "./ExportModal";
-import { useState } from "react";
 import { ArticleType } from "../article/Article";
+import AddToAnthology from "../article/AddToAnthology";
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
+import { fullUrl } from "../../utils";
 
 const { Text } = Typography;
 
@@ -23,6 +33,8 @@ const ShareButtonWidget = ({
   anthologyId,
 }: IWidget) => {
   const [exportOpen, setExportOpen] = useState(false);
+  const [addToAnthologyOpen, setAddToAnthologyOpen] = useState(false);
+  const user = useAppSelector(currentUser);
 
   return (
     <>
@@ -42,13 +54,30 @@ const ShareButtonWidget = ({
               key: "export",
               icon: <ExportOutlined />,
             },
+            {
+              label: "添加到文集",
+              key: "add_to_anthology",
+              icon: <InboxOutlined />,
+            },
+            {
+              label: "创建副本",
+              key: "fork",
+              icon: <ForkOutlined />,
+              disabled: user && type === "article" ? false : true,
+            },
           ],
           onClick: ({ key }) => {
             switch (key) {
               case "export":
                 setExportOpen(true);
                 break;
-
+              case "add_to_anthology":
+                setAddToAnthologyOpen(true);
+                break;
+              case "fork":
+                const url = `/studio/${user?.nickName}/article/create?parent=${articleId}`;
+                window.open(fullUrl(url), "_blank");
+                break;
               default:
                 break;
             }
@@ -67,6 +96,13 @@ const ShareButtonWidget = ({
         open={exportOpen}
         onClose={() => setExportOpen(false)}
       />
+      {articleId ? (
+        <AddToAnthology
+          open={addToAnthologyOpen}
+          onClose={(isOpen: boolean) => setAddToAnthologyOpen(isOpen)}
+          articleIds={[articleId]}
+        />
+      ) : undefined}
     </>
   );
 };

+ 5 - 0
dashboard/src/components/fts/FullTextSearchResult.tsx

@@ -69,6 +69,11 @@ const FullTxtSearchResultWidget = ({
   const [loading, setLoading] = useState(false);
   const [currPage, setCurrPage] = useState<number>(1);
 
+  useEffect(
+    () => setCurrPage(1),
+    [view, keyWord, tags, bookId, match, pageType]
+  );
+
   useEffect(() => {
     let url = `/v2/search?view=${view}&key=${keyWord}`;
     if (typeof tags !== "undefined") {

+ 43 - 0
dashboard/src/components/general/ErrorResult.tsx

@@ -0,0 +1,43 @@
+import { Result } from "antd";
+import { ResultStatusType } from "antd/lib/result";
+
+interface IWidget {
+  code: number;
+  message?: string;
+}
+
+const ErrorResultWidget = ({ code, message }: IWidget) => {
+  let strStatus: ResultStatusType;
+  let strTitle: string = "";
+  switch (code) {
+    case 401:
+      strStatus = 403;
+      strTitle = "未登录";
+      break;
+    case 403:
+      strStatus = 403;
+      strTitle = "没有权限";
+      break;
+    case 404:
+      strStatus = 404;
+      strTitle = "没有找到指定的资源";
+      break;
+    case 500:
+      strStatus = 500;
+      strTitle = "服务器内部错误";
+      break;
+    default:
+      strStatus = "error";
+      strTitle = "无法识别的错误代码" + code;
+      break;
+  }
+  return (
+    <Result
+      status={strStatus}
+      title={strTitle}
+      subTitle="Sorry, something went wrong."
+    />
+  );
+};
+
+export default ErrorResultWidget;

+ 2 - 0
dashboard/src/components/general/TermTextArea.tsx

@@ -34,6 +34,8 @@ const TermTextAreaWidget = ({
   const refTextArea = useRef<HTMLTextAreaElement>(null);
   const refShadow = useRef<HTMLDivElement>(null);
 
+  console.log("render");
+
   function term_at_menu_hide() {
     setMenuDisplay("none");
     setTermSearch("");

+ 21 - 15
dashboard/src/components/general/TermTextAreaMenu.tsx

@@ -27,7 +27,7 @@ interface IWidget {
   onSelect?: Function;
 }
 const TermTextAreaMenuWidget = ({
-  items = [],
+  items,
   searchKey = "",
   maxItem = 10,
   visible = false,
@@ -39,24 +39,29 @@ const TermTextAreaMenuWidget = ({
   const [wordList, setWordList] = useState<IWordWithEn[]>();
   const sysTerms = useAppSelector(getTerm);
   console.log("items", items);
+
   useEffect(() => {
+    let parents: string[] = [];
+    let mWords: IWordWithEn[] = [];
     //本句单词
-    const mWords = items?.map((item) => {
-      return {
-        word: item,
-        en: PaliToEn(item),
-      };
-    });
+    if (items) {
+      mWords = items?.map((item) => {
+        return {
+          word: item,
+          en: PaliToEn(item),
+        };
+      });
 
-    //计算这些单词的base
-    let parents: string[] = [];
-    items?.forEach((value) => {
-      getPaliBase(value).forEach((base) => {
-        if (!parents.includes(base) && !items.includes(base)) {
-          parents.push(base);
-        }
+      //计算这些单词的base
+
+      items?.forEach((value) => {
+        getPaliBase(value).forEach((base) => {
+          if (!parents.includes(base) && !items.includes(base)) {
+            parents.push(base);
+          }
+        });
       });
-    });
+    }
 
     const term = sysTerms ? sysTerms?.map((item) => item.word) : [];
     //本句单词parent
@@ -81,6 +86,7 @@ const TermTextAreaMenuWidget = ({
           isTerm: true,
         };
       });
+
     setWordList([...parentTerm, ...mWords, ...sysTerm]);
 
     //此处千万不能加其他dependency 否则会引起无限循环

+ 1 - 1
dashboard/src/components/template/MdView.tsx

@@ -3,7 +3,7 @@ import { TCodeConvertor, XmlToReact } from "./utilities";
 const { Paragraph, Text } = Typography;
 
 interface IWidget {
-  html?: string;
+  html?: string | null;
   className?: string;
   placeholder?: string;
   wordWidget?: boolean;

+ 2 - 1
dashboard/src/components/template/SentEdit.tsx

@@ -113,9 +113,10 @@ export const SentEditInner = ({
       setLoadedRes(res);
     }
   }, [translation]);
+
   useEffect(() => {
     const content = origin?.find(
-      (value) => value.channel.type === "wbw"
+      (value) => value.contentType === "json"
     )?.content;
     if (content) {
       setWbwData(JSON.parse(content));

+ 1 - 1
dashboard/src/components/template/SentEdit/SentCell.tsx

@@ -301,7 +301,7 @@ const SentCellWidget = ({
                   marginBottom: 0,
                 }}
                 placeholder="请输入"
-                html={sentData.html}
+                html={sentData.html ? sentData.html : sentData.content}
                 wordWidget={wordWidget}
               />
             )}

+ 1 - 1
dashboard/src/components/template/SentEdit/SentContent.tsx

@@ -121,7 +121,7 @@ const SentContentWidget = ({
       />
       <div style={{ flex: layoutFlex.left, color: "#9f3a01" }}>
         {origin?.map((item, id) => {
-          if (item.channel.type === "wbw") {
+          if (item.contentType === "json") {
             return (
               <WbwSentCtl
                 key={id}

+ 14 - 23
dashboard/src/pages/library/article/show.tsx

@@ -36,7 +36,7 @@ import { paraParam } from "../../../reducers/para-change";
 import { get } from "../../../request";
 import store from "../../../store";
 import { IRecent } from "../../../components/recent/RecentList";
-import { convertToPlain, fullUrl } from "../../../utils";
+import { fullUrl } from "../../../utils";
 import ThemeSelect from "../../../components/general/ThemeSelect";
 import {
   IShowDiscussion,
@@ -172,20 +172,6 @@ const Widget = () => {
                 >
                   Edit
                 </Button>
-                <Button
-                  disabled={user ? false : true}
-                  ghost
-                  onClick={(event) => {
-                    const url = `/studio/${user?.nickName}/article/create?parent=${loadedArticleData.uid}`;
-                    if (event.ctrlKey || event.metaKey) {
-                      window.open(fullUrl(url), "_blank");
-                    } else {
-                      navigate(url);
-                    }
-                  }}
-                >
-                  Fork
-                </Button>
               </>
             ) : undefined}
             <ShareButton
@@ -300,6 +286,7 @@ const Widget = () => {
                 <ToolButtonToc
                   type={type as ArticleType}
                   articleId={id}
+                  channels={searchParams.get("channel")?.split("_")}
                   anthologyId={searchParams.get("anthology")}
                   onSelect={(key: Key) => {
                     console.log("toc click", key);
@@ -347,13 +334,14 @@ const Widget = () => {
               articleId={id}
               anthologyId={searchParams.get("anthology")}
               mode={searchParams.get("mode") as ArticleMode}
-              onArticleChange={(article: string, target?: string) => {
-                console.log("article change", article, target);
-                let mType = type;
-                if (article.split("-").length === 2) {
-                  mType = "chapter";
-                }
-                let url = `/article/${mType}/${article}?mode=${currMode}`;
+              onArticleChange={(
+                newType: ArticleType,
+                article: string,
+                target?: string
+              ) => {
+                console.log("article change", newType, article, target);
+
+                let url = `/article/${newType}/${article}?mode=${currMode}`;
                 searchParams.forEach((value, key) => {
                   console.log(value, key);
                   if (key !== "mode") {
@@ -368,7 +356,10 @@ const Widget = () => {
               }}
               onLoad={(article: IArticleDataResponse) => {
                 setLoadedArticleData(article);
-                document.title = convertToPlain(article.title).slice(0, 128);
+                const windowTitle = article.title_text
+                  ? article.title_text
+                  : article.title;
+                document.title = windowTitle.slice(0, 128);
                 const paramTopic = searchParams.get("topic");
                 const paramComment = searchParams.get("comment");
                 const paramType = searchParams.get("dis_type");

+ 1 - 0
dashboard/src/pages/studio/anthology/edit.tsx

@@ -50,6 +50,7 @@ const Widget = () => {
               label: intl.formatMessage({ id: "buttons.basic.information" }),
               children: (
                 <AnthologyInfoEdit
+                  studioName={studioname}
                   anthologyId={anthology_id}
                   onLoad={(value: IAnthologyDataResponse) => {
                     setTitle(value.title);

+ 3 - 0
dashboard/src/request.ts

@@ -54,6 +54,9 @@ export const options = (method: string): RequestInit => {
 
 export const get = async <R>(path: string): Promise<R> => {
   const response = await fetch(backend(path), options("GET"));
+  if (!response.ok) {
+    throw response.status;
+  }
   const res: R = await response.json();
   return res;
 };

+ 16 - 4
rpc/tulip/tulip/content_download.php

@@ -5,7 +5,12 @@ require dirname(__FILE__) . '/logger.php';
 require dirname(__FILE__) . '/pdo.php';
 
 logger('debug','download full test search content start');
+$param = getopt('b:');
 
+if(isset($param['b'])){
+    $bookId = (int)$param['b'];
+    logger('debug','update book='.$bookId);
+}
 $PDO = new PdoHelper;
 
 $PDO->connectDb();
@@ -14,16 +19,23 @@ logger('debug','connect database finished');
 $client = new GuzzleHttp\Client();
 
 $pageSize = 1000;
+
     $urlBase = Config['api_server'] . '/v2/pali-search-data';
     logger('debug','url='.$urlBase);
-
-    for ($book=1; $book < 218; $book++) { 
+    if(isset($bookId)){
+        $from = $bookId;
+        $to = $bookId;
+    }else{
+        $from = 1;
+        $to = 217; 
+    }
+    for ($book=$from; $book <= $to; $book++) { 
         $currPage = 1;
-        $url = $urlBase . "?book={$book}";
+        $urlBook = $urlBase . "?book={$book}";
         logger('debug','fetch book='.$book);
         do {
             $goNext = false;
-            $url = $url . "&start={$currPage}&page_size={$pageSize}";
+            $url = $urlBook . "&start={$currPage}&page_size={$pageSize}";
             logger('debug','url='.$url);
             $res = $client->request('GET', $url);
             $status = $res->getStatusCode();