visuddhinanda 1 miesiąc temu
rodzic
commit
4180676e72
39 zmienionych plików z 3569 dodań i 30 usunięć
  1. 10 6
      dashboard-v6/backup/components/article/ArticleDrawer.tsx
  2. 43 0
      dashboard-v6/documents/development/v6-todo-list.md
  3. 20 1
      dashboard-v6/src/Router.tsx
  4. 83 0
      dashboard-v6/src/components/anthology/AnthologyCreate.tsx
  5. 399 0
      dashboard-v6/src/components/anthology/AnthologyList.tsx
  6. 67 0
      dashboard-v6/src/components/anthology/AnthologyModal.tsx
  7. 52 0
      dashboard-v6/src/components/anthology/AnthologySelect.tsx
  8. 87 0
      dashboard-v6/src/components/anthology/AnthologyTocTree.tsx
  9. 216 0
      dashboard-v6/src/components/anthology/EditableTocTree.tsx
  10. 42 0
      dashboard-v6/src/components/anthology/TextBookToc.tsx
  11. 226 0
      dashboard-v6/src/components/article/Article.tsx
  12. 130 0
      dashboard-v6/src/components/article/ArticleDrawer.tsx
  13. 71 0
      dashboard-v6/src/components/article/TypeAnthology.tsx
  14. 101 0
      dashboard-v6/src/components/article/TypeArticle.tsx
  15. 335 0
      dashboard-v6/src/components/article/TypeArticleReader.tsx
  16. 211 0
      dashboard-v6/src/components/article/TypeArticleReaderToolbar.tsx
  17. 172 0
      dashboard-v6/src/components/article/TypeCSPara.tsx
  18. 235 0
      dashboard-v6/src/components/article/TypeCourse.tsx
  19. 198 0
      dashboard-v6/src/components/article/TypePage.tsx
  20. 4 1
      dashboard-v6/src/components/article/TypePali.tsx
  21. 38 0
      dashboard-v6/src/components/article/TypeSeries.tsx
  22. 29 0
      dashboard-v6/src/components/article/TypeTask.tsx
  23. 2 2
      dashboard-v6/src/components/article/components/EditableTree.tsx
  24. 1 1
      dashboard-v6/src/components/article/components/TocTree.tsx
  25. 213 0
      dashboard-v6/src/components/general/SplitLayout/README.md
  26. 82 0
      dashboard-v6/src/components/general/SplitLayout/SplitLayout.module.css
  27. 123 0
      dashboard-v6/src/components/general/SplitLayout/SplitLayout.tsx
  28. 20 0
      dashboard-v6/src/components/general/SplitLayout/SplitLayoutContext.ts
  29. 277 0
      dashboard-v6/src/components/general/SplitLayout/SplitLayoutTest.tsx
  30. 4 0
      dashboard-v6/src/components/general/SplitLayout/index.ts
  31. 7 4
      dashboard-v6/src/components/group/AddMember.tsx
  32. 2 2
      dashboard-v6/src/components/group/GroupMember.tsx
  33. 7 10
      dashboard-v6/src/components/navigation/MainMenu.tsx
  34. 3 1
      dashboard-v6/src/components/tipitaka/ChapterHead.tsx
  35. 26 0
      dashboard-v6/src/features/tipitaka/ChapterPage.tsx
  36. 1 1
      dashboard-v6/src/load.ts
  37. 13 0
      dashboard-v6/src/pages/workspace/anthology/anthology.tsx
  38. 11 0
      dashboard-v6/src/pages/workspace/editor/chapter.tsx
  39. 8 1
      dashboard-v6/src/routes/testRoutes.tsx

+ 10 - 6
dashboard-v6/backup/components/article/ArticleDrawer.tsx

@@ -2,7 +2,7 @@ import { Button, Drawer, Space, Typography } from "antd";
 import React, { useEffect, useState } from "react";
 import { Link } from "react-router";
 
-import Article, { type ArticleMode, ArticleType } from "./Article";
+import Article from "./Article";
 import type { IArticleDataResponse } from "../../api/Article";
 const { Text } = Typography;
 
@@ -17,9 +17,9 @@ interface IWidget {
   anthologyId?: string;
   mode?: ArticleMode;
   open?: boolean;
-  onClose?: Function;
-  onTitleChange?: Function;
-  onArticleEdit?: Function;
+  onClose?: () => void;
+  onTitleChange?: (value: string) => void;
+  onArticleEdit?: (value: IArticleDataResponse) => void;
 }
 
 const ArticleDrawerWidget = ({
@@ -39,8 +39,12 @@ const ArticleDrawerWidget = ({
 }: IWidget) => {
   const [openDrawer, setOpenDrawer] = useState(open);
   const [drawerTitle, setDrawerTitle] = useState(title);
-  useEffect(() => setOpenDrawer(open), [open]);
-  useEffect(() => setDrawerTitle(title), [title]);
+  useEffect(() => {
+    setOpenDrawer(open);
+  }, [open]);
+  useEffect(() => {
+    setDrawerTitle(title);
+  }, [title]);
   const showDrawer = () => {
     setOpenDrawer(true);
   };

+ 43 - 0
dashboard-v6/documents/development/v6-todo-list.md

@@ -0,0 +1,43 @@
+# 📋 项目迁移 Todo List
+
+## 🧭 二、路由结构迁移
+
+### 2️⃣ Basic 模块
+
+- [x] `/palicanon`=>`workgroup/tipitaka`
+- [ ] `/recent/list`=>`workgroup/recent`
+- [x] `/channel/list`=>`workgroup/channel`
+- [ ] `/exp/list`
+- [ ] `/setting`
+- [ ] `/ai/models/list`=>`resources/ai-models`
+
+---
+
+### 3️⃣ Advance 模块
+
+#### Task 子模块
+
+- [ ] `/task/hall`=>`workgroup/task`
+- [ ] `/task/list`=>`workgroup/task`
+- [ ] `/task/projects`=>`workgroup/task`
+- [ ] `/task/workflows`=>`workgroup/task`
+
+#### 内容模块
+
+- [ ] `/course/list`=>`workgroup/course`
+- [ ] `/dict/list`=>`resources/dict`
+- [x] `/term/list`=>`workgroup/term`
+- [ ] `/article/list`=>`workgroup/article`
+- [ ] `/anthology/list`=>`workgroup/anthology`
+- [ ] `/attachment/list`=>`resources/attachment`
+- [ ] `/tags/list`=>`resources/tags`
+
+---
+
+### 4️⃣ Collaboration 模块
+
+- [ ] `/group/list`=>`collaboration/team-space`
+- [ ] `/invite/list`=>`collaboration/invite`
+- [ ] `/transfer/list`=>`collaboration/transfer`
+
+---

+ 20 - 1
dashboard-v6/src/Router.tsx

@@ -33,6 +33,13 @@ const WorkspaceChat = lazy(() => import("./pages/workspace/chat"));
 const WorkspaceTerm = lazy(() => import("./pages/workspace/term/list"));
 const WorkspaceTermShow = lazy(() => import("./pages/workspace/term/show"));
 const WorkspaceTermEdit = lazy(() => import("./pages/workspace/term/edit"));
+const WorkspaceEditChapter = lazy(
+  () => import("./pages/workspace/editor/chapter")
+);
+
+const WorkspaceAnthologyList = lazy(
+  () => import("./pages/workspace/anthology/anthology")
+);
 
 // ↓ 新增:TestLayout
 const TestLayout = lazy(() => import("./layouts/test"));
@@ -86,6 +93,11 @@ const router = createBrowserRouter(
               Component: WorkspaceChat,
               handle: { id: "workspace.ai", crumb: "ai" },
             },
+            {
+              path: "anthology",
+              Component: WorkspaceAnthologyList,
+              handle: { id: "workspace.anthology", crumb: "anthology" },
+            },
             {
               path: "tipitaka",
               Component: WorkspaceTipitaka,
@@ -171,7 +183,14 @@ const router = createBrowserRouter(
                 },
                 {
                   path: "chapter",
-                  children: [{ path: ":id" }],
+                  children: [
+                    {
+                      path: ":id",
+                      children: [
+                        { index: true, Component: WorkspaceEditChapter },
+                      ],
+                    },
+                  ],
                 },
                 {
                   path: "para",

+ 83 - 0
dashboard-v6/src/components/anthology/AnthologyCreate.tsx

@@ -0,0 +1,83 @@
+import {
+  ProForm,
+  type ProFormInstance,
+  ProFormText,
+} from "@ant-design/pro-components";
+import { useIntl } from "react-intl";
+import { message } from "antd";
+
+import LangSelect from "../general/LangSelect";
+import type {
+  IAnthologyCreateRequest,
+  IAnthologyResponse,
+} from "../../api/Article";
+import { post } from "../../request";
+import { useRef } from "react";
+
+interface IFormData {
+  title: string;
+  lang: string;
+  studio: string;
+}
+
+interface IWidget {
+  studio?: string;
+  onSuccess?: () => void;
+}
+const AnthologyCreateWidget = ({ studio, onSuccess }: IWidget) => {
+  const intl = useIntl();
+  const formRef = useRef<ProFormInstance | undefined>(undefined);
+
+  return (
+    <ProForm<IFormData>
+      formRef={formRef}
+      onFinish={async (values: IFormData) => {
+        if (typeof studio === "undefined") {
+          return;
+        }
+        values.studio = studio;
+        const url = `/api/v2/anthology`;
+        console.info("api request", url, values);
+        const res = await post<IAnthologyCreateRequest, IAnthologyResponse>(
+          url,
+          values
+        );
+        console.debug("api response", res);
+        if (res.ok) {
+          message.success(intl.formatMessage({ id: "flashes.success" }));
+          if (typeof onSuccess !== "undefined") {
+            onSuccess();
+            formRef.current?.resetFields(["title"]);
+          }
+        } else {
+          message.error(res.message);
+        }
+      }}
+    >
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="title"
+          required
+          label={intl.formatMessage({
+            id: "forms.fields.title.label",
+          })}
+          rules={[
+            {
+              required: true,
+              message: intl.formatMessage({
+                id: "forms.message.title.required",
+              }),
+              max: 255,
+            },
+          ]}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <LangSelect />
+      </ProForm.Group>
+    </ProForm>
+  );
+};
+
+export default AnthologyCreateWidget;

+ 399 - 0
dashboard-v6/src/components/anthology/AnthologyList.tsx

@@ -0,0 +1,399 @@
+import { type ActionType, ProTable } from "@ant-design/pro-components";
+import { useIntl } from "react-intl";
+import { Link } from "react-router";
+import { message, Modal, Typography } from "antd";
+import { PlusOutlined } from "@ant-design/icons";
+import { Button, Dropdown, Popover } from "antd";
+import {
+  ExclamationCircleOutlined,
+  TeamOutlined,
+  DeleteOutlined,
+  EyeOutlined,
+} from "@ant-design/icons";
+
+import AnthologyCreate from "./AnthologyCreate";
+import type {
+  IAnthologyListResponse,
+  IDeleteResponse,
+} from "../../api/Article";
+import { delete_, get } from "../../request";
+import { PublicityValueEnum } from "../studio/table";
+import { useEffect, useRef, useState } from "react";
+
+import { type IResNumberResponse } from "../channel/ChannelTable";
+import { fullUrl, getSorterUrl } from "../../utils";
+import type { IStudio } from "../../api/Auth";
+import { EResType } from "../share/utils";
+import Studio from "../auth/Studio";
+import StatusBadge from "../general/StatusBadge";
+import Share from "../share/Share";
+
+const { Text } = Typography;
+
+interface IItem {
+  sn: number;
+  id: string;
+  title: string;
+  subtitle: string;
+  publicity: number;
+  articles: number;
+  studio?: IStudio;
+  updated_at: string;
+}
+interface IWidget {
+  title?: string;
+  studioName?: string;
+  showCol?: string[];
+  showCreate?: boolean;
+  showOption?: boolean;
+  onTitleClick?: (id: string) => void;
+}
+const AnthologyListWidget = ({
+  title,
+  studioName,
+  showCreate = true,
+  showOption = true,
+  onTitleClick,
+}: IWidget) => {
+  const intl = useIntl();
+  const [openCreate, setOpenCreate] = useState(false);
+
+  const [activeKey, setActiveKey] = useState<React.Key | undefined>("my");
+  const [myNumber, setMyNumber] = useState<number>(0);
+  const [collaborationNumber, setCollaborationNumber] = useState<number>(0);
+
+  useEffect(() => {
+    /**
+     * 获取各种课程的数量
+     */
+    const url = `/api/v2/anthology-my-number?studio=${studioName}`;
+    console.log("url", url);
+    get<IResNumberResponse>(url).then((json) => {
+      if (json.ok) {
+        setMyNumber(json.data.my);
+        setCollaborationNumber(json.data.collaboration);
+      }
+    });
+  }, [studioName]);
+
+  const showDeleteConfirm = (id: string, title: string) => {
+    Modal.confirm({
+      icon: <ExclamationCircleOutlined />,
+      title:
+        intl.formatMessage({
+          id: "message.delete.confirm",
+        }) +
+        intl.formatMessage({
+          id: "message.irrevocable",
+        }),
+
+      content: title,
+      okText: intl.formatMessage({
+        id: "buttons.delete",
+      }),
+      okType: "danger",
+      cancelText: intl.formatMessage({
+        id: "buttons.no",
+      }),
+      onOk() {
+        console.log("delete", id);
+        return delete_<IDeleteResponse>(`/api/v2/anthology/${id}`)
+          .then((json) => {
+            if (json.ok) {
+              message.success("删除成功");
+              ref.current?.reload();
+            } else {
+              message.error(json.message);
+            }
+          })
+          .catch((e) => console.log("Oops errors!", e));
+      },
+    });
+  };
+
+  const [isModalOpen, setIsModalOpen] = useState(false);
+  const [shareResId, setShareResId] = useState<string>("");
+  const [shareResType, setShareResType] = useState<EResType>(
+    EResType.collection
+  );
+  const showShareModal = (resId: string, resType: EResType) => {
+    setShareResId(resId);
+    setShareResType(resType);
+    setIsModalOpen(true);
+  };
+
+  const handleOk = () => {
+    setIsModalOpen(false);
+  };
+
+  const handleCancel = () => {
+    setIsModalOpen(false);
+  };
+
+  const ref = useRef<ActionType | null>(null);
+  return (
+    <>
+      <ProTable<IItem>
+        headerTitle={title}
+        actionRef={ref}
+        columns={[
+          {
+            title: intl.formatMessage({
+              id: "dict.fields.sn.label",
+            }),
+            dataIndex: "sn",
+            key: "sn",
+            width: 50,
+            search: false,
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.title.label",
+            }),
+            dataIndex: "title",
+            key: "title",
+            tooltip: "过长会自动收缩",
+            ellipsis: true,
+            render: (_text, row, index) => {
+              return (
+                <div key={index}>
+                  <div>
+                    <Typography.Link
+                      onClick={() => {
+                        if (typeof onTitleClick !== "undefined") {
+                          onTitleClick(row.id);
+                        }
+                      }}
+                    >
+                      {row.title}
+                    </Typography.Link>
+                  </div>
+                  <Text type="secondary">{row.subtitle}</Text>
+                </div>
+              );
+            },
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.owner.label",
+            }),
+            dataIndex: "studio",
+            key: "studio",
+            render: (_text, row) => {
+              return <Studio data={row.studio} />;
+            },
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.publicity.label",
+            }),
+            dataIndex: "publicity",
+            key: "publicity",
+            width: 100,
+            search: false,
+            filters: true,
+            onFilter: true,
+            valueEnum: PublicityValueEnum(),
+          },
+          {
+            title: intl.formatMessage({
+              id: "article.fields.article.count.label",
+            }),
+            dataIndex: "articles",
+            key: "articles",
+            width: 100,
+            search: false,
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.updated-at.label",
+            }),
+            key: "updated_at",
+            width: 100,
+            search: false,
+            dataIndex: "updated_at",
+            valueType: "date",
+            sorter: true,
+          },
+          {
+            title: intl.formatMessage({ id: "buttons.option" }),
+            key: "option",
+            width: 120,
+            hideInTable: !showOption,
+            valueType: "option",
+            render: (_text, row, index) => [
+              <Dropdown.Button
+                key={index}
+                type="link"
+                trigger={["click", "contextMenu"]}
+                menu={{
+                  items: [
+                    {
+                      key: "open",
+                      label: (
+                        <Link to={`/anthology/${row.id}`}>
+                          {intl.formatMessage({
+                            id: "buttons.open.in.library",
+                          })}
+                        </Link>
+                      ),
+                      icon: <EyeOutlined />,
+                    },
+                    {
+                      key: "share",
+                      label: intl.formatMessage({
+                        id: "buttons.share",
+                      }),
+                      icon: <TeamOutlined />,
+                    },
+                    {
+                      key: "remove",
+                      label: intl.formatMessage({
+                        id: "buttons.delete",
+                      }),
+                      icon: <DeleteOutlined />,
+                      danger: true,
+                    },
+                  ],
+                  onClick: (e) => {
+                    switch (e.key) {
+                      case "open":
+                        window.open(fullUrl(`/anthology/${row.id}`), "_blank");
+                        break;
+                      case "share":
+                        console.log("share");
+                        showShareModal(row.id, EResType.collection);
+                        break;
+                      case "remove":
+                        showDeleteConfirm(row.id, row.title);
+                        break;
+                    }
+                  },
+                }}
+              >
+                <Link to={`/anthology/${row.id}`} target="_blank">
+                  {intl.formatMessage({
+                    id: "buttons.view",
+                  })}
+                </Link>
+              </Dropdown.Button>,
+            ],
+          },
+        ]}
+        request={async (params = {}, sorter, filter) => {
+          console.log(params, sorter, filter);
+          let url = `/api/v2/anthology?view=studio&view2=${activeKey}&name=${studioName}`;
+          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 += getSorterUrl(sorter);
+
+          const res = await get<IAnthologyListResponse>(url);
+          const items: IItem[] = res.data.rows.map((item, id) => {
+            return {
+              sn: id + offset + 1,
+              id: item.uid,
+              title: item.title,
+              subtitle: item.subtitle,
+              publicity: item.status,
+              articles: item.childrenNumber,
+              studio: item.studio,
+              updated_at: item.updated_at,
+            };
+          });
+          console.log(items);
+          return {
+            total: res.data.count,
+            succcess: true,
+            data: items,
+          };
+        }}
+        rowKey="id"
+        bordered
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+          pageSize: 10,
+        }}
+        search={false}
+        options={{
+          search: true,
+        }}
+        toolBarRender={() => [
+          showCreate ? (
+            <Popover
+              content={
+                <AnthologyCreate
+                  studio={studioName}
+                  onSuccess={() => {
+                    setOpenCreate(false);
+                    ref.current?.reload();
+                  }}
+                />
+              }
+              placement="bottomRight"
+              trigger="click"
+              open={openCreate}
+              onOpenChange={(open: boolean) => {
+                setOpenCreate(open);
+              }}
+            >
+              <Button key="button" icon={<PlusOutlined />} type="primary">
+                {intl.formatMessage({ id: "buttons.create" })}
+              </Button>
+            </Popover>
+          ) : undefined,
+        ]}
+        toolbar={{
+          menu: {
+            activeKey,
+            items: [
+              {
+                key: "my",
+                label: (
+                  <span>
+                    {intl.formatMessage({ id: "labels.this-studio" })}
+                    <StatusBadge count={myNumber} active={activeKey === "my"} />
+                  </span>
+                ),
+              },
+              {
+                key: "collaboration",
+                label: (
+                  <span>
+                    {intl.formatMessage({ id: "labels.collaboration" })}
+                    <StatusBadge
+                      count={collaborationNumber}
+                      active={activeKey === "collaboration"}
+                    />
+                  </span>
+                ),
+              },
+            ],
+            onChange(key) {
+              console.log("show course", key);
+              setActiveKey(key);
+              ref.current?.reload();
+            },
+          },
+        }}
+      />
+
+      <Modal
+        destroyOnHidden={true}
+        width={700}
+        title={intl.formatMessage({ id: "labels.collaboration" })}
+        open={isModalOpen}
+        onOk={handleOk}
+        onCancel={handleCancel}
+      >
+        <Share resId={shareResId} resType={shareResType} />
+      </Modal>
+    </>
+  );
+};
+
+export default AnthologyListWidget;

