2
0
visuddhinanda 1 сар өмнө
parent
commit
984e5ac393
100 өөрчлөгдсөн 5310 нэмэгдсэн , 840 устгасан
  1. 1 0
      dashboard-v4/dashboard/src/components/dict/Dictionary.tsx
  2. 1 0
      dashboard-v4/dashboard/src/components/term/TermShow.tsx
  3. 3 22
      dashboard-v6/backup/components/discussion/DiscussionItem.tsx
  4. 208 0
      dashboard-v6/documents/development/frontend-standards.md
  5. 42 3
      dashboard-v6/src/App.tsx
  6. 21 7
      dashboard-v6/src/Router.tsx
  7. 35 1
      dashboard-v6/src/api/Article.ts
  8. 1 0
      dashboard-v6/src/api/Channel.ts
  9. 1 1
      dashboard-v6/src/api/Comment.ts
  10. 0 402
      dashboard-v6/src/api/Corpus.ts
  11. 22 0
      dashboard-v6/src/api/discussion.ts
  12. 102 0
      dashboard-v6/src/api/pali-text.ts
  13. 101 0
      dashboard-v6/src/api/progress.ts
  14. 1 1
      dashboard-v6/src/api/recent.ts
  15. 33 0
      dashboard-v6/src/api/sentence-history.ts
  16. 133 0
      dashboard-v6/src/api/sentence-pr.ts
  17. 242 7
      dashboard-v6/src/api/sentence.ts
  18. 1 1
      dashboard-v6/src/api/view.ts
  19. 1 1
      dashboard-v6/src/api/workspace.ts
  20. 25 0
      dashboard-v6/src/components/ai/Chat.tsx
  21. 173 0
      dashboard-v6/src/components/article/article.css
  22. 2 1
      dashboard-v6/src/components/channel/ChannelList.tsx
  23. 1 1
      dashboard-v6/src/components/channel/ChannelMy.tsx
  24. 1 1
      dashboard-v6/src/components/channel/ChannelPicker.tsx
  25. 1 1
      dashboard-v6/src/components/channel/ChannelPickerTable.tsx
  26. 5 5
      dashboard-v6/src/components/channel/ChannelSentDiff.tsx
  27. 1 1
      dashboard-v6/src/components/channel/ChannelTableModal.tsx
  28. 1 1
      dashboard-v6/src/components/channel/ChapterInChannelList.tsx
  29. 1 1
      dashboard-v6/src/components/channel/CopyToStep.tsx
  30. 155 0
      dashboard-v6/src/components/dict/SearchVocabulary.tsx
  31. 39 0
      dashboard-v6/src/components/discussion/DiscussionMock.tsx
  32. 5 4
      dashboard-v6/src/components/general/DataImport.tsx
  33. 193 102
      dashboard-v6/src/components/navigation/MainMenu.tsx
  34. 1 1
      dashboard-v6/src/components/nissaya/NissayaAligner.tsx
  35. 2 1
      dashboard-v6/src/components/nissaya/NissayaCard.tsx
  36. 1 1
      dashboard-v6/src/components/related-para/RelatedPara.tsx
  37. 1 1
      dashboard-v6/src/components/sentence-editor/EditInfo.tsx
  38. 1 1
      dashboard-v6/src/components/sentence-editor/InteractiveButton.tsx
  39. 1 1
      dashboard-v6/src/components/sentence-editor/PrAcceptButton.tsx
  40. 1 1
      dashboard-v6/src/components/sentence-editor/SentCanRead.tsx
  41. 27 27
      dashboard-v6/src/components/sentence-editor/SentCell.tsx
  42. 7 7
      dashboard-v6/src/components/sentence-editor/SentCellEditable.tsx
  43. 2 1
      dashboard-v6/src/components/sentence-editor/SentContent.tsx
  44. 3 1
      dashboard-v6/src/components/sentence-editor/SentEdit.tsx
  45. 358 0
      dashboard-v6/src/components/sentence-editor/SentEditInnerDemo.tsx
  46. 2 1
      dashboard-v6/src/components/sentence-editor/SentEditMenu.tsx
  47. 1 1
      dashboard-v6/src/components/sentence-editor/SentMenu.tsx
  48. 235 0
      dashboard-v6/src/components/sentence-editor/SentRead.tsx
  49. 4 1
      dashboard-v6/src/components/sentence-editor/SentTab.tsx
  50. 45 59
      dashboard-v6/src/components/sentence-editor/SentTabCopy.tsx
  51. 1 1
      dashboard-v6/src/components/sentence-editor/SentWbw.tsx
  52. 1 1
      dashboard-v6/src/components/sentence-editor/SentWbwEdit.tsx
  53. 1 1
      dashboard-v6/src/components/sentence-editor/SuggestionAdd.tsx
  54. 1 1
      dashboard-v6/src/components/sentence-editor/SuggestionBox.tsx
  55. 1 1
      dashboard-v6/src/components/sentence-editor/SuggestionButton.tsx
  56. 20 22
      dashboard-v6/src/components/sentence-editor/SuggestionFocus.tsx
  57. 103 77
      dashboard-v6/src/components/sentence-editor/SuggestionList.tsx
  58. 31 28
      dashboard-v6/src/components/sentence-editor/SuggestionPopover.tsx
  59. 1 1
      dashboard-v6/src/components/sentence-editor/SuggestionTabs.tsx
  60. 1 1
      dashboard-v6/src/components/sentence-editor/SuggestionToolbar.tsx
  61. 1 1
      dashboard-v6/src/components/sentence-editor/utils.ts
  62. 1 1
      dashboard-v6/src/components/sentence/utils.ts
  63. 255 0
      dashboard-v6/src/components/term/GrammarBook.tsx
  64. 40 0
      dashboard-v6/src/components/term/GrammarRecent.tsx
  65. 243 0
      dashboard-v6/src/components/term/TermCommunity.tsx
  66. 483 0
      dashboard-v6/src/components/term/TermEdit.tsx
  67. 70 0
      dashboard-v6/src/components/term/TermExport.tsx
  68. 137 0
      dashboard-v6/src/components/term/TermItem.tsx
  69. 338 0
      dashboard-v6/src/components/term/TermList.tsx
  70. 61 3
      dashboard-v6/src/components/term/TermModal.tsx
  71. 119 0
      dashboard-v6/src/components/term/TermSearch.tsx
  72. 93 0
      dashboard-v6/src/components/term/TermShow.tsx
  73. 544 0
      dashboard-v6/src/components/term/TermTest.tsx
  74. 26 0
      dashboard-v6/src/components/term/utils.ts
  75. 56 0
      dashboard-v6/src/components/theme/ThemeSwitch.tsx
  76. 2 1
      dashboard-v6/src/components/tipitaka/PaliChapterChannelList.tsx
  77. 4 1
      dashboard-v6/src/components/tipitaka/PaliChapterHead.tsx
  78. 1 1
      dashboard-v6/src/components/tipitaka/PaliChapterListByPara.tsx
  79. 1 1
      dashboard-v6/src/components/tipitaka/PaliChapterListByTag.tsx
  80. 1 1
      dashboard-v6/src/components/tipitaka/TocPath.tsx
  81. 1 1
      dashboard-v6/src/components/token/Token.tsx
  82. 1 1
      dashboard-v6/src/components/token/TokenModal.tsx
  83. 1 1
      dashboard-v6/src/components/tpl-builder/ArticleTpl.tsx
  84. 1 1
      dashboard-v6/src/components/tpl-builder/TplBuilder.tsx
  85. 1 1
      dashboard-v6/src/components/wbw/WbwMeaning.tsx
  86. 1 1
      dashboard-v6/src/components/wbw/WbwPali.tsx
  87. 2 1
      dashboard-v6/src/components/wbw/WbwSentCtl.tsx
  88. 1 1
      dashboard-v6/src/components/wbw/WbwWord.tsx
  89. 1 1
      dashboard-v6/src/components/workspace/home/RecentItem.tsx
  90. 69 0
      dashboard-v6/src/hooks/useMergedState.ts
  91. 8 5
      dashboard-v6/src/layouts/workspace/index.tsx
  92. 4 4
      dashboard-v6/src/load.ts
  93. 303 0
      dashboard-v6/src/pages/workspace/chat/index.tsx
  94. 11 0
      dashboard-v6/src/pages/workspace/term/edit.tsx
  95. 12 0
      dashboard-v6/src/pages/workspace/term/list.tsx
  96. 1 1
      dashboard-v6/src/reducers/accept-pr.ts
  97. 1 1
      dashboard-v6/src/reducers/article-mode.ts
  98. 8 5
      dashboard-v6/src/reducers/cart-mode.ts
  99. 1 1
      dashboard-v6/src/reducers/discussion.ts
  100. 1 1
      dashboard-v6/src/reducers/para-change.ts

+ 1 - 0
dashboard-v4/dashboard/src/components/dict/Dictionary.tsx

