visuddhinanda 1 ay önce
ebeveyn
işleme
464b71dfe0
96 değiştirilmiş dosya ile 895 ekleme ve 291 silme
  1. 16 15
      api-v12/app/Http/Controllers/RecentController.php
  2. 1 1
      api-v12/app/Http/Controllers/SentenceController.php
  3. 21 19
      api-v12/app/Http/Resources/RecentResource.php
  4. 1 1
      dashboard-v6/src/Router.tsx
  5. 1 1
      dashboard-v6/src/api/Comment.ts
  6. 0 33
      dashboard-v6/src/api/article.ts
  7. 64 0
      dashboard-v6/src/api/pali-text.ts
  8. 32 5
      dashboard-v6/src/api/recent.ts
  9. 1 1
      dashboard-v6/src/api/sentence.ts
  10. 1 1
      dashboard-v6/src/api/view.ts
  11. 2 2
      dashboard-v6/src/api/workspace.ts
  12. 1 1
      dashboard-v6/src/components/anthology/AddToAnthology.tsx
  13. 1 1
      dashboard-v6/src/components/anthology/AnthologiesAtArticle.tsx
  14. 1 1
      dashboard-v6/src/components/anthology/AnthologyCard.tsx
  15. 1 1
      dashboard-v6/src/components/anthology/AnthologyCreate.tsx
  16. 1 1
      dashboard-v6/src/components/anthology/AnthologyInfoEdit.tsx
  17. 1 1
      dashboard-v6/src/components/anthology/AnthologyList.tsx
  18. 1 1
      dashboard-v6/src/components/anthology/AnthologySelect.tsx
  19. 1 1
      dashboard-v6/src/components/anthology/AnthologyStudioList.tsx
  20. 1 1
      dashboard-v6/src/components/anthology/AnthologyTocTree.tsx
  21. 1 1
      dashboard-v6/src/components/anthology/EditableTocTree.tsx
  22. 1 1
      dashboard-v6/src/components/anthology/components/AnthologyInfo.tsx
  23. 1 1
      dashboard-v6/src/components/anthology/hooks/useAnthology.tsx
  24. 1 1
      dashboard-v6/src/components/article/ArticleCreate.tsx
  25. 1 1
      dashboard-v6/src/components/article/ArticleDrawer.tsx
  26. 1 1
      dashboard-v6/src/components/article/ArticleEdit.tsx
  27. 1 1
      dashboard-v6/src/components/article/ArticleEditDrawer.tsx
  28. 1 1
      dashboard-v6/src/components/article/ArticleList.tsx
  29. 1 1
      dashboard-v6/src/components/article/ArticlePrevDrawer.tsx
  30. 1 1
      dashboard-v6/src/components/article/ArticleReader.tsx
  31. 1 1
      dashboard-v6/src/components/article/ArticleReaderTest.tsx
  32. 1 1
      dashboard-v6/src/components/article/TypeAnthology.tsx
  33. 1 1
      dashboard-v6/src/components/article/TypeArticle.tsx
  34. 18 7
      dashboard-v6/src/components/article/TypePali.tsx
  35. 1 1
      dashboard-v6/src/components/article/TypePaliTest.tsx
  36. 1 1
      dashboard-v6/src/components/article/TypeTerm.tsx
  37. 1 1
      dashboard-v6/src/components/article/components/ArticleNavigation.tsx
  38. 1 1
      dashboard-v6/src/components/article/components/Navigate.tsx
  39. 2 2
      dashboard-v6/src/components/article/hooks/useArticle.ts
  40. 2 2
      dashboard-v6/src/components/article/hooks/useArticleList.ts
  41. 2 2
      dashboard-v6/src/components/article/hooks/useArticleListControlled.ts
  42. 2 2
      dashboard-v6/src/components/article/hooks/useArticleMutations.ts
  43. 1 1
      dashboard-v6/src/components/article/hooks/useTerm.ts
  44. 1 1
      dashboard-v6/src/components/attachment/AttachmentList.tsx
  45. 1 1
      dashboard-v6/src/components/channel/ChannelMy.tsx
  46. 1 1
      dashboard-v6/src/components/channel/ChannelPicker.tsx
  47. 1 1
      dashboard-v6/src/components/channel/ChannelPickerTable.tsx
  48. 1 1
      dashboard-v6/src/components/channel/ChannelTable.tsx
  49. 1 1
      dashboard-v6/src/components/channel/ChannelTableModal.tsx
  50. 1 1
      dashboard-v6/src/components/channel/CopyToStep.tsx
  51. 1 1
      dashboard-v6/src/components/channel/hooks/useChannelProgress.ts
  52. 1 1
      dashboard-v6/src/components/dict/UserDictList.tsx
  53. 1 1
      dashboard-v6/src/components/dict/UserDictTable.tsx
  54. 1 1
      dashboard-v6/src/components/discussion/AnchorCard.tsx
  55. 1 1
      dashboard-v6/src/components/discussion/DiscussionAnchor.tsx
  56. 1 1
      dashboard-v6/src/components/discussion/DiscussionCreate.tsx
  57. 1 1
      dashboard-v6/src/components/discussion/DiscussionListCard.tsx
  58. 1 1
      dashboard-v6/src/components/discussion/DiscussionShow.tsx
  59. 65 58
      dashboard-v6/src/components/recent/Recent.tsx
  60. 1 44
      dashboard-v6/src/components/recent/RecentList.tsx
  61. 64 0
      dashboard-v6/src/components/recent/RecentRead.tsx
  62. 1 1
      dashboard-v6/src/components/sentence-editor/SentCell.tsx
  63. 1 1
      dashboard-v6/src/components/sentence-editor/SentContent.tsx
  64. 1 1
      dashboard-v6/src/components/sentence-editor/SentEdit.tsx
  65. 1 1
      dashboard-v6/src/components/sentence-editor/SentEditMenu.tsx
  66. 1 1
      dashboard-v6/src/components/sentence-editor/SentMenu.tsx
  67. 1 1
      dashboard-v6/src/components/sentence-editor/SentTab.tsx
  68. 1 1
      dashboard-v6/src/components/tag/TagsOnItem.tsx
  69. 1 1
      dashboard-v6/src/components/term/TermList.tsx
  70. 38 0
      dashboard-v6/src/components/tipitaka/PaliTextToc.tsx
  71. 216 0
      dashboard-v6/src/components/tipitaka/TocTree.tsx
  72. 153 0
      dashboard-v6/src/components/tipitaka/hooks/usePaliBookToc.ts
  73. 1 1
      dashboard-v6/src/components/token/Token.tsx
  74. 1 1
      dashboard-v6/src/components/token/TokenModal.tsx
  75. 1 1
      dashboard-v6/src/components/tpl-builder/ArticleTpl.tsx
  76. 1 1
      dashboard-v6/src/components/tpl-builder/TplBuilder.tsx
  77. 1 1
      dashboard-v6/src/components/wbw/WbwMeaning.tsx
  78. 1 1
      dashboard-v6/src/components/wbw/WbwPali.tsx
  79. 1 1
      dashboard-v6/src/components/wbw/WbwSentCtl.tsx
  80. 1 1
      dashboard-v6/src/components/wbw/WbwWord.tsx
  81. 1 1
      dashboard-v6/src/components/webhook/WebhookList.tsx
  82. 1 1
      dashboard-v6/src/components/workspace/home/RecentItem.tsx
  83. 1 1
      dashboard-v6/src/features/editor/Article.tsx
  84. 18 3
      dashboard-v6/src/features/editor/Chapter.tsx
  85. 27 2
      dashboard-v6/src/features/editor/Term.tsx
  86. 6 4
      dashboard-v6/src/hooks/useRecent.ts.ts
  87. 67 0
      dashboard-v6/src/hooks/useSaveRecent.ts
  88. 4 13
      dashboard-v6/src/hooks/useTipitaka.ts
  89. 1 4
      dashboard-v6/src/layouts/Root.tsx
  90. 2 2
      dashboard-v6/src/layouts/workspace/index.tsx
  91. 1 1
      dashboard-v6/src/pages/workspace/article/show.tsx
  92. 1 1
      dashboard-v6/src/pages/workspace/tipitaka/bypath.tsx
  93. 1 1
      dashboard-v6/src/pages/workspace/tipitaka/chapter.tsx
  94. 1 1
      dashboard-v6/src/reducers/article-mode.ts
  95. 1 1
      dashboard-v6/src/reducers/para-change.ts
  96. 1 1
      dashboard-v6/src/types/article.ts

+ 16 - 15
api-v12/app/Http/Controllers/RecentController.php

@@ -20,20 +20,22 @@ class RecentController extends Controller
         //
         switch ($request->view) {
             case 'user':
-                $table = Recent::where('user_uid',$request->get('id'));
+                $table = Recent::where('user_uid', $request->get('id'));
                 break;
             default:
                 return $this->error('known view');
                 break;
         }
-
-        $table->orderBy($request->get('order','updated_at'),$request->get('dir','desc'));
+        if ($request->has('type')) {
+            $table->where('type', $request->get('type'));
+        }
+        $table->orderBy($request->get('order', 'updated_at'), $request->get('dir', 'desc'));
         $count = $table->count();
-        $table->skip($request->get("offset",0))
-              ->take($request->get('limit',1000));
+        $table->skip($request->get("offset", 0))
+            ->take($request->get('limit', 1000));
 
         $result = $table->get();