+ 67 - 0
dashboard-v6/src/components/anthology/AnthologyModal.tsx

@@ -0,0 +1,67 @@
+import { useState } from "react";
+import { Modal } from "antd";
+import AnthologyList from "./AnthologyList";
+
+interface IWidget {
+  studioName?: string;
+  trigger?: React.ReactNode;
+  open?: boolean;
+  onClose?: (closed: boolean) => void;
+  onSelect?: (selected: string) => void;
+  onCancel?: () => void;
+}
+const AnthologyModalWidget = ({
+  studioName,
+  trigger,
+  open,
+  onClose,
+  onSelect,
+}: IWidget) => {
+  const [innerOpen, setInnerOpen] = useState(false);
+
+  const isModalOpen = open ?? innerOpen;
+
+  const openModal = () => {
+    if (open === undefined) {
+      setInnerOpen(true);
+    } else {
+      onClose?.(true);
+    }
+  };
+
+  const closeModal = () => {
+    onClose?.(false);
+    if (open === undefined) {
+      setInnerOpen(false);
+    }
+  };
+
+  return (
+    <>
+      <span role="button" tabIndex={0} onClick={openModal}>
+        {trigger}
+      </span>
+
+      <Modal
+        width="80%"
+        title="加入文集"
+        open={isModalOpen}
+        onOk={closeModal}
+        onCancel={closeModal}
+      >
+        <AnthologyList
+          title="选择文集"
+          studioName={studioName}
+          showCreate={false}
+          showOption={false}
+          onTitleClick={(id) => {
+            onSelect?.(id);
+            closeModal();
+          }}
+        />
+      </Modal>
+    </>
+  );
+};
+
+export default AnthologyModalWidget;

+ 52 - 0
dashboard-v6/src/components/anthology/AnthologySelect.tsx

@@ -0,0 +1,52 @@
+import { Select } from "antd";
+import { useEffect, useState } from "react";
+import { get } from "../../request";
+import type { IAnthologyListResponse } from "../../api/Article";
+
+interface IOptions {
+  value: string;
+  label: string;
+}
+interface IWidget {
+  studioName?: string;
+  onSelect?: (value: string) => void;
+}
+const AnthologyTocTreeWidget = ({ studioName, onSelect }: IWidget) => {
+  const [anthology, setAnthology] = useState<IOptions[]>([
+    { value: "all", label: "全部" },
+    { value: "none", label: "没有加入文集的" },
+  ]);
+  useEffect(() => {
+    const url = `/api/v2/anthology?view=studio&name=${studioName}`;
+    get<IAnthologyListResponse>(url).then((json) => {
+      if (json.ok) {
+        const data = json.data.rows.map((item) => {
+          return {
+            value: item.uid,
+            label: item.title,
+          };
+        });
+        setAnthology([
+          { value: "all", label: "全部" },
+          { value: "none", label: "没有加入文集的" },
+          ...data,
+        ]);
+      }
+    });
+  }, [studioName]);
+  return (
+    <Select
+      defaultValue="all"
+      style={{ width: 180 }}
+      onChange={(value: string) => {
+        console.log(`selected ${value}`);
+        if (typeof onSelect !== "undefined") {
+          onSelect(value);
+        }
+      }}
+      options={anthology}
+    />
+  );
+};
+
+export default AnthologyTocTreeWidget;

+ 87 - 0
dashboard-v6/src/components/anthology/AnthologyTocTree.tsx

@@ -0,0 +1,87 @@
+import { useEffect, useState } from "react";
+
+import { get } from "../../request";
+import type { IArticleMapListResponse } from "../../api/Article";
+import type { ListNodeData } from "../article/components/EditableTree";
+import TocTree from "../article/components/TocTree";
+
+interface IWidget {
+  anthologyId?: string;
+  channels?: string[];
+  onClick?: (
+    anthologyId: string,
+    id: string,
+    target: "_blank" | "self"
+  ) => void;
+  onArticleSelect?: (anthologyId: string, keys: string[]) => void;
+}
+const AnthologyTocTreeWidget = ({
+  anthologyId,
+  channels,
+  onClick,
+  onArticleSelect,
+}: IWidget) => {
+  const [tocData, setTocData] = useState<ListNodeData[]>([]);
+  const [expandedKeys, setExpandedKeys] = useState<string[]>();
+
+  useEffect(() => {
+    if (typeof anthologyId === "undefined") {
+      return;
+    }
+    let url = `/api/v2/article-map?view=anthology&id=${anthologyId}&lazy=1`;
+    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_text ? item.title_text : item.title,
+            level: item.level,
+            children: item.children,
+            status: item.status,
+            deletedAt: item.deleted_at,
+          };
+        });
+        setTocData(toc);
+        if (json.data.rows.length === json.data.count) {
+          setExpandedKeys(
+            json.data.rows
+              .filter((value) => value.level === 1)
+              .map((item) => (item.article_id ? item.article_id : item.title))
+          );
+        } else {
+          setExpandedKeys(undefined);
+        }
+      }
+    });
+  }, [anthologyId, channels]);
+  return (
+    <TocTree
+      treeData={tocData}
+      expandedKeys={expandedKeys}
+      onSelect={(keys: string[]) => {
+        if (
+          typeof onArticleSelect !== "undefined" &&
+          typeof anthologyId !== "undefined"
+        ) {
+          onArticleSelect(anthologyId, keys);
+        }
+      }}
+      onClick={(
+        id: string,
+        e: React.MouseEvent<HTMLSpanElement, MouseEvent>
+      ) => {
+        const target = e.ctrlKey || e.metaKey ? "_blank" : "self";
+        if (
+          typeof onClick !== "undefined" &&
+          typeof anthologyId !== "undefined"
+        ) {
+          onClick(anthologyId, id, target);
+        }
+      }}
+    />
+  );
+};
+
+export default AnthologyTocTreeWidget;

+ 216 - 0
dashboard-v6/src/components/anthology/EditableTocTree.tsx