@@ -93,6 +93,7 @@ const DictionaryWidget = ({ word, compact = false, onSearch }: IWidget) => {
             <Col flex="560px">
               <div style={{ display: "flex" }}>
                 <SearchVocabulary
+                  key={wordInput?.toLowerCase()}
                   compact={compact}
                   value={wordInput?.toLowerCase()}
                   onSearch={dictSearch}

+ 1 - 0
dashboard-v4/dashboard/src/components/term/TermShow.tsx

@@ -53,6 +53,7 @@ const TermShowWidget = ({
               {compact ? <></> : <Col flex="auto"></Col>}
               <Col flex="560px">
                 <SearchVocabulary
+                  key={word}
                   value={word}
                   onSearch={dictSearch}
                   onSplit={(word: string | undefined) => {

+ 3 - 22
dashboard-v6/backup/components/discussion/DiscussionItem.tsx

@@ -1,30 +1,11 @@
 import { Avatar } from "antd";
 import { useEffect, useState } from "react";
-import type { IUser } from "../auth/User"
+import type { IUser } from "../auth/User";
 import DiscussionShow from "./DiscussionShow";
 import DiscussionEdit from "./DiscussionEdit";
-import type { TResType } from "./DiscussionListCard"
-import type { TDiscussionType } from "./Discussion"
+import type { TResType } from "./DiscussionListCard";
+import type { TDiscussionType } from "./Discussion";
 
-export interface IComment {
-  id?: string; //id未提供为新建
-  resId?: string;
-  resType?: TResType;
-  type: TDiscussionType;
-  tplId?: string;
-  user: IUser;
-  parent?: string | null;
-  title?: string;
-  content?: string;
-  html?: string;
-  summary?: string;
-  status?: "active" | "close";
-  children?: IComment[];
-  childrenCount?: number;
-  newTpl?: boolean;
-  createdAt?: string;
-  updatedAt?: string;
-}
 interface IWidget {
   data: IComment;
   isFocus?: boolean;

+ 208 - 0
dashboard-v6/documents/development/frontend-standards.md

@@ -0,0 +1,208 @@
+# 前端开发规范
+
+> React 18 + Ant Design v6 · 适用于 LLM 辅助代码重构
+
+---
+
+## 目录结构
+
+```
+src/
+├── api/           # HTTP 请求层
+├── types/         # TypeScript 类型定义
+├── hooks/         # 业务逻辑 & 异步状态
+├── services/      # 复杂业务逻辑(可选,简单项目可省略)
+├── stores/        # 全局状态(Zustand / Jotai)
+├── components/    # 通用 UI 组件(无业务逻辑)
+├── features/      # 业务功能模块(胶水层)
+├── pages/         # 路由入口(薄层)
+├── layouts/       # 页面框架
+└── utils/         # 纯工具函数
+```
+
+---
+
+## 各层职责
+
+| 目录          | 应该放                                                         | 不应该放                               |
+| ------------- | -------------------------------------------------------------- | -------------------------------------- |
+| `layouts/`    | 页面框架、导航、侧边栏、Header/Footer、Outlet                  | 业务逻辑、API 调用、具体页面内容       |
+| `pages/`      | 路由入口、路由参数提取、权限守卫、页面 title、组合 features    | 业务逻辑、UI 细节、直接调 API          |
+| `features/`   | 连接 hooks 与 components、业务交互回调、局部状态(筛选条件等) | 纯 UI 样式、直接 fetch、跨模块共享逻辑 |
+| `components/` | 纯 UI 组件、样式、props 驱动的交互                             | API 调用、业务判断、store 依赖         |
+| `api/`        | HTTP 请求函数、请求/响应结构映射、拦截器                       | 业务逻辑、UI 反馈、缓存管理            |
+| `hooks/`      | 异步状态管理、业务逻辑封装、跨组件共享状态                     | JSX 渲染、直接操作 DOM、样式           |
+| `services/`   | 复杂业务计算、多个 api 组合调用、领域逻辑                      | UI 反馈、React 相关代码                |
+| `types/`      | 全局共享的 TS 类型、接口定义、枚举                             | 逻辑代码、默认值、工具函数             |
+| `utils/`      | 纯函数工具(格式化、校验、日期处理)                           | 副作用、API 调用、React 代码           |
+
+---
+
+## 数据流向(单向)
+
+```
+用户操作
+  → features/ 触发
+    → hooks/ 处理逻辑
+      → api/ 发请求
+        → 响应回 hooks/(缓存/状态更新)
+          → features/ 重渲染
+            → components/ 展示结果
+```
+
+---
+
+## components 与 features 的区别
+
+**判断标准是与业务是否耦合,而不是复杂度。**
+
+| 维度     | `components/`        | `features/`             |
+| -------- | -------------------- | ----------------------- |
+| 判断标准 | 不知道业务是什么     | 知道具体业务是什么      |
+| 数据来源 | 只接收 props         | 自己调 hooks 取业务数据 |
+| 可复用性 | 跨项目可复用         | 只在本项目有意义        |
+| 复杂度   | 不限(可以非常复杂) | 不限                    |
+
+```tsx
+// ✅ components/ — 不知道「课程」是什么业务概念,换个项目也能用
+<VideoPlayer attachmentId="abc123" />;
+
+// ✅ features/ — 知道业务,负责注入业务回调
+const CourseVideoPlayer = ({ lessonId }) => {
+  const { attachmentId } = useLesson(lessonId); // 业务
+  const { markCompleted } = useLessonProgress(lessonId); // 业务
+  return <VideoPlayer attachmentId={attachmentId} onEnded={markCompleted} />;
+};
+```
+
+> **原则**:`components/` 通过 props 暴露关键事件(`onEnded`、`onProgress`),`features/` 负责注入业务逻辑,保持 `components/` 干净可复用。
+
+---
+
+## hooks 放置规则
+
+**核心问题:这个 hook 会不会在这个组件之外被用到?**
+
+| 情况                                                     | 放哪里       |
+| -------------------------------------------------------- | ------------ |
+| 封装了业务 API 调用                                      | `src/hooks/` |
+| 多个不相关的地方都会用                                   | `src/hooks/` |
+| 只服务于某个组件族,外部不会用                           | 组件目录里   |
+| 依赖组件内部 ref 或第三方库特定实例(playerRef、mapRef) | 组件目录里   |
+
+```text
+src/hooks/
+  └── useAttachment.ts       ✅ 任何地方都可能用,与具体组件无关
+
+components/VideoPlayer/core/
+  ├── useVideoPlayer.ts      ✅ 依赖 video.js 实例,强绑定 VideoPlayer 内部
+  └── useVideoControls.ts    ✅ 依赖 playerRef,离开组件没有意义
+```
+
+---
+
+## 错误处理分层
+
+```text
+api 层              抛出结构化错误,不处理 UI
+QueryClient 全局    处理通用错误(401 / 403 / 500)
+hook onError        处理业务特定错误(该 feature 内有特殊含义的错误码)
+组件层 try/catch    处理仅影响当前组件交互的错误(如表单校验)
+```
+
+**决策树:**
+
+```text
+收到错误
+  ├── 所有页面都一样处理?(401/403/500)
+  │     → QueryClient 全局 onError
+  └── 只在这个业务场景特殊处理?
+        ├── 影响整个 feature 的逻辑  → hook 的 onError
+        └── 只影响当前组件交互       → 组件内 try/catch
+```
+
+**全局错误处理示例:**
+
+```ts
+const queryClient = new QueryClient({
+  queryCache: new QueryCache({
+    onError: (error) => {
+      if (error.code === 403) notification.error({ message: "权限不足" });
+      if (error.code === 401) authStore.logout();
+      if (error.code >= 500) notification.error({ message: "服务器异常" });
+      // 其他 code 不处理,交给业务层
+    },
+  }),
+});
+```
+
+**⚠️ antd v6 notification 问题**:`App.useApp()` 取到的实例无法在 QueryClient 回调中直接使用,需挂载单例:
+
+```ts
+// utils/antdStatic.ts
+let _notification: NotificationInstance;
+export const setNotification = (n: NotificationInstance) => {
+  _notification = n;
+};
+export const staticNotification = {
+  error: (args) => _notification?.error(args),
+};
+
+// layouts/AppInitializer.tsx(在 <App> 内部)
+const { notification } = App.useApp();
+useEffect(() => {
+  setNotification(notification);
+}, []);
+```
+
+---
+
+## Ant Design v6 适配要点
+
+**根节点配置:**
+
+```tsx
+// main.tsx
+<ConfigProvider theme={theme} locale={zhCN}>
+  <App>
+    <QueryClientProvider client={queryClient}>
+      <RouterProvider router={router} />
+    </QueryClientProvider>
+  </App>
+</ConfigProvider>
+```
+
+**注意事项:**
+
+- 使用 `App.useApp()` 替代 `message.xxx` / `Modal.confirm`(v6 静态方法已弃用)
+- Form 数据保持在 Form 实例内,只有提交结果才流向业务层
+- 复杂表格的 `columns` 单独抽成 `columns.tsx`,保持组件干净
+- `ConfigProvider` 放顶层,统一管理 token、locale、theme
+
+---
+
+## 推荐技术栈
+
+| 职责         | 方案                                |
+| ------------ | ----------------------------------- |
+| 异步状态管理 | TanStack Query v5                   |
+| 全局同步状态 | Zustand                             |
+| 路由         | React Router v7                     |
+| HTTP 客户端  | Axios(拦截器统一处理 token、错误) |
+| UI 组件库    | Ant Design v6                       |
+
+---
+
+## 一句话总结
+
+| 层            | 定义                                   |
+| ------------- | -------------------------------------- |
+| `api/`        | 只管收发 HTTP,不含任何判断            |
+| `hooks/`      | 承载状态管理、缓存、错误处理,不含 JSX |
+| `services/`   | 纯业务计算,与 UI/React 完全无关       |
+| `components/` | 纯 UI,props 驱动,可跨项目复用        |
+| `features/`   | 胶水层,把 hooks 的数据喂给 components |
+| `pages/`      | 路由入口,组合 features,越薄越好      |
+| `layouts/`    | 页面框架壳子,不知道任何业务           |
+| `stores/`     | 只存真正需要跨模块共享的状态           |
+| `utils/`      | 纯函数,无副作用,无 React             |

+ 42 - 3
dashboard-v6/src/App.tsx

@@ -1,23 +1,62 @@
-import { Suspense } from "react";
+// src/App.tsx
+import { App as AntdApp, ConfigProvider, theme } from "antd";
+import { Suspense, useEffect, useState } from "react";
 import { IntlProvider } from "react-intl";
-import { Provider } from "react-redux";
+import { Provider, useSelector } from "react-redux";
 
 import Router from "./Router";
 import store from "./store";
 import { detect as detect_locale, messages as get_messages } from "./locales";
 import Loading from "./components/loading/Loading";
 import onLoad from "./load";
+import { mode as _mode } from "./reducers/theme";
 
 onLoad();
 const locale = detect_locale();
 const messages = get_messages(locale);
 
+const prefersDark = window.matchMedia("(prefers-color-scheme: dark)");
+
+const ThemedApp = () => {
+  const themeMode = useSelector(_mode);
+  const [systemIsDark, setSystemIsDark] = useState(prefersDark.matches);
+
+  useEffect(() => {
+    localStorage.setItem("theme/mode", themeMode);
+  }, [themeMode]);
+
+  useEffect(() => {
+    const handler = (e: MediaQueryListEvent) => setSystemIsDark(e.matches);
+    prefersDark.addEventListener("change", handler);
+    return () => prefersDark.removeEventListener("change", handler);
+  }, []);
+
+  const isDark =
+    themeMode === "dark" || (themeMode === "system" && systemIsDark);
+
+  return (
+    <ConfigProvider
+      theme={{
+        algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
+        token: {
+          colorPrimary: "#1677ff",
+          borderRadius: 6,
+        },
+      }}
+    >
+      <AntdApp>
+        <Router />
+      </AntdApp>
+    </ConfigProvider>
+  );
+};
+
 const Widget = () => {
   return (
     <IntlProvider locale={locale} messages={messages}>
       <Suspense fallback={<Loading />}>
         <Provider store={store}>
-          <Router />
+          <ThemedApp />
         </Provider>
       </Suspense>
     </IntlProvider>

+ 21 - 7
dashboard-v6/src/Router.tsx

@@ -15,7 +15,6 @@ const UsersSignIn = lazy(() => import("./pages/users/sign-in"));
 const UsersSignUp = lazy(() => import("./pages/users/sign-up"));
 const UsersForgotPassword = lazy(() => import("./pages/users/forgot-password"));
 const UsersResetPassword = lazy(() => import("./pages/users/reset-password"));
-const UsersPersonal = lazy(() => import("./pages/users/personal"));
 const DashboardIndex = lazy(() => import("./pages/dashboard/index"));
 const Home = lazy(() => import("./pages/home"));
 const WorkspaceChannel = lazy(() => import("./pages/workspace/channel/list"));
@@ -29,6 +28,10 @@ const WorkspaceTipitaka = lazy(
   () => import("./pages/workspace/tipitaka/bypath")
 );
 const WorkspaceHome = lazy(() => import("./pages/workspace/home"));
+const WorkspaceChat = lazy(() => import("./pages/workspace/chat"));
+
+const WorkspaceTerm = lazy(() => import("./pages/workspace/term/list"));
+const WorkspaceTermEdit = lazy(() => import("./pages/workspace/term/edit"));
 
 // ↓ 新增:TestLayout
 const TestLayout = lazy(() => import("./layouts/test"));
@@ -74,18 +77,18 @@ const router = createBrowserRouter(
         {
           path: "workspace",
           Component: WorkspaceLayout,
-          handle: { crumb: "workspace" },
+          handle: { id: "workspace.home", crumb: "workspace" },
           children: [
             { index: true, Component: WorkspaceHome },
             {
               path: "ai",
-              Component: UsersPersonal,
-              handle: { crumb: "ai" },
+              Component: WorkspaceChat,
+              handle: { id: "workspace.ai", crumb: "ai" },
             },
             {
               path: "tipitaka",
               Component: WorkspaceTipitaka,
-              handle: { crumb: "tipitaka" },
+              handle: { id: "workspace.tipitaka", crumb: "tipitaka" },
               children: [
                 {
                   path: ":root",
@@ -107,7 +110,7 @@ const router = createBrowserRouter(
             },
             {
               path: "channel",
-              handle: { crumb: "channel" },
+              handle: { id: "workspace.channel", crumb: "channel" },
               children: [
                 {
                   index: true,
@@ -134,6 +137,11 @@ const router = createBrowserRouter(
                 },
               ],
             },
+            {
+              path: "term",
+              handle: { id: "workspace.term", crumb: "term" },
+              Component: WorkspaceTerm,
+            },
             {
               path: "edit",
               Component: WorkspaceEditorLayout,
@@ -165,7 +173,13 @@ const router = createBrowserRouter(
                 },
                 {
                   path: "wiki",
-                  children: [{ path: ":id" }],
+                  children: [
+                    {
+                      path: ":id",
+                      Component: WorkspaceTermEdit,
+                      children: [{ index: true, Component: WorkspaceTermEdit }],
+                    },
+                  ],
                 },
               ],
             },

+ 35 - 1
dashboard-v6/src/api/Article.ts

@@ -1,6 +1,40 @@
 import type { IStudio, IStudioApiResponse, IUser, TRole } from "./Auth";
 import type { IChannel } from "./Channel";
-import type { ITocPathNode } from "./Corpus";
+import type { ITocPathNode } from "./pali-text";
+
+export type TContentType = "text" | "markdown" | "html" | "json";
+
+export type ArticleMode = "read" | "edit" | "wbw" | "auto";
+export type ArticleType =
+  | "anthology"
+  | "article"
+  | "series"
+  | "chapter"
+  | "para"
+  | "cs-para"
+  | "sent"
+  | "sim"
+  | "page"
+  | "textbook"
+  | "sent-original"
+  | "sent-commentary"
+  | "sent-nissaya"
+  | "sent-translation"
+  | "term"
+  | "task";
+/**
+ * 每种article type 对应的路由参数
+ * article/id?anthology=id&channel=id1,id2&mode=ArticleMode
+ * chapter/book-para?channel=id1,id2&mode=ArticleMode
+ * para/book?par=para1,para2&channel=id1,id2&mode=ArticleMode
+ * cs-para/book-para?channel=id1,id2&mode=ArticleMode
+ * sent/id?channel=id1,id2&mode=ArticleMode
+ * sim/id?channel=id1,id2&mode=ArticleMode
+ * textbook/articleId?course=id&mode=ArticleMode
+ * exercise/articleId?course=id&exercise=id&username=name&mode=ArticleMode
+ * exercise-list/articleId?course=id&exercise=id&mode=ArticleMode
+ * sent-original/id
+ */
 
 export interface IArticleListApiResponse {
   article: string;

+ 1 - 0
dashboard-v6/src/api/Channel.ts

@@ -25,6 +25,7 @@ export interface ChannelInfoProps {
   studio: IStudio;
   count?: number;
 }
+
 /**
  * 句子完成情况
  * [句子字符数,是否完成]

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

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

+ 0 - 402
dashboard-v6/src/api/Corpus.ts

@@ -1,407 +1,5 @@
-import type { MenuProps } from "antd";
-import type { IStudio } from "./Auth";
-import type { IUser } from "./Auth";
-import type { IChannel, TChannelType } from "./Channel";
-import type { TagNode } from "./Tag";
-import type { ISuggestionCount } from "./Suggestion";
-
-export type TContentType = "text" | "markdown" | "html" | "json";
-
-export interface ISentence {
-  id?: string;
-  uid?: string;
-  content: string | null;
-  contentType?: TContentType;
-  html: string;
-  book: number;
-  para: number;
-  wordStart: number;
-  wordEnd: number;
-  editor: IUser;
-  acceptor?: IUser;
-  prEditAt?: string;
-  channel: IChannel;
-  studio?: IStudio;
-  forkAt?: string | null;
-  updateAt: string;
-  createdAt?: string;
-  suggestionCount?: ISuggestionCount;
-  openInEditMode?: boolean;
-  translationChannels?: string[];
-}
-
-export interface ITocPathNode {
-  key?: string;
-  book?: number;
-  paragraph?: number;
-  title: string;
-  paliTitle?: string;
-  level: number;
-  menu?: MenuProps["items"];
-}
-export type ArticleMode = "read" | "edit" | "wbw" | "auto";
-export type ArticleType =
-  | "anthology"
-  | "article"
-  | "series"
-  | "chapter"
-  | "para"
-  | "cs-para"
-  | "sent"
-  | "sim"
-  | "page"
-  | "textbook"
-  | "sent-original"
-  | "sent-commentary"
-  | "sent-nissaya"
-  | "sent-translation"
-  | "term"
-  | "task";
-/**
- * 每种article type 对应的路由参数
- * article/id?anthology=id&channel=id1,id2&mode=ArticleMode
- * chapter/book-para?channel=id1,id2&mode=ArticleMode
- * para/book?par=para1,para2&channel=id1,id2&mode=ArticleMode
- * cs-para/book-para?channel=id1,id2&mode=ArticleMode
- * sent/id?channel=id1,id2&mode=ArticleMode
- * sim/id?channel=id1,id2&mode=ArticleMode
- * textbook/articleId?course=id&mode=ArticleMode
- * exercise/articleId?course=id&exercise=id&username=name&mode=ArticleMode
- * exercise-list/articleId?course=id&exercise=id&mode=ArticleMode
- * sent-original/id
- */
-
-export interface ISentEditData {
-  id: string;
-  book: number;
-  para: number;
-  wordStart: number;
-  wordEnd: number;
-  channels?: string[];
-  origin?: ISentence[];
-  translation?: ISentence[];
-  commentaries?: ISentence[];
-  answer?: ISentence;
-  path?: ITocPathNode[];
-  layout?: "row" | "column";
-  tranNum?: number;
-  nissayaNum?: number;
-  commNum?: number;
-  originNum: number;
-  simNum?: number;
-  compact?: boolean;
-  mode?: ArticleMode;
-  showWbwProgress?: boolean;
-  readonly?: boolean;
-  wbwProgress?: number;
-  wbwScore?: number;
-}
-
-export interface IApiPaliChapterList {
-  id: string;
-  book: number;
-  paragraph: number;
-  level: number;
-  toc: string;
-  title: string;
-  lenght: number;
-  chapter_len: number;
-  next_chapter: number;
-  prev_chapter: number;
-  parent: number;
-  chapter_strlen: number;
-  path: string;
-  progress_line?: number[];
-}
-
-export interface IPaliChapterListResponse {
-  ok: boolean;
-  message: string;
-  data: { rows: IApiPaliChapterList[]; count: number };
-}
-export interface IApiResponsePaliChapter {
-  ok: boolean;
-  message: string;
-  data: IApiPaliChapterList;
-}
-
-export interface IPaliPara {
-  book: number;
-  paragraph: number;
-  level: number;
-  class: string;
-  toc: string;
-  text: string;
-  html: string;
-  lenght: number;
-  chapter_len: number;
-  next_chapter: number;
-  prev_chapter: number;
-  parent: number;
-  chapter_strlen: number;
-  path: string;
-  uid: string;
-}
-
-export interface IPaliParagraphResponse {
-  ok: boolean;
-  message: string;
-  data: IPaliPara;
-}
-export interface IPaliListResponse {
-  ok: boolean;
-  message: string;
-  data: { rows: IPaliPara[]; count: number };
-}
-
-/**
- * progress?view=chapter_channels&book=98&par=22
- */
-export interface IChapterChannelData {
-  book: number;
-  para: number;
-  uid: string;
-  channel_id: string;
-  progress: number;
-  progress_line?: number[];
-  updated_at: string;
-  views: number;
-  likes: number[];
-  channel: {
-    type: TChannelType;
-    owner_uid: string;
-    editor_id: number;
-    name: string;
-    summary: string;
-    lang: string;
-    status: number;
-    created_at: string;
-    updated_at: string;
-    uid: string;
-  };
-  studio: IStudio;
-}
-
-export interface IChapterChannelListResponse {
-  ok: boolean;
-  message: string;
-  data: { rows: IChapterChannelData[]; count: number };
-}
-
-export interface IApiChapterTag {
-  id: string;
-  name: string;
-  count: number;
-}
-export interface IApiResponseChapterTagList {
-  ok: boolean;
-  message: string;
-  data: { rows: IApiChapterTag[]; count: number };
-}
-
-export interface IApiResponseChannelListData {
-  channel_id: string;
-  count: number;
-  channel: {
-    id: number;
-    type: TChannelType;
-    owner_uid: string;
-    editor_id: number;
-    name: string;
-    summary: string;
-    lang: string;
-    status: number;
-    setting: string;
-    created_at: string;
-    updated_at: string;
-    uid: string;
-  };
-  studio: IStudio;
-}
-export interface IApiResponseChannelList {
-  ok: boolean;
-  message: string;
-  data: { rows: IApiResponseChannelListData[]; count: number };
-}
-
-export interface ISentenceDiffRequest {
-  sentences: string[];
-  channels: string[];
-}
-export interface ISentenceDiffData {
-  book_id: number;
-  paragraph: number;
-  word_start: number;
-  word_end: number;
-  channel_uid: string;
-  content: string | null;
-  content_type: string;
-  editor_uid: string;
-  updated_at: string;
-}
-export interface ISentenceDiffResponse {
-  ok: boolean;
-  message: string;
-  data: { rows: ISentenceDiffData[]; count: number };
-}
-
-export interface ISentenceRequest {
-  book: number;
-  para: number;
-  wordStart: number;
-  wordEnd: number;
-  channel: string;
-  content: string | null;
-  contentType?: TContentType;
-  prEditor?: string;
-  prId?: string;
-  prUuid?: string;
-  prEditAt?: string;
-  channels?: string;
-  html?: boolean;
-  token?: string | null;
-}
-
-export interface ISentenceData {
-  id?: string;
-  book: number;
-  paragraph: number;
-  word_start: number;
-  word_end: number;
-  content: string;
-  content_type?: TContentType;
-  html: string;
-  editor: IUser;
-  channel: IChannel;
-  studio: IStudio;
-  updated_at: string;
-  acceptor?: IUser;
-  pr_edit_at?: string;
-  fork_at?: string;
-  suggestionCount?: ISuggestionCount;
-}
-
-export interface ISentenceResponse {
-  ok: boolean;
-  message: string;
-  data: ISentenceData;
-}
-export interface ISentenceListResponse {
-  ok: boolean;
-  message: string;
-  data: { rows: ISentenceData[]; count: number };
-}
-export interface ISentenceNewRequest {
-  sentences: ISentenceDiffData[];
-  channel?: string;
-  copy?: boolean;
-  fork_from?: string;
-}
-
-export interface IPaliToc {
-  book: number;
-  paragraph: number;
-  level: string;
-  toc: string;
-  translation?: string;
-}
-
-export interface IPaliTocListResponse {
-  ok: boolean;
-  message: string;
-  data: { rows: IPaliToc[]; count: number };
-}
-
-export interface IChapterToc {
-  book: number;
-  paragraph: number;
-  level: number;
-  text: string | null;
-  chapter_len: number;
-  chapter_strlen: number;
-  parent: number;
-}
-
-export interface IChapterTocListResponse {
-  ok: boolean;
-  message: string;
-  data: { rows: IChapterToc[]; count: number };
-}
-
 export interface IPaliBookListResponse {
   name: string;
   tag: string[];
   children?: IPaliBookListResponse[];
 }
-
-export interface IChapterData {
-  title: string;
-  toc: string;
-  book: number;
-  para: number;
-  path: string;
-  tags: TagNode[];
-  channel: { name: string; owner_uid: string };
-  studio: IStudio;
-  channel_id: string;
-  summary: string;
-  view: number;
-  like: number;
-  status?: number;
-  progress: number;
-  progress_line?: number[];
-  created_at: string;
-  updated_at: string;
-}
-export interface IChapterListResponse {
-  ok: boolean;
-  message: string;
-  data: { rows: IChapterData[]; count: number };
-}
-
-export interface ILangList {
-  lang: string;
-  count: number;
-}
-export interface IChapterLangListResponse {
-  ok: boolean;
-  message: string;
-  data: { rows: ILangList[]; count: number };
-}
-
-export interface ISentencePrRequest {
-  book?: number;
-  para?: number;
-  begin?: number;
-  end?: number;
-  channel?: string;
-  text: string;
-}
-export interface ISentencePrResponseData {
-  book_id: number;
-  paragraph: number;
-  word_start: number;
-  word_end: number;
-  channel_uid: string;
-}
-export interface ISentencePrResponse {
-  ok: boolean;
-  message: string;
-  data: {
-    new: ISentencePrResponseData;
-    count: number;
-    webhook: { message: string; ok: boolean };
-  };
-}
-
-export interface ISentenceWbwListResponse {
-  ok: boolean;
-  message: string;
-  data: { rows: ISentEditData[]; count: number };
-}
-
-export interface IEditableSentence {
-  ok: boolean;
-  message: string;
-  data: ISentEditData;
-}

+ 22 - 0
dashboard-v6/src/api/discussion.ts

@@ -1,3 +1,5 @@
+import type { IUser } from "./Auth";
+
 export type TResType =
   | "article"
   | "channel"
@@ -7,3 +9,23 @@ export type TResType =
   | "term"
   | "task";
 export type TDiscussionType = "qa" | "discussion" | "help" | "comment";
+
+export interface IComment {
+  id?: string; //id未提供为新建
+  resId?: string;
+  resType?: TResType;
+  type: TDiscussionType;
+  tplId?: string;
+  user: IUser;
+  parent?: string | null;
+  title?: string;
+  content?: string;
+  html?: string;
+  summary?: string;
+  status?: "active" | "close";
+  children?: IComment[];
+  childrenCount?: number;
+  newTpl?: boolean;
+  createdAt?: string;
+  updatedAt?: string;
+}

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

@@ -0,0 +1,102 @@
+import type { MenuProps } from "antd";
+export interface ITocPathNode {
+  key?: string;
+  book?: number;
+  paragraph?: number;
+  title: string;
+  paliTitle?: string;
+  level: number;
+  menu?: MenuProps["items"];
+}
+
+export interface IApiPaliChapterList {
+  id: string;
+  book: number;
+  paragraph: number;
+  level: number;
+  toc: string;
+  title: string;
+  lenght: number;
+  chapter_len: number;
+  next_chapter: number;
+  prev_chapter: number;
+  parent: number;
+  chapter_strlen: number;
+  path: string;
+  progress_line?: number[];
+}
+export interface IPaliChapterListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: IApiPaliChapterList[]; count: number };
+}
+export interface IApiResponsePaliChapter {
+  ok: boolean;
+  message: string;
+  data: IApiPaliChapterList;
+}
+
+//////////////////
+
+export interface IPaliPara {
+  book: number;
+  paragraph: number;
+  level: number;
+  class: string;
+  toc: string;
+  text: string;
+  html: string;
+  lenght: number;
+  chapter_len: number;
+  next_chapter: number;
+  prev_chapter: number;
+  parent: number;
+  chapter_strlen: number;
+  path: string;
+  uid: string;
+}
+
+export interface IPaliParagraphResponse {
+  ok: boolean;
+  message: string;
+  data: IPaliPara;
+}
+export interface IPaliListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: IPaliPara[]; count: number };
+}
+
+//
+
+export interface IPaliToc {
+  book: number;
+  paragraph: number;
+  level: string;
+  toc: string;
+  translation?: string;
+}
+
+export interface IPaliTocListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: IPaliToc[]; count: number };
+}
+
+//
+
+export interface IChapterToc {
+  book: number;
+  paragraph: number;
+  level: number;
+  text: string | null;
+  chapter_len: number;
+  chapter_strlen: number;
+  parent: number;
+}
+
+export interface IChapterTocListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: IChapterToc[]; count: number };
+}

+ 101 - 0
dashboard-v6/src/api/progress.ts

@@ -0,0 +1,101 @@
+import type { IStudio } from "./Auth";
+import type { TChannelType } from "./Channel";
+import type { TagNode } from "./Tag";
+
+export interface IApiResponseChannelListData {
+  channel_id: string;
+  count: number;
+  channel: {
+    id: number;
+    type: TChannelType;
+    owner_uid: string;
+    editor_id: number;
+    name: string;
+    summary: string;
+    lang: string;
+    status: number;
+    setting: string;
+    created_at: string;
+    updated_at: string;
+    uid: string;
+  };
+  studio: IStudio;
+}
+export interface IApiResponseChannelList {
+  ok: boolean;
+  message: string;
+  data: { rows: IApiResponseChannelListData[]; count: number };
+}
+
+//=========
+
+export interface IChapterData {
+  title: string;
+  toc: string;
+  book: number;
+  para: number;
+  path: string;
+  tags: TagNode[];
+  channel: { name: string; owner_uid: string };
+  studio: IStudio;
+  channel_id: string;
+  summary: string;
+  view: number;
+  like: number;
+  status?: number;
+  progress: number;
+  progress_line?: number[];
+  created_at: string;
+  updated_at: string;
+}
+export interface IChapterListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: IChapterData[]; count: number };
+}
+
+//===========
+
+export interface ILangList {
+  lang: string;
+  count: number;
+}
+export interface IChapterLangListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: ILangList[]; count: number };
+}
+
+/**
+ * progress?view=chapter_channels&book=98&par=22
+ */
+export interface IChapterChannelData {
+  book: number;
+  para: number;
+  uid: string;
+  channel_id: string;
+  progress: number;
+  progress_line?: number[];
+  updated_at: string;
+  views: number;
+  likes: number[];
+  channel: {
+    type: TChannelType;
+    owner_uid: string;
+    editor_id: number;
+    name: string;
+    summary: string;
+    lang: string;
+    status: number;
+    created_at: string;
+    updated_at: string;
+    uid: string;
+  };
+  studio: IStudio;
+}
+
+export interface IChapterChannelListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: IChapterChannelData[]; count: number };
+}

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

@@ -1,5 +1,5 @@
 import { get } from "../request";
-import type { ArticleType } from "./Corpus";
+import type { ArticleType } from "./Article";
 
 export interface IRecent {
   id: string;

+ 33 - 0
dashboard-v6/src/api/sentence-history.ts

@@ -1,6 +1,9 @@
+import { get } from "../request";
 import type { IStudio, IUser } from "./Auth";
 import type { IChannel } from "./Channel";
 
+// ─── 原有类型定义,保持不动 ─────────────────────────────────────────────────────
+
 export interface ISentHistoryData {
   id: string;
   sent_uid: string;
@@ -28,3 +31,33 @@ export interface ISentHistory {
   accepter?: IUser;
   createdAt: string;
 }
+
+// ─── 新增:纯 HTTP 函数,供 hooks 层调用 ────────────────────────────────────────
+// history 是只读表,只有查询操作
+
+export type THistoryView = "sentence" | "channel";
+
+/**
+ * 查询某句的修改历史
+ * @param sentId   句子的数据库 id(sentences.id)
+ * @param view     查询视角
+ * @param fork     是否只看 fork 记录(EditInfo 里的 Fork 组件用)
+ */
+export async function fetchSentenceHistory(
+  sentId: string,
+  view: THistoryView = "sentence",
+  fork = false
+): Promise<ISentHistoryData[]> {
+  let url = `/v2/sent_history?view=${view}&id=${sentId}`;
+  if (fork) {
+    url += `&fork=1`;
+  }
+
+  const json = await get<ISentHistoryListResponse>(url);
+
+  if (!json.ok) {
+    throw new Error(json.message ?? "历史记录加载失败");
+  }
+
+  return json.data.rows;
+}

+ 133 - 0
dashboard-v6/src/api/sentence-pr.ts

@@ -0,0 +1,133 @@
+import { get, post, put, delete_ } from "../request";
+import type { ISentence } from "./sentence";
+import type { IChannel } from "./Channel";
+import type { IUser } from "./Auth";
+
+// ─── 原有类型定义,保持不动 ─────────────────────────────────────────────────────
+
+export interface ISentencePrRequest {
+  book?: number;
+  para?: number;
+  begin?: number;
+  end?: number;
+  channel?: string;
+  text: string;
+}
+export interface ISentencePrResponseData {
+  book_id: number;
+  paragraph: number;
+  word_start: number;
+  word_end: number;
+  channel_uid: string;
+}
+export interface ISentencePrResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    new: ISentencePrResponseData;
+    count: number;
+    webhook: { message: string; ok: boolean };
+  };
+}
+
+// ─── 新增:PR 列表查询的响应类型 ────────────────────────────────────────────────
+
+export interface ISentencePrData {
+  id: string;
+  uid: string;
+  book: number;
+  paragraph: number;
+  word_start: number;
+  word_end: number;
+  content: string;
+  html: string;
+  editor: IUser;
+  channel: IChannel;
+  updated_at: string;
+}
+
+export interface ISentencePrListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: ISentencePrData[]; count: number };
+}
+
+export interface IDeleteResponse {
+  ok: boolean;
+  message: string;
+}
+
+// ─── 新增:纯 HTTP 函数,供 hooks 层调用 ────────────────────────────────────────
+
+/**
+ * 查询某句下的 PR 列表
+ */
+export async function fetchSentencePrList(
+  book: number,
+  para: number,
+  wordStart: number,
+  wordEnd: number,
+  channelId: string
+): Promise<ISentencePrData[]> {
+  const url = `/v2/sentpr?view=sent-info&book=${book}&para=${para}&start=${wordStart}&end=${wordEnd}&channel=${channelId}`;
+
+  const json = await get<ISentencePrListResponse>(url);
+
+  if (!json.ok) {
+    throw new Error(json.message ?? "PR 列表加载失败");
+  }
+
+  return json.data.rows;
+}
+
+/**
+ * 创建 PR(提交修改建议)
+ */
+export async function createSentencePr(
+  sentence: ISentence,
+  content: string
+): Promise<void> {
+  const json = await post<ISentencePrRequest, ISentencePrResponse>(
+    `/v2/sentpr`,
+    {
+      book: sentence.book,
+      para: sentence.para,
+      begin: sentence.wordStart,
+      end: sentence.wordEnd,
+      channel: sentence.channel.id,
+      text: content,
+    }
+  );
+
+  if (!json.ok) {
+    throw new Error(json.message ?? "提交建议失败");
+  }
+}
+
+/**
+ * 更新 PR 内容
+ */
+export async function updateSentencePr(
+  prId: string,
+  content: string
+): Promise<void> {
+  const json = await put<Pick<ISentencePrRequest, "text">, ISentencePrResponse>(
+    `/v2/sentpr/${prId}`,
+    { text: content }
+  );
+
+  if (!json.ok) {
+    throw new Error(json.message ?? "更新建议失败");
+  }
+}
+
+/**
+ * 删除 PR
+ */
+export async function deleteSentencePr(prId: string): Promise<void> {
+  const json = await delete_<IDeleteResponse>(`/v2/sentpr/${prId}`);
+
+  if (!json.ok) {
+    throw new Error(json.message ?? "删除失败");
+  }
+}

+ 242 - 7
dashboard-v6/src/api/sentence.ts

@@ -1,15 +1,152 @@
 import type { IntlShape } from "react-intl";
-import type {
-  ISentence,
-  ISentenceData,
-  ISentenceRequest,
-  ISentenceResponse,
-} from "./Corpus";
+import type { ArticleMode, TContentType } from "./Article";
 import store from "../store";
 import { statusChange } from "../reducers/net-status";
-import { put } from "../request";
+import { get, put } from "../request";
 import { message } from "antd";
 import { toISentence } from "../components/sentence/utils";
+import type { IStudio, IUser } from "./Auth";
+import type { IChannel } from "./Channel";
+import type { ISuggestionCount } from "./Suggestion";
+import type { ITocPathNode } from "./pali-text";
+
+// ─── 以下是原有类型定义,保持不动 ─────────────────────────────────────────────
+export interface ISentence {
+  id?: string;
+  uid?: string;
+  content: string | null;
+  contentType?: TContentType;
+  html: string;
+  book: number;
+  para: number;
+  wordStart: number;
+  wordEnd: number;
+  editor: IUser;
+  acceptor?: IUser;
+  prEditAt?: string;
+  channel: IChannel;
+  studio?: IStudio;
+  forkAt?: string | null;
+  updateAt: string;
+  createdAt?: string;
+  suggestionCount?: ISuggestionCount;
+  openInEditMode?: boolean;
+  translationChannels?: string[];
+}
+
+export interface ISentenceDiffRequest {
+  sentences: string[];
+  channels: string[];
+}
+export interface ISentenceDiffData {
+  book_id: number;
+  paragraph: number;
+  word_start: number;
+  word_end: number;
+  channel_uid: string;
+  content: string | null;
+  content_type: string;
+  editor_uid: string;
+  updated_at: string;
+}
+export interface ISentenceDiffResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: ISentenceDiffData[]; count: number };
+}
+
+export interface ISentenceRequest {
+  book: number;
+  para: number;
+  wordStart: number;
+  wordEnd: number;
+  channel: string;
+  content: string | null;
+  contentType?: TContentType;
+  prEditor?: string;
+  prId?: string;
+  prUuid?: string;
+  prEditAt?: string;
+  channels?: string;
+  html?: boolean;
+  token?: string | null;
+}
+
+export interface ISentenceData {
+  id?: string;
+  book: number;
+  paragraph: number;
+  word_start: number;
+  word_end: number;
+  content: string;
+  content_type?: TContentType;
+  html: string;
+  editor: IUser;
+  channel: IChannel;
+  studio: IStudio;
+  updated_at: string;
+  acceptor?: IUser;
+  pr_edit_at?: string;
+  fork_at?: string;
+  suggestionCount?: ISuggestionCount;
+}
+
+export interface ISentenceResponse {
+  ok: boolean;
+  message: string;
+  data: ISentenceData;
+}
+export interface ISentenceListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: ISentenceData[]; count: number };
+}
+export interface ISentenceNewRequest {
+  sentences: ISentenceDiffData[];
+  channel?: string;
+  copy?: boolean;
+  fork_from?: string;
+}
+
+export interface ISentenceWbwListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: ISentEditData[]; count: number };
+}
+
+export interface IEditableSentence {
+  ok: boolean;
+  message: string;
+  data: ISentEditData;
+}
+
+export interface ISentEditData {
+  id: string;
+  book: number;
+  para: number;
+  wordStart: number;
+  wordEnd: number;
+  channels?: string[];
+  origin?: ISentence[];
+  translation?: ISentence[];
+  commentaries?: ISentence[];
+  answer?: ISentence;
+  path?: ITocPathNode[];
+  layout?: "row" | "column";
+  tranNum?: number;
+  nissayaNum?: number;
+  commNum?: number;
+  originNum: number;
+  simNum?: number;
+  compact?: boolean;
+  mode?: ArticleMode;
+  showWbwProgress?: boolean;
+  readonly?: boolean;
+  wbwProgress?: number;
+  wbwScore?: number;
+}
+
+// ─── 原有函数,保持不动,重构完成后再删除 ──────────────────────────────────────
 
 export const sentSave = async (
   sent: ISentence,
@@ -65,3 +202,101 @@ export const sentSave = async (
     finish?.();
   }
 };
+
+// ─── 新增:纯 HTTP 函数,供 hooks 层调用 ────────────────────────────────────────
+// 规范:只管收发,不含 UI 反馈、不含 store.dispatch、不含业务判断
+
+/**
+ * 加载单条句子
+ */
+export async function fetchSentence(
+  book: number,
+  para: number,
+  wordStart: number,
+  wordEnd: number,
+  channelId: string
+): Promise<ISentenceData> {
+  const sentId = `${book}-${para}-${wordStart}-${wordEnd}`;
+  const url = `/v2/sentence?view=channel&sentence=${sentId}&channel=${channelId}&html=true`;
+
+  const json = await get<ISentenceListResponse>(url);
+
+  if (!json.ok || json.data.count === 0) {
+    throw new Error(json.message ?? "句子加载失败");
+  }
+
+  return json.data.rows[0];
+}
+
+/**
+ * 保存句子内容(新建 or 更新)
+ */
+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 json = await put<ISentenceRequest, ISentenceResponse>(url, {
+    book: sent.book,
+    para: sent.para,
+    wordStart: sent.wordStart,
+    wordEnd: sent.wordEnd,
+    channel: sent.channel.id,
+    content: sent.content,
+    contentType: sent.contentType,
+    channels: sent.translationChannels?.join(),
+    token: sessionStorage.getItem(sent.channel.id),
+  });
+
+  if (!json.ok) {
+    throw new Error(json.message ?? "保存失败");
+  }
+
+  return json.data;
+}
+
+/**
+ * 采纳 PR:把 PR 内容写回正式句子
+ */
+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 json = await put<ISentenceRequest, ISentenceResponse>(url, {
+    book: prData.book,
+    para: prData.para,
+    wordStart: prData.wordStart,
+    wordEnd: prData.wordEnd,
+    channel: prData.channel.id,
+    content: prData.content,
+    prEditor: prData.editor?.id,
+    prId: prData.id,
+    prUuid: prData.uid,
+    prEditAt: prData.updateAt,
+    token: sessionStorage.getItem(prData.channel.id),
+  });
+
+  if (!json.ok) {
+    throw new Error(json.message ?? "采纳失败");
+  }
+
+  return json.data;
+}
+
+/**
+ * 获取 Snowflake ID(wbw 节点赋 uid 用)
+ */
+export async function fetchSnowflakeIds(count: number): Promise<string[]> {
+  const json = await get<{
+    ok: boolean;
+    message?: string;
+    data: { rows: string[]; count: number };
+  }>(`/v2/snowflake?count=${count}`);
+
+  if (!json.ok) {
+    throw new Error(json.message ?? "获取 ID 失败");
+  }
+
+  return json.data.rows;
+}

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

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

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