-		return $this->ok(["rows"=>RecentResource::collection($result),"count"=>$count]);
+        return $this->ok(["rows" => RecentResource::collection($result), "count" => $count]);
     }
 
     /**
@@ -45,8 +47,8 @@ class RecentController extends Controller
     public function store(Request $request)
     {
         $user = AuthApi::current($request);
-        if(!$user){
-            return $this->error(__('auth.failed'),[],401);
+        if (!$user) {
+            return $this->error(__('auth.failed'), [], 401);
         }
 
         $validated = $request->validate([
@@ -55,13 +57,13 @@ class RecentController extends Controller
         ]);
 
         $row = Recent::firstOrNew([
-            "type"=>$request->get("type"),
-            "article_id"=>$request->get("article_id"),
-            "user_uid"=>$user['user_uid'],
-        ],[
-            "id"=>Str::uuid(),
+            "type" => $request->get("type"),
+            "article_id" => $request->get("article_id"),
+            "user_uid" => $user['user_uid'],
+        ], [
+            "id" => Str::uuid(),
         ]);
-        $row->param = $request->get("param",null);
+        $row->param = $request->get("param", null);
         $row->save();
         return $this->ok(new RecentResource($row));
     }
@@ -76,7 +78,6 @@ class RecentController extends Controller
     {
         //
         return $this->ok(new RecentResource($recent));
-
     }
 
     /**

+ 1 - 1
api-v12/app/Http/Controllers/SentenceController.php

@@ -232,7 +232,7 @@ class SentenceController extends Controller
             }
             return $this->ok($output);
         } else {
-            return $this->error("没有查询到数据");
+            return $this->ok([]);
         }
     }
     /**

+ 21 - 19
api-v12/app/Http/Resources/RecentResource.php

@@ -6,6 +6,7 @@ use Illuminate\Http\Resources\Json\JsonResource;
 use App\Http\Api\ChannelApi;
 use App\Models\Sentence;
 use App\Models\Article;
+use App\Models\DhammaTerm;
 
 class RecentResource extends JsonResource
 {
@@ -27,35 +28,36 @@ class RecentResource extends JsonResource
         $title = '';
         switch ($this->type) {
             case 'article':
-                $title = Article::where('uid',$this->article_id)->value('title');
+                $title = Article::where('uid', $this->article_id)->value('title');
                 break;
             case 'chapter':
-
-
-                if(!empty($this->param)){
-                    $param = json_decode($this->param,true);
-                    if(isset($param['channel'])){
-                        $channelId = explode('_',$param['channel'])[0];
+                if (!empty($this->param)) {
+                    $param = json_decode($this->param, true);
+                    if (isset($param['channel'])) {
+                        $channelId = explode('_', $param['channel'])[0];
                     }
                 }
                 $paliChannel = ChannelApi::getSysChannel('_System_Pali_VRI_');
-                if(!isset($channelId)){
+                if (!isset($channelId)) {
                     $channelId = $paliChannel;
                 }
-                $para = explode('-',$this->article_id);
-                if(count($para)===2){
-                    $title = Sentence::where('book_id',(int)$para[0])
-                                        ->where('paragraph',(int)$para[1])
-                                        ->where('channel_uid',$channelId)
-                                        ->value('content');
-                    if(empty($title)){
-                        $title = Sentence::where('book_id',(int)$para[0])
-                                            ->where('paragraph',(int)$para[1])
-                                            ->where('channel_uid',$paliChannel)
-                                            ->value('content');
+                $para = explode('-', $this->article_id);
+                if (count($para) === 2) {
+                    $title = Sentence::where('book_id', (int)$para[0])
+                        ->where('paragraph', (int)$para[1])
+                        ->where('channel_uid', $channelId)
+                        ->value('content');
+                    if (empty($title)) {
+                        $title = Sentence::where('book_id', (int)$para[0])
+                            ->where('paragraph', (int)$para[1])
+                            ->where('channel_uid', $paliChannel)
+                            ->value('content');
                     }
                 }
 
+                break;
+            case 'term':
+                $title = DhammaTerm::where('guid', $this->article_id)->value('word');
                 break;
             default:
                 $title = $this->article_id;

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

@@ -4,7 +4,7 @@ import { RouterProvider } from "react-router/dom";
 import { channelLoader } from "./api/channel";
 import { testRoutes } from "./routes/testRoutes";
 import { buildRouteConfig } from "./routes/buildRoutes";
-import { anthologyLoader, articleLoader } from "./api/Article";
+import { anthologyLoader, articleLoader } from "./api/article";
 import { termLoader } from "./api/Term";
 
 const RootLayout = lazy(() => import("./layouts/Root"));

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

@@ -1,5 +1,5 @@
 import type { IUser } from "./Auth";
-import type { TContentType } from "./Article";
+import type { TContentType } from "./article";
 import type { TDiscussionType, TResType } from "./discussion";
 import type { ITagMapData } from "./Tag";
 

+ 0 - 33
dashboard-v6/src/api/Article.ts → dashboard-v6/src/api/article.ts

@@ -537,39 +537,6 @@ export const fetchArticleList = (
 
 // src/api/Article.ts 新增部分
 
-export const fetchChapterArticle = (
-  articleId: string,
-  mode: "read" | "edit",
-  channelId?: string | null
-): Promise<IArticleResponse> => {
-  let url = `/api/v2/corpus-chapter/${articleId}?mode=${mode}`;
-  if (channelId) url += `&channels=${channelId}`;
-  return get<IArticleResponse>(url);
-};
-
-export const fetchParaArticle = (
-  book: string,
-  para: string,
-  mode: "read" | "edit",
-  channelId?: string | null
-): Promise<IArticleResponse> => {
-  let url = `/api/v2/corpus?view=para&book=${book}&par=${para}&mode=${mode}`;
-  if (channelId) url += `&channels=${channelId}`;
-  return get<IArticleResponse>(url);
-};
-
-export const fetchNextParaChunk = (
-  paraId: string,
-  mode: string,
-  from: number,
-  to: number,
-  channelId?: string | null
-): Promise<IArticleResponse> => {
-  let url = `/api/v2/corpus-chapter/${paraId}?mode=${mode}&from=${from}&to=${to}`;
-  if (channelId) url += `&channels=${channelId}`;
-  return get<IArticleResponse>(url);
-};
-
 export const fetchAnthology = (id: string): Promise<IAnthologyResponse> => {
   return get<IAnthologyResponse>(`/api/v2/anthology/${id}`);
 };

+ 64 - 0
dashboard-v6/src/api/pali-text.ts

@@ -1,4 +1,6 @@
+// src/api/pali-text.ts
 import type { MenuProps } from "antd";
+
 export interface ITocPathNode {
   key?: string;
   book?: number;
@@ -100,3 +102,65 @@ export interface IChapterTocListResponse {
   message: string;
   data: { rows: IChapterToc[]; count: number };
 }
+
+import { get } from "../request";
+import type { IArticleResponse } from "./article";
+
+export interface IFetchPaliBookTocParams {
+  /** 二选一:传 series 走系列模式,否则传 book + para */
+  series?: string;
+  book?: number;
+  para?: number;
+}
+
+/**
+ * 获取巴利文目录列表
+ * GET /api/v2/palitext?view=book-toc[&series=xxx | &book=x&para=y]
+ */
+export const fetchPaliBookToc = (
+  params: IFetchPaliBookTocParams
+): Promise<IPaliTocListResponse> => {
+  const query = new URLSearchParams({ view: "book-toc" });
+
+  if (params.series) {
+    query.set("series", params.series);
+  } else {
+    if (params.book !== undefined) query.set("book", String(params.book));
+    if (params.para !== undefined) query.set("para", String(params.para));
+  }
+
+  return get<IPaliTocListResponse>(`/api/v2/palitext?${query.toString()}`);
+};
+
+export const fetchChapter = (
+  articleId: string,
+  mode: "read" | "edit",
+  channelId?: string | null
+): Promise<IArticleResponse> => {
+  let url = `/api/v2/corpus-chapter/${articleId}?mode=${mode}`;
+  if (channelId) url += `&channels=${channelId}`;
+  return get<IArticleResponse>(url);
+};
+
+export const fetchPara = (
+  book: string,
+  para: string,
+  mode: "read" | "edit",
+  channelId?: string | null
+): Promise<IArticleResponse> => {
+  let url = `/api/v2/corpus?view=para&book=${book}&par=${para}&mode=${mode}`;
+  if (channelId) url += `&channels=${channelId}`;
+  return get<IArticleResponse>(url);
+};
+
+export const fetchNextParaChunk = (
+  paraId: string,
+  mode: string,
+  from: number,
+  to: number,
+  channelId?: string | null
+): Promise<IArticleResponse> => {
+  let url = `/api/v2/corpus-chapter/${paraId}?mode=${mode}&from=${from}&to=${to}`;
+  if (channelId) url += `&channels=${channelId}`;
+  return get<IArticleResponse>(url);
+};

+ 32 - 5
dashboard-v6/src/api/recent.ts

@@ -1,5 +1,6 @@
-import { get } from "../request";
-import type { ArticleType } from "./Article";
+// src/api/recent.ts
+import { get, post } from "../request";
+import type { ArticleType } from "./article";
 
 export interface IRecent {
   id: string;
@@ -44,13 +45,39 @@ export interface IRecentListResponse {
   };
 }
 
+// 新增
+export interface IGetRecentByUserParams {
+  userId: string;
+  pageSize: number;
+  page?: number;
+  type?: ArticleType;
+}
+
+// 修改
 export const getRecentByUser = async (
-  userId: string,
-  pageSize: number,
-  page: number = 0
+  params: IGetRecentByUserParams
 ): Promise<IRecentListResponse> => {
+  const { userId, pageSize, page = 0, type } = params;
+
   let url = `/api/v2/recent?view=user&id=${userId}`;
   url += `&limit=${pageSize}&offset=${page}`;
+  if (type !== undefined) url += `&type=${type}`;
+
   console.log("url", url);
   return await get<IRecentListResponse>(url);
 };
+
+export interface ISaveRecentRequest {
+  type: ArticleType;
+  article_id: string;
+  param?: string;
+}
+
+export const saveRecent = async (
+  payload: ISaveRecentRequest
+): Promise<IRecentResponse> => {
+  return await post<ISaveRecentRequest, IRecentResponse>(
+    "/api/v2/recent",
+    payload
+  );
+};

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

@@ -1,5 +1,5 @@
 import type { IntlShape } from "react-intl";
-import type { ArticleMode, TContentType } from "./Article";
+import type { ArticleMode, TContentType } from "./article";
 
 import { get, put } from "../request";
 import { message } from "antd";

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

@@ -1,4 +1,4 @@
-import type { ArticleType } from "./Article";
+import type { ArticleType } from "./article";
 
 export interface IViewRequest {
   target_type: ArticleType;

+ 2 - 2
dashboard-v6/src/api/workspace.ts

@@ -1,4 +1,4 @@
-import type { ArticleType } from "./Article";
+import type { ArticleType } from "./article";
 import { getRecentByUser } from "./recent";
 
 export type ModuleItem = {
@@ -67,7 +67,7 @@ export async function fetchModules(): Promise<ModuleItem[]> {
 
 // TODO: replace with real fetch
 export async function fetchRecentItems(userId: string): Promise<RecentItem[]> {
-  const res = await getRecentByUser(userId, 10);
+  const res = await getRecentByUser({ userId, pageSize: 10 });
   return res.data.rows.map((item, id) => {
     return {
       id: id,

+ 1 - 1
dashboard-v6/src/components/anthology/AddToAnthology.tsx

@@ -5,7 +5,7 @@ import AnthologyModal from "../anthology/AnthologyModal";
 import type {
   IArticleMapAddRequest,
   IArticleMapAddResponse,
-} from "../../api/Article";
+} from "../../api/article";
 import { useAppSelector } from "../../hooks";
 import { currentUser } from "../../reducers/current-user";
 

+ 1 - 1
dashboard-v6/src/components/anthology/AnthologiesAtArticle.tsx

@@ -1,7 +1,7 @@
 import { Space, Typography, message } from "antd";
 import { useEffect, useState } from "react";
 import { get } from "../../request";
-import type { IArticleMapListResponse } from "../../api/Article";
+import type { IArticleMapListResponse } from "../../api/article";
 
 const { Link, Paragraph } = Typography;
 interface IList {

+ 1 - 1
dashboard-v6/src/components/anthology/AnthologyCard.tsx

@@ -4,7 +4,7 @@ import { Card } from "antd";
 import { Typography } from "antd";
 
 import StudioName from "../auth/Studio";
-import type { IAnthologyData } from "../../api/Article";
+import type { IAnthologyData } from "../../api/article";
 
 const { Title, Text } = Typography;
 

+ 1 - 1
dashboard-v6/src/components/anthology/AnthologyCreate.tsx

@@ -10,7 +10,7 @@ import LangSelect from "../general/LangSelect";
 import type {
   IAnthologyCreateRequest,
   IAnthologyResponse,
-} from "../../api/Article";
+} from "../../api/article";
 import { post } from "../../request";
 import { useRef } from "react";
 

+ 1 - 1
dashboard-v6/src/components/anthology/AnthologyInfoEdit.tsx

@@ -13,7 +13,7 @@ import type {
   IAnthologyDataRequest,
   IAnthologyDataResponse,
   IAnthologyResponse,
-} from "../../api/Article";
+} from "../../api/article";
 import LangSelect from "../general/LangSelect";
 import PublicitySelect from "../studio/PublicitySelect";
 import { useState } from "react";

+ 1 - 1
dashboard-v6/src/components/anthology/AnthologyList.tsx

@@ -15,7 +15,7 @@ import AnthologyCreate from "./AnthologyCreate";
 import type {
   IAnthologyListResponse,
   IDeleteResponse,
-} from "../../api/Article";
+} from "../../api/article";
 import { delete_, get } from "../../request";
 import { PublicityValueEnum } from "../studio/table";
 import { useEffect, useRef, useState } from "react";

+ 1 - 1
dashboard-v6/src/components/anthology/AnthologySelect.tsx

@@ -1,7 +1,7 @@
 import { Select } from "antd";
 import { useEffect, useState } from "react";
 import { get } from "../../request";
-import type { IAnthologyListResponse } from "../../api/Article";
+import type { IAnthologyListResponse } from "../../api/article";
 
 interface IOptions {
   value: string;

+ 1 - 1
dashboard-v6/src/components/anthology/AnthologyStudioList.tsx

@@ -3,7 +3,7 @@ import { useState, useEffect } from "react";
 import { List, Space, Card } from "antd";
 
 import StudioName from "../auth/Studio";
-import type { IAnthologyStudioListApiResponse } from "../../api/Article";
+import type { IAnthologyStudioListApiResponse } from "../../api/article";
 import type { IStudioApiResponse } from "../../api/Auth";
 import { get } from "../../request";
 

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

@@ -1,7 +1,7 @@
 import { useEffect, useState } from "react";
 
 import { get } from "../../request";
-import type { IArticleMapListResponse } from "../../api/Article";
+import type { IArticleMapListResponse } from "../../api/article";
 import type { ListNodeData } from "../article/components/EditableTree";
 import TocTree from "../article/components/TocTree";
 import type { TTarget } from "../../types";

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

@@ -14,7 +14,7 @@ import type {
   IArticleMapRequest,
   IArticleMapUpdateRequest,
   IArticleResponse,
-} from "../../api/Article";
+} from "../../api/article";
 import ArticleListModal from "../article/ArticleListModal";
 
 import { fullUrl, randomString } from "../../utils";

+ 1 - 1
dashboard-v6/src/components/anthology/components/AnthologyInfo.tsx

@@ -1,5 +1,5 @@
 import { Space, Typography } from "antd";
-import type { IAnthologyDataResponse } from "../../../api/Article";
+import type { IAnthologyDataResponse } from "../../../api/article";
 import Studio from "../../auth/Studio";
 import TimeShow from "../../general/TimeShow";
 import Marked from "../../general/Marked";

+ 1 - 1
dashboard-v6/src/components/anthology/hooks/useAnthology.tsx

@@ -22,7 +22,7 @@ import { useState, useEffect, useCallback } from "react";
 import {
   fetchAnthology,
   type IAnthologyDataResponse,
-} from "../../../api/Article";
+} from "../../../api/article";
 import { HttpError } from "../../../request";
 
 interface UseAnthologyResult {

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

@@ -13,7 +13,7 @@ import type {
   IArticleCreateRequest,
   IArticleDataResponse,
   IArticleResponse,
-} from "../../api/Article";
+} from "../../api/article";
 import LangSelect from "../general/LangSelect";
 import { useEffect, useRef, useState } from "react";
 

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

@@ -6,7 +6,7 @@ import type {
   ArticleMode,
   ArticleType,
   IArticleDataResponse,
-} from "../../api/Article";
+} from "../../api/article";
 import TypeArticle from "./TypeArticle";
 const { Text } = Typography;
 

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

@@ -16,7 +16,7 @@ import type {
   IArticleDataRequest,
   IArticleDataResponse,
   IArticleResponse,
-} from "../../api/Article";
+} from "../../api/article";
 import LangSelect from "../general/LangSelect";
 import PublicitySelect from "../studio/PublicitySelect";
 

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

@@ -1,6 +1,6 @@
 import { Drawer } from "antd";
 import React, { useEffect, useState } from "react";
-import type { IArticleDataResponse } from "../../api/Article";
+import type { IArticleDataResponse } from "../../api/article";
 
 import ArticleEdit from "./ArticleEdit";
 

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

@@ -23,7 +23,7 @@ import {
 
 import ArticleCreate from "./ArticleCreate";
 import { delete_, get } from "../../request";
-import type { IArticleListResponse, IDeleteResponse } from "../../api/Article";
+import type { IArticleListResponse, IDeleteResponse } from "../../api/article";
 import { PublicityValueEnum } from "../studio/table";
 import { useEffect, useRef, useState } from "react";
 

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

@@ -1,7 +1,7 @@
 import { Drawer, Typography } from "antd";
 import React, { useEffect, useState } from "react";
 import { put } from "../../request";
-import type { IArticleDataResponse, IArticleResponse } from "../../api/Article";
+import type { IArticleDataResponse, IArticleResponse } from "../../api/article";
 import ArticleLayout from "./components/ArticleLayout";
 
 const { Paragraph } = Typography;

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

@@ -1,7 +1,7 @@
 import type React from "react";
 import { Divider, Space, Tag } from "antd";
 
-import type { ArticleMode, ArticleType } from "../../api/Article";
+import type { ArticleMode, ArticleType } from "../../api/article";
 
 import "./article.css";
 

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

@@ -23,7 +23,7 @@ import {
   EyeOutlined,
 } from "@ant-design/icons";
 import ArticleReader from "./ArticleReader";
-import type { ArticleMode } from "../../api/Article";
+import type { ArticleMode } from "../../api/article";
 
 const { darkAlgorithm } = theme;
 const { Title, Text } = Typography;

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

@@ -1,6 +1,6 @@
 import "./article.css";
 
-import type { ArticleMode } from "../../api/Article";
+import type { ArticleMode } from "../../api/article";
 import AnthologyDetail from "../anthology/AnthologyReader";
 import type { TTarget } from "../../types";
 

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

@@ -5,7 +5,7 @@ import type {
   ArticleMode,
   ArticleType,
   IArticleDataResponse,
-} from "../../api/Article";
+} from "../../api/article";
 
 import TypeArticleReader from "./ArticleReader";
 import ArticleEdit from "./ArticleEdit";

+ 18 - 7
dashboard-v6/src/components/article/TypePali.tsx

@@ -7,7 +7,7 @@ import type {
   ArticleMode,
   ArticleType,
   IArticleDataResponse,
-} from "../../api/Article";
+} from "../../api/article";
 import { useAppSelector } from "../../hooks";
 import { currentUser } from "../../reducers/current-user";
 import store from "../../store";
@@ -24,6 +24,8 @@ import Navigate from "./components/Navigate";
 import TplBuilder from "../tpl-builder/TplBuilder";
 import ArticleHeader from "./components/ArticleHeader";
 import { TaskBuilderChapterModal } from "../task/TaskBuilderChapterModal";
