visuddhinanda 1 месяц назад
Родитель
Сommit
ea44459231
63 измененных файлов с 763 добавлено и 410 удалено
  1. 231 5
      api-v12/app/Http/Controllers/CorpusController.php
  2. 1 1
      dashboard-v6/src/api/article.ts
  3. 4 4
      dashboard-v6/src/api/sentence.ts
  4. 71 95
      dashboard-v6/src/api/workspace.ts
  5. 6 4
      dashboard-v6/src/components/article/TypePali.tsx
  6. 7 4
      dashboard-v6/src/components/article/components/ArticleLayout.tsx
  7. 6 11
      dashboard-v6/src/components/dict/DictGroupTitle.tsx
  8. 1 1
      dashboard-v6/src/components/discussion/DiscussionAnchor.tsx
  9. 4 3
      dashboard-v6/src/components/discussion/DiscussionListCard.tsx
  10. 1 1
      dashboard-v6/src/components/editor/panels/SuggestionPanel.tsx
  11. 2 2
      dashboard-v6/src/components/general/SplitLayout/RightToolbar.module.css
  12. 9 7
      dashboard-v6/src/components/general/SplitLayout/SplitLayout.tsx
  13. 0 27
      dashboard-v6/src/components/sentence-editor/utils.ts
  14. 0 0
      dashboard-v6/src/components/sentence/EditInfo.tsx
  15. 0 0
      dashboard-v6/src/components/sentence/InteractiveButton.tsx
  16. 1 1
      dashboard-v6/src/components/sentence/PrAcceptButton.tsx
  17. 0 0
      dashboard-v6/src/components/sentence/SentAdd.tsx
  18. 1 1
      dashboard-v6/src/components/sentence/SentAttachment.tsx
  19. 1 1
      dashboard-v6/src/components/sentence/SentCanRead.tsx
  20. 0 0
      dashboard-v6/src/components/sentence/SentCart.tsx
  21. 1 1
      dashboard-v6/src/components/sentence/SentCell.tsx
  22. 0 0
      dashboard-v6/src/components/sentence/SentCellEditable.tsx
  23. 0 0
      dashboard-v6/src/components/sentence/SentContent.tsx
  24. 0 0
      dashboard-v6/src/components/sentence/SentEdit.tsx
  25. 0 0
      dashboard-v6/src/components/sentence/SentEditInnerDemo.tsx
  26. 0 0
      dashboard-v6/src/components/sentence/SentEditMenu.tsx
  27. 0 0
      dashboard-v6/src/components/sentence/SentMenu.tsx
  28. 3 16
      dashboard-v6/src/components/sentence/SentRead.tsx
  29. 0 0
      dashboard-v6/src/components/sentence/SentSim.tsx
  30. 0 0
      dashboard-v6/src/components/sentence/SentSimTest.tsx
  31. 0 0
      dashboard-v6/src/components/sentence/SentTab.tsx
  32. 0 0
      dashboard-v6/src/components/sentence/SentTabButton.tsx
  33. 0 0
      dashboard-v6/src/components/sentence/SentTabButtonWbw.tsx
  34. 0 0
      dashboard-v6/src/components/sentence/SentTabCopy.tsx
  35. 0 0
      dashboard-v6/src/components/sentence/SentWbw.tsx
  36. 0 0
      dashboard-v6/src/components/sentence/SentWbwEdit.tsx
  37. 0 0
      dashboard-v6/src/components/sentence/SuggestionAdd.tsx
  38. 0 0
      dashboard-v6/src/components/sentence/SuggestionBox.tsx
  39. 0 0
      dashboard-v6/src/components/sentence/SuggestionButton.tsx
  40. 0 0
      dashboard-v6/src/components/sentence/SuggestionFocus.tsx
  41. 0 0
      dashboard-v6/src/components/sentence/SuggestionList.tsx
  42. 0 0
      dashboard-v6/src/components/sentence/SuggestionPopover.tsx
  43. 0 0
      dashboard-v6/src/components/sentence/SuggestionTabs.tsx
  44. 0 0
      dashboard-v6/src/components/sentence/SuggestionToolbar.tsx
  45. 34 0
      dashboard-v6/src/components/sentence/components/MdOrigin.tsx
  46. 17 0
      dashboard-v6/src/components/sentence/components/MdTranslation.tsx
  47. 0 0
      dashboard-v6/src/components/sentence/style.css
  48. 26 0
      dashboard-v6/src/components/sentence/utils.ts
  49. 0 1
      dashboard-v6/src/components/template/MdTpl.tsx
  50. 1 1
      dashboard-v6/src/components/template/ParaHandle.tsx
  51. 53 14
      dashboard-v6/src/components/template/Paragraph.tsx
  52. 1 4
      dashboard-v6/src/components/template/SentEdit.tsx
  53. 2 2
      dashboard-v6/src/components/template/SentRead.tsx
  54. 20 16
      dashboard-v6/src/components/tipitaka/BookTreeList.tsx
  55. 56 0
      dashboard-v6/src/components/tipitaka/components/ParagraphRead.tsx
  56. 76 78
      dashboard-v6/src/components/workspace/home/ModuleCard.tsx
  57. 1 0
      dashboard-v6/src/components/workspace/home/ModuleGrid.tsx
  58. 60 58
      dashboard-v6/src/components/workspace/home/RecentItem.tsx
  59. 12 9
      dashboard-v6/src/components/workspace/home/RecentList.tsx
  60. 37 36
      dashboard-v6/src/components/workspace/home/WorkspaceHero.tsx
  61. 14 1
      dashboard-v6/src/hooks/useTipitaka.ts
  62. 1 1
      dashboard-v6/src/pages/workspace/home.tsx
  63. 2 4
      dashboard-v6/src/routes/testRoutes.tsx

+ 231 - 5
api-v12/app/Http/Controllers/CorpusController.php

@@ -532,7 +532,8 @@ class CorpusController extends Controller
         if (count($record) === 0) {
             return $this->error("no data");
         }