@@ -0,0 +1,216 @@
+import { Button, message } from "antd";
+import { useEffect, useState } from "react";
+import { FolderOpenOutlined } from "@ant-design/icons";
+
+import { get as getUiLang } from "../../locales";
+
+import { get, post, put } from "../../request";
+import type {
+  IAnthologyDataResponse,
+  IArticleCreateRequest,
+  IArticleDataResponse,
+  IArticleMapAddResponse,
+  IArticleMapListResponse,
+  IArticleMapRequest,
+  IArticleMapUpdateRequest,
+  IArticleResponse,
+} from "../../api/Article";
+import ArticleListModal from "../article/ArticleListModal";
+
+import { fullUrl, randomString } from "../../utils";
+import type {
+  ListNodeData,
+  TreeNodeData,
+} from "../article/components/EditableTree";
+import EditableTree from "../article/components/EditableTree";
+import ArticleDrawer from "../article/ArticleDrawer";
+
+interface IWidget {
+  anthologyId?: string;
+  studioName?: string;
+  myStudioName?: string;
+  anthology?: IAnthologyDataResponse;
+}
+const EditableTocTreeWidget = ({
+  anthologyId,
+  anthology,
+  studioName,
+  myStudioName,
+}: IWidget) => {
+  const [tocData, setTocData] = useState<ListNodeData[]>([]);
+  const [addArticle, setAddArticle] = useState<TreeNodeData>();
+  const [updatedArticle, setUpdatedArticle] = useState<TreeNodeData>();
+  const [openViewer, setOpenViewer] = useState(false);
+  const [viewArticle, setViewArticle] = useState<TreeNodeData>();
+
+  const save = (data?: ListNodeData[]) => {
+    console.debug("onSave", data);
+    if (typeof data === "undefined") {
+      console.warn("data === undefined");
+      return;
+    }
+    const url = `/api/v2/article-map/${anthologyId}`;
+    console.info("url", url);
+    const newData: IArticleMapRequest[] = data.map((item) => {
+      let title = "";
+      if (typeof item.title === "string") {
+        title = item.title;
+      }
+      //TODO 整一个string title
+      return {
+        article_id: item.key,
+        level: item.level,
+        title: title,
+        children: item.children,
+        status: item.status,
+        deleted_at: item.deletedAt,
+      };
+    });
+
+    put<IArticleMapUpdateRequest, IArticleMapAddResponse>(url, {
+      data: newData,
+      operation: "anthology",
+    })
+      .finally(() => {})
+      .then((json) => {
+        if (json.ok) {
+          message.success(json.data);
+        } else {
+          message.error(json.message);
+        }
+      })
+      .catch((e) => message.error(e));
+  };
+
+  useEffect(() => {
+    get<IArticleMapListResponse>(
+      `/api/v2/article-map?view=anthology&id=${anthologyId}`
+    ).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_text: item.title_text ? item.title_text : item.title,
+            level: item.level,
+            status: item.status,
+            deletedAt: item.deleted_at,
+          };
+        });
+        setTocData(toc);
+      }
+    });
+  }, [anthologyId]);
+
+  return (
+    <div>
+      <EditableTree
+        initValue={tocData}
+        addOnArticle={addArticle}
+        addFileButton={
+          <ArticleListModal
+            studioName={myStudioName}
+            trigger={<Button icon={<FolderOpenOutlined />}>添加</Button>}
+            multiple={false}
+            onSelect={(id: string, title: string) => {
+              console.log("add article", id);
+              const newNode: TreeNodeData = {
+                key: randomString(),
+                id: id,
+                title: title,
+                title_text: title,
+                children: [],
+                level: 1,
+              };
+              setAddArticle(newNode);
+            }}
+          />
+        }
+        updatedNode={updatedArticle}
+        onChange={(data: ListNodeData[]) => {
+          save(data);
+        }}
+        onSave={(data: ListNodeData[]) => {
+          save(data);
+        }}
+        onAppend={async (node: TreeNodeData): Promise<TreeNodeData> => {
+          /**
+           * 在某节点下append新的节点
+           */
+          if (typeof studioName === "undefined") {
+            console.log("studio", studioName);
+            throw new Error("studioName is undefined");
+          }
+          const res = await post<IArticleCreateRequest, IArticleResponse>(
+            `/api/v2/article`,
+            {
+              title: "new article",
+              lang: anthology?.lang ?? getUiLang(),
+              studio: studioName,
+              anthologyId: anthologyId,
+              status: anthology?.status ?? undefined,
+            }
+          );
+
+          console.log(res);
+
+          if (!res.ok) {
+            throw new Error("Create article failed");
+          }
+
+          return {
+            key: randomString(),
+            id: res.data.uid,
+            title: res.data.title,
+            title_text: res.data.title,
+            children: [],
+            level: node.level + 1,
+          };
+        }}
+        onTitleClick={(
+          e: React.MouseEvent<HTMLElement, MouseEvent>,
+          node: TreeNodeData
+        ) => {
+          if (e.ctrlKey || e.metaKey) {
+            window.open(fullUrl(`/article/article/${node.id}`), "_blank");
+          } else {
+            setViewArticle(node);
+            setOpenViewer(true);
+          }
+        }}
+      />
+      <ArticleDrawer
+        articleId={viewArticle?.id}
+        anthologyId={anthologyId}
+        type="article"
+        open={openViewer}
+        title={viewArticle?.title_text}
+        onClose={() => setOpenViewer(false)}
+        onArticleEdit={(value: IArticleDataResponse) => {
+          setUpdatedArticle({
+            key: randomString(),
+            id: value.uid,
+            title: value.title,
+            title_text: value.title_text,
+            level: 0,
+            children: [],
+          });
+        }}
+        onTitleChange={(value: string) => {
+          if (viewArticle?.id) {
+            setUpdatedArticle({
+              key: randomString(),
+              id: viewArticle?.id,
+              title: value,
+              title_text: value,
+              level: 0,
+              children: [],
+            });
+          }
+        }}
+      />
+    </div>
+  );
+};
+
+export default EditableTocTreeWidget;

+ 42 - 0
dashboard-v6/src/components/anthology/TextBookToc.tsx

@@ -0,0 +1,42 @@
+import { useEffect, useState } from "react";
+import AnthologyTocTree from "./AnthologyTocTree";
+import { get } from "../../request";
+import type { ICourseResponse } from "../../api/Course";
+
+interface IWidget {
+  courseId?: string | null;
+  channels?: string[];
+  onClick?: (article: string, target: string) => void;
+}
+const TextBookTocWidget = ({ courseId, channels, onClick }: IWidget) => {
+  const [anthologyId, setAnthologyId] = useState<string>();
+
+  useEffect(() => {
+    if (!courseId) {
+      return;
+    }
+    const url = `/api/v2/course/${courseId}`;
+    console.debug("course url", url);
+    get<ICourseResponse>(url).then((json) => {
+      console.debug("course data", json.data);
+      if (json.ok) {
+        setAnthologyId(json.data.anthology_id);
+      }
+    });
+  }, [courseId]);
+
+  return (
+    <AnthologyTocTree
+      anthologyId={anthologyId}
+      channels={channels}
+      onClick={(_anthology: string, article: string, target: string) => {
+        console.debug("AnthologyTocTree onClick", article);
+        if (typeof onClick !== "undefined") {
+          onClick(article, target);
+        }
+      }}
+    />
+  );
+};
+
+export default TextBookTocWidget;

+ 226 - 0
dashboard-v6/src/components/article/Article.tsx

@@ -0,0 +1,226 @@
+import type { IArticleDataResponse } from "../../api/Article";
+import TypeArticle from "./TypeArticle";
+import TypeAnthology from "./TypeAnthology";
+import TypeTerm from "./TypeTerm";
+import TypePali from "./TypePali";
+import "./article.css";
+import TypePage from "./TypePage";
+import TypeCSPara from "./TypeCSPara";
+import type { ISearchParams } from "../../pages/library/article/show";
+import TypeCourse from "./TypeCourse";
+import { useEffect, useState } from "react";
+import { fullUrl } from "../../utils";
+import TypeSeries from "./TypeSeries";
+import DiscussionCount from "../discussion/DiscussionCount";
+import TypeTask from "./TypeTask";
+
+interface IWidget {
+  type?: ArticleType;
+  articleId?: string;
+  mode?: ArticleMode | null;
+  channelId?: string | null;
+  parentChannels?: string[];
+  book?: string | null;
+  para?: string | null;
+  anthologyId?: string | null;
+  courseId?: string | null;
+  active?: boolean;
+  focus?: string | null;
+  hideInteractive?: boolean;
+  hideTitle?: boolean;
+  isSubWindow?: boolean;
+  onArticleChange?: (
+    type: ArticleType,
+    id: string,
+    target: string,
+    param?: ISearchParams[]
+  ) => void;
+  onLoad?: Function;
+  onAnthologySelect?: Function;
+  onTitle?: Function;
+  onArticleEdit?: Function;
+}
+const ArticleWidget = ({
+  type,
+  book,
+  para,
+  channelId,
+  parentChannels,
+  articleId,
+  anthologyId,
+  courseId,
+  mode = "read",
+  active = false,
+  focus,
+  hideInteractive = false,
+  hideTitle = false,
+  isSubWindow = false,
+  onArticleChange,
+  onLoad,
+  onAnthologySelect,
+  onTitle,
+  onArticleEdit,
+}: IWidget) => {
+  const [currId, setCurrId] = useState(articleId);
+  useEffect(() => setCurrId(articleId), [articleId]);
+
+  return (
+    <div>
+      <DiscussionCount courseId={type === "textbook" ? courseId : undefined} />
+      {type === "article" ? (
+        <TypeArticle
+          isSubWindow={isSubWindow}
+          type={type}
+          articleId={onArticleChange ? articleId : currId}
+          channelId={channelId}
+          parentChannels={parentChannels}
+          mode={mode}
+          anthologyId={anthologyId}
+          active={active}
+          hideInteractive={hideInteractive}
+          hideTitle={hideTitle}
+          onArticleEdit={(value: IArticleDataResponse) => {
+            if (typeof onArticleEdit !== "undefined") {
+              onArticleEdit(value);
+            }
+          }}
+          onArticleChange={onArticleChange}
+          onLoad={(data: IArticleDataResponse) => {
+            if (typeof onLoad !== "undefined") {
+              onLoad(data);
+            }
+            if (typeof onTitle !== "undefined") {
+              onTitle(data.title);
+            }
+          }}
+          onAnthologySelect={(id: string) => {
+            if (typeof onAnthologySelect !== "undefined") {
+              onAnthologySelect(id);
+            }
+          }}
+        />
+      ) : type === "anthology" ? (
+        <TypeAnthology
+          type={type}
+          articleId={onArticleChange ? articleId : currId}
+          channelId={channelId}
+          mode={mode}
+          onArticleChange={(type: ArticleType, id: string, target: string) => {
+            if (typeof onArticleChange !== "undefined") {
+              onArticleChange(type, id, target);
+            }
+          }}
+          onTitle={(value: string) => {
+            if (typeof onTitle !== "undefined") {
+              onTitle(value);
+            }
+          }}
+        />
+      ) : type === "term" ? (
+        <TypeTerm
+          articleId={onArticleChange ? articleId : currId}
+          channelId={channelId}
+          mode={mode}
+          onArticleChange={(type: ArticleType, id: string, target: string) => {
+            if (typeof onArticleChange !== "undefined") {
+              onArticleChange(type, id, target);
+            }
+          }}
+        />
+      ) : type === "chapter" || type === "para" ? (
+        <TypePali
+          type={type}
+          articleId={onArticleChange ? articleId : currId}
+          channelId={channelId}
+          mode={mode}
+          book={book}
+          para={para}
+          focus={focus}
+          onArticleChange={(
+            type: ArticleType,
+            id: string,
+            target: string,
+            param?: ISearchParams[]
+          ) => {
+            if (typeof onArticleChange !== "undefined") {
+              onArticleChange(type, id, target, param);
+            }
+          }}
+          onLoad={(data: IArticleDataResponse) => {
+            if (typeof onLoad !== "undefined") {
+              onLoad(data);
+            }
+          }}
+          onTitle={(value: string) => {
+            if (typeof onTitle !== "undefined") {
+              onTitle(value);
+            }
+          }}
+        />
+      ) : type === "series" ? (
+        <TypeSeries
+          articleId={onArticleChange ? articleId : currId}
+          channelId={channelId}
+          onArticleChange={(
+            type: ArticleType,
+            id: string,
+            target: string,
+            param: ISearchParams[]
+          ) => {
+            if (typeof onArticleChange !== "undefined") {
+              onArticleChange(type, id, target, param);
+            }
+          }}
+        />
+      ) : type === "page" ? (
+        <TypePage
+          articleId={onArticleChange ? articleId : currId}
+          channelId={channelId}
+          focus={focus}
+          mode={mode}
+          onArticleChange={(type: ArticleType, id: string, target: string) => {
+            if (typeof onArticleChange !== "undefined") {
+              onArticleChange(type, id, target);
+            } else {
+              if (target === "_blank") {
+                let url = `/article/page/${id}?mode=${mode}`;
+                if (channelId) {
+                  url += `&channel=${channelId}`;
+                }
+                window.open(fullUrl(url), "_blank");
+              } else {
+                setCurrId(id);
+              }
+            }
+          }}
+        />
+      ) : type === "cs-para" ? (
+        <TypeCSPara
+          articleId={onArticleChange ? articleId : currId}
+          channelId={channelId}
+          mode={mode}
+          onArticleChange={(type: ArticleType, id: string, target: string) => {
+            if (typeof onArticleChange !== "undefined") {
+              onArticleChange(type, id, target);
+            }
+          }}
+        />
+      ) : type === "textbook" ? (
+        <TypeCourse
+          type={type}
+          articleId={onArticleChange ? articleId : currId}
+          channelId={channelId}
+          courseId={courseId}
+          mode={mode}
+          onArticleChange={onArticleChange}
+        />
+      ) : type === "task" ? (
+        <TypeTask articleId={articleId} />
+      ) : (
+        <></>
+      )}
+    </div>
+  );
+};
+
+export default ArticleWidget;

+ 130 - 0
dashboard-v6/src/components/article/ArticleDrawer.tsx