+import type { TTarget } from "../../types";
+import TocPath from "../tipitaka/TocPath";
 
 export interface ISearchParams {
   key: string;
@@ -44,7 +46,7 @@ interface IWidget {
   onArticleChange?: (
     type: ArticleType,
     id: string,
-    target: string,
+    target: TTarget,
     param?: ISearchParams[]
   ) => void;
   onLoad?: (data: IArticleDataResponse) => void;
@@ -121,7 +123,7 @@ const TypePali = ({
     };
     fullPath = [...articleData.path, currNode];
   }
-  /*
+
   const handlePathChange = (
     node: ITocPathNode,
     e: React.MouseEvent<HTMLSpanElement | HTMLAnchorElement, MouseEvent>
@@ -135,10 +137,10 @@ const TypePali = ({
       newType = "chapter";
       newArticle = node.key ? node.key : `${node.book}-${node.paragraph}`;
     }
-    const target = e.ctrlKey || e.metaKey ? "_blank" : "self";
+    const target = e.ctrlKey || e.metaKey ? "_blank" : "_self";
     onArticleChange?.(newType, newArticle, target);
   };
-*/
+
   return (
     <div>
       <TaskBuilderChapterModal
@@ -160,7 +162,16 @@ const TypePali = ({
 
       <div></div>
       <ArticleHeader
-        header={headerExtra}
+        header={
+          <Space>
+            <>{headerExtra}</>
+            <TocPath
+              data={articleData?.path}
+              channels={channels}
+              onChange={handlePathChange}
+            />
+          </Space>
+        }
         action={
           <Dropdown
             menu={{
@@ -237,7 +248,7 @@ const TypePali = ({
             if (node) {
               const newType = node.level === 0 ? "series" : "chapter";
               const newArticle = node.key ?? `${node.book}-${node.paragraph}`;
-              onArticleChange?.(newType, newArticle, "self");
+              onArticleChange?.(newType, newArticle, "_self");
             }
           }}
           onChange={(

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

@@ -17,7 +17,7 @@ import {
 } from "antd";
 import { PlayCircleOutlined, ReloadOutlined } from "@ant-design/icons";
 import TypePali from "./TypePali"; // 根据实际路径调整
-import type { ArticleMode, ArticleType } from "../../api/Article";
+import type { ArticleMode, ArticleType } from "../../api/article";
 
 const { Title, Text } = Typography;
 const { useToken } = theme;

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

@@ -1,5 +1,5 @@
 import { Breadcrumb, Button, Space } from "antd";
-import type { ArticleMode } from "../../api/Article";
+import type { ArticleMode } from "../../api/article";
 import { useAppSelector } from "../../hooks";
 import { currentUser } from "../../reducers/current-user";
 

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

@@ -4,7 +4,7 @@ import type {
   ArticleType,
   IArticleNavData,
   IArticleNavResponse,
-} from "../../../api/Article";
+} from "../../../api/article";
 import type { TTarget } from "../../../types";
 import NavigateButton from "./NavigateButton";
 import type { ITocPathNode } from "../../../api/pali-text";

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

@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
 
 import React from "react";
 import NavigateButton from "./NavigateButton";
-import type { ArticleType } from "../../../api/Article";
+import type { ArticleType } from "../../../api/article";
 import type { ITocPathNode } from "../../../api/pali-text";
 import { get } from "../../../request";
 

+ 2 - 2
dashboard-v6/src/components/article/hooks/useArticle.ts

@@ -34,11 +34,11 @@
 // ─────────────────────────────────────────────
 import { useState, useEffect, useCallback, useRef } from "react";
 
-import { fetchArticle } from "../../../api/Article";
+import { fetchArticle } from "../../../api/article";
 import type {
   IArticleDataResponse,
   IFetchArticleParams,
-} from "../../../api/Article";
+} from "../../../api/article";
 import { HttpError } from "../../../request";
 
 interface IUseArticleReturn {

+ 2 - 2
dashboard-v6/src/components/article/hooks/useArticleList.ts

@@ -44,12 +44,12 @@ import { useState, useEffect, useCallback } from "react";
 
 import type { SortOrder } from "antd/es/table/interface";
 
-import { fetchArticleList } from "../../../api/Article";
+import { fetchArticleList } from "../../../api/article";
 import type {
   IArticleDataResponse,
   IListArticleParams,
   TArticleSortField,
-} from "../../../api/Article";
+} from "../../../api/article";
 
 interface IArticleListData {
   rows: IArticleDataResponse[];

+ 2 - 2
dashboard-v6/src/components/article/hooks/useArticleListControlled.ts

@@ -37,11 +37,11 @@
 
 import { useState, useEffect, useCallback } from "react";
 
-import { fetchArticleList } from "../../../api/Article";
+import { fetchArticleList } from "../../../api/article";
 import type {
   IArticleDataResponse,
   IListArticleParams,
-} from "../../../api/Article";
+} from "../../../api/article";
 
 interface IArticleListData {
   rows: IArticleDataResponse[];

+ 2 - 2
dashboard-v6/src/components/article/hooks/useArticleMutations.ts

@@ -59,12 +59,12 @@ import {
   createArticle,
   updateArticle,
   deleteArticle,
-} from "../../../api/Article";
+} from "../../../api/article";
 import type {
   IArticleDataResponse,
   IArticleCreateRequest,
   IArticleDataRequest,
-} from "../../../api/Article";
+} from "../../../api/article";
 
 // ─────────────────────────────────────────────
 // Callbacks

+ 1 - 1
dashboard-v6/src/components/article/hooks/useTerm.ts

@@ -1,6 +1,6 @@
 import { useEffect, useState, useTransition } from "react";
 
-import type { ArticleMode, IArticleDataResponse } from "../../../api/Article";
+import type { ArticleMode, IArticleDataResponse } from "../../../api/article";
 import { getTerm, type ITermDataResponse } from "../../../api/Term";
 import { message } from "antd";
 

+ 1 - 1
dashboard-v6/src/components/attachment/AttachmentList.tsx

@@ -26,7 +26,7 @@ import { type ActionType, ProList } from "@ant-design/pro-components";
 import type { IUserDictDeleteRequest } from "../../api/dict";
 import { delete_2, get, put } from "../../request";
 import { useRef, useState } from "react";
-import type { IDeleteResponse } from "../../api/Article";
+import type { IDeleteResponse } from "../../api/article";
 import TimeShow from "../general/TimeShow";
 import { getSorterUrl } from "../../utils";
 import {

+ 1 - 1
dashboard-v6/src/components/channel/ChannelMy.tsx

@@ -30,7 +30,7 @@ import StudioName from "../auth/Studio";
 import ProgressSvg from "./ProgressSvg";
 import CopyToModal from "./CopyToModal";
 import { ChannelInfoModal } from "./ChannelInfo";
-import type { ArticleType } from "../../api/Article";
+import type { ArticleType } from "../../api/article";
 import TokenModal from "../token/TokenModal";
 import NissayaAlignerModal from "../nissaya/NissayaAlignerModal";
 import { useChannelProgress } from "./hooks/useChannelProgress";

+ 1 - 1
dashboard-v6/src/components/channel/ChannelPicker.tsx

@@ -4,7 +4,7 @@ import { Modal } from "antd";
 import ChannelPickerTable from "./ChannelPickerTable";
 
 import { useIntl } from "react-intl";
-import type { ArticleType } from "../../api/Article";
+import type { ArticleType } from "../../api/article";
 import type { IChannel } from "../../api/channel";
 
 interface IWidget {

+ 1 - 1
dashboard-v6/src/components/channel/ChannelPickerTable.tsx

@@ -24,7 +24,7 @@ import ProgressSvg from "./ProgressSvg";
 
 import CopyToModal from "./CopyToModal";
 
-import type { ArticleType } from "../../api/Article";
+import type { ArticleType } from "../../api/article";
 import Studio from "../../../src/components/auth/Studio";
 
 const { Link, Text } = Typography;

+ 1 - 1
dashboard-v6/src/components/channel/ChannelTable.tsx

@@ -18,7 +18,7 @@ import type {
   TChannelType,
 } from "../../api/channel";
 import { PublicityValueEnum } from "../studio/table";
-import type { IDeleteResponse } from "../../../src/api/Article";
+import type { IDeleteResponse } from "../../api/article";
 import { useEffect, useRef, useState } from "react";
 import type { IStudio, TRole } from "../../../src/api/Auth";
 import ShareModal from "../share/ShareModal";

+ 1 - 1
dashboard-v6/src/components/channel/ChannelTableModal.tsx

@@ -7,7 +7,7 @@ import { currentUser as _currentUser } from "../../reducers/current-user";
 
 import type { IChannel, TChannelType } from "../../api/channel";
 import { useIntl } from "react-intl";
-import type { ArticleType } from "../../api/Article";
+import type { ArticleType } from "../../api/article";
 
 interface IWidget {
   trigger?: React.ReactNode;

+ 1 - 1
dashboard-v6/src/components/channel/CopyToStep.tsx

@@ -5,7 +5,7 @@ import ChannelPickerTable from "./ChannelPickerTable";
 import ChannelSentDiff from "./ChannelSentDiff";
 import CopyToResult from "./CopyToResult";
 import type { IChannel } from "../../api/channel";
-import type { ArticleType } from "../../api/Article";
+import type { ArticleType } from "../../api/article";
 
 interface IWidget {
   initStep?: number;

+ 1 - 1
dashboard-v6/src/components/channel/hooks/useChannelProgress.ts

@@ -23,7 +23,7 @@ import {
   fetchSentencesInChapter,
   type IChannelItem,
 } from "../../../api/channel";
-import type { ArticleType } from "../../../api/Article";
+import type { ArticleType } from "../../../api/article";
 import { getSentIdInArticle } from "../utils";
 
 interface IUseChannelProgressReturn {

+ 1 - 1
dashboard-v6/src/components/dict/UserDictList.tsx

@@ -28,7 +28,7 @@ import type {
 import { delete_2, get } from "../../request";
 import { useEffect, useRef, useState } from "react";
 import DictEdit from "./DictEdit";
-import type { IDeleteResponse } from "../../api/Article";
+import type { IDeleteResponse } from "../../api/article";
 import TimeShow from "../general/TimeShow";
 import { getSorterUrl } from "../../utils";
 import MdView from "../general/MdView";

+ 1 - 1
dashboard-v6/src/components/dict/UserDictTable.tsx

@@ -25,7 +25,7 @@ import type {
 import { delete_2, get } from "../../request";
 import { useRef, useState } from "react";
 import DictEdit from "./DictEdit";
-import type { IDeleteResponse } from "../../api/Article";
+import type { IDeleteResponse } from "../../api/article";
 import TimeShow from "../general/TimeShow";
 import { getSorterUrl } from "../../utils";
 

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

@@ -4,7 +4,7 @@ import { Card, Space, Segmented } from "antd";
 
 import store from "../../store";
 import { modeChange } from "../../reducers/article-mode";
-import type { ArticleMode } from "../../api/Article";
+import type { ArticleMode } from "../../api/article";
 
 interface IWidgetArticleCard {
   title?: React.ReactNode;

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

@@ -1,7 +1,7 @@
 import { Skeleton } from "antd";
 import { useEffect, useState } from "react";
 import { get } from "../../request";
-import type { IArticleResponse } from "../../api/Article";
+import type { IArticleResponse } from "../../api/article";
 import type { ICommentAnchorResponse } from "../../api/Comment";
 
 import AnchorCard from "./AnchorCard";

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

@@ -14,7 +14,7 @@ import { currentUser as _currentUser } from "../../reducers/current-user";
 import { useEffect, useRef, useState } from "react";
 import MDEditor from "@uiw/react-md-editor";
 import type { IComment, TDiscussionType } from "../../api/discussion";
-import type { TContentType } from "../../api/Article";
+import type { TContentType } from "../../api/article";
 import { discussionCountUpgrade, toIComment } from "./utils";
 
 interface IWidget {

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

@@ -10,7 +10,7 @@ import { type ActionType, ProList } from "@ant-design/pro-components";
 import { renderBadge } from "../channel/ChannelTable";
 import DiscussionCreate from "./DiscussionCreate";
 import User from "../auth/User";
-import type { IArticleListResponse } from "../../api/Article";
+import type { IArticleListResponse } from "../../api/article";
 import { useAppSelector } from "../../hooks";
 import { currentUser as _currentUser } from "../../reducers/current-user";
 import { CommentOutlinedIcon, TemplateOutlinedIcon } from "../../assets/icon";

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

@@ -27,7 +27,7 @@ import type { IComment } from "./DiscussionItem";
 import TimeShow from "../general/TimeShow";
 import Marked from "../general/Marked";
 import { delete_, put } from "../../request";
-import type { IDeleteResponse } from "../../api/Article";
+import type { IDeleteResponse } from "../../api/article";
 import { fullUrl } from "../../utils";
 import type { ICommentRequest, ICommentResponse } from "../../api/Comment";
 import { useState } from "react";

+ 65 - 58
dashboard-v6/src/components/recent/Recent.tsx

@@ -1,64 +1,71 @@
-import { Button, List } from "antd";
-import { useEffect, useState } from "react";
-import { useIntl } from "react-intl";
-import { Link } from "react-router";
+// src/components/Recent.tsx
+import { List, Alert } from "antd";
+import { ClockCircleOutlined } from "@ant-design/icons";
+import type { IGetRecentByUserParams } from "../../api/recent";
+import { type IRecentData } from "../../api/recent";
+import { useRecent } from "../../hooks/useRecent.ts";
 
-import { get } from "../../request";
-import type { IView, IViewListResponse } from "../../api/view";
+interface RecentProps extends IGetRecentByUserParams {
+  onClick?: (id: string) => void;
+}
 
-const RecentWidget = () => {
-  const [listData, setListData] = useState<IView[]>([]);
-  const intl = useIntl();
-  useEffect(() => {
-    const url = `/api/v2/view?view=user&limit=10`;
-    get<IViewListResponse>(url).then((json) => {
-      if (json.ok) {
-        const items: IView[] = json.data.rows.map((item, id) => {
-          return {
-            sn: id + 1,
-            id: item.id,
-            title: item.title,
-            subtitle: item.org_title,
-            type: item.target_type,
-            meta: JSON.parse(item.meta),
-            updatedAt: item.updated_at,
-          };
-        });
-        setListData(items);
-      }
-    });
-  }, []);
-  return (
-    <div style={{ padding: 6 }}>
-      <List
-        itemLayout="vertical"
-        header={intl.formatMessage({
-          id: `labels.recent-scan`,
-        })}
-        size="small"
-        dataSource={listData}
-        renderItem={(item) => {
-          let url = `/article/${item.type}/`;
-          switch (item.type) {
-            case "chapter":
-              url += item.meta.book + "-" + item.meta.para;
-              break;
+export const Recent = ({
+  userId,
+  pageSize = 20,
+  page = 0,
+  type,
+  onClick,
+}: RecentProps) => {
+  const { data, loading, errorCode, refresh } = useRecent(
+    userId,
+    pageSize,
+    page,
+    type
+  );
 
-            default:
-              break;
-          }
-          return (
-            <List.Item>
-              <Link to={url} target="_blank">
-                {item.title ? item.title : item.subtitle}
-              </Link>
-            </List.Item>
-          );
-        }}
+  if (errorCode !== null) {
+    return (
+      <Alert
+        type="error"
+        title={`加载失败(错误码:${errorCode})`}
+        action={
+          <a onClick={refresh} style={{ cursor: "pointer" }}>
+            重试
+          </a>
+        }
       />
-      <Button type="link">{intl.formatMessage({ id: "buttons.more" })}</Button>
-    </div>
+    );
+  }
+
+  const rows: IRecentData[] = data?.data?.rows ?? [];
+
+  return (
+    <List<IRecentData>
+      loading={loading}
+      dataSource={rows}
+      locale={{ emptyText: "暂无最近访问记录" }}
+      renderItem={(item) => (
+        <List.Item
+          key={item.id}
+          onClick={() => onClick?.(item.id)}
+          style={{ cursor: onClick ? "pointer" : "default" }}
+        >
+          <List.Item.Meta
+            avatar={
+              <ClockCircleOutlined style={{ fontSize: 16, marginTop: 4 }} />
+            }
+            title={item.title}
+            description={
+              <span style={{ fontSize: 12, color: "#999" }}>
+                {item.type}
+                {item.updated_at
+                  ? ` · ${new Date(item.updated_at).toLocaleString("zh-CN")}`
+                  : ""}
+              </span>
+            }
+          />
+        </List.Item>
+      )}
+    />
   );
 };
-
-export default RecentWidget;

+ 1 - 44
dashboard-v6/src/components/recent/RecentList.tsx

@@ -14,50 +14,7 @@ import {
   ChapterOutlinedIcon,
   ParagraphOutlinedIcon,
 } from "../../assets/icon";
-import type { ArticleType } from "../../api/Article";
-
-export interface IRecentRequest {
-  type: ArticleType;
-  article_id: string;
-  param?: string;
-}
-interface IParam {
-  book?: string;
-  para?: string;
-  channel?: string;
-  mode?: string;
-}
-interface IRecentData {
-  id: string;
-  title: string;
-  type: ArticleType;
-  article_id: string;
-  param: string | null;
-  updated_at: string;
-}
-
-export interface IRecentResponse {
-  ok: boolean;
-  message: string;
-  data: IRecentData;
-}
-interface IRecentListResponse {
-  ok: boolean;
-  message: string;
-  data: {
-    rows: IRecentData[];
-    count: number;
-  };
-}
-
-export interface IRecent {
-  id: string;
-  title: string;
-  type: ArticleType;
-  articleId: string;
-  updatedAt: string;
-  param?: IParam;
-}
+import type { IRecent, IRecentListResponse } from "../../api/recent";
 
 interface IWidget {
   onSelect?: (

+ 64 - 0
dashboard-v6/src/components/recent/RecentRead.tsx

@@ -0,0 +1,64 @@
+import { Button, List } from "antd";
+import { useEffect, useState } from "react";
+import { useIntl } from "react-intl";
+import { Link } from "react-router";
+
+import { get } from "../../request";
+import type { IView, IViewListResponse } from "../../api/view";
+
+const RecentWidget = () => {
+  const [listData, setListData] = useState<IView[]>([]);
+  const intl = useIntl();
+  useEffect(() => {
+    const url = `/api/v2/view?view=user&limit=10`;
+    get<IViewListResponse>(url).then((json) => {
+      if (json.ok) {
+        const items: IView[] = json.data.rows.map((item, id) => {
+          return {
+            sn: id + 1,
+            id: item.id,
+            title: item.title,
+            subtitle: item.org_title,
+            type: item.target_type,
+            meta: JSON.parse(item.meta),
+            updatedAt: item.updated_at,
+          };
+        });
+        setListData(items);
+      }
+    });
+  }, []);
+  return (
+    <div style={{ padding: 6 }}>
+      <List
+        itemLayout="vertical"
+        header={intl.formatMessage({
+          id: "labels.recent-scan",
+        })}
+        size="small"
+        dataSource={listData}
+        renderItem={(item) => {
+          let url = `/workspace/tipitaka/${item.type}/`;
+          switch (item.type) {
+            case "chapter":
+              url += item.meta.book + "-" + item.meta.para;
+              break;
+
+            default:
+              break;
+          }
+          return (
+            <List.Item>
+              <Link to={url} target="_blank">
+                {item.title ? item.title : item.subtitle}
+              </Link>
+            </List.Item>
+          );
+        }}
+      />
+      <Button type="link">{intl.formatMessage({ id: "buttons.more" })}</Button>
+    </div>
+  );
+};
+
+export default RecentWidget;

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

@@ -18,7 +18,7 @@ import { getEnding } from "../../reducers/nissaya-ending-vocabulary";
 import { anchor, message } from "../../reducers/discussion";
 import TextDiff from "../general/TextDiff";
 
-import type { IDeleteResponse } from "../../api/Article";
+import type { IDeleteResponse } from "../../api/article";
 import { delete_, get } from "../../request";
 
 import "./style.css";

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

@@ -9,7 +9,7 @@ import SuggestionFocus from "./SuggestionFocus";
 import store from "../../store";
 import { push } from "../../reducers/sentence";
 import type { ISentence } from "../../api/sentence";
-import type { ArticleMode } from "../../api/Article";
+import type { ArticleMode } from "../../api/article";
 import type { IWbw } from "../../types/wbw";
 import { GetUserSetting } from "../setting/default";
 import NissayaSent from "../nissaya/NissayaSent";

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

@@ -10,7 +10,7 @@ import "./style.css";
 import { settingInfo } from "../../reducers/setting";
 
 import { useSetting } from "../../hooks/useSetting";
-import type { ArticleMode } from "../../api/Article";
+import type { ArticleMode } from "../../api/article";
 import { GetUserSetting } from "../setting/default";
 import SentContent from "./SentContent";
 import type { IWbw } from "../../types/wbw";

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

@@ -12,7 +12,7 @@ import {
 } from "@ant-design/icons";
 import type { MenuProps } from "antd";
 import type { ISentence } from "../../api/sentence";
-import type { ArticleMode, TContentType } from "../../api/Article";
+import type { ArticleMode, TContentType } from "../../api/article";
 
 import {
   CommentOutlinedIcon,

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

@@ -3,7 +3,7 @@ import { Badge, Button, Dropdown, Space } from "antd";
 import { MoreOutlined, CheckOutlined } from "@ant-design/icons";
 import type { MenuProps } from "antd";
 
-import type { ArticleMode } from "../../api/Article";
+import type { ArticleMode } from "../../api/article";
 import RelatedPara from "../related-para/RelatedPara";
 
 interface IWidget {

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

@@ -23,7 +23,7 @@ import type { IWbw } from "../../types/wbw";
 import type { IResNumber } from "../../api/channel";
 import RelaGraphic from "../wbw/RelaGraphic";
 import type { ITocPathNode } from "../../api/pali-text";
-import type { ArticleMode } from "../../api/Article";
+import type { ArticleMode } from "../../api/article";
 import type { ISentence } from "../../api/sentence";
 
 const { Text } = Typography;

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

@@ -22,7 +22,7 @@ import { delete_, get, post } from "../../request";
 import { useRef, useState } from "react";
 
 import TagsList from "./TagList";
-import type { IDeleteResponse } from "../../api/Article";
+import type { IDeleteResponse } from "../../api/article";
 import store from "../../store";
 import { tagsUpgrade } from "../../reducers/discussion-count";
 

+ 1 - 1
dashboard-v6/src/components/term/TermList.tsx

@@ -10,7 +10,7 @@ import {
 
 import type { ITermDeleteRequest, ITermListResponse } from "../../api/Term";
 import { delete_2, get } from "../../request";
-import type { IDeleteResponse } from "../../api/Article";
+import type { IDeleteResponse } from "../../api/article";
 import { useRef } from "react";
 import TermExport from "./TermExport";
 

+ 38 - 0
dashboard-v6/src/components/tipitaka/PaliTextToc.tsx

@@ -0,0 +1,38 @@
+// src/components/article/PaliTextToc.tsx
+import { Skeleton } from "antd";
+
+import TocTree from "./TocTree";
+import { usePaliBookToc } from "./hooks/usePaliBookToc";
+
+interface IWidget {
+  book?: number;
+  para?: number;
+  series?: string;
+  onSelect?: (selectedKeys?: string[]) => void;
+  onClick?: (
+    id: string,
+    e: React.MouseEvent<HTMLSpanElement, MouseEvent>
+  ) => void;
+}
+
+const PaliTextToc = ({ book, para, series, onSelect, onClick }: IWidget) => {
+  const { tocList, selectedKeys, expandedKeys, loading } = usePaliBookToc({
+    book,
+    para,
+    series,
+  });
+
+  if (loading) return <Skeleton active />;
+
+  return (
+    <TocTree
+      treeData={tocList}
+      selectedKeys={selectedKeys}
+      expandedKeys={expandedKeys}
+      onSelect={onSelect}
+      onClick={onClick}
+    />
+  );
+};
+
+export default PaliTextToc;

+ 216 - 0
dashboard-v6/src/components/tipitaka/TocTree.tsx

@@ -0,0 +1,216 @@
+import { Tree, Typography } from "antd";
+import { useMemo, useState } from "react";
+
+import type { Key } from "antd/lib/table/interface";
+import { randomString } from "../../utils";
+import type { DataNode, EventDataNode } from "antd/es/tree";
+import type { ListNodeData } from "../article/components/EditableTree";
+import PaliText from "../general/PaliText";
+
+const { Text } = Typography;
+
+interface IIdMap {
+  key: string;
+  id: string;
+}
+export interface TreeNodeData {
+  key: string;
+  id: string;
+  title: string | React.ReactNode;
+  isLeaf?: boolean;
+  children?: TreeNodeData[];
+  level: number;
+  status?: number;
+  deletedAt?: string | null;
+}
+
+function tocGetTreeData(
+  listData: ListNodeData[],
+  active = ""
+): [TreeNodeData[] | undefined, IIdMap[]] {
+  const treeData: TreeNodeData[] = [];
+  let tocActivePath: TreeNodeData[] = [];
+  const treeParents = [];
+  const rootNode: TreeNodeData = {
+    key: randomString(),
+    id: "0",
+    title: "root",
+    level: 0,
+    children: [],
+  };
+  const idMap: IIdMap[] = [];
+  treeData.push(rootNode);
+  let lastInsNode: TreeNodeData = rootNode;
+
+  let iCurrLevel = 0;
+  for (let index = 0; index < listData.length; index++) {
+    const element = listData[index];
+    const newNode: TreeNodeData = {
+      key: randomString(),
+      id: element.key,
+      isLeaf: element.children === 0,
+      title: element.title,
+      level: element.level,
+      status: element.status,
+      deletedAt: element.deletedAt,
+    };
+    idMap.push({
+      key: newNode.key,
+      id: newNode.id,
+    });
+    if (newNode.level > iCurrLevel) {
+      treeParents.push(lastInsNode);
+      if (typeof lastInsNode.children === "undefined") {
+        lastInsNode.children = [];
+      }
+      lastInsNode.children.push(newNode);
+    } else if (newNode.level === iCurrLevel) {
+      const parentNode = treeParents[treeParents.length - 1];
+      if (typeof parentNode !== "undefined") {
+        if (typeof parentNode.children === "undefined") {
+          parentNode.children = [];
+        }
+        parentNode.children.push(newNode);
+      }
+    } else {
+      while (treeParents.length > 1) {
+        treeParents.pop();
+        if (treeParents[treeParents.length - 1].level < newNode.level) {
+          break;
+        }
+      }
+      const parentNode = treeParents[treeParents.length - 1];
+      if (typeof parentNode !== "undefined") {
+        if (typeof parentNode.children === "undefined") {
+          parentNode.children = [];
+        }
+        parentNode.children.push(newNode);
+      }
+    }
+    lastInsNode = newNode;
+    iCurrLevel = newNode.level;
+
+    if (active === element.key) {
+      tocActivePath = [];
+      for (let index = 1; index < treeParents.length; index++) {
+        tocActivePath.push(treeParents[index]);
+      }
+    }
+  }
+
+  return [treeData[0].children, idMap];
+}
+
+interface IWidgetTocTree {
+  treeData?: ListNodeData[];
+  expandedKeys?: Key[];
+  selectedKeys?: Key[];
+  onSelect?: (selectedId?: string[]) => void;
+  onClick?: (
+    selectedId: string,
+    e: React.MouseEvent<HTMLSpanElement, MouseEvent>
+  ) => void;
+  onLoad?: (key: string) => string;
+}
+
+const TocTreeWidget = ({
+  treeData,
+  expandedKeys,
+  selectedKeys,
+  onSelect,
+  onClick,
+}: IWidgetTocTree) => {
+  // 用于记录用户手动展开/收起的状态
+  const [manualExpanded, setManualExpanded] = useState<Key[] | undefined>();
+
+  const [tree, keyIdMap] = useMemo(() => {
+    if (treeData && treeData.length > 0) {
+      const [data, idMap] = tocGetTreeData(treeData, "");
+      return [data, idMap];
+    } else {
+      return [[], undefined];
+    }
+  }, [treeData]);
+
+  // 用 useMemo 替代 useEffect + setState,避免级联渲染
+  const selected = useMemo(() => {
+    if (!keyIdMap) return undefined;
+    return selectedKeys?.map((item) => {
+      const mapIndex = keyIdMap.findIndex((value) => value.id === item);
+      return mapIndex !== -1 ? keyIdMap[mapIndex].key : "";
+    });
+  }, [keyIdMap, selectedKeys]);
+
+  const expandedFromProps = useMemo(() => {
+    if (!keyIdMap) return undefined;
+    return expandedKeys?.map((item) => {
+      const mapIndex = keyIdMap.findIndex((value) => value.id === item);
+      return mapIndex !== -1 ? keyIdMap[mapIndex].key : "";
+    });
+  }, [expandedKeys, keyIdMap]);
+
+  // 优先使用用户手动操作的展开状态,否则使用 props 传入的
+  const expanded = manualExpanded ?? expandedFromProps;
+
+  return (
+    <Tree
+      treeData={tree}
+      selectedKeys={selected}
+      expandedKeys={expanded}
+      autoExpandParent
+      onExpand={(expandedKeys: Key[]) => {
+        setManualExpanded(expandedKeys);
+      }}
+      onClick={(
+        e: React.MouseEvent<HTMLSpanElement, MouseEvent>,
+        node: EventDataNode<DataNode>
+      ) => {
+        if (typeof onClick !== "undefined") {
+          const selectedId = keyIdMap?.find(
+            (value) => node.key === value.key
+          )?.id;
+          if (selectedId) {
+            onClick(selectedId, e);
+          }
+        }
+      }}
+      onSelect={(selectedKeys: Key[]) => {
+        if (typeof onSelect !== "undefined") {
+          const selectedId = keyIdMap
+            ?.filter((value) => selectedKeys.includes(value.key))
+            .map((item) => item.id);
+          onSelect(selectedId);
+        }
+      }}
+      blockNode
+      titleRender={(node: DataNode) => {
+        const treeNode = node as TreeNodeData; // 类型断言
+        const currNode =
+          typeof treeNode.title === "string" ? (
+            treeNode.title === "" ? (
+              "[unnamed]"
+            ) : (
+              <PaliText
+                textType={treeNode.status === 10 ? "secondary" : undefined}
+                text={treeNode.title}
+              />
+            )
+          ) : (
+            treeNode.title
+          );
+
+        return (
+          <Text
+            delete={treeNode.deletedAt ? true : false}
+            disabled={treeNode.deletedAt ? true : false}
+            type={treeNode.status === 10 ? "secondary" : undefined}
+          >
+            {currNode}
+          </Text>
+        );
+      }}
+    />
+  );
+};
+
+export default TocTreeWidget;

+ 153 - 0
dashboard-v6/src/components/tipitaka/hooks/usePaliBookToc.ts

@@ -0,0 +1,153 @@
+// ─────────────────────────────────────────────
+// src/hooks/usePaliBookToc.ts
+// ─────────────────────────────────────────────
+/**
+ * usePaliBookToc
+ *
+ * 获取巴利文书籍目录列表,并派生出当前段落对应的
+ * selectedKeys / expandedKeys,供 TocTree 直接消费。
+ *
+ * @param params  { book, para, series }
+ *
+ * @returns
+ *   - tocList      已转换为 ListNodeData[] 的目录节点
+ *   - selectedKeys 当前段落命中的 key(["book-para"] 或 [])
+ *   - expandedKeys 需要展开的 key(同 selectedKeys)
+ *   - loading      请求进行中
+ *   - errorCode    HTTP 错误码,无错误时为 null
+ *   - errorMessage 后端错误信息,无错误时为 null
+ *   - refresh      手动重新请求
+ *
+ * @example
+ * const { tocList, selectedKeys, expandedKeys, loading } = usePaliBookToc({
+ *   book: 1,
+ *   para: 42,
+ * });
+ */
+// ─────────────────────────────────────────────
+
+import { useState, useEffect, useCallback, useRef } from "react";
+import type { ListNodeData } from "../../article/components/EditableTree";
+import {
+  fetchPaliBookToc,
+  type IFetchPaliBookTocParams,
+} from "../../../api/pali-text";
+import { HttpError } from "../../../request";
+
+interface IUsePaliBookTocReturn {
+  tocList: ListNodeData[];
+  selectedKeys: string[];
+  expandedKeys: string[];
+  loading: boolean;
+  errorCode: number | null;
+  errorMessage: string | null;
+  refresh: () => void;
+}
+
+export const usePaliBookToc = (
+  params: IFetchPaliBookTocParams = {}
+): IUsePaliBookTocReturn => {
+  const [tocList, setTocList] = useState<ListNodeData[]>([]);
+  const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
+  const [expandedKeys, setExpandedKeys] = useState<string[]>([]);
+  const [loading, setLoading] = useState(false);
+  const [errorCode, setErrorCode] = useState<number | null>(null);
+  const [errorMessage, setErrorMessage] = useState<string | null>(null);
+  const [tick, setTick] = useState(0);
+
+  // 用 JSON 序列化做稳定的依赖比较,避免每次 render 传入新对象引用导致无限循环
+  const paramsKey = JSON.stringify(params);
+  const paramsRef = useRef<IFetchPaliBookTocParams>(params);
+  useEffect(() => {
+    paramsRef.current = params;
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [paramsKey]);
+
+  const refresh = useCallback(() => setTick((t) => t + 1), []);
+
+  useEffect(() => {
+    // book+para 模式:两个参数都必须有效;series 模式:series 非空即可
+    const { book, para, series } = paramsRef.current;
+    const isReady = series
+      ? Boolean(series)
+      : book !== undefined && para !== undefined;
+    if (!isReady) return;
+
+    let active = true;
+
+    const fetchData = async () => {
+      // ✅ 所有 setState 都在异步回调里,不在 effect 同步体内直接调用
+      //    (将 setLoading(true) 改为在微任务开头执行,规避 ESLint 规则)
+      await Promise.resolve(); // 让 effect 同步体先完成,再切换状态
+      if (!active) return;
+
+      setLoading(true);
+      setErrorCode(null);
+      setErrorMessage(null);
+
+      try {
+        const json = await fetchPaliBookToc(paramsRef.current);
+        if (!active) return;
+
+        if (!json.ok) {
+          setErrorCode(400);
+          setErrorMessage(json.message);
+          return;
+        }
+
+        // 转换成 ListNodeData
+        const nodes: ListNodeData[] = json.data.rows.map((item) => ({
+          key: `${item.book}-${item.paragraph}`,
+          title: item.toc,
+          level: parseInt(item.level as unknown as string),
+        }));
+        setTocList(nodes);
+
+        // 计算 selectedKeys / expandedKeys
+        if (json.data.rows.length > 0 && para !== undefined) {
+          const matched: string[] = [];
+          for (let i = json.data.rows.length - 1; i >= 0; i--) {
+            const row = json.data.rows[i];
+            if (row.book === book && row.paragraph <= para) {
+              matched.push(`${row.book}-${row.paragraph}`);
+              break;
+            }
+          }
+          setSelectedKeys(matched);
+          setExpandedKeys(matched);
+        } else {
+          setSelectedKeys([]);
+          setExpandedKeys([]);
+        }
+      } catch (e) {
+        console.error("usePaliBookToc fetch", e);
+        if (!active) return;
+        if (e instanceof HttpError) {
+          setErrorCode(e.status);
+          setErrorMessage(e.message);
+        } else {
+          setErrorCode(0); // 0 表示网络层错误
+          setErrorMessage("Network error");
+        }
+      } finally {
+        if (active) setLoading(false);
+      }
+    };
+
+    fetchData();
+
+    return () => {
+      active = false;
+    };
+  }, [paramsKey, tick]);
+
+  return {
+    tocList,
+    selectedKeys,
+    expandedKeys,
+    loading,
+    errorCode,
+    errorMessage,
+    refresh,
+  };
+};

+ 1 - 1
dashboard-v6/src/components/token/Token.tsx

@@ -12,7 +12,7 @@ import type {
   ITokenCreateResponse,
   TPower,
 } from "../../api/token";
-import type { ArticleType } from "../../api/Article";
+import type { ArticleType } from "../../api/article";
 const { Text } = Typography;
 
 interface IWidget {

+ 1 - 1
dashboard-v6/src/components/token/TokenModal.tsx

@@ -2,7 +2,7 @@ import { useState } from "react";
 import { Modal } from "antd";
 
 import Token from "./Token";
-import type { ArticleType } from "../../api/Article";
+import type { ArticleType } from "../../api/article";
 
 interface IWidget {
   channelId?: string;

+ 1 - 1
dashboard-v6/src/components/tpl-builder/ArticleTpl.tsx

@@ -1,5 +1,5 @@
 import type { JSX } from "react";
-import type { ArticleType } from "../../api/Article";
+import type { ArticleType } from "../../api/article";
 import type { TDisplayStyle } from "../../types/template";
 
 interface IWidget {

+ 1 - 1
dashboard-v6/src/components/tpl-builder/TplBuilder.tsx

@@ -1,6 +1,6 @@
 import { useEffect, useState } from "react";
 import { Modal, Tabs } from "antd";
-import type { ArticleType } from "../../api/Article";
+import type { ArticleType } from "../../api/article";
 import { ArticleTplMock } from "./ArticleTpl";
 import { VideoTplMock } from "./VideoTpl";
 

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

@@ -6,7 +6,7 @@ import WbwMeaningSelect from "./WbwMeaningSelect";
 
 import CaseFormula from "./CaseFormula";
 import EditableLabel from "../general/EditableLabel";
-import type { ArticleMode } from "../../api/Article";
+import type { ArticleMode } from "../../api/article";
 import { errorClass } from "./utils";
 import type { IWbw, TWbwDisplayMode } from "../../types/wbw";
 

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

@@ -25,7 +25,7 @@ import type { TooltipPlacement } from "antd/es/tooltip"; // antd6: 路径从 lib
 import { temp } from "../../reducers/setting";
 import TagsArea from "../tag/TagsArea";
 import type { IStudio } from "../../api/Auth";
-import type { ArticleMode } from "../../api/Article";
+import type { ArticleMode } from "../../api/article";
 import type { ITagMapData } from "../../api/Tag";
 import PaliText from "../general/PaliText";
 import { bookMarkColor } from "./utils";

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

@@ -30,7 +30,7 @@ import {
   type WbwElement,
 } from "../../types/wbw";
 import type { IChannel, TChannelType } from "../../api/channel";
-import type { ArticleMode } from "../../api/Article";
+import type { ArticleMode } from "../../api/article";
 import type { ISentenceWbwListResponse } from "../../api/sentence";
 import type { IStudio } from "../../api/Auth";
 import { useWbwStreamProcessor } from "../../hooks/useWbwStreamProcessor";

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

@@ -19,7 +19,7 @@ import WbwRelationAdd from "./WbwRelationAdd";
 import WbwReal from "./WbwReal";
 import WbwDetailFm from "./WbwDetailFm";
 import type { IStudio } from "../../api/Auth";
-import type { ArticleMode } from "../../api/Article";
+import type { ArticleMode } from "../../api/article";
 import {
   WbwStatus,
   type IWbw,

+ 1 - 1
dashboard-v6/src/components/webhook/WebhookList.tsx

@@ -13,7 +13,7 @@ import {
 } from "@ant-design/icons";
 
 import { delete_, get } from "../../request";
-import type { IDeleteResponse } from "../../api/Article";
+import type { IDeleteResponse } from "../../api/article";
 import { useRef } from "react";
 import type { IWebhookApiData, IWebhookListResponse } from "../../api/webhook";
 

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

@@ -1,7 +1,7 @@
 import type { CSSProperties } from "react";
 import { ClockCircleOutlined } from "@ant-design/icons";
 import type { RecentItem as RecentItemType } from "../../../api/workspace";
-import type { ArticleType } from "../../../api/Article";
+import type { ArticleType } from "../../../api/article";
 
 const typeColor: Record<ArticleType, string> = {
   chapter: "#b5854a",

+ 1 - 1
dashboard-v6/src/features/editor/Article.tsx

@@ -2,7 +2,7 @@
 // Props
 // ─────────────────────────────────────────────
 
-import type { ArticleMode } from "../../api/Article";
+import type { ArticleMode } from "../../api/article";
 import AnthologyTocTree from "../../components/anthology/AnthologyTocTree";
 import TypeArticle from "../../components/article/TypeArticle";
 import Editor from "../../components/editor";

+ 18 - 3
dashboard-v6/src/features/editor/Chapter.tsx

@@ -2,11 +2,13 @@
 // Props
 // ─────────────────────────────────────────────
 
-import type { ArticleMode, ArticleType } from "../../api/Article";
+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 type { TTarget } from "../../types";
 
 export interface ChapterEditorProps {
   chapterId?: string;
@@ -19,7 +21,7 @@ export interface ChapterEditorProps {
   onArticleChange?: (
     type: ArticleType,
     id: string,
-    target: string,
+    target: TTarget,
     param?: ISearchParams[]
   ) => void;
 }
@@ -34,10 +36,23 @@ export default function ChapterEditor({
   channelId,
   onArticleChange,
 }: ChapterEditorProps) {
+  const [book, para] = chapterId
+    ? chapterId.split("-").map((item) => parseInt(item))
+    : [undefined, undefined];
   return (
     <Editor
       sidebarTitle="recent scan"
-      sidebar={<>recent list</>}
+      sidebar={
+        <PaliTextToc
+          book={book}
+          para={para}
+          onSelect={(selected) => {
+            if (selected) {
+              onArticleChange?.("chapter", selected[0], "_self");
+            }
+          }}
+        />
+      }
       articleId={chapterId}
       channelId={channelId}
     >

+ 27 - 2
dashboard-v6/src/features/editor/Term.tsx

@@ -2,9 +2,15 @@
 // Props
 // ─────────────────────────────────────────────
 
-import type { ArticleMode } from "../../api/Article";
+import { useEffect } from "react";
+import type { ArticleMode } from "../../api/article";
 import TypeTerm from "../../components/article/TypeTerm";
 import Editor from "../../components/editor";
+import { Recent } from "../../components/recent/Recent";
+import { useAppSelector } from "../../hooks";
+import { useSaveRecent } from "../../hooks/useSaveRecent";
+import { currentUser } from "../../reducers/current-user";
+import { useLocation } from "react-router";
 
 export interface ArticleEditorProps {
   termId?: string;
@@ -32,10 +38,29 @@ export default function TermEditor({
   channelId,
   onEdit,
 }: ArticleEditorProps) {
+  const currUser = useAppSelector(currentUser);
+
+  const { save } = useSaveRecent();
+  const { search } = useLocation();
+
+  useEffect(() => {
+    if (!currUser?.id || !termId) return;
+
+    save({
+      type: "term",
+      article_id: termId,
+      param: search || undefined,
+    });
+  }, [currUser?.id, termId, search, save]);
+
   return (
     <Editor
       sidebarTitle="recent scan"
-      sidebar={<>recent list</>}
+      sidebar={
+        currUser ? (
+          <Recent userId={currUser?.id} pageSize={20} type="term" />
+        ) : null
+      }
       articleId={termId}
       anthologyId={anthologyId}
       channelId={channelId}

+ 6 - 4
dashboard-v6/src/hooks/useRecent.ts.ts

@@ -1,4 +1,4 @@
-// hooks/useRecent.ts
+// src/hooks/useRecent.ts
 /**
  * useRecent
  *
@@ -23,6 +23,7 @@
 import { useState, useEffect, useCallback } from "react";
 import { getRecentByUser, type IRecentListResponse } from "../api/recent";
 import { HttpError } from "../request";
+import type { ArticleType } from "../api/article";
 
 interface UseRecentResult {
   data: IRecentListResponse | null;
@@ -34,7 +35,8 @@ interface UseRecentResult {
 export const useRecent = (
   userId?: string,
   pageSize: number = 20,
-  page: number = 0
+  page: number = 0,
+  type?: ArticleType // 新增
 ): UseRecentResult => {
   const [data, setData] = useState<IRecentListResponse | null>(null);
   const [loading, setLoading] = useState(false);
@@ -53,7 +55,7 @@ export const useRecent = (
       setErrorCode(null);
 
       try {
-        const res = await getRecentByUser(userId, pageSize, page);
+        const res = await getRecentByUser({ userId, pageSize, page, type });
         if (!active) return;
 
         if (!res.ok) {
@@ -82,7 +84,7 @@ export const useRecent = (
     return () => {
       active = false;
     };
-  }, [userId, pageSize, page, tick]);
+  }, [userId, pageSize, page, tick, type]);
 
   return { data, loading, errorCode, refresh };
 };

+ 67 - 0
dashboard-v6/src/hooks/useSaveRecent.ts

@@ -0,0 +1,67 @@
+/**
+ * useSaveRecent
+ *
+ * 创建或更新最近访问记录(对应后端 firstOrNew upsert)
+ *
+ * @returns
+ *   - save      触发保存,传入 ISaveRecentRequest
+ *   - loading   请求进行中
+ *   - errorCode 失败时的错误码,无错误时为 null
+ *   - data      最新返回的记录,未请求或失败时为 null
+ * 
+ * const { save, loading, errorCode } = useSaveRecent();
+
+// 进入页面时记录
+await save({
+  type: "book",
+  article_id: "abc123",
+  param: JSON.stringify({ book: "genesis", para: "1" }),
+});
+ */
+import { useState, useCallback } from "react";
+import {
+  saveRecent,
+  type ISaveRecentRequest,
+  type IRecentResponse,
+} from "../api/recent";
+import { HttpError } from "../request";
+
+interface UseSaveRecentResult {
+  save: (payload: ISaveRecentRequest) => Promise<void>;
+  loading: boolean;
+  errorCode: number | null;
+  data: IRecentResponse | null;
+}
+
+export const useSaveRecent = (): UseSaveRecentResult => {
+  const [data, setData] = useState<IRecentResponse | null>(null);
+  const [loading, setLoading] = useState(false);
+  const [errorCode, setErrorCode] = useState<number | null>(null);
+
+  const save = useCallback(async (payload: ISaveRecentRequest) => {
+    setLoading(true);
+    setErrorCode(null);
+
+    try {
+      const res = await saveRecent(payload);
+
+      if (!res.ok) {
+        setErrorCode(-1);
+        return;
+      }
+
+      setData(res);
+    } catch (e) {
+      console.error("recent save", e);
+      if (e instanceof HttpError) {
+        setErrorCode(e.status);
+      } else {
+        setErrorCode(0);
+      }
+    } finally {
+      setLoading(false);
+    }
+  }, []);
+
+  return { save, loading, errorCode, data };
+};

+ 4 - 13
dashboard-v6/src/hooks/useTipitaka.ts

@@ -6,12 +6,8 @@ import type {
   ArticleType,
   IArticleDataResponse,
   IChapterToc,
-} from "../api/Article";
-import {
-  fetchChapterArticle,
-  fetchParaArticle,
-  fetchNextParaChunk,
-} from "../api/Article";
+} from "../api/article";
+import { fetchChapter, fetchNextParaChunk, fetchPara } from "../api/pali-text";
 
 interface IUseTipitakaProps {
   type?: ArticleType;
@@ -68,15 +64,10 @@ const useTipitaka = ({
       try {
         let response;
         if (type === "chapter") {
-          response = await fetchChapterArticle(id, srcDataMode, channelId);
+          response = await fetchChapter(id, srcDataMode, channelId);
         } else if (type === "para") {
           const _book = book ?? id;
-          response = await fetchParaArticle(
-            _book,
-            para ?? "",
-            srcDataMode,
-            channelId
-          );
+          response = await fetchPara(_book, para ?? "", srcDataMode, channelId);
         } else {
           return;
         }

+ 1 - 4
dashboard-v6/src/layouts/Root.tsx

@@ -5,10 +5,7 @@ const Widget = () => {
 
   return (
     <div>
-      <div>
-        <Outlet />
-      </div>
-      <div>root layout footer</div>
+      <Outlet />
     </div>
   );
 };

+ 2 - 2
dashboard-v6/src/layouts/workspace/index.tsx

@@ -33,10 +33,10 @@ const Widget = () => {
       <Layout>
         <div
           style={{
-            padding: "0 24px", // 建议保留左右内边距,否则内容会贴边
+            padding: "4 16px", // 建议保留左右内边距,否则内容会贴边
             display: "flex",
             alignItems: "center", // 垂直居中
-            height: 44,
+            height: 24,
             justifyContent: "space-between", // 如果需要左右分布(如左侧面包屑,右侧头像)可开启
           }}
         >

+ 1 - 1
dashboard-v6/src/pages/workspace/article/show.tsx

@@ -1,5 +1,5 @@
 import { useNavigate, useParams, useSearchParams } from "react-router";
-import type { ArticleMode } from "../../../api/Article";
+import type { ArticleMode } from "../../../api/article";
 import ArticleEditor from "../../../features/editor/Article";
 
 const Widget = () => {

+ 1 - 1
dashboard-v6/src/pages/workspace/tipitaka/bypath.tsx

@@ -10,7 +10,7 @@ import BookTreeList, {
 import PaliChapterListByTag from "../../../components/tipitaka/PaliChapterListByTag";
 import type { IChapterClickEvent } from "../../../components/tipitaka/PaliChapterList";
 import BookViewer from "../../../components/tipitaka/BookViewer";
-import Recent from "../../../components/recent/Recent";
+import Recent from "../../../components/recent/RecentRead";
 
 // 将纯逻辑函数移出组件外,避免每次渲染都重新定义
 const getTagByPath = (

+ 1 - 1
dashboard-v6/src/pages/workspace/tipitaka/chapter.tsx

@@ -1,5 +1,5 @@
 import { useNavigate, useParams, useSearchParams } from "react-router";
-import type { ArticleMode } from "../../../api/Article";
+import type { ArticleMode } from "../../../api/article";
 import ChapterEditor from "../../../features/editor/Chapter";
 
 const Widget = () => {

+ 1 - 1
dashboard-v6/src/reducers/article-mode.ts

@@ -4,7 +4,7 @@
 import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
 
 import type { RootState } from "../store";
-import type { ArticleMode } from "../api/Article";
+import type { ArticleMode } from "../api/article";
 
 interface IMode {
   id?: string;

+ 1 - 1
dashboard-v6/src/reducers/para-change.ts

@@ -4,7 +4,7 @@
 import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
 
 import type { RootState } from "../store";
-import type { ArticleType } from "../api/Article";
+import type { ArticleType } from "../api/article";
 
 export interface IParam {
   book: number;

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

@@ -1,4 +1,4 @@
-import type { ArticleMode, ArticleType } from "../api/Article";
+import type { ArticleMode, ArticleType } from "../api/article";
 
 export const SENTENCE_FIX_WIDTH = 800;