-        $this->result['content'] = $this->makeContent($record, $mode, $indexChannel, $indexedHeading, false, true);
+        $this->result['content'] = json_encode($this->makeContentObj($record, $mode, $indexChannel), JSON_UNESCAPED_UNICODE);
+        $this->result['content_type'] = 'json';
         if (!$request->has('from')) {
             //第一次才显示toc
             $this->result['toc'] = TocResource::collection($toc);
@@ -580,8 +581,6 @@ class CorpusController extends Controller
     private function makeContent($record, $mode, $indexChannel, $indexedHeading = [], $onlyProps = false, $paraMark = false, $format = 'react')
     {
         $content = [];
-        $lastSent = "0-0";
-        $sentCount = 0;
         $sent = [];
         $sent["origin"] = [];
         $sent["translation"] = [];
@@ -589,7 +588,7 @@ class CorpusController extends Controller
 
         //获取句子编号列表
         $sentList = [];
-        foreach ($record as $key => $value) {
+        foreach ($record as  $value) {
             $currSentId = "{$value->book_id}-{$value->paragraph}-{$value->word_start}-{$value->word_end}";
             $sentList[$currSentId] = [$value->book_id, $value->paragraph, $value->word_start, $value->word_end];
             $value->sid = "{$currSentId}_{$value->channel_uid}";
@@ -606,7 +605,6 @@ class CorpusController extends Controller
             if ($currPara !== $para) {
                 $currPara = $para;
                 //输出段落标记
-
                 if ($paraMark) {
                     $sentInPara = array();
                     foreach ($sentList as $sentId => $sentParam) {
@@ -812,6 +810,234 @@ class CorpusController extends Controller
         $output = \implode("", $content);
         return "<div>{$output}</div>";
     }
+
+    /**
+     * 根据句子库数据生成以段落为单位的文章内容
+     * $record 句子数据
+     * $mode read | edit | wbw
+     * $indexChannel channel索引
+     * $indexedHeading 标题索引 用于给段落加标题标签 <h1> ect.
+     */
+    private function makeContentObj($record, $mode, $indexChannel, $format = 'react')
+    {
+        $content = [];
+
+
+        //获取句子编号列表
+        $paraIndex = [];
+        foreach ($record as  $value) {
+            $currSentId = "{$value->book_id}-{$value->paragraph}-{$value->word_start}-{$value->word_end}";
+            $value->sid = "{$currSentId}_{$value->channel_uid}";
+
+            $currParaId = "{$value->book_id}-{$value->paragraph}";
+            if (!isset($paraIndex[$currParaId])) {
+                $paraIndex[$currParaId] = [];
+            }
+            $paraIndex[$currParaId][] = $value;
+        }
+        $channelsId = array();
+        foreach ($indexChannel as $channelId => $info) {
+            $channelsId[] = $channelId;
+        }
+        array_pop($channelsId);
+        //遍历列表查找每个句子的所有channel的数据,并填充
+        $paragraphs = [];
+        foreach ($paraIndex as $currParaId => $sentData) {
+            $arrParaId = explode('-', $currParaId);
+            $sentIndex = [];
+            foreach ($sentData as  $sent) {
+                $currSentId = "{$sent->book_id}-{$sent->paragraph}-{$sent->word_start}-{$sent->word_end}";
+                $sentIndex[$currSentId] = [$sent->book_id, $sent->paragraph, $sent->word_start, $sent->word_end];
+            }
+            $sentInPara = array_values($sentIndex);
+            $paraProps = [
+                'book' => $arrParaId[0],
+                'para' => $arrParaId[1],
+                'channels' => $channelsId,
+                'sentences' => $sentInPara,
+                'mode' => $mode,
+                'children' => [],
+            ];
+            //建立段落里面的句子列表
+            foreach ($sentIndex as $ids => $arrSentId) {
+                $sentNode = $this->newSent($arrSentId[0], $arrSentId[1], $arrSentId[2], $arrSentId[3]);
+                foreach ($indexChannel as $channelId => $info) {
+                    # code...
+                    $sid = "{$ids}_{$channelId}";
+                    if (isset($info->studio)) {
+                        $studioInfo = $info->studio;
+                    } else {
+                        $studioInfo = null;
+                    }
+                    $newSent = [
+                        "content" => "",
+                        "html" => "",
+                        "book" => $arrSentId[0],
+                        "para" => $arrSentId[1],
+                        "wordStart" => $arrSentId[2],
+                        "wordEnd" => $arrSentId[3],
+                        "channel" => [
+                            "name" => $info->name,
+                            "type" => $info->type,
+                            "id" => $info->uid,
+                            'lang' => $info->lang,
+                        ],
+                        "studio" => $studioInfo,
+                        "updateAt" => "",
+                        "suggestionCount" => SuggestionApi::getCountBySent($arrSentId[0], $arrSentId[1], $arrSentId[2], $arrSentId[3], $channelId),
+                    ];
+
+                    $row = Arr::first($sentData, function ($value, $key) use ($sid) {
+                        return $value->sid === $sid;
+                    });
+                    if ($row) {
+                        $newSent['id'] = $row->uid;
+                        $newSent['content'] = $row->content;
+                        $newSent['contentType'] = $row->content_type;
+                        $newSent['html'] = '';
+                        $newSent["editor"] = UserApi::getByUuid($row->editor_uid);
+                        /**
+                         * TODO 刷库改数据
+                         * 旧版api没有更新updated_at所以造成旧版的数据updated_at数据比modify_time 要晚
+                         */
+                        $newSent['forkAt'] =  $row->fork_at; //
+                        $newSent['updateAt'] =  $row->updated_at; //
+                        $newSent['updateAt'] = date("Y-m-d H:i:s.", $row->modify_time / 1000) . ($row->modify_time % 1000) . " UTC";
+
+                        $newSent['createdAt'] = $row->created_at;
+                        if ($mode !== "read") {
+                            if (isset($row->acceptor_uid) && !empty($row->acceptor_uid)) {
+                                $newSent["acceptor"] = UserApi::getByUuid($row->acceptor_uid);
+                                $newSent["prEditAt"] = $row->pr_edit_at;
+                            }
+                        }
+                        switch ($info->type) {
+                            case 'wbw':
+                            case 'original':
+                                //
+                                // 在编辑模式下。
+                                // 如果是原文,查看是否有逐词解析数据,
+                                // 有的话优先显示。
+                                // 阅读模式直接显示html原文
+                                // 传过来的数据一定有一个原文channel
+                                //
+                                if ($mode === "read") {
+                                    $newSent['content'] = "";
+                                    $newSent['html'] = MdRender::render(
+                                        $row->content,
+                                        [$row->channel_uid],
+                                        null,
+                                        $mode,
+                                        "translation",
+                                        $row->content_type,
+                                        $format
+                                    );
+                                } else {
+                                    if ($row->content_type === 'json') {
+                                        $newSent['channel']['type'] = "wbw";
+                                        if (isset($this->wbwChannels[0])) {
+                                            $newSent['channel']['name'] = $indexChannel[$this->wbwChannels[0]]->name;
+                                            $newSent['channel']['lang'] = $indexChannel[$this->wbwChannels[0]]->lang;
+                                            $newSent['channel']['id'] = $this->wbwChannels[0];
+                                            //存在一个translation channel
+                                            //尝试查找逐词解析数据。找到,替换现有数据
+                                            $wbwData = $this->getWbw(
+                                                $arrSentId[0],
+                                                $arrSentId[1],
+                                                $arrSentId[2],
+                                                $arrSentId[3],
+                                                $this->wbwChannels[0]
+                                            );
+                                            if ($wbwData) {
+                                                $newSent['content'] = $wbwData;
+                                                $newSent['contentType'] = 'json';
+                                                $newSent['html'] = "";
+                                                $newSent['studio'] = $indexChannel[$this->wbwChannels[0]]->studio;
+                                            }
+                                        }
+                                    } else {
+                                        $newSent['content'] = $row->content;
+                                        $newSent['html'] = MdRender::render(
+                                            $row->content,
+                                            [$row->channel_uid],
+                                            null,
+                                            $mode,
+                                            "translation",
+                                            $row->content_type,
+                                            $format
+                                        );
+                                    }
+                                }
+
+                                break;
+                            case 'nissaya':
+                                $newSent['html'] = Cache::remember(
+                                    "/sent/{$channelId}/{$ids}/{$format}",
+                                    config('mint.cache.expire'),
+                                    function () use ($row, $mode, $format) {
+                                        if ($row->content_type === 'markdown') {
+                                            return MdRender::render(
+                                                $row->content,
+                                                [$row->channel_uid],
+                                                null,
+                                                $mode,
+                                                "nissaya",
+                                                $row->content_type,
+                                                $format
+                                            );
+                                        } else {
+                                            return null;
+                                        }
+                                    }
+                                );
+                                break;
+                            case 'commentary':
+                                $options = [
+                                    'debug' => $this->debug,
+                                    'format' => $format,
+                                    'mode' => $mode,
+                                    'channelType' => 'translation',
+                                    'contentType' => $row->content_type,
+                                ];
+                                $mdRender = new MdRender($options);
+                                $newSent['html'] = $mdRender->convert($row->content, $channelsId);
+                                break;
+                            default:
+                                $options = [
+                                    'debug' => $this->debug,
+                                    'format' => $format,
+                                    'mode' => $mode,
+                                    'channelType' => 'translation',
+                                    'contentType' => $row->content_type,
+                                ];
+                                $mdRender = new MdRender($options);
+                                $newSent['html'] = $mdRender->convert($row->content, [$row->channel_uid]);
+                                //Log::debug('md render', ['content' => $row->content, 'options' => $options, 'render' => $newSent['html']]);
+                                break;
+                        }
+                    } else {
+                        Log::warning('no sentence record');
+                    }
+                    switch ($info->type) {
+                        case 'wbw':
+                        case 'original':
+                            array_push($sentNode["origin"], $newSent);
+                            break;
+                        case 'commentary':
+                            array_push($sentNode["commentaries"], $newSent);
+                            break;
+                        default:
+                            array_push($sentNode["translation"], $newSent);
+                            break;
+                    }
+                }
+                $paraProps['children'][] = $sentNode;
+            }
+            $paragraphs[] = $paraProps;
+        }
+        return $paragraphs;
+    }
+
     public function getWbw($book, $para, $start, $end, $channel)
     {
         /**

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

@@ -137,7 +137,7 @@ export interface IArticleDataResponse {
   summary: string | null;
   _summary?: string;
   content?: string;
-  content_type?: string;
+  content_type?: TContentType;
   toc?: IChapterToc[];
   html?: string;
   path?: ITocPathNode[];

+ 4 - 4
dashboard-v6/src/api/sentence.ts

@@ -156,7 +156,7 @@ export const sentSave = async (
   //FIXME
   //store.dispatch(statusChange({ status: "loading" }));
   const id = `${sent.book}_${sent.para}_${sent.wordStart}_${sent.wordEnd}_${sent.channel.id}`;
-  const url = `/v2/sentence/${id}?mode=edit&html=true`;
+  const url = `/api/v2/sentence/${id}?mode=edit&html=true`;
   console.info("SentWbwEdit url", url);
 
   try {
@@ -223,7 +223,7 @@ export async function fetchSentence(
   channelId: string
 ): Promise<ISentenceData> {
   const sentId = `${book}-${para}-${wordStart}-${wordEnd}`;
-  const url = `/v2/sentence?view=channel&sentence=${sentId}&channel=${channelId}&html=true`;
+  const url = `/api/v2/sentence?view=channel&sentence=${sentId}&channel=${channelId}&html=true`;
 
   const json = await get<ISentenceListResponse>(url);
 
@@ -239,7 +239,7 @@ export async function fetchSentence(
  */
 export async function saveSentence(sent: ISentence): Promise<ISentenceData> {
   const id = `${sent.book}_${sent.para}_${sent.wordStart}_${sent.wordEnd}_${sent.channel.id}`;
-  const url = `/v2/sentence/${id}?mode=edit&html=true`;
+  const url = `/api/v2/sentence/${id}?mode=edit&html=true`;
 
   const json = await put<ISentenceRequest, ISentenceResponse>(url, {
     book: sent.book,
@@ -267,7 +267,7 @@ export async function acceptSentencePr(
   prData: ISentence
 ): Promise<ISentenceData> {
   const id = `${prData.book}_${prData.para}_${prData.wordStart}_${prData.wordEnd}_${prData.channel.id}`;
-  const url = `/v2/sentence/${id}?mode=edit&html=true`;
+  const url = `/api/v2/sentence/${id}?mode=edit&html=true`;
 
   const json = await put<ISentenceRequest, ISentenceResponse>(url, {
     book: prData.book,

+ 71 - 95
dashboard-v6/src/api/workspace.ts

@@ -1,19 +1,28 @@
 import type { ArticleType } from "./article";
 import { getRecentByUser } from "./recent";
 
-export type ModuleItem = {
+// 静态配置,前端维护,与 API 无关
+export type ModuleConfig = {
   key: string;
   title: string;
   titleZh: string;
   description: string;
-  icon: string; // icon name, rendered by caller
+  icon: string;
   path: string;
   color: string;
   bg: string;
   accent: string;
+};
+
+// API 只返回动态数据
+export type ModuleStats = {
+  key: string;
   stats: string;
 };
 
+// 合并后传给组件
+export type ModuleItem = ModuleConfig & { stats: string };
+
 export type RecentItem = {
   id: number;
   title: string;
@@ -23,103 +32,70 @@ export type RecentItem = {
   emoji: string;
 };
 
-// TODO: replace with real fetch
-export async function fetchModules(): Promise<ModuleItem[]> {
+// 静态配置写在前端
+export const MODULE_CONFIGS: ModuleConfig[] = [
+  {
+    key: "tipitaka",
+    title: "Tipitaka",
+    titleZh: "大藏经",
+    description: "浏览与研读巴利文三藏经典,包含律藏、经藏与论藏。",
+    icon: "BookOutlined",
+    path: "/workspace/tipitaka/lib",
+    color: "#b5854a",
+    bg: "linear-gradient(135deg,rgba(253, 246, 236, 0.56) 0%,rgba(245, 230, 204, 0.51) 100%)",
+    accent: "#8c6320",
+  },
+  {
+    key: "article",
+    title: "Article",
+    titleZh: "文章",
+    description: "撰写、整理与发布法义文章、学习笔记及研究报告。",
+    icon: "FileTextOutlined",
+    path: "/workspace/article",
+    color: "#4a7fb5",
+    bg: "linear-gradient(135deg,rgba(236, 243, 253, 0.52) 0%,rgba(204, 221, 245, 0.56) 100%)",
+    accent: "#20508c",
+  },
+  {
+    key: "task",
+    title: "Task",
+    titleZh: "任务",
+    description: "管理个人修学计划、法务安排与日常待办事项。",
+    icon: "CheckSquareOutlined",
+    path: "/workspace/task",
+    color: "#4ab58a",
+    bg: "linear-gradient(135deg,rgba(236, 253, 246, 0.57) 0%,rgba(204, 240, 224, 0.57) 100%)",
+    accent: "#1a7a56",
+  },
+];
+
+// API 只 fetch stats,TODO: 替换为真实接口
+export async function fetchModuleStats(): Promise<ModuleStats[]> {
   return [
-    {
-      key: "tipitaka",
-      title: "Tipitaka",
-      titleZh: "大藏经",
-      description: "浏览与研读巴利文三藏经典,包含律藏、经藏与论藏。",
-      icon: "BookOutlined",
-      path: "/workspace/tipitaka",
-      color: "#b5854a",
-      bg: "linear-gradient(135deg, #fdf6ec 0%, #f5e6cc 100%)",
-      accent: "#8c6320",
-      stats: "3 部 · 律经论",
-    },
-    {
-      key: "article",
-      title: "Article",
-      titleZh: "文章",
-      description: "撰写、整理与发布法义文章、学习笔记及研究报告。",
-      icon: "FileTextOutlined",
-      path: "/workspace/edit/article",
-      color: "#4a7fb5",
-      bg: "linear-gradient(135deg, #ecf3fd 0%, #ccddf5 100%)",
-      accent: "#20508c",
-      stats: "24 篇文章",
-    },
-    {
-      key: "task",
-      title: "Task",
-      titleZh: "任务",
-      description: "管理个人修学计划、法务安排与日常待办事项。",
-      icon: "CheckSquareOutlined",
-      path: "/workspace/task",
-      color: "#4ab58a",
-      bg: "linear-gradient(135deg, #ecfdf6 0%, #ccf0e0 100%)",
-      accent: "#1a7a56",
-      stats: "5 项进行中",
-    },
+    { key: "tipitaka", stats: "3 部 · 律经论" },
+    { key: "article", stats: "24 篇文章" },
+    { key: "task", stats: "5 项进行中" },
   ];
 }
 
-// TODO: replace with real fetch
+// 合并配置与动态数据
+export async function fetchModules(): Promise<ModuleItem[]> {
+  const stats = await fetchModuleStats();
+  const statsMap = Object.fromEntries(stats.map((s) => [s.key, s.stats]));
+  return MODULE_CONFIGS.map((config) => ({
+    ...config,
+    stats: statsMap[config.key] ?? "",
+  }));
+}
+
 export async function fetchRecentItems(userId: string): Promise<RecentItem[]> {
   const res = await getRecentByUser({ userId, pageSize: 10 });
-  return res.data.rows.map((item, id) => {
-    return {
-      id: id,
-      title: item.title,
-      subtitle: "Tipitaka · 律藏",
-      time: item.updated_at,
-      type: item.type,
-      emoji: "📜",
-    };
-  });
-  /*
-  return [
-    {
-      id: 1,
-      title: "巴利文大藏经",
-      subtitle: "Tipitaka · 律藏",
-      time: "今天",
-      type: "tipitaka",
-      emoji: "📜",
-    },
-    {
-      id: 2,
-      title: "比库戒学习笔记",
-      subtitle: "Article · 学习",
-      time: "昨天",
-      type: "article",
-      emoji: "📝",
-    },
-    {
-      id: 3,
-      title: "161101伍波萨他的准备工作",
-      subtitle: "Article · 法务",
-      time: "Jan 1",
-      type: "article",
-      emoji: "📄",
-    },
-    {
-      id: 4,
-      title: "本周学习任务",
-      subtitle: "Task · 进行中",
-      time: "Feb 20",
-      type: "task",
-      emoji: "✅",
-    },
-    {
-      id: 5,
-      title: "阿毗达磨注释",
-      subtitle: "Tipitaka · 论藏",
-      time: "Feb 18",
-      type: "tipitaka",
-      emoji: "📚",
-    },
-  ];
-  */
+  return res.data.rows.map((item, id) => ({
+    id,
+    title: item.title,
+    subtitle: "Tipitaka · 律藏",
+    time: item.updated_at,
+    type: item.type,
+    emoji: "📜",
+  }));
 }

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

@@ -26,6 +26,7 @@ import ArticleHeader from "./components/ArticleHeader";
 import { TaskBuilderChapterModal } from "../task/TaskBuilderChapterModal";
 import type { TTarget } from "../../types";
 import TocPath from "../tipitaka/TocPath";
+import { ParagraphCtl } from "../template/Paragraph";
 
 export interface ISearchParams {
   key: string;
@@ -74,7 +75,7 @@ const TypePali = ({
 
   const {
     articleData,
-    articleHtml,
+    nodeData,
     toc,
     loading,
     errorCode,
@@ -166,7 +167,7 @@ const TypePali = ({
           <Space>
             <>{headerExtra}</>
             <TocPath
-              data={articleData?.path}
+              data={fullPath}
               channels={channels}
               onChange={handlePathChange}
             />
@@ -194,8 +195,9 @@ const TypePali = ({
         title={title}
         subTitle={articleData?.subtitle}
         summary={articleData?.summary}
-        content={articleData?.content}
-        html={articleHtml}
+        nodes={nodeData.map((item) => {
+          return <ParagraphCtl {...item} />;
+        })}
         loading={loading}
         errorCode={errorCode}
         remains={remains}

+ 7 - 4
dashboard-v6/src/components/article/components/ArticleLayout.tsx

@@ -1,8 +1,8 @@
-import { Typography, Divider, Skeleton, Space } from "antd";
+import { Typography, Divider, Skeleton, Space, Flex } from "antd";
 import type { IStudio, IUser } from "../../../api/Auth";
 import VisibleObserver from "../../general/VisibleObserver";
 import MdView from "../../general/MdView";
-import type { JSX } from "react";
+import type { JSX, ReactNode } from "react";
 import ArticleSkeleton from "./ArticleSkeleton";
 import ErrorResult from "../../general/ErrorResult";
 import User from "../../auth/User";
@@ -23,6 +23,7 @@ export interface IArticleLayout {
   summary?: string | null;
   content?: string;
   html?: string[];
+  nodes?: ReactNode[];
   resList?: JSX.Element;
   created_at?: string;
   updated_at?: string;
@@ -42,6 +43,7 @@ const ArticleLayout = ({
   summary,
   content,
   html = [],
+  nodes,
   editor,
   updated_at,
   resList,
@@ -61,7 +63,7 @@ const ArticleLayout = ({
         <ErrorResult code={errorCode} />
       ) : (
         <div>
-          <Space orientation="vertical">
+          <Flex orientation="vertical" gap="middle">
             {hideTitle ? (
               <></>
             ) : (
@@ -83,7 +85,7 @@ const ArticleLayout = ({
               <User {...editor} /> edit at {updated_at}
             </Space>
             <Divider />
-          </Space>
+          </Flex>
           {html
             ? html.map((item, id) => {
                 return (
@@ -93,6 +95,7 @@ const ArticleLayout = ({
                 );
               })
             : content}
+          {nodes}
           {remains ? (
             <>
               <VisibleObserver

+ 6 - 11
dashboard-v6/src/components/dict/DictGroupTitle.tsx

@@ -6,7 +6,7 @@ interface IWidget {
   path: string[];
 }
 
-const DictGroupTitleWidget = ({ title, path }: IWidget) => {
+const DictGroupTitle = ({ title, path }: IWidget) => {
   const [fixed, setFixed] = useState<boolean>();
   return (
     <Affix
@@ -16,24 +16,19 @@ const DictGroupTitleWidget = ({ title, path }: IWidget) => {
       }
       onChange={(affixed) => setFixed(affixed)}
     >
-      {fixed ? (
+      {!fixed && title}
+      {fixed && (
         <Breadcrumb
           style={{
             backgroundColor: "white",
             padding: 4,
             borderBottom: "1px solid gray",
           }}
-        >
-          <Breadcrumb.Item key={"top"}>Top</Breadcrumb.Item>
-          {path.map((item, index) => {
-            return <Breadcrumb.Item key={index}>{item}</Breadcrumb.Item>;
-          })}
-        </Breadcrumb>
-      ) : (
-        title
+          items={[{ title: "Top" }, ...path.map((item) => ({ title: item }))]}
+        />
       )}
     </Affix>
   );
 };
 
-export default DictGroupTitleWidget;
+export default DictGroupTitle;

+ 1 - 1
dashboard-v6/src/components/discussion/DiscussionAnchor.tsx

@@ -49,7 +49,7 @@ const DiscussionAnchorWidget = ({
     let url: string;
     switch (resType) {
       case "sentence":
-        url = `/v2/sentence/${resId}`;
+        url = `/api/v2/sentence/${resId}`;
         console.info("api request", url);
         setLoading(true);
         get<ISentenceResponse>(url)

+ 4 - 3
dashboard-v6/src/components/discussion/DiscussionListCard.tsx

@@ -229,15 +229,16 @@ const DiscussionListCardWidget = ({
             //获取channel模版
             let studioName: string | undefined;
             switch (resType) {
-              case "sentence":
-                const url = `/v2/sentence/${resId}`;
+              case "sentence": {
+                const url = `/api/v2/sentence/${resId}`;
                 console.info("api request", url);
                 const sentInfo = await get<ISentenceResponse>(url);
                 console.info("api response", sentInfo);
                 studioName = sentInfo.data.studio.realName;
                 break;
+              }
             }
-            const urlTpl = `/v2/article?view=template&studio_name=${studioName}&subtitle=_template_discussion_topic_&content=true`;
+            const urlTpl = `/api/v2/article?view=template&studio_name=${studioName}&subtitle=_template_discussion_topic_&content=true`;
             const resTpl = await get<IArticleListResponse>(urlTpl);
             if (resTpl.ok) {
               console.log("resTpl.data.rows", resTpl.data.rows);

+ 1 - 1
dashboard-v6/src/components/editor/panels/SuggestionPanel.tsx

@@ -1,4 +1,4 @@
-import SuggestionBox from "../../sentence-editor/SuggestionBox";
+import SuggestionBox from "../../sentence/SuggestionBox";
 
 interface SearchPanelProps {
   articleId?: string;

+ 2 - 2
dashboard-v6/src/components/general/SplitLayout/RightToolbar.module.css

@@ -7,7 +7,7 @@
 .container {
   display: flex;
   flex-direction: row;
-  height: 100%;
+  height: 100vh;
   overflow: hidden;
 }
 
@@ -53,7 +53,7 @@
 .panelBody {
   overflow-y: scroll;
   overflow-x: hidden;
-  height: calc(100vh - 96px);
+  height: calc(100vh - 40px);
   /* display:contents 的子项可直接撑满 */
   display: flex;
   flex-direction: column;

+ 9 - 7
dashboard-v6/src/components/general/SplitLayout/SplitLayout.tsx

@@ -1,5 +1,5 @@
 import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
-import { Button, Popover, Splitter } from "antd";
+import { Affix, Button, Popover, Splitter } from "antd";
 import { useCallback, useState, type ReactNode } from "react";
 import RightToolbar, { type RightToolbarTab } from "./RightToolbar";
 import styles from "./SplitLayout.module.css";
@@ -233,12 +233,14 @@ export default function SplitLayout({
                 resizable={rightOpen}
                 className={styles.rightAreaPanel}
               >
-                <RightToolbar
-                  tabs={rightTabs!}
-                  activeKey={rightActiveKey}
-                  onTabClick={onRightTabClick}
-                  onClose={closeRightPanel}
-                />
+                <Affix offsetTop={0}>
+                  <RightToolbar
+                    tabs={rightTabs!}
+                    activeKey={rightActiveKey}
+                    onTabClick={onRightTabClick}
+                    onClose={closeRightPanel}
+                  />
+                </Affix>
               </Splitter.Panel>
             </Splitter>
           ) : (

+ 0 - 27
dashboard-v6/src/components/sentence-editor/utils.ts

@@ -1,27 +0,0 @@
-import type { ISentence } from "../../api/sentence";
-
-import type { ISentCart } from "./SentCart";
-import store from "../../store";
-import { show } from "../../reducers/discussion";
-import { openPanel } from "../../reducers/right-panel";
-
-export const addToCart = (add: ISentCart[]): number => {
-  const oldText = localStorage.getItem("cart/text");
-  let cartText: ISentCart[] = [];
-  if (oldText) {
-    cartText = JSON.parse(oldText);
-  }
-  cartText = [...cartText, ...add];
-  localStorage.setItem("cart/text", JSON.stringify(cartText));
-  return cartText.length;
-};
-
-export const prOpen = (data: ISentence) => {
-  store.dispatch(
-    show({
-      type: "pr",
-      sent: data,
-    })
-  );
-  store.dispatch(openPanel("suggestion"));
-};

+ 0 - 0
dashboard-v6/src/components/sentence-editor/EditInfo.tsx → dashboard-v6/src/components/sentence/EditInfo.tsx


+ 0 - 0
dashboard-v6/src/components/sentence-editor/InteractiveButton.tsx → dashboard-v6/src/components/sentence/InteractiveButton.tsx


+ 1 - 1
dashboard-v6/src/components/sentence-editor/PrAcceptButton.tsx → dashboard-v6/src/components/sentence/PrAcceptButton.tsx

@@ -24,7 +24,7 @@ const PrAcceptButtonWidget = ({ data, onAccept }: IWidget) => {
 
   const save = () => {
     setSaving(true);
-    const url = `/v2/sentence/${data.book}_${data.para}_${data.wordStart}_${data.wordEnd}_${data.channel.id}`;
+    const url = `/api/v2/sentence/${data.book}_${data.para}_${data.wordStart}_${data.wordEnd}_${data.channel.id}`;
     const prData = {
       book: data.book,
       para: data.para,

+ 0 - 0
dashboard-v6/src/components/sentence-editor/SentAdd.tsx → dashboard-v6/src/components/sentence/SentAdd.tsx


+ 1 - 1
dashboard-v6/src/components/sentence-editor/SentAttachment.tsx → dashboard-v6/src/components/sentence/SentAttachment.tsx

@@ -14,7 +14,7 @@ const SentAttachment = ({ sentenceId }: IWidget) => {
     if (!sentenceId) {
       return;
     }
-    const url = `/v2/sentence-attachment?view=sentence&id=${sentenceId}`;
+    const url = `/api/v2/sentence-attachment?view=sentence&id=${sentenceId}`;
     console.debug("api request", url);
     get<IResAttachmentListResponse>(url).then((json) => {
       console.debug("api response", json);

+ 1 - 1
dashboard-v6/src/components/sentence-editor/SentCanRead.tsx → dashboard-v6/src/components/sentence/SentCanRead.tsx

@@ -41,7 +41,7 @@ const SentCanReadWidget = ({
 
   const load = useCallback(() => {
     const sentId = `${book}-${para}-${wordStart}-${wordEnd}`;
-    let url = `/v2/sentence?view=sent-can-read&sentence=${sentId}&type=${type}&mode=edit&html=true`;
+    let url = `/api/v2/sentence?view=sent-can-read&sentence=${sentId}&type=${type}&mode=edit&html=true`;
     url += channelsId ? `&excludes=${channelsId.join()}` : "";
     if (type === "commentary" || type === "similar") {
       url += channelsId ? `&channels=${channelsId.join()}` : "";

+ 0 - 0
dashboard-v6/src/components/sentence-editor/SentCart.tsx → dashboard-v6/src/components/sentence/SentCart.tsx


+ 1 - 1
dashboard-v6/src/components/sentence-editor/SentCell.tsx → dashboard-v6/src/components/sentence/SentCell.tsx

@@ -172,7 +172,7 @@ const SentCellWidget = ({
     if (typeof sentData === "undefined") {
       return;
     }
-    let url = `/v2/sentence?view=channel&sentence=${sentId}&html=true`;
+    let url = `/api/v2/sentence?view=channel&sentence=${sentId}&html=true`;
     url += `&channel=${sentData.channel.id}`;
     console.debug("api request", url);
     setLoading(true);

+ 0 - 0
dashboard-v6/src/components/sentence-editor/SentCellEditable.tsx → dashboard-v6/src/components/sentence/SentCellEditable.tsx


+ 0 - 0
dashboard-v6/src/components/sentence-editor/SentContent.tsx → dashboard-v6/src/components/sentence/SentContent.tsx


+ 0 - 0
dashboard-v6/src/components/sentence-editor/SentEdit.tsx → dashboard-v6/src/components/sentence/SentEdit.tsx


+ 0 - 0
dashboard-v6/src/components/sentence-editor/SentEditInnerDemo.tsx → dashboard-v6/src/components/sentence/SentEditInnerDemo.tsx


+ 0 - 0
dashboard-v6/src/components/sentence-editor/SentEditMenu.tsx → dashboard-v6/src/components/sentence/SentEditMenu.tsx


+ 0 - 0
dashboard-v6/src/components/sentence-editor/SentMenu.tsx → dashboard-v6/src/components/sentence/SentMenu.tsx


+ 3 - 16
dashboard-v6/src/components/sentence-editor/SentRead.tsx → dashboard-v6/src/components/sentence/SentRead.tsx

@@ -14,12 +14,12 @@ import "./style.css";
 import type { ISentence } from "../../api/sentence";
 import { get } from "../../request";
 import { GetUserSetting } from "../setting/default";
-import type { TCodeConvertor } from "../../types/template";
 import { openDiscussion } from "../discussion/utils";
 import { prOpen } from "./utils";
 import MdView from "../general/MdView";
 import InteractiveButton from "./InteractiveButton";
 import type { IEditableSentence } from "../../api/sentence";
+import MdOrigin from "./components/MdOrigin";
 
 const { Text } = Typography;
 
@@ -31,7 +31,7 @@ const items: MenuProps["items"] = [
 ];
 
 export interface IWidgetSentReadFrame {
-  sentId?: string;
+  id?: string;
   book?: number;
   para?: number;
   wordStart?: number;
@@ -58,12 +58,6 @@ const SentReadFrame = ({
   const [sentData, setSentData] = useState<IWidgetSentEditInner>();
   const [showEdit, setShowEdit] = useState(false);
 
-  /** 派生数据:主巴利编码 */
-  const paliCode = useMemo(() => {
-    const v = GetUserSetting("setting.pali.script.primary", settings);
-    return (v ?? "roman") as TCodeConvertor;
-  }, [settings]);
-
   /** 派生数据:是否显示原文 */
   const displayOriginal = useMemo(() => {
     return GetUserSetting("setting.display.original", settings);
@@ -152,14 +146,7 @@ const SentReadFrame = ({
         }}
       >
         {origin?.map((item, id) => (
-          <Text key={id}>
-            <MdView
-              style={{ color: "brown" }}
-              html={item.html}
-              wordWidget
-              convertor={paliCode}
-            />
-          </Text>
+          <MdOrigin text={item.html} key={id} />
         ))}
       </span>
 

+ 0 - 0
dashboard-v6/src/components/sentence-editor/SentSim.tsx → dashboard-v6/src/components/sentence/SentSim.tsx


+ 0 - 0
dashboard-v6/src/components/sentence-editor/SentSimTest.tsx → dashboard-v6/src/components/sentence/SentSimTest.tsx


+ 0 - 0
dashboard-v6/src/components/sentence-editor/SentTab.tsx → dashboard-v6/src/components/sentence/SentTab.tsx


+ 0 - 0
dashboard-v6/src/components/sentence-editor/SentTabButton.tsx → dashboard-v6/src/components/sentence/SentTabButton.tsx


+ 0 - 0
dashboard-v6/src/components/sentence-editor/SentTabButtonWbw.tsx → dashboard-v6/src/components/sentence/SentTabButtonWbw.tsx


+ 0 - 0
dashboard-v6/src/components/sentence-editor/SentTabCopy.tsx → dashboard-v6/src/components/sentence/SentTabCopy.tsx


+ 0 - 0
dashboard-v6/src/components/sentence-editor/SentWbw.tsx → dashboard-v6/src/components/sentence/SentWbw.tsx


+ 0 - 0
dashboard-v6/src/components/sentence-editor/SentWbwEdit.tsx → dashboard-v6/src/components/sentence/SentWbwEdit.tsx


+ 0 - 0
dashboard-v6/src/components/sentence-editor/SuggestionAdd.tsx → dashboard-v6/src/components/sentence/SuggestionAdd.tsx


+ 0 - 0
dashboard-v6/src/components/sentence-editor/SuggestionBox.tsx → dashboard-v6/src/components/sentence/SuggestionBox.tsx


+ 0 - 0
dashboard-v6/src/components/sentence-editor/SuggestionButton.tsx → dashboard-v6/src/components/sentence/SuggestionButton.tsx


+ 0 - 0
dashboard-v6/src/components/sentence-editor/SuggestionFocus.tsx → dashboard-v6/src/components/sentence/SuggestionFocus.tsx


+ 0 - 0
dashboard-v6/src/components/sentence-editor/SuggestionList.tsx → dashboard-v6/src/components/sentence/SuggestionList.tsx


+ 0 - 0
dashboard-v6/src/components/sentence-editor/SuggestionPopover.tsx → dashboard-v6/src/components/sentence/SuggestionPopover.tsx


+ 0 - 0
dashboard-v6/src/components/sentence-editor/SuggestionTabs.tsx → dashboard-v6/src/components/sentence/SuggestionTabs.tsx


+ 0 - 0
dashboard-v6/src/components/sentence-editor/SuggestionToolbar.tsx → dashboard-v6/src/components/sentence/SuggestionToolbar.tsx


+ 34 - 0
dashboard-v6/src/components/sentence/components/MdOrigin.tsx

@@ -0,0 +1,34 @@
+import { Typography } from "antd";
+import MdView from "../../general/MdView";
+import { useMemo } from "react";
+import { useAppSelector } from "../../../hooks";
+import { settingInfo } from "../../../reducers/setting";
+import { GetUserSetting } from "../../setting/default";
+import type { TCodeConvertor } from "../../../types/template";
+
+interface IWidget {
+  text?: string;
+}
+const { Text } = Typography;
+
+const MdOrigin = ({ text }: IWidget) => {
+  const settings = useAppSelector(settingInfo);
+
+  /** 派生数据:主巴利编码 */
+  const paliCode = useMemo(() => {
+    const v = GetUserSetting("setting.pali.script.primary", settings);
+    return (v ?? "roman") as TCodeConvertor;
+  }, [settings]);
+  return (
+    <Text className="sent_read_translation" style={{ display: "inline" }}>
+      <MdView
+        style={{ color: "brown", display: "inline" }}
+        html={text}
+        wordWidget
+        convertor={paliCode}
+      />
+    </Text>
+  );
+};
+
+export default MdOrigin;

+ 17 - 0
dashboard-v6/src/components/sentence/components/MdTranslation.tsx

@@ -0,0 +1,17 @@
+import { Typography } from "antd";
+import MdView from "../../general/MdView";
+
+interface IWidget {
+  text?: string;
+}
+const { Text } = Typography;
+
+const MdTranslation = ({ text }: IWidget) => {
+  return (
+    <Text className="sent_read_translation" style={{ display: "inline" }}>
+      <MdView style={{ display: "inline" }} html={text} />
+    </Text>
+  );
+};
+
+export default MdTranslation;

+ 0 - 0
dashboard-v6/src/components/sentence-editor/style.css → dashboard-v6/src/components/sentence/style.css


+ 26 - 0
dashboard-v6/src/components/sentence/utils.ts

@@ -1,5 +1,31 @@
 import type { ISentence, ISentenceData } from "../../api/sentence";
 
+import type { ISentCart } from "./SentCart";
+import store from "../../store";
+import { show } from "../../reducers/discussion";
+import { openPanel } from "../../reducers/right-panel";
+
+export const addToCart = (add: ISentCart[]): number => {
+  const oldText = localStorage.getItem("cart/text");
+  let cartText: ISentCart[] = [];
+  if (oldText) {
+    cartText = JSON.parse(oldText);
+  }
+  cartText = [...cartText, ...add];
+  localStorage.setItem("cart/text", JSON.stringify(cartText));
+  return cartText.length;
+};
+
+export const prOpen = (data: ISentence) => {
+  store.dispatch(
+    show({
+      type: "pr",
+      sent: data,
+    })
+  );
+  store.dispatch(openPanel("suggestion"));
+};
+
 export const toISentence = (
   item: ISentenceData,
   channelsId?: string[]

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

@@ -57,7 +57,6 @@ const Widget = ({ tpl, props, children }: IWidgetMdTpl) => {
       return <Confidence props={props ? props : ""} />;
     case "paragraph":
       return <Paragraph props={props ? props : ""} />;
-
     default:
       return <>未定义模版({tpl})</>;
   }

+ 1 - 1
dashboard-v6/src/components/template/ParaHandle.tsx

@@ -4,7 +4,7 @@ import { fullUrl, scrollToTop } from "../../utils";
 import { useIntl } from "react-intl";
 import store from "../../store";
 import { modeChange } from "../../reducers/article-mode";
-import { addToCart } from "../sentence-editor/utils";
+import { addToCart } from "../sentence/utils";
 
 interface IWidgetParaHandleCtl {
   book: number;

+ 53 - 14
dashboard-v6/src/components/template/Paragraph.tsx

@@ -1,26 +1,36 @@
-import { useMemo } from "react";
+import { useMemo, useState } from "react";
+import { EditOutlined, EyeOutlined } from "@ant-design/icons";
+
 import { useAppSelector } from "../../hooks";
 import { currFocus } from "../../reducers/focus";
 import { ParaHandleCtl } from "./ParaHandle";
+import { SentEditInner, type IWidgetSentEditInner } from "../sentence/SentEdit";
+import { Button } from "antd";
+import type { ArticleMode } from "../../api/article";
+import ParagraphRead from "../tipitaka/components/ParagraphRead";
 
-interface IWidgetParaShellCtl {
+export interface IParagraphProps {
   book: number;
   para: number;
-  mode?: string;
+  mode?: ArticleMode;
   channels?: string[];
   sentenceIds: string[];
-  children?: React.ReactNode | React.ReactNode[];
+  children?: IWidgetSentEditInner[];
+  onModeChange?: (mode: ArticleMode) => void;
 }
-const ParagraphCtl = ({
+export const ParagraphCtl = ({
   book,
   para,
   mode = "read",
   channels,
   sentenceIds,
   children,
-}: IWidgetParaShellCtl) => {
+  onModeChange,
+}: IParagraphProps) => {
+  const [innerMode, setInnerMode] = useState<ArticleMode>("read");
   const focus = useAppSelector(currFocus);
-
+  console.debug("para children", book, para, children?.length);
+  console.debug("para children", children);
   const isFocus = useMemo(() => {
     if (focus) {
       if (focus.focus?.type === "para") {
@@ -58,11 +68,8 @@ const ParagraphCtl = ({
     >
       <div
         style={{
-          position: "absolute",
-          marginTop: -31,
-          marginLeft: -6,
-          border: border,
-          borderRadius: "6px",
+          display: "flex",
+          justifyContent: "space-between",
         }}
       >
         <ParaHandleCtl
@@ -72,8 +79,40 @@ const ParagraphCtl = ({
           channels={channels}
           sentences={sentenceIds}
         />
+        <div>
+          {innerMode === "edit" && (
+            <Button
+              type="link"
+              icon={<EyeOutlined />}
+              onClick={() => {
+                if (onModeChange) {
+                  onModeChange("read");
+                } else {
+                  setInnerMode("read");
+                }
+              }}
+            />
+          )}
+          {innerMode === "read" && (
+            <Button
+              type="link"
+              icon={<EditOutlined />}
+              onClick={() => {
+                if (onModeChange) {
+                  onModeChange("edit");
+                } else {
+                  setInnerMode("edit");
+                }
+              }}
+            />
+          )}
+        </div>
+      </div>
+      <div>
+        {innerMode === "edit" &&
+          children?.map((item) => <SentEditInner {...item} />)}
+        {innerMode === "read" && <ParagraphRead data={children} />}
       </div>
-      {children}
     </div>
   );
 };
@@ -83,7 +122,7 @@ interface IWidget {
   children?: React.ReactNode | React.ReactNode[];
 }
 const Widget = ({ props }: IWidget) => {
-  const prop = JSON.parse(atob(props)) as IWidgetParaShellCtl;
+  const prop = JSON.parse(atob(props)) as IParagraphProps;
   return (
     <>
       <ParagraphCtl {...prop} />

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

@@ -1,7 +1,4 @@
-import {
-  SentEditInner,
-  type IWidgetSentEditInner,
-} from "../sentence-editor/SentEdit";
+import { SentEditInner, type IWidgetSentEditInner } from "../sentence/SentEdit";
 
 interface IWidgetSentEdit {
   props: string;

+ 2 - 2
dashboard-v6/src/components/template/SentRead.tsx

@@ -1,5 +1,5 @@
-import type { IWidgetSentReadFrame } from "../sentence-editor/SentRead";
-import SentReadFrame from "../sentence-editor/SentRead";
+import type { IWidgetSentReadFrame } from "../sentence/SentRead";
+import SentReadFrame from "../sentence/SentRead";
 
 interface IWidget {
   props: string;

+ 20 - 16
dashboard-v6/src/components/tipitaka/BookTreeList.tsx

@@ -141,6 +141,25 @@ const BoolTreeListWidget = ({
     }
   }
 
+  const bcItems = [
+    {
+      title: (
+        <Link to={`/workspace/tipitaka/${currRoot}`}>
+          <HomeOutlined />
+        </Link>
+      ),
+    },
+    ...bookPath.map((item) => {
+      return {
+        title: (
+          <Link to={`/workspace/tipitaka/${currRoot}/${item.to}`}>
+            <PaliText text={item.title} />
+          </Link>
+        ),
+      };
+    }),
+  ];
+
   return (
     <>
       <Row style={{ padding: 10 }}>
@@ -149,22 +168,7 @@ const BoolTreeListWidget = ({
           sm={24}
           style={{ display: "flex", justifyContent: "space-between" }}
         >
-          <Breadcrumb>
-            <Breadcrumb.Item>
-              <Link to={`/workspace/tipitaka/${currRoot}`}>
-                <HomeOutlined />
-              </Link>
-            </Breadcrumb.Item>
-            {bookPath.map((item, id) => {
-              return (
-                <Breadcrumb.Item key={id}>
-                  <Link to={`/workspace/tipitaka/${currRoot}/${item.to}`}>
-                    <PaliText text={item.title} />
-                  </Link>
-                </Breadcrumb.Item>
-              );
-            })}
-          </Breadcrumb>
+          <Breadcrumb items={bcItems} />
           {/**
            * TODO reload
              *           <FullSearchInput

+ 56 - 0
dashboard-v6/src/components/tipitaka/components/ParagraphRead.tsx

@@ -0,0 +1,56 @@
+import MdOrigin from "../../sentence/components/MdOrigin";
+import MdTranslation from "../../sentence/components/MdTranslation";
+import type { IWidgetSentEditInner } from "../../sentence/SentEdit";
+
+interface IWidget {
+  data?: IWidgetSentEditInner[];
+}
+const ParagraphRead = ({ data }: IWidget) => {
+  const channels: string[] = [];
+  data?.forEach((value) => {
+    value.translation?.forEach((trans) => {
+      if (!channels.includes(trans.channel.id)) {
+        channels.push(trans.channel.id);
+      }
+    });
+  });
+  return (
+    <div>
+      <div style={{ display: "flex" }}>
+        {/**原文区 */}
+        <div className="sent_read" style={{ flex: 5, padding: 4 }}>
+          {data?.map((item) => {
+            return item.origin?.map((org, id) => {
+              return <MdOrigin text={org.html} key={id} />;
+            });
+          })}
+        </div>
+        {/**译文区 */}
+        <div
+          className="sent_read"
+          style={{ display: "flex", flex: 5, padding: 4 }}
+        >
+          {channels.map((channel) => {
+            return (
+              <div>
+                {data?.map((item) => {
+                  return item.translation?.map((trans, id) => {
+                    if (trans.channel.id === channel) {
+                      return <MdTranslation text={trans.html} key={id} />;
+                    } else {
+                      return <span>no data</span>;
+                    }
+                  });
+                })}
+              </div>
+            );
+          })}
+        </div>
+      </div>
+      {/**注疏区 */}
+      <div></div>
+    </div>
+  );
+};
+
+export default ParagraphRead;

+ 76 - 78
dashboard-v6/src/components/workspace/home/ModuleCard.tsx

@@ -1,4 +1,7 @@
+// src/components/workspace/home/ModuleCard.tsx
+
 import type { CSSProperties } from "react";
+import { theme } from "antd";
 import { useNavigate } from "react-router";
 import {
   BookOutlined,
@@ -28,6 +31,68 @@ export default function ModuleCard({
   path,
 }: ModuleCardProps) {
   const navigate = useNavigate();
+  const { token } = theme.useToken();
+
+  const styles: Record<string, CSSProperties> = {
+    card: {
+      borderRadius: token.borderRadiusLG,
+      padding: 20,
+      border: `1px solid ${token.colorBorderSecondary}`,
+    },
+    inner: {
+      display: "flex",
+      flexDirection: "column",
+      gap: 12,
+      position: "relative",
+    },
+    icon: {
+      fontSize: 22,
+      width: 44,
+      height: 44,
+      borderRadius: 10,
+      border: "1.5px solid",
+      display: "flex",
+      alignItems: "center",
+      justifyContent: "center",
+      background: token.colorBgContainer + "99",
+    },
+    info: {
+      flex: 1,
+    },
+    titleRow: {
+      display: "flex",
+      alignItems: "baseline",
+      gap: 8,
+      marginBottom: 6,
+    },
+    title: {
+      fontSize: 16,
+      fontWeight: 700,
+      letterSpacing: "-0.01em",
+      fontFamily: "Georgia, serif",
+    },
+    titleZh: {
+      fontSize: 12,
+      color: token.colorTextTertiary,
+    },
+    desc: {
+      fontSize: 13,
+      color: token.colorTextSecondary,
+      lineHeight: 1.6,
+      margin: "0 0 8px",
+    },
+    stats: {
+      fontSize: 12,
+      fontWeight: 500,
+    },
+    arrow: {
+      position: "absolute",
+      top: 0,
+      right: 0,
+      fontSize: 14,
+      opacity: 0.5,
+    },
+  };
 
   return (
     <>
@@ -37,13 +102,7 @@ export default function ModuleCard({
         onClick={() => navigate(path)}
       >
         <div style={styles.inner}>
-          <div
-            style={{
-              ...styles.icon,
-              color,
-              borderColor: color + "33",
-            }}
-          >
+          <div style={{ ...styles.icon, color, borderColor: color + "33" }}>
             {iconMap[icon]}
           </div>
           <div style={styles.info}>
@@ -59,77 +118,16 @@ export default function ModuleCard({
       </div>
 
       <style>{`
-        .workspace-module-card {
-          cursor: pointer;
-          transition: transform 0.2s ease, box-shadow 0.2s ease;
-          box-shadow: 0 1px 4px rgba(0,0,0,0.06);
-        }
-        .workspace-module-card:hover {
-          transform: translateY(-3px);
-          box-shadow: 0 8px 24px rgba(0,0,0,0.1);
-        }
-      `}</style>
+  .workspace-module-card {
+    cursor: pointer;
+    transition: transform 0.2s ease, box-shadow 0.2s ease;
+    box-shadow: ${token.boxShadowTertiary};
+  }
+  .workspace-module-card:hover {
+    transform: translateY(-3px);
+    box-shadow: ${token.boxShadow};
+  }
+`}</style>
     </>
   );
 }
-
-const styles: Record<string, CSSProperties> = {
-  card: {
-    borderRadius: 12,
-    padding: 20,
-    border: "1px solid rgba(0,0,0,0.06)",
-  },
-  inner: {
-    display: "flex",
-    flexDirection: "column",
-    gap: 12,
-    position: "relative",
-  },
-  icon: {
-    fontSize: 22,
-    width: 44,
-    height: 44,
-    borderRadius: 10,
-    border: "1.5px solid",
-    display: "flex",
-    alignItems: "center",
-    justifyContent: "center",
-    background: "rgba(255,255,255,0.6)",
-  },
-  info: {
-    flex: 1,
-  },
-  titleRow: {
-    display: "flex",
-    alignItems: "baseline",
-    gap: 8,
-    marginBottom: 6,
-  },
-  title: {
-    fontSize: 16,
-    fontWeight: 700,
-    letterSpacing: "-0.01em",
-    fontFamily: "Georgia, serif",
-  },
-  titleZh: {
-    fontSize: 12,
-    color: "#a09080",
-  },
-  desc: {
-    fontSize: 13,
-    color: "#6b5f52",
-    lineHeight: 1.6,
-    margin: "0 0 8px",
-  },
-  stats: {
-    fontSize: 12,
-    fontWeight: 500,
-  },
-  arrow: {
-    position: "absolute",
-    top: 0,
-    right: 0,
-    fontSize: 14,
-    opacity: 0.5,
-  },
-};

+ 1 - 0
dashboard-v6/src/components/workspace/home/ModuleGrid.tsx

@@ -1,3 +1,4 @@
+// src/components/workspace/home/ModuleGrid.tsx
 import type { CSSProperties } from "react";
 import type { ModuleItem } from "../../../api/workspace";
 import ModuleCard from "./ModuleCard";

+ 60 - 58
dashboard-v6/src/components/workspace/home/RecentItem.tsx

@@ -1,4 +1,5 @@
 import type { CSSProperties } from "react";
+import { theme } from "antd";
 import { ClockCircleOutlined } from "@ant-design/icons";
 import type { RecentItem as RecentItemType } from "../../../api/workspace";
 import type { ArticleType } from "../../../api/article";
@@ -34,8 +35,66 @@ export default function RecentItem({
   time,
   onClick,
 }: RecentItemProps) {
+  const { token } = theme.useToken();
   const color = typeColor[type];
 
+  const styles: Record<string, CSSProperties> = {
+    row: {
+      display: "flex",
+      alignItems: "center",
+      gap: 14,
+      padding: "14px 20px",
+      borderBottom: `1px solid ${token.colorBorderSecondary}`,
+      background: token.colorBgContainer,
+    },
+    emoji: {
+      fontSize: 20,
+      width: 36,
+      textAlign: "center",
+      flexShrink: 0,
+    },
+    info: {
+      flex: 1,
+      display: "flex",
+      flexDirection: "column",
+      gap: 2,
+      minWidth: 0,
+    },
+    title: {
+      fontSize: 14,
+      fontWeight: 500,
+      color: token.colorText,
+      overflow: "hidden",
+      textOverflow: "ellipsis",
+      whiteSpace: "nowrap",
+    },
+    subtitle: {
+      fontSize: 12,
+      color: token.colorTextTertiary,
+    },
+    right: {
+      display: "flex",
+      flexDirection: "column",
+      alignItems: "flex-end",
+      gap: 4,
+      flexShrink: 0,
+    },
+    tag: {
+      fontSize: 11,
+      fontWeight: 600,
+      padding: "2px 8px",
+      borderRadius: 4,
+      letterSpacing: "0.04em",
+      textTransform: "uppercase",
+    },
+    time: {
+      fontSize: 11,
+      color: token.colorTextQuaternary,
+      display: "flex",
+      alignItems: "center",
+    },
+  };
+
   return (
     <>
       <div
@@ -65,66 +124,9 @@ export default function RecentItem({
           cursor: pointer;
         }
         .workspace-recent-item:hover {
-          background: #f7f7f5 !important;
+          background: ${token.colorBgTextHover} !important;
         }
       `}</style>
     </>
   );
 }
-
-const styles: Record<string, CSSProperties> = {
-  row: {
-    display: "flex",
-    alignItems: "center",
-    gap: 14,
-    padding: "14px 20px",
-    borderBottom: "1px solid #f0ece6",
-    background: "#fff",
-  },
-  emoji: {
-    fontSize: 20,
-    width: 36,
-    textAlign: "center",
-    flexShrink: 0,
-  },
-  info: {
-    flex: 1,
-    display: "flex",
-    flexDirection: "column",
-    gap: 2,
-    minWidth: 0,
-  },
-  title: {
-    fontSize: 14,
-    fontWeight: 500,
-    color: "#2d2416",
-    overflow: "hidden",
-    textOverflow: "ellipsis",
-    whiteSpace: "nowrap",
-  },
-  subtitle: {
-    fontSize: 12,
-    color: "#a09080",
-  },
-  right: {
-    display: "flex",
-    flexDirection: "column",
-    alignItems: "flex-end",
-    gap: 4,
-    flexShrink: 0,
-  },
-  tag: {
-    fontSize: 11,
-    fontWeight: 600,
-    padding: "2px 8px",
-    borderRadius: 4,
-    letterSpacing: "0.04em",
-    textTransform: "uppercase",
-  },
-  time: {
-    fontSize: 11,
-    color: "#b5a898",
-    display: "flex",
-    alignItems: "center",
-  },
-};

+ 12 - 9
dashboard-v6/src/components/workspace/home/RecentList.tsx

@@ -1,4 +1,5 @@
 import type { CSSProperties } from "react";
+import { theme } from "antd";
 import type { RecentItem as RecentItemType } from "../../../api/workspace";
 import RecentItem from "./RecentItem";
 
@@ -7,6 +8,17 @@ type RecentListProps = {
 };
 
 export default function RecentList({ items }: RecentListProps) {
+  const { token } = theme.useToken();
+
+  const styles: Record<string, CSSProperties> = {
+    list: {
+      background: token.colorBgContainer,
+      borderRadius: token.borderRadiusLG,
+      border: `1px solid ${token.colorBorderSecondary}`,
+      overflow: "hidden",
+    },
+  };
+
   return (
     <div style={styles.list}>
       {items.map((item) => (
@@ -15,12 +27,3 @@ export default function RecentList({ items }: RecentListProps) {
     </div>
   );
 }
-
-const styles: Record<string, CSSProperties> = {
-  list: {
-    background: "#fff",
-    borderRadius: 12,
-    border: "1px solid #ede9e3",
-    overflow: "hidden",
-  },
-};

+ 37 - 36
dashboard-v6/src/components/workspace/home/WorkspaceHero.tsx

@@ -1,9 +1,45 @@
 import { useMemo, type CSSProperties } from "react";
+import { theme } from "antd";
 import { getGreeting } from "../../../api/greetings";
 
 export default function WorkspaceHero() {
-  // useMemo 确保同一次渲染内问候语固定,不会因重渲染随机变化
   const greeting = useMemo(() => getGreeting(), []);
+  const { token } = theme.useToken();
+
+  const styles: Record<string, CSSProperties> = {
+    hero: {
+      background: `linear-gradient(160deg, ${token.colorBgContainer} 0%, ${token.colorBgLayout} 100%)`,
+      borderBottom: `1px solid ${token.colorBorderSecondary}`,
+      padding: "48px 0 36px",
+    },
+    inner: {
+      maxWidth: 880,
+      margin: "0 auto",
+      padding: "0 32px",
+    },
+    label: {
+      fontSize: 13,
+      color: token.colorTextQuaternary,
+      letterSpacing: "0.12em",
+      textTransform: "uppercase",
+      marginBottom: 8,
+      fontFamily: "Georgia, serif",
+    },
+    title: {
+      fontSize: 36,
+      fontWeight: 700,
+      color: token.colorText,
+      margin: "0 0 8px",
+      letterSpacing: "-0.02em",
+      fontFamily: "'Noto Serif SC', 'Source Han Serif CN', Georgia, serif",
+    },
+    sub: {
+      fontSize: 15,
+      color: token.colorTextSecondary,
+      margin: 0,
+      fontFamily: "'Noto Serif SC', 'Source Han Serif CN', Georgia, serif",
+    },
+  };
 
   return (
     <div style={styles.hero}>
@@ -15,38 +51,3 @@ export default function WorkspaceHero() {
     </div>
   );
 }
-
-const styles: Record<string, CSSProperties> = {
-  hero: {
-    background: "linear-gradient(160deg, #fff 0%, #f0ede8 100%)",
-    borderBottom: "1px solid #e8e4de",
-    padding: "48px 0 36px",
-  },
-  inner: {
-    maxWidth: 880,
-    margin: "0 auto",
-    padding: "0 32px",
-  },
-  label: {
-    fontSize: 13,
-    color: "#b5a898",
-    letterSpacing: "0.12em",
-    textTransform: "uppercase",
-    marginBottom: 8,
-    fontFamily: "Georgia, serif",
-  },
-  title: {
-    fontSize: 36,
-    fontWeight: 700,
-    color: "#2d2416",
-    margin: "0 0 8px",
-    letterSpacing: "-0.02em",
-    fontFamily: "'Noto Serif SC', 'Source Han Serif CN', Georgia, serif",
-  },
-  sub: {
-    fontSize: 15,
-    color: "#8c7e6e",
-    margin: 0,
-    fontFamily: "'Noto Serif SC', 'Source Han Serif CN', Georgia, serif",
-  },
-};

+ 14 - 1
dashboard-v6/src/hooks/useTipitaka.ts

@@ -8,6 +8,7 @@ import type {
   IChapterToc,
 } from "../api/article";
 import { fetchChapter, fetchNextParaChunk, fetchPara } from "../api/pali-text";
+import type { IParagraphProps } from "../components/template/Paragraph";
 
 interface IUseTipitakaProps {
   type?: ArticleType;
@@ -22,6 +23,7 @@ interface IUseTipitakaProps {
 interface IUseTipitakaReturn {
   articleData: IArticleDataResponse | undefined;
   articleHtml: string[];
+  nodeData: IParagraphProps[];
   toc: IChapterToc[] | undefined;
   loading: boolean;
   errorCode: number | undefined;
@@ -40,7 +42,8 @@ const useTipitaka = ({
   active = true,
 }: IUseTipitakaProps): IUseTipitakaReturn => {
   const [articleData, setArticleData] = useState<IArticleDataResponse>();
-  const [articleHtml, setArticleHtml] = useState<string[]>(["<span />"]);
+  const [articleHtml, setArticleHtml] = useState<string[]>([]);
+  const [nodeData, setNodeData] = useState<IParagraphProps[]>([]);
   const [toc, setToc] = useState<IChapterToc[]>();
   const [loading, setLoading] = useState(false);
   const [errorCode, setErrorCode] = useState<number>();
@@ -79,6 +82,9 @@ const useTipitaka = ({
           setArticleHtml([
             response.data.html ?? response.data.content ?? "<span />",
           ]);
+          if (response.data.content && response.data.content_type === "json") {
+            setNodeData(JSON.parse(response.data.content));
+          }
           setToc(response.data.toc);
           setRemains(response.data.from !== undefined);
           setErrorCode(undefined);
@@ -131,6 +137,12 @@ const useTipitaka = ({
           return prev;
         });
         setArticleHtml((prev) => [...prev, response.data.content as string]);
+        if (response.data.content && response.data.content_type === "json") {
+          const newNodes: IParagraphProps[] = JSON.parse(
+            response.data.content
+          ) as IParagraphProps[];
+          setNodeData((prev) => [...prev, ...newNodes]);
+        }
       }
     } catch (e) {
       console.error("loadNextChunk error", e);
@@ -144,6 +156,7 @@ const useTipitaka = ({
   return {
     articleData,
     articleHtml,
+    nodeData,
     toc,
     loading,
     errorCode,

+ 1 - 1
dashboard-v6/src/pages/workspace/home.tsx

@@ -1,3 +1,4 @@
+// src/pages/workspace/home.tsx
 import { useEffect, useState, type CSSProperties } from "react";
 import WorkspaceHero from "../../components/workspace/home/WorkspaceHero";
 import SectionPanel from "../../components/workspace/home/SectionPanel";
@@ -42,7 +43,6 @@ export default function WorkspaceHome() {
 const styles: Record<string, CSSProperties> = {
   page: {
     minHeight: "100vh",
-    background: "#f9f8f6",
     fontFamily: "'Noto Serif SC', 'Source Han Serif CN', Georgia, serif",
   },
   content: {

+ 2 - 4
dashboard-v6/src/routes/testRoutes.tsx

@@ -4,11 +4,9 @@ import type { ComponentType } from "react";
 const TestVideoPlayerTest = lazy(
   () => import("../components/video/VideoPlayerTest")
 );
-const SentSimTest = lazy(
-  () => import("../components/sentence-editor/SentSimTest")
-);
+const SentSimTest = lazy(() => import("../components/sentence/SentSimTest"));
 const SentEditInnerDemo = lazy(
-  () => import("../components/sentence-editor/SentEditInnerDemo")
+  () => import("../components/sentence/SentEditInnerDemo")
 );
 const EditableTreeTest = lazy(
   () => import("../components/article/components/EditableTreeTest")