@@ -0,0 +1,130 @@
+import { Button, Drawer, Space, Typography } from "antd";
+import React, { useEffect, useState } from "react";
+import { Link } from "react-router";
+
+import type {
+  ArticleMode,
+  ArticleType,
+  IArticleDataResponse,
+} from "../../api/Article";
+import Article from "./Article";
+const { Text } = Typography;
+
+interface IWidget {
+  trigger?: React.ReactNode;
+  title?: string;
+  type?: ArticleType;
+  book?: string;
+  para?: string;
+  channelId?: string;
+  articleId?: string;
+  anthologyId?: string;
+  mode?: ArticleMode;
+  open?: boolean;
+  onClose?: () => void;
+  onTitleChange?: (value: string) => void;
+  onArticleEdit?: (value: IArticleDataResponse) => void;
+}
+
+const ArticleDrawerWidget = ({
+  trigger,
+  title,
+  type,
+  book,
+  para,
+  channelId,
+  articleId,
+  anthologyId,
+  mode,
+  open,
+  onClose,
+  onTitleChange,
+  onArticleEdit,
+}: IWidget) => {
+  const [openDrawer, setOpenDrawer] = useState(open);
+  const [drawerTitle, setDrawerTitle] = useState(title);
+  useEffect(() => {
+    setOpenDrawer(open);
+  }, [open]);
+  useEffect(() => {
+    setDrawerTitle(title);
+  }, [title]);
+  const showDrawer = () => {
+    setOpenDrawer(true);
+  };
+
+  const onDrawerClose = () => {
+    setOpenDrawer(false);
+    if (document.getElementsByTagName("body")[0].hasAttribute("style")) {
+      document.getElementsByTagName("body")[0].removeAttribute("style");
+    }
+    if (typeof onClose !== "undefined") {
+      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 (
+    <>
+      <span onClick={() => showDrawer()}>{trigger}</span>
+      <Drawer
+        title={
+          <Text
+            editable={{
+              onChange: (value: string) => {
+                setDrawerTitle(value);
+                if (typeof onTitleChange !== "undefined") {
+                  onTitleChange(value);
+                }
+              },
+            }}
+          >
+            {drawerTitle}
+          </Text>
+        }
+        width={1000}
+        placement="right"
+        onClose={onDrawerClose}
+        open={openDrawer}
+        destroyOnHidden={true}
+        extra={
+          <Space>
+            <Button>
+              <Link to={getUrl()}>在单页面中打开</Link>
+            </Button>
+            <Button>
+              <Link to={getUrl("edit")}>翻译模式</Link>
+            </Button>
+          </Space>
+        }
+      >
+        <Article
+          active={true}
+          type={type as ArticleType}
+          book={book}
+          para={para}
+          channelId={channelId}
+          articleId={articleId}
+          anthologyId={anthologyId}
+          mode={mode}
+          onArticleEdit={(value: IArticleDataResponse) => {
+            setDrawerTitle(value.title_text);
+            if (typeof onArticleEdit !== "undefined") {
+              onArticleEdit(value);
+            }
+          }}
+        />
+      </Drawer>
+    </>
+  );
+};
+
+export default ArticleDrawerWidget;

+ 71 - 0
dashboard-v6/src/components/article/TypeAnthology.tsx

@@ -0,0 +1,71 @@
+import type { ArticleMode, ArticleType } from "./Article";
+import AnthologyDetail from "./AnthologyDetail";
+import "./article.css";
+import { useState, useMemo } from "react";
+import ErrorResult from "../general/ErrorResult";
+import ArticleSkeleton from "./ArticleSkeleton";
+
+interface IWidget {
+  type?: ArticleType;
+  articleId?: string;
+  mode?: ArticleMode | null;
+  channelId?: string | null;
+  onArticleChange?: (
+    type: string,
+    articleId: string,
+    target: string,
+    extra?: { anthologyId?: string }
+  ) => void;
+  onFinal?: () => void;
+  onLoad?: () => void;
+  onTitle?: (title: string) => void;
+}
+
+const TypeAnthologyWidget = ({
+  channelId,
+  articleId,
+  onArticleChange,
+  onTitle,
+}: IWidget) => {
+  const [loading, setLoading] = useState(false);
+  const [errorCode, setErrorCode] = useState<number | null>(null);
+
+  /** ✅ 避免每次 render 都 split */
+  const channels = useMemo(
+    () => (channelId ? channelId.split("_") : undefined),
+    [channelId]
+  );
+
+  return (
+    <div>
+      {loading && <ArticleSkeleton />}
+
+      {!loading && errorCode && <ErrorResult code={errorCode} />}
+
+      {!errorCode && (
+        <AnthologyDetail
+          visible={!loading}
+          channels={channels}
+          aid={articleId}
+          onArticleClick={(anthologyId, articleId, target) => {
+            onArticleChange?.("article", articleId, target, {
+              anthologyId,
+            });
+          }}
+          onLoading={setLoading}
+          onError={(error: unknown) => {
+            console.error(error);
+            //TODO get real error code
+            setErrorCode(404);
+            //setErrorCode(message); //old code
+          }}
+          onTitle={(value) => {
+            onTitle?.(value);
+          }}
+        />
+      )}
+    </div>
+  );
+};
+
+export default TypeAnthologyWidget;

+ 101 - 0
dashboard-v6/src/components/article/TypeArticle.tsx

@@ -0,0 +1,101 @@
+import { useState } from "react";
+import { Modal } from "antd";
+import { ExclamationCircleOutlined } from "@ant-design/icons";
+import type { IArticleDataResponse } from "../../api/Article";
+import type { ArticleMode, ArticleType } from "./Article";
+import TypeArticleReader from "./TypeArticleReader";
+import ArticleEdit from "./ArticleEdit";
+
+interface IWidget {
+  type?: ArticleType;
+  articleId?: string;
+  mode?: ArticleMode | null;
+  channelId?: string | null;
+  parentChannels?: string[];
+  anthologyId?: string | null;
+  active?: boolean;
+  hideInteractive?: boolean;
+  hideTitle?: boolean;
+  isSubWindow?: boolean;
+  onArticleChange?: Function;
+  onArticleEdit?: Function;
+  onLoad?: Function;
+  onAnthologySelect?: Function;
+}
+const TypeArticleWidget = ({
+  type,
+  channelId,
+  parentChannels,
+  articleId,
+  anthologyId,
+  mode = "read",
+  active = false,
+  hideInteractive = false,
+  hideTitle = false,
+  isSubWindow = false,
+  onArticleChange,
+  onLoad,
+  onAnthologySelect,
+  onArticleEdit,
+}: IWidget) => {
+  const [edit, setEdit] = useState(false);
+  return (
+    <div>
+      {edit ? (
+        <ArticleEdit
+          anthologyId={anthologyId ? anthologyId : undefined}
+          articleId={articleId}
+          resetButton="cancel"
+          onSubmit={(value: IArticleDataResponse) => {
+            if (typeof onArticleEdit !== "undefined") {
+              onArticleEdit(value);
+            }
+            setEdit(false);
+          }}
+          onCancel={() => {
+            Modal.confirm({
+              icon: <ExclamationCircleOutlined />,
+              content: "放弃修改吗?",
+              okType: "danger",
+              onOk() {
+                setEdit(false);
+              },
+            });
+          }}
+        />
+      ) : (
+        <TypeArticleReader
+          isSubWindow={isSubWindow}
+          type={type}
+          channelId={channelId}
+          parentChannels={parentChannels}
+          articleId={articleId}
+          anthologyId={anthologyId}
+          mode={mode}
+          active={active}
+          hideInteractive={hideInteractive}
+          hideTitle={hideTitle}
+          onArticleChange={onArticleChange}
+          onLoad={(data: IArticleDataResponse) => {
+            if (typeof onLoad !== "undefined") {
+              onLoad(data);
+            }
+          }}
+          onAnthologySelect={(
+            id: string,
+            e: React.MouseEvent<HTMLElement, MouseEvent>
+          ) => {
+            if (typeof onAnthologySelect !== "undefined") {
+              onAnthologySelect(id, e);
+            }
+          }}
+          onEdit={() => {
+            setEdit(true);
+          }}
+        />
+      )}
+    </div>
+  );
+};
+
+export default TypeArticleWidget;

+ 335 - 0
dashboard-v6/src/components/article/TypeArticleReader.tsx

@@ -0,0 +1,335 @@
+import { useEffect, useState } from "react";
+import { Divider, message, Space, Tag } from "antd";
+
+import { get } from "../../request";
+import type {
+  IAnthologyResponse,
+  IArticleDataResponse,
+  IArticleNavData,
+  IArticleNavResponse,
+  IArticleResponse,
+} from "../../api/Article";
+import ArticleView, { type IFirstAnthology } from "./ArticleView";
+import TocTree from "./TocTree";
+import PaliText from "../template/Wbw/PaliText";
+import type { ITocPathNode } from "../../../src/components/tipitaka/TocPath";
+import type { ArticleMode, ArticleType } from "./Article";
+import "./article.css";
+import ArticleSkeleton from "./ArticleSkeleton";
+import ErrorResult from "../general/ErrorResult";
+import NavigateButton from "./NavigateButton";
+import InteractiveArea from "../discussion/InteractiveArea";
+import TypeArticleReaderToolbar from "./TypeArticleReaderToolbar";
+import type { IChannel } from "../channel/Channel";
+
+interface IWidget {
+  type?: ArticleType;
+  articleId?: string;
+  mode?: ArticleMode | null;
+  channelId?: string | null;
+  parentChannels?: string[];
+  anthologyId?: string | null;
+  active?: boolean;
+  hideInteractive?: boolean;
+  hideTitle?: boolean;
+  isSubWindow?: boolean;
+  onArticleChange?: Function;
+  onLoad?: Function;
+  onAnthologySelect?: Function;
+  onEdit?: Function;
+}
+const TypeArticleReaderWidget = ({
+  type,
+  channelId,
+  parentChannels,
+  articleId,
+  anthologyId,
+  mode = "read",
+  active = false,
+  hideInteractive = false,
+  hideTitle = false,
+  isSubWindow = false,
+  onArticleChange,
+  onLoad,
+  onAnthologySelect,
+  onEdit,
+}: 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 [nav, setNav] = useState<IArticleNavData>();
+  const [_defaultChannel, setDefaultChannel] = useState<IChannel | null>();
+
+  const channels = channelId?.split("_");
+
+  const srcDataMode = mode === "edit" || mode === "wbw" ? "edit" : "read";
+
+  //创建时获取文集channel
+  useEffect(() => {
+    if (!anthologyId) {
+      return;
+    }
+    if (channelId) {
+      return;
+    }
+    const url = `/v2/anthology/${anthologyId}`;
+    console.info("api request", url);
+
+    get<IAnthologyResponse>(url).then((json) => {
+      if (json.ok) {
+        if (json.data.default_channel) {
+          if (typeof onArticleChange === "undefined") {
+            //自控
+            setDefaultChannel(json.data.default_channel);
+          } else {
+            //外控
+            onArticleChange("article", articleId, null);
+          }
+        } else {
+          setDefaultChannel(null);
+        }
+      }
+    });
+  }, []);
+
+  useEffect(() => {
+    console.log("srcDataMode", srcDataMode);
+    if (!active) {
+      return;
+    }
+
+    if (typeof type === "undefined") {
+      return;
+    }
+
+    let mChannels: string[] = [];
+    if (channelId) {
+      mChannels.push(channelId);
+    }
+    if (parentChannels) {
+      mChannels = [...parentChannels, ...mChannels];
+    }
+    let url = `/v2/article/${articleId}?mode=${srcDataMode}`;
+    if (mChannels.length > 0) {
+      url += `&channel=${mChannels.join("_")}`;
+    }
+    url += anthologyId ? `&anthology=${anthologyId}` : "";
+    console.info("article api request", url);
+    setLoading(true);
+    get<IArticleResponse>(url)
+      .then((json) => {
+        console.info("article api response", 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]);
+          } else {
+            setArticleHtml([""]);
+          }
+          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,
+                };
+              })}
+              onClick={(
+                id: string,
+                e: React.MouseEvent<HTMLSpanElement, MouseEvent>
+              ) => {
+                const target = e.ctrlKey || e.metaKey ? "_blank" : "self";
+                if (typeof onArticleChange !== "undefined") {
+                  onArticleChange("article", id, target);
+                }
+              }}
+            />
+          );
+
+          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]);
+
+  useEffect(() => {
+    const url = `/v2/nav-article/${articleId}_${anthologyId}`;
+    console.info("api request", url);
+    get<IArticleNavResponse>(url)
+      .then((json) => {
+        console.debug("api response", json);
+        if (json.ok) {
+          setNav(json.data);
+        }
+      })
+      .catch((e) => {
+        console.error(e);
+      });
+  }, [anthologyId, articleId]);
+
+  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,
+    };
+  }
+
+  const title = articleData?.title_text ?? articleData?.title;
+
+  let endOfChapter = false;
+  if (nav?.curr && nav?.next) {
+    if (nav?.curr?.level > nav?.next?.level) {
+      endOfChapter = true;
+    }
+  }
+
+  let topOfChapter = false;
+  if (nav?.curr && nav?.prev) {
+    if (nav?.curr?.level > nav?.prev?.level) {
+      topOfChapter = true;
+    }
+  }
+
+  return (
+    <div>
+      {loading ? (
+        <ArticleSkeleton />
+      ) : errorCode ? (
+        <ErrorResult code={errorCode} />
+      ) : (
+        <>
+          <TypeArticleReaderToolbar
+            title={title}
+            articleId={articleId}
+            anthologyId={anthologyId}
+            role={articleData?.role}
+            isSubWindow={isSubWindow}
+            onEdit={() => {
+              if (typeof onEdit !== "undefined") {
+                onEdit();
+              }
+            }}
+            onAnthologySelect={(
+              id: string,
+              e: React.MouseEvent<HTMLElement, MouseEvent>
+            ) => {
+              if (typeof onAnthologySelect !== "undefined") {
+                onAnthologySelect(id, e);
+              }
+            }}
+          />
+          <ArticleView
+            id={articleData?.uid}
+            title={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}
+            hideTitle={hideTitle}
+            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 />
+          <NavigateButton
+            prevTitle={nav?.prev?.title}
+            nextTitle={nav?.next?.title}
+            topOfChapter={topOfChapter}
+            endOfChapter={endOfChapter}
+            path={currPath}
+            onNext={() => {
+              if (typeof onArticleChange !== "undefined") {
+                onArticleChange("article", nav?.next?.article_id);
+              }
+            }}
+            onPrev={() => {
+              if (typeof onArticleChange !== "undefined") {
+                onArticleChange("article", nav?.prev?.article_id);
+              }
+            }}
+            onPathChange={(key: string) => {
+              if (typeof onArticleChange !== "undefined") {
+                const node = currPath?.find((value) => value.key === key);
+                if (node) {
+                  let newType = type;
+                  if (node.level === 0) {
+                    newType = "anthology";
+                  } else {
+                    newType = "article";
+                  }
+                  onArticleChange(newType, node.key, "_self");
+                }
+              }
+            }}
+          />
+          {hideInteractive ? (
+            <></>
+          ) : (
+            <InteractiveArea resType={"article"} resId={articleId} />
+          )}
+        </>
+      )}
+    </div>
+  );
+};
+
+export default TypeArticleReaderWidget;

+ 211 - 0
dashboard-v6/src/components/article/TypeArticleReaderToolbar.tsx