@@ -1,4 +1,4 @@
-import type { ArticleType } from "./Corpus";
+import type { ArticleType } from "./Article";
 import { getRecentByUser } from "./recent";
 
 export type ModuleItem = {

+ 25 - 0
dashboard-v6/src/components/ai/Chat.tsx

@@ -0,0 +1,25 @@
+import { Bubble, Sender } from "@ant-design/x";
+
+const messages = [
+  {
+    content: "Hello, Ant Design X!",
+    role: "user",
+    key: "user_0",
+  },
+];
+
+const App = () => (
+  <div
+    style={{
+      height: "400px",
+      display: "flex",
+      flexDirection: "column",
+      justifyContent: "space-between",
+    }}
+  >
+    <Bubble.List items={messages} />
+    <Sender />
+  </div>
+);
+
+export default App;

+ 173 - 0
dashboard-v6/src/components/article/article.css

@@ -0,0 +1,173 @@
+.pcd_article h1,
+.pcd_article h2,
+.pcd_article h3,
+.pcd_article h4,
+.pcd_article h5,
+.pcd_article h6 {
+  font-weight: 700;
+}
+
+/*重置文章容器的计数器*/
+.pcd_article {
+  counter-reset: h1 h2 h3 h4;
+}
+
+/*重置文章导航容器的计数器*/
+.article_anchor {
+  counter-reset: h1 h2 h3 h4 !important;
+}
+
+/*统一文章导航容器的字体*/
+.article_anchor h1,
+.article_anchor h2,
+.article_anchor h3,
+.article_anchor h4,
+.article_anchor h5,
+.article_anchor h6 {
+  font-size: 14px !important;
+  font-weight: 500 !important;
+}
+
+.article_anchor div:has(> a > h1) {
+  counter-reset: h2;
+  counter-increment: h1;
+}
+
+.paper_zh h1::before {
+  content: counter(h1, trad-chinese-informal) "、";
+}
+.paper_en h1::before {
+  content: counter(h1) ".";
+}
+
+.article_anchor div:has(> a > h2) {
+  counter-reset: h3;
+  counter-increment: h2;
+}
+.paper_zh h2::before {
+  content: "(" counter(h2, trad-chinese-informal) ")";
+}
+.paper_en h2::before {
+  content: counter(h1) "." counter(h2);
+}
+
+.article_anchor div:has(> a > h3) {
+  counter-increment: h3;
+  counter-reset: h4;
+}
+.paper_zh h3::before {
+  content: counter(h3) ".";
+}
+.paper_en h3::before {
+  content: counter(h1) "." counter(h2) "." counter(h3);
+}
+
+.article_anchor div:has(> a > h4) {
+  counter-increment: h4;
+  /*counter-reset: h5;*/
+}
+.paper_zh h4::before {
+  content: "(" counter(h4) ")";
+}
+.paper_en h4::before {
+  content: counter(h1) "." counter(h2) "." counter(h3) "." counter(h4);
+}
+
+.pcd_md_editor h1,
+.pcd_article h1 {
+  margin-top: 1em;
+  font-size: 27px !important;
+  border-bottom: 1px solid gray;
+  counter-increment: h1;
+}
+.pcd_md_editor h1 + h2,
+.pcd_article h1 + h2 {
+  counter-set: h2 1;
+}
+
+.pcd_md_editor h2,
+.pcd_article h2 {
+  margin-top: 1em;
+  font-size: 24px !important;
+  border-bottom: 1px solid gray;
+  counter-reset: h3;
+  counter-increment: h2;
+}
+.pcd_md_editor h2 + h3,
+.pcd_article h2 + h3 {
+  counter-set: h3 1;
+}
+.pcd_md_editor h3,
+.pcd_article h3 {
+  margin-top: 0.5em;
+  font-size: 22px !important;
+  counter-increment: h3;
+  counter-reset: h4;
+}
+.pcd_md_editor h3 + h4,
+.pcd_article h3 + h4 {
+  counter-set: h4 1;
+}
+.pcd_md_editor h4,
+.pcd_article h4 {
+  font-size: 20px !important;
+  counter-increment: h4;
+  counter-reset: h5;
+}
+.pcd_article h5 {
+  font-size: 18px !important;
+}
+.pcd_article h6 {
+  font-size: 16px !important;
+  font-weight: 700;
+}
+.pcd_article blockquote {
+  margin-left: 1em;
+  border-left: 4px solid #dad9d9;
+  padding-left: 0.5em;
+  color: gray;
+}
+
+.pcd_article img {
+  max-width: 100%;
+}
+
+.pcd_article table {
+  border-spacing: 0;
+  border-collapse: collapse;
+  display: block;
+  /*width: -webkit-max-content;*/
+  /*width: max-content;*/
+  max-width: 100%;
+}
+.pcd_article td,
+.pcd_article th {
+  padding: 0;
+}
+
+.pcd_article table th {
+  font-weight: 600;
+}
+.pcd_article table th,
+.pcd_article table td {
+  padding: 6px 13px;
+  border: 1px solid #d0d7de;
+}
+.pcd_article table tr {
+  background-color: #ffffff;
+  border-top: 1px solid hsl(210, 18%, 87%);
+}
+.pcd_article table tr:nth-child(2n) {
+  background-color: #f6f8fa;
+}
+.pcd_article table img {
+  background-color: transparent;
+}
+
+.pcd_article video {
+  max-width: 90%;
+}
+
+.video-js video {
+  max-width: 100%;
+}

+ 2 - 1
dashboard-v6/src/components/channel/ChannelList.tsx

@@ -3,10 +3,11 @@ import { useState, useEffect } from "react";
 import { Card, List, message, Space, Tag } from "antd";
 
 import type { IChannelApiData } from "../../api/Channel";
-import type { IApiResponseChannelList } from "../../api/Corpus";
+
 import { get } from "../../request";
 import ChannelListItem from "./ChannelListItem";
 import type { IStudio } from "../../api/Auth";
+import type { IApiResponseChannelList } from "../../api/progress";
 
 export interface ChannelFilterProps {
   chapterProgress: number;

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

@@ -38,7 +38,7 @@ import CopyToModal from "./CopyToModal";
 
 import { ChannelInfoModal } from "./ChannelInfo";
 
-import type { ArticleType } from "../../api/Corpus";
+import type { ArticleType } from "../../api/Article";
 import { getSentIdInArticle } from "./utils";
 import TokenModal from "../token/TokenModal";
 import NissayaAlignerModal from "../nissaya/NissayaAlignerModal";

+ 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/Corpus";
+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 { IStudio } from "../../../src/api/Auth";
-import type { ArticleType } from "../../../src/api/Corpus";
+import type { ArticleType } from "../../api/Article";
 import Studio from "../../../src/components/auth/Studio";
 
 const { Link, Text } = Typography;

+ 5 - 5
dashboard-v6/src/components/channel/ChannelSentDiff.tsx

@@ -2,7 +2,7 @@ import { Button, message, Select, Table, Tooltip, Typography } from "antd";
 import { type Change, diffChars } from "diff";
 import { useEffect, useState } from "react";
 
-import { post } from "../../../src/request";
+import { post } from "../../request";
 import type {
   ISentence,
   ISentenceDiffData,
@@ -10,11 +10,11 @@ import type {
   ISentenceDiffResponse,
   ISentenceListResponse,
   ISentenceNewRequest,
-} from "../../../src/api/Corpus";
+} from "../../api/sentence";
 
-import store from "../../../src/store";
-import { accept } from "../../../src/reducers/accept-pr";
-import type { IChannel } from "../../../src/api/Channel";
+import store from "../../store";
+import { accept } from "../../reducers/accept-pr";
+import type { IChannel } from "../../api/Channel";
 import { toISentence } from "../sentence/utils";
 
 const { Text } = Typography;

+ 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/Corpus";
+import type { ArticleType } from "../../api/Article";
 
 interface IWidget {
   trigger?: React.ReactNode;

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

@@ -7,8 +7,8 @@ import { DeleteOutlined } from "@ant-design/icons";
 
 import { get } from "../../request";
 
-import type { IChapterListResponse } from "../../api/Corpus";
 import type { IArticleParam } from "../../types/article";
+import type { IChapterListResponse } from "../../api/progress";
 
 const { Text } = Typography;
 

+ 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/Corpus";
+import type { ArticleType } from "../../api/Article";
 
 interface IWidget {
   initStep?: number;

+ 155 - 0
dashboard-v6/src/components/dict/SearchVocabulary.tsx

@@ -0,0 +1,155 @@
+import { get } from "../../request";
+import type { IVocabularyListResponse } from "../../api/Dict";
+import { useRef, useState } from "react";
+import { AutoComplete, Input, Space, Typography } from "antd";
+import { DictIcon } from "../../assets/icon";
+
+const { Text, Link } = Typography;
+
+interface ValueType {
+  key?: string;
+  label: React.ReactNode;
+  value: string | number;
+}
+interface IWidget {
+  value?: string;
+  api?: string;
+  compact?: boolean;
+  onSearch?: (value: string, split?: boolean) => void;
+  onSplit?: (value: string | null) => void;
+}
+
+const SearchVocabularyWidget = ({
+  value,
+  api = "vocabulary",
+  compact = false,
+  onSplit,
+  onSearch,
+}: IWidget) => {
+  const [options, setOptions] = useState<ValueType[]>([]);
+  const [fetching, setFetching] = useState(false);
+  const [input, setInput] = useState<string | undefined>(value);
+  const [factors, setFactors] = useState<string[]>([]);
+  const intervalRef = useRef<number | null>(null);
+
+  const renderItem = (title: string, count: number, meaning?: string) => ({
+    value: title,
+    label: (
+      <div>
+        <div style={{ display: "flex", justifyContent: "space-between" }}>
+          {title}
+          <span>
+            <DictIcon /> {count}
+          </span>
+        </div>
+        <div>
+          <Text type="secondary">{meaning}</Text>
+        </div>
+      </div>
+    ),
+  });
+
+  const stopLookup = () => {
+    if (intervalRef.current) {
+      window.clearInterval(intervalRef.current);
+      intervalRef.current = null;
+    }
+  };
+
+  const factorChange = (word?: string) => {
+    if (typeof word === "undefined" || word.includes(":")) {
+      setFactors([]);
+      return;
+    }
+    const strFactors = word.replaceAll("+", "-");
+    if (strFactors.indexOf("-") >= 0) {
+      setFactors(strFactors.split("-"));
+      onSplit?.(strFactors.replaceAll("-", "+"));
+    } else {
+      setFactors([]);
+      onSplit?.(null);
+    }
+  };
+
+  const search = (value: string) => {
+    stopLookup();
+    if (value === "") return;
+
+    get<IVocabularyListResponse>(`/v2/${api}?view=key&key=${value}`)
+      .then((json) => {
+        const words: ValueType[] = json.data.rows
+          .map((item) => {
+            let weight = item.count / (item.strlen - value.length + 0.1);
+            if (item.word.length === value.length) {
+              weight = 100;
+            }
+            return {
+              word: item.word,
+              count: item.count,
+              meaning: item.meaning,
+              weight,
+            };
+          })
+          .sort((a, b) => b.weight - a.weight)
+          .slice(0, 7)
+          .map((item) => renderItem(item.word, item.count, item.meaning));
+
+        setOptions(words);
+      })
+      .finally(() => {
+        setFetching(false);
+      });
+  };
+
+  return (
+    <div style={{ width: "100%" }}>
+      {fetching ? <></> : null}
+      <AutoComplete
+        getPopupContainer={() =>
+          document.getElementsByClassName("dict_search_div")[0] as HTMLElement
+        }
+        value={input}
+        style={{ width: "100%" }}
+        classNames={{ popup: { root: "certain-category-search-dropdown" } }}
+        popupMatchSelectWidth={400}
+        options={options}
+        onChange={(val: string) => {
+          setInput(val);
+          factorChange(val);
+        }}
+        showSearch={{
+          onSearch: (val: string) => {
+            setFetching(true);
+            search(val);
+          },
+        }}
+        onSelect={(val: string) => {
+          onSearch?.(val);
+        }}
+      >
+        <Input.Search
+          style={{ width: "100%" }}
+          size={compact ? undefined : "large"}
+          placeholder="search here"
+          onSearch={(val: string) => {
+            onSearch?.(val);
+          }}
+        />
+      </AutoComplete>
+      <Space style={{ display: "none" }}>
+        {factors.map((item, id) => (
+          <Link
+            key={id}
+            onClick={() => {
+              onSearch?.(item, true);
+            }}
+          >
+            {item}
+          </Link>
+        ))}
+      </Space>
+    </div>
+  );
+};
+
+export default SearchVocabularyWidget;

+ 39 - 0
dashboard-v6/src/components/discussion/DiscussionMock.tsx

@@ -0,0 +1,39 @@
+import type { IComment, TDiscussionType, TResType } from "../../api/discussion";
+
+export interface IAnswerCount {
+  id: string;
+  count: number;
+}
+
+interface IWidget {
+  resId?: string;
+  resType?: TResType;
+  showTopicId?: string;
+  focus?: string;
+  type?: TDiscussionType;
+  showStudent?: boolean;
+  onTopicReady?: (value: IComment) => void;
+}
+
+const DiscussionWidget = ({
+  resId,
+  resType,
+  showTopicId,
+  showStudent = false,
+  focus,
+  type = "discussion",
+}: IWidget) => {
+  console.debug(
+    "discussion mock",
+    resId,
+    resType,
+    showTopicId,
+    showStudent,
+    focus,
+    type
+  );
+
+  return <>discussion mock</>;
+};
+
+export default DiscussionWidget;

+ 5 - 4
dashboard-v6/backup/components/admin/relation/DataImport.tsx → dashboard-v6/src/components/general/DataImport.tsx

@@ -1,11 +1,12 @@
 import { ModalForm, ProFormUploadDragger } from "@ant-design/pro-components";
 import { Form, message } from "antd";
 
-import { API_HOST, get } from "../../../request";
+import { get } from "../../request";
 import type { UploadFile } from "antd/es/upload/interface";
-import type { IAttachmentResponse } from "../../../api/Attachments";
+import type { IAttachmentResponse } from "../../api/Attachments";
 import modal from "antd/lib/modal";
 import { useIntl } from "react-intl";
+import type { JSX } from "react";
 
 interface INissayaEndingUpload {
   filename: UploadFile<IAttachmentResponse>[];
@@ -24,7 +25,7 @@ interface IWidget {
   url: string;
   urlExtra?: string;
   trigger?: JSX.Element;
-  onSuccess?: Function;
+  onSuccess?: () => void;
 }
 const DataImportWidget = ({
   title,
@@ -91,7 +92,7 @@ const DataImportWidget = ({
         fieldProps={{
           name: "file",
         }}
-        action={`${API_HOST}/api/v2/attachments?is_tmp=true`}
+        action={`${import.meta.env.BASE_URL}/api/v2/attachments?is_tmp=true`}
       />
     </ModalForm>
   );

+ 193 - 102
dashboard-v6/src/components/navigation/MainMenu.tsx

@@ -5,8 +5,7 @@ import {
   FieldTimeOutlined,
   FolderOutlined,
 } from "@ant-design/icons";
-
-import { useLocation, useNavigate } from "react-router";
+import { useNavigate, useMatches, type UIMatch } from "react-router";
 import {
   ChannelIcon,
   CourseOutLinedIcon,
@@ -15,111 +14,202 @@ import {
   TaskIcon,
   TipitakaIcon,
 } from "../../assets/icon";
+import React from "react";
+
+/* ================= 类型 ================= */
+
+interface MenuItem {
+  key: string;
+  label?: React.ReactNode;
+  icon?: React.ReactNode;
+  type?: "divider";
+  children?: MenuItem[];
+
+  /** ⭐ 用于高亮匹配 */
+  activeId?: string | string[];
+}
 
 interface Props {
   onSearch?: () => void;
 }
+
+export interface RouteHandle {
+  id?: string;
+  crumb?: string | ((match: UIMatch) => string);
+}
+/* ================= 当前路由ID ================= */
+
+function useCurrentRouteId(): string | undefined {
+  const matches = useMatches() as UIMatch<number, RouteHandle>[];
+  return [...matches].reverse().find((match) => match.handle?.id)?.handle?.id;
+}
+
+/* ================= 匹配算法 ================= */
+
+function matchActive(routeId: string | undefined, active?: string | string[]) {
+  if (!routeId || !active) return false;
+
+  const list = Array.isArray(active) ? active : [active];
+
+  return list.some((id) => routeId === id || routeId.startsWith(id + "."));
+}
+
+/* ================= 找当前选中key ================= */
+
+function findSelectedKey(
+  items: MenuItem[],
+  routeId?: string
+): string | undefined {
+  for (const item of items) {
+    if (matchActive(routeId, item.activeId)) return item.key;
+
+    if (item.children) {
+      const k = findSelectedKey(item.children, routeId);
+      if (k) return k;
+    }
+  }
+}
+
+/* ================= 找展开父级keys ================= */
+
+function findOpenKeys(
+  items: MenuItem[],
+  routeId?: string,
+  parents: string[] = []
+): string[] {
+  for (const item of items) {
+    if (matchActive(routeId, item.activeId)) {
+      return parents;
+    }
+
+    if (item.children) {
+      const found = findOpenKeys(item.children, routeId, [
+        ...parents,
+        item.key,
+      ]);
+      if (found.length) return found;
+    }
+  }
+  return [];
+}
+
+/* ================= 菜单配置 ================= */
+
+const items: MenuItem[] = [
+  {
+    key: "search",
+    icon: <SearchOutlined />,
+    label: "搜索",
+  },
+  {
+    key: "/workspace",
+    icon: <HomeOutlined />,
+    label: "主页",
+    activeId: "workspace.home",
+  },
+  {
+    key: "/workspace/ai",
+    icon: <RobotIcon />,
+    label: "AI",
+    activeId: "workspace.ai",
+  },
+  {
+    key: "/workspace/tipitaka",
+    icon: <TipitakaIcon />,
+    label: "巴利三藏",
+    activeId: "workspace.tipitaka",
+  },
+
+  { type: "divider", key: "d1" },
+
+  {
+    key: "/workspace/recent",
+    icon: <FieldTimeOutlined />,
+    label: "最近打开",
+  },
+
+  {
+    key: "/workspace/articles",
+    icon: <DocumentIcon />,
+    label: "文章",
+    children: [
+      {
+        key: "/workspace/articles/uncategorized",
+        label: "未分类",
+        icon: <FolderOutlined />,
+      },
+      {
+        key: "/workspace/articles/angl",
+        label: "文集1",
+        icon: <FolderOutlined />,
+      },
+      {
+        key: "/workspace/articles",
+        label: "ALL",
+      },
+    ],
+  },
+
+  {
+    key: "/workspace/channel",
+    icon: <ChannelIcon />,
+    label: "频道",
+    activeId: "workspace.channel",
+  },
+
+  {
+    key: "/workspace/term",
+    icon: <ChannelIcon />,
+    label: "Term",
+    activeId: "workspace.term",
+  },
+
+  {
+    key: "/workspace/course",
+    icon: <CourseOutLinedIcon />,
+    label: "Course",
+  },
+
+  {
+    key: "/workspace/task",
+    icon: <TaskIcon />,
+    label: "Task",
+    activeId: "workspace.task",
+    children: [
+      {
+        key: "/workspace/task/pending",
+        label: "Pending",
+        activeId: "workspace.task.pending",
+      },
+      {
+        key: "/workspace/task/to-do-list",
+        label: "To-Do List",
+        activeId: "workspace.task.todo",
+      },
+      {
+        key: "/workspace/task/hell",
+        label: "Task Hell",
+        activeId: "workspace.task.hell",
+      },
+    ],
+  },
+];
+
+/* ================= 组件 ================= */
+
 const Widget = ({ onSearch }: Props) => {
-  const location = useLocation();
   const navigate = useNavigate();
+  const routeId = useCurrentRouteId();
+
+  console.log("nav", routeId);
+  /** 当前选中 */
+  const selectedKey = findSelectedKey(items, routeId);
+
+  /** 自动展开父级 */
+  const openKeys = findOpenKeys(items, routeId);
 
-  const items: MenuProps["items"] = [
-    {
-      key: "search",
-      icon: <SearchOutlined />,
-      label: "搜索",
-    },
-    {
-      key: "/workspace",
-      icon: <HomeOutlined />,
-      label: "主页",
-    },
-    {
-      key: "/workspace/ai",
-      icon: <RobotIcon />,
-      label: "AI",
-    },
-    {
-      key: "/workspace/tipitaka",
-      icon: <TipitakaIcon />,
-      label: "巴利三藏",
-    },
-    {
-      key: "divider",
-      type: "divider",
-    },
-    {
-      key: "/workspace/recent",
-      icon: <FieldTimeOutlined />,
-      label: "最近打开",
-      children: [],
-    },
-    {
-      key: "/workspace/articles",
-      icon: <DocumentIcon />,
-      label: "文章",
-      children: [
-        {
-          key: "/workspace/articles/uncategorized",
-          label: "未分类",
-          icon: <FolderOutlined />,
-        },
-        {
-          key: "/workspace/articles/angl",
-          label: "文集1",
-          icon: <FolderOutlined />,
-        },
-        {
-          key: "/workspace/articles",
-          label: "ALL",
-        },
-      ],
-    },
-    {
-      key: "/workspace/channel",
-      icon: <ChannelIcon />,
-      label: "频道",
-    },
-    {
-      key: "/workspace/course",
-      icon: <CourseOutLinedIcon />,
-      label: "course",
-    },
-    {
-      key: "/workspace/task",
-      icon: <TaskIcon />,
-      label: "task",
-      children: [
-        {
-          key: "/workspace/task/pending",
-          label: "pending",
-        },
-        {
-          key: "/workspace/task/to-do-list",
-          label: "To-do List",
-        },
-        {
-          key: "/workspace/task/hell",
-          label: "task hell",
-        },
-      ],
-    },
-  ];
-
-  /** 当前高亮规则 */
-  const selectedKey: string =
-    location.pathname === "/"
-      ? "/"
-      : (items?.find(
-          (i) =>
-            i &&
-            "key" in i &&
-            typeof i.key === "string" &&
-            location.pathname.startsWith(i.key)
-        )?.key as string) || "";
-
-  /** 点击菜单 */
-  const handleClick = ({ key }: { key: string }) => {
+  /** 点击 */
+  const handleClick: MenuProps["onClick"] = ({ key }) => {
     if (key === "search") {
       onSearch?.();
       return;
@@ -130,8 +220,9 @@ const Widget = ({ onSearch }: Props) => {
   return (
     <Menu
       mode="inline"
-      selectedKeys={[selectedKey]}
-      items={items}
+      selectedKeys={selectedKey ? [selectedKey] : []}
+      defaultOpenKeys={openKeys}
+      items={items as MenuProps["items"]}
       onClick={handleClick}
       style={{ borderRight: 0 }}
     />

+ 1 - 1
dashboard-v6/src/components/nissaya/NissayaAligner.tsx

@@ -20,7 +20,7 @@ import { post } from "../../request";
 import type {
   ISentenceDiffRequest,
   ISentenceDiffResponse,
-} from "../../api/Corpus";
+} from "../../api/sentence";
 
 const { Dragger } = Upload;
 const { TextArea } = Input;

+ 2 - 1
dashboard-v6/src/components/nissaya/NissayaCard.tsx

@@ -8,9 +8,10 @@ import { get } from "../../request";
 import { get as getLang } from "../../locales";
 
 import NissayaCardTable, { type INissayaRelation } from "./NissayaCardTable";
-import { TermModalMock as TermModal } from "../term/TermModal";
+
 import type { ITerm } from "../../api/Term";
 import MdView from "../general/MdView";
+import TermModal from "../term/TermModal";
 
 const { Paragraph, Title } = Typography;
 

+ 1 - 1
dashboard-v6/src/components/related-para/RelatedPara.tsx

@@ -6,7 +6,7 @@ import { useEffect, useState, type JSX } from "react";
 
 import store from "../../store";
 import { change } from "../../reducers/para-change";
-import type { ITocPathNode } from "../../api/Corpus";
+import type { ITocPathNode } from "../../api/pali-text";
 import TocPath from "../tipitaka/TocPath";
 
 interface ITag {

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

@@ -13,7 +13,7 @@ import type {
   ISentHistoryData,
   ISentHistoryListResponse,
 } from "../../api/sentence-history";
-import type { ISentence } from "../../api/Corpus";
+import type { ISentence } from "../../api/sentence";
 
 const { Text } = Typography;
 

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

@@ -1,7 +1,7 @@
 import { Divider, Space } from "antd";
 import SuggestionButton from "./SuggestionButton";
 import DiscussionButton from "../discussion/DiscussionButton";
-import type { ISentence } from "../../api/Corpus";
+import type { ISentence } from "../../api/sentence";
 import {
   type MouseEventHandler,
   useCallback,

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

@@ -8,7 +8,7 @@ import type {
   ISentence,
   ISentenceRequest,
   ISentenceResponse,
-} from "../../api/Corpus";
+} from "../../api/sentence";
 import store from "../../store";
 import { accept } from "../../reducers/accept-pr";
 import { toISentence } from "../sentence/utils";

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

@@ -4,7 +4,7 @@ import { ReloadOutlined } from "@ant-design/icons";
 
 import { get } from "../../request";
 import type { IChannel, TChannelType } from "../../api/Channel";
-import type { ISentence, ISentenceListResponse } from "../../api/Corpus";
+import type { ISentence, ISentenceListResponse } from "../../api/sentence";
 
 import SentCell from "./SentCell";
 import SentAdd from "./SentAdd";

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

@@ -3,7 +3,7 @@ import { useIntl } from "react-intl";
 import { message as AntdMessage, Modal, Collapse } from "antd";
 import { ExclamationCircleOutlined, LoadingOutlined } from "@ant-design/icons";
 
-import type { ISentence } from "../../api/Corpus";
+import type { ISentence } from "../../api/sentence";
 import SentEditMenu from "./SentEditMenu";
 import SentCellEditable from "./SentCellEditable";
 
@@ -27,7 +27,7 @@ import CopyToModal from "../channel/CopyToModal";
 import store from "../../store";
 import { randomString } from "../../utils";
 import User from "../auth/User";
-import type { ISentenceListResponse } from "../../api/Corpus";
+import type { ISentenceListResponse } from "../../api/sentence";
 
 import SentAttachment from "./SentAttachment";
 
@@ -75,7 +75,10 @@ const SentCellWidget = ({
   console.debug("SentCell render", value);
   const intl = useIntl();
   const [isEditMode, setIsEditMode] = useState(editMode);
-  const [sentData, setSentData] = useState<ISentence | undefined>(initValue);
+  // 用一个独立的 state 存储来自 acceptPr 的覆盖值
+  const [overrideSentData, setOverrideSentData] = useState<
+    ISentence | undefined
+  >(undefined);
   const [loading, setLoading] = useState(false);
   const [uuid] = useState(randomString());
   const endings = useAppSelector(getEnding);
@@ -87,6 +90,11 @@ const SentCellWidget = ({
   const anchorInfo = useAppSelector(anchor);
   const [copyOpen, setCopyOpen] = useState<boolean>(false);
 
+  // sentData 由 useMemo 派生:优先级 overrideSentData > value > initValue
+  const sentData = useMemo(() => {
+    return overrideSentData ?? value ?? initValue;
+  }, [overrideSentData, value, initValue]);
+
   const sentId = `${sentData?.book}-${sentData?.para}-${sentData?.wordStart}-${sentData?.wordEnd}`;
   const sid = `${sentData?.book}_${sentData?.para}_${sentData?.wordStart}_${sentData?.wordEnd}_${sentData?.channel?.id}`;
 
@@ -115,30 +123,18 @@ const SentCellWidget = ({
     }
   }, [anchorInfo, initValue?.id, sid]);
 
-  useEffect(() => {
-    if (value) {
-      setSentData(value);
-    }
-  }, [value]);
+  // 保留一个 setter 供内部使用(如 refresh、paste、format convert 等)
+  // 这些场景改为直接更新 overrideSentData
+  const setSentData = (data: ISentence) => {
+    setOverrideSentData(data);
+  };
 
   useEffect(() => {
     console.debug("sent cell acceptPr", acceptPr, uuid);
-    if (isPr) {
-      console.debug("sent cell is pr");
-      return;
-    }
-    if (typeof acceptPr === "undefined" || acceptPr.length === 0) {
-      console.debug("sent cell acceptPr is empty");
-      return;
-    }
-    if (!sentData) {
-      console.debug("sent cell sentData is empty");
-      return;
-    }
-    if (changedSent?.includes(uuid)) {
-      console.debug("sent cell already apply", uuid);
-      return;
-    }
+    if (isPr) return;
+    if (typeof acceptPr === "undefined" || acceptPr.length === 0) return;
+    if (!sentData) return;
+    if (changedSent?.includes(uuid)) return;
 
     const found = acceptPr
       .filter((value) => typeof value !== "undefined")
@@ -146,10 +142,14 @@ const SentCellWidget = ({
         const vId = `${value.book}_${value.para}_${value.wordStart}_${value.wordEnd}_${value.channel.id}`;
         return vId === sid;
       });
+
     if (typeof found !== "undefined") {
-      console.debug("sent cell sentence apply", uuid, found, found);
-      setSentData(found);
-      store.dispatch(done(uuid));
+      console.debug("sent cell sentence apply", uuid, found);
+      // 用 setTimeout 将 setState 移出 effect 同步体,避免级联渲染
+      setTimeout(() => {
+        setOverrideSentData(found);
+        store.dispatch(done(uuid));
+      }, 0);
     }
   }, [acceptPr, sentData, isPr, uuid, changedSent, sid]);
 

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

@@ -4,18 +4,18 @@ import { Button, message, Typography } from "antd";
 import { SaveOutlined } from "@ant-design/icons";
 
 import { post, put } from "../../request";
-import type {
-  ISentence,
-  ISentencePrRequest,
-  ISentencePrResponse,
-} from "../../api/Corpus";
+import type { ISentence } from "../../api/sentence";
 
 import TermTextArea from "../general/TermTextArea";
 import { useAppSelector } from "../../hooks";
 import { wordList } from "../../reducers/sent-word";
 
 import { sentSave } from "../../api/sentence";
-import Builder from "../tpl-builder/Builder";
+import TplBuilder from "../tpl-builder/TplBuilder";
+import type {
+  ISentencePrRequest,
+  ISentencePrResponse,
+} from "../../api/sentence-pr";
 
 const { Text } = Typography;
 
@@ -179,7 +179,7 @@ const SentCellEditable = ({
             </Button>
           </span>
           <Text keyboard style={{ cursor: "pointer" }}>
-            <Builder trigger={"<t>"} />
+            <TplBuilder trigger={"<t>"} />
           </Text>
         </div>
         <div>

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

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

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

@@ -10,13 +10,15 @@ import "./style.css";
 import { settingInfo } from "../../reducers/setting";
 
 import { useSetting } from "../../hooks/useSetting";
-import type { ArticleMode, ISentence, ITocPathNode } from "../../api/Corpus";
+import type { ArticleMode } from "../../api/Article";
 import { GetUserSetting } from "../setting/default";
 import SentContent from "./SentContent";
 import type { IWbw } from "../../types/wbw";
 import SentTab from "./SentTab";
 import { SENTENCE_FIX_WIDTH } from "../../types/article";
 import SentCell from "./SentCell";
+import type { ISentence } from "../../api/sentence";
+import type { ITocPathNode } from "../../api/pali-text";
 
 export interface IResNumber {
   translation?: number;

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

@@ -0,0 +1,358 @@
+/**
+ * SentEditInnerDemo.tsx
+ * ──────────────────────────────────────────────────────────────────
+ * 用于在页面上直观测试 <SentEditInner> 组件的演示页面。
+ * 所有数据均为 mock,不依赖真实 API。
+ *
+ * 使用方式(开发环境临时路由):
+ *   <Route path="/dev/sent-edit-demo" element={<SentEditInnerDemo />} />
+ * ──────────────────────────────────────────────────────────────────
+ */
+
+import React, { useState } from "react";
+import { ConfigProvider, Space, Switch, Tag, Typography, Divider } from "antd";
+
+import type { ISentence } from "../../api/sentence";
+import type { ITocPathNode } from "../../api/pali-text";
+import type { IUser } from "../../api/Auth";
+import { SentEditInner, type IWidgetSentEditInner } from "./SentEdit";
+import type { IWbw } from "../../types/wbw";
+
+const { Title, Text } = Typography;
+
+// ─── Mock 数据 ────────────────────────────────────────────────────
+
+const mockEditor: IUser = {
+  id: "user-001",
+  avatar: "",
+  nickName: "测试用户",
+  userName: "测试用户",
+};
+
+const mockTranslationChannel = {
+  id: "ch-translation-01",
+  name: "汉译频道",
+  type: "translation" as const,
+  studio: { id: "studio-01", name: "测试工作室" },
+};
+
+const mockCommentaryChannel = {
+  id: "ch-commentary-01",
+  name: "注释频道",
+  type: "commentary" as const,
+  studio: { id: "studio-01", name: "测试工作室" },
+};
+
+/** 原文(json 格式的 wbw 数据 + html) */
+
+const mockOrigin: ISentence[] = [
+  {
+    id: "origin-001",
+    book: 1,
+    para: 1,
+    wordStart: 1,
+    wordEnd: 5,
+    content: JSON.stringify([
+      {
+        uid: "w1",
+        book: 1,
+        para: 1,
+        sn: [1],
+        word: { value: "Evaṃ", status: 7 },
+        real: { value: "evaṃ", status: 7 },
+        meaning: { value: "如是", status: 7 },
+        type: { value: "ind", status: 7 },
+        grammar: { value: "不变词", status: 7 },
+        confidence: 1,
+      },
+      {
+        uid: "w2",
+        book: 1,
+        para: 1,
+        sn: [2],
+        word: { value: "me", status: 7 },
+        real: { value: "ahaṃ", status: 7 },
+        meaning: { value: "我", status: 7 },
+        type: { value: "pron", status: 7 },
+        grammar: { value: "代词 主格 单数", status: 7 },
+        confidence: 1,
+      },
+      {
+        uid: "w3",
+        book: 1,
+        para: 1,
+        sn: [3],
+        word: { value: "sutaṃ", status: 7 },
+        real: { value: "suta", status: 7 },
+        meaning: { value: "所闻", status: 7 },
+        type: { value: "pp", status: 7 },
+        grammar: { value: "过去分词 主格 单数 中性", status: 7 },
+        confidence: 1,
+      },
+      {
+        uid: "w4",
+        book: 1,
+        para: 1,
+        sn: [4],
+        word: { value: "—", status: 0 },
+        real: { value: null, status: 0 },
+        meaning: { value: "——", status: 0 },
+        confidence: 0,
+      },
+      {
+        uid: "w5",
+        book: 1,
+        para: 1,
+        sn: [5],
+        word: { value: "ekaṃ", status: 7 },
+        real: { value: "eka", status: 7 },
+        meaning: { value: "一", status: 7 },
+        type: { value: "num", status: 7 },
+        grammar: { value: "数词 业格 单数 中性", status: 7 },
+        confidence: 1,
+      },
+    ] satisfies IWbw[]),
+    contentType: "json",
+    html: "<span>Evaṃ me sutaṃ — ekaṃ</span>",
+    editor: mockEditor,
+    channel: { id: "origin", name: "原文", type: "translation" as const },
+    updateAt: "2024-01-01T00:00:00Z",
+  },
+  {
+    id: "origin-002",
+    book: 1,
+    para: 1,
+    wordStart: 1,
+    wordEnd: 5,
+    content: "Evaṃ me sutaṃ — ekaṃ",
+    contentType: "markdown",
+    html: "<span>Evaṃ me sutaṃ — ekaṃ</span>",
+    editor: mockEditor,
+    channel: {
+      id: "origin-text",
+      name: "原文(文本)",
+      type: "translation" as const,
+    },
+    updateAt: "2024-01-01T00:00:00Z",
+  },
+];
+
+/** 翻译列表(translation + nissaya + commentary) */
+const mockTranslation: ISentence[] = [
+  {
+    id: "trans-001",
+    book: 1,
+    para: 1,
+    wordStart: 1,
+    wordEnd: 5,
+    content: "如是我闻——一时,",
+    contentType: "markdown",
+    html: "<p>如是我闻——一时,</p>",
+    editor: mockEditor,
+    channel: mockTranslationChannel,
+    updateAt: "2024-03-01T08:00:00Z",
+    suggestionCount: { suggestion: 3, discussion: 1 },
+  },
+  {
+    id: "nissaya-001",
+    book: 1,
+    para: 1,
+    wordStart: 1,
+    wordEnd: 5,
+    content: "(evaṃ)如是(me)我(sutaṃ)所闻",
+    contentType: "markdown",
+    html: "<p>(evaṃ)如是(me)我(sutaṃ)所闻</p>",
+    editor: mockEditor,
+    channel: mockTranslationChannel,
+    updateAt: "2024-03-02T09:00:00Z",
+  },
+];
+
+/** 注释列表(单独卡片展示) */
+const mockCommentaries: ISentence[] = [
+  {
+    id: "comm-001",
+    book: 1,
+    para: 1,
+    wordStart: 1,
+    wordEnd: 5,
+    content:
+      "「如是我闻」是结集者阿难在结集时所加的导语,表明以下内容是亲耳所闻。",
+    contentType: "markdown",
+    html: "<p>「如是我闻」是结集者阿难在结集时所加的导语,表明以下内容是亲耳所闻。</p>",
+    editor: mockEditor,
+    channel: mockCommentaryChannel,
+    updateAt: "2024-03-03T10:00:00Z",
+    openInEditMode: false,
+  },
+];
+
+/** 目录路径 */
+const mockPath: ITocPathNode[] = [
+  { title: "长部", paliTitle: "Dīgha Nikāya", level: 0, book: 1 },
+  {
+    title: "梵网经",
+    paliTitle: "Brahmajāla Sutta",
+    level: 1,
+    book: 1,
+    paragraph: 1,
+  },
+];
+
+// ─── 基础 props ────────────────────────────────────────────────────
+
+const baseProps: IWidgetSentEditInner = {
+  id: "sent-demo-1_1_1_5",
+  book: 1,
+  para: 1,
+  wordStart: 1,
+  wordEnd: 5,
+  origin: mockOrigin,
+  translation: mockTranslation,
+  commentaries: mockCommentaries,
+  path: mockPath,
+  tranNum: 2,
+  nissayaNum: 1,
+  commNum: 1,
+  originNum: 1,
+  simNum: 0,
+  compact: false,
+  showWbwProgress: false,
+  readonly: false,
+};
+
+// ─── Demo 页面 ────────────────────────────────────────────────────
+
+const SentEditInnerDemo: React.FC = () => {
+  const [layout, setLayout] = useState<"column" | "row">("column");
+  const [compact, setCompact] = useState(false);
+  const [readonly, setReadonly] = useState(false);
+  const [showWbwProgress, setShowWbwProgress] = useState(false);
+
+  return (
+    <ConfigProvider>
+      <div style={{ maxWidth: 1200, margin: "0 auto", padding: "24px 16px" }}>
+        {/* 页头 */}
+        <div style={{ marginBottom: 24 }}>
+          <Title level={3} style={{ margin: 0 }}>
+            🧪 SentEditInner — 组件演示
+          </Title>
+          <Text type="secondary">
+            book=1 · para=1 · wordStart=1 · wordEnd=5 · 所有数据为 mock
+          </Text>
+        </div>
+
+        {/* 控制面板 */}
+        <div
+          style={{
+            display: "flex",
+            flexWrap: "wrap",
+            gap: 24,
+            padding: "16px 20px",
+            background: "#fafafa",
+            border: "1px solid #e8e8e8",
+            borderRadius: 8,
+            marginBottom: 32,
+          }}
+        >
+          <Space>
+            <Text>layout</Text>
+            <Switch
+              checkedChildren="row"
+              unCheckedChildren="column"
+              checked={layout === "row"}
+              onChange={(v) => setLayout(v ? "row" : "column")}
+            />
+            <Tag color={layout === "row" ? "blue" : "green"}>{layout}</Tag>
+          </Space>
+          <Space>
+            <Text>compact</Text>
+            <Switch checked={compact} onChange={setCompact} />
+          </Space>
+          <Space>
+            <Text>readonly</Text>
+            <Switch checked={readonly} onChange={setReadonly} />
+          </Space>
+          <Space>
+            <Text>showWbwProgress</Text>
+            <Switch checked={showWbwProgress} onChange={setShowWbwProgress} />
+          </Space>
+        </div>
+
+        <Divider>渲染结果</Divider>
+
+        {/* 目标组件 */}
+        <div
+          style={{
+            border: "2px dashed #d9d9d9",
+            borderRadius: 8,
+            padding: 16,
+            background: "#fff",
+          }}
+        >
+          <SentEditInner
+            {...baseProps}
+            layout={layout}
+            compact={compact}
+            readonly={readonly}
+            showWbwProgress={showWbwProgress}
+            onTranslationChange={(data) => {
+              console.log("[Demo] onTranslationChange →", data);
+            }}
+          />
+        </div>
+
+        {/* 第二个:无注释、无翻译的极简情形 */}
+        <Divider style={{ marginTop: 40 }}>极简情形(无翻译 / 无注释)</Divider>
+        <div
+          style={{
+            border: "2px dashed #ffe58f",
+            borderRadius: 8,
+            padding: 16,
+            background: "#fffbe6",
+          }}
+        >
+          <SentEditInner
+            id="sent-demo-minimal"
+            book={1}
+            para={2}
+            wordStart={1}
+            wordEnd={3}
+            origin={[mockOrigin[1]]}
+            originNum={1}
+            layout="column"
+            compact
+            readonly
+          />
+        </div>
+
+        {/* Mock 数据预览 */}
+        <Divider style={{ marginTop: 40 }}>Mock 数据预览</Divider>
+        <pre
+          style={{
+            background: "#282c34",
+            color: "#abb2bf",
+            padding: 20,
+            borderRadius: 8,
+            fontSize: 12,
+            overflow: "auto",
+            maxHeight: 400,
+          }}
+        >
+          {JSON.stringify(
+            {
+              origin: mockOrigin,
+              translation: mockTranslation,
+              commentaries: mockCommentaries,
+              path: mockPath,
+            },
+            null,
+            2
+          )}
+        </pre>
+      </div>
+    </ConfigProvider>
+  );
+};
+
+export default SentEditInnerDemo;

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

@@ -11,7 +11,8 @@ import {
   ReloadOutlined,
 } from "@ant-design/icons";
 import type { MenuProps } from "antd";
-import type { ArticleMode, ISentence, TContentType } from "../../api/Corpus";
+import type { ISentence } from "../../api/sentence";
+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/Corpus";
+import type { ArticleMode } from "../../api/Article";
 import RelatedPara from "../related-para/RelatedPara";
 
 interface IWidget {

+ 235 - 0
dashboard-v6/src/components/sentence-editor/SentRead.tsx

@@ -0,0 +1,235 @@
+import { useEffect, useMemo, useState, useCallback } from "react";
+import { Button, Dropdown, type MenuProps, Typography } from "antd";
+import { LoadingOutlined, CloseOutlined } from "@ant-design/icons";
+
+import { useAppSelector } from "../../hooks";
+import { settingInfo } from "../../reducers/setting";
+
+import { type IWidgetSentEditInner, SentEditInner } from "./SentEdit";
+
+import store from "../../store";
+import { push } from "../../reducers/sentence";
+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";
+
+const { Text } = Typography;
+
+const items: MenuProps["items"] = [
+  { label: "编辑", key: "edit" },
+  { label: "讨论", key: "discussion" },
+  { label: "修改建议", key: "pr" },
+  { label: "标签", key: "tag" },
+];
+
+export interface IWidgetSentReadFrame {
+  origin?: ISentence[];
+  translation?: ISentence[];
+  layout?: "row" | "column";
+  book?: number;
+  para?: number;
+  wordStart?: number;
+  wordEnd?: number;
+  sentId?: string;
+  error?: string;
+}
+
+const SentReadFrame = ({
+  origin,
+  translation,
+  book,
+  para,
+  wordStart,
+  wordEnd,
+  error,
+}: IWidgetSentReadFrame) => {
+  const settings = useAppSelector(settingInfo);
+
+  const [loadingId, setLoadingId] = useState<string | null>(null);
+  const [active, setActive] = useState(false);
+  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);
+  }, [settings]);
+
+  /** 派生数据:布局方向 */
+  const layoutDirection = useMemo<React.CSSProperties["flexDirection"]>(() => {
+    const v = GetUserSetting("setting.layout.direction", settings);
+    if (
+      v === "row" ||
+      v === "column" ||
+      v === "row-reverse" ||
+      v === "column-reverse"
+    ) {
+      return v;
+    }
+    return "row";
+  }, [settings]);
+
+  /** push 到 store(副作用) */
+  useEffect(() => {
+    store.dispatch(
+      push({
+        id: `${book}-${para}-${wordStart}-${wordEnd}`,
+        origin: origin?.map((item) => item.html),
+        translation: translation?.map((item) => item.html),
+      })
+    );
+  }, [book, origin, para, translation, wordEnd, wordStart]);
+
+  /** 菜单点击 */
+  const handleMenuClick = useCallback(async (key: string, item: ISentence) => {
+    switch (key) {
+      case "edit":
+        if (!item.id) return;
+        setLoadingId(item.id);
+
+        try {
+          const json = await get<IEditableSentence>(
+            `/v2/editable-sentence/${item.id}`
+          );
+          if (json.ok) {
+            setSentData(json.data);
+            setShowEdit(true);
+          }
+        } finally {
+          setLoadingId(null);
+        }
+        break;
+
+      case "discussion":
+        if (item.id) {
+          openDiscussion(item.id, "sentence", false);
+        }
+        break;
+
+      case "pr":
+        prOpen(item);
+        break;
+    }
+  }, []);
+
+  return (
+    <span
+      className="sent_read_shell"
+      style={{ flexDirection: layoutDirection }}
+    >
+      <Text type="danger" mark>
+        {error}
+      </Text>
+
+      {/* anchor */}
+      <span
+        dangerouslySetInnerHTML={{
+          __html: `<span class="pcd_sent" id="sent_${book}-${para}-${wordStart}-${wordEnd}"></span>`,
+        }}
+      />
+
+      {/* 原文 */}
+      <span
+        style={{
+          flex: 5,
+          color: "#9f3a01",
+          display:
+            displayOriginal === false && translation?.length ? "none" : "block",
+        }}
+      >
+        {origin?.map((item, id) => (
+          <Text key={id}>
+            <MdView
+              style={{ color: "brown" }}
+              html={item.html}
+              wordWidget
+              convertor={paliCode}
+            />
+          </Text>
+        ))}
+      </span>
+
+      {/* 译文 */}
+      <span className="sent_read" style={{ flex: 5 }}>
+        {translation?.map((item, id) => (
+          <span key={id}>
+            {loadingId === item.id && <LoadingOutlined />}
+
+            <Dropdown
+              trigger={["contextMenu"]}
+              menu={{
+                items,
+                onClick: (e) => handleMenuClick(e.key, item),
+              }}
+            >
+              <Text
+                className="sent_read_translation"
+                style={{ display: showEdit ? "none" : "inline" }}
+              >
+                <MdView
+                  html={item.html}
+                  style={{ backgroundColor: active ? "beige" : undefined }}
+                />
+              </Text>
+            </Dropdown>
+
+            {/* 编辑面板 */}
+            {showEdit && (
+              <div>
+                <div style={{ textAlign: "right" }}>
+                  <Button
+                    size="small"
+                    icon={<CloseOutlined />}
+                    onClick={() => setShowEdit(false)}
+                  >
+                    返回审阅模式
+                  </Button>
+                </div>
+
+                {sentData ? (
+                  <SentEditInner
+                    mode="edit"
+                    {...sentData}
+                    onTranslationChange={(data: ISentence) => {
+                      if (!translation) return;
+                      const copy = [...translation];
+                      copy[id] = data;
+                    }}
+                  />
+                ) : (
+                  "无数据"
+                )}
+              </div>
+            )}
+
+            <InteractiveButton
+              data={item}
+              compact
+              float
+              hideCount
+              hideInZero
+              onMouseEnter={() => setActive(true)}
+              onMouseLeave={() => setActive(false)}
+            />
+          </span>
+        ))}
+      </span>
+    </span>
+  );
+};
+
+export default SentReadFrame;

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

@@ -18,10 +18,13 @@ import SentTabCopy from "./SentTabCopy";
 import { fullUrl } from "../../utils";
 import SentWbw from "./SentWbw";
 import SentTabButtonWbw from "./SentTabButtonWbw";
-import type { ArticleMode, ISentence, ITocPathNode } from "../../api/Corpus";
+
 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 { ISentence } from "../../api/sentence";
 
 const { Text } = Typography;
 

+ 45 - 59
dashboard-v6/src/components/sentence-editor/SentTabCopy.tsx

@@ -1,4 +1,4 @@
-import { Dropdown, Tooltip, notification } from "antd";
+import { App, Dropdown, Tooltip } from "antd";
 import {
   CopyOutlined,
   ShoppingCartOutlined,
@@ -8,7 +8,7 @@ import {
 import { useEffect, useState } from "react";
 
 import store from "../../store";
-import { modeChange } from "../../reducers/cart-mode";
+import { modeChange, type TCopyMode } from "../../reducers/cart-mode";
 import { useAppSelector } from "../../hooks";
 import { mode as _mode } from "../../reducers/cart-mode";
 import type { IWbw } from "../../types/wbw";
@@ -18,50 +18,56 @@ interface IWidget {
   text?: string;
   wbwData?: IWbw[];
 }
+
 const SentTabCopyWidget = ({ text, wbwData }: IWidget) => {
-  const [mode, setMode] = useState("copy");
+  const { notification } = App.useApp();
+  // ✅ 直接读取 Redux,不再维护镜像 local state
+  const mode = useAppSelector(_mode);
   const [success, setSuccess] = useState(false);
-  const currMode = useAppSelector(_mode);
 
+  // ✅ Redux 变化时同步写入 localStorage(外部系统同步,effect 正当用途)
   useEffect(() => {
-    const modeSetting = localStorage.getItem("cart/mode");
-    if (modeSetting === "cart") {
-      setMode("cart");
+    if (mode) {
+      localStorage.setItem("cart/mode", mode);
     }
-  }, []);
-
-  useEffect(() => {
-    localStorage.setItem("cart/mode", mode);
   }, [mode]);
 
-  useEffect(() => {
-    if (currMode) {
-      setMode(currMode);
-    }
-  }, [currMode]);
-
-  const copy = (mode: string) => {
-    if (text) {
-      if (mode === "copy") {
-        navigator.clipboard.writeText(text).then(() => {
-          setSuccess(true);
-          setTimeout(() => setSuccess(false), 3000);
-        });
-      } else {
-        const paliText = wbwData
-          ?.filter((value) => value.type?.value !== ".ctl.")
-          .map((item) => item.word.value)
-          .join(" ");
+  const handleCopy = (targetMode: TCopyMode) => {
+    if (!text) return;
 
-        addToCart([{ id: text, text: paliText ? paliText : "" }]);
-        notification.success({
-          message: "句子已经添加到Cart",
-        });
+    if (targetMode === "single") {
+      navigator.clipboard.writeText(text).then(() => {
         setSuccess(true);
         setTimeout(() => setSuccess(false), 3000);
-      }
+      });
+    } else {
+      const paliText =
+        wbwData
+          ?.filter((item) => item.type?.value !== ".ctl.")
+          .map((item) => item.word.value)
+          .join(" ") ?? "";
+
+      addToCart([{ id: text, text: paliText }]);
+      notification.success({ message: "句子已经添加到Cart" });
+      setSuccess(true);
+      setTimeout(() => setSuccess(false), 3000);
     }
   };
+
+  const handleMenuClick = (key: string) => {
+    const newMode = key as TCopyMode;
+    store.dispatch(modeChange(newMode));
+    handleCopy(newMode);
+  };
+
+  const icon = success ? (
+    <CheckOutlined />
+  ) : mode === "single" ? (
+    <CopyOutlined />
+  ) : (
+    <ShoppingCartOutlined />
+  );
+
   return (
     <Dropdown.Button
       size="small"
@@ -69,34 +75,14 @@ const SentTabCopyWidget = ({ text, wbwData }: IWidget) => {
       icon={<DownOutlined />}
       menu={{
         items: [
-          {
-            label: "copy",
-            key: "copy",
-            icon: <CopyOutlined />,
-          },
-          {
-            label: "add to cart",
-            key: "cart",
-            icon: <ShoppingCartOutlined />,
-          },
+          { label: "copy", key: "copy", icon: <CopyOutlined /> },
+          { label: "add to cart", key: "cart", icon: <ShoppingCartOutlined /> },
         ],
-        onClick: (e) => {
-          setMode(e.key);
-          store.dispatch(modeChange(e.key));
-          copy(e.key);
-        },
+        onClick: (e) => handleMenuClick(e.key),
       }}
-      onClick={() => copy(mode)}
+      onClick={() => handleCopy(mode)}
     >
-      <Tooltip title={(success ? "已经" : "") + `${mode}`}>
-        {success ? (
-          <CheckOutlined />
-        ) : mode === "copy" ? (
-          <CopyOutlined />
-        ) : (
-          <ShoppingCartOutlined />
-        )}
-      </Tooltip>
+      <Tooltip title={`${success ? "已完成:" : ""}${mode}`}>{icon}</Tooltip>
     </Dropdown.Button>
   );
 };

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

@@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from "react";
 import { ReloadOutlined } from "@ant-design/icons";
 
 import { get } from "../../request";
-import type { ISentence, ISentenceWbwListResponse } from "../../api/Corpus";
+import type { ISentence, ISentenceWbwListResponse } from "../../api/sentence";
 
 import { useAppSelector } from "../../hooks";
 import { courseInfo, memberInfo } from "../../reducers/current-course";

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

@@ -3,7 +3,7 @@ import { useIntl } from "react-intl";
 import { Button, message } from "antd";
 import { EyeOutlined } from "@ant-design/icons";
 
-import type { ISentence } from "../../api/Corpus";
+import type { ISentence } from "../../api/sentence";
 
 import type { IWbw } from "../../types/wbw";
 import { sentSave } from "../../api/sentence";

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

@@ -2,7 +2,7 @@ import { Button } from "antd";
 import { useEffect, useState } from "react";
 import { PlusOutlined } from "@ant-design/icons";
 
-import type { ISentence } from "../../api/Corpus";
+import type { ISentence } from "../../api/sentence";
 import SentCellEditable from "./SentCellEditable";
 
 interface IWidget {

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

@@ -3,7 +3,7 @@ import { Alert, Button, Space } from "antd";
 
 import SuggestionList from "./SuggestionList";
 import SuggestionAdd from "./SuggestionAdd";
-import type { ISentence } from "../../api/Corpus";
+import type { ISentence } from "../../api/sentence";
 import Marked from "../general/Marked";
 import { useAppSelector } from "../../hooks";
 import { message } from "../../reducers/discussion";

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

@@ -1,6 +1,6 @@
 import { Space, Tooltip } from "antd";
 
-import type { ISentence } from "../../api/Corpus";
+import type { ISentence } from "../../api/sentence";
 import { HandOutlinedIcon } from "../../assets/icon";
 import SuggestionPopover from "./SuggestionPopover";
 import { prOpen } from "./utils";

+ 20 - 22
dashboard-v6/src/components/sentence-editor/SuggestionFocus.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useRef } from "react";
 import { useAppSelector } from "../../hooks";
 import { prInfo } from "../../reducers/pr-load";
 
@@ -10,6 +10,7 @@ interface IWidget {
   channelId: string;
   children?: React.ReactNode;
 }
+
 const SuggestionFocusWidget = ({
   book,
   para,
@@ -19,31 +20,28 @@ const SuggestionFocusWidget = ({
   children,
 }: IWidget) => {
   const pr = useAppSelector(prInfo);
-  const [highlight, setHighlight] = useState(false);
   const divRef = useRef<HTMLDivElement>(null);
 
+  // 直接派生,无需 useState + useEffect
+  const highlight =
+    !!pr &&
+    book === pr.book &&
+    para === pr.paragraph &&
+    start === pr.word_start &&
+    end === pr.word_end &&
+    channelId === pr.channel.id;
+
+  // 仅负责滚动这一"外部副作用",不再 setState
   useEffect(() => {
-    if (pr) {
-      if (
-        book === pr.book &&
-        para === pr.paragraph &&
-        start === pr.word_start &&
-        end === pr.word_end &&
-        channelId === pr.channel.id
-      ) {
-        setHighlight(true);
-        divRef.current?.scrollIntoView({
-          behavior: "smooth",
-          block: "center",
-          inline: "nearest",
-        });
-      } else {
-        setHighlight(false);
-      }
-    } else {
-      setHighlight(false);
+    if (highlight) {
+      divRef.current?.scrollIntoView({
+        behavior: "smooth",
+        block: "center",
+        inline: "nearest",
+      });
     }
-  }, [book, channelId, end, para, pr, start]);
+  }, [highlight]);
+
   return (
     <div
       ref={divRef}

+ 103 - 77
dashboard-v6/src/components/sentence-editor/SuggestionList.tsx

@@ -5,7 +5,7 @@ import { ReloadOutlined } from "@ant-design/icons";
 import { get } from "../../request";
 import type { ISuggestionListResponse } from "../../api/Suggestion";
 
-import type { ISentence } from "../../api/Corpus";
+import type { ISentence } from "../../api/sentence";
 import SentCell from "./SentCell";
 import type { IChannel } from "../../api/Channel";
 interface IWidget {
@@ -35,97 +35,123 @@ const SuggestionListWidget = ({
   const [sentData, setSentData] = useState<ISentence[]>([]);
   const [loading, setLoading] = useState(false);
   const [showDiff, setShowDiff] = useState(true);
-  const load = () => {
+  const [refreshCount, setRefreshCount] = useState(0);
+
+  useEffect(() => {
     if (!enable) {
       return;
     }
-    const url = `/v2/sentpr?view=sent-info&book=${book}&para=${para}&start=${wordStart}&end=${wordEnd}&channel=${channel.id}`;
-    console.log("url", url);
-    setLoading(true);
-    get<ISuggestionListResponse>(url)
-      .then((json) => {
+
+    const controller = new AbortController();
+
+    const fetchData = async () => {
+      const url = `/v2/sentpr?view=sent-info&book=${book}&para=${para}&start=${wordStart}&end=${wordEnd}&channel=${channel.id}`;
+      console.log("url", url);
+      setLoading(true);
+      try {
+        const json = await get<ISuggestionListResponse>(url);
         if (json.ok) {
-          const newData: ISentence[] = json.data.rows.map((item) => {
-            return {
-              id: item.id,
-              uid: item.uid,
-              content: item.content,
-              html: item.html,
-              book: item.book,
-              para: item.paragraph,
-              wordStart: item.word_start,
-              wordEnd: item.word_end,
-              editor: item.editor,
-              channel: { name: item.channel.name, id: item.channel.id },
-              updateAt: item.updated_at,
-            };
-          });
+          const newData: ISentence[] = json.data.rows.map((item) => ({
+            id: item.id,
+            uid: item.uid,
+            content: item.content,
+            html: item.html,
+            book: item.book,
+            para: item.paragraph,
+            wordStart: item.word_start,
+            wordEnd: item.word_end,
+            editor: item.editor,
+            channel: { name: item.channel.name, id: item.channel.id },
+            updateAt: item.updated_at,
+          }));
           setSentData(newData);
-          if (typeof onChange !== "undefined") {
-            onChange(json.data.count);
-          }
+          onChange?.(json.data.count);
         } else {
           message.error(json.message);
         }
-      })
-      .finally(() => {
-        setLoading(false);
-        if (reload && typeof onReload !== "undefined") {
-          onReload();
+      } catch (e) {
+        if ((e as Error).name === "AbortError") {
+          // 请求被取消,忽略,不更新状态
+          return;
         }
-      });
-  };
-  useEffect(() => {
-    load();
-  }, [book, channel.id, para, reload, wordEnd, wordStart]);
-  useEffect(() => {
-    if (reload) {
-      load();
-    }
-  }, [reload]);
+        message.error((e as Error).message);
+      } finally {
+        // 只有请求未被取消时才更新 loading 状态
+        if (!controller.signal.aborted) {
+          setLoading(false);
+          if (reload) {
+            onReload?.();
+          }
+        }
+      }
+    };
+
+    fetchData();
+
+    // cleanup:依赖变化或组件卸载时取消上一次请求
+    return () => {
+      controller.abort();
+    };
+
+    //FIXME reload 由 true 变为 false 的时候会再次刷新
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [
+    book,
+    channel.id,
+    para,
+    wordEnd,
+    wordStart,
+    enable,
+    reload,
+    refreshCount,
+  ]);
+  //                                                                ^^^^^^^^^^^
+  // reload 保留在依赖中,外部 reload prop 变化时也会触发;
+  // refreshCount 变化时触发手动刷新。
+
+  const handleRefresh = () => setRefreshCount((c) => c + 1);
+
   return (
     <>
       {loading ? (
         <Skeleton />
       ) : (
-        <>
-          <List
-            header={
-              <div style={{ textAlign: "right" }}>
-                <Space>
-                  <Button
-                    type="link"
-                    size="small"
-                    icon={<ReloadOutlined />}
-                    onClick={() => load()}
-                  ></Button>
-                  {"文本比对"}
-                  <Switch
-                    size="small"
-                    defaultChecked
-                    onChange={(checked) => setShowDiff(checked)}
-                  />
-                </Space>
-              </div>
-            }
-            itemLayout="vertical"
-            size="small"
-            dataSource={sentData}
-            renderItem={(item, id) => (
-              <List.Item>
-                <SentCell
-                  value={item}
-                  key={id}
-                  isPr={true}
-                  showDiff={showDiff}
-                  diffText={content}
-                  onDelete={() => load()}
-                  onChange={() => load()}
+        <List
+          header={
+            <div style={{ textAlign: "right" }}>
+              <Space>
+                <Button
+                  type="link"
+                  size="small"
+                  icon={<ReloadOutlined />}
+                  onClick={handleRefresh}
                 />
-              </List.Item>
-            )}
-          />
-        </>
+                {"文本比对"}
+                <Switch
+                  size="small"
+                  defaultChecked
+                  onChange={(checked) => setShowDiff(checked)}
+                />
+              </Space>
+            </div>
+          }
+          itemLayout="vertical"
+          size="small"
+          dataSource={sentData}
+          renderItem={(item, id) => (
+            <List.Item>
+              <SentCell
+                value={item}
+                key={id}
+                isPr={true}
+                showDiff={showDiff}
+                diffText={content}
+                onDelete={handleRefresh}
+                onChange={handleRefresh}
+              />
+            </List.Item>
+          )}
+        />
       )}
     </>
   );

+ 31 - 28
dashboard-v6/src/components/sentence-editor/SuggestionPopover.tsx

@@ -1,7 +1,7 @@
 import { Popover } from "antd";
-import { useEffect, useState } from "react";
+import { useMemo, useState } from "react";
 import SentCell from "./SentCell";
-import type { ISentence } from "../../api/Corpus";
+import type { ISentence } from "../../api/sentence";
 import { useAppSelector } from "../../hooks";
 import { prInfo, refresh } from "../../reducers/pr-load";
 import store from "../../store";
@@ -13,6 +13,7 @@ interface IWidget {
   end: number;
   channelId: string;
 }
+
 const SuggestionPopoverWidget = ({
   book,
   para,
@@ -21,41 +22,43 @@ const SuggestionPopoverWidget = ({
   channelId,
 }: IWidget) => {
   const [open, setOpen] = useState(false);
-  const [sentData, setSentData] = useState<ISentence>();
   const pr = useAppSelector(prInfo);
 
-  useEffect(() => {
-    if (pr) {
-      if (
-        book === pr.book &&
-        para === pr.paragraph &&
-        start === pr.word_start &&
-        end === pr.word_end &&
-        channelId === pr.channel.id
-      ) {
-        setSentData({
-          id: pr.id,
-          content: pr.content,
-          html: pr.html,
-          book: pr.book,
-          para: pr.paragraph,
-          wordStart: pr.word_start,
-          wordEnd: pr.word_end,
-          editor: pr.editor,
-          channel: { name: pr.channel.name, id: pr.channel.id },
-          updateAt: pr.updated_at,
-        });
-        setOpen(true);
-      }
+  const sentData = useMemo<ISentence | undefined>(() => {
+    if (
+      pr &&
+      book === pr.book &&
+      para === pr.paragraph &&
+      start === pr.word_start &&
+      end === pr.word_end &&
+      channelId === pr.channel.id
+    ) {
+      return {
+        id: pr.id,
+        content: pr.content,
+        html: pr.html,
+        book: pr.book,
+        para: pr.paragraph,
+        wordStart: pr.word_start,
+        wordEnd: pr.word_end,
+        editor: pr.editor,
+        channel: { name: pr.channel.name, id: pr.channel.id },
+        updateAt: pr.updated_at,
+      };
     }
+    return undefined;
   }, [book, channelId, end, para, pr, start]);
 
+  // Derive open state from sentData so no effect is needed
+  const isOpen = open && !!sentData;
+
   const handleOpenChange = (newOpen: boolean) => {
     setOpen(newOpen);
-    if (newOpen === false) {
+    if (!newOpen) {
       store.dispatch(refresh(null));
     }
   };
+
   return (
     <Popover
       placement="bottomRight"
@@ -67,7 +70,7 @@ const SuggestionPopoverWidget = ({
       }
       title={`${sentData?.editor.nickName}提交的修改建议`}
       trigger="click"
-      open={open}
+      open={isOpen}
       onOpenChange={handleOpenChange}
     >
       <span></span>

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

@@ -2,7 +2,7 @@ import { useState } from "react";
 import { type RadioChangeEvent, Space } from "antd";
 import { Radio } from "antd";
 
-import type { ISentence } from "../../api/Corpus";
+import type { ISentence } from "../../api/sentence";
 import { SuggestionIcon } from "../../assets/icon";
 import SuggestionAdd from "./SuggestionAdd";
 import SuggestionList from "./SuggestionList";

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

@@ -2,7 +2,7 @@ import { Divider, Popconfirm, Space, Tooltip, Typography } from "antd";
 import { LikeOutlined, DeleteOutlined } from "@ant-design/icons";
 import { useIntl } from "react-intl";
 
-import type { ISentence } from "../../api/Corpus";
+import type { ISentence } from "../../api/sentence";
 import PrAcceptButton from "./PrAcceptButton";
 import InteractiveButton from "./InteractiveButton";
 

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

@@ -1,4 +1,4 @@
-import type { ISentence } from "../../api/Corpus";
+import type { ISentence } from "../../api/sentence";
 
 import type { ISentCart } from "./SentCart";
 import store from "../../store";

+ 1 - 1
dashboard-v6/src/components/sentence/utils.ts

@@ -1,4 +1,4 @@
-import type { ISentence, ISentenceData } from "../../api/Corpus";
+import type { ISentence, ISentenceData } from "../../api/sentence";
 
 export const toISentence = (
   item: ISentenceData,

+ 255 - 0
dashboard-v6/src/components/term/GrammarBook.tsx

@@ -0,0 +1,255 @@
+import { Button, Dropdown, Input, List } from "antd";
+import { useEffect, useState } from "react";
+import {
+  ArrowLeftOutlined,
+  FieldTimeOutlined,
+  MoreOutlined,
+  FileAddOutlined,
+} from "@ant-design/icons";
+
+import { type ITerm, getGrammar } from "../../reducers/term-vocabulary";
+import { useAppSelector } from "../../hooks";
+import TermSearch from "./TermSearch";
+import {
+  grammar,
+  grammarId,
+  grammarWord,
+  grammarWordId,
+} from "../../reducers/command";
+import store from "../../store";
+import GrammarRecent, { type IGrammarRecent } from "./GrammarRecent";
+import { useIntl } from "react-intl";
+
+import { get } from "../../request";
+import type {
+  IApiResponseChannelData,
+  IApiResponseChannelList,
+} from "../../api/Channel";
+import { grammarTermFetch } from "../../load";
+import TermModal from "./TermModal";
+import { popRecent, pushRecent } from "./utils";
+
+const { Search } = Input;
+
+interface IGrammarList {
+  term: ITerm;
+  weight: number;
+}
+const GrammarBookWidget = () => {
+  const intl = useIntl();
+
+  const [result, setResult] = useState<IGrammarList[]>();
+  const [localTermId, setLocalTermId] = useState<string>();
+  const [localTermSearch, setLocalTermSearch] = useState<string>();
+  const [showRecent, setShowRecent] = useState(false);
+  const [create, setCreate] = useState(false);
+  const [grammarChannel, setGrammarChannel] =
+    useState<IApiResponseChannelData>();
+  const sysGrammar = useAppSelector(getGrammar);
+  const searchWord = useAppSelector(grammarWord);
+  const searchWordId = useAppSelector(grammarWordId);
+
+  const termId = searchWordId || localTermId;
+  const termSearch = searchWord || localTermSearch;
+
+  useEffect(() => {
+    const url = `/api/v2/channel?view=system`;
+    get<IApiResponseChannelList>(url).then((json) => {
+      if (json.ok) {
+        const channel = json.data.rows.find(
+          (value) => value.name === "_System_Grammar_Term_zh-hans_"
+        );
+        setGrammarChannel(channel);
+      }
+    });
+  }, []);
+
+  useEffect(() => {
+    if (searchWord && searchWord.length > 0) {
+      pushRecent({
+        title: searchWord,
+        description: searchWord,
+        word: searchWord,
+      });
+
+      store.dispatch(grammar(""));
+    }
+  }, [searchWord]);
+
+  useEffect(() => {
+    if (searchWordId && searchWordId.length > 0) {
+      pushRecent({
+        title: searchWordId,
+        description: searchWordId,
+        wordId: searchWordId,
+      });
+
+      store.dispatch(grammarId(""));
+    }
+  }, [searchWordId]);
+
+  return (
+    <div>
+      <div style={{ display: "flex" }}>
+        <Button
+          icon={<ArrowLeftOutlined />}
+          type="text"
+          onClick={() => {
+            const top = popRecent();
+            if (top) {
+              setLocalTermId(top.wordId);
+              setLocalTermSearch(top.word);
+            }
+          }}
+        />
+        <Search
+          placeholder="input search text"
+          onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
+            console.debug("on change", event.target.value);
+            setLocalTermId(undefined);
+            setLocalTermSearch(undefined);
+            const keyWord = event.target.value;
+            if (keyWord.trim().length === 0) {
+              setShowRecent(true);
+            } else {
+              setShowRecent(false);
+            }
+            /**
+             * 权重算法
+             * 约靠近头,分数约高
+             * 剩余尾巴约短,分数越高
+             */
+            const search = sysGrammar
+              ?.map((item) => {
+                let weight = 0;
+                const wordBegin = item.word
+                  .toLocaleLowerCase()
+                  .indexOf(keyWord.toLocaleLowerCase());
+                if (wordBegin >= 0) {
+                  weight += (1 / (wordBegin + 1)) * 1000;
+                  const wordRemain =
+                    item.word.length - keyWord.length - wordBegin;
+                  weight += (1 / (wordRemain + 1)) * 100;
+                }
+                const meaningBegin = (item.meaning + item.other_meaning)
+                  .toLocaleLowerCase()
+                  .indexOf(keyWord);
+                if (meaningBegin >= 0) {
+                  weight += (1 / (meaningBegin + 1)) * 1000;
+                  const meaningRemain =
+                    item.meaning.length - keyWord.length - wordBegin;
+                  weight += (1 / (meaningRemain + 1)) * 100;
+                }
+                return { term: item, weight: weight };
+              })
+              .filter((value) => value.weight > 0)
+              .sort((a, b) => b.weight - a.weight);
+
+            setResult(search);
+          }}
+          style={{ width: "100%" }}
+        />
+        <Dropdown
+          trigger={["click"]}
+          menu={{
+            items: [
+              {
+                key: "recent",
+                label: "最近查询",
+                icon: <FieldTimeOutlined />,
+              },
+              {
+                key: "create",
+                label: intl.formatMessage({ id: "buttons.create" }),
+                icon: <FileAddOutlined />,
+                children: [
+                  {
+                    key: "create_collection",
+                    label: "固定搭配",
+                  },
+                ],
+              },
+            ],
+            onClick: (e) => {
+              switch (e.key) {
+                case "recent":
+                  setShowRecent(true);
+                  break;
+                case "create_collection":
+                  setCreate(true);
+                  break;
+              }
+            },
+          }}
+        >
+          <Button type="text" icon={<MoreOutlined />} />
+        </Dropdown>
+      </div>
+      <div>
+        {showRecent ? (
+          <GrammarRecent
+            onClick={(value: IGrammarRecent) => {
+              console.debug("grammar book recent click", value);
+              setLocalTermId(value.wordId);
+              setLocalTermSearch(value.word);
+              setShowRecent(false);
+            }}
+          />
+        ) : termId || termSearch ? (
+          <TermSearch
+            wordId={termId}
+            word={termSearch}
+            onIdChange={(value: string) => {
+              setLocalTermId(value);
+              setLocalTermSearch(undefined);
+              pushRecent({ title: value, description: value, wordId: value });
+            }}
+          />
+        ) : (
+          <List
+            size="small"
+            dataSource={result}
+            renderItem={(item) => {
+              const description =
+                item.term.meaning +
+                (item.term.other_meaning ? "," + item.term.other_meaning : "");
+
+              return (
+                <List.Item
+                  key={item.term.guid}
+                  style={{ cursor: "pointer" }}
+                  onClick={() => {
+                    setLocalTermId(item.term.guid);
+                    setLocalTermSearch(undefined);
+                    pushRecent({
+                      title: item.term.word,
+                      description: description,
+                      wordId: item.term.guid,
+                    });
+                  }}
+                >
+                  <List.Item.Meta
+                    title={item.term.word}
+                    description={description}
+                  />
+                </List.Item>
+              );
+            }}
+          />
+        )}
+      </div>
+      <TermModal
+        parentChannelId={grammarChannel?.uid}
+        tags={[":collection:"]}
+        open={create}
+        onClose={() => setCreate(false)}
+        onUpdate={() => {
+          //获取语法术语表
+          grammarTermFetch();
+        }}
+      />
+    </div>
+  );
+};
+
+export default GrammarBookWidget;

+ 40 - 0
dashboard-v6/src/components/term/GrammarRecent.tsx

@@ -0,0 +1,40 @@
+import { List } from "antd";
+import { storeKey } from "./utils";
+
+export interface IGrammarRecent {
+  title: string;
+  description?: string;
+  word?: string;
+  wordId?: string;
+}
+
+interface IWidget {
+  onClick?: (item: IGrammarRecent) => void;
+}
+const GrammarRecentWidget = ({ onClick }: IWidget) => {
+  const data = localStorage.getItem(storeKey);
+  let items: IGrammarRecent[] = [];
+  if (data) {
+    items = JSON.parse(data);
+  }
+  return (
+    <List
+      header={"最近搜索"}
+      size="small"
+      dataSource={items}
+      renderItem={(item, index) => (
+        <List.Item
+          key={index}
+          style={{ cursor: "pointer" }}
+          onClick={() => {
+            onClick?.(item);
+          }}
+        >
+          <List.Item.Meta title={item.title} description={item.description} />
+        </List.Item>
+      )}
+    />
+  );
+};
+
+export default GrammarRecentWidget;

+ 243 - 0
dashboard-v6/src/components/term/TermCommunity.tsx

@@ -0,0 +1,243 @@
+import {
+  Badge,
+  Card,
+  Dropdown,
+  type MenuProps,
+  Popover,
+  Space,
+  Typography,
+} from "antd";
+import { DownOutlined } from "@ant-design/icons";
+import { useState, useEffect } from "react";
+import { useIntl } from "react-intl";
+import { get } from "../../request";
+
+import type { ITermListResponse } from "../../api/Term";
+import { Link } from "react-router";
+import type { IUser } from "../../api/Auth";
+
+const { Title, Text } = Typography;
+
+interface IItem<R> {
+  value: R;
+  score: number;
+}
+interface IWord {
+  meaning: IItem<string>[];
+  note: IItem<string>[];
+  editor: IItem<IUser>[];
+}
+
+interface IWidget {
+  word: string | undefined;
+}
+const TermCommunityWidget = ({ word }: IWidget) => {
+  const intl = useIntl();
+  const [show, setShow] = useState(false);
+  const [wordData, setWordData] = useState<IWord>();
+  const minScore = 100; //分数阈值。低于这个分数只显示在弹出菜单中
+
+  useEffect(() => {
+    if (typeof word === "undefined") {
+      return;
+    }
+    const url = `/api/v2/terms?view=word&word=${word}&exp=1`;
+    console.log("url", url);
+    get<ITermListResponse>(url)
+      .then((json) => {
+        if (json.ok === false) {
+          return;
+        }
+        const meaning = new Map<string, number>();
+        const note = new Map<string, number>();
+        const editorId = new Map<string, number>();
+        const editor = new Map<string, IUser>();
+        for (const it of json.data.rows) {
+          let score: number | undefined;
+          let currScore = 100;
+          if (it.exp) {
+            //分数计算
+            currScore = Math.floor(it.exp / 3600);
+          }
+          if (it.meaning) {
+            score = meaning.get(it.meaning);
+            meaning.set(it.meaning, score ? score + currScore : currScore);
+          }
+
+          if (it.note) {
+            score = note.get(it.note);
+            const noteScore = it.note.length;
+            note.set(it.note, score ? score + noteScore : noteScore);
+          }
+
+          if (it.editor) {
+            score = editorId.get(it.editor.id);
+            editorId.set(it.editor.id, score ? score + currScore : currScore);
+            editor.set(it.editor.id, it.editor);
+          }
+        }
+        const _data: IWord = {
+          meaning: [],
+          note: [],
+          editor: [],
+        };
+        meaning.forEach((value, key) => {
+          if (key && key.length > 0) {
+            _data.meaning.push({ value: key, score: value });
+          }
+        });
+        _data.meaning.sort((a, b) => b.score - a.score);
+        note.forEach((value, key) => {
+          if (key && key.length > 0) {
+            _data.note.push({ value: key, score: value });
+          }
+        });
+        _data.note.sort((a, b) => b.score - a.score);
+
+        editorId.forEach((value, key) => {
+          const currEditor = editor.get(key);
+          if (currEditor) {
+            _data.editor.push({ value: currEditor, score: value });
+          }
+        });
+        _data.editor.sort((a, b) => b.score - a.score);
+        setWordData(_data);
+        if (_data.editor.length > 0) {
+          setShow(true);
+        }
+      })
+      .catch((error) => {
+        console.error(error);
+      });
+  }, [word, setWordData]);
+
+  const isShow = (score: number, index: number) => {
+    const Ms = 500,
+      Rd = 5,
+      minScore = 15;
+    const minOrder = Math.log(score) / Math.log(Math.pow(Ms, 1 / Rd));
+    if (index < minOrder && score > minScore) {
+      return true;
+    } else {
+      return false;
+    }
+  };
+
+  const meaningLow = wordData?.meaning.filter(
+    (value, index: number) => !isShow(value.score, index)
+  );
+  const meaningExtra = meaningLow?.map((item, id) => {
+    return <span key={id}>{item.value}</span>;
+  });
+
+  const mainCollaboratorNum = 3; //默认显示的协作者数量,其余的在更多中显示
+  const collaboratorRender = (name: string, id: number, score: number) => {
+    return (
+      <Space key={id}>
+        {name}
+        <Badge
+          style={{ display: "none" }}
+          color="geekblue"
+          size="small"
+          count={score}
+        />
+      </Space>
+    );
+  };
+  const items: MenuProps["items"] = wordData?.editor
+    .filter((_value, index) => index >= mainCollaboratorNum)
+    .map((item, id) => {
+      return {
+        key: id,
+        label: collaboratorRender(item.value.nickName, id, item.score),
+      };
+    });
+  const more = wordData ? (
+    wordData.editor.length > mainCollaboratorNum ? (
+      <Dropdown menu={{ items }}>
+        <Typography.Link>
+          <Space>
+            {intl.formatMessage({
+              id: `buttons.more`,
+            })}
+            <DownOutlined />
+          </Space>
+        </Typography.Link>
+      </Dropdown>
+    ) : undefined
+  ) : undefined;
+
+  return show ? (
+    <Card>
+      <Space>
+        <Title level={5} id={`community`}>
+          {"社区术语"}
+        </Title>
+        <Link to={`/term/list/${word}`}>详情</Link>
+      </Space>
+
+      <div key="meaning">
+        <Space style={{ flexWrap: "wrap" }}>
+          <Text strong>{"意思:"}</Text>
+          {wordData?.meaning
+            .filter((value, index: number) => isShow(value.score, index))
+            .map((item, id) => {
+              return (
+                <Space key={id}>
+                  {item.value}
+                  <Badge
+                    style={{ display: "none" }}
+                    color="geekblue"
+                    size="small"
+                    count={item.score}
+                  />
+                </Space>
+              );
+            })}
+          {meaningLow && meaningLow.length > 0 ? (
+            <Popover content={<Space>{meaningExtra}</Space>} placement="bottom">
+              <Typography.Link>
+                <Space>
+                  {intl.formatMessage({
+                    id: `buttons.more`,
+                  })}
+                  <DownOutlined />
+                </Space>
+              </Typography.Link>
+            </Popover>
+          ) : undefined}
+        </Space>
+      </div>
+      <div key="note">
+        <Space style={{ flexWrap: "wrap" }}>
+          <Text strong>{"note:"}</Text>
+          {wordData?.note
+            .filter((value) => value.score >= minScore)
+            .map((item, id) => {
+              return (
+                <Space key={id}>
+                  {item.value}
+                  <Badge color="geekblue" size="small" count={item.score} />
+                </Space>
+              );
+            })}
+        </Space>
+      </div>
+      <div key="collaborator">
+        <Space style={{ flexWrap: "wrap" }}>
+          <Text strong>{"贡献者:"}</Text>
+          {wordData?.editor
+            .filter((_value, index) => index < mainCollaboratorNum)
+            .map((item, id) => {
+              return collaboratorRender(item.value.nickName, id, item.score);
+            })}
+          {more}
+        </Space>
+      </div>
+    </Card>
+  ) : (
+    <></>
+  );
+};
+
+export default TermCommunityWidget;

+ 483 - 0
dashboard-v6/src/components/term/TermEdit.tsx

@@ -0,0 +1,483 @@
+import { useIntl } from "react-intl";
+import {
+  ProForm,
+  ProFormCheckbox,
+  ProFormDependency,
+  type ProFormInstance,
+  ProFormSelect,
+  ProFormSwitch,
+  ProFormText,
+} from "@ant-design/pro-components";
+
+import LangSelect from "../general/LangSelect";
+import ChannelSelect from "../channel/ChannelSelect";
+import { Alert, App, AutoComplete, Button, Form, Space, Tag } from "antd";
+import { useEffect, useRef, useState } from "react";
+import type {
+  ITerm,
+  ITermCreateResponse,
+  ITermDataRequest,
+  ITermDataResponse,
+  ITermListResponse,
+  ITermResponse,
+} from "../../api/Term";
+import { get, post, put } from "../../request";
+import MDEditor from "@uiw/react-md-editor";
+import { useAppSelector } from "../../hooks";
+import { currentUser as _currentUser } from "../../reducers/current-user";
+import store from "../../store";
+import { push } from "../../reducers/term-vocabulary";
+
+interface ValueType {
+  key?: string;
+  label: React.ReactNode;
+  value: string | number;
+}
+
+interface IWidget {
+  id?: string;
+  word?: string;
+  tags?: string[];
+  studioName?: string;
+  channelId?: string;
+  parentChannelId?: string;
+  parentStudioId?: string;
+  community?: boolean;
+  onUpdate?: (data: ITermDataResponse) => void;
+}
+const TermEditWidget = ({
+  id,
+  word,
+  tags,
+  channelId,
+  studioName,
+  parentChannelId,
+  parentStudioId,
+  community = false,
+  onUpdate,
+}: IWidget) => {
+  const intl = useIntl();
+  const [meaningOptions, setMeaningOptions] = useState<ValueType[]>([]);
+  const [readonly, setReadonly] = useState(false);
+  const [isSaveAs, setIsSaveAs] = useState(false);
+  const [currChannel, setCurrChannel] = useState<ValueType[]>([]);
+  const user = useAppSelector(_currentUser);
+  const { message } = App.useApp();
+
+  const [form] = Form.useForm<ITerm>();
+  const formRef = useRef<ProFormInstance | undefined>(undefined);
+  useEffect(() => {
+    if (word) {
+      const url = `/api/v2/terms?view=word&word=${word}`;
+      console.info("api request", url);
+      get<ITermListResponse>(url).then((json) => {
+        const meaning = json.data.rows.map((item) => item.meaning);
+        const meaningMap = new Map<string, number>();
+        for (const it of meaning) {
+          const count = meaningMap.get(it);
+          if (typeof count === "undefined") {
+            meaningMap.set(it, 1);
+          } else {
+            meaningMap.set(it, count + 1);
+          }
+        }
+        const meaningList: ValueType[] = [];
+        meaningMap.forEach((value, key) => {
+          meaningList.push({
+            value: key,
+            label: (
+              <Space
+                style={{ display: "flex", justifyContent: "space-between" }}
+              >
+                {key}
+                <Tag>{value}</Tag>
+              </Space>
+            ),
+          });
+        });
+
+        setMeaningOptions(meaningList);
+      });
+    }
+  }, [word]);
+
+  let channelDisable = false;
+  if (community) {
+    channelDisable = true;
+  }
+  if (readonly) {
+    channelDisable = true;
+  }
+  if (id) {
+    channelDisable = true;
+  }
+
+  return (
+    <>
+      {community ? (
+        <Alert
+          title="该资源为社区数据,您可以修改并保存到一个您有修改权限的版本中。"
+          type="info"
+          closable
+          action={
+            <Button disabled size="small" type="text">
+              详情
+            </Button>
+          }
+        />
+      ) : readonly ? (
+        <Alert
+          title="该资源为只读,如果需要修改,请联络拥有者分配权限。或者您可以在下面的版本选择中选择另一个版本,将该术语保存到一个您有修改权限的版本中。"
+          type="warning"
+          closable
+          action={
+            <Button disabled size="small" type="text">
+              详情
+            </Button>
+          }
+        />
+      ) : undefined}
+      <ProForm<ITerm>
+        form={form}
+        formRef={formRef}
+        autoFocusFirstInput={true}
+        onFinish={async (values: ITerm) => {
+          console.log("term submit", values);
+          if (
+            typeof values.word === "undefined" ||
+            typeof values.meaning === "undefined"
+          ) {
+            return;
+          }
+          let copy_channel = "";
+          if (values.copy_channel && values.copy_channel.length > 0) {
+            copy_channel = values.copy_channel[values.copy_channel.length - 1];
+          }
+          const newValue = {
+            id: values.id,
+            word: values.word,
+            tag: values.tag,
+            meaning: values.meaning,
+            other_meaning: values.meaning2?.join(),
+            note: values.note,
+            channel: values.save_as ? copy_channel : values.channelId,
+            parent_channel_id: parentChannelId,
+            studioName: studioName,
+            studioId: parentStudioId,
+            language: values.save_as ? values.copy_lang : values.lang,
+            pr: values.save_as ? values.pr : undefined,
+          };
+          console.log("value", newValue);
+          let res: ITermResponse;
+          if (typeof values.id === "undefined" || community || values.save_as) {
+            const url = `/api/v2/terms?community_summary=1`;
+            console.info("api request", url, newValue);
+            res = await post<ITermDataRequest, ITermResponse>(url, newValue);
+          } else {
+            const url = `/api/v2/terms/${values.id}?community_summary=1`;
+            console.info("api request", url, newValue);
+            res = await put<ITermDataRequest, ITermResponse>(url, newValue);
+          }
+          console.debug("api response", res);
+
+          if (res.ok) {
+            message.success("提交成功");
+            store.dispatch(
+              push({ word: res.data.word, meaning: res.data.meaning })
+            );
+            onUpdate?.(res.data);
+          } else {
+            message.error(res.message);
+          }
+
+          return true;
+        }}
+        request={async () => {
+          let url: string;
+          let data: ITerm = {
+            word: word ? word : "",
+            tag: tags?.join(),
+            meaning: "",
+            meaning2: [],
+            note: "",
+            lang: "",
+            copy_channel: [],
+          };
+          if (typeof id !== "undefined") {
+            // 如果是编辑,就从服务器拉取数据。
+            url = "/api/v2/terms/" + id;
+            console.info("TermEdit is edit api request", url);
+            const res = await get<ITermResponse>(url);
+            console.debug("TermEdit is edit api response", res);
+            if (res.ok) {
+              let meaning2: string[] = [];
+              if (res.data.other_meaning) {
+                meaning2 = res.data.other_meaning.split(",");
+              }
+
+              let realChannelId: string | undefined = "";
+              if (parentStudioId) {
+                if (user?.id === parentStudioId) {
+                  if (community) {
+                    realChannelId = "";
+                  } else {
+                    realChannelId = res.data.channel?.id;
+                  }
+                } else {
+                  realChannelId = parentChannelId;
+                }
+              } else {
+                if (res.data.channel) {
+                  realChannelId = res.data.channel?.id;
+                  setCurrChannel([
+                    {
+                      label: res.data.channel?.name,
+                      value: res.data.channel?.id,
+                    },
+                  ]);
+                }
+              }
+              let copyToChannel: string[] = [];
+              if (parentChannelId) {
+                if (user?.roles?.includes("basic")) {
+                  copyToChannel = [parentChannelId];
+                } else {
+                  copyToChannel = [""];
+                }
+              }
+
+              data = {
+                id: res.data.guid,
+                word: res.data.word,
+                tag: res.data.tag,
+                meaning: res.data.meaning,
+                meaning2: meaning2,
+                note: res.data.note ? res.data.note : "",
+                lang: res.data.language,
+                channelId: realChannelId,
+                copy_channel: copyToChannel,
+              };
+              if (res.data.role === "reader" || res.data.role === "unknown") {
+                setReadonly(true);
+              }
+            }
+          } else if (typeof parentChannelId !== "undefined") {
+            /**
+             * 在channel新建
+             * basic:仅保存在这个版本
+             * pro: 默认studio通用
+             */
+            url = `/api/v2/terms?view=create-by-channel&channel=${parentChannelId}&word=${word}`;
+            console.info("api request 在channel新建", url);
+            const res = await get<ITermCreateResponse>(url);
+            console.debug("api response", res);
+            let channelId = "";
+            let copyToChannel: string[] = [];
+            if (user?.roles?.includes("basic")) {
+              channelId = parentChannelId;
+              copyToChannel = [parentChannelId];
+            } else {
+              channelId = user?.id === parentStudioId ? "" : parentChannelId;
+              copyToChannel = [res.data.studio.id, parentChannelId];
+            }
+            data = {
+              word: word ? word : "",
+              tag: tags?.join(),
+              meaning: "",
+              meaning2: [],
+              note: "",
+              lang: res.data.language,
+              channelId: channelId,
+              copy_channel: copyToChannel,
+            };
+          } else if (typeof studioName !== "undefined") {
+            //在studio新建
+
+            url = `/api/v2/terms?view=create-by-studio&studio=${studioName}&word=${word}`;
+            console.debug("在 studio 新建", url);
+          }
+
+          return data;
+        }}
+      >
+        <ProForm.Group>
+          <ProFormText width="md" name="id" hidden />
+
+          <ProFormText
+            width="md"
+            name="word"
+            initialValue={word}
+            required
+            label={intl.formatMessage({
+              id: "term.fields.word.label",
+            })}
+            rules={[
+              {
+                required: true,
+              },
+            ]}
+            fieldProps={{
+              showCount: true,
+              maxLength: 128,
+            }}
+          />
+          <ProFormText
+            width="md"
+            name="tag"
+            tooltip={intl.formatMessage({
+              id: "term.fields.description.tooltip",
+            })}
+            label={intl.formatMessage({
+              id: "term.fields.description.label",
+            })}
+          />
+        </ProForm.Group>
+        <ProForm.Group>
+          <ProForm.Item
+            width="md"
+            name="meaning"
+            label={intl.formatMessage({
+              id: "term.fields.meaning.label",
+            })}
+            rules={[
+              {
+                required: true,
+              },
+            ]}
+          >
+            <AutoComplete
+              options={meaningOptions}
+              style={{ width: "328px" }}
+              allowClear
+              maxLength={128}
+            />
+          </ProForm.Item>
+
+          <ProFormSelect
+            width="md"
+            name="meaning2"
+            label={intl.formatMessage({
+              id: "term.fields.meaning2.label",
+            })}
+            fieldProps={{
+              mode: "tags",
+              tokenSeparators: [",", ","],
+            }}
+            placeholder="Please select other meanings"
+            rules={[
+              {
+                type: "array",
+              },
+            ]}
+          />
+        </ProForm.Group>
+        <ProForm.Group>
+          <ProFormSelect
+            name="channelId"
+            allowClear
+            label="版本(已经建立的术语,版本不可修改。可以选择另存为复制到另一个版本。)"
+            width="md"
+            placeholder={intl.formatMessage({
+              id: "term.general-in-studio",
+            })}
+            disabled={channelDisable}
+            options={[
+              {
+                value: "",
+                label: intl.formatMessage({
+                  id: "term.general-in-studio",
+                }),
+                disabled:
+                  user?.id !== parentStudioId || user?.roles?.includes("basic"),
+              },
+              {
+                value: parentChannelId ?? channelId,
+                label: "仅用于此版本",
+                disabled: !community && readonly,
+              },
+              ...currChannel,
+            ]}
+          />
+
+          <ProFormDependency name={["channelId"]}>
+            {({ channelId }) => {
+              const hasChannel = channelId
+                ? channelId === ""
+                  ? false
+                  : true
+                : false;
+              return (
+                <LangSelect
+                  disabled={hasChannel || channelDisable}
+                  required={!hasChannel}
+                />
+              );
+            }}
+          </ProFormDependency>
+        </ProForm.Group>
+        <ProForm.Group>
+          <Form.Item
+            style={{ width: "100%" }}
+            name="note"
+            label={intl.formatMessage({ id: "forms.fields.note.label" })}
+          >
+            <MDEditor />
+          </Form.Item>
+        </ProForm.Group>
+        <ProForm.Group>
+          <ProFormSwitch
+            name="save_as"
+            label="另存为"
+            fieldProps={{
+              onChange: (checked: boolean) => {
+                setIsSaveAs(checked);
+              },
+            }}
+          />
+        </ProForm.Group>
+        <ProForm.Group style={{ display: isSaveAs ? "block" : "none" }}>
+          <ChannelSelect
+            channelId={channelId}
+            parentChannelId={parentChannelId}
+            parentStudioId={parentStudioId}
+            width="md"
+            name="copy_channel"
+            placeholder={intl.formatMessage({
+              id: "term.general-in-studio",
+            })}
+            allowClear={user?.roles?.includes("basic") ? false : true}
+            tooltip={intl.formatMessage({
+              id: "term.fields.channel.tooltip",
+            })}
+            label={intl.formatMessage({
+              id: "term.fields.channel.label",
+            })}
+          />
+          <ProFormDependency name={["copy_channel"]}>
+            {({ copy_channel }) => {
+              const hasChannel = copy_channel
+                ? copy_channel.length === 0 || copy_channel[0] === ""
+                  ? false
+                  : true
+                : false;
+              return (
+                <LangSelect
+                  name="copy_lang"
+                  disabled={hasChannel}
+                  required={isSaveAs && !hasChannel}
+                />
+              );
+            }}
+          </ProFormDependency>
+        </ProForm.Group>
+        <ProForm.Group style={{ display: isSaveAs ? "block" : "none" }}>
+          <ProFormCheckbox disabled name="pr">
+            同时提交修改建议
+          </ProFormCheckbox>
+        </ProForm.Group>
+      </ProForm>
+    </>
+  );
+};
+
+export default TermEditWidget;

+ 70 - 0
dashboard-v6/src/components/term/TermExport.tsx

@@ -0,0 +1,70 @@
+import { useState } from "react";
+import { useIntl } from "react-intl";
+import { Button, message } from "antd";
+import { ExportOutlined } from "@ant-design/icons";
+import modal from "antd/lib/modal";
+
+import { get } from "../../request";
+
+interface IExportResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    uuid: string;
+    filename: string;
+    type: string;
+  };
+}
+interface IWidget {
+  channelId?: string;
+  studioName?: string;
+}
+const TermExportWidget = ({ channelId, studioName }: IWidget) => {
+  const intl = useIntl();
+  const [loading, setLoading] = useState(false);
+  return (
+    <Button
+      loading={loading}
+      icon={<ExportOutlined />}
+      onClick={() => {
+        let url = `/api/v2/terms-export?view=`;
+        if (typeof channelId !== "undefined") {
+          url += `channel&id=${channelId}`;
+        } else if (typeof studioName !== "undefined") {
+          url += `studio&name=${studioName}`;
+        }
+        setLoading(true);
+        get<IExportResponse>(url)
+          .then((json) => {
+            if (json.ok) {
+              const link = `${import.meta.env.BASE_URL}/api/api/v2/terms-export/${json.data.uuid}`;
+              modal.info({
+                title: intl.formatMessage({ id: "buttons.download" }),
+                content: (
+                  <>
+                    <a
+                      href={link}
+                      target="_blank"
+                      key="export"
+                      rel="noreferrer"
+                    >
+                      {intl.formatMessage({ id: "buttons.download.link" })}
+                    </a>
+                  </>
+                ),
+              });
+            } else {
+              message.error(json.message);
+            }
+          })
+          .finally(() => {
+            setLoading(false);
+          });
+      }}
+    >
+      {intl.formatMessage({ id: "buttons.export" })}
+    </Button>
+  );
+};
+
+export default TermExportWidget;

+ 137 - 0
dashboard-v6/src/components/term/TermItem.tsx

@@ -0,0 +1,137 @@
+import { Button, Card, Dropdown, Space, Typography } from "antd";
+import {
+  MoreOutlined,
+  EditOutlined,
+  TranslationOutlined,
+} from "@ant-design/icons";
+
+import type { ITerm, ITermDataResponse } from "../../api/Term";
+
+import TimeShow from "../general/TimeShow";
+import TermModal from "./TermModal";
+import { useEffect, useState } from "react";
+import StudioName from "../auth/Studio";
+import { Link, useNavigate } from "react-router";
+import { useAppSelector } from "../../hooks";
+import { click, clickedTerm } from "../../reducers/term-click";
+import store from "../../store";
+import "../article/article.css";
+import Discussion from "../discussion/DiscussionMock";
+import { useIntl } from "react-intl";
+import User from "../auth/User";
+import MdView from "../general/MdView";
+
+const { Text } = Typography;
+
+interface IWidget {
+  data?: ITermDataResponse;
+  onTermClick?: (clicked: ITerm) => void;
+}
+const TermItemWidget = ({ data, onTermClick }: IWidget) => {
+  const [openTermModal, setOpenTermModal] = useState(false);
+  const [showDiscussion, setShowDiscussion] = useState(false);
+  const navigate = useNavigate();
+  const termClicked = useAppSelector(clickedTerm);
+  const intl = useIntl();
+
+  useEffect(() => {
+    console.debug("on redux", termClicked, data);
+    if (!termClicked) {
+      return;
+    }
+    if (termClicked?.channelId === data?.channel?.id) {
+      store.dispatch(click(null));
+      onTermClick?.(termClicked);
+    }
+  }, [data, termClicked]);
+
+  return (
+    <>
+      <Card
+        bodyStyle={{ padding: 8 }}
+        title={
+          <Space orientation="vertical" size={3}>
+            <Space>
+              <Link to={`/term/show/${data?.guid}`}>
+                <Text strong>{data?.meaning}</Text>
+              </Link>
+              <Text type="secondary">{data?.other_meaning}</Text>
+            </Space>
+            <Space style={{ fontSize: "80%" }}>
+              <StudioName data={data?.studio} />
+              {data?.channel
+                ? data.channel.name
+                : intl.formatMessage({
+                    id: "term.general-in-studio",
+                  })}
+              <Text type="secondary">
+                <User {...data?.editor} showAvatar={false} />
+              </Text>
+              <TimeShow type="secondary" updatedAt={data?.updated_at} />
+            </Space>
+          </Space>
+        }
+        extra={
+          <Dropdown
+            key={1}
+            trigger={["click"]}
+            menu={{
+              items: [
+                {
+                  key: "edit",
+                  label: "edit",
+                  icon: <EditOutlined />,
+                },
+                {
+                  key: "translate",
+                  label: "translate",
+                  icon: <TranslationOutlined />,
+                },
+              ],
+              onClick: (e) => {
+                console.log("click ", e);
+                switch (e.key) {
+                  case "edit":
+                    setOpenTermModal(true);
+                    break;
+                  case "translate":
+                    navigate(`/article/term/${data?.guid}`);
+                    break;
+                  default:
+                    break;
+                }
+              },
+            }}
+            placement="bottomRight"
+          >
+            <Button
+              size="small"
+              shape="circle"
+              icon={<MoreOutlined />}
+            ></Button>
+          </Dropdown>
+        }
+      >
+        <div className="pcd_article">
+          <MdView html={data?.html} />
+        </div>
+        {showDiscussion ? (
+          <Discussion resType="term" resId={data?.guid} />
+        ) : (
+          <div style={{ textAlign: "right" }}>
+            <Button type="link" onClick={() => setShowDiscussion(true)}>
+              纠错
+            </Button>
+          </div>
+        )}
+      </Card>
+      <TermModal
+        id={data?.guid}
+        open={openTermModal}
+        onClose={() => setOpenTermModal(false)}
+      />
+    </>
+  );
+};
+
+export default TermItemWidget;

+ 338 - 0
dashboard-v6/src/components/term/TermList.tsx

@@ -0,0 +1,338 @@
+import { type ActionType, ProTable } from "@ant-design/pro-components";
+import { useIntl } from "react-intl";
+import { Button, Space, Table, Dropdown, Modal, message } from "antd";
+import {
+  ExclamationCircleOutlined,
+  DeleteOutlined,
+  ImportOutlined,
+  PlusOutlined,
+} from "@ant-design/icons";
+
+import type { ITermDeleteRequest, ITermListResponse } from "../../api/Term";
+import { delete_2, get } from "../../request";
+import type { IDeleteResponse } from "../../api/Article";
+import { useRef } from "react";
+import TermExport from "./TermExport";
+
+import TermModal from "./TermModal";
+import { getSorterUrl } from "../../utils";
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
+import type { IChannel } from "../../api/Channel";
+import DataImport from "../general/DataImport";
+
+interface IItem {
+  sn: number;
+  id: string;
+  word: string;
+  tag: string;
+  channel?: IChannel;
+  meaning: string;
+  meaning2: string;
+  note: string | null;
+  updated_at: string;
+}
+
+interface IWidget {
+  studioName?: string;
+  channelId?: string;
+}
+const TermListWidget = ({ studioName, channelId }: IWidget) => {
+  const intl = useIntl();
+  const currUser = useAppSelector(currentUser);
+
+  const showDeleteConfirm = (id: string[], title: string) => {
+    Modal.confirm({
+      icon: <ExclamationCircleOutlined />,
+      title:
+        intl.formatMessage({
+          id: "message.delete.confirm",
+        }) +
+        intl.formatMessage({
+          id: "message.irrevocable",
+        }),
+
+      content: title,
+      okText: intl.formatMessage({
+        id: "buttons.delete",
+      }),
+      okType: "danger",
+      cancelText: intl.formatMessage({
+        id: "buttons.no",
+      }),
+      onOk() {
+        console.log("delete", id);
+        return delete_2<ITermDeleteRequest, IDeleteResponse>(
+          `/api/v2/terms/${id}`,
+          {
+            uuid: true,
+            id: id,
+          }
+        )
+          .then((json) => {
+            if (json.ok) {
+              message.success("删除成功");
+              ref.current?.reload();
+            } else {
+              message.error(json.message);
+            }
+          })
+          .catch((e: unknown) => console.log("Oops errors!", e));
+      },
+    });
+  };
+
+  const ref = useRef<ActionType | null>(null);
+
+  return (
+    <>
+      <ProTable<IItem>
+        actionRef={ref}
+        columns={[
+          {
+            title: intl.formatMessage({
+              id: "term.fields.sn.label",
+            }),
+            dataIndex: "sn",
+            key: "sn",
+            width: 80,
+            search: false,
+          },
+          {
+            title: intl.formatMessage({
+              id: "term.fields.word.label",
+            }),
+            dataIndex: "word",
+            key: "word",
+            tooltip: "单词过长会自动收缩",
+            ellipsis: true,
+          },
+          {
+            title: intl.formatMessage({
+              id: "term.fields.description.label",
+            }),
+            dataIndex: "tag",
+            key: "tag",
+            search: false,
+          },
+          {
+            title: intl.formatMessage({
+              id: "term.fields.channel.label",
+            }),
+            dataIndex: "channel",
+            key: "channel",
+            render(_dom, entity) {
+              return entity.channel?.name;
+            },
+          },
+          {
+            title: intl.formatMessage({
+              id: "term.fields.meaning.label",
+            }),
+            dataIndex: "meaning",
+            key: "meaning",
+          },
+          {
+            title: intl.formatMessage({
+              id: "term.fields.meaning2.label",
+            }),
+            dataIndex: "meaning2",
+            key: "meaning2",
+            tooltip: "意思过长会自动收缩",
+            ellipsis: true,
+          },
+          {
+            title: intl.formatMessage({
+              id: "term.fields.note.label",
+            }),
+            dataIndex: "note",
+            key: "note",
+            search: false,
+            tooltip: "注释过长会自动收缩",
+            ellipsis: true,
+          },
+          {
+            title: intl.formatMessage({
+              id: "forms.fields.updated-at.label",
+            }),
+            key: "updated_at",
+            width: 200,
+            search: false,
+            dataIndex: "updated_at",
+            valueType: "date",
+            sorter: true,
+          },
+          {
+            title: intl.formatMessage({ id: "buttons.option" }),
+            key: "option",
+            width: 120,
+            valueType: "option",
+            render: (_text, row, index) => {
+              return [
+                <Dropdown.Button
+                  key={index}
+                  type="link"
+                  menu={{
+                    items: [
+                      {
+                        key: "remove",
+                        label: intl.formatMessage({
+                          id: "buttons.delete",
+                        }),
+                        icon: <DeleteOutlined />,
+                        danger: true,
+                      },
+                    ],
+                    onClick: (e) => {
+                      switch (e.key) {
+                        case "remove":
+                          showDeleteConfirm([row.id], row.word);
+                          break;
+                        default:
+                          break;
+                      }
+                    },
+                  }}
+                >
+                  <TermModal
+                    trigger={intl.formatMessage({
+                      id: "buttons.edit",
+                    })}
+                    id={row.id}
+                    studioName={studioName}
+                    channelId={channelId}
+                    onUpdate={() => ref.current?.reload()}
+                  />
+                </Dropdown.Button>,
+              ];
+            },
+          },
+        ]}
+        rowSelection={{
+          // 自定义选择项参考: https://ant.design/components/table-cn/#components-table-demo-row-selection-custom
+          // 注释该行则默认不显示下拉选项
+          selections: [Table.SELECTION_ALL, Table.SELECTION_INVERT],
+        }}
+        tableAlertRender={({ selectedRowKeys, onCleanSelected }) => (
+          <Space size={24}>
+            <span>
+              {intl.formatMessage({ id: "buttons.selected" })}
+              {selectedRowKeys.length}
+              <Button
+                type="link"
+                style={{ marginInlineStart: 8 }}
+                onClick={onCleanSelected}
+              >
+                {intl.formatMessage({ id: "buttons.unselect" })}
+              </Button>
+            </span>
+          </Space>
+        )}
+        tableAlertOptionRender={({ selectedRowKeys, onCleanSelected }) => {
+          return (
+            <Space size={16}>
+              <Button
+                type="link"
+                onClick={() => {
+                  console.log(selectedRowKeys);
+                  showDeleteConfirm(
+                    selectedRowKeys.map((item) => item.toString()),
+                    selectedRowKeys.length + "个单词"
+                  );
+                  onCleanSelected();
+                }}
+              >
+                批量删除
+              </Button>
+            </Space>
+          );
+        }}
+        request={async (params = {}, sorter, filter) => {
+          console.log(params, sorter, filter);
+          const offset =
+            ((params.current ? params.current : 1) - 1) *
+            (params.pageSize ? params.pageSize : 20);
+          let url = `/api/v2/terms?`;
+          if (typeof channelId === "string") {
+            url += `view=channel&id=${channelId}`;
+          } else {
+            url += `view=studio&name=${studioName}`;
+          }
+
+          url += `&limit=${params.pageSize}&offset=${offset}`;
+          if (typeof params.keyword !== "undefined") {
+            url += "&search=" + (params.keyword ? params.keyword : "");
+          }
+          url += getSorterUrl(sorter);
+          const res = await get<ITermListResponse>(url);
+          console.log(res);
+          const items: IItem[] = res.data.rows.map((item, id) => {
+            return {
+              sn: id + offset + 1,
+              id: item.guid,
+              word: item.word,
+              tag: item.tag,
+              channel: item.channel,
+              meaning: item.meaning,
+              meaning2: item.other_meaning,
+              note: item.note,
+              updated_at: item.updated_at,
+            };
+          });
+          return {
+            total: res.data.count,
+            success: true,
+            data: items,
+          };
+        }}
+        rowKey="id"
+        //bordered
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+        }}
+        toolBarRender={() => [
+          <DataImport
+            url="/api/v2/terms-import"
+            urlExtra={
+              channelId
+                ? `view=channel&id=${channelId}`
+                : `view=studio&name=${studioName}`
+            }
+            trigger={
+              <Button icon={<ImportOutlined />}>
+                {intl.formatMessage({ id: "buttons.import" })}
+              </Button>
+            }
+            onSuccess={() => {
+              ref.current?.reload();
+            }}
+          />,
+          <TermExport channelId={channelId} studioName={studioName} />,
+          <TermModal
+            trigger={
+              <Button
+                key="button"
+                icon={<PlusOutlined />}
+                type="primary"
+                disabled={currUser?.roles?.includes("basic")}
+              >
+                {intl.formatMessage({ id: "buttons.create" })}
+              </Button>
+            }
+            studioName={studioName}
+            channelId={channelId}
+            onUpdate={() => ref.current?.reload()}
+          />,
+        ]}
+        search={false}
+        options={{
+          search: true,
+        }}
+        dateFormatter="string"
+      />
+    </>
+  );
+};
+
+export default TermListWidget;

+ 61 - 3
dashboard-v6/src/components/term/TermModal.tsx

@@ -1,4 +1,8 @@
+import { Modal, Space } from "antd";
+import { Link } from "react-router";
+import TermEdit from "./TermEdit";
 import type { ITermDataResponse } from "../../api/Term";
+import useMergedState from "../../hooks/useMergedState"; // 确保路径正确
 
 interface IWidget {
   trigger?: React.ReactNode;
@@ -14,12 +18,66 @@ interface IWidget {
   onUpdate?: (value: ITermDataResponse) => void;
   onClose?: () => void;
 }
-export const TermModalMock = ({ open, trigger }: IWidget) => {
-  console.debug("TermModalMock", open);
+
+const TermModalWidget = (props: IWidget) => {
+  const {
+    trigger,
+    open: propsOpen,
+    onUpdate,
+    onClose,
+    studioName,
+    ...restEditProps // 其余属性透传给 TermEdit
+  } = props;
+
+  // 统一管理状态:优先使用 props.open,否则使用内部 state
+  const [isModalOpen, setIsModalOpen] = useMergedState(false, {
+    value: propsOpen,
+    onChange: (val) => {
+      if (!val) onClose?.();
+    },
+  });
+
+  const close = () => setIsModalOpen(false);
+  const show = () => setIsModalOpen(true);
 
   return (
     <>
-      <span>{trigger}</span>
+      {trigger && <span onClick={show}>{trigger}</span>}
+
+      <Modal
+        style={{ top: 20 }}
+        width={760}
+        title={
+          <Space>
+            <span>术语</span>
+            {studioName && (
+              <Link
+                to={`/workspace/editor/wiki/${restEditProps.id}`}
+                target="_blank"
+                style={{ fontSize: "12px", fontWeight: "normal" }}
+              >
+                在 Studio 中打开
+              </Link>
+            )}
+          </Space>
+        }
+        footer={null}
+        mask={{ closable: false }}
+        destroyOnHidden // 关闭时销毁内部组件,防止数据残留
+        open={isModalOpen}
+        onCancel={close}
+      >
+        <TermEdit
+          {...restEditProps}
+          studioName={studioName}
+          onUpdate={(value: ITermDataResponse) => {
+            close();
+            onUpdate?.(value);
+          }}
+        />
+      </Modal>
     </>
   );
 };
+
+export default TermModalWidget;

+ 119 - 0
dashboard-v6/src/components/term/TermSearch.tsx

@@ -0,0 +1,119 @@
+import { useEffect, useState } from "react";
+import { Col, Row, Skeleton, Typography } from "antd";
+
+import { get } from "../../request";
+import type {
+  ITerm,
+  ITermDataResponse,
+  ITermListResponse,
+  ITermResponse,
+} from "../../api/Term";
+import TermItem from "./TermItem";
+
+const { Title } = Typography;
+
+interface IWidget {
+  word?: string;
+  wordId?: string;
+  compact?: boolean;
+  onIdChange?: (id: string) => void;
+}
+
+const TermSearchWidget = ({
+  word,
+  wordId,
+  compact = false,
+  onIdChange,
+}: IWidget) => {
+  const [tableData, setTableData] = useState<ITermDataResponse[]>([]);
+  const [loading, setLoading] = useState(false);
+
+  useEffect(() => {
+    if (!word && !wordId) return;
+
+    let cancelled = false;
+
+    const fetchData = async () => {
+      setLoading(true);
+
+      try {
+        if (word && word.length > 0) {
+          const url = `/api/v2/terms?view=word&word=${word}`;
+          console.info("term url", url);
+
+          const json = await get<ITermListResponse>(url);
+
+          if (!cancelled) {
+            setTableData(json.data.rows);
+          }
+        } else if (wordId && wordId.length > 0) {
+          const url = `/api/v2/terms/${wordId}`;
+          console.info("term url", url);
+
+          const json = await get<ITermResponse>(url);
+
+          if (!cancelled) {
+            setTableData([json.data]);
+          }
+        }
+      } catch (error) {
+        console.error(error);
+      } finally {
+        if (!cancelled) setLoading(false);
+      }
+    };
+
+    fetchData();
+
+    return () => {
+      cancelled = true;
+    };
+  }, [word, wordId]);
+
+  const handleTermClick = (value: ITerm) => {
+    if (!value.id) return;
+
+    if (onIdChange) {
+      onIdChange(value.id);
+    } else {
+      // 内部加载
+      (async () => {
+        setLoading(true);
+        try {
+          const json = await get<ITermResponse>(`/api/v2/terms/${value.id}`);
+          setTableData([json.data]);
+        } finally {
+          setLoading(false);
+        }
+      })();
+    }
+  };
+
+  return (
+    <Row>
+      <Col flex="200px">{!compact && null}</Col>
+
+      <Col flex="760px">
+        <Title level={4}>{word}</Title>
+
+        {loading ? (
+          <Skeleton active />
+        ) : (
+          <div>
+            {tableData.map((item) => (
+              <TermItem
+                key={item.id}
+                data={item}
+                onTermClick={handleTermClick}
+              />
+            ))}
+          </div>
+        )}
+      </Col>
+
+      <Col flex="200px" />
+    </Row>
+  );
+};
+
+export default TermSearchWidget;

+ 93 - 0
dashboard-v6/src/components/term/TermShow.tsx

@@ -0,0 +1,93 @@
+import { useState } from "react";
+import { Layout, Affix, Col, Row } from "antd";
+
+import TermSearch from "./TermSearch";
+import SearchVocabulary from "../dict/SearchVocabulary";
+
+const { Content } = Layout;
+
+interface IWidget {
+  word?: string;
+  wordId?: string;
+  compact?: boolean;
+  hideInput?: boolean;
+  onSearch?: (value: string, isFactor?: boolean) => void;
+  onIdChange?: (value: string) => void;
+}
+
+const TermShowWidget = ({
+  word,
+  wordId,
+  compact = false,
+  hideInput = false,
+  onSearch,
+  onIdChange,
+}: IWidget) => {
+  const [localWord, setLocalWord] = useState<string | undefined>(
+    word?.toLowerCase()
+  );
+  const [container, setContainer] = useState<HTMLDivElement | null>(null);
+
+  const [prevWord, setPrevWord] = useState<string | undefined>(undefined);
+
+  // 用 state 追踪 prop 变化(React 官方推荐的派生 state 写法)
+  if (word !== prevWord) {
+    setPrevWord(word);
+    setLocalWord(word?.toLowerCase());
+  }
+  // 直接从 prop 派生,无需 useEffect
+  const wordSearch = localWord;
+
+  const dictSearch = (value: string, isFactor?: boolean) => {
+    console.log("onSearch", value);
+    setLocalWord(value.toLowerCase());
+    onSearch?.(value, isFactor);
+  };
+
+  return (
+    <div ref={setContainer}>
+      {!hideInput && (
+        <Affix offsetTop={0} target={compact ? () => container : undefined}>
+          <div
+            style={{
+              backgroundColor: "rgba(100,100,100,0.3)",
+              backdropFilter: "blur(5px)",
+            }}
+          >
+            <Row style={{ paddingTop: "0.5em", paddingBottom: "0.5em" }}>
+              {!compact && <Col flex="auto" />}
+              <Col flex="560px">
+                <SearchVocabulary
+                  key={word}
+                  value={word}
+                  onSearch={dictSearch}
+                />
+              </Col>
+              {!compact && <Col flex="auto" />}
+            </Row>
+          </div>
+        </Affix>
+      )}
+
+      <Content style={{ minHeight: 700 }}>
+        <Row>
+          {!compact && <Col flex="auto" />}
+          <Col flex="1260px">
+            <TermSearch
+              word={wordSearch}
+              wordId={wordId}
+              compact={compact}
+              onIdChange={(value: string) => {
+                console.debug("term onIdChange", value);
+                onIdChange?.(value);
+              }}
+            />
+          </Col>
+          {!compact && <Col flex="auto" />}
+        </Row>
+      </Content>
+    </div>
+  );
+};
+
+export default TermShowWidget;

+ 544 - 0
dashboard-v6/src/components/term/TermTest.tsx

@@ -0,0 +1,544 @@
+import React, { useState } from "react";
+import {
+  Badge,
+  Button,
+  Card,
+  Col,
+  Row,
+  Space,
+  Switch,
+  Table,
+  Tag,
+  Typography,
+} from "antd";
+import {
+  BookOutlined,
+  EditOutlined,
+  GlobalOutlined,
+  PlusOutlined,
+} from "@ant-design/icons";
+
+// ─── 导入被测试的真实组件 ──────────────────────────────────────────────────────
+import TermModal from "./TermModal";
+import TermEdit from "./TermEdit";
+import type { ITermDataResponse } from "../../api/Term";
+import type { IChannel } from "../../api/Channel";
+
+const { Title, Text, Paragraph } = Typography;
+
+// ─── Mock 数据 ─────────────────────────────────────────────────────────────────
+
+const mockStudio = {
+  id: "studio-001",
+  nickName: "语言工作室",
+  studioName: "lang-studio",
+  realName: "Language Studio",
+  avatar: "https://api.dicebear.com/7.x/bottts/svg?seed=studio",
+  roles: ["owner"],
+};
+
+const mockChannel: IChannel = {
+  id: "channel-001",
+  name: "英语词汇",
+  type: "translation",
+  lang: "en",
+};
+
+const mockEditor = {
+  id: "user-001",
+  nickName: "张三",
+  userName: "zhangsan",
+  roles: ["editor"],
+};
+
+const MOCK_TERMS: ITermDataResponse[] = [
+  {
+    id: 1,
+    guid: "term-guid-001",
+    word: "ephemeral",
+    tag: "adjective",
+    meaning: "短暂的;转瞬即逝的",
+    other_meaning: "瞬间的,昙花一现的",
+    note: "常用于描述事物的短暂存在,如 ephemeral beauty(短暂的美丽)",
+    html: "<p>短暂的;转瞬即逝的</p>",
+    channal: "channel-001",
+    channel: mockChannel,
+    studio: mockStudio,
+    editor: mockEditor,
+    role: "editor",
+    exp: 85,
+    language: "en",
+    community: false,
+    summary: "形容某事物存在时间极短,很快就消逝的状态。",
+    summary_is_community: false,
+    created_at: "2024-01-15T08:30:00Z",
+    updated_at: "2024-03-10T14:22:00Z",
+  },
+  {
+    id: 2,
+    guid: "term-guid-002",
+    word: "serendipity",
+    tag: "noun",
+    meaning: "意外发现美好事物的运气;机缘巧合",
+    other_meaning: "奇缘,幸运的意外发现",
+    note: "源自波斯童话《三个锡兰王子》,常用于形容偶然发现好东西的惊喜",
+    channal: "channel-001",
+    channel: mockChannel,
+    studio: mockStudio,
+    editor: mockEditor,
+    role: "editor",
+    exp: 92,
+    language: "en",
+    community: true,
+    summary: "指在寻找某物时意外发现更有价值事物的幸运能力。",
+    summary_is_community: true,
+    created_at: "2024-02-01T10:00:00Z",
+    updated_at: "2024-03-12T09:15:00Z",
+  },
+  {
+    id: 3,
+    guid: "term-guid-003",
+    word: "倔强",
+    tag: "adjective",
+    meaning: "固执而不肯屈服;刚强",
+    other_meaning: "坚定,不服软",
+    note: "含有褒义,形容人意志坚定,不轻易妥协",
+    channal: "channel-002",
+    studio: mockStudio,
+    editor: mockEditor,
+    role: "manager",
+    exp: 78,
+    language: "zh",
+    community: false,
+    created_at: "2024-01-20T16:45:00Z",
+    updated_at: "2024-03-08T11:30:00Z",
+  },
+];
+
+// ─── 日志条目类型 ───────────────────────────────────────────────────────────────
+
+interface ILogEntry {
+  time: string;
+  scene: string;
+  type: "onUpdate" | "onClose";
+  data?: ITermDataResponse;
+}
+
+// ─── TermTest ─────────────────────────────────────────────────────────────────
+
+const TermTest: React.FC = () => {
+  // 场景二:受控模式
+  const [controlledOpen, setControlledOpen] = useState(false);
+  const [useControlled, setUseControlled] = useState(false);
+
+  // 日志
+  const [logs, setLogs] = useState<ILogEntry[]>([]);
+
+  const pushLog = (
+    scene: string,
+    type: ILogEntry["type"],
+    data?: ITermDataResponse
+  ) => {
+    setLogs((prev) => [
+      { time: new Date().toLocaleTimeString("zh-CN"), scene, type, data },
+      ...prev.slice(0, 19),
+    ]);
+  };
+
+  // 场景三:词条列表(onUpdate 同步更新本地数据)
+  const [terms, setTerms] = useState<ITermDataResponse[]>(MOCK_TERMS);
+
+  const handleTermUpdate = (scene: string) => (value: ITermDataResponse) => {
+    pushLog(scene, "onUpdate", value);
+    setTerms((prev) => {
+      const idx = prev.findIndex((t) => t.id === value.id);
+      if (idx >= 0) {
+        const next = [...prev];
+        next[idx] = value;
+        return next;
+      }
+      return [value, ...prev];
+    });
+  };
+
+  // 场景三:表格列
+  const columns = [
+    {
+      title: "词条",
+      dataIndex: "word",
+      key: "word",
+      render: (word: string, record: ITermDataResponse) => (
+        <Space>
+          <Text strong>{word}</Text>
+          <Tag>{record.tag}</Tag>
+          {record.community && <Tag color="blue">社区</Tag>}
+        </Space>
+      ),
+    },
+    {
+      title: "释义",
+      dataIndex: "meaning",
+      key: "meaning",
+      render: (meaning: string, record: ITermDataResponse) => (
+        <div>
+          <div>{meaning}</div>
+          {record.other_meaning && (
+            <Text type="secondary" style={{ fontSize: 12 }}>
+              {record.other_meaning}
+            </Text>
+          )}
+        </div>
+      ),
+    },
+    {
+      title: "语言",
+      dataIndex: "language",
+      key: "language",
+      render: (lang: string) => (
+        <Tag color={lang === "en" ? "geekblue" : "green"}>
+          {lang.toUpperCase()}
+        </Tag>
+      ),
+    },
+    {
+      title: "操作",
+      key: "action",
+      render: (_: unknown, record: ITermDataResponse) => (
+        // 多实例并存,验证互不干扰
+        <TermModal
+          trigger={
+            <Button size="small" icon={<EditOutlined />} type="link">
+              编辑
+            </Button>
+          }
+          id={record.guid}
+          word={record.word}
+          studioName={mockStudio.studioName}
+          channelId={record.channal}
+          community={record.community}
+          onUpdate={handleTermUpdate(`场景三·编辑「${record.word}」`)}
+          onClose={() => pushLog(`场景三·「${record.word}」`, "onClose")}
+        />
+      ),
+    },
+  ];
+
+  return (
+    <div style={{ padding: 32, background: "#f5f6fa", minHeight: "100vh" }}>
+      <title>TermTest — 组件集成测试页</title>
+      {/* 页头 */}
+      <div style={{ marginBottom: 32 }}>
+        <Title level={2} style={{ marginBottom: 4 }}>
+          <BookOutlined style={{ marginRight: 10, color: "#1677ff" }} />
+          TermTest — 组件集成测试页
+        </Title>
+        <Text type="secondary">
+          直接测试真实的 <code>TermModal</code> 与 <code>TermEdit</code>{" "}
+          组件,所有回调输出记录在右侧日志区
+        </Text>
+      </div>
+
+      <Row gutter={[24, 24]}>
+        {/* ── 左侧:测试场景区 ── */}
+        <Col span={24} xl={16}>
+          <Row gutter={[16, 16]}>
+            {/* ── 场景一:Trigger 非受控 ── */}
+            <Col span={24}>
+              <Card
+                title={
+                  <Space>
+                    <Badge color="blue" />
+                    场景一:Trigger 触发(非受控)
+                  </Space>
+                }
+                extra={<Tag color="blue">uncontrolled</Tag>}
+              >
+                <Paragraph
+                  type="secondary"
+                  style={{ fontSize: 13, marginBottom: 16 }}
+                >
+                  通过 <code>trigger</code> prop 触发弹窗,内部管理 open 状态。
+                  覆盖:新建(无 id)、编辑(有 id + word)、社区词条、无
+                  studioName。
+                </Paragraph>
+                <Space wrap>
+                  {/* 1-A:新建,不传 id */}
+                  <TermModal
+                    trigger={
+                      <Button type="primary" icon={<PlusOutlined />}>
+                        新建词条(无 id)
+                      </Button>
+                    }
+                    studioName={mockStudio.studioName}
+                    channelId="channel-001"
+                    onUpdate={handleTermUpdate("场景一A·新建")}
+                    onClose={() => pushLog("场景一A·新建", "onClose")}
+                  />
+
+                  {/* 1-B:编辑,传入 id + word */}
+                  <TermModal
+                    trigger={
+                      <Button icon={<EditOutlined />}>
+                        编辑词条(有 id + word)
+                      </Button>
+                    }
+                    id={MOCK_TERMS[0].guid}
+                    word={MOCK_TERMS[0].word}
+                    studioName={mockStudio.studioName}
+                    channelId={MOCK_TERMS[0].channal}
+                    onUpdate={handleTermUpdate("场景一B·编辑")}
+                    onClose={() => pushLog("场景一B·编辑", "onClose")}
+                  />
+
+                  {/* 1-C:社区词条 */}
+                  <TermModal
+                    trigger={
+                      <Button type="dashed" icon={<GlobalOutlined />}>
+                        社区词条(community)
+                      </Button>
+                    }
+                    word="ubiquitous"
+                    studioName={mockStudio.studioName}
+                    community={true}
+                    onUpdate={handleTermUpdate("场景一C·社区")}
+                    onClose={() => pushLog("场景一C·社区", "onClose")}
+                  />
+
+                  {/* 1-D:不传 studioName,验证 Studio 链接不渲染 */}
+                  <TermModal
+                    trigger={<Button>无 studioName</Button>}
+                    channelId="channel-001"
+                    onUpdate={handleTermUpdate("场景一D·无studioName")}
+                    onClose={() => pushLog("场景一D·无studioName", "onClose")}
+                  />
+                </Space>
+              </Card>
+            </Col>
+
+            {/* ── 场景二:受控模式 ── */}
+            <Col span={24}>
+              <Card
+                title={
+                  <Space>
+                    <Badge color="purple" />
+                    场景二:受控模式(open prop)
+                  </Space>
+                }
+                extra={<Tag color="purple">controlled</Tag>}
+              >
+                <Paragraph
+                  type="secondary"
+                  style={{ fontSize: 13, marginBottom: 16 }}
+                >
+                  外部通过 <code>open</code> prop 控制弹窗,<code>onClose</code>{" "}
+                  负责收起。先开启开关,再点「外部打开」按钮。
+                </Paragraph>
+                <Space align="center" style={{ marginBottom: 12 }}>
+                  <Switch
+                    checked={useControlled}
+                    onChange={(val) => {
+                      setUseControlled(val);
+                      if (!val) setControlledOpen(false);
+                    }}
+                    checkedChildren="受控已开"
+                    unCheckedChildren="受控已关"
+                  />
+                  <Button
+                    type="primary"
+                    ghost
+                    disabled={!useControlled}
+                    onClick={() => setControlledOpen(true)}
+                  >
+                    外部打开 Modal
+                  </Button>
+                  <Text type="secondary" style={{ fontSize: 12 }}>
+                    open 值:
+                    <code>
+                      {useControlled ? String(controlledOpen) : "— (undefined)"}
+                    </code>
+                  </Text>
+                </Space>
+
+                {/* 受控模式:不传 trigger,open 由外部控制 */}
+                <TermModal
+                  open={useControlled ? controlledOpen : undefined}
+                  studioName={mockStudio.studioName}
+                  channelId="channel-002"
+                  word={MOCK_TERMS[1].word}
+                  id={MOCK_TERMS[1].guid}
+                  onUpdate={handleTermUpdate("场景二·受控编辑")}
+                  onClose={() => {
+                    setControlledOpen(false);
+                    pushLog("场景二·受控", "onClose");
+                  }}
+                />
+              </Card>
+            </Col>
+
+            {/* ── 场景三:列表行内触发,多实例 ── */}
+            <Col span={24}>
+              <Card
+                title={
+                  <Space>
+                    <Badge color="green" />
+                    场景三:列表行内编辑触发(多实例并存)
+                  </Space>
+                }
+                extra={
+                  <Badge
+                    count={terms.length}
+                    style={{ backgroundColor: "#52c41a" }}
+                  />
+                }
+              >
+                <Paragraph
+                  type="secondary"
+                  style={{ fontSize: 13, marginBottom: 16 }}
+                >
+                  每行独立的 <code>TermModal</code>,验证多实例互不干扰。
+                  <code>onUpdate</code> 会同步更新本地词条列表。
+                </Paragraph>
+                <Table
+                  dataSource={terms}
+                  columns={columns}
+                  rowKey="guid"
+                  size="middle"
+                  pagination={false}
+                />
+              </Card>
+            </Col>
+
+            {/* ── 场景四:TermEdit 独立使用 ── */}
+            <Col span={24}>
+              <Card
+                title={
+                  <Space>
+                    <Badge color="orange" />
+                    场景四:TermEdit 独立嵌入(无 Modal 包裹)
+                  </Space>
+                }
+                extra={<Tag color="orange">TermEdit standalone</Tag>}
+              >
+                <Paragraph
+                  type="secondary"
+                  style={{ fontSize: 13, marginBottom: 16 }}
+                >
+                  直接渲染 <code>TermEdit</code>,不经过
+                  Modal,验证其独立可用性。
+                </Paragraph>
+                <TermEdit
+                  studioName={mockStudio.studioName}
+                  channelId="channel-001"
+                  parentChannelId="parent-channel-001"
+                  parentStudioId={mockStudio.id}
+                  onUpdate={handleTermUpdate("场景四·独立TermEdit")}
+                />
+              </Card>
+            </Col>
+          </Row>
+        </Col>
+
+        {/* ── 右侧:回调日志 ── */}
+        <Col span={24} xl={8}>
+          <Card
+            title="回调日志"
+            style={{ position: "sticky", top: 24 }}
+            extra={
+              <Button
+                size="small"
+                type="text"
+                danger
+                disabled={logs.length === 0}
+                onClick={() => setLogs([])}
+              >
+                清空
+              </Button>
+            }
+            styles={{
+              body: {
+                padding: "12px 16px",
+                maxHeight: "80vh",
+                overflowY: "auto",
+              },
+            }}
+          >
+            {logs.length === 0 ? (
+              <div
+                style={{
+                  textAlign: "center",
+                  padding: "48px 0",
+                  color: "#bbb",
+                }}
+              >
+                <BookOutlined
+                  style={{ fontSize: 28, display: "block", marginBottom: 8 }}
+                />
+                <Text type="secondary">操作上方组件后此处显示回调</Text>
+              </div>
+            ) : (
+              <Space direction="vertical" style={{ width: "100%" }} size={8}>
+                {logs.map((log, i) => (
+                  <div
+                    key={i}
+                    style={{
+                      border: `1px solid ${
+                        log.type === "onUpdate" ? "#b7eb8f" : "#ffe7ba"
+                      }`,
+                      borderRadius: 6,
+                      padding: "8px 10px",
+                      background:
+                        log.type === "onUpdate" ? "#f6ffed" : "#fff7e6",
+                      fontSize: 12,
+                    }}
+                  >
+                    <div style={{ marginBottom: 4 }}>
+                      <Tag
+                        color={log.type === "onUpdate" ? "success" : "warning"}
+                        style={{ fontSize: 11 }}
+                      >
+                        {log.type}
+                      </Tag>
+                      <Text type="secondary" style={{ fontSize: 11 }}>
+                        {log.time}
+                      </Text>
+                      <Text
+                        strong
+                        style={{
+                          display: "block",
+                          marginTop: 2,
+                          color: "#444",
+                        }}
+                      >
+                        {log.scene}
+                      </Text>
+                    </div>
+                    {log.data && (
+                      <pre
+                        style={{
+                          margin: 0,
+                          background: "#1e1e2e",
+                          color: "#cdd6f4",
+                          borderRadius: 4,
+                          padding: "6px 8px",
+                          fontSize: 11,
+                          lineHeight: 1.5,
+                          overflowX: "auto",
+                          maxHeight: 160,
+                          overflowY: "auto",
+                        }}
+                      >
+                        {JSON.stringify(log.data, null, 2)}
+                      </pre>
+                    )}
+                  </div>
+                ))}
+              </Space>
+            )}
+          </Card>
+        </Col>
+      </Row>
+    </div>
+  );
+};
+
+export default TermTest;

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

@@ -0,0 +1,26 @@
+import type { IGrammarRecent } from "./GrammarRecent";
+
+const maxRecent = 10;
+export const storeKey = "grammar-handbook/recent";
+
+export const popRecent = (): IGrammarRecent | null => {
+  const old = localStorage.getItem(storeKey);
+  if (old) {
+    const recentList = JSON.parse(old);
+    const top = recentList.shift();
+    localStorage.setItem(storeKey, JSON.stringify(recentList));
+    return top;
+  } else {
+    return null;
+  }
+};
+
+export const pushRecent = (value: IGrammarRecent) => {
+  const old = localStorage.getItem(storeKey);
+  if (old) {
+    const newRecent = [value, ...JSON.parse(old)].slice(0, maxRecent - 1);
+    localStorage.setItem(storeKey, JSON.stringify(newRecent));
+  } else {
+    localStorage.setItem(storeKey, JSON.stringify([value]));
+  }
+};

+ 56 - 0
dashboard-v6/src/components/theme/ThemeSwitch.tsx

@@ -0,0 +1,56 @@
+// src/components/ThemeSwitch.tsx
+import { Dropdown, type MenuProps } from "antd";
+import {
+  DesktopOutlined,
+  SunOutlined,
+  MoonOutlined,
+  CheckOutlined,
+} from "@ant-design/icons";
+import { useAppSelector } from "../../hooks";
+import {
+  mode as _mode,
+  themeChange,
+  type TThemeMode,
+} from "../../reducers/theme";
+import store from "../../store";
+
+const icons = {
+  system: <DesktopOutlined />,
+  light: <SunOutlined />,
+  dark: <MoonOutlined />,
+};
+
+const labels = {
+  system: "跟随系统",
+  light: "亮色主题",
+  dark: "暗色主题",
+};
+
+const ThemeSwitch = () => {
+  const themeMode = useAppSelector(_mode);
+
+  const items: MenuProps["items"] = (
+    ["system", "light", "dark"] as TThemeMode[]
+  ).map((key) => ({
+    key,
+    icon: icons[key],
+    label: labels[key],
+    itemIcon: themeMode === key ? <CheckOutlined /> : null,
+  }));
+
+  return (
+    <Dropdown
+      menu={{
+        items,
+        onClick: ({ key }) => store.dispatch(themeChange(key as TThemeMode)),
+      }}
+      trigger={["click"]}
+    >
+      <span style={{ cursor: "pointer", fontSize: 18 }}>
+        {icons[themeMode]}
+      </span>
+    </Dropdown>
+  );
+};
+
+export default ThemeSwitch;

+ 2 - 1
dashboard-v6/src/components/tipitaka/PaliChapterChannelList.tsx

@@ -1,9 +1,10 @@
 import { useState, useEffect } from "react";
 
 import { get } from "../../request";
-import type { IChapterChannelListResponse } from "../../api/Corpus";
+
 import type { IChapter } from "./BookViewer";
 import ChapterInChannel, { type IChapterChannelData } from "./ChapterInChannel";
+import type { IChapterChannelListResponse } from "../../api/progress";
 
 interface IWidget {
   para: IChapter;

+ 4 - 1
dashboard-v6/src/components/tipitaka/PaliChapterHead.tsx

@@ -1,7 +1,10 @@
 import { useState, useEffect } from "react";
 import { message } from "antd";
 
-import type { IApiResponsePaliChapter, ITocPathNode } from "../../api/Corpus";
+import type {
+  IApiResponsePaliChapter,
+  ITocPathNode,
+} from "../../api/pali-text";
 import { get } from "../../request";
 
 import type { IChapter } from "./BookViewer";

+ 1 - 1
dashboard-v6/src/components/tipitaka/PaliChapterListByPara.tsx

@@ -1,7 +1,7 @@
 import { useState, useEffect } from "react";
 
 import { get } from "../../request";
-import type { IPaliChapterListResponse } from "../../api/Corpus";
+import type { IPaliChapterListResponse } from "../../api/pali-text";
 import type { IChapter } from "./BookViewer";
 import type { IPaliChapterData } from "./PaliChapterCard";
 import PaliChapterList, { type IChapterClickEvent } from "./PaliChapterList";

+ 1 - 1
dashboard-v6/src/components/tipitaka/PaliChapterListByTag.tsx

@@ -1,7 +1,7 @@
 import { useState, useEffect } from "react";
 
 import { get } from "../../request";
-import type { IPaliChapterListResponse } from "../../api/Corpus";
+import type { IPaliChapterListResponse } from "../../api/pali-text";
 import type { IPaliChapterData } from "./PaliChapterCard";
 import PaliChapterList, { type IChapterClickEvent } from "./PaliChapterList";
 

+ 1 - 1
dashboard-v6/src/components/tipitaka/TocPath.tsx

@@ -3,7 +3,7 @@ import { Breadcrumb, Popover, Tag, Typography } from "antd";
 
 import React, { type JSX } from "react";
 import { fullUrl } from "../../utils";
-import type { ITocPathNode } from "../../api/Corpus";
+import type { ITocPathNode } from "../../api/pali-text";
 import PaliText from "../general/PaliText";
 
 export declare type ELinkType = "none" | "blank" | "self";

+ 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/Corpus";
+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/Corpus";
+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/Corpus";
+import type { ArticleType } from "../../api/Article";
 import type { TDisplayStyle } from "../../types/template";
 
 interface IWidget {

+ 1 - 1
dashboard-v6/src/components/tpl-builder/Builder.tsx → 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/Corpus";
+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/Corpus";
+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/Corpus";
+import type { ArticleMode } from "../../api/Article";
 import type { ITagMapData } from "../../api/Tag";
 import PaliText from "../general/PaliText";
 import { bookMarkColor } from "./utils";

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

@@ -30,7 +30,8 @@ import {
   type WbwElement,
 } from "../../types/wbw";
 import type { IChannel, TChannelType } from "../../api/Channel";
-import type { ArticleMode, ISentenceWbwListResponse } from "../../api/Corpus";
+import type { ArticleMode } from "../../api/Article";
+import type { ISentenceWbwListResponse } from "../../api/sentence";
 import type { IStudio } from "../../api/Auth";
 import { useWbwStreamProcessor } from "../../hooks/useWbwStreamProcessor";
 import { GetUserSetting } from "../setting/default";

+ 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/Corpus";
+import type { ArticleMode } from "../../api/Article";
 import {
   WbwStatus,
   type IWbw,

+ 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/Corpus";
+import type { ArticleType } from "../../../api/Article";
 
 const typeColor: Record<ArticleType, string> = {
   chapter: "#b5854a",

+ 69 - 0
dashboard-v6/src/hooks/useMergedState.ts

@@ -0,0 +1,69 @@
+import { useState, useCallback } from "react";
+
+/**
+ * useMergedState - 统一管理“受控(Controlled)”与“非受控(Uncontrolled)”状态的 Hook。
+ * * [用途]
+ * 当一个组件需要同时支持:
+ * 1. 内部自管状态(不传 value 时,组件可以自己开关/变化)。
+ * 2. 外部完全掌控状态(传入 value 时,状态由父组件决定,内部调用 set 仅触发 onChange)。
+ * 这种模式常用于 Modal, Input, Select 等通用 UI 组件。
+ * * [参数]
+ * @param defaultStateValue - 默认初始值(非受控模式下使用)。
+ * @param option - 配置项,包含:
+ * - value: 外部传入的受控值。
+ * - onChange: 状态变更时的回调函数。
+ * * [返回值]
+ * @returns [mergedValue, setMergedValue]
+ * - mergedValue: 最终确定的状态值。
+ * - setMergedValue: 更新状态的函数(内部会自动判断是更新本地状态还是仅触发回调)。
+ * * [使用示例]
+ * const [open, setOpen] = useMergedState(false, {
+ * value: props.open,
+ * onChange: props.onOpenChange
+ * });
+ */
+function useMergedState<T>(
+  defaultStateValue: T | (() => T),
+  option?: {
+    value?: T;
+    onChange?: (value: T, prevValue: T) => void;
+  }
+): [T, (value: T) => void] {
+  const { value, onChange } = option || {};
+
+  // 1. 内部状态:用于非受控模式
+  const [innerValue, setInnerValue] = useState<T>(() => {
+    if (value !== undefined) {
+      return value;
+    }
+    return typeof defaultStateValue === "function"
+      ? (defaultStateValue as () => T)()
+      : defaultStateValue;
+  });
+
+  // 2. 决定最终输出的值:如果 value 存在,则为受控模式
+  const mergedValue = value !== undefined ? value : innerValue;
+
+  // 3. 定义更新函数
+  const triggerChange = useCallback(
+    (newValue: T) => {
+      // 如果值没变,不触发更新
+      if (newValue === mergedValue) return;
+
+      // 如果是非受控模式,更新内部状态
+      if (value === undefined) {
+        setInnerValue(newValue);
+      }
+
+      // 无论受控还是非受控,都触发回调通知父组件
+      if (onChange) {
+        onChange(newValue, mergedValue);
+      }
+    },
+    [value, mergedValue, onChange]
+  );
+
+  return [mergedValue, triggerChange];
+}
+
+export default useMergedState;

+ 8 - 5
dashboard-v6/src/layouts/workspace/index.tsx

@@ -5,8 +5,9 @@ import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
 import MainMenu from "../../components/navigation/MainMenu";
 import SignInAvatar from "../../components/auth/SignInAvatar";
 import HeaderBreadcrumb from "../../components/navigation/HeaderBreadcrumb";
+import ThemeSwitch from "../../components/theme/ThemeSwitch";
 
-const { Header, Sider, Content } = Layout;
+const { Sider, Content } = Layout;
 const Widget = () => {
   const [collapsed, setCollapsed] = useState(false);
   return (
@@ -28,18 +29,20 @@ const Widget = () => {
         <MainMenu />
       </Sider>
       <Layout>
-        <Header
+        <div
           style={{
-            backgroundColor: "white",
             padding: "0 24px", // 建议保留左右内边距,否则内容会贴边
             display: "flex",
             alignItems: "center", // 垂直居中
             height: 44,
-            // justifyContent: 'space-between', // 如果需要左右分布(如左侧面包屑,右侧头像)可开启
+            justifyContent: "space-between", // 如果需要左右分布(如左侧面包屑,右侧头像)可开启
           }}
         >
           <HeaderBreadcrumb />
-        </Header>
+          <div>
+            <ThemeSwitch />
+          </div>
+        </div>
 
         <Content style={{ padding: 12 }}>
           <Outlet />

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

@@ -14,7 +14,7 @@ import {
   type ISettingItem,
   refresh as refreshSetting,
 } from "./reducers/setting";
-import { refresh as refreshTheme } from "./reducers/theme";
+import { themeChange, type TThemeMode } from "./reducers/theme";
 import { get } from "./request";
 import { get as getLang } from "./locales";
 
@@ -178,10 +178,10 @@ const init = () => {
 
   //获取用户选择的主题
   const theme = localStorage.getItem("theme");
-  if (theme === "dark") {
-    store.dispatch(refreshTheme("dark"));
+  if (theme) {
+    store.dispatch(themeChange(theme as TThemeMode));
   } else {
-    store.dispatch(refreshTheme("ant"));
+    store.dispatch(themeChange("system"));
   }
 
   //设置时区到cookie

+ 303 - 0
dashboard-v6/src/pages/workspace/chat/index.tsx

@@ -0,0 +1,303 @@
+import { SyncOutlined } from "@ant-design/icons";
+import type { BubbleListProps } from "@ant-design/x";
+import { Bubble, Sender } from "@ant-design/x";
+import XMarkdown from "@ant-design/x-markdown";
+import {
+  OpenAIChatProvider,
+  useXChat,
+  type XModelParams,
+  type XModelResponse,
+  XRequest,
+} from "@ant-design/x-sdk";
+import { Button, Flex, Tooltip } from "antd";
+import React from "react";
+
+/**
+ * 🔔 请替换 BASE_URL、PATH、MODEL、API_KEY 为您自己的值
+ * 🔔 Please replace the BASE_URL, PATH, MODEL, API_KEY with your own values.
+ */
+
+const BASE_URL = "https://api.x.ant.design/api/big_model_glm-4.5-flash";
+
+/**
+ * 🔔 当前请求中 MODEL 是固定的,请替换为您自己的 BASE_URL 和 MODEL
+ * 🔔 The MODEL is fixed in the current request, please replace it with your BASE_URL and MODEL
+ */
+
+const MODEL = "THUDM/glm-4-9b-chat";
+
+// 本地化钩子:根据当前语言环境返回对应的文本
+// Localization hook: return corresponding text based on current language environment
+const useLocale = () => {
+  const isCN =
+    typeof location !== "undefined" ? location.pathname.endsWith("-cn") : false;
+  return {
+    abort: isCN ? "中止" : "abort",
+    addUserMessage: isCN ? "添加用户消息" : "Add a user message",
+    addAIMessage: isCN ? "添加AI消息" : "Add an AI message",
+    addSystemMessage: isCN ? "添加系统消息" : "Add a system message",
+    editLastMessage: isCN ? "编辑最后一条消息" : "Edit the last message",
+    placeholder: isCN
+      ? "请输入内容,按下 Enter 发送消息"
+      : "Please enter content and press Enter to send message",
+    waiting: isCN ? "请稍候..." : "Please wait...",
+    requestFailed: isCN
+      ? "请求失败,请重试!"
+      : "Request failed, please try again!",
+    requestAborted: isCN ? "请求已中止" : "Request is aborted",
+    noMessages: isCN
+      ? "暂无消息,请输入问题并发送"
+      : "No messages yet, please enter a question and send",
+    requesting: isCN ? "请求中" : "Requesting",
+    qaCompleted: isCN ? "问答完成" : "Q&A completed",
+    retry: isCN ? "重试" : "Retry",
+    currentStatus: isCN ? "当前状态:" : "Current status:",
+    historyUserMessage: isCN
+      ? "这是一条历史消息"
+      : "This is a historical message",
+    historyAIResponse: isCN
+      ? "这是一条历史回答消息,请发送新消息。"
+      : "This is a historical response message, please send a new message.",
+    deleteFirstMessage: isCN ? "删除第一条消息" : "Delete the first message",
+  };
+};
+
+// 消息角色配置:定义助手和用户消息的布局和渲染方式
+// Message role configuration: define layout and rendering for assistant and user messages
+const role: BubbleListProps["role"] = {
+  assistant: {
+    placement: "start",
+    contentRender(content: string) {
+      // 双 '\n' 在markdown中会被解析为新段落,因此需要替换为单个 '\n'
+      // Double '\n' in a mark will causes markdown parse as a new paragraph, so we need to replace it with a single '\n'
+      const newContent = content.replace(/\n\n/g, "<br/><br/>");
+      return <XMarkdown content={newContent} />;
+    },
+  },
+  user: {
+    placement: "end",
+  },
+};
+
+const App = () => {
+  const [content, setContent] = React.useState("");
+  // 创建OpenAI聊天提供者:配置请求参数和模型
+  // Create OpenAI chat provider: configure request parameters and model
+  const [provider] = React.useState(
+    new OpenAIChatProvider({
+      request: XRequest<XModelParams, XModelResponse>(BASE_URL, {
+        manual: true,
+        params: {
+          model: MODEL,
+          stream: true,
+        },
+      }),
+    })
+  );
+  const locale = useLocale();
+
+  // 聊天消息管理:处理消息列表、历史消息、错误处理等
+  // Chat message management: handle message list, historical messages, error handling, etc.
+  const {
+    onRequest,
+    messages,
+    removeMessage,
+    setMessages,
+    setMessage,
+    isRequesting,
+    abort,
+    onReload,
+  } = useXChat({
+    provider,
+    // 默认消息:包含历史对话作为示例
+    // Default messages: include historical conversation as examples
+    defaultMessages: [
+      {
+        id: "1",
+        message: { role: "user", content: locale.historyUserMessage },
+        status: "success",
+      },
+      {
+        id: "2",
+        message: { role: "assistant", content: locale.historyAIResponse },
+        status: "success",
+      },
+    ],
+    requestFallback: (_, { error, errorInfo, messageInfo }) => {
+      // 请求失败时的回退处理:区分中止错误和其他错误
+      // Fallback handling for request failure: distinguish between abort error and other errors
+      if (error.name === "AbortError") {
+        return {
+          content: messageInfo?.message?.content || locale.requestAborted,
+          role: "assistant",
+        };
+      }
+      return {
+        content: errorInfo?.error?.message || locale.requestFailed,
+        role: "assistant",
+      };
+    },
+    requestPlaceholder: () => {
+      // 请求占位符:在等待响应时显示等待消息
+      // Request placeholder: display waiting message while waiting for response
+      return {
+        content: locale.waiting,
+        role: "assistant",
+      };
+    },
+  });
+
+  // 添加用户消息:向消息列表中添加一条用户消息
+  // Add user message: add a user message to the message list
+  const addUserMessage = () => {
+    setMessages([
+      ...messages,
+      {
+        id: Date.now(),
+        message: { role: "user", content: locale.addUserMessage },
+        status: "success",
+      },
+    ]);
+  };
+
+  // 添加AI消息:向消息列表中添加一条AI助手消息
+  // Add AI message: add an AI assistant message to the message list
+  const addAIMessage = () => {
+    setMessages([
+      ...messages,
+      {
+        id: Date.now(),
+        message: { role: "assistant", content: locale.addAIMessage },
+        status: "success",
+      },
+    ]);
+  };
+
+  // 添加系统消息:向消息列表中添加一条系统消息
+  // Add system message: add a system message to the message list
+  const addSystemMessage = () => {
+    setMessages([
+      ...messages,
+      {
+        id: Date.now(),
+        message: { role: "system", content: locale.addSystemMessage },
+        status: "success",
+      },
+    ]);
+  };
+
+  // 编辑最后一条消息:修改消息列表中最后一条消息的内容
+  // Edit last message: modify the content of the last message in the message list
+  const editLastMessage = () => {
+    const lastMessage = messages[messages.length - 1];
+    setMessage(lastMessage.id, {
+      message: {
+        role: lastMessage.message.role,
+        content: locale.editLastMessage,
+      },
+    });
+  };
+
+  return (
+    <Flex vertical gap="middle">
+      {/* 状态和控制区域:显示当前状态并提供操作按钮 */}
+      {/* Status and control area: display current status and provide action buttons */}
+      <Flex vertical gap="middle">
+        <div>
+          {locale.currentStatus}{" "}
+          {isRequesting
+            ? locale.requesting
+            : messages.length === 0
+              ? locale.noMessages
+              : locale.qaCompleted}
+        </div>
+        <Flex align="center" gap="middle">
+          {/* 中止按钮:仅在请求进行中时可用 */}
+          {/* Abort button: only available when request is in progress */}
+          <Button disabled={!isRequesting} onClick={abort}>
+            {locale.abort}
+          </Button>
+          <Button onClick={addUserMessage}>{locale.addUserMessage}</Button>
+          <Button onClick={addAIMessage}>{locale.addAIMessage}</Button>
+          <Button onClick={addSystemMessage}>{locale.addSystemMessage}</Button>
+          {/* 编辑按钮:仅在存在消息时可用 */}
+          {/* Edit button: only available when messages exist */}
+          <Button disabled={!messages.length} onClick={editLastMessage}>
+            {locale.editLastMessage}
+          </Button>
+          <Button
+            disabled={!messages.length}
+            onClick={() => {
+              removeMessage(messages?.[0]?.id);
+            }}
+          >
+            {locale.deleteFirstMessage}
+          </Button>
+        </Flex>
+      </Flex>
+
+      {/* 消息列表:显示所有聊天消息,包括历史消息 */}
+      {/* Message list: display all chat messages, including historical messages */}
+      <Bubble.List
+        style={{ height: 500 }}
+        role={role}
+        items={messages.map(({ id, message, status }) => ({
+          key: id,
+          role: message.role,
+          status: status,
+          loading: status === "loading",
+          content: message.content,
+          // 为助手消息添加重试按钮
+          // Add retry button for assistant messages
+          components:
+            message.role === "assistant"
+              ? {
+                  footer: (
+                    <Tooltip title={locale.retry}>
+                      <Button
+                        size="small"
+                        type="text"
+                        icon={<SyncOutlined />}
+                        style={{ marginInlineEnd: "auto" }}
+                        onClick={() =>
+                          onReload(id, {
+                            userAction: "retry",
+                          })
+                        }
+                      />
+                    </Tooltip>
+                  ),
+                }
+              : {},
+        }))}
+      />
+      <Sender
+        loading={isRequesting}
+        value={content}
+        onCancel={() => {
+          abort();
+        }}
+        onChange={setContent}
+        placeholder={locale.placeholder}
+        onSubmit={(nextContent) => {
+          onRequest({
+            messages: [
+              {
+                role: "user",
+                content: nextContent,
+              },
+            ],
+            frequency_penalty: 0,
+            max_tokens: 1024,
+            thinking: {
+              type: "disabled",
+            },
+          });
+          setContent("");
+        }}
+      />
+    </Flex>
+  );
+};
+
+export default App;

+ 11 - 0
dashboard-v6/src/pages/workspace/term/edit.tsx

@@ -0,0 +1,11 @@
+import { useParams } from "react-router";
+
+import TermShow from "../../../components/term/TermShow";
+
+const Widget = () => {
+  const { id } = useParams();
+
+  return <TermShow wordId={id} />;
+};
+
+export default Widget;

+ 12 - 0
dashboard-v6/src/pages/workspace/term/list.tsx

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

+ 1 - 1
dashboard-v6/src/reducers/accept-pr.ts

@@ -4,7 +4,7 @@
 import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
 
 import type { RootState } from "../store";
-import type { ISentence } from "../api/Corpus";
+import type { ISentence } from "../api/sentence";
 
 interface IState {
   sentences?: ISentence[];

+ 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/Corpus";
+import type { ArticleMode } from "../api/Article";
 
 interface IMode {
   id?: string;

+ 8 - 5
dashboard-v6/src/reducers/cart-mode.ts

@@ -5,17 +5,21 @@ import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
 
 import type { RootState } from "../store";
 
+export type TCopyMode = "batch" | "single";
+
 interface IState {
-  mode?: string;
+  mode: TCopyMode;
 }
 
-const initialState: IState = {};
+const initialState: IState = {
+  mode: "single",
+};
 
 export const slice = createSlice({
   name: "cart-mode",
   initialState,
   reducers: {
-    modeChange: (state, action: PayloadAction<string>) => {
+    modeChange: (state, action: PayloadAction<TCopyMode>) => {
       state.mode = action.payload;
     },
   },
@@ -23,7 +27,6 @@ export const slice = createSlice({
 
 export const { modeChange } = slice.actions;
 
-export const mode = (state: RootState): string | undefined =>
-  state.cartMode.mode;
+export const mode = (state: RootState): TCopyMode => state.cartMode.mode;
 
 export default slice.reducer;

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

@@ -4,7 +4,7 @@
 import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
 
 import type { RootState } from "../store";
-import type { ISentence } from "../api/Corpus";
+import type { ISentence } from "../api/sentence";
 import type { TResType } from "../api/discussion";
 
 export interface IShowDiscussion {

+ 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/Corpus";
+import type { ArticleType } from "../api/Article";
 
 export interface IParam {
   book: number;

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно