visuddhinanda 4 недель назад
Родитель
Сommit
3d3c3d40a8
59 измененных файлов с 1694 добавлено и 112 удалено
  1. 53 17
      api-v12/app/Http/Api/TemplateRender.php
  2. 12 14
      dashboard-v6/documents/development/v6-todo-list.md
  3. 9 4
      dashboard-v6/src/Router.tsx
  4. 1 1
      dashboard-v6/src/api/Attachments.ts
  5. 1 1
      dashboard-v6/src/api/Comment.ts
  6. 1 1
      dashboard-v6/src/api/Share.ts
  7. 1 1
      dashboard-v6/src/api/fts.ts
  8. 22 0
      dashboard-v6/src/api/group.ts
  9. 1 1
      dashboard-v6/src/api/progress.ts
  10. 22 0
      dashboard-v6/src/api/tag.ts
  11. 13 9
      dashboard-v6/src/components/article/TypePali.tsx
  12. 323 0
      dashboard-v6/src/components/general/DisplayWrapper.tsx
  13. 2 2
      dashboard-v6/src/components/group/AddMember.tsx
  14. 1 1
      dashboard-v6/src/components/group/Group.tsx
  15. 2 2
      dashboard-v6/src/components/group/GroupCreate.tsx
  16. 3 3
      dashboard-v6/src/components/group/GroupFile.tsx
  17. 1 1
      dashboard-v6/src/components/group/GroupMember.tsx
  18. 2 2
      dashboard-v6/src/components/group/GroupSelect.tsx
  19. 76 0
      dashboard-v6/src/components/invite/InviteCreate.tsx
  20. 156 0
      dashboard-v6/src/components/invite/InviteList.tsx
  21. 28 6
      dashboard-v6/src/components/navigation/MainMenu.tsx
  22. 19 15
      dashboard-v6/src/components/sentence/SentRead.tsx
  23. 1 1
      dashboard-v6/src/components/setting/SettingAccount.tsx
  24. 1 1
      dashboard-v6/src/components/share/Collaborator.tsx
  25. 4 6
      dashboard-v6/src/components/tag/TagCreate.tsx
  26. 2 2
      dashboard-v6/src/components/tag/TagList.tsx
  27. 1 1
      dashboard-v6/src/components/tag/TagSelect.tsx
  28. 1 1
      dashboard-v6/src/components/tag/TagSelectButton.tsx
  29. 2 2
      dashboard-v6/src/components/tag/TagShow.tsx
  30. 1 1
      dashboard-v6/src/components/tag/TagsArea.tsx
  31. 4 4
      dashboard-v6/src/components/tag/TagsOnItem.tsx
  32. 4 1
      dashboard-v6/src/components/template/MdTpl.tsx
  33. 31 0
      dashboard-v6/src/components/template/Para.tsx
  34. 1 1
      dashboard-v6/src/components/transfer/TransferCreate.tsx
  35. 2 2
      dashboard-v6/src/components/transfer/TransferList.tsx
  36. 1 1
      dashboard-v6/src/components/wbw/WbwDetail.tsx
  37. 1 1
      dashboard-v6/src/components/wbw/WbwPali.tsx
  38. 1 1
      dashboard-v6/src/components/wbw/WbwSentCtl.tsx
  39. 103 0
      dashboard-v6/src/features/editor/Paragraph.tsx
  40. 81 0
      dashboard-v6/src/features/group/GroupEdit.tsx
  41. 323 0
      dashboard-v6/src/features/group/GroupList.tsx
  42. 39 0
      dashboard-v6/src/features/group/GroupShow.tsx
  43. 15 2
      dashboard-v6/src/hooks/useTipitaka.ts
  44. 1 1
      dashboard-v6/src/load.ts
  45. 12 0
      dashboard-v6/src/pages/workspace/invite/index.tsx
  46. 15 0
      dashboard-v6/src/pages/workspace/tag/edit.tsx
  47. 23 0
      dashboard-v6/src/pages/workspace/tag/index.tsx
  48. 11 0
      dashboard-v6/src/pages/workspace/tag/show.tsx
  49. 16 0
      dashboard-v6/src/pages/workspace/team/edit.tsx
  50. 30 0
      dashboard-v6/src/pages/workspace/team/index.tsx
  51. 20 0
      dashboard-v6/src/pages/workspace/team/show.tsx
  52. 44 0
      dashboard-v6/src/pages/workspace/tipitaka/para.tsx
  53. 15 0
      dashboard-v6/src/pages/workspace/transfer/index.tsx
  54. 1 1
      dashboard-v6/src/reducers/discussion-count.ts
  55. 20 0
      dashboard-v6/src/routes/inviteRoutes.ts
  56. 42 0
      dashboard-v6/src/routes/tagRoutes.ts
  57. 42 0
      dashboard-v6/src/routes/teamRoutes.ts
  58. 14 1
      dashboard-v6/src/routes/tipitakaRoutes.ts
  59. 20 0
      dashboard-v6/src/routes/transferRoutes.ts

+ 53 - 17
api-v12/app/Http/Api/TemplateRender.php

@@ -160,6 +160,9 @@ class TemplateRender
             case 'ai':
                 $result = $this->render_ai();
                 break;
+            case 'para':
+                $result = $this->render_para();
+                break;
             default:
                 if (mb_substr($tpl_name, 0, 4, "UTF-8") === 'Tpl:') {
                     $result = $this->render_tpl($tpl_name);
@@ -190,12 +193,45 @@ class TemplateRender
             ));
             $content = $m->render($content, $this->param);
         }
-        return [
-            'props' => base64_encode(\json_encode(['content' => $content])),
-            'html' => $content,
-            'tag' => 'span',
-            'tpl' => 'tpl',
-        ];
+        $output = [];
+        switch ($this->format) {
+            case 'react':
+                $output = [
+                    'props' => base64_encode(\json_encode(['content' => $content])),
+                    'html' => $content,
+                    'tag' => 'span',
+                    'tpl' => 'tpl',
+                ];
+                break;
+            default:
+                $output = $content;
+                break;
+        }
+        return $output;
+    }
+
+    public function render_para()
+    {
+        $props = [];
+        $props['id'] = $this->get_param($this->param, "id", 1);
+        $props['title'] = $this->get_param($this->param, "title", 2);
+        $props['style'] = $this->get_param($this->param, "style", 3);
+
+        $output = [];
+        switch ($this->format) {
+            case 'react':
+                $output = [
+                    'props' => base64_encode(\json_encode($props)),
+                    'html' => $props['title'],
+                    'tag' => 'span',
+                    'tpl' => 'para',
+                ];
+                break;
+            default:
+                $output = $props['title'];
+                break;
+        }
+        return $output;
     }
 
     public function getTermProps($word, $tag = null, $channel = null)
@@ -960,7 +996,7 @@ class TemplateRender
 
         $sid = $this->get_param($this->param, "id", 1);
         $channel = $this->get_param($this->param, "channel", 2);
-        $text = $this->get_param($this->param, "text", 2, 'both');
+        $show = $this->get_param($this->param, "text", 2, 'both');
 
         if (!empty($channel)) {
             $channels = explode(',', $channel);
@@ -988,7 +1024,7 @@ class TemplateRender
         } else {
             $tpl = "sentedit";
         }
-
+        $props['show'] = $show;
         //输出引用
         $arrSid = explode('-', $sid);
         $bookPara = array_slice($arrSid, 0, 2);
@@ -1027,14 +1063,14 @@ class TemplateRender
                 break;
             case 'prompt':
                 $output = '';