@@ -0,0 +1,211 @@
+import { Button, Dropdown, Tooltip } from "antd";
+import {
+  ReloadOutlined,
+  MoreOutlined,
+  InboxOutlined,
+  EditOutlined,
+  FileOutlined,
+  CopyOutlined,
+  InfoCircleOutlined,
+} from "@ant-design/icons";
+
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
+import AddToAnthology from "./AddToAnthology";
+import { useState } from "react";
+import { fullUrl } from "../../utils";
+import { ArticleTplModal } from "../template/Builder/ArticleTpl";
+import AnthologiesAtArticle from "./AnthologiesAtArticle";
+import type { TRole } from "../../api/Auth";
+import { useIntl } from "react-intl";
+import { TabIcon } from "../../assets/icon";
+import WordCount from "./WordCount";
+
+interface IWidget {
+  articleId?: string;
+  anthologyId?: string | null;
+  title?: string;
+  role?: TRole;
+  isSubWindow?: boolean;
+  onEdit?: Function;
+  onAnthologySelect?: Function;
+}
+const TypeArticleReaderToolbarWidget = ({
+  articleId,
+  anthologyId,
+  title,
+  role = "reader",
+  isSubWindow = false,
+  onEdit,
+  onAnthologySelect,
+}: IWidget) => {
+  const intl = useIntl();
+  const user = useAppSelector(currentUser);
+  const [addToAnthologyOpen, setAddToAnthologyOpen] = useState(false);
+  const [tplOpen, setTplOpen] = useState(false);
+  const [wordCountOpen, setWordCountOpen] = useState(false);
+
+  const editable = role === "owner" || role === "manager" || role === "editor";
+
+  return (
+    <div>
+      <div
+        style={{ padding: 4, display: "flex", justifyContent: "space-between" }}
+      >
+        <div>
+          {isSubWindow ? (
+            <></>
+          ) : (
+            <AnthologiesAtArticle
+              articleId={articleId}
+              anthologyId={anthologyId}
+              onClick={(
+                id: string,
+                e: React.MouseEvent<HTMLElement, MouseEvent>
+              ) => {
+                if (typeof onAnthologySelect !== "undefined") {
+                  onAnthologySelect(id, e);
+                }
+              }}
+            />
+          )}
+        </div>
+        <div>
+          <Tooltip
+            title={intl.formatMessage({
+              id: "buttons.edit",
+            })}
+          >
+            <Button
+              type="link"
+              size="small"
+              disabled={!editable}
+              icon={<EditOutlined />}
+              onClick={() => {
+                if (typeof onEdit !== "undefined") {
+                  onEdit();
+                }
+              }}
+            />
+          </Tooltip>
+          <Button type="link" size="small" icon={<ReloadOutlined />} />
+          <Dropdown
+            menu={{
+              items: [
+                {
+                  label: intl.formatMessage(
+                    {
+                      id: "buttons.open.in.new.tab",
+                    },
+                    { item: "" }
+                  ),
+                  key: "open_in_tab",
+                  icon: <TabIcon />,
+                },
+                {
+                  label: intl.formatMessage({
+                    id: "buttons.add_to_anthology",
+                  }),
+                  key: "add_to_anthology",
+                  icon: <InboxOutlined />,
+                  disabled: user ? false : true,
+                },
+                {
+                  label: intl.formatMessage({
+                    id: "buttons.edit",
+                  }),
+                  key: "edit",
+                  icon: <EditOutlined />,
+                  disabled: !editable,
+                },
+                {
+                  label: intl.formatMessage({
+                    id: "buttons.open.in.studio",
+                  }),
+                  key: "open-studio",
+                  icon: <EditOutlined />,
+                  disabled: user ? false : true,
+                },
+                {
+                  label: "获取文章引用模版",
+                  key: "tpl",
+                  icon: <FileOutlined />,
+                },
+                {
+                  label: "创建副本",
+                  key: "fork",
+                  icon: <CopyOutlined />,
+                  disabled: user ? false : true,
+                },
+                {
+                  label: "字数统计",
+                  key: "word-count",
+                  icon: <InfoCircleOutlined />,
+                },
+              ],
+              onClick: ({ key }) => {
+                console.log(`Click on item ${key}`);
+                switch (key) {
+                  case "open_in_tab":
+                    window.open(
+                      fullUrl(`/article/article/${articleId}`),
+                      "_blank"
+                    );
+                    break;
+                  case "add_to_anthology":
+                    setAddToAnthologyOpen(true);
+                    break;
+                  case "fork":
+                    const url = `/studio/${user?.realName}/article/create?parent=${articleId}`;
+                    window.open(fullUrl(url), "_blank");
+                    break;
+                  case "tpl":
+                    setTplOpen(true);
+                    break;
+                  case "word-count":
+                    setWordCountOpen(true);
+                    break;
+                  case "edit":
+                    if (typeof onEdit !== "undefined") {
+                      onEdit();
+                    }
+                    break;
+                }
+              },
+            }}
+            placement="bottomRight"
+          >
+            <Button
+              onClick={(e) => e.preventDefault()}
+              icon={<MoreOutlined />}
+              size="small"
+              type="link"
+            />
+          </Dropdown>
+        </div>
+      </div>
+      {articleId ? (
+        <AddToAnthology
+          open={addToAnthologyOpen}
+          onClose={(isOpen: boolean) => setAddToAnthologyOpen(isOpen)}
+          articleIds={[articleId]}
+        />
+      ) : undefined}
+
+      <ArticleTplModal
+        title={title}
+        type="article"
+        articleId={articleId}
+        open={tplOpen}
+        onClose={() => setTplOpen(false)}
+      />
+      <WordCount
+        open={wordCountOpen}
+        articleId={articleId}
+        onClose={() => setWordCountOpen(false)}
+      />
+    </div>
+  );
+};
+
+export default TypeArticleReaderToolbarWidget;

+ 172 - 0
dashboard-v6/src/components/article/TypeCSPara.tsx

@@ -0,0 +1,172 @@
+import { useEffect, useState } from "react";
+import { message } from "antd";
+
+import { get } from "../../request";
+import type { ICSParaNavData, ICSParaNavResponse } from "../../api/Article";
+import type { ArticleMode, ArticleType } from "./Article";
+import TypePali from "./TypePali";
+import NavigateButton from "./NavigateButton";
+import ArticleSkeleton from "./ArticleSkeleton";
+import ErrorResult from "../general/ErrorResult";
+import "./article.css";
+import type { ISearchParams } from "../../pages/library/article/show";
+
+interface IParam {
+  articleId?: string;
+  mode?: ArticleMode | null;
+  channelId?: string | null;
+  book?: string | null;
+  para?: string | null;
+}
+interface IWidget {
+  articleId?: string;
+  mode?: ArticleMode | null;
+  channelId?: string | null;
+  onArticleChange?: (
+    type: ArticleType,
+    id: string,
+    target: string,
+    param?: ISearchParams[]
+  ) => void;
+}
+const TypeCSParaWidget = ({
+  channelId,
+  articleId,
+  mode = "read",
+  onArticleChange,
+}: IWidget) => {
+  /**
+   * 页面加载
+   * M 缅文页码
+   * P PTS页码
+   * V vri页码
+   * T 泰文页码
+   * O 其他
+   * para 缅文段落号
+   * url 格式 /article/page/M-dīghanikāya-2-10
+   * 书名在 dashboard\src\components\fts\book_name.ts
+   */
+
+  const [paramPali, setParamPali] = useState<IParam>();
+  const [nav, setNav] = useState<ICSParaNavData>();
+  const [errorCode, setErrorCode] = useState<number>();
+  const [errorMessage, setErrorMessage] = useState<string>();
+
+  useEffect(() => {
+    if (typeof articleId === "undefined") {
+      console.error("articleId 不能为空");
+      return;
+    }
+
+    const pageParam = articleId.split("_");
+    if (pageParam.length !== 3) {
+      console.error("pageParam 必须为三个");
+      return;
+    }
+
+    const url = `/v2/nav-cs-para/${articleId}`;
+    console.log("url", url);
+    get<ICSParaNavResponse>(url)
+      .then((json) => {
+        if (json.ok) {
+          const data = json.data;
+          setNav(data);
+          const begin = data.curr.start;
+          const end = data.end;
+          const para: number[] = [];
+          for (let index = begin; index <= end; index++) {
+            para.push(index);
+          }
+          setParamPali({
+            articleId: `${data.curr.book}-${data.curr.start}`,
+            book: data.curr.book.toString(),
+            para: para.join(),
+            mode: mode,
+            channelId: channelId,
+          });
+        } else {
+          message.error(json.message);
+        }
+      })
+      .finally(() => {})
+      .catch((e) => {
+        console.error(e);
+        setErrorCode(e);
+        if (e === 404) {
+          setErrorMessage(`该页面不存在。`);
+        }
+      });
+  }, [articleId, channelId, mode]);
+
+  return (
+    <div>
+      {paramPali ? (
+        <>
+          <TypePali
+            type={"para"}
+            hideNav
+            {...paramPali}
+            onArticleChange={(
+              type: ArticleType,
+              id: string,
+              target: string,
+              param?: ISearchParams[] | undefined
+            ) => {
+              if (typeof onArticleChange !== "undefined") {
+                onArticleChange(type, id, target, param);
+              }
+            }}
+          />
+          <NavigateButton
+            prevTitle={nav?.prev?.content.slice(0, 10)}
+            nextTitle={nav?.next?.content.slice(0, 10)}
+            onNext={(event: React.MouseEvent<HTMLElement, MouseEvent>) => {
+              if (typeof onArticleChange !== "undefined") {
+                if (typeof articleId === "undefined") {
+                  return;
+                }
+                const pageParam = articleId.split("_");
+                if (pageParam.length !== 3) {
+                  return;
+                }
+                const id = `${pageParam[0]}-${pageParam[1]}-${
+                  parseInt(pageParam[2]) + 1
+                }`;
+                let target = "_self";
+                if (event.ctrlKey || event.metaKey) {
+                  target = "_blank";
+                }
+                onArticleChange("cs-para", id, target);
+              }
+            }}
+            onPrev={(event: React.MouseEvent<HTMLElement, MouseEvent>) => {
+              if (typeof onArticleChange !== "undefined") {
+                if (typeof articleId === "undefined") {
+                  return;
+                }
+                const pageParam = articleId.split("_");
+                if (pageParam.length < 3) {
+                  return;
+                }
+                const id = `${pageParam[0]}-${pageParam[1]}-${
+                  parseInt(pageParam[2]) - 1
+                }`;
+                let target = "_self";
+                if (event.ctrlKey || event.metaKey) {
+                  target = "_blank";
+                }
+                onArticleChange("cs-para", id, target);
+              }
+            }}
+          />
+        </>
+      ) : errorCode ? (
+        <ErrorResult code={errorCode} message={errorMessage} />
+      ) : (
+        <ArticleSkeleton />
+      )}
+    </div>
+  );
+};
+
+export default TypeCSParaWidget;

+ 235 - 0
dashboard-v6/src/components/article/TypeCourse.tsx

@@ -0,0 +1,235 @@
+import { useEffect, useMemo, useState } from "react";
+import { get } from "../../request";
+import store from "../../store";
+
+import type {
+  ICourseCurrUserResponse,
+  ICourseDataResponse,
+  ICourseMemberListResponse,
+  ICourseResponse,
+  ICourseUser,
+} from "../../api/Course";
+
+import { signIn } from "../../reducers/course-user";
+import {
+  type ITextbook,
+  memberRefresh,
+  refresh,
+} from "../../reducers/current-course";
+
+import "./article.css";
+
+import type { ArticleMode, ArticleType } from "./Article";
+import TypeArticle from "./TypeArticle";
+import { Link, useNavigate, useSearchParams } from "react-router";
+
+import SelectChannel from "../course/SelectChannel";
+import { Space, Tag, Typography } from "antd";
+import { useIntl } from "react-intl";
+import type { ISearchParams } from "../../pages/library/article/show";
+
+const { Text } = Typography;
+
+interface IWidget {
+  type?: ArticleType;
+  articleId?: string;
+  mode?: ArticleMode | null;
+  channelId?: string | null;
+  book?: string | null;
+  para?: string | null;
+  courseId?: string | null;
+  exerciseId?: string;
+  userName?: string;
+  active?: boolean;
+  onArticleChange?: (
+    type: ArticleType,
+    id: string,
+    target: string,
+    param?: ISearchParams[]
+  ) => void;
+  onFinal?: () => void;
+  onLoad?: () => void;
+  onLoading?: (loading: boolean) => void;
+  onError?: (msg: string) => void;
+}
+
+const TypeCourseWidget = ({
+  type,
+  channelId,
+  articleId,
+  courseId,
+  mode = "read",
+  onArticleChange,
+}: IWidget) => {
+  const intl = useIntl();
+  const navigate = useNavigate();
+
+  const [anthologyId, setAnthologyId] = useState<string>();
+  const [course, setCourse] = useState<ICourseDataResponse>();
+  const [currUser, setCurrUser] = useState<ICourseUser>();
+  const [channelPickerOpen, setChannelPickerOpen] = useState(false);
+
+  const [searchParams, setSearchParams] = useSearchParams();
+
+  /** ---------------- 课程用户信息 ---------------- */
+  useEffect(() => {
+    if (type !== "textbook" || !courseId) return;
+
+    let ignore = false;
+
+    (async () => {
+      const res = await get<ICourseCurrUserResponse>(
+        `/v2/course-curr?course_id=${courseId}`
+      );
+
+      if (!res.ok || ignore) return;
+
+      setCurrUser(res.data);
+
+      if (!res.data.channel_id) setChannelPickerOpen(true);
+
+      store.dispatch(
+        signIn({
+          channelId: res.data.channel_id,
+          role: res.data.role,
+        })
+      );
+
+      /** 老师加载成员列表 */
+      if (res.data.role && res.data.role !== "student") {
+        const list = await get<ICourseMemberListResponse>(
+          `/v2/course-member?view=course&id=${courseId}`
+        );
+
+        if (list.ok) {
+          store.dispatch(memberRefresh(list.data.rows));
+        }
+      }
+    })();
+
+    return () => {
+      ignore = true;
+    };
+  }, [courseId, type]);
+
+  /** ---------------- 同步 URL 参数 ---------------- */
+  const paramsString = searchParams.toString();
+
+  useEffect(() => {
+    if (!currUser && !course) return;
+
+    const output: Record<string, string> = { mode: mode ?? "read" };
+
+    new URLSearchParams(paramsString).forEach((value, key) => {
+      if (key !== "mode" && key !== "channel") {
+        output[key] = value;
+      }
+    });
+
+    if (currUser?.role === "student") {
+      if (currUser.channel_id) {
+        output.channel = currUser.channel_id;
+      }
+    } else if (course?.channel_id) {
+      output.channel = course.channel_id;
+    }
+
+    setSearchParams(output);
+  }, [currUser, course, mode, paramsString]);
+
+  /** ---------------- 课程信息 ---------------- */
+  useEffect(() => {
+    if (!courseId) return;
+
+    let ignore = false;
+
+    (async () => {
+      const json = await get<ICourseResponse>(`/v2/course/${courseId}`);
+
+      if (!json.ok || ignore) return;
+
+      setAnthologyId(json.data.anthology_id);
+      setCourse(json.data);
+
+      if (articleId) {
+        const ic: ITextbook = {
+          course: json.data,
+          courseId,
+          articleId,
+          channelId: json.data.channel_id,
+        };
+
+        store.dispatch(refresh(ic));
+      }
+    })();
+
+    return () => {
+      ignore = true;
+    };
+  }, [articleId, courseId]);
+
+  /** ---------------- 计算 channelId ---------------- */
+  const channelsId = useMemo(() => {
+    if (!currUser || !course) return "";
+
+    if (currUser.role === "student") {
+      return currUser.channel_id
+        ? `${currUser.channel_id}_${course.channel_id}`
+        : (course.channel_id ?? "");
+    }
+
+    return course.channel_id ?? "";
+  }, [currUser, course]);
+
+  /** ---------------- loading ---------------- */
+  if (!anthologyId || !currUser) return <>loading</>;
+
+  return (
+    <>
+      {currUser.role === "student" && !currUser.channel_id && (
+        <SelectChannel
+          courseId={courseId}
+          open={channelPickerOpen}
+          onOpenChange={setChannelPickerOpen}
+          onSelected={() => window.location.reload()}
+        />
+      )}
+
+      <Space>
+        <Text>
+          课程:
+          <Link to={`/course/show/${course?.id}`} target="_blank">
+            {course?.title}
+          </Link>
+        </Text>
+
+        <Tag>
+          {intl.formatMessage({
+            id: `auth.role.${currUser.role}`,
+          })}
+        </Tag>
+      </Space>
+
+      <TypeArticle
+        type="article"
+        articleId={articleId}
+        channelId={channelsId}
+        mode={mode}
+        anthologyId={anthologyId}
+        active
+        onArticleChange={(type: ArticleType, id: string, target: string) => {
+          if (type === "article" && courseId && channelId) {
+            onArticleChange?.("textbook", id, target, [
+              { key: "course", value: courseId },
+              { key: "channel", value: channelId },
+            ]);
+          } else {
+            navigate(`/course/show/${courseId}`);
+          }
+        }}
+      />
+    </>
+  );
+};
+
+export default TypeCourseWidget;

+ 198 - 0
dashboard-v6/src/components/article/TypePage.tsx

@@ -0,0 +1,198 @@
+import { useEffect, useState } from "react";
+import { Alert, message } from "antd";
+import { useIntl } from "react-intl";
+
+import { get } from "../../request";
+import type { IPageNavData, IPageNavResponse } from "../../api/Article";
+import type { ArticleMode, ArticleType } from "./Article";
+import { bookName } from "../fts/book_name";
+import TypePali from "./TypePali";
+import NavigateButton from "./NavigateButton";
+import ArticleSkeleton from "./ArticleSkeleton";
+import ErrorResult from "../general/ErrorResult";
+import "./article.css";
+import { fullUrl } from "../../utils";
+import type { ISearchParams } from "../../pages/library/article/show";
+
+interface IParam {
+  articleId?: string;
+  mode?: ArticleMode | null;
+  channelId?: string | null;
+  book?: string | null;
+  para?: string | null;
+}
+interface IWidget {
+  articleId?: string;
+  mode?: ArticleMode | null;
+  channelId?: string | null;
+  focus?: string | null;
+  onArticleChange?: (
+    type: ArticleType,
+    id: string,
+    target: string,
+    param?: ISearchParams[]
+  ) => void;
+  onFinal?: Function;
+  onLoad?: Function;
+}
+const TypePageWidget = ({
+  channelId,
+  articleId,
+  focus,
+  mode = "read",
+  onArticleChange,
+}: IWidget) => {
+  /**
+   * 页面加载
+   * M 缅文页码
+   * P PTS页码
+   * V vri页码
+   * T 泰文页码
+   * O 其他
+   * para 缅文段落号
+   * url 格式 /article/page/M-dīghanikāya-2-10
+   * 书名在 dashboard\src\components\fts\book_name.ts
+   */
+
+  const [paramPali, setParamPali] = useState<IParam>();
+  const [nav, setNav] = useState<IPageNavData>();
+  const [errorCode, setErrorCode] = useState<number>();
+  const [errorMessage, setErrorMessage] = useState<string>();
+  const [pageInfo, setPageInfo] = useState<string>();
+  const [currId, setCurrId] = useState(articleId);
+
+  const intl = useIntl();
+
+  useEffect(() => setCurrId(articleId), [articleId]);
+
+  useEffect(() => {
+    if (typeof currId === "undefined") {
+      return;
+    }
+
+    const pageParam = currId.split("_");
+    if (pageParam.length < 4) {
+      return;
+    }
+    //查询书号
+    const booksId = bookName
+      .filter((value) => value.term === pageParam[1])
+      .map((item) => item.id)
+      .join("_");
+    const url = `/v2/nav-page/${pageParam[0].toUpperCase()}-${booksId}-${
+      pageParam[2]
+    }-${pageParam[3]}`;
+    setPageInfo(
+      `版本:` +
+        intl.formatMessage({
+          id: `labels.page.number.type.` + pageParam[0].toUpperCase(),
+        }) +
+        ` 书名:${pageParam[1]} 卷号:${pageParam[2]} 页码:${pageParam[3]}`
+    );
+    console.log("url", url);
+    get<IPageNavResponse>(url)
+      .then((json) => {
+        if (json.ok) {
+          const data = json.data;
+          setNav(data);
+          const begin = data.curr.paragraph;
+          const end = data.next.paragraph;
+          const para: number[] = [];
+          for (let index = begin; index <= end; index++) {
+            para.push(index);
+          }
+          setParamPali({
+            articleId: `${data.curr.book}-${data.curr.paragraph}`,
+            book: data.curr.book.toString(),
+            para: para.join(),
+            mode: mode,
+            channelId: channelId,
+          });
+        } else {
+          message.error(json.message);
+        }
+      })
+      .finally(() => {})
+      .catch((e) => {
+        console.error(e);
+        setErrorCode(e);
+        if (e === 404) {
+          setErrorMessage(`该页面不存在。页面信息:${pageInfo}`);
+        }
+      });
+  }, [currId, channelId, intl, mode, pageInfo]);
+
+  const seek = (
+    event: React.MouseEvent<HTMLElement, MouseEvent>,
+    page: number
+  ) => {
+    if (typeof currId === "undefined") {
+      return;
+    }
+    const pageParam = currId.split("_");
+    if (pageParam.length < 4) {
+      return;
+    }
+    const id = `${pageParam[0]}_${pageParam[1]}_${pageParam[2]}_${
+      parseInt(pageParam[3]) + page
+    }`;
+    let target = "_self";
+    if (event.ctrlKey || event.metaKey) {
+      target = "_blank";
+    }
+    if (typeof onArticleChange !== "undefined") {
+      onArticleChange("page", id, target);
+    } else {
+      if (target === "_blank") {
+        let url = `/article/page/${id}?mode=${mode}`;
+        if (channelId) {
+          url += `&channel=${channelId}`;
+        }
+        window.open(fullUrl(url), "_blank");
+      } else {
+        setCurrId(id);
+      }
+    }
+  };
+
+  return (
+    <div>
+      {pageInfo ? <Alert title={pageInfo} type="info" closable /> : undefined}
+      {paramPali ? (
+        <>
+          <TypePali
+            type={"para"}
+            hideNav
+            {...paramPali}
+            focus={focus}
+            onArticleChange={(
+              type: ArticleType,
+              id: string,
+              target: string
+            ) => {
+              if (typeof onArticleChange !== "undefined") {
+                onArticleChange(type, id, target);
+              }
+            }}
+          />
+          <NavigateButton
+            prevTitle={nav?.prev.page.toString()}
+            nextTitle={nav?.next.page.toString()}
+            onNext={(event: React.MouseEvent<HTMLElement, MouseEvent>) => {
+              seek(event, 1);
+            }}
+            onPrev={(event: React.MouseEvent<HTMLElement, MouseEvent>) => {
+              seek(event, -1);
+            }}
+          />
+        </>
+      ) : errorCode ? (
+        <ErrorResult code={errorCode} message={errorMessage} />
+      ) : (
+        <ArticleSkeleton />
+      )}
+    </div>
+  );
+};
+
+export default TypePageWidget;

+ 4 - 1
dashboard-v6/src/components/article/TypePali.tsx

@@ -2,7 +2,7 @@
 
 import { Divider, Dropdown, Button, Space, Tag } from "antd";
 import { MoreOutlined } from "@ant-design/icons";