-                if ($text === 'both' || $text === 'origin') {
+                if ($show === 'both' || $show === 'origin') {
                     if (isset($props['origin']) && is_array($props['origin'])) {
                         foreach ($props['origin'] as $key => $value) {
                             $output .= $value['html'];
                         }
                     }
                 }
-                if ($text === 'both' || $text === 'translation') {
+                if ($show === 'both' || $show === 'translation') {
                     if (isset($props['translation']) && is_array($props['translation'])) {
                         foreach ($props['translation'] as $key => $value) {
                             $output .= $value['html'];
@@ -1045,14 +1081,14 @@ class TemplateRender
             case 'html':
                 $output = '';
                 $output .= '<span class="sentence">';
-                if ($text === 'both' || $text === 'origin') {
+                if ($show === 'both' || $show === 'origin') {
                     if (isset($props['origin']) && is_array($props['origin'])) {
                         foreach ($props['origin'] as $key => $value) {
                             $output .= '<span class="origin">' . $value['html'] . '</span>';
                         }
                     }
                 }
-                if ($text === 'both' || $text === 'translation') {
+                if ($show === 'both' || $show === 'translation') {
                     if (isset($props['translation']) && is_array($props['translation'])) {
                         foreach ($props['translation'] as $key => $value) {
                             $output .= '<span class="translation">' . $value['html'] . '</span>';
@@ -1072,7 +1108,7 @@ class TemplateRender
                 break;
             case 'simple':
                 $output = '';
-                if ($text === 'both' || $text === 'origin') {
+                if ($show === 'both' || $show === 'origin') {
                     if (empty($output)) {
                         if (
                             isset($props['origin']) &&
@@ -1085,7 +1121,7 @@ class TemplateRender
                         }
                     }
                 }
-                if ($text === 'both' || $text === 'translation') {
+                if ($show === 'both' || $show === 'translation') {
                     if (
                         isset($props['translation']) &&
                         is_array($props['translation']) &&
@@ -1099,7 +1135,7 @@ class TemplateRender
                 break;
             case 'markdown':
                 $output = '';
-                if ($text === 'both' || $text === 'origin') {
+                if ($show === 'both' || $show === 'origin') {
                     if (
                         $this->options['origin'] === true ||
                         $this->options['origin'] === 'true'
@@ -1111,7 +1147,7 @@ class TemplateRender
                         }
                     }
                 }
-                if ($text === 'both' || $text === 'translation') {
+                if ($show === 'both' || $show === 'translation') {
                     if (
                         $this->options['translation']  === true ||
                         $this->options['translation']  === 'true'
@@ -1125,7 +1161,7 @@ class TemplateRender
                                 $output .= trim($value['html']);
                             }
                         } else {
-                            if ($text === 'translation') {
+                            if ($show === 'translation') {
                                 //无译文用原文代替
                                 if (isset($props['origin']) && is_array($props['origin'])) {
                                     foreach ($props['origin'] as $key => $value) {

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

@@ -8,8 +8,8 @@
 - [x] `/recent/list`=>`workgroup/recent`
 - [x] `/channel/list`=>`workgroup/channel`
 - [ ] `/exp/list` [3]
-- [ ] `/setting` [3]
-- [ ] `/ai/models/list`=>`resources/ai-models` [3]
+- [x] `/setting` [3]
+- [x] `/ai/models/list`=>`resources/ai-models` [3]
 
 ---
 
@@ -17,10 +17,10 @@
 
 #### Task 子模块 [2]
 
-- [ ] `/task/hall`=>`workgroup/task`
-- [ ] `/task/list`=>`workgroup/task`
-- [ ] `/task/projects`=>`workgroup/task`
-- [ ] `/task/workflows`=>`workgroup/task`
+- [x] `/task/hall`=>`workgroup/task`
+- [x] `/task/list`=>`workgroup/task`
+- [x] `/task/projects`=>`workgroup/task`
+- [x] `/task/workflows`=>`workgroup/task`
 
 #### 内容模块
 
@@ -30,15 +30,15 @@
 - [x] `/article/list`=>`workgroup/article`
 - [x] `/anthology/list`=>`workgroup/anthology`
 - [ ] `/attachment/list`=>`resources/attachment`
-- [ ] `/tags/list`=>`resources/tags` [3]
+- [x] `/tags/list`=>`resources/tags` [3]
 
 ---
 
 ### 4️⃣ Collaboration 模块
 
-- [ ] `/group/list`=>`collaboration/team` [3]
-- [ ] `/invite/list`=>`collaboration/invite` [3]
-- [ ] `/transfer/list`=>`collaboration/transfer` [3]
+- [x] `/group/list`=>`collaboration/team` [3]
+- [x] `/invite/list`=>`collaboration/invite` [3]
+- [x] `/transfer/list`=>`collaboration/transfer` [3]
 
 ---
 
@@ -52,9 +52,7 @@
 - [x] "article"
 - [ ] "textbook"
 - [x] "term"
-- [ ] "task"
-- [ ] "chapter"
-- [ ] "para"
+- [x] "chapter"
+- [x] "para"
 - [ ] "series"
 - [ ] "cs-para"
-- [ ] "page"

+ 9 - 4
dashboard-v6/src/Router.tsx

@@ -11,6 +11,10 @@ import articleRoutes from "./routes/articleRoutes";
 import tipitakaRoutes from "./routes/tipitakaRoutes";
 import channelRoutes from "./routes/channelRoutes";
 import termRoutes from "./routes/termRoutes";
+import teamRoutes from "./routes/teamRoutes";
+import inviteRoutes from "./routes/inviteRoutes";
+import transferRoutes from "./routes/transferRoutes";
+import tagRoutes from "./routes/tagRoutes";
 
 const RootLayout = lazy(() => import("./layouts/Root"));
 const AnonymousLayout = lazy(() => import("./layouts/anonymous"));
@@ -84,6 +88,10 @@ const router = createBrowserRouter(
             ...tipitakaRoutes,
             ...channelRoutes,
             ...termRoutes,
+            ...teamRoutes,
+            ...inviteRoutes,
+            ...transferRoutes,
+            ...tagRoutes,
           ],
         },
 
@@ -91,10 +99,7 @@ const router = createBrowserRouter(
         {
           path: "test",
           Component: TestLayout,
-          children: [
-            { index: true },
-            ...buildRouteConfig(testRoutes),
-          ],
+          children: [{ index: true }, ...buildRouteConfig(testRoutes)],
         },
       ],
     },

+ 1 - 1
dashboard-v6/src/api/Attachments.ts

@@ -1,6 +1,6 @@
 import { message } from "antd";
 import { delete_ } from "../request";
-import type { IDeleteResponse } from "./Group";
+import type { IDeleteResponse } from "./group";
 
 export interface IAttachmentRequest {
   id: string;

+ 1 - 1
dashboard-v6/src/api/Comment.ts

@@ -3,7 +3,7 @@
 import type { IUser } from "./Auth";
 import type { TContentType } from "./article";
 import type { TDiscussionType, TResType } from "./discussion";
-import type { ITagMapData } from "./Tag";
+import type { ITagMapData } from "./tag";
 import { get } from "../request";
 
 export interface ICommentRequest {

+ 1 - 1
dashboard-v6/src/api/Share.ts

@@ -1,5 +1,5 @@
 import type { IUser, TRole } from "./Auth";
-import type { IGroup } from "./Group";
+import type { IGroup } from "./group";
 
 export interface IShareRequest {
   res_id: string;

+ 1 - 1
dashboard-v6/src/api/fts.ts

@@ -1,4 +1,4 @@
-import type { ITag } from "./Tag";
+import type { ITag } from "./tag";
 export interface IFtsData {
   book: number;
   paragraph: number;

+ 22 - 0
dashboard-v6/src/api/Group.ts → dashboard-v6/src/api/group.ts

@@ -1,3 +1,5 @@
+import type { LoaderFunctionArgs } from "react-router";
+import { get } from "../request";
 import type { IStudio, IUser, TRole } from "./Auth";
 
 export interface IGroup {
@@ -81,3 +83,23 @@ export interface IDeleteResponse {
   message: string;
   data: number;
 }
+
+export const fetchGroup = (groupId: string): Promise<IGroupResponse> => {
+  return get<IGroupResponse>(`/api/v2/group/${groupId}`);
+};
+
+export async function groupLoader({ params }: LoaderFunctionArgs) {
+  const teamId = params.teamId;
+
+  if (!teamId) {
+    throw new Response("Missing teamId", { status: 400 });
+  }
+
+  const res = await fetchGroup(teamId);
+
+  if (!res.ok) {
+    throw new Response("team not found", { status: 404 });
+  }
+
+  return res.data;
+}

+ 1 - 1
dashboard-v6/src/api/progress.ts

@@ -1,6 +1,6 @@
 import type { IStudio } from "./Auth";
 import type { TChannelType } from "./channel";
-import type { TagNode } from "./Tag";
+import type { TagNode } from "./tag";
 
 export interface IApiResponseChannelListData {
   channel_id: string;

+ 22 - 0
dashboard-v6/src/api/Tag.ts → dashboard-v6/src/api/tag.ts

@@ -1,3 +1,5 @@
+import type { LoaderFunctionArgs } from "react-router";
+import { get } from "../request";
 import type { IStudio, IUser } from "./Auth";
 
 export interface TagNode {
@@ -81,3 +83,23 @@ export interface ITagMapResponseList {
   message: string;
   data: { rows: ITagMapData[]; count: number };
 }
+
+export const fetchTag = (tagId: string): Promise<ITagResponse> => {
+  return get<ITagResponse>(`/api/v2/tag/${tagId}`);
+};
+
+export async function tagLoader({ params }: LoaderFunctionArgs) {
+  const tagId = params.tagId;
+
+  if (!tagId) {
+    throw new Response("Missing tagId", { status: 400 });
+  }
+
+  const res = await fetchTag(tagId);
+
+  if (!res.ok) {
+    throw new Response("tag not found", { status: 404 });
+  }
+
+  return res.data;
+}

+ 13 - 9
dashboard-v6/src/components/article/TypePali.tsx

@@ -96,15 +96,12 @@ const TypePali = ({
   // 派生展示数据
   let title = "";
   if (articleData) {
-    if (type === "chapter") {
-      title = articleData.title_text ?? articleData.title;
-    } else {
-      const chapterId = id?.split("-");
-      title = chapterId
-        ? chapterId.length > 1
-          ? chapterId[1]
-          : "unknown"
-        : "unknown";
+    title = articleData.title_text ?? articleData.title;
+    if (type === "para" && id) {
+      const [, para] = id.split("-");
+      if (para) {
+        title = title + "-" + para;
+      }
     }
   }
 
@@ -198,6 +195,13 @@ const TypePali = ({
         nodes={nodeData.map((item) => {
           return <ParagraphNode initData={item} />;
         })}
+        html={
+          nodeData.length === 0
+            ? articleData?.content
+              ? [articleData?.content]
+              : [""]
+            : [""]
+        }
         loading={loading}
         errorCode={errorCode}
         remains={remains}

+ 323 - 0
dashboard-v6/src/components/general/DisplayWrapper.tsx

@@ -0,0 +1,323 @@
+/**
+ * DisplayWrapper
+ *
+ * 将任意内容包装为可切换显示模式的通用容器组件。
+ *
+ * ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+ * Props
+ * ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+ *
+ *  style       TDisplayStyle   显示模式,默认 "modal"
+ *  title       ReactNode       标题,用于 Modal/Card/Toggle 标题栏,
+ *                              以及 icon/tag/reference/link 的文字内容
+ *  trigger     ReactNode       自定义触发区域内容(modal/popover/link 模式)
+ *                              不传时降级为 title
+ *  href        string          link 模式的跳转地址;
+ *                              modal 模式下 Ctrl/Cmd+Click 时新窗口打开
+ *  icon        ReactNode       icon / tag 模式的图标,不传时降级为 <FileOutlined />
+ *  children    ReactNode       弹出层 / 展开区域内渲染的内容
+ *
+ * ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+ * 显示模式一览
+ * ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+ *
+ *  modal       点击 trigger 弹出 Modal;Ctrl/Cmd+Click 新窗口打开 href    (点击)
+ *  popover     点击 trigger 弹出气泡框,含关闭按钮                        (点击)
+ *  card        常驻卡片,直接渲染 children                               (常驻)
+ *  window      常驻裸 div,无任何装饰,直接渲染 children                  (常驻)
+ *  toggle      折叠面板,点击标题展开 / 收起                              (点击)
+ *  link        纯文字链接,点击新窗口打开 href                            (点击跳转)
+ *  icon        显示图标,hover 弹出 popover                              (hover)
+ *  tag         Antd Tag 带边框,显示 icon + title,hover 弹出 popover     (hover)
+ *  reference   行内文字加虚线下划线,hover 弹出 popover,适合论文引用场景   (hover)
+ *
+ * ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+ * 调用范例
+ * ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+ *
+ * // modal — 点击弹出;Ctrl/Cmd+Click 新窗口打开
+ * <DisplayWrapper style="modal" title="视频标题" href="https://example.com/video">
+ *   <Video src="https://example.com/video.mp4" />
+ * </DisplayWrapper>
+ *
+ * // modal — 自定义 trigger
+ * <DisplayWrapper style="modal" title="视频标题" trigger={<span><PlayCircleOutlined /> 点击播放</span>}>
+ *   <Video src="https://example.com/video.mp4" />
+ * </DisplayWrapper>
+ *
+ * // popover — 点击触发,含关闭按钮
+ * <DisplayWrapper style="popover" title="预览" trigger={<span>点击查看</span>}>
+ *   <Image src="https://example.com/image.png" />
+ * </DisplayWrapper>
+ *
+ * // card — 常驻卡片
+ * <DisplayWrapper style="card" title="视频">
+ *   <Video src="https://example.com/video.mp4" />
+ * </DisplayWrapper>
+ *
+ * // window — 常驻裸容器
+ * <DisplayWrapper style="window">
+ *   <Video src="https://example.com/video.mp4" />
+ * </DisplayWrapper>
+ *
+ * // toggle — 折叠面板
+ * <DisplayWrapper style="toggle" title="点击展开">
+ *   <Video src="https://example.com/video.mp4" />
+ * </DisplayWrapper>
+ *
+ * // link — 点击新窗口跳转
+ * <DisplayWrapper style="link" title="查看原始文件" href="https://example.com/file.pdf" />
+ *
+ * // icon — hover 显示 popover;不传 icon 降级为 <FileOutlined />
+ * <DisplayWrapper style="icon" title="附件说明" icon={<PaperClipOutlined />}>
+ *   <p>这是附件的详细说明内容。</p>
+ * </DisplayWrapper>
+ *
+ * // tag — Antd Tag,hover 显示 popover;不传 icon 降级为 <FileOutlined />
+ * <DisplayWrapper style="tag" title="参考资料" icon={<BookOutlined />}>
+ *   <p>Smith et al., 2024 — Some Paper Title</p>
+ * </DisplayWrapper>
+ *
+ * // reference — 行内虚线下划线,hover 显示 popover,适合论文内文引用
+ * <DisplayWrapper style="reference" title="Smith et al., 2024">
+ *   <p>Some Paper Title, Journal of Examples, Vol. 1, pp. 1–10.</p>
+ * </DisplayWrapper>
+ */
+
+import { useState } from "react";
+import { Button, Card, Collapse, Modal, Popover, Tag, Typography } from "antd";
+import { CloseOutlined, FileOutlined } from "@ant-design/icons";
+
+// ---- Types ----
+
+export type TDisplayStyle =
+  | "modal"
+  | "popover"
+  | "card"
+  | "window"
+  | "toggle"
+  | "link"
+  | "icon"
+  | "tag"
+  | "reference";
+
+export interface IDisplayWrapperProps {
+  /** 显示模式,默认 modal */
+  style?: TDisplayStyle;
+  /** Modal / Card / Toggle 标题栏文字 */
+  title?: React.ReactNode;
+  /** modal / popover / link / icon / tag / reference 的点击/hover 触发区域内容 */
+  trigger?: React.ReactNode;
+  /** link 模式跳转地址;modal 模式 Ctrl+Click 跳转地址 */
+  href?: string;
+  /** icon / tag 模式使用的图标,不传时降级为 <FileOutlined /> */
+  icon?: React.ReactNode;
+  /** 弹出层 / 展开区域内渲染的内容 */
+  children?: React.ReactNode;
+}
+
+// ---- Hover Popover(icon / tag / reference 共用) ----
+
+interface IHoverPopoverProps {
+  triggerNode: React.ReactNode;
+  title?: React.ReactNode;
+  children?: React.ReactNode;
+}
+
+const HoverPopover = ({ triggerNode, title, children }: IHoverPopoverProps) => (
+  <Popover
+    title={title}
+    content={children}
+    styles={{ container: { width: 700 } }}
+    trigger="hover"
+    placement="bottom"
+  >
+    {triggerNode}
+  </Popover>
+);
+
+// ---- Click Popover(原 popover 模式) ----
+
+const ClickPopover = ({ trigger, title, children }: IDisplayWrapperProps) => {
+  const [open, setOpen] = useState(false);
+  return (
+    <Popover
+      title={
+        <div
+          style={{
+            display: "flex",
+            justifyContent: "space-between",
+            alignItems: "center",
+          }}
+        >
+          {title}
+          <Button
+            type="link"
+            size="small"
+            icon={<CloseOutlined />}
+            onClick={() => setOpen(false)}
+          />
+        </div>
+      }
+      content={children}
+      trigger="click"
+      placement="bottom"
+      open={open}
+    >
+      <span onClick={() => setOpen(true)} style={{ cursor: "pointer" }}>
+        {trigger ?? title}
+      </span>
+    </Popover>
+  );
+};
+
+// ---- Modal 模式 ----
+
+const ModalDisplay = ({
+  trigger,
+  title,
+  href,
+  children,
+}: IDisplayWrapperProps) => {
+  const [open, setOpen] = useState(false);
+  return (
+    <>
+      <Typography.Link
+        onClick={(e: React.MouseEvent<HTMLElement>) => {
+          if ((e.ctrlKey || e.metaKey) && href) {
+            window.open(href, "_blank");
+          } else {
+            setOpen(true);
+          }
+        }}
+      >
+        {trigger ?? title}
+      </Typography.Link>
+      <Modal
+        width={800}
+        destroyOnHidden
+        style={{ maxWidth: "90%", top: 20 }}
+        title={title}
+        open={open}
+        onOk={() => setOpen(false)}
+        onCancel={() => setOpen(false)}
+        footer={[]}
+      >
+        {children}
+      </Modal>
+    </>
+  );
+};
+
+// ---- Card 模式 ----
+
+const CardDisplay = ({ title, children }: IDisplayWrapperProps) => (
+  <Card title={title}>{children}</Card>
+);
+
+// ---- Window 模式 ----
+
+const WindowDisplay = ({ children }: IDisplayWrapperProps) => (
+  <div>{children}</div>
+);
+
+// ---- Toggle 模式 ----
+
+const ToggleDisplay = ({ title, children }: IDisplayWrapperProps) => (
+  <Collapse bordered={false}>
+    <Collapse.Panel header={title} key="panel">
+      {children}
+    </Collapse.Panel>
+  </Collapse>
+);
+
+// ---- Link 模式 ----
+
+const LinkDisplay = ({ trigger, title, href }: IDisplayWrapperProps) => (
+  <Typography.Link onClick={() => href && window.open(href, "_blank")}>
+    {trigger ?? title}
+  </Typography.Link>
+);
+
+// ---- Icon 模式(hover popover) ----
+
+const IconDisplay = ({ icon, title, children }: IDisplayWrapperProps) => {
+  const iconNode = icon ?? <FileOutlined />;
+  return (
+    <HoverPopover
+      title={title}
+      triggerNode={
+        <span style={{ cursor: "pointer", fontSize: 16 }}>{iconNode}</span>
+      }
+    >
+      {children}
+    </HoverPopover>
+  );
+};
+
+// ---- Tag 模式(hover popover) ----
+
+const TagDisplay = ({ icon, title, children }: IDisplayWrapperProps) => {
+  const iconNode = icon ?? <FileOutlined />;
+  return (
+    <HoverPopover
+      title={title}
+      triggerNode={
+        <Tag icon={iconNode} style={{ cursor: "pointer" }}>
+          {title}
+        </Tag>
+      }
+    >
+      {children}
+    </HoverPopover>
+  );
+};
+
+// ---- Reference 模式(hover popover) ----
+
+const referenceStyle: React.CSSProperties = {
+  borderBottom: "1px dashed currentColor",
+  cursor: "help",
+  textDecoration: "none",
+  color: "inherit",
+};
+
+const ReferenceDisplay = ({ title, children }: IDisplayWrapperProps) => (
+  <HoverPopover
+    title={title}
+    triggerNode={<span style={referenceStyle}>{title}</span>}
+  >
+    {children}
+  </HoverPopover>
+);
+
+// ---- DisplayWrapper 主组件 ----
+
+export const DisplayWrapper = (props: IDisplayWrapperProps) => {
+  const { style = "modal" } = props;
+
+  switch (style) {
+    case "modal":
+      return <ModalDisplay {...props} />;
+    case "popover":
+      return <ClickPopover {...props} />;
+    case "card":
+      return <CardDisplay {...props} />;
+    case "window":
+      return <WindowDisplay {...props} />;
+    case "toggle":
+      return <ToggleDisplay {...props} />;
+    case "link":
+      return <LinkDisplay {...props} />;
+    case "icon":
+      return <IconDisplay {...props} />;
+    case "tag":
+      return <TagDisplay {...props} />;
+    case "reference":
+      return <ReferenceDisplay {...props} />;
+    default:
+      return null;
+  }
+};
+
+export default DisplayWrapper;

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

@@ -7,7 +7,7 @@ import type { IUserListResponse } from "../../api/Auth";
 import type {
   IGroupMemberRequest,
   IGroupMemberResponse,
-} from "../../api/Group";
+} from "../../api/group";
 import { useState } from "react";
 
 interface IFormData {
@@ -55,7 +55,7 @@ const AddMemberWidget = ({ groupId, onCreated }: IWidget) => {
           request={async ({ keyWords }) => {
             console.log("keyWord", keyWords);
             const json = await get<IUserListResponse>(
-              `/v2/user?view=key&key=${keyWords}`
+              `/api/v2/user?view=key&key=${keyWords}`
             );
             const userList = json.data.rows.map((item) => {
               return {

+ 1 - 1
dashboard-v6/src/components/group/Group.tsx

@@ -1,5 +1,5 @@
 import { Space } from "antd";
-import type { IGroup } from "../../api/Group";
+import type { IGroup } from "../../api/group";
 
 interface IWidget {
   group?: IGroup;

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

@@ -6,7 +6,7 @@ import {
 } from "@ant-design/pro-components";
 import { message } from "antd";
 import { post } from "../../request";
-import type { IGroupRequest, IGroupResponse } from "../../api/Group";
+import type { IGroupRequest, IGroupResponse } from "../../api/group";
 import { useRef } from "react";
 
 interface IFormData {
@@ -29,7 +29,7 @@ const GroupCreateWidget = ({ studio, onCreate }: IWidgetGroupCreate) => {
           return;
         }
         console.log(values);
-        const res = await post<IGroupRequest, IGroupResponse>(`/v2/group`, {
+        const res = await post<IGroupRequest, IGroupResponse>(`/api/v2/group`, {
           name: values.name,
           studio_name: studio,
         });

+ 3 - 3
dashboard-v6/src/components/group/GroupFile.tsx

@@ -4,7 +4,7 @@ import { type ActionType, ProList } from "@ant-design/pro-components";
 import { Space, Tag, Button, Layout, Popconfirm } from "antd";
 import { delete_, get } from "../../request";
 import type { IShareListResponse } from "../../api/Share";
-import type { IDeleteResponse } from "../../api/Group";
+import type { IDeleteResponse } from "../../api/group";
 
 const { Content } = Layout;
 
@@ -76,7 +76,7 @@ const GroupFileWidget = ({ groupId }: IWidget) => {
                   })}
                   onConfirm={() => {
                     console.log("delete", row.id);
-                    delete_<IDeleteResponse>("/v2/share/" + row.id).then(
+                    delete_<IDeleteResponse>("/api/v2/share/" + row.id).then(
                       (json) => {
                         if (json.ok) {
                           console.log("delete ok");
@@ -101,7 +101,7 @@ const GroupFileWidget = ({ groupId }: IWidget) => {
         request={async (params = {}, sorter, filter) => {
           console.log(params, sorter, filter);
 
-          let url = `/v2/share?view=group&id=${groupId}`;
+          let url = `/api/v2/share?view=group&id=${groupId}`;
           const offset =
             ((params.current ? params.current : 1) - 1) *
             (params.pageSize ? params.pageSize : 20);

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

@@ -8,7 +8,7 @@ import { delete_, get } from "../../request";
 import type {
   IGroupMemberDeleteResponse,
   IGroupMemberListResponse,
-} from "../../api/Group";
+} from "../../api/group";
 import User from "../auth/User";
 import type { IUser } from "../../api/Auth";
 

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

@@ -2,7 +2,7 @@ import { ProFormSelect } from "@ant-design/pro-components";
 import { useIntl } from "react-intl";
 
 import { get } from "../../request";
-import type { IGroupListResponse } from "../../api/Group";
+import type { IGroupListResponse } from "../../api/group";
 
 interface IWidget {
   name?: string;
@@ -29,7 +29,7 @@ const GroupSelectWidget = ({
         mode: multiple ? "multiple" : undefined,
       }}
       request={async ({ keyWords }) => {
-        const url = `/v2/group?view=all&search=${keyWords}`;
+        const url = `/api/v2/group?view=all&search=${keyWords}`;
         console.log("group keyWord", url);
         const json = await get<IGroupListResponse>(url);
         const userList = json.data.rows.map((item) => {

+ 76 - 0
dashboard-v6/src/components/invite/InviteCreate.tsx

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

+ 156 - 0
dashboard-v6/src/components/invite/InviteList.tsx

@@ -0,0 +1,156 @@
+import { useIntl } from "react-intl";
+import { Button, Popover } from "antd";
+import { type ActionType, ProTable } from "@ant-design/pro-components";
+import { UserAddOutlined } from "@ant-design/icons";
+
+import { get } from "../../request";
+import { RoleValueEnum } from "../studio/table";
+
+import { useRef, useState } from "react";
+import InviteCreate from "./InviteCreate";
+import { getSorterUrl } from "../../utils";
+import type { IInviteListResponse } from "../../api/Auth";
+
+interface DataItem {
+  sn: number;
+  id: string;
+  email: string;
+  status: string;
+  created_at: string;
+}
+
+interface IWidget {
+  studioName?: string;
+}
+
+const InviteListWidget = ({ studioName }: IWidget) => {
+  const intl = useIntl(); //i18n
+  const [openCreate, setOpenCreate] = useState(false);
+
+  const ref = useRef<ActionType | null>(null);
+
+  return (
+    <>
+      <ProTable<DataItem>
+        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.email.label",
+            }),
+            dataIndex: "email",
+            key: "email",
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.status.label",
+            }),
+            dataIndex: "status",
+            key: "status",
+            width: 100,
+            search: false,
+            filters: true,
+            onFilter: true,
+            valueEnum: RoleValueEnum(),
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.created-at.label",
+            }),
+            key: "created_at",
+            width: 100,
+            search: false,
+            dataIndex: "created_at",
+            valueType: "date",
+          },
+        ]}
+        request={async (params = {}, sorter, filter) => {
+          console.log(params, sorter, filter);
+          let url = `/api/v2/invite?`;
+          if (studioName) {
+            url += `view=studio&studio=${studioName}`;
+          } else {
+            url += `view=all`;
+          }
+          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);
+
+          console.log(url);
+          const res = await get<IInviteListResponse>(url);
+          const items: DataItem[] = res.data.rows.map((item, id) => {
+            return {
+              sn: id + offset + 1,
+              id: item.id,
+              email: item.email,
+              status: item.status,
+              created_at: item.created_at,
+            };
+          });
+          console.log(items);
+          return {
+            total: res.data.count,
+            succcess: true,
+            data: items,
+          };
+        }}
+        rowKey="id"
+        bordered
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+        }}
+        search={false}
+        options={{
+          search: true,
+        }}
+        toolBarRender={
+          studioName
+            ? () => [
+                <Popover
+                  content={
+                    <InviteCreate
+                      studio={studioName}
+                      onCreate={() => {
+                        setOpenCreate(false);
+                        ref.current?.reload();
+                      }}
+                    />
+                  }
+                  placement="bottomRight"
+                  trigger="click"
+                  open={openCreate}
+                  onOpenChange={(open: boolean) => {
+                    setOpenCreate(open);
+                  }}
+                >
+                  <Button
+                    key="button"
+                    icon={<UserAddOutlined />}
+                    type="primary"
+                  >
+                    {intl.formatMessage({ id: "buttons.invite" })}
+                  </Button>
+                </Popover>,
+              ]
+            : undefined
+        }
+      />
+    </>
+  );
+};
+
+export default InviteListWidget;

+ 28 - 6
dashboard-v6/src/components/navigation/MainMenu.tsx

@@ -247,19 +247,41 @@ const Widget = ({ onSearch }: Props) => {
       label: "tools",
       children: [
         {
-          key: "/workspace/tools/tag",
+          key: "/workspace/tag",
           label: "tag",
-          activeId: "workspace.tools.tag",
+          activeId: "workspace.tag",
         },
         {
-          key: "/workspace/tools/drive",
+          key: "/workspace/drive",
           label: "drive",
-          activeId: "workspace.tools.drive",
+          activeId: "workspace.drive",
         },
         {
-          key: "/workspace/tools/dict",
+          key: "/workspace/dict",
           label: "dict",
-          activeId: "workspace.tools.dict",
+          activeId: "workspace.dict",
+        },
+      ],
+    },
+    {
+      key: "/workspace/collaboration",
+      icon: <CourseOutLinedIcon />,
+      label: "collaboration",
+      children: [
+        {
+          key: "/workspace/team",
+          label: "team",
+          activeId: "workspace.team",
+        },
+        {
+          key: "/workspace/invite",
+          label: "invite",
+          activeId: "workspace.invite",
+        },
+        {
+          key: "/workspace/transfer",
+          label: "transfer",
+          activeId: "workspace.transfer",
         },
       ],
     },

+ 19 - 15
dashboard-v6/src/components/sentence/SentRead.tsx

@@ -39,6 +39,7 @@ export interface IWidgetSentReadFrame {
   translation?: ISentence[];
   commentaries?: ISentence[];
   layout?: "row" | "column";
+  show?: "origin" | "translation" | "both";
   error?: string;
 }
 
@@ -50,6 +51,7 @@ const SentReadFrame = ({
   para,
   wordStart,
   wordEnd,
+  show = "both",
   error,
 }: IWidgetSentReadFrame) => {
   const layoutDirection = useSetting("setting.layout.direction");
@@ -124,20 +126,22 @@ const SentReadFrame = ({
           style={{ flex: 5 }}
         >
           {/* 原文 */}
-          <span
-            style={{
-              flex: 5,
-              color: "#9f3a01",
-              display:
-                displayOriginal === false && translation?.length
-                  ? "none"
-                  : "block",
-            }}
-          >
-            {origin?.map((item, id) => (
-              <MdOrigin text={item.html} key={id} />
-            ))}
-          </span>
+          {(show === "both" || show === "origin") && (
+            <span
+              style={{
+                flex: 5,
+                color: "#9f3a01",
+                display:
+                  displayOriginal === false && translation?.length
+                    ? "none"
+                    : "block",
+              }}
+            >
+              {origin?.map((item, id) => (
+                <MdOrigin text={item.html} key={id} />
+              ))}
+            </span>
+          )}
 
           {/* 译文 */}
           <span className="sent_read" style={{ flex: 5 }}>
@@ -152,7 +156,7 @@ const SentReadFrame = ({
                     onClick: (e) => handleMenuClick(e.key, item),
                   }}
                 >
-                  {showEdit && <MdTranslation text={item.html} />}
+                  {!showEdit && <MdTranslation text={item.html} />}
                 </Dropdown>
 
                 {/* 编辑面板 */}

+ 1 - 1
dashboard-v6/src/components/setting/SettingAccount.tsx

@@ -13,7 +13,7 @@ import type { IUserRequest, IUserResponse } from "../../api/Auth";
 import type { UploadFile } from "antd/es/upload/interface";
 import { get as getToken } from "../../reducers/current-user";
 import type { IAttachmentResponse } from "../../api/Attachments";
-import type { IDeleteResponse } from "../../api/Group";
+import type { IDeleteResponse } from "../../api/group";
 
 interface IAccount {
   id: string;

+ 1 - 1
dashboard-v6/src/components/share/Collaborator.tsx

@@ -16,7 +16,7 @@ import User from "../auth/User";
 import type { IUser, TRole } from "../../api/Auth";
 
 import Group from "../group/Group";
-import type { IGroup } from "../../api/Group";
+import type { IGroup } from "../../api/group";
 
 interface ICollaborator {
   sn?: number;

+ 4 - 6
dashboard-v6/src/components/tag/TagCreate.tsx

@@ -7,9 +7,9 @@ import {
 } from "@ant-design/pro-components";
 import { Tag, message } from "antd";
 
-import { get, post, put } from "../../request";
+import { post, put } from "../../request";
 import { useRef } from "react";
-import type { ITagRequest, ITagResponse } from "../../api/Tag";
+import { fetchTag, type ITagRequest, type ITagResponse } from "../../api/tag";
 
 interface IWidgetCourseCreate {
   studio?: string;
@@ -52,7 +52,7 @@ const TagCreateWidget = ({ studio, tagId, onCreate }: IWidgetCourseCreate) => {
         console.log(values);
         if (studio) {
           values.studio = studio;
-          let url = `/v2/tag`;
+          let url = `/api/v2/tag`;
           if (tagId) {
             url += `/${tagId}`;
           }
@@ -81,9 +81,7 @@ const TagCreateWidget = ({ studio, tagId, onCreate }: IWidgetCourseCreate) => {
       request={
         tagId
           ? async () => {
-              const url = `/v2/tag/${tagId}`;
-              console.info("api request", url);
-              const res = await get<ITagResponse>(url);
+              const res = await fetchTag(tagId);
               console.info("api response", res);
               return res.data;
             }

+ 2 - 2
dashboard-v6/src/components/tag/TagList.tsx

@@ -2,7 +2,7 @@ import { type ActionType, ProList } from "@ant-design/pro-components";
 import { Button, Popover, Tag } from "antd";
 import { PlusOutlined } from "@ant-design/icons";
 
-import type { ITagData, ITagResponseList } from "../../api/Tag";
+import type { ITagData, ITagResponseList } from "../../api/tag";
 import { getSorterUrl, numToHex } from "../../utils";
 import { get } from "../../request";
 import { useRef, useState } from "react";
@@ -63,7 +63,7 @@ const TagsList = ({ studioName, readonly = false, onSelect }: IWidget) => {
       rowKey="name"
       request={async (params = {}, sorter, filter) => {
         console.log(params, sorter, filter);
-        let url = `/v2/tag?view=studio&name=${studioName}`;
+        let url = `/api/v2/tag?view=studio&name=${studioName}`;
         const offset =
           ((params.current ? params.current : 1) - 1) *
           (params.pageSize ? params.pageSize : 20);

+ 1 - 1
dashboard-v6/src/components/tag/TagSelect.tsx

@@ -1,7 +1,7 @@
 import { useState } from "react";
 import { Modal } from "antd";
 import TagList from "./TagList";
-import type { ITagData } from "../../api/Tag";
+import type { ITagData } from "../../api/tag";
 
 interface IWidget {
   studioName?: string;

+ 1 - 1
dashboard-v6/src/components/tag/TagSelectButton.tsx

@@ -5,7 +5,7 @@ import { useAppSelector } from "../../hooks";
 import { courseInfo } from "../../reducers/current-course";
 import { currentUser } from "../../reducers/current-user";
 import TagsManager from "./TagsManager";
-import type { ITagMapData } from "../../api/Tag";
+import type { ITagMapData } from "../../api/tag";
 
 interface IWidget {
   resId?: string;

+ 2 - 2
dashboard-v6/src/components/tag/TagShow.tsx

@@ -1,7 +1,7 @@
 import { type ActionType, ProList } from "@ant-design/pro-components";
 import { Button } from "antd";
 
-import type { ITagMapData, ITagMapResponseList } from "../../api/Tag";
+import type { ITagMapData, ITagMapResponseList } from "../../api/tag";
 import { getSorterUrl } from "../../utils";
 import { get } from "../../request";
 import { useRef } from "react";
@@ -23,7 +23,7 @@ const TagsList = ({ tagId }: IWidget) => {
       rowKey="name"
       request={async (params = {}, sorter, filter) => {
         console.log(params, sorter, filter);
-        let url = `/v2/tag-map?view=items&tag_id=${tagId}`;
+        let url = `/api/v2/tag-map?view=items&tag_id=${tagId}`;
         const offset =
           ((params.current ? params.current : 1) - 1) *
           (params.pageSize ? params.pageSize : pageSize);

+ 1 - 1
dashboard-v6/src/components/tag/TagsArea.tsx

@@ -1,5 +1,5 @@
 import { Badge, Popover, Tag } from "antd";
-import type { ITagMapData } from "../../api/Tag";
+import type { ITagMapData } from "../../api/tag";
 
 import { useAppSelector } from "../../hooks";
 import { tagList } from "../../reducers/discussion-count";

+ 4 - 4
dashboard-v6/src/components/tag/TagsOnItem.tsx

@@ -16,7 +16,7 @@ import type {
   ITagMapRequest,
   ITagMapResponse,
   ITagMapResponseList,
-} from "../../api/Tag";
+} from "../../api/tag";
 import { getSorterUrl, numToHex } from "../../utils";
 import { delete_, get, post } from "../../request";
 import { useRef, useState } from "react";
@@ -62,7 +62,7 @@ const TagsOnItem = ({
                 readonly
                 onSelect={async (record: ITagData) => {
                   //新建记录
-                  const url = "/v2/tag-map";
+                  const url = "/api/v2/tag-map";
                   const data: ITagMapRequest = {
                     table_name: resType,
                     anchor_id: resId,
@@ -108,7 +108,7 @@ const TagsOnItem = ({
       rowKey="name"
       request={async (params = {}, sorter, filter) => {
         console.log(params, sorter, filter);
-        let url = `/v2/tag-map?view=item&studio=${studioName}&res_id=${resId}`;
+        let url = `/api/v2/tag-map?view=item&studio=${studioName}&res_id=${resId}`;
         const offset =
           ((params.current ? params.current : 1) - 1) *
           (params.pageSize ? params.pageSize : 10);
@@ -171,7 +171,7 @@ const TagsOnItem = ({
             <Popconfirm
               title="Delete the tag?"
               onConfirm={async () => {
-                const url = `/v2/tag-map/${entity.id}?course=${courseId}`;
+                const url = `/api/v2/tag-map/${entity.id}?course=${courseId}`;
                 console.log("delete api request", url);
                 try {
                   const json = await delete_<IDeleteResponse>(url);

+ 4 - 1
dashboard-v6/src/components/template/MdTpl.tsx

@@ -3,6 +3,7 @@ import GrammarTermLookup from "./GrammarTermLookup";
 import Mermaid from "./Mermaid";
 import Nissaya from "./Nissaya";
 import Note from "./Note";
+import Para from "./Para";
 import ParaHandle from "./ParaHandle";
 import ParaShell from "./ParaShell";
 import Paragraph from "./Paragraph";
@@ -42,7 +43,7 @@ const Widget = ({ tpl, props, children }: IWidgetMdTpl) => {
       return <Nissaya props={props ? props : ""}>{children}</Nissaya>;
     case "toggle":
       return <Toggle props={props ? props : undefined}>{children}</Toggle>;
-    case "para":
+    case "para_handle":
       return <ParaHandle props={props ? props : ""} />;
     case "mermaid":
       return <Mermaid props={props ? props : ""} />;
@@ -60,6 +61,8 @@ const Widget = ({ tpl, props, children }: IWidgetMdTpl) => {
       return <Paragraph props={props ? props : ""} />;
     case "tpl":
       return <Tpl props={props ? props : ""} />;
+    case "para":
+      return <Para props={props ? props : ""} />;
     default:
       return <>未定义模版({tpl})</>;
   }

+ 31 - 0
dashboard-v6/src/components/template/Para.tsx

@@ -0,0 +1,31 @@
+import {
+  DisplayWrapper,
+  type IDisplayWrapperProps,
+} from "../general/DisplayWrapper";
+import TypePali from "../article/TypePali";
+
+interface IWidget extends IDisplayWrapperProps {
+  id?: string;
+}
+const ParaCtl = (props: IWidget) => {
+  return (
+    <DisplayWrapper {...props}>
+      <TypePali type="para" id={props.id} />
+    </DisplayWrapper>
+  );
+};
+
+interface IWidget {
+  props: string;
+}
+const Widget = ({ props }: IWidget) => {
+  const prop = JSON.parse(atob(props)) as IWidget;
+  console.log(prop);
+  return (
+    <>
+      <ParaCtl {...prop} />
+    </>
+  );
+};
+
+export default Widget;

+ 1 - 1
dashboard-v6/src/components/transfer/TransferCreate.tsx

@@ -71,7 +71,7 @@ const TransferCreateWidget = ({
           new_owner: values.studio,
         };
         const res = await post<ITransferRequest, ITransferCreateResponse>(
-          `/v2/transfer`,
+          `/api/v2/transfer`,
           data
         );
         if (res.ok) {

+ 2 - 2
dashboard-v6/src/components/transfer/TransferList.tsx

@@ -60,7 +60,7 @@ const TransferListWidget = ({ studioName }: IWidget) => {
     const data: ITransferRequest = {
       status: status,
     };
-    put<ITransferRequest, ITransferResponse>(`/v2/transfer/${id}`, data)
+    put<ITransferRequest, ITransferResponse>(`/api/v2/transfer/${id}`, data)
       .then((json) => {
         if (json.ok) {
           ref.current?.reload();
@@ -187,7 +187,7 @@ const TransferListWidget = ({ studioName }: IWidget) => {
           },
         }}
         request={async (params = {}) => {
-          let url = `/v2/transfer?view=studio&name=${studioName}&view2=${activeKey}`;
+          let url = `/api/v2/transfer?view=studio&name=${studioName}&view2=${activeKey}`;
           const offset =
             ((params.current ? params.current : 1) - 1) *
             (params.pageSize ? params.pageSize : 20);

+ 1 - 1
dashboard-v6/src/components/wbw/WbwDetail.tsx

@@ -37,7 +37,7 @@ import { tempSet } from "../../reducers/setting";
 import { PopPlacement } from "./WbwPali";
 import store from "../../store";
 import TagSelectButton from "../tag/TagSelectButton";
-import type { ITagMapData } from "../../api/Tag";
+import type { ITagMapData } from "../../api/tag";
 
 interface IWidget {
   data: IWbw;

+ 1 - 1
dashboard-v6/src/components/wbw/WbwPali.tsx

@@ -26,7 +26,7 @@ import { temp } from "../../reducers/setting";
 import TagsArea from "../tag/TagsArea";
 import type { IStudio } from "../../api/Auth";
 import type { ArticleMode } from "../../api/article";
-import type { ITagMapData } from "../../api/Tag";
+import type { ITagMapData } from "../../api/tag";
 import PaliText from "../general/PaliText";
 import { bookMarkColor } from "./utils";
 import type { IWbw, IWbwAttachment, TWbwDisplayMode } from "../../types/wbw";

+ 1 - 1
dashboard-v6/src/components/wbw/WbwSentCtl.tsx

@@ -37,7 +37,7 @@ import { useWbwStreamProcessor } from "../../hooks/useWbwStreamProcessor";
 import { GetUserSetting } from "../setting/default";
 import type { IDictRequest } from "../../api/dict";
 import { UserWbwPost } from "../dict/utils";
-import type { IDeleteResponse } from "../../api/Group";
+import type { IDeleteResponse } from "../../api/group";
 import Studio from "../auth/Studio";
 import {
   createSnIndexMap,

+ 103 - 0
dashboard-v6/src/features/editor/Paragraph.tsx

@@ -0,0 +1,103 @@
+// ─────────────────────────────────────────────
+// Props
+// ─────────────────────────────────────────────
+
+import { useLocation } from "react-router";
+import type { ArticleMode, ArticleType } from "../../api/article";
+import TypePali, {
+  type ISearchParams,
+} from "../../components/article/TypePali";
+import Editor from "../../components/editor";
+import PaliTextToc from "../../components/tipitaka/PaliTextToc";
+import { useSaveRecent } from "../../hooks/useSaveRecent";
+import type { TTarget } from "../../types";
+import { useEffect } from "react";
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
+
+export interface ChapterEditorProps {
+  chapterId?: string;
+  mode?: ArticleMode;
+  channelId?: string | null;
+
+  // ── 路由事件回调(由 page 层处理导航)──
+  /** 选择了新的 chapter 时触发 */
+  onSelect?: (id: string) => void;
+  onArticleChange?: (
+    type: ArticleType,
+    id: string,
+    target: TTarget,
+    param?: ISearchParams[]
+  ) => void;
+}
+
+// ─────────────────────────────────────────────
+// Component
+// ─────────────────────────────────────────────
+
+export default function ParaEditor({
+  chapterId,
+  mode = "read",
+  channelId,
+  onArticleChange,
+}: ChapterEditorProps) {
+  const [book, para] = chapterId
+    ? chapterId.split("-").map((item) => parseInt(item))
+    : [undefined, undefined];
+  const currUser = useAppSelector(currentUser);
+  const { save } = useSaveRecent();
+  const { search } = useLocation();
+
+  useEffect(() => {
+    if (!currUser?.id || !chapterId) return;
+
+    save({
+      type: "chapter",
+      article_id: chapterId,
+      param: search || undefined,
+    });
+  }, [currUser?.id, chapterId, search, save]);
+
+  return (
+    <Editor
+      sidebarTitle="recent scan"
+      sidebar={
+        <PaliTextToc
+          book={book}
+          para={para}
+          onSelect={(selected) => {
+            if (selected) {
+              onArticleChange?.("chapter", selected[0], "_self");
+            }
+          }}
+        />
+      }
+      articleId={chapterId}
+      articleType="chapter"
+      channelId={channelId}
+      onChannelSelect={(selected) => {
+        if (chapterId) {
+          const channelParams = [
+            {
+              key: "channel",
+              value: selected.map((item) => item.id).join("_"),
+            },
+          ];
+          console.debug("onChannelSelect", channelParams);
+          onArticleChange?.("chapter", chapterId, "_self", channelParams);
+        }
+      }}
+    >
+      {({ expandButton }) => (
+        <TypePali
+          id={chapterId}
+          type="para"
+          mode={mode}
+          channelId={channelId}
+          headerExtra={expandButton}
+          onArticleChange={onArticleChange}
+        />
+      )}
+    </Editor>
+  );
+}

+ 81 - 0
dashboard-v6/src/features/group/GroupEdit.tsx

@@ -0,0 +1,81 @@
+import { useState } from "react";
+import { useIntl } from "react-intl";
+import {
+  ProForm,
+  ProFormText,
+  ProFormTextArea,
+} from "@ant-design/pro-components";
+import { message, Card } from "antd";
+
+import { get, put } from "../../request";
+
+import type { IGroupRequest, IGroupResponse } from "../../api/group";
+
+interface IFormData {
+  id: string;
+  name: string;
+  description: string;
+}
+
+interface IWidget {
+  groupId?: string;
+}
+const GroupEdit = ({ groupId }: IWidget) => {
+  const intl = useIntl();
+
+  const [title, setTitle] = useState("Loading");
+
+  return (
+    <Card title={title}>
+      <ProForm<IFormData>
+        onFinish={async (values: IFormData) => {
+          console.log(values);
+          const res = await put<IGroupRequest, IGroupResponse>(
+            `/api/v2/group/${groupId}`,
+            values
+          );
+          if (res.ok) {
+            message.success(intl.formatMessage({ id: "flashes.success" }));
+          }
+        }}
+        formKey="group_edit"
+        request={async () => {
+          const res = await get<IGroupResponse>(`/api/v2/group/${groupId}`);
+          setTitle(res.data.name);
+          document.title = `${res.data.name}`;
+          return {
+            id: res.data.uid,
+            name: res.data.name,
+            description: res.data.description,
+          };
+        }}
+      >
+        <ProForm.Group>
+          <ProFormText
+            width="md"
+            name="name"
+            required
+            label={intl.formatMessage({ id: "forms.fields.name.label" })}
+            rules={[
+              {
+                required: true,
+              },
+            ]}
+          />
+        </ProForm.Group>
+
+        <ProForm.Group>
+          <ProFormTextArea
+            width="md"
+            name="description"
+            label={intl.formatMessage({
+              id: "forms.fields.description.label",
+            })}
+          />
+        </ProForm.Group>
+      </ProForm>
+    </Card>
+  );
+};
+
+export default GroupEdit;

+ 323 - 0
dashboard-v6/src/features/group/GroupList.tsx

@@ -0,0 +1,323 @@
+import { useIntl } from "react-intl";
+import { Button, Popover, Dropdown, Modal, message } from "antd";
+import { ProTable, type ActionType } from "@ant-design/pro-components";
+import {
+  PlusOutlined,
+  DeleteOutlined,
+  ExclamationCircleOutlined,
+} from "@ant-design/icons";
+
+import { delete_, get } from "../../request";
+
+import GroupCreate from "../../components/group/GroupCreate";
+import { RoleValueEnum } from "../../components/studio/table";
+
+import { useEffect, useRef, useState } from "react";
+
+import StudioName from "../../components/auth/Studio";
+
+import { getSorterUrl } from "../../utils";
+import type { IStudio } from "../../api/Auth";
+import type { IDeleteResponse, IGroupListResponse } from "../../api/group";
+import StatusBadge from "../../components/general/StatusBadge";
+
+interface IMyNumberResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    my: number;
+    collaboration: number;
+  };
+}
+
+interface DataItem {
+  sn: number;
+  id: string;
+  name: string;
+  description: string;
+  role: string;
+  created_at: string;
+  studio?: IStudio;
+}
+
+interface IWidget {
+  studioName?: string;
+  onSelect?: (id: string) => void;
+  onSetting?: (id: string) => void;
+}
+const GroupList = ({ studioName, onSelect, onSetting }: IWidget) => {
+  const intl = useIntl(); //i18n
+
+  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/group-my-number?studio=${studioName}`;
+    console.log("url", url);
+    get<IMyNumberResponse>(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/group/${id}`)
+          .then((json) => {
+            if (json.ok) {
+              message.success("删除成功");
+              ref.current?.reload();
+            } else {
+              message.error(json.message);
+            }
+          })
+          .catch((e) => console.log("Oops errors!", e));
+      },
+    });
+  };
+
+  const ref = useRef<ActionType | null>(null);
+
+  return (
+    <>
+      <ProTable<DataItem>
+        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.name.label",
+            }),
+            dataIndex: "name",
+            key: "name",
+            tooltip: "过长会自动收缩",
+            ellipsis: true,
+            render: (_, row) => {
+              return (
+                <Button type="link" onClick={() => onSelect?.(row.id)}>
+                  {row.name}
+                </Button>
+              );
+            },
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.owner.label",
+            }),
+            key: "owner",
+            search: false,
+            render: (_, row) => {
+              return <StudioName data={row.studio} />;
+            },
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.description.label",
+            }),
+            dataIndex: "description",
+            key: "description",
+            search: false,
+            tooltip: "过长会自动收缩",
+            ellipsis: true,
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.role.label",
+            }),
+            dataIndex: "role",
+            key: "role",
+            width: 100,
+            search: false,
+            filters: true,
+            onFilter: true,
+            valueEnum: RoleValueEnum(),
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.created-at.label",
+            }),
+            key: "created_at",
+            width: 100,
+            search: false,
+            dataIndex: "created_at",
+            valueType: "date",
+            sorter: true,
+          },
+          {
+            title: intl.formatMessage({ id: "buttons.option" }),
+            key: "option",
+            width: 120,
+            valueType: "option",
+            render: (_, row, index) => [
+              <Dropdown.Button
+                key={index}
+                type="link"
+                menu={{
+                  items: [
+                    {
+                      key: "remove",
+                      label: intl.formatMessage({
+                        id: "buttons.delete",
+                      }),
+                      icon: <DeleteOutlined />,
+                      danger: true,
+                    },
+                  ],
+                  onClick: (e) => {
+                    switch (e.key) {
+                      case "share":
+                        break;
+                      case "remove":
+                        showDeleteConfirm(row.id, row.name);
+                        break;
+                      default:
+                        break;
+                    }
+                  },
+                }}
+              >
+                <Button type="link" onClick={() => onSetting?.(row.id)}>
+                  {intl.formatMessage({
+                    id: "buttons.setting",
+                  })}
+                </Button>
+              </Dropdown.Button>,
+            ],
+          },
+        ]}
+        request={async (params = {}, sorter, filter) => {
+          console.log(params, sorter, filter);
+          let url = `/api/v2/group?view=studio&name=${studioName}&view2=${activeKey}`;
+          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);
+
+          console.log(url);
+          const res = await get<IGroupListResponse>(url);
+          const items: DataItem[] = res.data.rows.map((item, id) => {
+            return {
+              sn: id + 1,
+              id: item.uid,
+              name: item.name,
+              description: item.description,
+              role: item.role,
+              created_at: item.created_at,
+              studio: item.studio,
+            };
+          });
+          console.log(items);
+          return {
+            total: res.data.count,
+            succcess: true,
+            data: items,
+          };
+        }}
+        rowKey="id"
+        bordered
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+        }}
+        search={false}
+        options={{
+          search: true,
+        }}
+        toolBarRender={() => [
+          <Popover
+            content={
+              <GroupCreate
+                studio={studioName}
+                onCreate={() => {
+                  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>,
+        ]}
+        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();
+            },
+          },
+        }}
+      />
+    </>
+  );
+};
+
+export default GroupList;

+ 39 - 0
dashboard-v6/src/features/group/GroupShow.tsx

@@ -0,0 +1,39 @@
+import { useIntl } from "react-intl";
+import { Button, Card, Space } from "antd";
+import { Col, Row } from "antd";
+import { SettingOutlined } from "@ant-design/icons";
+
+import GroupFile from "../../components/group/GroupFile";
+import GroupMember from "../../components/group/GroupMember";
+
+interface IWidget {
+  teamId?: string;
+  onSetting?: () => void;
+}
+const GroupShow = ({ teamId, onSetting }: IWidget) => {
+  const intl = useIntl();
+
+  return (
+    <Card
+      extra={
+        <Space>
+          <Button type="link" danger>
+            {intl.formatMessage({ id: "buttons.group.exit" })}
+          </Button>
+          <Button type="link" icon={<SettingOutlined />} onClick={onSetting} />
+        </Space>
+      }
+    >
+      <Row>
+        <Col flex="auto" style={{ paddingRight: 10 }}>
+          <GroupFile groupId={teamId} />
+        </Col>
+        <Col flex="380px">
+          <GroupMember groupId={teamId} />
+        </Col>
+      </Row>
+    </Card>
+  );
+};
+
+export default GroupShow;

+ 15 - 2
dashboard-v6/src/hooks/useTipitaka.ts

@@ -73,8 +73,21 @@ const useTipitaka = ({
         if (type === "chapter") {
           response = await fetchChapter(id, srcDataMode, channelId);
         } else if (type === "para") {
-          const _book = book ?? id;
-          response = await fetchPara(_book, para ?? "", srcDataMode, channelId);
+          const [book, pFrom, pTo] = id
+            .split("-")
+            .map((item) => parseInt(item));
+          const _to = pTo ?? pFrom;
+
+          const pList: number[] = [];
+          for (let index = pFrom; index <= _to; index++) {
+            pList.push(index);
+          }
+          response = await fetchPara(
+            book.toString(),
+            pList.join(","),
+            srcDataMode,
+            channelId
+          );
         } else {
           return;
         }

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

@@ -23,7 +23,7 @@ import { grammar, type ITerm, update } from "./reducers/term-vocabulary";
 import { push as nissayaEndingPush } from "./reducers/nissaya-ending-vocabulary";
 
 import { pushRelation } from "./reducers/relation";
-import type { IGroupMemberListResponse } from "./api/Group";
+import type { IGroupMemberListResponse } from "./api/group";
 
 import type { IAiModel } from "./api/ai";
 import type { IStudio } from "./api/Auth";

+ 12 - 0
dashboard-v6/src/pages/workspace/invite/index.tsx

@@ -0,0 +1,12 @@
+import InviteList from "../../../components/invite/InviteList";
+import { useAppSelector } from "../../../hooks";
+import { currentUser } from "../../../reducers/current-user";
+
+const Widget = () => {
+  const user = useAppSelector(currentUser);
+  const studioName = user?.realName;
+
+  return <InviteList studioName={studioName} />;
+};
+
+export default Widget;

+ 15 - 0
dashboard-v6/src/pages/workspace/tag/edit.tsx

@@ -0,0 +1,15 @@
+import { useParams } from "react-router";
+
+import TagCreate from "../../../components/tag/TagCreate";
+import { useAppSelector } from "../../../hooks";
+import { currentUser } from "../../../reducers/current-user";
+
+const Widget = () => {
+  const { tagId } = useParams(); //url 参数
+  const user = useAppSelector(currentUser);
+  const studioName = user?.realName;
+
+  return <TagCreate studio={studioName} tagId={tagId} />;
+};
+
+export default Widget;

+ 23 - 0
dashboard-v6/src/pages/workspace/tag/index.tsx

@@ -0,0 +1,23 @@
+import { useNavigate } from "react-router";
+
+import TagList from "../../../components/tag/TagList";
+
+import { useAppSelector } from "../../../hooks";
+import { currentUser } from "../../../reducers/current-user";
+
+const Widget = () => {
+  const user = useAppSelector(currentUser);
+  const studioName = user?.realName;
+  const navigate = useNavigate();
+  return (
+    <TagList
+      studioName={studioName}
+      onSelect={(tag) => {
+        const url = `/workspace/tag/${tag.id}`;
+        navigate(url);
+      }}
+    />
+  );
+};
+
+export default Widget;

+ 11 - 0
dashboard-v6/src/pages/workspace/tag/show.tsx

@@ -0,0 +1,11 @@
+import { useParams } from "react-router";
+
+import TagShow from "../../../components/tag/TagShow";
+
+const Widget = () => {
+  const { tagId } = useParams(); //url 参数
+
+  return <TagShow tagId={tagId} />;
+};
+
+export default Widget;

+ 16 - 0
dashboard-v6/src/pages/workspace/team/edit.tsx

@@ -0,0 +1,16 @@
+import { useParams } from "react-router";
+
+import GroupEdit from "../../../features/group/GroupEdit";
+
+const Widget = () => {
+  const { teamId } = useParams(); //url 参数
+
+  return (
+    <>
+      <title>Team Space</title>
+      <GroupEdit groupId={teamId} />
+    </>
+  );
+};
+
+export default Widget;

+ 30 - 0
dashboard-v6/src/pages/workspace/team/index.tsx

@@ -0,0 +1,30 @@
+import { useNavigate } from "react-router";
+
+import { useAppSelector } from "../../../hooks";
+import { currentUser } from "../../../reducers/current-user";
+import GroupList from "../../../features/group/GroupList";
+
+const Widget = () => {
+  const user = useAppSelector(currentUser);
+  const studioName = user?.realName;
+  const navigate = useNavigate();
+  console.debug("channel list", studioName);
+  return (
+    <>
+      <title>Team Space</title>
+      <GroupList
+        studioName={studioName}
+        onSelect={(id) => {
+          const url = `/workspace/team/${id}`;
+          navigate(url);
+        }}
+        onSetting={(id) => {
+          const url = `/workspace/team/${id}/setting`;
+          navigate(url);
+        }}
+      />
+    </>
+  );
+};
+
+export default Widget;

+ 20 - 0
dashboard-v6/src/pages/workspace/team/show.tsx

@@ -0,0 +1,20 @@
+import { useNavigate, useParams } from "react-router";
+
+import GroupShow from "../../../features/group/GroupShow";
+
+const Widget = () => {
+  const { teamId } = useParams(); //url 参数
+  const navigate = useNavigate();
+
+  return (
+    <>
+      <title>Team Space</title>
+      <GroupShow
+        teamId={teamId}
+        onSetting={() => navigate(`/workspace/team/${teamId}/setting`)}
+      />
+    </>
+  );
+};
+
+export default Widget;

+ 44 - 0
dashboard-v6/src/pages/workspace/tipitaka/para.tsx

@@ -0,0 +1,44 @@
+import {
+  useLocation,
+  useNavigate,
+  useParams,
+  useSearchParams,
+} from "react-router";
+import type { ArticleMode } from "../../../api/article";
+import ParaEditor from "../../../features/editor/Paragraph";
+
+const Widget = () => {
+  const { id } = useParams();
+  const [searchParams] = useSearchParams();
+  const navigate = useNavigate();
+  const { search } = useLocation();
+  const mode = searchParams.get("mode") ?? "read";
+  const channelId = searchParams.get("channel");
+
+  return (
+    <ParaEditor
+      chapterId={id}
+      mode={mode as ArticleMode}
+      channelId={channelId}
+      onSelect={(id) => {
+        navigate(`/workspace/tipitaka/para/${id}${search}`);
+      }}
+      onArticleChange={(type, id, target, param) => {
+        const url = `workspace/tipitaka/${type}/${id}`;
+        const urlSearch = param
+          ? "?" + param?.map((item) => `${item.key}=${item.value}`).join("&")
+          : search;
+        if (target === "_blank") {
+          window.open(
+            `${window.location.origin}${import.meta.env.BASE_URL}${url}${urlSearch}`,
+            "_blank"
+          );
+        } else {
+          navigate(`/${url}${urlSearch}`);
+        }
+      }}
+    />
+  );
+};
+
+export default Widget;

+ 15 - 0
dashboard-v6/src/pages/workspace/transfer/index.tsx

@@ -0,0 +1,15 @@
+import TransferList from "../../../components/transfer/TransferList";
+import { useAppSelector } from "../../../hooks";
+import { currentUser } from "../../../reducers/current-user";
+
+const Widget = () => {
+  const user = useAppSelector(currentUser);
+  const studioName = user?.realName;
+  return (
+    <>
+      <TransferList studioName={studioName} />
+    </>
+  );
+};
+
+export default Widget;

+ 1 - 1
dashboard-v6/src/reducers/discussion-count.ts

@@ -5,7 +5,7 @@ import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
 
 import type { RootState } from "../store";
 import type { IDiscussionCountData } from "../api/Comment";
-import type { ITagMapData } from "../api/Tag";
+import type { ITagMapData } from "../api/tag";
 
 export interface IUpgrade {
   resId: string;

+ 20 - 0
dashboard-v6/src/routes/inviteRoutes.ts

@@ -0,0 +1,20 @@
+// src/routes/channelRoutes.ts
+import { lazy } from "react";
+import type { RouteObject } from "react-router";
+
+const WorkspaceInvite = lazy(() => import("../pages/workspace/invite"));
+
+const channelRoutes: RouteObject[] = [
+  {
+    path: "invite",
+    handle: { id: "workspace.invite", crumb: "invite" },
+    children: [
+      {
+        index: true,
+        Component: WorkspaceInvite,
+      },
+    ],
+  },
+];
+
+export default channelRoutes;

+ 42 - 0
dashboard-v6/src/routes/tagRoutes.ts

@@ -0,0 +1,42 @@
+// src/routes/channelRoutes.ts
+import { lazy } from "react";
+import type { RouteObject } from "react-router";
+import { tagLoader } from "../api/tag";
+
+const WorkspaceTag = lazy(() => import("../pages/workspace/tag"));
+const WorkspaceTagShow = lazy(() => import("../pages/workspace/tag/show"));
+const WorkspaceTagEdit = lazy(() => import("../pages/workspace/tag/edit"));
+
+const channelRoutes: RouteObject[] = [
+  {
+    path: "tag",
+    handle: { id: "workspace.tag", crumb: "tag" },
+    children: [
+      {
+        index: true,
+        Component: WorkspaceTag,
+      },
+      {
+        path: ":tagId",
+        loader: tagLoader,
+        handle: {
+          crumb: (match: { data: { name: string } }) => match.data.name,
+        },
+        children: [
+          {
+            index: true,
+            Component: WorkspaceTagShow,
+            handle: { id: "workspace.tag" },
+          },
+          {
+            path: "edit",
+            Component: WorkspaceTagEdit,
+            handle: { id: "workspace.tag.edit", crumb: "edit" },
+          },
+        ],
+      },
+    ],
+  },
+];
+
+export default channelRoutes;

+ 42 - 0
dashboard-v6/src/routes/teamRoutes.ts

@@ -0,0 +1,42 @@
+// src/routes/channelRoutes.ts
+import { lazy } from "react";
+import type { RouteObject } from "react-router";
+import { groupLoader } from "../api/group";
+
+const WorkspaceTeam = lazy(() => import("../pages/workspace/team"));
+const WorkspaceTeamShow = lazy(() => import("../pages/workspace/team/show"));
+const WorkspaceTeamSetting = lazy(() => import("../pages/workspace/team/edit"));
+
+const channelRoutes: RouteObject[] = [
+  {
+    path: "team",
+    handle: { id: "workspace.team", crumb: "team" },
+    children: [
+      {
+        index: true,
+        Component: WorkspaceTeam,
+      },
+      {
+        path: ":teamId",
+        loader: groupLoader,
+        handle: {
+          crumb: (match: { data: { name: string } }) => match.data.name,
+        },
+        children: [
+          {
+            index: true,
+            Component: WorkspaceTeamShow,
+            handle: { id: "workspace.team" },
+          },
+          {
+            path: "setting",
+            Component: WorkspaceTeamSetting,
+            handle: { id: "workspace.team.setting", crumb: "setting" },
+          },
+        ],
+      },
+    ],
+  },
+];
+
+export default channelRoutes;

+ 14 - 1
dashboard-v6/src/routes/tipitakaRoutes.ts

@@ -8,6 +8,9 @@ const WorkspaceTipitaka = lazy(
 const WorkspaceTipitakaChapter = lazy(
   () => import("../pages/workspace/tipitaka/chapter")
 );
+const WorkspaceTipitakaPara = lazy(
+  () => import("../pages/workspace/tipitaka/para")
+);
 
 const tipitakaRoutes: RouteObject[] = [
   {
@@ -43,7 +46,17 @@ const tipitakaRoutes: RouteObject[] = [
           {
             path: ":id",
             Component: WorkspaceTipitakaChapter,
-            handle: { id: "workspace.tipitaka.chapter", crumb: "chapter" },
+            handle: { id: "workspace.tipitaka", crumb: "chapter" },
+          },
+        ],
+      },
+      {
+        path: "para",
+        children: [
+          {
+            path: ":id",
+            Component: WorkspaceTipitakaPara,
+            handle: { id: "workspace.tipitaka", crumb: "para" },
           },
         ],
       },

+ 20 - 0
dashboard-v6/src/routes/transferRoutes.ts

@@ -0,0 +1,20 @@
+// src/routes/channelRoutes.ts
+import { lazy } from "react";
+import type { RouteObject } from "react-router";
+
+const WorkspaceTransfer = lazy(() => import("../pages/workspace/transfer"));
+
+const channelRoutes: RouteObject[] = [
+  {
+    path: "transfer",
+    handle: { id: "workspace.transfer", crumb: "transfer" },
+    children: [
+      {
+        index: true,
+        Component: WorkspaceTransfer,
+      },
+    ],
+  },
+];
+
+export default channelRoutes;