-import { useEffect } from "react";
+import { useEffect, type ReactNode } from "react";
 import type {
   ArticleMode,
   ArticleType,
@@ -31,6 +31,7 @@ interface ISearchParams {
 }
 
 interface IWidget {
+  headerExtra?: ReactNode;
   type?: ArticleType;
   id?: string;
   mode?: ArticleMode | null;
@@ -51,6 +52,7 @@ interface IWidget {
 }
 
 const TypePali = ({
+  headerExtra,
   type,
   id,
   mode = "read",
@@ -158,6 +160,7 @@ const TypePali = ({
 
       <div></div>
       <ArticleHeader
+        header={headerExtra}
         action={
           <Dropdown
             menu={{

+ 38 - 0
dashboard-v6/src/components/article/TypeSeries.tsx

@@ -0,0 +1,38 @@
+import { Typography } from "antd";
+import PaliTextToc from "./PaliTextToc";
+
+const { Title } = Typography;
+
+interface IWidget {
+  articleId?: string;
+  channelId?: string | null;
+  onArticleChange?: Function;
+}
+const TypeSeriesWidget = ({ articleId, onArticleChange }: IWidget) => {
+  return (
+    <div>
+      <Title level={3}>
+        {"丛书:"}
+        {articleId}
+      </Title>
+      <Title level={4}>{"书目列表"}</Title>
+      <PaliTextToc
+        series={articleId}
+        onClick={(
+          id: string,
+          e: React.MouseEvent<HTMLSpanElement, MouseEvent>
+        ) => {
+          if (typeof onArticleChange !== "undefined") {
+            if (e.ctrlKey || e.metaKey) {
+              onArticleChange("chapter", id, "_blank");
+            } else {
+              onArticleChange("chapter", id, "_self");
+            }
+          }
+        }}
+      />
+    </div>
+  );
+};
+
+export default TypeSeriesWidget;

+ 29 - 0
dashboard-v6/src/components/article/TypeTask.tsx

@@ -0,0 +1,29 @@
+import type { ArticleMode, ArticleType } from "./Article";
+import type { ITaskData } from "../../api/task";
+import Task from "../task/Task";
+import { openDiscussion } from "../discussion/DiscussionButton";
+
+interface IWidget {
+  type?: ArticleType;
+  articleId?: string;
+  mode?: ArticleMode | null;
+  channelId?: string | null;
+  onArticleChange?: (data: ITaskData) => void;
+  onLoad?: (data: ITaskData) => void;
+}
+const TypeTask = ({ articleId }: IWidget) => {
+  return (
+    <div>
+      <Task
+        taskId={articleId}
+        onDiscussion={() => {
+          if (articleId) {
+            openDiscussion(articleId, "task", false);
+          }
+        }}
+      />
+    </div>
+  );
+};
+
+export default TypeTask;

+ 2 - 2
dashboard-v6/src/components/article/components/EditableTree.tsx

@@ -135,9 +135,9 @@ interface IWidget {
   addFileButton?: React.ReactNode;
   addOnArticle?: TreeNodeData;
   updatedNode?: TreeNodeData;
-  onChange?: (listTreeData?: ListNodeData[]) => void;
+  onChange?: (listTreeData: ListNodeData[]) => void;
   onSelect?: (selectedKeys: React.Key[]) => void;
-  onSave?: (listTreeData?: ListNodeData[]) => void;
+  onSave?: (listTreeData: ListNodeData[]) => void;
   onAppend?: (parent: TreeNodeData) => Promise<TreeNodeData>;
   onTitleClick?: (
     e: React.MouseEvent<HTMLElement, MouseEvent>,

+ 1 - 1
dashboard-v6/src/components/article/components/TocTree.tsx

@@ -110,7 +110,7 @@ interface IWidgetTocTree {
   treeData?: ListNodeData[];
   expandedKeys?: Key[];
   selectedKeys?: Key[];
-  onSelect?: (selectedId?: string[]) => void;
+  onSelect?: (selectedId: string[]) => void;
   onClick?: (
     selectedId: string,
     e: React.MouseEvent<HTMLSpanElement, MouseEvent>

+ 213 - 0
dashboard-v6/src/components/general/SplitLayout/README.md

@@ -0,0 +1,213 @@
+# SplitLayout
+
+可复用的左右分栏页面框架组件。基于 **React 18 + Ant Design v6 Splitter** 实现。
+
+---
+
+## 设计意图
+
+### 核心问题
+
+右侧内容区是**不确定的、可更换的**,但框架需要在左侧面板收起时,向右侧注入一个"展开按钮"。  
+这是一个跨层通信问题:框架不能侵入右侧组件的内部结构,右侧组件也不应该依赖具体的框架实现。
+
+### 解决方案:Render Props(方案 A)+ Context(方案 B)并存
+
+| 方案 | 适用场景 | 特点 |
+|------|----------|------|
+| **方案 A** Render Props | 右侧组件接受 `expandButton` prop | 明确、类型安全,调用方控制放置位置 |
+| **方案 B** Context Hook | 右侧深层组件自己取按钮 | 解耦、灵活,无需层层透传 |
+
+框架对外同时支持两种用法,调用方按需选择。
+
+### 布局结构
+
+```
+SplitLayout
+├── Context.Provider          ← 提供 collapsed / toggle / expandButton
+├── antd Splitter
+│   ├── Panel left (可拖拽调宽)
+│   │   ├── sidebarHeader
+│   │   │   ├── sidebarTitle  ← 调用方传入(ReactNode)
+│   │   │   └── 收起按钮      ← 始终在标题行右侧,collapsed=false 时可见
+│   │   └── sidebarContent   ← 调用方传入 sidebar
+│   └── Panel right
+│       └── children          ← render props 或普通 ReactNode
+```
+
+### 展开/收起按钮的位置逻辑
+
+```
+collapsed = false(展开状态):
+  左侧面板正常显示
+  sidebarHeader 右侧有「收起」按钮(MenuFoldOutlined)
+  expandButton = null(右侧无需渲染任何东西)
+
+collapsed = true(收起状态):
+  左侧面板宽度 → 0,内容隐藏(display: none)
+  expandButton = 真实按钮节点(MenuUnfoldOutlined)
+  右侧组件决定把它放在哪里(header 角落、工具栏等)
+```
+
+---
+
+## 目录归属
+
+```
+src/components/SplitLayout/
+├── SplitLayout.tsx          # 框架主组件 + Context + Hook
+├── SplitLayout.module.css   # 样式(CSS Modules)
+├── index.ts                 # 统一出口
+└── README.md                # 本文件
+```
+
+> **归属原则**:`components/` — 纯 UI,无业务逻辑,跨项目可复用。  
+> 右侧具体页面属于 `features/` 或 `pages/`,通过 render props 或 `useSplitLayout()` 取得 `expandButton`,自行决定渲染位置。
+
+---
+
+## API
+
+### `<SplitLayout>` Props
+
+| Prop | 类型 | 默认值 | 说明 |
+|------|------|--------|------|
+| `sidebarTitle` | `ReactNode` | — | 左侧面板标题区左侧内容 |
+| `sidebar` | `ReactNode` | — | 左侧面板主体内容 |
+| `children` | `ReactNode \| (ctx) => ReactNode` | — | 右侧内容,支持 render props |
+| `defaultSidebarSize` | `number` | `240` | 左侧面板默认宽度(px) |
+| `minSidebarSize` | `number` | `160` | 左侧面板最小宽度(px) |
+| `maxSidebarSize` | `number` | `480` | 左侧面板最大宽度(px) |
+
+### `useSplitLayout()` 返回值
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `collapsed` | `boolean` | 当前是否已收起 |
+| `toggle` | `() => void` | 切换收起/展开 |
+| `expandButton` | `ReactNode` | 展开按钮节点,`collapsed=false` 时为 `null` |
+
+> ⚠️ `useSplitLayout()` 必须在 `<SplitLayout>` 的子树内调用,否则抛出错误。
+
+---
+
+## 使用示例
+
+### 方案 A:Render Props
+
+右侧组件接受 `expandButton` 作为 prop,自行决定放置位置。  
+**适合**:右侧组件较简单,可以接受外部注入的场景。
+
+```tsx
+// pages/DeployPage.tsx
+import SplitLayout from "@/components/SplitLayout";
+import FileTree from "@/features/deploy/FileTree";
+import ContentArea from "@/features/deploy/ContentArea";
+
+export default function DeployPage() {
+  return (
+    <SplitLayout
+      sidebarTitle="mint / deploy"
+      sidebar={<FileTree />}
+    >
+      {({ expandButton }) => (
+        // expandButton 在收起时是真实按钮,展开时是 null
+        // ContentArea 自己决定把它放在 header 的哪个位置
+        <ContentArea headerExtra={expandButton} />
+      )}
+    </SplitLayout>
+  );
+}
+```
+
+```tsx
+// features/deploy/ContentArea.tsx
+interface ContentAreaProps {
+  headerExtra?: ReactNode;
+}
+
+export default function ContentArea({ headerExtra }: ContentAreaProps) {
+  return (
+    <div>
+      <header>
+        <h2>deploy / group_vars</h2>
+        {/* 收起状态下显示展开按钮,展开状态下此处为空 */}
+        {headerExtra}
+      </header>
+      <main>{/* 内容 */}</main>
+    </div>
+  );
+}
+```
+
+---
+
+### 方案 B:useSplitLayout Hook
+
+右侧深层组件直接从 Context 取按钮,无需层层透传 prop。  
+**适合**:右侧组件层级复杂,或展开按钮需要放在深层子组件的场景。
+
+```tsx
+// pages/DeployPage.tsx
+import SplitLayout from "@/components/SplitLayout";
+import FileTree from "@/features/deploy/FileTree";
+import ComplexContent from "@/features/deploy/ComplexContent";
+
+export default function DeployPage() {
+  return (
+    <SplitLayout
+      sidebarTitle="mint / deploy"
+      sidebar={<FileTree />}
+    >
+      {/* 普通 ReactNode,ComplexContent 内部自己取 */}
+      <ComplexContent />
+    </SplitLayout>
+  );
+}
+```
+
+```tsx
+// features/deploy/ComplexContent.tsx
+import { useSplitLayout } from "@/components/SplitLayout";
+
+export default function ComplexContent() {
+  // 从 Context 直接取,不需要 prop 传递
+  const { expandButton } = useSplitLayout();
+
+  return (
+    <div>
+      <header>
+        {expandButton}
+        <nav>{/* 面包屑等 */}</nav>
+      </header>
+      <DeepChildComponent />
+    </div>
+  );
+}
+```
+
+---
+
+### 方案 A + B 混用
+
+```tsx
+// 某些场景下,既用 render props 传 expandButton,
+// 右侧某个深层组件又需要知道 collapsed 状态
+<SplitLayout sidebarTitle="Files" sidebar={<Tree />}>
+  {({ expandButton }) => (
+    <Layout headerExtra={expandButton}>
+      {/* 这里的 DeepWidget 可以用 useSplitLayout() 取 collapsed */}
+      <DeepWidget />
+    </Layout>
+  )}
+</SplitLayout>
+```
+
+---
+
+## 注意事项
+
+1. **antd v6**:`Splitter` 是 v5.21+ 引入的组件,请确认版本 ≥ 5.21 或已使用 v6。
+2. **高度**:`SplitLayout` 自身不设定高度,父容器需提供明确高度(如 `height: 100vh` 或 `height: 100%`)。
+3. **CSS Modules**:样式通过 CSS Modules 隔离,不会污染全局。框架内的 antd token 变量(`--ant-color-*`)会自动跟随 `ConfigProvider` 的主题。
+4. **收起时宽度**:`size={0}` + `display: none` 双重保护,避免内容溢出影响布局。

+ 82 - 0
dashboard-v6/src/components/general/SplitLayout/SplitLayout.module.css

@@ -0,0 +1,82 @@
+/* ─────────────────────────────────────────────
+   SplitLayout.module.css
+   ───────────────────────────────────────────── */
+
+/* 整体 Splitter 容器:撑满父元素 */
+.splitter {
+  width: 100%;
+  height: 100%;
+}
+
+/* ── 左侧面板 ── */
+.leftPanel {
+  overflow: hidden;
+  /* 收起动画:宽度过渡由 antd Splitter 控制,
+     此处只做内容淡出保护 */
+  transition: opacity 0.2s ease;
+}
+
+/* 左侧面板内部竖向 flex 容器 */
+.sidebarInner {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  overflow: hidden;
+}
+
+/* 标题行 */
+.sidebarHeader {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 8px 8px 8px 12px;
+  flex-shrink: 0;
+  border-bottom: 1px solid var(--ant-color-split, #f0f0f0);
+  min-height: 40px;
+}
+
+.sidebarTitle {
+  font-size: 14px;
+  font-weight: 600;
+  color: var(--ant-color-text, rgba(0, 0, 0, 0.88));
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  flex: 1;
+}
+
+/* 收起按钮:标题行右侧 */
+.collapseBtn {
+  flex-shrink: 0;
+  color: var(--ant-color-text-secondary, rgba(0, 0, 0, 0.45));
+}
+
+.collapseBtn:hover {
+  color: var(--ant-color-primary, #1677ff) !important;
+  background: var(--ant-color-primary-bg, #e6f4ff) !important;
+}
+
+/* 侧边栏主体,允许滚动 */
+.sidebarContent {
+  flex: 1;
+  overflow-y: auto;
+  overflow-x: hidden;
+}
+
+/* ── 右侧面板 ── */
+.rightPanel {
+  overflow: hidden;
+  position: relative;
+}
+
+/* ── 展开按钮(右侧使用)── */
+/* 框架提供默认样式;右侧组件可自行覆盖 */
+.expandBtn {
+  color: var(--ant-color-text-secondary, rgba(0, 0, 0, 0.45));
+  border-radius: 4px;
+}
+
+.expandBtn:hover {
+  color: var(--ant-color-primary, #1677ff) !important;
+  background: var(--ant-color-primary-bg, #e6f4ff) !important;
+}

+ 123 - 0
dashboard-v6/src/components/general/SplitLayout/SplitLayout.tsx

@@ -0,0 +1,123 @@
+import { useCallback, useState, type ReactNode } from "react";
+import { Button, Splitter } from "antd";
+import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
+import styles from "./SplitLayout.module.css";
+import {
+  SplitLayoutContext,
+  type SplitLayoutContextValue,
+} from "./SplitLayoutContext";
+
+// ─────────────────────────────────────────────
+// Props
+// ─────────────────────────────────────────────
+
+export interface SplitLayoutProps {
+  /** 左侧面板标题区域(左侧),支持任意 ReactNode */
+  sidebarTitle: ReactNode;
+  /** 左侧面板内容 */
+  sidebar: ReactNode;
+  /**
+   * 右侧内容。
+   *
+   * 支持两种用法:
+   *
+   * 1. Render Props(方案 A)—— 框架直接把 expandButton 传入:
+   *    ```tsx
+   *    <SplitLayout ...>
+   *      {({ expandButton }) => <MyPage headerExtra={expandButton} />}
+   *    </SplitLayout>
+   *    ```
+   *
+   * 2. 普通 ReactNode(方案 B)—— 右侧组件自己调用 useSplitLayout():
+   *    ```tsx
+   *    <SplitLayout ...>
+   *      <ComplexPage />
+   *    </SplitLayout>
+   *    ```
+   */
+  children:
+    | ReactNode
+    | ((ctx: Pick<SplitLayoutContextValue, "expandButton">) => ReactNode);
+  /** 左侧面板默认宽度(px),默认 240 */
+  defaultSidebarSize?: number;
+  /** 左侧面板最小宽度(px),默认 160 */
+  minSidebarSize?: number;
+  /** 左侧面板最大宽度(px),默认 480 */
+  maxSidebarSize?: number;
+}
+
+// ─────────────────────────────────────────────
+// Component
+// ─────────────────────────────────────────────
+
+export default function SplitLayout({
+  sidebarTitle,
+  sidebar,
+  children,
+  defaultSidebarSize = 240,
+  minSidebarSize = 160,
+  maxSidebarSize = 480,
+}: SplitLayoutProps) {
+  const [collapsed, setCollapsed] = useState(false);
+
+  const toggle = useCallback(() => setCollapsed((v) => !v), []);
+
+  // 展开按钮:仅在收起状态下渲染真实节点
+  const expandButton = collapsed ? (
+    <Button
+      type="text"
+      size="small"
+      icon={<MenuUnfoldOutlined />}
+      onClick={toggle}
+      className={styles.expandBtn}
+      title="展开侧边栏"
+    />
+  ) : null;
+
+  const ctx: SplitLayoutContextValue = { collapsed, toggle, expandButton };
+
+  // 右侧内容:支持 render props 和普通 ReactNode 两种形式
+  const rightContent =
+    typeof children === "function" ? children({ expandButton }) : children;
+
+  return (
+    <SplitLayoutContext.Provider value={ctx}>
+      <Splitter className={styles.splitter}>
+        {/* ── 左侧面板 ── */}
+        <Splitter.Panel
+          size={collapsed ? 0 : defaultSidebarSize}
+          min={collapsed ? 0 : minSidebarSize}
+          max={maxSidebarSize}
+          className={styles.leftPanel}
+          collapsible
+        >
+          <div
+            className={styles.sidebarInner}
+            style={{ display: collapsed ? "none" : "flex" }}
+          >
+            {/* 标题行:左侧 title,右侧收起按钮 */}
+            <div className={styles.sidebarHeader}>
+              <span className={styles.sidebarTitle}>{sidebarTitle}</span>
+              <Button
+                type="text"
+                size="small"
+                icon={<MenuFoldOutlined />}
+                onClick={toggle}
+                className={styles.collapseBtn}
+                title="收起侧边栏"
+              />
+            </div>
+
+            {/* 侧边栏主体内容 */}
+            <div className={styles.sidebarContent}>{sidebar}</div>
+          </div>
+        </Splitter.Panel>
+
+        {/* ── 右侧面板 ── */}
+        <Splitter.Panel className={styles.rightPanel}>
+          {rightContent}
+        </Splitter.Panel>
+      </Splitter>
+    </SplitLayoutContext.Provider>
+  );
+}

+ 20 - 0
dashboard-v6/src/components/general/SplitLayout/SplitLayoutContext.ts

@@ -0,0 +1,20 @@
+import { createContext, useContext } from "react";
+import type { ReactNode } from "react";
+
+export interface SplitLayoutContextValue {
+  collapsed: boolean;
+  toggle: () => void;
+  expandButton: ReactNode;
+}
+
+export const SplitLayoutContext = createContext<SplitLayoutContextValue | null>(
+  null
+);
+
+export function useSplitLayout(): SplitLayoutContextValue {
+  const ctx = useContext(SplitLayoutContext);
+  if (!ctx) {
+    throw new Error("useSplitLayout must be used within <SplitLayout>");
+  }
+  return ctx;
+}

+ 277 - 0
dashboard-v6/src/components/general/SplitLayout/SplitLayoutTest.tsx

@@ -0,0 +1,277 @@
+import { FileOutlined, FolderOutlined } from "@ant-design/icons";
+import { Segmented, Tree, Typography } from "antd";
+import type { TreeDataNode } from "antd";
+import { useState, type ReactNode } from "react";
+import SplitLayout from "./SplitLayout";
+import { useSplitLayout } from "./SplitLayoutContext";
+
+// ─────────────────────────────────────────────
+// 模拟文件树数据
+// ─────────────────────────────────────────────
+
+const treeData: TreeDataNode[] = [
+  {
+    title: "group_vars",
+    key: "group_vars",
+    icon: <FolderOutlined />,
+    children: [
+      { title: "all.yml", key: "group_vars/all.yml", icon: <FileOutlined /> },
+      {
+        title: "production.yml",
+        key: "group_vars/production.yml",
+        icon: <FileOutlined />,
+      },
+    ],
+  },
+  {
+    title: "roles",
+    key: "roles",
+    icon: <FolderOutlined />,
+    children: [
+      {
+        title: "common",
+        key: "roles/common",
+        icon: <FolderOutlined />,
+        children: [
+          {
+            title: "tasks",
+            key: "roles/common/tasks",
+            icon: <FolderOutlined />,
+            children: [
+              {
+                title: "main.yml",
+                key: "roles/common/tasks/main.yml",
+                icon: <FileOutlined />,
+              },
+            ],
+          },
+        ],
+      },
+    ],
+  },
+  {
+    title: "scripts",
+    key: "scripts",
+    icon: <FolderOutlined />,
+    children: [
+      { title: "deploy.sh", key: "scripts/deploy.sh", icon: <FileOutlined /> },
+      {
+        title: "rollback.sh",
+        key: "scripts/rollback.sh",
+        icon: <FileOutlined />,
+      },
+    ],
+  },
+  {
+    title: "staging",
+    key: "staging",
+    icon: <FolderOutlined />,
+    children: [
+      {
+        title: "inventory.yml",
+        key: "staging/inventory.yml",
+        icon: <FileOutlined />,
+      },
+    ],
+  },
+  { title: ".gitignore", key: ".gitignore", icon: <FileOutlined /> },
+  { title: "ansible.cfg", key: "ansible.cfg", icon: <FileOutlined /> },
+];
+
+const fileContent = `# group_vars/all.yml
+ansible_user: deploy
+ansible_ssh_private_key_file: ~/.ssh/id_rsa
+
+app_name: mint
+app_env: production
+app_port: 8080
+
+docker_registry: registry.example.com
+docker_image: "{{ app_name }}:{{ app_version }}"
+
+workers:
+  ai_translate:
+    replicas: 3
+    image: mint-translate-worker
+    env:
+      MODEL: gpt-4o
+      CONCURRENCY: 4`;
+
+// ─────────────────────────────────────────────
+// 共用样式
+// ─────────────────────────────────────────────
+
+const headerStyle: React.CSSProperties = {
+  display: "flex",
+  alignItems: "center",
+  gap: 8,
+  padding: "8px 16px",
+  borderBottom: "1px solid var(--ant-color-split, #f0f0f0)",
+  minHeight: 40,
+  flexShrink: 0,
+};
+
+const preStyle: React.CSSProperties = {
+  background: "var(--ant-color-fill-quaternary, #f5f5f5)",
+  borderRadius: 6,
+  padding: 16,
+  fontSize: 13,
+  lineHeight: 1.7,
+  overflow: "auto",
+};
+
+// ─────────────────────────────────────────────
+// 模拟侧边栏
+// ─────────────────────────────────────────────
+
+function MockSidebar() {
+  return (
+    <Tree
+      showIcon
+      defaultExpandedKeys={["group_vars", "roles"]}
+      treeData={treeData}
+      style={{ padding: "8px 4px" }}
+    />
+  );
+}
+
+// ─────────────────────────────────────────────
+// 方案 A:MockContentA
+// expandButton 由外部(render props)注入,组件本身无框架依赖
+// ─────────────────────────────────────────────
+
+interface MockContentAProps {
+  headerExtra?: ReactNode;
+}
+
+function MockContentA({ headerExtra }: MockContentAProps) {
+  return (
+    <div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
+      <div style={headerStyle}>
+        {headerExtra}
+        <Typography.Text type="secondary" style={{ fontSize: 13 }}>
+          mint / deploy /
+        </Typography.Text>
+        <Typography.Text strong style={{ fontSize: 13 }}>
+          group_vars
+        </Typography.Text>
+        <Typography.Text
+          type="secondary"
+          style={{ marginLeft: "auto", fontSize: 11 }}
+        >
+          方案 A · Render Props
+        </Typography.Text>
+      </div>
+      <div style={{ flex: 1, padding: 24, overflow: "auto" }}>
+        <Typography.Title level={5} style={{ marginTop: 0 }}>
+          all.yml
+        </Typography.Title>
+        <Typography.Paragraph type="secondary" style={{ fontSize: 12 }}>
+          Last commit: <strong>add multi ai-translate worker support</strong> ·
+          11 months ago
+        </Typography.Paragraph>
+        <pre style={preStyle}>{fileContent}</pre>
+      </div>
+    </div>
+  );
+}
+
+// ─────────────────────────────────────────────
+// 方案 B:MockContentB
+// 自己调用 useSplitLayout() 取 expandButton,无需外部注入
+// ─────────────────────────────────────────────
+
+function MockContentB() {
+  const { expandButton } = useSplitLayout();
+
+  return (
+    <div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
+      <div style={headerStyle}>
+        {expandButton}
+        <Typography.Text type="secondary" style={{ fontSize: 13 }}>
+          mint / deploy /
+        </Typography.Text>
+        <Typography.Text strong style={{ fontSize: 13 }}>
+          group_vars
+        </Typography.Text>
+        <Typography.Text
+          type="secondary"
+          style={{ marginLeft: "auto", fontSize: 11 }}
+        >
+          方案 B · useSplitLayout Hook
+        </Typography.Text>
+      </div>
+      <div style={{ flex: 1, padding: 24, overflow: "auto" }}>
+        <Typography.Title level={5} style={{ marginTop: 0 }}>
+          all.yml
+        </Typography.Title>
+        <Typography.Paragraph type="secondary" style={{ fontSize: 12 }}>
+          Last commit: <strong>add multi ai-translate worker support</strong> ·
+          11 months ago
+        </Typography.Paragraph>
+        <pre style={preStyle}>{fileContent}</pre>
+      </div>
+    </div>
+  );
+}
+
+// ─────────────────────────────────────────────
+// 测试入口
+// ─────────────────────────────────────────────
+
+type Mode = "A" | "B";
+
+export default function SplitLayoutTest() {
+  const [mode, setMode] = useState<Mode>("A");
+
+  return (
+    <div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
+      {/* 顶部切换条 */}
+      <div
+        style={{
+          display: "flex",
+          alignItems: "center",
+          gap: 12,
+          padding: "8px 16px",
+          borderBottom: "1px solid var(--ant-color-split, #f0f0f0)",
+          flexShrink: 0,
+        }}
+      >
+        <Typography.Text type="secondary" style={{ fontSize: 13 }}>
+          SplitLayout 测试
+        </Typography.Text>
+        <Segmented<Mode>
+          size="small"
+          value={mode}
+          onChange={setMode}
+          options={[
+            { label: "方案 A · Render Props", value: "A" },
+            { label: "方案 B · Hook", value: "B" },
+          ]}
+        />
+      </div>
+
+      {/* 方案 A:children 是函数,框架把 expandButton 作为参数传入 */}
+      {mode === "A" && (
+        <SplitLayout
+          key="mode-a"
+          sidebarTitle="mint / deploy"
+          sidebar={<MockSidebar />}
+        >
+          {({ expandButton }) => <MockContentA headerExtra={expandButton} />}
+        </SplitLayout>
+      )}
+
+      {/* 方案 B:children 是普通 ReactNode,MockContentB 自己取 expandButton */}
+      {mode === "B" && (
+        <SplitLayout
+          key="mode-b"
+          sidebarTitle="mint / deploy"
+          sidebar={<MockSidebar />}
+        >
+          <MockContentB />
+        </SplitLayout>
+      )}
+    </div>
+  );
+}

+ 4 - 0
dashboard-v6/src/components/general/SplitLayout/index.ts

@@ -0,0 +1,4 @@
+export { default } from "./SplitLayout";
+export { useSplitLayout } from "./SplitLayoutContext";
+export type { SplitLayoutProps } from "./SplitLayout";
+export type { SplitLayoutContextValue } from "./SplitLayoutContext";

+ 7 - 4
dashboard-v6/src/components/group/AddMember.tsx

@@ -27,10 +27,13 @@ const AddMemberWidget = ({ groupId, onCreated }: IWidget) => {
       onFinish={async (values: IFormData) => {
         console.log(values);
         if (typeof groupId !== "undefined") {
-          post<IGroupMemberRequest, IGroupMemberResponse>("/v2/group-member", {
-            user_id: values.userId,
-            group_id: groupId,
-          }).then((json) => {
+          post<IGroupMemberRequest, IGroupMemberResponse>(
+            "/api/v2/group-member",
+            {
+              user_id: values.userId,
+              group_id: groupId,
+            }
+          ).then((json) => {
             console.log("add member", json);
             if (json.ok) {
               message.success(intl.formatMessage({ id: "flashes.success" }));

+ 2 - 2
dashboard-v6/src/components/group/GroupMember.tsx

@@ -60,7 +60,7 @@ const GroupMemberWidget = ({ groupId }: IWidgetGroupFile) => {
         request={async (params = {}, sorter, filter) => {
           console.log(params, sorter, filter);
 
-          let url = `/v2/group-member?view=group&id=${groupId}`;
+          let url = `/api/v2/group-member?view=group&id=${groupId}`;
           const offset =
             ((params.current ? params.current : 1) - 1) *
             (params.pageSize ? params.pageSize : 20);
@@ -157,7 +157,7 @@ const GroupMemberWidget = ({ groupId }: IWidgetGroupFile) => {
                   onConfirm={() => {
                     console.log("delete", row.id);
                     delete_<IGroupMemberDeleteResponse>(
-                      "/v2/group-member/" + row.id
+                      "/api/v2/group-member/" + row.id
                     ).then((json) => {
                       if (json.ok) {
                         console.log("delete ok");

+ 7 - 10
dashboard-v6/src/components/navigation/MainMenu.tsx

@@ -4,6 +4,7 @@ import {
   HomeOutlined,
   FieldTimeOutlined,
   FolderOutlined,
+  FileOutlined,
 } from "@ant-design/icons";
 import { useNavigate, useMatches, type UIMatch } from "react-router";
 import {
@@ -129,24 +130,20 @@ const items: MenuItem[] = [
   },
 
   {
-    key: "/workspace/articles",
+    key: "/workspace/articles/",
     icon: <DocumentIcon />,
     label: "文章",
     children: [
       {
-        key: "/workspace/articles/uncategorized",
-        label: "未分类",
-        icon: <FolderOutlined />,
+        key: "/workspace/articles",
+        label: "全部文章",
+        icon: <FileOutlined />,
       },
       {
-        key: "/workspace/articles/angl",
-        label: "文集1",
+        key: "/workspace/anthology",
+        label: "文集",
         icon: <FolderOutlined />,
       },
-      {
-        key: "/workspace/articles",
-        label: "ALL",
-      },
     ],
   },
 

+ 3 - 1
dashboard-v6/src/components/tipitaka/ChapterHead.tsx

@@ -18,7 +18,9 @@ const ChapterHeadWidget = (prop: IWidgetPaliChapterHeading) => {
   return (
     <>
       <Title level={4}>
-        <Link to={`/article/chapter/${prop.data.book}-${prop.data.para}`}>
+        <Link
+          to={`/workspace/edit/chapter/${prop.data.book}-${prop.data.para}`}
+        >
           {prop.data.title}
         </Link>
       </Title>

+ 26 - 0
dashboard-v6/src/features/tipitaka/ChapterPage.tsx

@@ -0,0 +1,26 @@
+import type { ArticleMode } from "../../api/Article";
+import TypePali from "../../components/article/TypePali";
+import SplitLayout from "../../components/general/SplitLayout";
+
+interface IWidget {
+  id?: string;
+  mode?: ArticleMode | null;
+  channelId?: string | null;
+}
+const Chapter = ({ id, mode, channelId }: IWidget) => {
+  return (
+    <SplitLayout key="mode-a" sidebarTitle="mint / deploy" sidebar={<></>}>
+      {({ expandButton }) => (
+        <TypePali
+          id={id}
+          type="chapter"
+          mode={mode}
+          channelId={channelId}
+          headerExtra={expandButton}
+        />
+      )}
+    </SplitLayout>
+  );
+};
+
+export default Chapter;

+ 1 - 1
dashboard-v6/src/load.ts

@@ -112,7 +112,7 @@ const init = () => {
       }
     });
 
-    get<IGroupMemberListResponse>("/v2/group-member?view=user").then(
+    get<IGroupMemberListResponse>("/api/v2/group-member?view=user").then(
       (response) => {
         console.log("auth", response);
         if (response.ok) {

+ 13 - 0
dashboard-v6/src/pages/workspace/anthology/anthology.tsx

@@ -0,0 +1,13 @@
+import { useAppSelector } from "../../../hooks";
+import { currentUser } from "../../../reducers/current-user";
+import AnthologyList from "../../../components/anthology/AnthologyList";
+
+const Widget = () => {
+  const user = useAppSelector(currentUser);
+  const studioName = user?.realName;
+
+  console.debug("channel list", studioName);
+  return <AnthologyList studioName={studioName} />;
+};
+
+export default Widget;

+ 11 - 0
dashboard-v6/src/pages/workspace/editor/chapter.tsx

@@ -0,0 +1,11 @@
+import { useParams } from "react-router";
+
+import ChapterPage from "../../../features/tipitaka/ChapterPage";
+
+const Widget = () => {
+  const { id } = useParams();
+  console.log("chapter", id);
+  return <ChapterPage id={id} />;
+};
+
+export default Widget;

+ 8 - 1
dashboard-v6/src/routes/testRoutes.tsx

@@ -16,7 +16,9 @@ const EditableTreeTest = lazy(
 
 const TermTest = lazy(() => import("../components/term/TermTest"));
 const TypePaliTest = lazy(() => import("../components/article/TypePaliTest"));
-
+const SplitLayoutTest = lazy(
+  () => import("../components/general/SplitLayout/SplitLayoutTest")
+);
 // 你可以继续添加更多测试组件
 // const TestButtonDemo = lazy(() => import("../components/button/ButtonDemo"));
 
@@ -50,6 +52,11 @@ export const testRoutes: TestRouteObject[] = [
     label: "EditableTreeTest",
     Component: EditableTreeTest,
   },
+  {
+    path: "SplitLayoutTest",
+    label: "SplitLayoutTest",
+    Component: SplitLayoutTest,
+  },
   {
     path: "editor",
     label: "Editor",