visuddhinanda hai 1 mes
pai
achega
f7e8dbbd51
Modificáronse 94 ficheiros con 8177 adicións e 187 borrados
  1. 1 1
      dashboard-v6/backup/components/admin/relation/DataImport.tsx
  2. 1 1
      dashboard-v6/backup/components/admin/relation/NissayaEndingEdit.tsx
  3. 1 1
      dashboard-v6/backup/components/admin/relation/RelationEdit.tsx
  4. 1 1
      dashboard-v6/backup/components/channel/ChannelTableModal.tsx
  5. 0 32
      dashboard-v6/backup/components/corpus/SentHistory.tsx
  6. 1 1
      dashboard-v6/backup/components/corpus/SentHistoryModal.tsx
  7. 1 1
      dashboard-v6/backup/components/course/CourseInvite.tsx
  8. 1 1
      dashboard-v6/backup/components/course/SelectChannel.tsx
  9. 2 2
      dashboard-v6/backup/components/discussion/DiscussionDrawer.tsx
  10. 2 2
      dashboard-v6/backup/components/export/ExportModal.tsx
  11. 1 1
      dashboard-v6/backup/components/general/NissayaCard.tsx
  12. 1 1
      dashboard-v6/backup/components/recent/RecentModal.tsx
  13. 1 1
      dashboard-v6/backup/components/tag/TagsManager.tsx
  14. 1 1
      dashboard-v6/backup/components/task/ProjectClone.tsx
  15. 1 1
      dashboard-v6/backup/components/task/ProjectEditDrawer.tsx
  16. 1 1
      dashboard-v6/backup/components/template/Builder/ArticleTpl.tsx
  17. 9 4
      dashboard-v6/backup/components/template/SentEdit/SentCanRead.tsx
  18. 0 80
      dashboard-v6/backup/components/template/UserSelect.tsx
  19. 1 1
      dashboard-v6/backup/components/template/Video.tsx
  20. 1 1
      dashboard-v6/backup/components/transfer/TransferCreate.tsx
  21. 8 8
      dashboard-v6/src/Router.tsx
  22. 17 0
      dashboard-v6/src/api/Channel.ts
  23. 0 12
      dashboard-v6/src/api/Corpus.ts
  24. 27 7
      dashboard-v6/src/api/recent.ts
  25. 43 0
      dashboard-v6/src/api/sent-sim.ts
  26. 30 0
      dashboard-v6/src/api/sentence-history.ts
  27. 67 0
      dashboard-v6/src/api/sentence.ts
  28. 19 3
      dashboard-v6/src/api/workspace.ts
  29. 22 0
      dashboard-v6/src/components/dict/GrammarLookup.tsx
  30. 87 0
      dashboard-v6/src/components/dict/utils.ts
  31. 110 0
      dashboard-v6/src/components/discussion/DiscussionButton.tsx
  32. 20 0
      dashboard-v6/src/components/discussion/utils.ts
  33. 35 0
      dashboard-v6/src/components/general/MdView.tsx
  34. 195 0
      dashboard-v6/src/components/nissaya/NissayaCard.tsx
  35. 283 0
      dashboard-v6/src/components/nissaya/NissayaCardTable.tsx
  36. 52 0
      dashboard-v6/src/components/nissaya/NissayaItem.tsx
  37. 70 0
      dashboard-v6/src/components/nissaya/NissayaMeaning.tsx
  38. 51 0
      dashboard-v6/src/components/nissaya/NissayaSent.tsx
  39. 29 0
      dashboard-v6/src/components/nissaya/utils.ts
  40. 169 0
      dashboard-v6/src/components/related-para/RelatedPara.tsx
  41. 149 0
      dashboard-v6/src/components/sentence-editor/EditInfo.tsx
  42. 114 0
      dashboard-v6/src/components/sentence-editor/InteractiveButton.tsx
  43. 80 0
      dashboard-v6/src/components/sentence-editor/PrAcceptButton.tsx
  44. 49 0
      dashboard-v6/src/components/sentence-editor/SentAdd.tsx
  45. 35 0
      dashboard-v6/src/components/sentence-editor/SentAttachment.tsx
  46. 185 0
      dashboard-v6/src/components/sentence-editor/SentCanRead.tsx
  47. 114 0
      dashboard-v6/src/components/sentence-editor/SentCart.tsx
  48. 511 0
      dashboard-v6/src/components/sentence-editor/SentCell.tsx
  49. 202 0
      dashboard-v6/src/components/sentence-editor/SentCellEditable.tsx
  50. 213 0
      dashboard-v6/src/components/sentence-editor/SentContent.tsx
  51. 248 0
      dashboard-v6/src/components/sentence-editor/SentEdit.tsx
  52. 263 0
      dashboard-v6/src/components/sentence-editor/SentEditMenu.tsx
  53. 125 0
      dashboard-v6/src/components/sentence-editor/SentMenu.tsx
  54. 103 0
      dashboard-v6/src/components/sentence-editor/SentSim.tsx
  55. 248 0
      dashboard-v6/src/components/sentence-editor/SentSimTest.tsx
  56. 394 0
      dashboard-v6/src/components/sentence-editor/SentTab.tsx
  57. 21 0
      dashboard-v6/src/components/sentence-editor/SentTabButton.tsx
  58. 67 0
      dashboard-v6/src/components/sentence-editor/SentTabButtonWbw.tsx
  59. 104 0
      dashboard-v6/src/components/sentence-editor/SentTabCopy.tsx
  60. 226 0
      dashboard-v6/src/components/sentence-editor/SentWbw.tsx
  61. 85 0
      dashboard-v6/src/components/sentence-editor/SentWbwEdit.tsx
  62. 54 0
      dashboard-v6/src/components/sentence-editor/SuggestionAdd.tsx
  63. 117 0
      dashboard-v6/src/components/sentence-editor/SuggestionBox.tsx
  64. 48 0
      dashboard-v6/src/components/sentence-editor/SuggestionButton.tsx
  65. 60 0
      dashboard-v6/src/components/sentence-editor/SuggestionFocus.tsx
  66. 134 0
      dashboard-v6/src/components/sentence-editor/SuggestionList.tsx
  67. 78 0
      dashboard-v6/src/components/sentence-editor/SuggestionPopover.tsx
  68. 76 0
      dashboard-v6/src/components/sentence-editor/SuggestionTabs.tsx
  69. 79 0
      dashboard-v6/src/components/sentence-editor/SuggestionToolbar.tsx
  70. 14 0
      dashboard-v6/src/components/sentence-editor/style.css
  71. 27 0
      dashboard-v6/src/components/sentence-editor/utils.ts
  72. 131 0
      dashboard-v6/src/components/sentence-history.tsx/SentHistory.tsx
  73. 48 0
      dashboard-v6/src/components/sentence-history.tsx/SentHistoryGroup.tsx
  74. 46 0
      dashboard-v6/src/components/sentence-history.tsx/SentHistoryItem.tsx
  75. 64 0
      dashboard-v6/src/components/sentence-history.tsx/SentHistoryModal.tsx
  76. 21 17
      dashboard-v6/src/components/sentence/utils.ts
  77. 27 0
      dashboard-v6/src/components/template/MdTpl.tsx
  78. 15 0
      dashboard-v6/src/components/template/SentEdit.tsx
  79. 15 0
      dashboard-v6/src/components/template/WbwSent.tsx
  80. 33 0
      dashboard-v6/src/components/template/Wd.tsx
  81. 114 0
      dashboard-v6/src/components/template/style.css
  82. 161 0
      dashboard-v6/src/components/template/utilities.ts
  83. 25 0
      dashboard-v6/src/components/term/TermModal.tsx
  84. 29 0
      dashboard-v6/src/components/tpl-builder/ArticleTpl.tsx
  85. 68 0
      dashboard-v6/src/components/tpl-builder/Builder.tsx
  86. 21 0
      dashboard-v6/src/components/tpl-builder/VideoTpl.tsx
  87. 1128 0
      dashboard-v6/src/components/wbw/WbwSentCtl.tsx
  88. 132 0
      dashboard-v6/src/components/wbw/utils.ts
  89. 21 3
      dashboard-v6/src/components/workspace/home/RecentItem.tsx
  90. 142 0
      dashboard-v6/src/hooks/useSentSim.ts
  91. 333 0
      dashboard-v6/src/hooks/useWbwStreamProcessor.ts
  92. 9 2
      dashboard-v6/src/pages/workspace/home.tsx
  93. 8 0
      dashboard-v6/src/routes/testRoutes.tsx
  94. 1 0
      dashboard-v6/src/types/article.ts

+ 1 - 1
dashboard-v6/backup/components/admin/relation/DataImport.tsx

@@ -43,7 +43,7 @@ const DataImportWidget = ({
       form={form}
       form={form}
       autoFocusFirstInput
       autoFocusFirstInput
       modalProps={{
       modalProps={{
-        destroyOnClose: true,
+        destroyOnHidden: true,
         onCancel: () => console.log("run"),
         onCancel: () => console.log("run"),
       }}
       }}
       submitTimeout={2000}
       submitTimeout={2000}

+ 1 - 1
dashboard-v6/backup/components/admin/relation/NissayaEndingEdit.tsx

@@ -32,7 +32,7 @@ const NissayaEndingWidget = ({
       form={form}
       form={form}
       autoFocusFirstInput
       autoFocusFirstInput
       modalProps={{
       modalProps={{
-        destroyOnClose: true,
+        destroyOnHidden: true,
         onCancel: () => console.log("run"),
         onCancel: () => console.log("run"),
       }}
       }}
       submitTimeout={2000}
       submitTimeout={2000}

+ 1 - 1
dashboard-v6/backup/components/admin/relation/RelationEdit.tsx

@@ -85,7 +85,7 @@ const RelationEditWidget = ({
       form={form}
       form={form}
       autoFocusFirstInput
       autoFocusFirstInput
       modalProps={{
       modalProps={{
-        destroyOnClose: true,
+        destroyOnHidden: true,
         onCancel: () => console.log("onCancel"),
         onCancel: () => console.log("onCancel"),
       }}
       }}
       submitTimeout={3000}
       submitTimeout={3000}

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

@@ -64,7 +64,7 @@ const ChannelTableModalWidget = ({
         title={intl.formatMessage({
         title={intl.formatMessage({
           id: "buttons.select.channel",
           id: "buttons.select.channel",
         })}
         })}
-        destroyOnClose
+        destroyOnHidden
         footer={false}
         footer={false}
         open={isModalOpen}
         open={isModalOpen}
         onOk={handleOk}
         onOk={handleOk}

+ 0 - 32
dashboard-v6/backup/components/corpus/SentHistory.tsx

@@ -1,42 +1,10 @@
 import { ProList } from "@ant-design/pro-components";
 import { ProList } from "@ant-design/pro-components";
 import { Space, Typography } from "antd";
 import { Space, Typography } from "antd";
 
 
-import { get } from "../../request";
-import User, { type IUser } from "../auth/User";
 import TimeShow from "../general/TimeShow";
 import TimeShow from "../general/TimeShow";
-import type { IChannel } from "../channel/Channel"
-import { MergeIcon2 } from "../../assets/icon";
-import type { IStudio } from "../auth/Studio"
 
 
 const { Paragraph } = Typography;
 const { Paragraph } = Typography;
 
 
-export interface ISentHistoryData {
-  id: string;
-  sent_uid: string;
-  content: string;
-  editor: IUser;
-  landmark: string;
-  fork_from?: IChannel;
-  fork_studio?: IStudio;
-  pr_from?: string | null;
-  accepter?: IUser;
-  created_at: string;
-}
-
-export interface ISentHistoryListResponse {
-  ok: boolean;
-  message: string;
-  data: { rows: ISentHistoryData[]; count: number };
-}
-
-interface ISentHistory {
-  content: string;
-  editor: IUser;
-  fork_from?: IChannel;
-  pr_from?: string | null;
-  accepter?: IUser;
-  createdAt: string;
-}
 interface IWidget {
 interface IWidget {
   sentId?: string;
   sentId?: string;
 }
 }

+ 1 - 1
dashboard-v6/backup/components/corpus/SentHistoryModal.tsx

@@ -51,7 +51,7 @@ const SentHistoryModalWidget = ({
         open={isModalOpen}
         open={isModalOpen}
         onOk={handleOk}
         onOk={handleOk}
         onCancel={handleCancel}
         onCancel={handleCancel}
-        destroyOnClose
+        destroyOnHidden
       >
       >
         <SentHistory sentId={sentId} />
         <SentHistory sentId={sentId} />
       </Modal>
       </Modal>

+ 1 - 1
dashboard-v6/backup/components/course/CourseInvite.tsx

@@ -44,7 +44,7 @@ const CourseInviteWidget = ({ courseId, onCreated }: IWidget) => {
         onCancel={() => setVisible(false)}
         onCancel={() => setVisible(false)}
         open={visible}
         open={visible}
         footer={false}
         footer={false}
-        destroyOnClose
+        destroyOnHidden
       >
       >
         <StepsForm<IFormData>
         <StepsForm<IFormData>
           formRef={formRef}
           formRef={formRef}

+ 1 - 1
dashboard-v6/backup/components/course/SelectChannel.tsx

@@ -48,7 +48,7 @@ const SelectChannelWidget = ({
           }
           }
           autoFocusFirstInput
           autoFocusFirstInput
           modalProps={{
           modalProps={{
-            destroyOnClose: true,
+            destroyOnHidden: true,
             onCancel: () => console.log("run"),
             onCancel: () => console.log("run"),
           }}
           }}
           submitTimeout={20000}
           submitTimeout={20000}

+ 2 - 2
dashboard-v6/backup/components/discussion/DiscussionDrawer.tsx

@@ -4,7 +4,7 @@ import { FullscreenOutlined, FullscreenExitOutlined } from "@ant-design/icons";
 
 
 import _DiscussionTopic from "./DiscussionTopic";
 import _DiscussionTopic from "./DiscussionTopic";
 import _DiscussionListCard, { type TResType } from "./DiscussionListCard";
 import _DiscussionListCard, { type TResType } from "./DiscussionListCard";
-import type { _IComment } from "./DiscussionItem"
+import type { _IComment } from "./DiscussionItem";
 import _DiscussionAnchor from "./DiscussionAnchor";
 import _DiscussionAnchor from "./DiscussionAnchor";
 import { Link } from "react-router";
 import { Link } from "react-router";
 import { useIntl } from "react-intl";
 import { useIntl } from "react-intl";
@@ -51,7 +51,7 @@ const DiscussionDrawerWidget = ({
       </span>
       </span>
       <Drawer
       <Drawer
         title="Discussion"
         title="Discussion"
-        destroyOnClose
+        destroyOnHidden
         extra={
         extra={
           <Space>
           <Space>
             <Link to={`/discussion/show/${resType}/${resId}`} target="_blank">
             <Link to={`/discussion/show/${resType}/${resId}`} target="_blank">

+ 2 - 2
dashboard-v6/backup/components/export/ExportModal.tsx

@@ -9,7 +9,7 @@ import {
 } from "antd";
 } from "antd";
 import { useEffect, useRef, useState } from "react";
 import { useEffect, useRef, useState } from "react";
 import { get } from "../../request";
 import { get } from "../../request";
-import type { ArticleType } from "../article/Article"
+import type { ArticleType } from "../article/Article";
 import ExportSettingLayout from "./ExportSettingLayout";
 import ExportSettingLayout from "./ExportSettingLayout";
 
 
 const { Text } = Typography;
 const { Text } = Typography;
@@ -151,7 +151,7 @@ const ExportModalWidget = ({
   useEffect(() => setIsModalOpen(open), [open]);
   useEffect(() => setIsModalOpen(open), [open]);
   return (
   return (
     <Modal
     <Modal
-      destroyOnClose
+      destroyOnHidden
       title="导出"
       title="导出"
       width={400}
       width={400}
       open={isModalOpen}
       open={isModalOpen}

+ 1 - 1
dashboard-v6/backup/components/general/NissayaCard.tsx

@@ -55,7 +55,7 @@ export const NissayaCardModal = ({ text, trigger }: INissayaCardModal) => {
         open={isModalOpen}
         open={isModalOpen}
         onOk={handleOk}
         onOk={handleOk}
         onCancel={handleCancel}
         onCancel={handleCancel}
-        destroyOnClose
+        destroyOnHidden
       >
       >
         <NissayaCardWidget text={text} />
         <NissayaCardWidget text={text} />
       </Modal>
       </Modal>

+ 1 - 1
dashboard-v6/backup/components/recent/RecentModal.tsx

@@ -57,7 +57,7 @@ const RecentModalWidget = ({
         open={isModalOpen}
         open={isModalOpen}
         onOk={handleOk}
         onOk={handleOk}
         onCancel={handleCancel}
         onCancel={handleCancel}
-        destroyOnClose
+        destroyOnHidden
       >
       >
         <RecentList
         <RecentList
           onSelect={(
           onSelect={(

+ 1 - 1
dashboard-v6/backup/components/tag/TagsManager.tsx

@@ -44,7 +44,7 @@ const TagsManagerWidget = ({
         open={isModalOpen}
         open={isModalOpen}
         onOk={handleOk}
         onOk={handleOk}
         onCancel={handleCancel}
         onCancel={handleCancel}
-        destroyOnClose
+        destroyOnHidden
         footer={false}
         footer={false}
       >
       >
         {title ? <Alert title={title} /> : undefined}
         {title ? <Alert title={title} /> : undefined}

+ 1 - 1
dashboard-v6/backup/components/task/ProjectClone.tsx

@@ -30,7 +30,7 @@ const ProjectClone = ({ trigger, studioName, projectId }: IWidget) => {
       form={form}
       form={form}
       autoFocusFirstInput
       autoFocusFirstInput
       modalProps={{
       modalProps={{
-        destroyOnClose: true,
+        destroyOnHidden: true,
         onCancel: () => console.log("run"),
         onCancel: () => console.log("run"),
       }}
       }}
       submitTimeout={2000}
       submitTimeout={2000}

+ 1 - 1
dashboard-v6/backup/components/task/ProjectEditDrawer.tsx

@@ -36,7 +36,7 @@ const ProjectEditDrawer = ({
         width={650}
         width={650}
         onClose={onCloseDrawer}
         onClose={onCloseDrawer}
         open={open}
         open={open}
-        destroyOnClose
+        destroyOnHidden
       >
       >
         <ProjectEdit studioName={studioName} projectId={projectId} />
         <ProjectEdit studioName={studioName} projectId={projectId} />
       </Drawer>
       </Drawer>

+ 1 - 1
dashboard-v6/backup/components/template/Builder/ArticleTpl.tsx

@@ -248,7 +248,7 @@ export const ArticleTplModal = ({
         open={isModalOpen}
         open={isModalOpen}
         onOk={handleOk}
         onOk={handleOk}
         onCancel={handleCancel}
         onCancel={handleCancel}
-        destroyOnClose
+        destroyOnHidden
       >
       >
         <ArticleTplWidget
         <ArticleTplWidget
           type={type}
           type={type}

+ 9 - 4
dashboard-v6/backup/components/template/SentEdit/SentCanRead.tsx

@@ -14,10 +14,14 @@ import { currentUser as _currentUser } from "../../../reducers/current-user";
 import type { IChannel } from "../../channel/Channel";
 import type { IChannel } from "../../channel/Channel";
 import type { IWbw } from "../Wbw/WbwWord";
 import type { IWbw } from "../Wbw/WbwWord";
 
 
-export const toISentence = (item: ISentenceData, channelsId?: string[]) => {
+export const toISentence = (
+  item: ISentenceData,
+  channelsId?: string[]
+): ISentence => {
   return {
   return {
     id: item.id,
     id: item.id,
     content: item.content,
     content: item.content,
+    contentType: item.content_type,
     html: item.html,
     html: item.html,
     book: item.book,
     book: item.book,
     para: item.paragraph,
     para: item.paragraph,
@@ -26,11 +30,12 @@ export const toISentence = (item: ISentenceData, channelsId?: string[]) => {
     editor: item.editor,
     editor: item.editor,
     studio: item.studio,
     studio: item.studio,
     channel: item.channel,
     channel: item.channel,
-    contentType: item.content_type,
+    updateAt: item.updated_at,
+    acceptor: item.acceptor,
+    prEditAt: item.pr_edit_at,
+    forkAt: item.fork_at,
     suggestionCount: item.suggestionCount,
     suggestionCount: item.suggestionCount,
     translationChannels: channelsId,
     translationChannels: channelsId,
-    forkAt: item.fork_at,
-    updateAt: item.updated_at,
   };
   };
 };
 };
 
 

+ 0 - 80
dashboard-v6/backup/components/template/UserSelect.tsx

@@ -1,80 +0,0 @@
-import {
-  ProFormSelect,
-  type RequestOptionsType,
-} from "@ant-design/pro-components";
-import { useIntl } from "react-intl";
-
-import { get } from "../../request";
-import type { IUserListResponse } from "../../api/Auth";
-
-interface IWidget {
-  name?: string;
-  width?: number | "md" | "sm" | "xl" | "xs" | "lg";
-  multiple?: boolean;
-  hidden?: boolean;
-  hiddenTitle?: boolean;
-  required?: boolean;
-  initialValue?: string | string[] | null;
-  options?: RequestOptionsType[];
-}
-const UserSelectWidget = ({
-  name = "user",
-  multiple = false,
-  width = "md",
-  hidden = false,
-  hiddenTitle = false,
-  required = true,
-  options = [],
-  initialValue,
-}: IWidget) => {
-  const intl = useIntl();
-  console.log("UserSelect options", options);
-  return (
-    <ProFormSelect
-      name={name}
-      label={
-        hiddenTitle
-          ? undefined
-          : intl.formatMessage({ id: "forms.fields.user.label" })
-      }
-      hidden={hidden}
-      width={width}
-      initialValue={initialValue}
-      showSearch
-      debounceTime={300}
-      fieldProps={{
-        mode: multiple ? "tags" : undefined,
-      }}
-      request={async ({ keyWords }) => {
-        console.log("keyWord", keyWords);
-
-        if (typeof keyWords === "string") {
-          const json = await get<IUserListResponse>(
-            `/v2/user?view=key&key=${keyWords}`
-          );
-          console.info("api response user select", json);
-          const userList: RequestOptionsType[] = json.data.rows.map((item) => {
-            return {
-              value: item.id,
-              label: `${item.nickName}`,
-            };
-          });
-          console.log("json", userList);
-          return userList;
-        } else {
-          const defaultOptions: RequestOptionsType[] = options.map((item) => {
-            return { label: item.label, value: item.value?.toString() };
-          });
-          return defaultOptions;
-        }
-      }}
-      rules={[
-        {
-          required: required,
-        },
-      ]}
-    />
-  );
-};
-
-export default UserSelectWidget;

+ 1 - 1
dashboard-v6/backup/components/template/Video.tsx

@@ -107,7 +107,7 @@ const VideoModal = ({ url, id, type, title }: IVideoCtl) => {
       </Typography.Link>
       </Typography.Link>
       <Modal
       <Modal
         width={800}
         width={800}
-        destroyOnClose
+        destroyOnHidden
         style={{ maxWidth: "90%", top: 20, height: 700 }}
         style={{ maxWidth: "90%", top: 20, height: 700 }}
         title={
         title={
           <div
           <div

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

@@ -51,7 +51,7 @@ const TransferCreateWidget = ({
       form={form}
       form={form}
       autoFocusFirstInput
       autoFocusFirstInput
       modalProps={{
       modalProps={{
-        destroyOnClose: true,
+        destroyOnHidden: true,
         onCancel: () => console.log("run"),
         onCancel: () => console.log("run"),
       }}
       }}
       submitTimeout={2000}
       submitTimeout={2000}

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

@@ -5,6 +5,12 @@ import { channelLoader } from "./api/Channel";
 import { testRoutes } from "./routes/testRoutes";
 import { testRoutes } from "./routes/testRoutes";
 import { buildRouteConfig } from "./routes/buildRoutes";
 import { buildRouteConfig } from "./routes/buildRoutes";
 
 
+const RootLayout = lazy(() => import("./layouts/Root"));
+const AnonymousLayout = lazy(() => import("./layouts/anonymous"));
+const DashboardLayout = lazy(() => import("./layouts/dashboard"));
+const WorkspaceLayout = lazy(() => import("./layouts/workspace"));
+const WorkspaceEditorLayout = lazy(() => import("./layouts/workspace/editor"));
+
 const UsersSignIn = lazy(() => import("./pages/users/sign-in"));
 const UsersSignIn = lazy(() => import("./pages/users/sign-in"));
 const UsersSignUp = lazy(() => import("./pages/users/sign-up"));
 const UsersSignUp = lazy(() => import("./pages/users/sign-up"));
 const UsersForgotPassword = lazy(() => import("./pages/users/forgot-password"));
 const UsersForgotPassword = lazy(() => import("./pages/users/forgot-password"));
@@ -22,13 +28,7 @@ const WorkspaceChannelSetting = lazy(
 const WorkspaceTipitaka = lazy(
 const WorkspaceTipitaka = lazy(
   () => import("./pages/workspace/tipitaka/bypath")
   () => import("./pages/workspace/tipitaka/bypath")
 );
 );
-
-const RootLayout = lazy(() => import("./layouts/Root"));
-const AnonymousLayout = lazy(() => import("./layouts/anonymous"));
-const DashboardLayout = lazy(() => import("./layouts/dashboard"));
-const WorkspaceLayout = lazy(() => import("./layouts/workspace"));
-const WorkspaceEditorLayout = lazy(() => import("./layouts/workspace/editor"));
-const WorkspaceHomeLayout = lazy(() => import("./layouts/workspace/home"));
+const WorkspaceHome = lazy(() => import("./pages/workspace/home"));
 
 
 // ↓ 新增:TestLayout
 // ↓ 新增:TestLayout
 const TestLayout = lazy(() => import("./layouts/test"));
 const TestLayout = lazy(() => import("./layouts/test"));
@@ -76,7 +76,7 @@ const router = createBrowserRouter(
           Component: WorkspaceLayout,
           Component: WorkspaceLayout,
           handle: { crumb: "workspace" },
           handle: { crumb: "workspace" },
           children: [
           children: [
-            { index: true, Component: WorkspaceHomeLayout },
+            { index: true, Component: WorkspaceHome },
             {
             {
               path: "ai",
               path: "ai",
               Component: UsersPersonal,
               Component: UsersPersonal,

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

@@ -80,6 +80,23 @@ export interface ISentInChapterListDataRow {
   word_end: number;
   word_end: number;
 }
 }
 
 
+export interface IResNumberResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    my: number;
+    collaboration: number;
+  };
+}
+
+export interface IResNumber {
+  translation?: number;
+  nissaya?: number;
+  commentary?: number;
+  origin?: number;
+  sim?: number;
+}
+
 export async function channelLoader({ params }: LoaderFunctionArgs) {
 export async function channelLoader({ params }: LoaderFunctionArgs) {
   const channelId = params.channelId;
   const channelId = params.channelId;
 
 

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

@@ -51,8 +51,6 @@ export type ArticleType =
   | "sim"
   | "sim"
   | "page"
   | "page"
   | "textbook"
   | "textbook"
-  | "exercise"
-  | "exercise-list"
   | "sent-original"
   | "sent-original"
   | "sent-commentary"
   | "sent-commentary"
   | "sent-nissaya"
   | "sent-nissaya"
@@ -396,16 +394,6 @@ export interface ISentencePrResponse {
   };
   };
 }
 }
 
 
-export interface ISimSent {
-  sent: string;
-  sim: number;
-}
-export interface ISentenceSimListResponse {
-  ok: boolean;
-  message: string;
-  data: { rows: ISimSent[]; count: number };
-}
-
 export interface ISentenceWbwListResponse {
 export interface ISentenceWbwListResponse {
   ok: boolean;
   ok: boolean;
   message: string;
   message: string;

+ 27 - 7
dashboard-v6/src/api/recent.ts

@@ -1,5 +1,14 @@
+import { get } from "../request";
 import type { ArticleType } from "./Corpus";
 import type { ArticleType } from "./Corpus";
 
 
+export interface IRecent {
+  id: string;
+  title: string;
+  type: ArticleType;
+  articleId: string;
+  updatedAt: string;
+  param?: IRecentParam;
+}
 export interface IRecentRequest {
 export interface IRecentRequest {
   type: ArticleType;
   type: ArticleType;
   article_id: string;
   article_id: string;
@@ -26,11 +35,22 @@ export interface IRecentResponse {
   data: IRecentData;
   data: IRecentData;
 }
 }
 
 
-export interface IRecent {
-  id: string;
-  title: string;
-  type: ArticleType;
-  articleId: string;
-  updatedAt: string;
-  param?: IRecentParam;
+export interface IRecentListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: IRecentData[];
+    count: number;
+  };
 }
 }
+
+export const getRecentByUser = async (
+  userId: string,
+  pageSize: number,
+  page: number = 0
+): Promise<IRecentListResponse> => {
+  let url = `/api/v2/recent?view=user&id=${userId}`;
+  url += `&limit=${pageSize}&offset=${page}`;
+  console.log("url", url);
+  return await get<IRecentListResponse>(url);
+};

+ 43 - 0
dashboard-v6/src/api/sent-sim.ts

@@ -0,0 +1,43 @@
+import { get } from "../request";
+
+/** 单条句子 */
+export interface ISimSent {
+  sent: string;
+  sim: number;
+}
+
+/** 后端返回结构 */
+export interface ISentenceSimListResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: ISimSent[];
+    count: number;
+  };
+}
+
+export interface ISentSimParams {
+  book: number;
+  para: number;
+  wordStart: number;
+  wordEnd: number;
+  limit: number;
+  offset: number;
+  sim: number;
+  channelsId?: string[];
+}
+
+export async function fetchSentSim(
+  params: ISentSimParams
+): Promise<ISentenceSimListResponse> {
+  const { book, para, wordStart, wordEnd, limit, offset, sim, channelsId } =
+    params;
+
+  let url = `/v2/sent-sim?view=sentence&book=${book}&paragraph=${para}&start=${wordStart}&end=${wordEnd}&mode=edit`;
+  url += `&limit=${limit}`;
+  url += `&offset=${offset}`;
+  url += `&sim=${sim}`;
+  url += channelsId ? `&channels=${channelsId.join()}` : "";
+
+  return get<ISentenceSimListResponse>(url);
+}

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

@@ -0,0 +1,30 @@
+import type { IStudio, IUser } from "./Auth";
+import type { IChannel } from "./Channel";
+
+export interface ISentHistoryData {
+  id: string;
+  sent_uid: string;
+  content: string;
+  editor: IUser;
+  landmark: string;
+  fork_from?: IChannel;
+  fork_studio?: IStudio;
+  pr_from?: string | null;
+  accepter?: IUser;
+  created_at: string;
+}
+
+export interface ISentHistoryListResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: ISentHistoryData[]; count: number };
+}
+
+export interface ISentHistory {
+  content: string;
+  editor: IUser;
+  fork_from?: IChannel;
+  pr_from?: string | null;
+  accepter?: IUser;
+  createdAt: string;
+}

+ 67 - 0
dashboard-v6/src/api/sentence.ts

@@ -0,0 +1,67 @@
+import type { IntlShape } from "react-intl";
+import type {
+  ISentence,
+  ISentenceData,
+  ISentenceRequest,
+  ISentenceResponse,
+} from "./Corpus";
+import store from "../store";
+import { statusChange } from "../reducers/net-status";
+import { put } from "../request";
+import { message } from "antd";
+import { toISentence } from "../components/sentence/utils";
+
+export const sentSave = async (
+  sent: ISentence,
+  intl: IntlShape,
+  ok?: (res: ISentence) => void,
+  finish?: () => void
+): Promise<ISentenceData | null> => {
+  store.dispatch(statusChange({ status: "loading" }));
+  const id = `${sent.book}_${sent.para}_${sent.wordStart}_${sent.wordEnd}_${sent.channel.id}`;
+  const url = `/v2/sentence/${id}?mode=edit&html=true`;
+  console.info("SentWbwEdit url", url);
+
+  try {
+    const res = 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 (res.ok) {
+      if (ok) {
+        console.debug("sent save ok", res.data);
+        const newData: ISentence = toISentence(res.data);
+        ok(newData);
+      }
+
+      store.dispatch(
+        statusChange({
+          status: "success",
+          message: intl.formatMessage({ id: "flashes.success" }),
+        })
+      );
+      return res.data;
+    } else {
+      message.error(res.message);
+      store.dispatch(
+        statusChange({
+          status: "fail",
+          message: res.message,
+        })
+      );
+      return null;
+    }
+  } catch (e) {
+    console.error("catch", e);
+    return null;
+  } finally {
+    finish?.();
+  }
+};

+ 19 - 3
dashboard-v6/src/api/workspace.ts

@@ -1,3 +1,6 @@
+import type { ArticleType } from "./Corpus";
+import { getRecentByUser } from "./recent";
+
 export type ModuleItem = {
 export type ModuleItem = {
   key: string;
   key: string;
   title: string;
   title: string;
@@ -16,7 +19,7 @@ export type RecentItem = {
   title: string;
   title: string;
   subtitle: string;
   subtitle: string;
   time: string;
   time: string;
-  type: "tipitaka" | "article" | "task";
+  type: ArticleType;
   emoji: string;
   emoji: string;
 };
 };
 
 
@@ -53,7 +56,7 @@ export async function fetchModules(): Promise<ModuleItem[]> {
       titleZh: "任务",
       titleZh: "任务",
       description: "管理个人修学计划、法务安排与日常待办事项。",
       description: "管理个人修学计划、法务安排与日常待办事项。",
       icon: "CheckSquareOutlined",
       icon: "CheckSquareOutlined",
-      path: "/workspace/channel",
+      path: "/workspace/task",
       color: "#4ab58a",
       color: "#4ab58a",
       bg: "linear-gradient(135deg, #ecfdf6 0%, #ccf0e0 100%)",
       bg: "linear-gradient(135deg, #ecfdf6 0%, #ccf0e0 100%)",
       accent: "#1a7a56",
       accent: "#1a7a56",
@@ -63,7 +66,19 @@ export async function fetchModules(): Promise<ModuleItem[]> {
 }
 }
 
 
 // TODO: replace with real fetch
 // TODO: replace with real fetch
-export async function fetchRecentItems(): Promise<RecentItem[]> {
+export async function fetchRecentItems(userId: string): Promise<RecentItem[]> {
+  const res = await getRecentByUser(userId, 10);
+  return res.data.rows.map((item, id) => {
+    return {
+      id: id,
+      title: item.title,
+      subtitle: "Tipitaka · 律藏",
+      time: item.updated_at,
+      type: item.type,
+      emoji: "📜",
+    };
+  });
+  /*
   return [
   return [
     {
     {
       id: 1,
       id: 1,
@@ -106,4 +121,5 @@ export async function fetchRecentItems(): Promise<RecentItem[]> {
       emoji: "📚",
       emoji: "📚",
     },
     },
   ];
   ];
+  */
 }
 }

+ 22 - 0
dashboard-v6/src/components/dict/GrammarLookup.tsx

@@ -0,0 +1,22 @@
+import { grammar } from "../../reducers/command";
+import { openPanel } from "../../reducers/right-panel";
+import store from "../../store";
+
+interface IWidget {
+  word?: string;
+  children?: React.ReactNode;
+}
+const GrammarLookup = ({ word, children }: IWidget) => {
+  return (
+    <span
+      onClick={() => {
+        store.dispatch(grammar(word));
+        store.dispatch(openPanel("grammar"));
+      }}
+    >
+      {children}
+    </span>
+  );
+};
+
+export default GrammarLookup;

+ 87 - 0
dashboard-v6/src/components/dict/utils.ts

@@ -0,0 +1,87 @@
+import type {
+  IDictRequest,
+  IDictResponse,
+  IUserDictCreate,
+} from "../../api/Dict";
+import { post } from "../../request";
+
+export const UserWbwPost = (data: IDictRequest[], view: string) => {
+  let wordData: IDictRequest[] = data;
+  data.forEach((value: IDictRequest) => {
+    if (value.parent && value.type !== "") {
+      if (!value.type?.includes("base") && value.type !== ".ind.") {
+        let pFactors = "";
+        let pFm;
+        const orgFactors = value.factors?.split("+");
+        if (
+          orgFactors &&
+          orgFactors.length > 0 &&
+          orgFactors[orgFactors.length - 1].includes("[")
+        ) {
+          pFactors = orgFactors.slice(0, -1).join("+");
+          pFm = value.factormean
+            ?.split("+")
+            .slice(0, orgFactors.length - 1)
+            .join("+");
+        }
+        let grammar = value.grammar?.split("$").slice(0, 1).join("");
+        if (value.type?.includes(".v")) {
+          grammar = "";
+        }
+        wordData.push({
+          word: value.parent,
+          type: "." + value.type?.replaceAll(".", "") + ":base.",
+          grammar: grammar,
+          mean: value.mean,
+          parent: value.parent2 ?? undefined,
+          factors: pFactors,
+          factormean: pFm,
+          confidence: value.confidence,
+          language: value.language,
+          status: value.status,
+        });
+      }
+    }
+
+    if (value.factors && value.factors.split("+").length > 0) {
+      const fm = value.factormean?.split("+");
+      const factors: IDictRequest[] = [];
+      value.factors.split("+").forEach((factor: string, index: number) => {
+        const currWord = factor.replaceAll("-", "");
+        console.debug("currWord", currWord);
+        const meaning = fm ? (fm[index].replaceAll("-", "") ?? null) : null;
+        if (meaning) {
+          factors.push({
+            word: currWord,
+            type: ".part.",
+            grammar: "",
+            mean: meaning,
+            confidence: value.confidence,
+            language: value.language,
+            status: value.status,
+          });
+        }
+
+        const subFactorsMeaning: string[] = fm ? fm[index].split("-") : [];
+        factor.split("-").forEach((subFactor, index1) => {
+          if (subFactorsMeaning[index1] && subFactorsMeaning[index1] !== "") {
+            factors.push({
+              word: subFactor,
+              type: ".part.",
+              grammar: "",
+              mean: subFactorsMeaning[index1],
+              confidence: value.confidence,
+              language: value.language,
+              status: value.status,
+            });
+          }
+        });
+      });
+      wordData = [...wordData, ...factors];
+    }
+  });
+  return post<IUserDictCreate, IDictResponse>("/v2/userdict", {
+    view: view,
+    data: JSON.stringify(wordData),
+  });
+};

+ 110 - 0
dashboard-v6/src/components/discussion/DiscussionButton.tsx

@@ -0,0 +1,110 @@
+import { Space, Tooltip } from "antd";
+
+import { count } from "../../reducers/discussion";
+import { CommentFillIcon, CommentOutlinedIcon } from "../../assets/icon";
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
+import { discussionList } from "../../reducers/discussion-count";
+import type {
+  IDiscussionCountData,
+  IDiscussionCountWbw,
+} from "../../api/Comment";
+import { useMemo } from "react";
+import type { TResType } from "../../api/discussion";
+import { openDiscussion } from "./utils";
+
+interface IWidget {
+  initCount?: number;
+  resId?: string;
+  resType?: TResType;
+  hideCount?: boolean;
+  hideInZero?: boolean;
+  onlyMe?: boolean;
+  wbw?: IDiscussionCountWbw;
+}
+const DiscussionButton = ({
+  initCount = 0,
+  resId,
+  resType = "sentence",
+  hideCount = false,
+  hideInZero = false,
+  onlyMe = false,
+  wbw,
+}: IWidget) => {
+  const user = useAppSelector(currentUser);
+  const discussions = useAppSelector(discussionList);
+  const discussionCount = useAppSelector(count);
+
+  const CommentCount = useMemo(() => {
+    if (
+      discussionCount?.resType === "sentence" &&
+      discussionCount.resId === resId
+    ) {
+      return discussionCount.count;
+    } else {
+      return initCount;
+    }
+  }, [discussionCount, resId, initCount]);
+
+  const all = discussions?.filter((value) => value.res_id === resId);
+  const my = all?.filter((value) => value.editor_uid === user?.id);
+
+  let withStudent: IDiscussionCountData[] | undefined;
+  if (wbw) {
+    withStudent = discussions?.filter(
+      (value) =>
+        value.wbw?.book_id === wbw?.book_id &&
+        value.wbw?.paragraph === wbw?.paragraph &&
+        value.wbw?.wid.toString() === wbw?.wid.toString()
+    );
+  }
+
+  //console.debug("DiscussionButton", discussions, wbw, withStudent);
+
+  let currCount = CommentCount;
+  if (onlyMe) {
+    if (my) {
+      currCount = my.length;
+    } else {
+      currCount = 0;
+    }
+  } else {
+    if (all) {
+      currCount = all.length;
+    } else {
+      currCount = 0;
+    }
+    if (withStudent) {
+      currCount += withStudent.length;
+    }
+  }
+
+  let myCount = false;
+  if (my && my.length > 0) {
+    myCount = true;
+  }
+
+  return hideInZero && currCount === 0 ? (
+    <></>
+  ) : (
+    <Tooltip title="讨论">
+      <Space
+        size={"small"}
+        style={{
+          cursor: "pointer",
+          color: currCount && currCount > 0 ? "#1890ff" : "unset",
+        }}
+        onClick={() => {
+          if (resId) {
+            openDiscussion(resId, resType, wbw ? true : false);
+          }
+        }}
+      >
+        {myCount ? <CommentFillIcon /> : <CommentOutlinedIcon />}
+        {hideCount ? <></> : currCount}
+      </Space>
+    </Tooltip>
+  );
+};
+
+export default DiscussionButton;

+ 20 - 0
dashboard-v6/src/components/discussion/utils.ts

@@ -0,0 +1,20 @@
+import type { TResType } from "../../api/discussion";
+import { show, type IShowDiscussion } from "../../reducers/discussion";
+import { openPanel } from "../../reducers/right-panel";
+import store from "../../store";
+
+export const openDiscussion = (
+  resId: string,
+  resType: TResType,
+  withStudent: boolean
+) => {
+  const data: IShowDiscussion = {
+    type: "discussion",
+    resId: resId,
+    resType: resType,
+    withStudent: withStudent,
+  };
+  console.debug("discussion show", data);
+  store.dispatch(show(data));
+  store.dispatch(openPanel("discussion"));
+};

+ 35 - 0
dashboard-v6/src/components/general/MdView.tsx

@@ -0,0 +1,35 @@
+import { Typography } from "antd";
+
+import { gfwClear } from "../../gfwlist";
+import type { TCodeConvertor } from "../../types/template";
+import { XmlToReact } from "../template/utilities";
+const { Text } = Typography;
+
+interface IWidget {
+  html?: string | null;
+  className?: string;
+  placeholder?: string;
+  wordWidget?: boolean;
+  convertor?: TCodeConvertor;
+  style?: React.CSSProperties;
+}
+const MdViewWidget = ({
+  html,
+  className,
+  wordWidget = false,
+  placeholder,
+  convertor,
+  style,
+}: IWidget) => {
+  if (html && html.trim() !== "") {
+    return (
+      <Text className={className} style={style}>
+        {XmlToReact(gfwClear(html), wordWidget, convertor)}
+      </Text>
+    );
+  } else {
+    return <Text type="secondary">{placeholder}</Text>;
+  }
+};
+
+export default MdViewWidget;

+ 195 - 0
dashboard-v6/src/components/nissaya/NissayaCard.tsx

@@ -0,0 +1,195 @@
+import { useEffect, useState, type JSX } from "react";
+import { App, Button, Modal, Popover, Skeleton, Typography } from "antd";
+import { EditOutlined, ReloadOutlined } from "@ant-design/icons";
+import { Link } from "react-router";
+import { useIntl } from "react-intl";
+
+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";
+
+const { Paragraph, Title } = Typography;
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+interface INissayaCardData {
+  row: INissayaRelation[];
+  ending: ITerm;
+}
+
+interface INissayaCardResponse {
+  ok: boolean;
+  message: string;
+  data: INissayaCardData;
+}
+
+// ---------------------------------------------------------------------------
+// Public wrappers
+// ---------------------------------------------------------------------------
+
+interface INissayaCardModal {
+  text?: string;
+  trigger?: JSX.Element | string;
+}
+
+export const NissayaCardPop = ({ text, trigger }: INissayaCardModal) => (
+  <Popover
+    style={{ width: 700 }}
+    content={<NissayaCardWidget text={text} cache hideEditButton />}
+    placement="bottom"
+  >
+    <Typography.Link>{trigger}</Typography.Link>
+  </Popover>
+);
+
+export const NissayaCardModal = ({ text, trigger }: INissayaCardModal) => {
+  const [open, setOpen] = useState(false);
+
+  return (
+    <>
+      <span onClick={() => setOpen(true)}>{trigger}</span>
+      <Modal
+        width={800}
+        title="缅文语尾"
+        open={open}
+        onOk={() => setOpen(false)}
+        onCancel={() => setOpen(false)}
+        destroyOnHidden
+      >
+        <NissayaCardWidget text={text} />
+      </Modal>
+    </>
+  );
+};
+
+// ---------------------------------------------------------------------------
+// Core widget
+// ---------------------------------------------------------------------------
+
+interface IWidgetProps {
+  text?: string;
+  cache?: boolean;
+  hideEditButton?: boolean;
+}
+
+const NissayaCardWidget = ({
+  text,
+  cache = false,
+  hideEditButton = false,
+}: IWidgetProps) => {
+  const intl = useIntl();
+  // antd v6: use App.useApp() instead of static message/notification
+  const { message } = App.useApp();
+
+  const [cardData, setCardData] = useState<INissayaRelation[]>();
+  const [term, setTerm] = useState<ITerm>();
+  const [loading, setLoading] = useState(false);
+  // Incrementing this counter triggers a manual reload
+  const [reloadTick, setReloadTick] = useState(0);
+
+  useEffect(() => {
+    if (!text) return;
+
+    const uiLang = getLang();
+    const cacheKey = `nissaya-ending/${uiLang}/${text}`;
+
+    // ── Cache hit (synchronous path) ────────────────────────────────────────
+    if (cache) {
+      const cached = sessionStorage.getItem(cacheKey);
+      if (cached) {
+        const parsed: INissayaCardData = JSON.parse(cached);
+        setCardData(parsed.row);
+        setTerm(parsed.ending);
+        return; // no network request needed
+      }
+    }
+
+    // ── Network request ─────────────────────────────────────────────────────
+    const url = `/v2/nissaya-card/${text}?lang=${uiLang}&content_type=json`;
+    console.debug("api request", url);
+
+    let cancelled = false;
+    setLoading(true);
+
+    get<INissayaCardResponse>(url)
+      .then((json) => {
+        if (cancelled) return;
+        console.debug("api response", json);
+
+        if (json.ok) {
+          setCardData(json.data.row);
+          setTerm(json.data.ending);
+          if (cache) {
+            sessionStorage.setItem(cacheKey, JSON.stringify(json.data));
+          }
+        } else {
+          message.error(json.message);
+        }
+      })
+      .catch((e: INissayaCardResponse) => {
+        if (!cancelled) message.error(e.message);
+      })
+      .finally(() => {
+        if (!cancelled) setLoading(false);
+      });
+
+    // Cleanup: mark stale requests so their callbacks are ignored
+    return () => {
+      cancelled = true;
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [cache, text, reloadTick]);
+
+  const handleReload = () => {
+    const uiLang = getLang();
+    sessionStorage.removeItem(`nissaya-ending/${uiLang}/${text}`);
+    setReloadTick((t) => t + 1);
+  };
+
+  if (loading) {
+    return <Skeleton title={{ width: 200 }} paragraph={{ rows: 4 }} active />;
+  }
+
+  return (
+    <div style={{ maxWidth: 750 }}>
+      {/* Header row */}
+      <div style={{ display: "flex", justifyContent: "space-between" }}>
+        <Title level={4}>
+          {term?.word}
+          {!hideEditButton && (
+            <TermModal
+              id={term?.id}
+              trigger={<Button type="link" icon={<EditOutlined />} />}
+            />
+          )}
+        </Title>
+
+        <div>
+          <Link to={`/nissaya/ending/${term?.word}`} target="_blank">
+            {intl.formatMessage(
+              { id: "buttons.open.in.new.tab" },
+              { item: "" }
+            )}
+          </Link>
+          <Button
+            type="link"
+            icon={<ReloadOutlined />}
+            onClick={handleReload}
+          />
+        </div>
+      </div>
+
+      <Paragraph>{term?.meaning}</Paragraph>
+      <MdView html={term?.html} />
+      {cardData && <NissayaCardTable data={cardData} />}
+    </div>
+  );
+};
+
+export default NissayaCardWidget;

+ 283 - 0
dashboard-v6/src/components/nissaya/NissayaCardTable.tsx

@@ -0,0 +1,283 @@
+import { Button, Space, Table, Tag, Typography } from "antd";
+import lodash from "lodash";
+
+import { ArrowRightOutlined } from "@ant-design/icons";
+import { useIntl } from "react-intl";
+import GrammarLookup from "../dict/GrammarLookup";
+import Marked from "../general/Marked";
+
+const { Link } = Typography;
+
+const caseTags = [
+  { case: "fpp", tags: ["verb", "derivative", "passive-verb"] },
+  { case: "ger", tags: ["verb"] },
+  { case: "inf", tags: ["verb"] },
+  { case: "grd", tags: ["verb"] },
+  { case: "pp", tags: ["verb", "participle"] },
+  { case: "prp", tags: ["verb", "participle"] },
+  { case: "v", tags: ["verb"] },
+  { case: "v:ind", tags: ["verb"] },
+  { case: "vdn", tags: ["verb", "participle"] },
+];
+interface ITags {
+  tag: string;
+  count: number;
+}
+const getCaseTags = (input: string[]): ITags[] => {
+  const tagsMap = new Map<string, number>();
+  input.forEach((value: string) => {
+    const found = caseTags.find((value1) => value1.case === value);
+    if (found !== undefined) {
+      found.tags.forEach((value3) => {
+        const count = tagsMap.get(value3);
+        if (typeof count === "undefined") {
+          tagsMap.set(value3, 1);
+        } else {
+          tagsMap.set(value3, count + 1);
+        }
+      });
+    }
+  });
+  const tags: ITags[] = [];
+  tagsMap.forEach((value, key) => {
+    tags.push({ tag: key, count: value });
+  });
+  tags.sort((a, b) => b.count - a.count);
+  return tags;
+};
+
+const randomString = () =>
+  lodash.times(20, () => lodash.random(35).toString(36)).join("");
+
+interface ICaseItem {
+  label: string;
+  case: string;
+  link: string;
+}
+interface IRelationNode {
+  case?: ICaseItem[];
+  spell?: string;
+}
+interface DataType {
+  key: string;
+  relation: string;
+  localRelation?: string;
+  tags?: ITags[];
+  to?: IRelationNode;
+  from?: IRelationNode;
+  category?: { name: string; note: string; meaning: string };
+  translation?: string;
+  isChildren?: boolean;
+  children?: DataType[];
+}
+export interface INissayaRelation {
+  from?: IRelationNode;
+  to?: IRelationNode;
+  category?: { name: string; note: string; meaning: string };
+  local_ending: string;
+  relation: string;
+  local_relation?: string;
+  relation_link: string;
+}
+interface IWidget {
+  data?: INissayaRelation[];
+}
+const NissayaCardTableWidget = ({ data }: IWidget) => {
+  const intl = useIntl();
+  let tableData: DataType[] = [];
+
+  if (typeof data === "undefined") {
+    tableData = [];
+  } else {
+    console.log("data", data);
+    const category: string[] = [];
+    const newData: DataType[] = [];
+    data.forEach((item) => {
+      if (item.category && item.category.name) {
+        const key = `${item.from?.spell}-${item.from?.case
+          ?.map((item) => item.label)
+          .join()}-${item.relation}-${item.category}`;
+        if (!category.includes(key)) {
+          category.push(key);
+          console.log("category", category);
+          //处理children
+          const children = data
+            .filter(
+              (value) =>
+                `${value.from?.spell}-${value.from?.case
+                  ?.map((item) => item.label)
+                  .join()}-${value.relation}-${value.category}` === key
+            )
+            .map((item) => {
+              return {
+                key: randomString(),
+                relation: item.relation,
+                localRelation: item.local_relation,
+                from: item.from,
+                to: item.to,
+                category: item.category,
+                translation: item.local_ending,
+                isChildren: true,
+              };
+            });
+          console.log("children", children);
+          const caseList: string[] = [];
+          children.forEach((value) => {
+            value.to?.case?.forEach((value1) => {
+              caseList.push(value1.case);
+            });
+          });
+          const tags = getCaseTags(caseList);
+          newData.push({
+            key: randomString(),
+            relation: item.relation,
+            localRelation: item.local_relation,
+            from: item.from,
+            to: item.to,
+            tags: tags,
+            category: item.category,
+            translation: item.local_ending,
+            children: children.length > 1 ? [...children] : undefined,
+          });
+        }
+      } else {
+        newData.push({
+          key: randomString(),
+          relation: item.relation,
+          localRelation: item.local_relation,
+          from: item.from,
+          to: item.to,
+          category: item.category,
+          translation: item.local_ending,
+        });
+      }
+    });
+    console.log("newData", newData);
+    tableData = newData;
+  }
+
+  return (
+    <Table
+      size="small"
+      columns={[
+        {
+          title: "本词特征",
+          dataIndex: "from",
+          key: "from",
+          width: "10%",
+          render: (_value, record) => {
+            return (
+              <Space>
+                {record.from?.case?.map((item, id) => {
+                  return (
+                    <GrammarLookup key={id} word={item.case}>
+                      <Link>
+                        <Tag>{item.label}</Tag>
+                      </Link>
+                    </GrammarLookup>
+                  );
+                })}
+                {record.from?.spell}
+              </Space>
+            );
+          },
+        },
+        {
+          title: "关系",
+          dataIndex: "relation",
+          key: "relation",
+          width: "30%",
+          render: (_value, record) => {
+            return (
+              <Space orientation="vertical">
+                <GrammarLookup word={record.relation}>
+                  <Link>{record.relation}</Link>
+                </GrammarLookup>
+                <div>{record.localRelation}</div>
+              </Space>
+            );
+          },
+        },
+        {
+          title: "目标词特征",
+          dataIndex: "to",
+          key: "to",
+          width: "20%",
+          render: (_value, record) => {
+            if (record.isChildren) {
+              return (
+                <Space>
+                  <ArrowRightOutlined />
+                  {record.to?.case?.map((item, id) => {
+                    return (
+                      <Button
+                        key={id}
+                        type="link"
+                        size="small"
+                        onClick={() => window.open(item.link, "_blank")}
+                      >
+                        <Tag key={id}>{item.label}</Tag>
+                      </Button>
+                    );
+                  })}
+                  {record.to?.spell}
+                </Space>
+              );
+            } else {
+              return (
+                <Space>
+                  <ArrowRightOutlined />
+                  {record.tags?.map((item, id) => {
+                    return (
+                      <Tag key={id}>
+                        {intl.formatMessage({
+                          id: `dict.case.category.${item.tag}`,
+                        })}
+                      </Tag>
+                    );
+                  })}
+                </Space>
+              );
+            }
+          },
+        },
+        {
+          title: "语法点",
+          dataIndex: "to",
+          key: "grammar",
+          width: "20%",
+          render: (_value, record) => {
+            if (!record.isChildren) {
+              return (
+                <GrammarLookup word={record.category?.name}>
+                  <Link>{record.category?.meaning}</Link>
+                </GrammarLookup>
+              );
+            }
+          },
+        },
+        {
+          title: "含义",
+          dataIndex: "address",
+          width: "40%",
+          key: "address",
+          render: (_value, record) => {
+            if (record.isChildren) {
+              return undefined;
+            } else {
+              return (
+                <div>
+                  <Marked text={record.category?.note} />
+                  <div>{record.translation}</div>
+                </div>
+              );
+            }
+          },
+        },
+      ]}
+      dataSource={tableData}
+    />
+  );
+};
+
+export default NissayaCardTableWidget;

+ 52 - 0
dashboard-v6/src/components/nissaya/NissayaItem.tsx

@@ -0,0 +1,52 @@
+import { Popover } from "antd";
+import { useAppSelector } from "../../hooks";
+import { settingInfo } from "../../reducers/setting";
+import PaliText from "../general/PaliText";
+import { GetUserSetting } from "../setting/default";
+import NissayaMeaning from "./NissayaMeaning";
+import { MoreIcon } from "../../assets/icon";
+
+export interface IWidgetNissayaItem {
+  original?: string;
+  pali?: string;
+  meaning?: string[];
+  lang?: string;
+  note?: string;
+  children?: React.ReactNode | React.ReactNode[];
+}
+const NissayaItem = ({ pali, meaning }: IWidgetNissayaItem) => {
+  const settings = useAppSelector(settingInfo);
+  const layout = GetUserSetting("setting.nissaya.layout.read", settings);
+  console.debug("NissayaCtl layout", layout);
+  const ect = meaning
+    ?.slice(0, -1)
+    .map((item, id) => <NissayaMeaning key={id} text={item} />);
+  return (
+    <span
+      style={{
+        display: layout === "inline" ? "inline-block" : "block",
+        marginRight: 10,
+      }}
+    >
+      <PaliText
+        lookup={true}
+        text={pali}
+        code="my"
+        termToLocal={false}
+        style={{ fontWeight: 700 }}
+      />{" "}
+      {ect && ect?.length > 0 ? (
+        <Popover content={ect}>
+          <MoreIcon />{" "}
+        </Popover>
+      ) : (
+        <></>
+      )}
+      {meaning?.slice(-1).map((item, id) => (
+        <NissayaMeaning key={id} text={item} />
+      ))}
+    </span>
+  );
+};
+
+export default NissayaItem;

+ 70 - 0
dashboard-v6/src/components/nissaya/NissayaMeaning.tsx

@@ -0,0 +1,70 @@
+import { useEffect, useState } from "react";
+import { Tag, Tooltip, Typography } from "antd";
+import { useAppSelector } from "../../hooks";
+
+import { getEnding } from "../../reducers/nissaya-ending-vocabulary";
+import Lookup from "../dict/Lookup";
+import { NissayaCardPop } from "./NissayaCard";
+import { my_to_roman } from "../../utils/code/my";
+import { nissayaBase } from "./utils";
+
+const { Text } = Typography;
+
+export interface IMeaning {
+  base: string;
+  ending?: string[];
+}
+
+interface IWidget {
+  text?: string;
+  code?: string;
+}
+
+const NissayaMeaningWidget = ({ text }: IWidget) => {
+  const [words, setWords] = useState<IMeaning[]>();
+  const endings = useAppSelector(getEnding);
+
+  const match = text?.match(/#(\d+)%/);
+  const cf = match ? parseInt(match[1]) : null;
+
+  useEffect(() => {
+    if (typeof text === "undefined" || typeof endings === "undefined") {
+      return;
+    }
+
+    const _text = text.replace(/#\d+%/, "");
+    const mWords: IMeaning[] = _text.split(" ").map((item) => {
+      return nissayaBase(item, endings);
+    });
+    setWords(mWords);
+  }, [endings, text]);
+
+  if (typeof text === "undefined") {
+    return <></>;
+  }
+
+  return (
+    <Text>
+      <>
+        {words?.map((item, id) => {
+          const result = my_to_roman(item.base);
+          return (
+            <span key={id}>
+              <Lookup search={item.base}>
+                <Tooltip title={result} mouseEnterDelay={2}>
+                  {item.base}
+                </Tooltip>
+              </Lookup>
+              {item.ending?.map((item, id) => {
+                return <NissayaCardPop text={item} key={id} trigger={item} />;
+              })}{" "}
+            </span>
+          );
+        })}
+      </>
+      <>{cf !== null && cf < 90 ? <Tag color="red">{cf}</Tag> : undefined}</>
+    </Text>
+  );
+};
+
+export default NissayaMeaningWidget;

+ 51 - 0
dashboard-v6/src/components/nissaya/NissayaSent.tsx

@@ -0,0 +1,51 @@
+import { Popover, Tag } from "antd";
+
+import NissayaItem from "./NissayaItem";
+import Marked from "../general/Marked";
+
+export interface INissaya {
+  original?: string;
+  translation?: string;
+  note?: string;
+  confidence?: number;
+}
+interface IWidget {
+  data?: INissaya[];
+}
+const NissayaSent = ({ data }: IWidget) => {
+  if (!data) {
+    return <></>;
+  }
+
+  return (
+    <>
+      {data.map((item, id) => {
+        return (
+          <span key={id}>
+            <NissayaItem
+              pali={item.original}
+              meaning={item.translation?.split(">")}
+            />
+            <>
+              {item.confidence && item.confidence < 90 ? (
+                <Tag color="red">{item.confidence}</Tag>
+              ) : undefined}
+            </>
+            <>
+              {item.note && (
+                <Popover
+                  styles={{ container: { width: 600 } }}
+                  placement="bottom"
+                  content={<Marked text={item.note} />}
+                >
+                  [nt]
+                </Popover>
+              )}
+            </>
+          </span>
+        );
+      })}
+    </>
+  );
+};
+export default NissayaSent;

+ 29 - 0
dashboard-v6/src/components/nissaya/utils.ts

@@ -0,0 +1,29 @@
+import type { IMeaning } from "./NissayaMeaning";
+
+export const nissayaBase = (item: string, endings: string[]): IMeaning => {
+  let word = item
+    .trim()
+    .replaceAll("။", "")
+    .replaceAll("[}", "")
+    .replaceAll("]", "")
+    .replaceAll("(", "")
+    .replaceAll(")", "")
+    .replaceAll("၊", "")
+    .replaceAll(",", "")
+    .replaceAll(".", "");
+
+  const end: string[] = [];
+  for (let loop = 0; loop < 3; loop++) {
+    for (let i = 0; i < word.length; i++) {
+      const ending = word.slice(i);
+      if (endings?.includes(ending)) {
+        end.unshift(word.slice(i));
+        word = word.slice(0, i);
+      }
+    }
+  }
+  return {
+    base: word,
+    ending: end,
+  };
+};

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

@@ -0,0 +1,169 @@
+import { Link } from "react-router";
+import { Badge, Card, List, message, Modal, Skeleton } from "antd";
+
+import { get } from "../../request";
+import { useEffect, useState, type JSX } from "react";
+
+import store from "../../store";
+import { change } from "../../reducers/para-change";
+import type { ITocPathNode } from "../../api/Corpus";
+import TocPath from "../tipitaka/TocPath";
+
+interface ITag {
+  id?: string;
+  name: string;
+  color?: string;
+}
+interface IRelatedParaData {
+  book: number;
+  para: number[];
+  book_title_pali: string;
+  book_title?: string;
+  cs6_para: number;
+  path?: ITocPathNode[];
+  tags?: ITag[];
+}
+interface IRelatedParaResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    rows: IRelatedParaData[];
+    count: number;
+  };
+}
+interface IWidget {
+  book?: number;
+  para?: number;
+  trigger?: JSX.Element;
+}
+const RelatedParaWidget = ({ book, para, trigger }: IWidget) => {
+  const [isModalOpen, setIsModalOpen] = useState(false);
+  const [tableData, setTableData] = useState<IRelatedParaData[]>([]);
+  const [load, setLoad] = useState(true);
+
+  const showModal = () => {
+    setIsModalOpen(true);
+  };
+
+  const handleOk = () => {
+    setIsModalOpen(false);
+  };
+
+  const handleCancel = () => {
+    setIsModalOpen(false);
+  };
+  useEffect(() => {
+    const fetchRelatedParagraphs = async () => {
+      if (typeof book === "number" && typeof para === "number" && isModalOpen) {
+        setLoad(true);
+        try {
+          const json = await get<IRelatedParaResponse>(
+            `/v2/related-paragraph?book=${book}&para=${para}`
+          );
+          console.log("import", json);
+          if (json.ok) {
+            setTableData(json.data.rows);
+          } else {
+            message.error(json.message);
+          }
+        } catch (error) {
+          message.error("请求失败,请稍后重试");
+          console.error("获取相关段落失败:", error);
+        } finally {
+          setLoad(false);
+        }
+      }
+    };
+
+    fetchRelatedParagraphs();
+  }, [book, para, isModalOpen]);
+
+  return (
+    <>
+      <span onClick={showModal}>{trigger ? trigger : "相关段落"}</span>
+      <Modal
+        title="根本和注疏"
+        open={isModalOpen}
+        onOk={handleOk}
+        onCancel={handleCancel}
+      >
+        {load ? (
+          <Skeleton paragraph={{ rows: 4 }} active />
+        ) : (
+          <List
+            itemLayout="vertical"
+            size="small"
+            split={false}
+            dataSource={tableData}
+            renderItem={(item) => {
+              const isPali = item.tags?.find((tag) => tag.name === "pāḷi");
+              const isAttha = item.tags?.find(
+                (tag) => tag.name === "aṭṭhakathā"
+              );
+              const isTika = item.tags?.find((tag) => tag.name === "ṭīkā");
+              const firstPara = item.para.length > 0 ? item.para[0] : 0;
+              return (
+                <List.Item>
+                  <Badge.Ribbon
+                    text={
+                      isPali
+                        ? "pāḷi"
+                        : isAttha
+                          ? "aṭṭhakathā"
+                          : isTika
+                            ? "ṭīkā"
+                            : undefined
+                    }
+                    color={
+                      isPali
+                        ? "volcano"
+                        : isAttha
+                          ? "green"
+                          : isTika
+                            ? "cyan"
+                            : undefined
+                    }
+                  >
+                    <Card
+                      title={
+                        <Link
+                          to={`/article/para/${item.book}-${firstPara}?book=${item.book}&par=${item.para}`}
+                          target="_blank"
+                        >
+                          {item.book_title_pali}
+                        </Link>
+                      }
+                      size="small"
+                    >
+                      <TocPath
+                        data={item.path}
+                        onChange={(node: ITocPathNode) => {
+                          if (node.book && node.paragraph) {
+                            const type = node.level
+                              ? node.level < 8
+                                ? "chapter"
+                                : "para"
+                              : "para";
+                            store.dispatch(
+                              change({
+                                book: node.book,
+                                para: node.paragraph,
+                                type: type,
+                              })
+                            );
+                          }
+                        }}
+                      />
+                    </Card>
+                  </Badge.Ribbon>
+                </List.Item>
+              );
+            }}
+          />
+        )}
+      </Modal>
+    </>
+  );
+};
+
+export default RelatedParaWidget;

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

@@ -0,0 +1,149 @@
+import { List, Popover, Typography, notification } from "antd";
+import { Space } from "antd";
+
+import User from "../auth/User";
+import Channel from "../channel/Channel";
+import TimeShow from "../general/TimeShow";
+import { MergeIcon2 } from "../../assets/icon";
+import { useEffect, useState } from "react";
+import { get } from "../../request";
+
+import dayjs from "dayjs";
+import type {
+  ISentHistoryData,
+  ISentHistoryListResponse,
+} from "../../api/sentence-history";
+import type { ISentence } from "../../api/Corpus";
+
+const { Text } = Typography;
+
+interface IFork {
+  sentId?: string;
+  highlight?: boolean;
+}
+const Fork = ({ sentId, highlight = false }: IFork) => {
+  const [data, setData] = useState<ISentHistoryData[]>();
+
+  useEffect(() => {
+    if (sentId) {
+      const url = `/v2/sent_history?view=sentence&id=${sentId}&fork=1`;
+      get<ISentHistoryListResponse>(url).then((json) => {
+        if (json.ok) {
+          setData(json.data.rows);
+        } else {
+          notification.error({ message: json.message });
+        }
+      });
+    }
+  }, [sentId]);
+  return (
+    <Popover
+      placement="bottom"
+      content={
+        <List
+          size="small"
+          header={highlight ? false : "已被修改"}
+          footer={false}
+          dataSource={data}
+          renderItem={(item) => (
+            <List.Item>
+              <Text type="secondary" style={{ fontSize: "85%" }}>
+                <Space>
+                  <User {...item.accepter} showAvatar={false} />
+                  {"fork from"}
+                  <Text>
+                    {item.fork_studio?.nickName}-{item.fork_from?.name}
+                  </Text>
+                  {"on"}
+                  <TimeShow
+                    type="secondary"
+                    title="复制"
+                    showLabel={false}
+                    createdAt={item.created_at}
+                  />
+                </Space>
+              </Text>
+            </List.Item>
+          )}
+        />
+      }
+    >
+      <span style={{ color: highlight ? "#1890ff" : "unset" }}>
+        <MergeIcon2 />
+      </span>
+    </Popover>
+  );
+};
+
+interface IMergeButton {
+  data: ISentence;
+}
+const MergeButton = ({ data }: IMergeButton) => {
+  if (data.forkAt) {
+    const fork = dayjs(data.forkAt);
+    const updated = dayjs(data.updateAt);
+    if (fork.isSame(updated)) {
+      return <Fork sentId={data.id} highlight />;
+    } else {
+      return <Fork sentId={data.id} />;
+    }
+  } else {
+    return <></>;
+  }
+};
+
+interface IDetailsWidget {
+  data: ISentence;
+  isPr?: boolean;
+}
+
+export const Details = ({ data, isPr }: IDetailsWidget) => (
+  <Space wrap>
+    {isPr ? <></> : <Channel {...data.channel} />}
+    <User {...data.editor} showAvatar={false} />
+    {data.prEditAt ? (
+      <TimeShow
+        type="secondary"
+        updatedAt={data.prEditAt}
+        createdAt={data.createdAt}
+      />
+    ) : (
+      <TimeShow
+        type="secondary"
+        updatedAt={data.updateAt}
+        createdAt={data.createdAt}
+      />
+    )}
+    <MergeButton data={data} />
+    <span style={{ display: "none" }}>
+      {data.acceptor ? (
+        <User {...data.acceptor} showAvatar={false} />
+      ) : undefined}
+      {data.acceptor ? "accept at" : undefined}
+      {data.prEditAt ? (
+        <TimeShow
+          type="secondary"
+          updatedAt={data.updateAt}
+          showLabel={false}
+        />
+      ) : undefined}
+    </span>
+  </Space>
+);
+
+interface IWidget {
+  data: ISentence;
+  isPr?: boolean;
+  compact?: boolean;
+}
+const EditInfoWidget = ({ data, isPr = false, compact = false }: IWidget) => {
+  return (
+    <div style={{ fontSize: "80%" }}>
+      <Text type="secondary">
+        {compact ? undefined : <Details data={data} isPr={isPr} />}
+      </Text>
+    </div>
+  );
+};
+
+export default EditInfoWidget;

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

@@ -0,0 +1,114 @@
+import { Divider, Space } from "antd";
+import SuggestionButton from "./SuggestionButton";
+import DiscussionButton from "../discussion/DiscussionButton";
+import type { ISentence } from "../../api/Corpus";
+import {
+  type MouseEventHandler,
+  useCallback,
+  useLayoutEffect,
+  useRef,
+  useState,
+} from "react";
+
+interface IWidget {
+  data: ISentence;
+  compact?: boolean;
+  float?: boolean;
+  hideCount?: boolean;
+  hideInZero?: boolean;
+  onMouseEnter?: MouseEventHandler | undefined;
+  onMouseLeave?: MouseEventHandler | undefined;
+}
+
+interface IFloatPosition {
+  left: number;
+  width: number;
+}
+
+const InteractiveButton = ({
+  data,
+  compact = false,
+  float = false,
+  hideCount = false,
+  hideInZero = false,
+  onMouseEnter,
+  onMouseLeave,
+}: IWidget) => {
+  const [position, setPosition] = useState<IFloatPosition>({
+    left: 0,
+    width: 0,
+  });
+  const observerRef = useRef<ResizeObserver | null>(null);
+
+  const updatePosition = useCallback((el: Element) => {
+    const rect = el.getBoundingClientRect();
+    setPosition({ left: rect.left, width: rect.width });
+  }, []);
+
+  useLayoutEffect(() => {
+    if (!float) return;
+
+    const targetNode = document.getElementsByClassName("article_shell")[0];
+    if (!targetNode) return;
+
+    // 初始化位置(在 layout effect 中读取 DOM 是合理的,
+    // 但 setState 要放在 callback/microtask 中避免 lint 警告)
+    const raf = requestAnimationFrame(() => {
+      updatePosition(targetNode);
+    });
+
+    observerRef.current = new ResizeObserver((entries) => {
+      for (const entry of entries) {
+        const rect = entry.target.getBoundingClientRect();
+        setPosition((prev) => {
+          const newWidth = entry.contentRect.width;
+          return prev.width === newWidth && prev.left === rect.left
+            ? prev
+            : { left: rect.left, width: newWidth };
+        });
+      }
+    });
+
+    observerRef.current.observe(targetNode);
+
+    const handleResize = () => updatePosition(targetNode);
+    window.addEventListener("resize", handleResize);
+
+    return () => {
+      cancelAnimationFrame(raf);
+      observerRef.current?.disconnect();
+      observerRef.current = null;
+      window.removeEventListener("resize", handleResize);
+    };
+  }, [float, updatePosition]);
+
+  const ButtonInner = (
+    <Space size="small" onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
+      <SuggestionButton
+        data={data}
+        hideCount={hideCount}
+        hideInZero={hideInZero}
+      />
+      {!compact && <Divider type="vertical" />}
+      <DiscussionButton
+        hideCount={hideCount}
+        hideInZero={hideInZero}
+        initCount={data.suggestionCount?.discussion}
+        resId={data.id}
+      />
+    </Space>
+  );
+
+  if (!float) return ButtonInner;
+
+  return (
+    <span
+      className="sent_read_interactive_button"
+      style={{ position: "absolute", left: position.left + position.width }}
+    >
+      {ButtonInner}
+    </span>
+  );
+};
+
+export default InteractiveButton;

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

@@ -0,0 +1,80 @@
+import { useState } from "react";
+import { useIntl } from "react-intl";
+import { Button, message, Tooltip } from "antd";
+import { CheckOutlined } from "@ant-design/icons";
+
+import { put } from "../../request";
+import type {
+  ISentence,
+  ISentenceRequest,
+  ISentenceResponse,
+} from "../../api/Corpus";
+import store from "../../store";
+import { accept } from "../../reducers/accept-pr";
+import { toISentence } from "../sentence/utils";
+
+interface IWidget {
+  data: ISentence;
+  onAccept?: (newData: ISentence) => void;
+}
+const PrAcceptButtonWidget = ({ data, onAccept }: IWidget) => {
+  const intl = useIntl();
+
+  const [saving, setSaving] = useState<boolean>(false);
+
+  const save = () => {
+    setSaving(true);
+    const url = `/v2/sentence/${data.book}_${data.para}_${data.wordStart}_${data.wordEnd}_${data.channel.id}`;
+    const prData = {
+      book: data.book,
+      para: data.para,
+      wordStart: data.wordStart,
+      wordEnd: data.wordEnd,
+      channel: data.channel.id,
+      content: data.content,
+      prEditor: data.editor.id,
+      prId: data.id,
+      prUuid: data.uid,
+      prEditAt: data.updateAt,
+      token: sessionStorage.getItem(data.channel.id),
+    };
+    console.debug("pr accept url", url, prData);
+    put<ISentenceRequest, ISentenceResponse>(url, prData)
+      .then((json) => {
+        console.log(json);
+        setSaving(false);
+
+        if (json.ok) {
+          message.success(intl.formatMessage({ id: "flashes.success" }));
+
+          const newData: ISentence = toISentence(json.data);
+
+          store.dispatch(accept([newData]));
+          if (typeof onAccept !== "undefined") {
+            onAccept(newData);
+          }
+        } else {
+          message.error(json.message);
+        }
+      })
+      .catch((e) => {
+        setSaving(false);
+        console.error("catch", e);
+        message.error(e.message);
+      });
+  };
+
+  return (
+    <Tooltip title="采纳此修改建议">
+      <Button
+        size="small"
+        type="text"
+        icon={<CheckOutlined />}
+        loading={saving}
+        onClick={() => save()}
+      />
+    </Tooltip>
+  );
+};
+
+export default PrAcceptButtonWidget;

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

@@ -0,0 +1,49 @@
+import { Button } from "antd";
+import { useState } from "react";
+import { PlusOutlined } from "@ant-design/icons";
+
+import { useIntl } from "react-intl";
+import type { IChannel, TChannelType } from "../../api/Channel";
+import ChannelTableModal from "../channel/ChannelTableModal";
+
+interface IWidget {
+  disableChannels?: string[];
+  type?: TChannelType;
+  onSelect?: (channel: IChannel) => void;
+}
+const Widget = ({
+  disableChannels,
+  type = "translation",
+  onSelect,
+}: IWidget) => {
+  const [channelPickerOpen, setChannelPickerOpen] = useState(false);
+  const intl = useIntl();
+  return (
+    <ChannelTableModal
+      disableChannels={disableChannels}
+      channelType={type}
+      trigger={
+        <Button
+          type="dashed"
+          style={{ width: 300 }}
+          icon={<PlusOutlined />}
+          onClick={() => {
+            setChannelPickerOpen(true);
+          }}
+        >
+          {intl.formatMessage({ id: "buttons.new" })}
+        </Button>
+      }
+      open={channelPickerOpen}
+      onClose={() => setChannelPickerOpen(false)}
+      onSelect={(channel: IChannel) => {
+        setChannelPickerOpen(false);
+        if (typeof onSelect !== "undefined") {
+          onSelect(channel);
+        }
+      }}
+    />
+  );
+};
+
+export default Widget;

+ 35 - 0
dashboard-v6/src/components/sentence-editor/SentAttachment.tsx

@@ -0,0 +1,35 @@
+import { useEffect, useState } from "react";
+import type {
+  IResAttachmentData,
+  IResAttachmentListResponse,
+} from "../../api/Attachments";
+import { get } from "../../request";
+
+interface IWidget {
+  sentenceId?: string;
+}
+const SentAttachment = ({ sentenceId }: IWidget) => {
+  const [Attachments, setAttachments] = useState<IResAttachmentData[]>();
+  useEffect(() => {
+    if (!sentenceId) {
+      return;
+    }
+    const url = `/v2/sentence-attachment?view=sentence&id=${sentenceId}`;
+    console.debug("api request", url);
+    get<IResAttachmentListResponse>(url).then((json) => {
+      console.debug("api response", json);
+      if (json.ok) {
+        setAttachments(json.data.rows);
+      }
+    });
+  }, [sentenceId]);
+  return (
+    <>
+      {Attachments?.map((item, id) => {
+        return <img key={id} src={item.attachment.url} alt="img" />;
+      })}
+    </>
+  );
+};
+
+export default SentAttachment;

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

@@ -0,0 +1,185 @@
+import { Button, message } from "antd";
+import { useCallback, useEffect, useState } from "react";
+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 SentCell from "./SentCell";
+import SentAdd from "./SentAdd";
+import { useAppSelector } from "../../hooks";
+import { currentUser as _currentUser } from "../../reducers/current-user";
+import { toISentence } from "../sentence/utils";
+import type { IWbw } from "../../types/wbw";
+
+interface IWidget {
+  book: number;
+  para: number;
+  wordStart: number;
+  wordEnd: number;
+  type: TChannelType;
+  channelsId?: string[];
+  origin?: ISentence[];
+  onReload?: () => void;
+  onCreate?: () => void;
+}
+const SentCanReadWidget = ({
+  book,
+  para,
+  wordStart,
+  wordEnd,
+  type,
+  channelsId,
+  origin,
+  onReload,
+  onCreate,
+}: IWidget) => {
+  const [sentData, setSentData] = useState<ISentence[]>([]);
+  const [channels, setChannels] = useState<string[]>();
+  const user = useAppSelector(_currentUser);
+
+  const load = useCallback(() => {
+    const sentId = `${book}-${para}-${wordStart}-${wordEnd}`;
+    let url = `/v2/sentence?view=sent-can-read&sentence=${sentId}&type=${type}&mode=edit&html=true`;
+    url += channelsId ? `&excludes=${channelsId.join()}` : "";
+    if (type === "commentary" || type === "similar") {
+      url += channelsId ? `&channels=${channelsId.join()}` : "";
+    }
+    console.info("ai request", url);
+    get<ISentenceListResponse>(url)
+      .then((json) => {
+        if (json.ok) {
+          const channels: string[] = json.data.rows.map(
+            (item) => item.channel.id
+          );
+          setChannels(channels);
+          const newData: ISentence[] = json.data.rows.map((item) =>
+            toISentence(item, channelsId)
+          );
+          setSentData(newData);
+        } else {
+          message.error(json.message);
+        }
+      })
+      .finally(() => {
+        onReload?.();
+      });
+  }, [book, para, wordStart, wordEnd, type, channelsId, onReload]);
+
+  useEffect(() => {
+    load();
+  }, [load]);
+
+  return (
+    <div>
+      <div style={{ display: "flex", justifyContent: "space-between" }}>
+        <span></span>
+        <Button
+          type="link"
+          shape="round"
+          icon={<ReloadOutlined />}
+          onClick={() => load()}
+        />
+      </div>
+      <div style={{ textAlign: "center" }}>
+        <SentAdd
+          disableChannels={channels}
+          type={type}
+          onSelect={(channel: IChannel) => {
+            if (typeof user === "undefined") {
+              return;
+            }
+            const newSent: ISentence = {
+              content: "",
+              contentType: "markdown",
+              html: "",
+              book: book,
+              para: para,
+              wordStart: wordStart,
+              wordEnd: wordEnd,
+              editor: {
+                id: user.id,
+                nickName: user.nickName,
+                userName: user.realName,
+              },
+              channel: channel,
+              translationChannels: channelsId,
+              updateAt: "",
+              openInEditMode: true,
+            };
+            setSentData((origin) => {
+              return [newSent, ...origin];
+            });
+
+            setChannels((origin) => {
+              if (origin) {
+                if (!origin.includes(newSent.channel.id)) {
+                  origin.push(newSent.channel.id);
+                  return origin;
+                }
+              } else {
+                return [newSent.channel.id];
+              }
+            });
+            if (typeof onCreate !== "undefined") {
+              onCreate();
+            }
+          }}
+        />
+      </div>
+      {sentData.map((item, id) => {
+        let diffText: string | null = null;
+        if (origin) {
+          diffText = origin[0].html;
+          if (origin[0].contentType === "json" && origin[0].content) {
+            const wbw = JSON.parse(origin[0].content) as IWbw[];
+            console.debug("wbw data", wbw);
+            diffText = wbw
+              .filter((value) => {
+                if (value.style && value.style.value === "note") {
+                  return false;
+                } else if (value.type && value.type.value === ".ctl.") {
+                  return false;
+                } else {
+                  return true;
+                }
+              })
+              .map(
+                (item) =>
+                  `${item.word.value
+                    .replaceAll("{", "**")
+                    .replaceAll("}", "**")}`
+              )
+              .join(" ");
+          }
+          console.debug("origin", origin);
+        }
+
+        return (
+          <SentCell
+            value={item}
+            key={id}
+            isPr={false}
+            diffText={diffText}
+            showDiff={origin ? true : false}
+            editMode={item.openInEditMode}
+            onChange={(value: ISentence) => {
+              console.debug("onChange", value);
+              setSentData((origin) => {
+                origin.forEach((value1, index, array) => {
+                  if (value1.id === value.id) {
+                    array[index] = value;
+                  }
+                });
+                return origin;
+              });
+            }}
+          />
+        );
+      })}
+    </div>
+  );
+};
+
+export default SentCanReadWidget;

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

@@ -0,0 +1,114 @@
+import { Badge, Button, List, Popover, Tooltip, Typography } from "antd";
+import { useEffect, useRef, useState } from "react";
+import { ShoppingCartOutlined, DeleteOutlined } from "@ant-design/icons";
+
+import "./style.css";
+
+const { Text } = Typography;
+
+export interface ISentCart {
+  id: string;
+  text: string;
+}
+
+const SentCartWidget = () => {
+  const [sentences, setSentences] = useState<ISentCart[]>([]);
+  const sentencesRef = useRef(sentences);
+
+  // Keep ref in sync so the interval callback always sees latest value
+  useEffect(() => {
+    sentencesRef.current = sentences;
+  }, [sentences]);
+
+  // Derive count directly — no need for a separate state
+  const count = sentences.length || undefined;
+
+  // Sync sentences → localStorage whenever it changes
+  useEffect(() => {
+    localStorage.setItem("cart/text", JSON.stringify(sentences));
+  }, [sentences]);
+
+  // Poll localStorage for external changes (e.g. another tab / component)
+  useEffect(() => {
+    const syncFromStorage = () => {
+      const raw = localStorage.getItem("cart/text");
+      const next: ISentCart[] = raw ? JSON.parse(raw) : [];
+      // Only call setState when the data actually changed to avoid extra renders
+      if (JSON.stringify(next) !== JSON.stringify(sentencesRef.current)) {
+        setSentences(next);
+      }
+    };
+
+    syncFromStorage(); // initial load
+    const timer = setInterval(syncFromStorage, 2000);
+    return () => clearInterval(timer);
+  }, []); // empty deps — runs once, uses ref for comparison
+
+  return (
+    <>
+      <Popover
+        placement="bottomRight"
+        arrow={{ pointAtCenter: true }}
+        destroyOnHidden
+        getTooltipContainer={() =>
+          document.getElementsByClassName("toolbar_center")[0] as HTMLElement
+        }
+        content={
+          <div>
+            <div style={{ display: "flex", justifyContent: "space-between" }}>
+              <div>{"复制句子编号"}</div>
+              <div>
+                <Text
+                  disabled={sentences.length === 0}
+                  copyable={{
+                    text: sentences.map((item) => item.id).join("\n"),
+                  }}
+                />
+                <Tooltip title="清空列表保留剪贴板数据">
+                  <Button
+                    disabled={sentences.length === 0}
+                    type="link"
+                    danger
+                    icon={<DeleteOutlined />}
+                    onClick={() => setSentences([])}
+                  />
+                </Tooltip>
+              </div>
+            </div>
+            <div style={{ width: 450, height: 300, overflowY: "auto" }}>
+              <List
+                size="small"
+                dataSource={sentences}
+                renderItem={(item, index) => (
+                  <List.Item key={item.id} className="cart_item">
+                    <List.Item.Meta title={item.id} description={item.text} />
+                    <Button
+                      className="cart_delete"
+                      type="link"
+                      danger
+                      icon={<DeleteOutlined />}
+                      onClick={() => {
+                        setSentences((prev) =>
+                          prev.filter((_, i) => i !== index)
+                        );
+                      }}
+                    />
+                  </List.Item>
+                )}
+              />
+            </div>
+          </div>
+        }
+        trigger="click"
+      >
+        <Badge style={{ cursor: "pointer" }} count={count} size="small">
+          <span style={{ color: "white", cursor: "pointer" }}>
+            <ShoppingCartOutlined />
+          </span>
+        </Badge>
+      </Popover>
+    </>
+  );
+};
+
+export default SentCartWidget;

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

@@ -0,0 +1,511 @@
+import { useEffect, useMemo, useState } from "react";
+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 SentEditMenu from "./SentEditMenu";
+import SentCellEditable from "./SentCellEditable";
+
+import EditInfo, { Details } from "./EditInfo";
+import SuggestionToolbar from "./SuggestionToolbar";
+import { useAppSelector } from "../../hooks";
+import { accept, doneSent, done, sentence } from "../../reducers/accept-pr";
+
+import SentWbwEdit from "./SentWbwEdit";
+import { getEnding } from "../../reducers/nissaya-ending-vocabulary";
+
+import { anchor, message } from "../../reducers/discussion";
+import TextDiff from "../general/TextDiff";
+
+import type { IDeleteResponse } from "../../api/Article";
+import { delete_, get } from "../../request";
+
+import "./style.css";
+import StudioName from "../auth/Studio";
+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 SentAttachment from "./SentAttachment";
+
+import type { IWbw } from "../../types/wbw";
+import { my_to_roman } from "../../utils/code/my";
+import { nissayaBase } from "../nissaya/utils";
+import { toISentence } from "../sentence/utils";
+import NissayaSent from "../nissaya/NissayaSent";
+import { sentSave } from "../../api/sentence";
+import MdView from "../general/MdView";
+
+interface ISnowFlakeResponse {
+  ok: boolean;
+  message?: string;
+  data: {
+    rows: string;
+    count: number;
+  };
+}
+
+interface IWidget {
+  initValue?: ISentence;
+  value?: ISentence;
+  wordWidget?: boolean;
+  isPr?: boolean;
+  editMode?: boolean;
+  compact?: boolean;
+  showDiff?: boolean;
+  diffText?: string | null;
+  onChange?: (data: ISentence) => void;
+  onDelete?: () => void;
+}
+const SentCellWidget = ({
+  initValue,
+  value,
+  wordWidget = false,
+  isPr = false,
+  editMode = false,
+  compact = false,
+  showDiff = false,
+  diffText,
+  onChange,
+  onDelete,
+}: IWidget) => {
+  console.debug("SentCell render", value);
+  const intl = useIntl();
+  const [isEditMode, setIsEditMode] = useState(editMode);
+  const [sentData, setSentData] = useState<ISentence | undefined>(initValue);
+  const [loading, setLoading] = useState(false);
+  const [uuid] = useState(randomString());
+  const endings = useAppSelector(getEnding);
+  const acceptPr = useAppSelector(sentence);
+  const changedSent = useAppSelector(doneSent);
+
+  const [prOpen, setPrOpen] = useState(false);
+  const discussionMessage = useAppSelector(message);
+  const anchorInfo = useAppSelector(anchor);
+  const [copyOpen, setCopyOpen] = useState<boolean>(false);
+
+  const sentId = `${sentData?.book}-${sentData?.para}-${sentData?.wordStart}-${sentData?.wordEnd}`;
+  const sid = `${sentData?.book}_${sentData?.para}_${sentData?.wordStart}_${sentData?.wordEnd}_${sentData?.channel?.id}`;
+
+  const bgColor = useMemo(() => {
+    if (
+      discussionMessage &&
+      discussionMessage.resId &&
+      discussionMessage.resId === initValue?.id
+    ) {
+      return "#1890ff33";
+    } else {
+      return undefined;
+    }
+  }, [discussionMessage, initValue?.id]);
+
+  useEffect(() => {
+    if (anchorInfo && anchorInfo?.resId === initValue?.id) {
+      const ele = document.getElementById(sid);
+      if (ele !== null) {
+        ele.scrollIntoView({
+          behavior: "smooth",
+          block: "center",
+          inline: "nearest",
+        });
+      }
+    }
+  }, [anchorInfo, initValue?.id, sid]);
+
+  useEffect(() => {
+    if (value) {
+      setSentData(value);
+    }
+  }, [value]);
+
+  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;
+    }
+
+    const found = acceptPr
+      .filter((value) => typeof value !== "undefined")
+      .find((value) => {
+        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));
+    }
+  }, [acceptPr, sentData, isPr, uuid, changedSent, sid]);
+
+  const deletePr = (id: string) => {
+    delete_<IDeleteResponse>(`/v2/sentpr/${id}`)
+      .then((json) => {
+        if (json.ok) {
+          AntdMessage.success("删除成功");
+          if (typeof onDelete !== "undefined") {
+            onDelete();
+          }
+        } else {
+          AntdMessage.error(json.message);
+        }
+      })
+      .catch((e) => console.log("Oops errors!", e));
+  };
+
+  const refresh = () => {
+    if (typeof sentData === "undefined") {
+      return;
+    }
+    let url = `/v2/sentence?view=channel&sentence=${sentId}&html=true`;
+    url += `&channel=${sentData.channel.id}`;
+    console.debug("api request", url);
+    setLoading(true);
+    get<ISentenceListResponse>(url)
+      .then((json) => {
+        console.debug("api response", json);
+
+        if (json.ok && json.data.count > 0) {
+          const newData: ISentence[] = json.data.rows.map((item) => {
+            return toISentence(item, [sentData.channel.id]);
+          });
+          setSentData(newData[0]);
+        }
+      })
+      .finally(() => setLoading(false));
+  };
+
+  return (
+    <div style={{ marginBottom: "8px", backgroundColor: bgColor }}>
+      {loading ? <LoadingOutlined /> : <></>}
+      {isPr ? undefined : (
+        <div
+          dangerouslySetInnerHTML={{
+            __html: `<div class="tran_sent" id="${sid}" ></div>`,
+          }}
+        />
+      )}
+      <SentEditMenu
+        isPr={isPr}
+        data={sentData}
+        onModeChange={(mode: string) => {
+          if (mode === "edit") {
+            setIsEditMode(true);
+          }
+        }}
+        onMenuClick={(key: string) => {
+          switch (key) {
+            case "refresh":
+              refresh();
+              break;
+            case "copy-to":
+              setCopyOpen(true);
+              break;
+            case "suggestion":
+              setPrOpen(true);
+              break;
+            case "paste":
+              navigator.clipboard.readText().then((value: string) => {
+                if (sentData && value !== "") {
+                  sentData.content = value;
+                  const newSent = sentSave(sentData, intl);
+                  newSent.then((value) => {
+                    //发布句子的改变,让同样的句子更新
+                    if (value) {
+                      const newData: ISentence = toISentence(value);
+                      store.dispatch(accept([newData]));
+                      if (typeof onChange !== "undefined") {
+                        onChange(newData);
+                      }
+                    }
+                  });
+                }
+              });
+              break;
+            case "delete":
+              Modal.confirm({
+                icon: <ExclamationCircleOutlined />,
+                title: intl.formatMessage({
+                  id: "message.delete.confirm",
+                }),
+
+                content: "",
+                okText: intl.formatMessage({
+                  id: "buttons.delete",
+                }),
+                okType: "danger",
+                cancelText: intl.formatMessage({
+                  id: "buttons.no",
+                }),
+                onOk() {
+                  if (isPr && sentData && sentData.id) {
+                    deletePr(sentData.id);
+                  }
+                },
+              });
+              break;
+            default:
+              break;
+          }
+        }}
+        onConvert={async (format: string) => {
+          switch (format) {
+            case "json": {
+              const wbw: IWbw[] = sentData?.content
+                ? sentData.content
+                    .split("\n")
+                    .filter((value) => value.trim().length > 0)
+                    .map((item, id) => {
+                      const parts = item.split("=");
+                      const word = my_to_roman(parts[0]);
+                      const meaning: string =
+                        parts.length > 1
+                          ? parts[1]
+                              .trim()
+                              .replaceAll("။", "")
+                              .replaceAll("(", " ( ")
+                              .replaceAll(")", " ) ")
+                          : "";
+                      const translation: string =
+                        parts.length > 2 ? parts[2].trim() : "";
+                      let parent: string = "";
+                      let factors: string = "";
+                      const factor1 = meaning
+                        .split(" ")
+                        .filter((value) => value !== "");
+                      factors = factor1
+                        .map((item) => {
+                          if (endings) {
+                            const base = nissayaBase(item, endings);
+                            if (factor1.length === 1) {
+                              parent = base.base;
+                            }
+                            const end = base.ending ? base.ending : [];
+                            return [base.base, ...end]
+                              .filter((value) => value !== "")
+                              .join("-");
+                          } else {
+                            return item;
+                          }
+                        })
+                        .join("+");
+                      return {
+                        uid: "0",
+                        book: sentData.book,
+                        para: sentData.para,
+                        sn: [id],
+                        word: { value: word ? word : parts[0], status: 0 },
+                        real: { value: meaning, status: 0 },
+                        meaning: { value: translation, status: 0 },
+                        parent: { value: parent, status: 0 },
+                        factors: {
+                          value: factors,
+                          status: 0,
+                        },
+                        confidence: 0.5,
+                      };
+                    })
+                : [];
+              if (wbw.length > 0) {
+                const snowflake = await get<ISnowFlakeResponse>(
+                  `/v2/snowflake?count=${wbw.length}`
+                );
+                wbw.forEach((_value: IWbw, index: number, array: IWbw[]) => {
+                  array[index].uid = snowflake.data.rows[index];
+                });
+              }
+
+              if (sentData) {
+                const newData = JSON.parse(JSON.stringify(sentData));
+                newData.contentType = "json";
+                newData.content = JSON.stringify(wbw);
+                setSentData(newData);
+                sentSave(newData, intl);
+              }
+
+              setIsEditMode(true);
+              break;
+            }
+            case "markdown":
+              Modal.confirm({
+                title: "格式转换",
+                content:
+                  "转换为markdown格式后,拆分意思数据会丢失。确定要转换吗?",
+                onOk() {
+                  if (sentData) {
+                    const newData = JSON.parse(JSON.stringify(sentData));
+                    const wbwData: IWbw[] = newData.content
+                      ? JSON.parse(newData.content)
+                      : [];
+                    const newContent = wbwData
+                      .filter((value) => value.sn.length === 1)
+                      .map((item) => {
+                        return [
+                          item.word.value,
+                          item.real.value,
+                          item.meaning?.value,
+                        ].join("=");
+                      })
+                      .join("\n");
+                    newData.content = newContent;
+                    newData["contentType"] = "markdown";
+                    sentSave(newData, intl);
+                    setSentData(newData);
+                  }
+                  setIsEditMode(true);
+                },
+              });
+
+              break;
+          }
+        }}
+      >
+        {sentData ? (
+          <div style={{ display: "flex" }}>
+            <div style={{ marginRight: 8 }}>
+              {isPr ? (
+                <User {...sentData.editor} showName={false} />
+              ) : (
+                <StudioName
+                  data={sentData.studio}
+                  hideName
+                  popOver={
+                    compact ? (
+                      <Details data={sentData} isPr={isPr} />
+                    ) : undefined
+                  }
+                />
+              )}
+            </div>
+            <div
+              style={{
+                display: "flex",
+                flexDirection: compact ? "row" : "column",
+                alignItems: "flex-start",
+                width: "100%",
+              }}
+            >
+              {isEditMode ? (
+                sentData?.contentType === "json" ? (
+                  <SentWbwEdit
+                    data={sentData}
+                    onClose={() => {
+                      setIsEditMode(false);
+                    }}
+                    onSave={(data: ISentence) => {
+                      console.debug("sent cell onSave", data);
+                      setSentData(data);
+                    }}
+                  />
+                ) : (
+                  <SentCellEditable
+                    data={sentData}
+                    isPr={isPr}
+                    onClose={() => {
+                      setIsEditMode(false);
+                    }}
+                    onSave={(data: ISentence) => {
+                      console.debug("sent cell onSave", data);
+                      //setSentData(data);
+                      store.dispatch(accept([data]));
+                      setIsEditMode(false);
+                      if (typeof onChange !== "undefined") {
+                        onChange(data);
+                      }
+                    }}
+                  />
+                )
+              ) : showDiff ? (
+                <TextDiff
+                  showToolTip={false}
+                  content={sentData.content}
+                  oldContent={diffText}
+                />
+              ) : sentData.channel.type === "nissaya" ? (
+                <NissayaSent data={JSON.parse(sentData.content ?? "[])")} />
+              ) : (
+                <MdView
+                  className="sentence"
+                  style={{
+                    width: "100%",
+                    marginBottom: 0,
+                  }}
+                  placeholder={intl.formatMessage({
+                    id: "labels.input",
+                  })}
+                  html={sentData.html ? sentData.html : sentData.content}
+                  wordWidget={wordWidget}
+                />
+              )}
+              <div
+                style={{
+                  display: "flex",
+                  justifyContent: "space-between",
+                  width: compact ? undefined : "100%",
+                  paddingRight: 20,
+                  flexWrap: "wrap",
+                }}
+              >
+                <EditInfo data={sentData} isPr={isPr} compact={compact} />
+                <SuggestionToolbar
+                  style={{
+                    marginBottom: 0,
+                    justifyContent: "flex-end",
+                    marginLeft: "auto",
+                  }}
+                  compact={compact}
+                  data={sentData}
+                  isPr={isPr}
+                  prOpen={prOpen}
+                  onPrClose={() => setPrOpen(false)}
+                  onDelete={() => {
+                    if (isPr && sentData.id) {
+                      deletePr(sentData.id);
+                    }
+                  }}
+                />
+              </div>
+            </div>
+          </div>
+        ) : undefined}
+      </SentEditMenu>
+
+      <CopyToModal
+        important
+        sentencesId={[sentId]}
+        channel={sentData?.channel}
+        open={copyOpen}
+        onClose={() => setCopyOpen(false)}
+      />
+      <Collapse
+        bordered={false}
+        style={{ display: "none", backgroundColor: "unset" }}
+      >
+        <Collapse.Panel
+          header={"attachment"}
+          key="parent2"
+          style={{ backgroundColor: "unset" }}
+        >
+          <SentAttachment sentenceId={sentData?.id} />
+        </Collapse.Panel>
+      </Collapse>
+    </div>
+  );
+};
+
+export default SentCellWidget;

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

@@ -0,0 +1,202 @@
+import { useCallback, useState } from "react";
+import { useIntl } from "react-intl";
+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 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";
+
+const { Text } = Typography;
+
+interface IWidget {
+  data: ISentence;
+  isPr?: boolean;
+  isCreatePr?: boolean;
+  onSave?: (data: ISentence) => void;
+  onPrSave?: () => void;
+  onClose?: () => void;
+  onCreate?: () => void;
+}
+const SentCellEditable = ({
+  data,
+  onPrSave,
+  onClose,
+  onCreate,
+  isPr = false,
+  isCreatePr = false,
+}: IWidget) => {
+  const intl = useIntl();
+  const [value, setValue] = useState(data.content);
+  const [saving, setSaving] = useState<boolean>(false);
+  const sentWords = useAppSelector(wordList);
+
+  const sentId = `${data.book}-${data.para}-${data.wordStart}-${data.wordEnd}`;
+  const termList = sentWords.find((value) => value.sentId === sentId)?.words;
+  const save = () => {
+    if (!value) {
+      return;
+    }
+    setSaving(true);
+    sentSave({ ...data, content: value }, intl)
+      .then((value) => {
+        if (value) {
+          message.success(intl.formatMessage({ id: "flashes.success" }));
+        }
+      })
+      .finally(() => {
+        setSaving(false);
+      });
+  };
+
+  const createPr = useCallback(() => {
+    if (!value) {
+      return;
+    }
+    setSaving(true);
+    const newData: ISentencePrRequest = {
+      book: data.book,
+      para: data.para,
+      begin: data.wordStart,
+      end: data.wordEnd,
+      channel: data.channel.id,
+      text: value ?? "",
+    };
+    post<ISentencePrRequest, ISentencePrResponse>(`/v2/sentpr`, newData)
+      .then((json) => {
+        if (json.ok) {
+          message.success(intl.formatMessage({ id: "flashes.success" }));
+          if (typeof onCreate !== "undefined") {
+            onCreate();
+          }
+        } else {
+          message.error(json.message);
+        }
+      })
+      .catch((e) => {
+        console.error("catch", e);
+        message.error(e.message);
+      })
+      .finally(() => {
+        setSaving(false);
+      });
+  }, [data, intl, onCreate, value]);
+
+  const updatePr = useCallback(() => {
+    if (!value) {
+      return;
+    }
+    setSaving(true);
+    const url = `/v2/sentpr/${data.id}`;
+    console.log("url", url);
+    put<ISentencePrRequest, ISentencePrResponse>(url, {
+      text: value,
+    })
+      .then((json) => {
+        if (json.ok) {
+          message.success(intl.formatMessage({ id: "flashes.success" }));
+          if (typeof onPrSave !== "undefined") {
+            onPrSave();
+          }
+        } else {
+          message.error(json.message);
+        }
+      })
+      .finally(() => {
+        setSaving(false);
+      })
+      .catch((e) => {
+        console.error("catch", e);
+        message.error(e.message);
+      });
+  }, [data.id, intl, onPrSave, value]);
+
+  const savePr = () => {
+    if (isCreatePr) {
+      createPr();
+    } else {
+      updatePr();
+    }
+  };
+
+  return (
+    <Typography.Paragraph style={{ width: "100%" }}>
+      <TermTextArea
+        value={value ? value : ""}
+        menuOptions={termList}
+        onChange={(value: string) => {
+          setValue(value);
+        }}
+        placeholder={intl.formatMessage({
+          id: "labels.input",
+        })}
+        onClose={() => {
+          if (typeof onClose !== "undefined") {
+            onClose();
+          }
+        }}
+        onSave={(value?: string) => {
+          if (value) {
+            setValue(value);
+            if (isPr) {
+              savePr();
+            } else {
+              save();
+            }
+          }
+        }}
+      />
+      <div style={{ display: "flex", justifyContent: "space-between" }}>
+        <div>
+          <span>
+            <Text keyboard>esc</Text>=
+            <Button
+              size="small"
+              type="link"
+              onClick={() => {
+                if (typeof onClose !== "undefined") {
+                  onClose();
+                }
+              }}
+            >
+              {intl.formatMessage({ id: "buttons.cancel" })}
+            </Button>
+          </span>
+          <span>
+            <Text keyboard>enter</Text>=
+            <Button size="small" type="link">
+              new line
+            </Button>
+          </span>
+          <Text keyboard style={{ cursor: "pointer" }}>
+            <Builder trigger={"<t>"} />
+          </Text>
+        </div>
+        <div>
+          <Text keyboard>Ctrl/⌘</Text>➕<Text keyboard>enter</Text>=
+          <Button
+            size="small"
+            type="primary"
+            icon={<SaveOutlined />}
+            loading={saving}
+            onClick={() => (isPr ? savePr() : save())}
+          >
+            {intl.formatMessage({ id: "buttons.save" })}
+          </Button>
+        </div>
+      </div>
+    </Typography.Paragraph>
+  );
+};
+
+export default SentCellEditable;

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

@@ -0,0 +1,213 @@
+import SentCell from "./SentCell";
+import { useAppSelector } from "../../hooks";
+import { settingInfo } from "../../reducers/setting";
+import { useEffect, useMemo, useRef, useState } from "react";
+
+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 { IWbw } from "../../types/wbw";
+import { GetUserSetting } from "../setting/default";
+import NissayaSent from "../nissaya/NissayaSent";
+import WbwSentCtl from "../wbw/WbwSentCtl";
+
+interface ILayoutFlex {
+  left: number;
+  right: number;
+}
+type TDirection = "row" | "column";
+
+interface IWidgetSentContent {
+  sid?: string;
+  book: number;
+  para: number;
+  wordStart: number;
+  wordEnd: number;
+  origin?: ISentence[];
+  translation?: ISentence[];
+  answer?: ISentence;
+  layout?: TDirection;
+  magicDict?: string;
+  compact?: boolean;
+  mode?: ArticleMode;
+  wbwProgress?: boolean;
+  readonly?: boolean;
+  onWbwChange?: (data: IWbw[]) => void;
+  onTranslationChange?: (data: ISentence) => void;
+  onMagicDictDone?: () => void;
+}
+
+const SentContentWidget = ({
+  sid,
+  book,
+  para,
+  wordStart,
+  wordEnd,
+  origin,
+  translation,
+  answer,
+  layout = "column",
+  compact = false,
+  mode,
+  wbwProgress = false,
+  readonly = false,
+  onWbwChange,
+  onTranslationChange,
+  onMagicDictDone,
+}: IWidgetSentContent) => {
+  const divShell = useRef<HTMLDivElement>(null);
+  const settings = useAppSelector(settingInfo);
+  const newMode = useAppSelector(_mode);
+
+  // Track container width via ResizeObserver — no setState inside effect
+  const [containerWidth, setContainerWidth] = useState<number>(0);
+
+  useEffect(() => {
+    const el = divShell.current;
+    if (!el) return;
+
+    // Set initial width
+    setContainerWidth(el.offsetWidth);
+
+    const observer = new ResizeObserver((entries) => {
+      const entry = entries[0];
+      if (entry) {
+        setContainerWidth(entry.contentRect.width);
+      }
+    });
+    observer.observe(el);
+    return () => observer.disconnect();
+  }, []);
+
+  // Derive layout direction from width + user setting — no setState in effect
+  const layoutDirection = useMemo<TDirection>(() => {
+    if (containerWidth > 0 && containerWidth < 550) return "column";
+    if (containerWidth === 0) return layout; // SSR / first render fallback
+
+    const userDirection = GetUserSetting("setting.layout.direction", settings);
+    if (typeof userDirection === "string") {
+      return userDirection as TDirection;
+    }
+    return layout;
+  }, [containerWidth, layout, settings]);
+
+  // Derive layout flex from mode — no setState in effect
+  const layoutFlex = useMemo<ILayoutFlex>(() => {
+    let currMode: ArticleMode | undefined;
+
+    if (typeof mode !== "undefined") {
+      currMode = mode;
+    } else if (typeof newMode !== "undefined") {
+      if (typeof newMode.id === "undefined") {
+        currMode = newMode.mode;
+      } else {
+        const sentId = newMode.id.split("-");
+        if (sentId.length === 2) {
+          if (book === parseInt(sentId[0]) && para === parseInt(sentId[1])) {
+            currMode = newMode.mode;
+          }
+        }
+      }
+    }
+
+    switch (currMode) {
+      case "wbw":
+        return { left: 7, right: 3 };
+      case "edit":
+      default:
+        return { left: 5, right: 5 };
+    }
+  }, [book, mode, newMode, para]);
+
+  // Sync sentence data to 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]);
+
+  return (
+    <div
+      ref={divShell}
+      style={{
+        display: "flex",
+        flexDirection: layoutDirection,
+        marginBottom: 0,
+      }}
+    >
+      <div
+        dangerouslySetInnerHTML={{
+          __html: `<div class="pcd_sent" id="sent_${sid}"></div>`,
+        }}
+      />
+      <div style={{ flex: layoutFlex.left, color: "#9f3a01" }}>
+        {origin?.map((item, id) => {
+          if (item.contentType === "json") {
+            if (item.channel.type === "nissaya") {
+              return (
+                <NissayaSent key={id} data={JSON.parse(item.content ?? "[]")} />
+              );
+            } else {
+              return (
+                <WbwSentCtl
+                  key={id}
+                  book={book}
+                  para={para}
+                  wordStart={wordStart}
+                  wordEnd={wordEnd}
+                  studio={item.studio}
+                  channelId={item.channel.id}
+                  channelType={item.channel.type}
+                  channelLang={item.channel.lang}
+                  data={JSON.parse(item.content ?? "")}
+                  answer={answer ? JSON.parse(answer.content ?? "") : undefined}
+                  mode={mode}
+                  wbwProgress={wbwProgress}
+                  readonly={readonly}
+                  onChange={(data: IWbw[]) => {
+                    onWbwChange?.(data);
+                  }}
+                  onMagicDictDone={() => {
+                    onMagicDictDone?.();
+                  }}
+                />
+              );
+            }
+          } else {
+            return <SentCell key={id} initValue={item} wordWidget={true} />;
+          }
+        })}
+      </div>
+      <div style={{ flex: layoutFlex.right }}>
+        {translation?.map((item, id) => {
+          return (
+            <SuggestionFocus
+              key={id}
+              book={item.book}
+              para={item.para}
+              start={item.wordStart}
+              end={item.wordEnd}
+              channelId={item.channel.id}
+            >
+              <SentCell
+                key={id}
+                initValue={item}
+                compact={compact}
+                onChange={onTranslationChange}
+              />
+            </SuggestionFocus>
+          );
+        })}
+      </div>
+    </div>
+  );
+};
+
+export default SentContentWidget;

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

@@ -0,0 +1,248 @@
+import { Affix } from "antd";
+import { useEffect, useRef, useState, useMemo } from "react";
+
+import type { TChannelType } from "../../api/Channel";
+import { useAppSelector } from "../../hooks";
+import { currFocus } from "../../reducers/focus";
+
+import "./style.css";
+
+import { settingInfo } from "../../reducers/setting";
+
+import { useSetting } from "../../hooks/useSetting";
+import type { ArticleMode, ISentence, ITocPathNode } from "../../api/Corpus";
+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";
+
+export interface IResNumber {
+  translation?: number;
+  nissaya?: number;
+  commentary?: number;
+  origin?: number;
+  sim?: number;
+}
+
+export interface ISentenceId {
+  book: number;
+  para: number;
+  wordStart: number;
+  wordEnd: number;
+}
+
+export interface IWidgetSentEditInner {
+  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;
+
+  onTranslationChange?: (data: ISentence) => void;
+}
+
+export const SentEditInner = ({
+  id,
+  book,
+  para,
+  wordStart,
+  wordEnd,
+  origin,
+  translation,
+  answer,
+  path,
+  layout = "column",
+  tranNum,
+  nissayaNum,
+  commNum,
+  originNum,
+  simNum,
+  compact = false,
+  mode,
+  showWbwProgress = false,
+  readonly = false,
+  commentaries,
+  onTranslationChange,
+}: IWidgetSentEditInner) => {
+  const [wbwData, setWbwData] = useState<IWbw[]>();
+  const [magicDict, setMagicDict] = useState<string>();
+  const [magicDictLoading, setMagicDictLoading] = useState(false);
+  const [isCompact, setIsCompact] = useState(compact);
+  const [articleMode, setArticleMode] = useState<ArticleMode | undefined>(mode);
+  const [affix, setAffix] = useState<boolean>(false);
+
+  const focus = useAppSelector(currFocus);
+  const settings = useAppSelector(settingInfo);
+  const divRef = useRef<HTMLDivElement>(null);
+  const rootFixed = useSetting("setting.layout.root.fixed");
+
+  // ✅ 从 settings 派生 commentaryLayout,无需 state + effect
+  const commentaryLayout = useMemo<string>(() => {
+    const layoutCommentary = GetUserSetting(
+      "setting.layout.commentary",
+      settings
+    );
+    return typeof layoutCommentary === "string" ? layoutCommentary : "column";
+  }, [settings]);
+
+  // ✅ 从 focus 派生 isFocus,无需 state
+  const isFocus = useMemo(() => {
+    return focus?.focus?.type === "sentence" && focus.focus.id === id;
+  }, [focus, id]);
+
+  // ✅ scroll 是真正的副作用,单独处理
+  useEffect(() => {
+    if (isFocus) {
+      divRef.current?.scrollIntoView({
+        behavior: "smooth",
+        block: "nearest",
+        inline: "nearest",
+      });
+    }
+  }, [isFocus]);
+
+  // ✅ 从 translation 派生 loadedRes,无需 state + effect
+  const loadedRes = useMemo<IResNumber | undefined>(() => {
+    if (!translation) return undefined;
+
+    const validRes = (value: ISentence, type: TChannelType) =>
+      value.channel.type === type &&
+      value.content &&
+      value.content.trim().length > 0;
+
+    return {
+      translation: translation.filter((value) => validRes(value, "translation"))
+        .length,
+      nissaya: translation.filter((value) => validRes(value, "nissaya")).length,
+      commentary: translation.filter((value) => validRes(value, "commentary"))
+        .length,
+    };
+  }, [translation]);
+
+  // ✅ 补全 origin 依赖
+  useEffect(() => {
+    const content = origin?.find(
+      (value) => value.contentType === "json"
+    )?.content;
+    if (content) {
+      setWbwData(JSON.parse(content));
+    }
+  }, [origin]);
+
+  const channelsId = translation?.map((item) => item.channel.id);
+
+  const content = (
+    <SentContent
+      sid={id}
+      book={book}
+      para={para}
+      wordStart={wordStart}
+      wordEnd={wordEnd}
+      origin={origin}
+      translation={translation}
+      answer={answer}
+      layout={layout}
+      magicDict={magicDict}
+      compact={isCompact}
+      mode={articleMode}
+      wbwProgress={showWbwProgress}
+      readonly={readonly}
+      onWbwChange={(data: IWbw[]) => {
+        setWbwData(data);
+      }}
+      onMagicDictDone={() => {
+        setMagicDictLoading(false);
+        setMagicDict(undefined);
+      }}
+      onTranslationChange={onTranslationChange}
+    />
+  );
+
+  return (
+    <div
+      ref={divRef}
+      className={`sent-edit-inner` + (isFocus ? " sent-focus" : "")}
+      style={{
+        display: commentaryLayout === "column" ? "block" : "flex",
+        width: commentaryLayout === "column" ? "100%" : SENTENCE_FIX_WIDTH,
+      }}
+    >
+      <div>
+        {affix || rootFixed === true ? (
+          <Affix offsetTop={44}>
+            <div className="affix">{content}</div>
+          </Affix>
+        ) : (
+          content
+        )}
+        <div
+          style={{
+            width: commentaryLayout === "column" ? "unset" : SENTENCE_FIX_WIDTH,
+          }}
+        >
+          <SentTab
+            id={id}
+            book={book}
+            para={para}
+            wordStart={wordStart}
+            wordEnd={wordEnd}
+            channelsId={channelsId}
+            path={path}
+            tranNum={tranNum}
+            nissayaNum={nissayaNum}
+            commNum={commNum}
+            originNum={originNum}
+            simNum={simNum}
+            loadedRes={loadedRes}
+            wbwData={wbwData}
+            origin={origin}
+            magicDictLoading={magicDictLoading}
+            compact={isCompact}
+            mode={articleMode}
+            onMagicDict={(type: string) => {
+              setMagicDict(type);
+              setMagicDictLoading(true);
+            }}
+            onCompact={(value: boolean) => setIsCompact(value)}
+            onModeChange={(value: ArticleMode | undefined) =>
+              setArticleMode(value)
+            }
+            onAffix={() => setAffix(!affix)}
+          />
+        </div>
+      </div>
+      <div className="pcd_sent_commentary">
+        {commentaries?.map((item, id) => {
+          return (
+            <SentCell
+              value={item}
+              key={id}
+              isPr={false}
+              editMode={item.openInEditMode}
+            />
+          );
+        })}
+      </div>
+    </div>
+  );
+};

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

@@ -0,0 +1,263 @@
+import { Button, Dropdown, Tooltip, message } from "antd";
+import { useState } from "react";
+import {
+  EditOutlined,
+  CopyOutlined,
+  MoreOutlined,
+  FieldTimeOutlined,
+  LinkOutlined,
+  FileMarkdownOutlined,
+  DeleteOutlined,
+  ReloadOutlined,
+} from "@ant-design/icons";
+import type { MenuProps } from "antd";
+import type { ArticleMode, ISentence, TContentType } from "../../api/Corpus";
+
+import {
+  CommentOutlinedIcon,
+  HandOutlinedIcon,
+  JsonOutlinedIcon,
+  MergeIcon2,
+  PasteOutLinedIcon,
+} from "../../assets/icon";
+import { useIntl } from "react-intl";
+import { fullUrl } from "../../utils";
+import SentHistoryModal from "../sentence-history.tsx/SentHistoryModal";
+
+interface IWidget {
+  data?: ISentence;
+  children?: React.ReactNode;
+  isPr?: boolean;
+  onModeChange?: (mode: ArticleMode) => void;
+  onConvert?: (type: TContentType) => void;
+  onMenuClick?: (key: string) => void;
+}
+const SentEditMenuWidget = ({
+  data,
+  children,
+  isPr = false,
+  onModeChange,
+  onConvert,
+  onMenuClick,
+}: IWidget) => {
+  const [isHover, setIsHover] = useState(false);
+  const [timelineOpen, setTimelineOpen] = useState(false);
+  const intl = useIntl();
+
+  const onClick: MenuProps["onClick"] = (e) => {
+    if (typeof onMenuClick !== "undefined") {
+      onMenuClick(e.key);
+    }
+    switch (e.key) {
+      case "json":
+        if (typeof onConvert !== "undefined") {
+          onConvert("json");
+        }
+        break;
+      case "markdown":
+        if (typeof onConvert !== "undefined") {
+          onConvert("markdown");
+        }
+        break;
+      case "timeline":
+        setTimelineOpen(true);
+        break;
+      case "refresh":
+        break;
+      case "copy-link":
+        if (data) {
+          let link = `/article/para/${data.book}-${data.para}?mode=edit`;
+          link += `&book=${data.book}&par=${data.para}`;
+          link += `&channel=${data.channel.id}`;
+
+          link += `&focus=${data.book}-${data.para}-${data.wordStart}-${data.wordEnd}`;
+          navigator.clipboard.writeText(fullUrl(link)).then(() => {
+            message.success("链接地址已经拷贝到剪贴板");
+          });
+        }
+        break;
+      default:
+        break;
+    }
+  };
+  const items: MenuProps["items"] = [
+    {
+      key: "refresh",
+      label: intl.formatMessage({
+        id: "buttons.refresh",
+      }),
+      icon: <ReloadOutlined />,
+    },
+    {
+      key: "timeline",
+      label: intl.formatMessage({
+        id: "buttons.timeline",
+      }),
+      icon: <FieldTimeOutlined />,
+      disabled: isPr,
+    },
+    {
+      key: "copy-to",
+      label: intl.formatMessage({
+        id: "buttons.copy.to",
+      }),
+      icon: <MergeIcon2 />,
+      disabled: isPr,
+    },
+    {
+      type: "divider",
+    },
+    {
+      key: "suggestion",
+      label: "suggestion",
+      icon: <HandOutlinedIcon />,
+      disabled: isPr,
+    },
+    {
+      key: "discussion",
+      label: "discussion",
+      icon: <CommentOutlinedIcon />,
+      disabled: isPr,
+    },
+    {
+      type: "divider",
+    },
+    {
+      key: "markdown",
+      label: "To Markdown",
+      icon: <FileMarkdownOutlined />,
+      disabled: !data || data.contentType === "markdown" || isPr,
+    },
+    {
+      key: "json",
+      label: "To Json",
+      icon: <JsonOutlinedIcon />,
+      disabled:
+        !data ||
+        data.channel.type !== "nissaya" ||
+        data.contentType === "json" ||
+        isPr,
+    },
+    {
+      type: "divider",
+    },
+    {
+      key: "copy-link",
+      label: intl.formatMessage({
+        id: "buttons.copy.link",
+      }),
+      icon: <LinkOutlined />,
+    },
+    {
+      key: "delete",
+      label: intl.formatMessage({
+        id: "buttons.delete",
+      }),
+      icon: <DeleteOutlined />,
+      danger: true,
+      disabled: !isPr,
+    },
+  ];
+
+  const buttonStyle = { backgroundColor: "rgba(1,1,1,0)", marginRight: 2 };
+
+  return (
+    <div
+      style={{ position: "relative" }}
+      onMouseEnter={() => {
+        setIsHover(true);
+      }}
+      onMouseLeave={() => {
+        setIsHover(false);
+      }}
+    >
+      <SentHistoryModal
+        open={timelineOpen}
+        onClose={() => setTimelineOpen(false)}
+        sentId={data?.id}
+      />
+      <div
+        style={{
+          marginTop: -22,
+          right: 30,
+          padding: 4,
+          border: "1px solid black",
+          borderRadius: 4,
+          backgroundColor: "rgb(239 239 206)",
+          position: "absolute",
+          display: isHover ? "block" : "none",
+        }}
+      >
+        <Tooltip
+          title={intl.formatMessage({
+            id: "buttons.edit",
+          })}
+        >
+          <Button
+            icon={<EditOutlined />}
+            size="small"
+            style={buttonStyle}
+            onClick={() => {
+              if (typeof onModeChange !== "undefined") {
+                onModeChange("edit");
+              }
+            }}
+          />
+        </Tooltip>
+        <Tooltip
+          title={intl.formatMessage({
+            id: "buttons.copy",
+          })}
+        >
+          <Button
+            icon={<CopyOutlined />}
+            style={buttonStyle}
+            size="small"
+            onClick={() => {
+              if (data?.content) {
+                navigator.clipboard.writeText(data.content).then(() => {
+                  message.success("已经拷贝到剪贴板");
+                });
+              } else {
+                message.success("内容为空");
+              }
+            }}
+          />
+        </Tooltip>
+        <Tooltip
+          title={intl.formatMessage({
+            id: "buttons.paste",
+          })}
+        >
+          <Button
+            icon={<PasteOutLinedIcon />}
+            size="small"
+            style={buttonStyle}
+            onClick={() => {
+              if (typeof onMenuClick !== "undefined") {
+                onMenuClick("paste");
+              }
+            }}
+          />
+        </Tooltip>
+        <Dropdown
+          disabled={data ? false : true}
+          menu={{ items, onClick }}
+          placement="bottomRight"
+        >
+          <Button icon={<MoreOutlined />} size="small" style={buttonStyle} />
+        </Dropdown>
+      </div>
+      <div
+        style={{
+          border: isHover ? "1px solid black" : "1px solid  rgba(1,1,1,0)",
+          borderRadius: 4,
+        }}
+      >
+        {children}
+      </div>
+    </div>
+  );
+};
+
+export default SentEditMenuWidget;

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

@@ -0,0 +1,125 @@
+import { useIntl } from "react-intl";
+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 RelatedPara from "../related-para/RelatedPara";
+
+interface IWidget {
+  book?: number;
+  para?: number;
+  loading?: boolean;
+  mode?: ArticleMode;
+  onMagicDict?: (mode: string) => void;
+  onMenuClick?: (key: string) => void;
+}
+const SentMenuWidget = ({
+  book,
+  para,
+  mode,
+  loading = false,
+  onMagicDict,
+  onMenuClick,
+}: IWidget) => {
+  const intl = useIntl();
+  const items: MenuProps["items"] = [
+    {
+      key: "show-commentary",
+      label: <RelatedPara book={book} para={para} />,
+    },
+    {
+      key: "show-nissaya",
+      label: "Nissaya",
+    },
+    {
+      key: "copy-id",
+      label: intl.formatMessage({ id: "buttons.copy.id" }),
+    },
+    {
+      key: "copy-link",
+      label: intl.formatMessage({ id: "buttons.copy.link" }),
+    },
+    {
+      key: "affix",
+      label: "总在最顶端开/关",
+    },
+    {
+      type: "divider",
+    },
+    {
+      key: "origin",
+      label: "原文模式",
+      children: [
+        {
+          key: "origin-auto",
+          label: "自动",
+          icon: (
+            <CheckOutlined
+              style={{ visibility: mode === undefined ? "visible" : "hidden" }}
+            />
+          ),
+        },
+        {
+          key: "origin-edit",
+          label: "翻译",
+          icon: (
+            <CheckOutlined
+              style={{ visibility: mode === "edit" ? "visible" : "hidden" }}
+            />
+          ),
+        },
+        {
+          key: "origin-wbw",
+          label: "逐词",
+          icon: (
+            <CheckOutlined
+              style={{ visibility: mode === "wbw" ? "visible" : "hidden" }}
+            />
+          ),
+        },
+      ],
+    },
+    {
+      key: "compact",
+      label: (
+        <Space>
+          {intl.formatMessage({ id: "buttons.compact" })}
+          <Badge count="Beta" showZero color="#faad14" />
+        </Space>
+      ),
+    },
+    {
+      key: "normal",
+      label: "正常",
+    },
+  ];
+  const onClick: MenuProps["onClick"] = ({ key }) => {
+    console.log(`Click on item ${key}`);
+    if (typeof onMenuClick !== "undefined") {
+      onMenuClick(key);
+    }
+    switch (key) {
+      case "magic-dict-current":
+        if (typeof onMagicDict !== "undefined") {
+          onMagicDict("current");
+        }
+        break;
+      default:
+        break;
+    }
+  };
+  return (
+    <Dropdown menu={{ items, onClick }} placement="topRight">
+      <Button
+        loading={loading}
+        onClick={(e) => e.preventDefault()}
+        icon={<MoreOutlined />}
+        size="small"
+        type="primary"
+      />
+    </Dropdown>
+  );
+};
+
+export default SentMenuWidget;

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

@@ -0,0 +1,103 @@
+import { Button, Divider, List, Space, Switch } from "antd";
+import { ReloadOutlined } from "@ant-design/icons";
+
+import type {
+  ISentenceSimListResponse,
+  ISentSimParams,
+} from "../../api/sent-sim";
+import { useSentSim } from "../../hooks/useSentSim";
+import SentCanRead from "./SentCanRead";
+import MdView from "../general/MdView";
+
+type Fetcher = (params: ISentSimParams) => Promise<ISentenceSimListResponse>;
+
+interface IWidget {
+  book: number;
+  para: number;
+  wordStart: number;
+  wordEnd: number;
+  channelsId?: string[];
+  limit?: number;
+  /** 可替换为 mock 函数,默认使用真实请求 */
+  fetcher?: Fetcher;
+  onCreate?: () => void;
+}
+
+const SentSimWidget = ({
+  book,
+  para,
+  wordStart,
+  wordEnd,
+  limit = 5,
+  channelsId,
+  fetcher,
+  onCreate,
+}: IWidget) => {
+  const {
+    sentData,
+    remain,
+    initLoading,
+    loading,
+    toggleSim,
+    loadMore,
+    reload,
+  } = useSentSim({
+    book,
+    para,
+    wordStart,
+    wordEnd,
+    limit,
+    channelsId,
+    fetcher,
+  });
+
+  return (
+    <>
+      <SentCanRead
+        book={book}
+        para={para}
+        wordStart={wordStart}
+        wordEnd={wordEnd}
+        type="similar"
+        channelsId={channelsId}
+        onCreate={onCreate}
+      />
+      <List
+        loading={initLoading}
+        header={
+          <div style={{ display: "flex", justifyContent: "space-between" }}>
+            <span />
+            <Space>
+              {"只显示相同句"}
+              <Switch onChange={toggleSim} />
+              <Button
+                type="link"
+                shape="round"
+                icon={<ReloadOutlined />}
+                loading={loading}
+                onClick={reload}
+              />
+            </Space>
+          </div>
+        }
+        itemLayout="horizontal"
+        split={false}
+        loadMore={
+          <Divider>
+            <Button disabled={remain <= 0} onClick={loadMore} loading={loading}>
+              load more
+            </Button>
+          </Divider>
+        }
+        dataSource={sentData}
+        renderItem={(item, index) => (
+          <List.Item>
+            <MdView html={item.sent} key={index} style={{ width: "100%" }} />
+          </List.Item>
+        )}
+      />
+    </>
+  );
+};
+
+export default SentSimWidget;

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

@@ -0,0 +1,248 @@
+/**
+ * SentSimTest — 可视化 Demo 页
+ * 通过 fetcher prop 注入 mock 函数,无需真实后端即可调试 SentSimWidget 的所有交互。
+ */
+import { useCallback, useState } from "react";
+import {
+  Alert,
+  Badge,
+  Button,
+  Card,
+  Col,
+  Divider,
+  Form,
+  InputNumber,
+  Row,
+  Select,
+  Slider,
+  Space,
+  Tag,
+  Typography,
+} from "antd";
+
+import type {
+  ISentenceSimListResponse,
+  ISentSimParams,
+} from "../../api/sent-sim";
+import SentSimWidget from "./SentSim";
+
+const { Title } = Typography;
+
+// ─── Mock 数据 ────────────────────────────────────────────────────────────────
+
+const MOCK_SENTENCES = [
+  { sent: "The quick brown fox jumps over the lazy dog.", sim: 1.0 },
+  { sent: "A fast auburn fox leaps above the sleepy hound.", sim: 0.92 },
+  { sent: "The nimble red fox vaults the tired dog.", sim: 0.85 },
+  { sent: "A swift fox jumped across the resting canine.", sim: 0.78 },
+  { sent: "The brown fox made a leap over the dog.", sim: 0.71 },
+  { sent: "Foxes are known for their agility and speed.", sim: 0.55 },
+  { sent: "Dogs tend to rest more than foxes in captivity.", sim: 0.42 },
+  { sent: "The weather is pleasant today with mild winds.", sim: 0.21 },
+  { sent: "She sells seashells by the seashore.", sim: 0.15 },
+  { sent: "An entirely unrelated sentence about quantum physics.", sim: 0.05 },
+];
+
+// ─── Mock 模式 ────────────────────────────────────────────────────────────────
+
+type MockMode = "success" | "slow" | "empty" | "error";
+
+const MODE_OPTIONS: { value: MockMode; label: string; color: string }[] = [
+  { value: "success", label: "✅ 正常返回", color: "green" },
+  { value: "slow", label: "🐢 慢速 2s", color: "orange" },
+  { value: "empty", label: "📭 空数据", color: "blue" },
+  { value: "error", label: "❌ 服务器错误", color: "red" },
+];
+
+function sleep(ms: number) {
+  return new Promise<void>((r) => setTimeout(r, ms));
+}
+
+// ─── Demo 配置 ────────────────────────────────────────────────────────────────
+
+interface IDemoConfig {
+  book: number;
+  para: number;
+  wordStart: number;
+  wordEnd: number;
+  limit: number;
+  delay: number;
+  mode: MockMode;
+}
+
+const DEFAULT: IDemoConfig = {
+  book: 1,
+  para: 1,
+  wordStart: 0,
+  wordEnd: 5,
+  limit: 3,
+  delay: 400,
+  mode: "success",
+};
+
+// ─── 组件 ─────────────────────────────────────────────────────────────────────
+
+const SentSimTest = () => {
+  const [draft, setDraft] = useState<IDemoConfig>(DEFAULT);
+  const [applied, setApplied] = useState<IDemoConfig>(DEFAULT);
+  // widgetKey 变化时强制销毁重建 Widget,确保状态干净
+  const [widgetKey, setWidgetKey] = useState(0);
+
+  const fetcher = useCallback(
+    async (params: ISentSimParams): Promise<ISentenceSimListResponse> => {
+      await sleep(applied.mode === "slow" ? 2000 : applied.delay);
+
+      if (applied.mode === "error") {
+        return {
+          ok: false,
+          message: "Mock 错误:服务器返回 500",
+          data: { rows: [], count: 0 },
+        };
+      }
+
+      if (applied.mode === "empty") {
+        return { ok: true, message: "", data: { rows: [], count: 0 } };
+      }
+
+      const pool =
+        params.sim === 1
+          ? MOCK_SENTENCES.filter((s) => s.sim >= 0.9)
+          : MOCK_SENTENCES;
+
+      const rows = pool.slice(params.offset, params.offset + params.limit);
+      return { ok: true, message: "", data: { rows, count: pool.length } };
+    },
+    [applied.mode, applied.delay]
+  );
+
+  function apply() {
+    setApplied(draft);
+    setWidgetKey((k) => k + 1);
+  }
+
+  function reset() {
+    setDraft(DEFAULT);
+    setApplied(DEFAULT);
+    setWidgetKey((k) => k + 1);
+  }
+
+  const currentMode = MODE_OPTIONS.find((o) => o.value === applied.mode)!;
+
+  return (
+    <div style={{ padding: 24, maxWidth: 860, margin: "0 auto" }}>
+      <Title level={3}>SentSimWidget · Mock 调试面板</Title>
+
+      {/* ── 控制面板 ─────────────────────────────────────────────────────── */}
+      <Card
+        size="small"
+        title="⚙️ 参数配置"
+        style={{ marginBottom: 20 }}
+        extra={<Badge color={currentMode.color} text={currentMode.label} />}
+      >
+        <Form layout="inline" size="small">
+          <Row gutter={[12, 12]} style={{ width: "100%" }}>
+            {/* 位置参数 */}
+            {(["book", "para", "wordStart", "wordEnd", "limit"] as const).map(
+              (key) => (
+                <Col key={key} span={4}>
+                  <Form.Item label={key}>
+                    <InputNumber
+                      min={0}
+                      value={draft[key]}
+                      onChange={(v) =>
+                        setDraft((d) => ({ ...d, [key]: v ?? 0 }))
+                      }
+                      style={{ width: "100%" }}
+                    />
+                  </Form.Item>
+                </Col>
+              )
+            )}
+
+            {/* Mock 模式 */}
+            <Col span={8}>
+              <Form.Item label="Mock 模式">
+                <Select
+                  value={draft.mode}
+                  style={{ width: 160 }}
+                  options={MODE_OPTIONS.map(({ value, label }) => ({
+                    value,
+                    label,
+                  }))}
+                  onChange={(v) => setDraft((d) => ({ ...d, mode: v }))}
+                />
+              </Form.Item>
+            </Col>
+
+            {/* 延迟 */}
+            <Col span={10}>
+              <Form.Item label={`延迟 ${draft.delay} ms`}>
+                <Slider
+                  min={0}
+                  max={3000}
+                  step={100}
+                  value={draft.delay}
+                  disabled={draft.mode === "slow"}
+                  style={{ width: 180 }}
+                  onChange={(v) => setDraft((d) => ({ ...d, delay: v }))}
+                />
+              </Form.Item>
+            </Col>
+
+            {/* 操作按钮 */}
+            <Col span={24} style={{ textAlign: "right" }}>
+              <Space>
+                <Button onClick={reset}>重置默认</Button>
+                <Button type="primary" onClick={apply}>
+                  应用 &amp; 重建 Widget
+                </Button>
+              </Space>
+            </Col>
+          </Row>
+        </Form>
+      </Card>
+
+      {/* ── Mock 数据预览 ─────────────────────────────────────────────────── */}
+      <Card
+        size="small"
+        title="📋 Mock 数据库(共 10 条)"
+        style={{ marginBottom: 20 }}
+      >
+        <Space wrap size={[6, 6]}>
+          {MOCK_SENTENCES.map((s, i) => (
+            <Tag
+              key={i}
+              color={s.sim >= 0.9 ? "green" : s.sim >= 0.5 ? "blue" : "default"}
+            >
+              sim={s.sim.toFixed(2)} · {s.sent.slice(0, 28)}…
+            </Tag>
+          ))}
+        </Space>
+        <Alert
+          style={{ marginTop: 10 }}
+          type="info"
+          showIcon
+          message='开启"只显示相同句"时只返回 sim ≥ 0.9 的数据(前 2 条)'
+        />
+      </Card>
+
+      <Divider>↓ SentSimWidget 实际渲染</Divider>
+
+      {/* ── Widget ───────────────────────────────────────────────────────── */}
+      <Card>
+        <SentSimWidget
+          key={widgetKey}
+          book={applied.book}
+          para={applied.para}
+          wordStart={applied.wordStart}
+          wordEnd={applied.wordEnd}
+          limit={applied.limit}
+          fetcher={fetcher}
+          onCreate={() => console.log("[SentSimTest] onCreate")}
+        />
+      </Card>
+    </div>
+  );
+};
+
+export default SentSimTest;

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

@@ -0,0 +1,394 @@
+import { useEffect, useState } from "react";
+import { Badge, Space, Tabs, Typography, message } from "antd";
+import {
+  TranslationOutlined,
+  CloseOutlined,
+  BlockOutlined,
+} from "@ant-design/icons";
+
+import SentTabButton from "./SentTabButton";
+import SentCanRead from "./SentCanRead";
+import SentSim from "./SentSim";
+import { useIntl } from "react-intl";
+import TocPath from "../../../src/components/tipitaka/TocPath";
+
+import SentMenu from "./SentMenu";
+
+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";
+
+const { Text } = Typography;
+
+interface IWidget {
+  id: string;
+  book: number;
+  para: number;
+  wordStart: number;
+  wordEnd: number;
+  channelsId?: string[];
+  path?: ITocPathNode[];
+  layout?: "row" | "column";
+  tranNum?: number;
+  nissayaNum?: number;
+  commNum?: number;
+  originNum: number;
+  simNum?: number;
+  wbwData?: IWbw[];
+  magicDictLoading?: boolean;
+  compact?: boolean;
+  mode?: ArticleMode;
+  loadedRes?: IResNumber;
+  origin?: ISentence[];
+  onMagicDict?: (type: string) => void;
+  onCompact?: (compact: boolean) => void;
+  onModeChange?: (mode: ArticleMode) => void;
+  onAffix?: () => void;
+}
+const SentTabWidget = ({
+  id,
+  book,
+  para,
+  wordStart,
+  wordEnd,
+  channelsId,
+  path,
+  tranNum = 0,
+  nissayaNum = 0,
+  commNum = 0,
+  originNum,
+  simNum = 0,
+  wbwData,
+  magicDictLoading = false,
+  compact = false,
+  mode,
+  loadedRes,
+  origin,
+  onMagicDict,
+  onCompact,
+  onModeChange,
+  onAffix,
+}: IWidget) => {
+  const intl = useIntl();
+  const [isCompact, setIsCompact] = useState(compact);
+  const [hover, setHover] = useState(false);
+  const [currKey, setCurrKey] = useState("close");
+  const [currTranNum, setCurrTranNum] = useState(tranNum);
+  const [currNissayaNum, setCurrNissayaNum] = useState(nissayaNum);
+  const [currCommNum, setCurrCommNum] = useState(commNum);
+  const [currSimilarNum, setCurrSimilarNum] = useState(simNum);
+  const [showWbwProgress, setShowWbwProgress] = useState(false);
+
+  console.log("SentTabWidget render");
+
+  useEffect(() => {
+    setIsCompact(compact);
+  }, [compact]);
+
+  const mPath = path
+    ? [
+        ...path,
+        { book: book, paragraph: para, title: para.toString(), level: 100 },
+      ]
+    : [];
+  if (typeof id === "undefined") {
+    return <></>;
+  }
+  const sentId = id.split("_");
+  const sId = sentId[0].split("-");
+  const tabButtonStyle: React.CSSProperties | undefined = compact
+    ? { visibility: hover || currKey !== "close" ? "visible" : "hidden" }
+    : undefined;
+
+  return (
+    <Tabs
+      className={
+        "sent_tabs" +
+        (isCompact ? " compact" : "") +
+        (currKey === "close" ? " curr_close" : "")
+      }
+      onMouseEnter={() => setHover(true)}
+      onMouseLeave={() => setHover(false)}
+      activeKey={currKey}
+      type="card"
+      onChange={(activeKey: string) => {
+        setCurrKey(activeKey);
+      }}
+      tabBarStyle={{ marginBottom: 0 }}
+      size="small"
+      tabBarGutter={0}
+      tabBarExtraContent={
+        <Space>
+          <TocPath
+            link="none"
+            data={mPath}
+            channels={channelsId}
+            trigger={path ? path.length > 0 ? path[0].title : <></> : <></>}
+          />
+          <Text>{sentId[0]}</Text>
+          <SentTabCopy wbwData={wbwData} text={`{{${sentId[0]}}}`} />
+          <SentMenu
+            book={book}
+            para={para}
+            loading={magicDictLoading}
+            mode={mode}
+            onMagicDict={(type: string) => {
+              if (typeof onMagicDict !== "undefined") {
+                onMagicDict(type);
+              }
+            }}
+            onMenuClick={(key: string) => {
+              switch (key) {
+                case "compact":
+                  if (typeof onCompact !== "undefined") {
+                    setIsCompact(true);
+                    onCompact(true);
+                  }
+                  break;
+                case "normal":
+                  if (typeof onCompact !== "undefined") {
+                    setIsCompact(false);
+                    onCompact(false);
+                  }
+                  break;
+                case "origin-edit":
+                  if (typeof onModeChange !== "undefined") {
+                    onModeChange("edit");
+                  }
+                  break;
+                case "origin-wbw":
+                  if (typeof onModeChange !== "undefined") {
+                    onModeChange("wbw");
+                  }
+                  break;
+                case "copy-id": {
+                  const id = `{{${book}-${para}-${wordStart}-${wordEnd}}}`;
+                  navigator.clipboard.writeText(id).then(() => {
+                    message.success("编号已经拷贝到剪贴板");
+                  });
+                  break;
+                }
+                case "copy-link": {
+                  let link = `/article/para/${book}-${para}?mode=edit`;
+                  link += `&book=${book}&par=${para}`;
+                  if (channelsId) {
+                    link += `&channel=` + channelsId?.join("_");
+                  }
+                  link += `&focus=${book}-${para}-${wordStart}-${wordEnd}`;
+                  navigator.clipboard.writeText(fullUrl(link)).then(() => {
+                    message.success("链接地址已经拷贝到剪贴板");
+                  });
+                  break;
+                }
+                case "affix":
+                  if (typeof onAffix !== "undefined") {
+                    onAffix();
+                  }
+                  break;
+                default:
+                  break;
+              }
+            }}
+          />
+        </Space>
+      }
+      items={[
+        {
+          label: (
+            <span style={tabButtonStyle}>
+              <Badge size="small" count={0}>
+                <CloseOutlined />
+              </Badge>
+            </span>
+          ),
+          key: "close",
+          children: <></>,
+        },
+        {
+          label: (
+            <SentTabButton
+              style={tabButtonStyle}
+              icon={<TranslationOutlined />}
+              type="translation"
+              sentId={id}
+              count={
+                currTranNum
+                  ? currTranNum -
+                    (loadedRes?.translation ? loadedRes.translation : 0)
+                  : undefined
+              }
+              title={intl.formatMessage({
+                id: "channel.type.translation.label",
+              })}
+            />
+          ),
+          key: "translation",
+          children: (
+            <div className="content">
+              <SentCanRead
+                book={parseInt(sId[0])}
+                para={parseInt(sId[1])}
+                wordStart={parseInt(sId[2])}
+                wordEnd={parseInt(sId[3])}
+                type="translation"
+                channelsId={channelsId}
+                onCreate={() => setCurrTranNum((origin) => origin + 1)}
+              />
+            </div>
+          ),
+        },
+        {
+          label: (
+            <SentTabButton
+              style={tabButtonStyle}
+              icon={<CloseOutlined />}
+              type="nissaya"
+              sentId={id}
+              count={
+                currNissayaNum
+                  ? currNissayaNum -
+                    (loadedRes?.nissaya ? loadedRes.nissaya : 0)
+                  : undefined
+              }
+              title={intl.formatMessage({
+                id: "channel.type.nissaya.label",
+              })}
+            />
+          ),
+          key: "nissaya",
+          children: (
+            <SentCanRead
+              book={parseInt(sId[0])}
+              para={parseInt(sId[1])}
+              wordStart={parseInt(sId[2])}
+              wordEnd={parseInt(sId[3])}
+              type="nissaya"
+              channelsId={channelsId}
+              onCreate={() => setCurrNissayaNum((origin) => origin + 1)}
+            />
+          ),
+        },
+        {
+          label: (
+            <SentTabButton
+              style={tabButtonStyle}
+              icon={<TranslationOutlined />}
+              type="commentary"
+              sentId={id}
+              count={
+                currCommNum
+                  ? currCommNum -
+                    (loadedRes?.commentary ? loadedRes.commentary : 0)
+                  : undefined
+              }
+              title={intl.formatMessage({
+                id: "channel.type.commentary.label",
+              })}
+            />
+          ),
+          key: "commentary",
+          children: (
+            <SentCanRead
+              book={parseInt(sId[0])}
+              para={parseInt(sId[1])}
+              wordStart={parseInt(sId[2])}
+              wordEnd={parseInt(sId[3])}
+              type="commentary"
+              channelsId={channelsId}
+              onCreate={() => setCurrCommNum((origin) => origin + 1)}
+            />
+          ),
+        },
+        {
+          label: (
+            <SentTabButton
+              icon={<BlockOutlined />}
+              type="original"
+              sentId={id}
+              count={originNum}
+              title={intl.formatMessage({
+                id: "channel.type.original.label",
+              })}
+            />
+          ),
+          key: "original",
+          children: (
+            <SentCanRead
+              book={parseInt(sId[0])}
+              para={parseInt(sId[1])}
+              wordStart={parseInt(sId[2])}
+              wordEnd={parseInt(sId[3])}
+              type="original"
+              origin={origin}
+            />
+          ),
+        },
+        {
+          label: (
+            <SentTabButton
+              style={tabButtonStyle}
+              icon={<BlockOutlined />}
+              type="original"
+              sentId={id}
+              count={currSimilarNum}
+              title={intl.formatMessage({
+                id: "buttons.sim",
+              })}
+            />
+          ),
+          key: "sim",
+          children: (
+            <SentSim
+              book={parseInt(sId[0])}
+              para={parseInt(sId[1])}
+              wordStart={parseInt(sId[2])}
+              wordEnd={parseInt(sId[3])}
+              channelsId={channelsId}
+              limit={5}
+              onCreate={() => setCurrSimilarNum((origin) => origin + 1)}
+            />
+          ),
+        },
+        {
+          label: (
+            <SentTabButtonWbw
+              style={tabButtonStyle}
+              sentId={id}
+              count={0}
+              onMenuClick={(keyPath: string[]) => {
+                switch (keyPath.join("-")) {
+                  case "show-progress":
+                    setShowWbwProgress((origin) => !origin);
+                    break;
+                }
+              }}
+            />
+          ),
+          key: "wbw",
+          children: (
+            <SentWbw
+              book={parseInt(sId[0])}
+              para={parseInt(sId[1])}
+              wordStart={parseInt(sId[2])}
+              wordEnd={parseInt(sId[3])}
+              channelsId={channelsId}
+              wbwProgress={showWbwProgress}
+            />
+          ),
+        },
+        {
+          label: <span style={tabButtonStyle}>{"关系图"}</span>,
+          key: "relation-graphic",
+          children: <RelaGraphic wbwData={wbwData} />,
+        },
+      ]}
+    />
+  );
+};
+
+export default SentTabWidget;

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

@@ -0,0 +1,21 @@
+import { Badge, Space } from "antd";
+import type { JSX } from "react";
+
+interface IWidget {
+  style?: React.CSSProperties;
+  icon?: JSX.Element;
+  type: string;
+  sentId: string;
+  count?: number;
+  title?: string;
+}
+const SentTabButtonWidget = ({ title, count = 0 }: IWidget) => {
+  return (
+    <Space>
+      <>{title}</>
+      <Badge size="small" color="geekblue" count={count}></Badge>
+    </Space>
+  );
+};
+
+export default SentTabButtonWidget;

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

@@ -0,0 +1,67 @@
+import { useIntl } from "react-intl";
+import { Badge, Dropdown } from "antd";
+import type { MenuProps } from "antd";
+import { BlockOutlined, CalendarOutlined } from "@ant-design/icons";
+
+const handleButtonClick = (e: React.MouseEvent<HTMLButtonElement>) => {
+  console.log("click left button", e);
+};
+
+interface IWidget {
+  style?: React.CSSProperties;
+  sentId: string;
+  count?: number;
+  onMenuClick?: (keyPath: string[]) => void;
+}
+const SentTabButtonWidget = ({ style, onMenuClick, count = 0 }: IWidget) => {
+  const intl = useIntl();
+  const items: MenuProps["items"] = [
+    {
+      label: "排序",
+      key: "orderby",
+      icon: <CalendarOutlined />,
+      children: [
+        {
+          label: "完成度",
+          key: "progress",
+        },
+        {
+          label: "问题数量",
+          key: "qa",
+        },
+      ],
+    },
+    {
+      label: "显示完成度",
+      key: "show-progress",
+      icon: <BlockOutlined />,
+    },
+  ];
+  const handleMenuClick: MenuProps["onClick"] = (e) => {
+    e.domEvent.stopPropagation();
+    if (typeof onMenuClick !== "undefined") {
+      onMenuClick(e.keyPath);
+    }
+  };
+  const menuProps = {
+    items,
+    onClick: handleMenuClick,
+  };
+
+  return (
+    <Dropdown.Button
+      style={style}
+      size="small"
+      type="text"
+      menu={menuProps}
+      onClick={handleButtonClick}
+    >
+      {intl.formatMessage({
+        id: "buttons.wbw",
+      })}
+      <Badge size="small" color="geekblue" count={count}></Badge>
+    </Dropdown.Button>
+  );
+};
+
+export default SentTabButtonWidget;

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

@@ -0,0 +1,104 @@
+import { Dropdown, Tooltip, notification } from "antd";
+import {
+  CopyOutlined,
+  ShoppingCartOutlined,
+  CheckOutlined,
+  DownOutlined,
+} from "@ant-design/icons";
+import { useEffect, useState } from "react";
+
+import store from "../../store";
+import { modeChange } from "../../reducers/cart-mode";
+import { useAppSelector } from "../../hooks";
+import { mode as _mode } from "../../reducers/cart-mode";
+import type { IWbw } from "../../types/wbw";
+import { addToCart } from "./utils";
+
+interface IWidget {
+  text?: string;
+  wbwData?: IWbw[];
+}
+const SentTabCopyWidget = ({ text, wbwData }: IWidget) => {
+  const [mode, setMode] = useState("copy");
+  const [success, setSuccess] = useState(false);
+  const currMode = useAppSelector(_mode);
+
+  useEffect(() => {
+    const modeSetting = localStorage.getItem("cart/mode");
+    if (modeSetting === "cart") {
+      setMode("cart");
+    }
+  }, []);
+
+  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(" ");
+
+        addToCart([{ id: text, text: paliText ? paliText : "" }]);
+        notification.success({
+          message: "句子已经添加到Cart",
+        });
+        setSuccess(true);
+        setTimeout(() => setSuccess(false), 3000);
+      }
+    }
+  };
+  return (
+    <Dropdown.Button
+      size="small"
+      type="link"
+      icon={<DownOutlined />}
+      menu={{
+        items: [
+          {
+            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={() => copy(mode)}
+    >
+      <Tooltip title={(success ? "已经" : "") + `${mode}`}>
+        {success ? (
+          <CheckOutlined />
+        ) : mode === "copy" ? (
+          <CopyOutlined />
+        ) : (
+          <ShoppingCartOutlined />
+        )}
+      </Tooltip>
+    </Dropdown.Button>
+  );
+};
+
+export default SentTabCopyWidget;

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

@@ -0,0 +1,226 @@
+import { Button, List, Select, Space, message } from "antd";
+import { useCallback, useEffect, useState } from "react";
+import { ReloadOutlined } from "@ant-design/icons";
+
+import { get } from "../../request";
+import type { ISentence, ISentenceWbwListResponse } from "../../api/Corpus";
+
+import { useAppSelector } from "../../hooks";
+import { courseInfo, memberInfo } from "../../reducers/current-course";
+import { courseUser } from "../../reducers/course-user";
+
+import moment from "dayjs";
+import type { IUser } from "../../api/Auth";
+import User from "../auth/User";
+import { getWbwProgress } from "../wbw/utils";
+import { SentEditInner, type IWidgetSentEditInner } from "./SentEdit";
+
+interface IWidget {
+  book: number;
+  para: number;
+  wordStart: number;
+  wordEnd: number;
+  channelsId?: string[];
+  reload?: boolean;
+  wbwProgress?: boolean;
+}
+const SentWbwWidget = ({
+  book,
+  para,
+  wordStart,
+  wordEnd,
+  channelsId,
+  wbwProgress = false,
+}: IWidget) => {
+  const [sentData, setSentData] = useState<IWidgetSentEditInner[]>([]);
+  const [answer, setAnswer] = useState<ISentence>();
+  const [loading, setLoading] = useState<boolean>(false);
+  const [order, setOrder] = useState("progress");
+  const course = useAppSelector(courseInfo);
+  const courseMember = useAppSelector(memberInfo);
+
+  const myCourse = useAppSelector(courseUser);
+
+  let isCourse: boolean = false;
+  if (myCourse && course) {
+    isCourse = true;
+  }
+
+  const load = useCallback(async () => {
+    let url = `/v2/wbw-sentence?view=sent-can-read`;
+    url += `&book=${book}&para=${para}&wordStart=${wordStart}&wordEnd=${wordEnd}`;
+
+    if (myCourse && course) {
+      url += `&course=${course.courseId}`;
+      if (myCourse.role === "student") {
+        url += `&channels=${course.channelId}`;
+      }
+    } else {
+      if (channelsId?.length && channelsId?.length > 0) {
+        url += `&exclude=${channelsId[0]}`;
+      }
+    }
+
+    setLoading(true);
+
+    try {
+      const json = await get<ISentenceWbwListResponse>(url);
+
+      if (!json.ok) {
+        message.error(json.message);
+        return;
+      }
+
+      let response = json.data.rows;
+
+      if (course && myCourse && myCourse.role !== "student") {
+        response = response.filter((v) =>
+          v.translation
+            ? v.translation[0].channel.id !== course.channelId
+            : true
+        );
+      }
+
+      response.forEach((value, index, array) => {
+        if (value.origin?.[0]?.content) {
+          const parsed = JSON.parse(value.origin[0].content);
+          array[index].wbwProgress = getWbwProgress(parsed);
+        }
+      });
+
+      setSentData(response);
+
+      if (myCourse && course) {
+        const answerData = json.data.rows.find(
+          (v) => v.origin?.[0].channel.id === course.channelId
+        );
+
+        if (answerData?.origin) {
+          setAnswer(answerData.origin[0]);
+        }
+      }
+    } finally {
+      setLoading(false);
+    }
+  }, [book, para, wordStart, wordEnd, myCourse, course, channelsId]);
+
+  useEffect(() => {
+    load();
+  }, [load]);
+
+  //没交作业的人
+
+  const nonWbwUser: IUser[] = [];
+  const isCourseAnswer = myCourse && course && myCourse.role !== "student";
+  if (isCourseAnswer && courseMember) {
+    const hasWbwUsers = sentData.map((item) =>
+      item.translation ? item.translation[0].studio : undefined
+    );
+    courseMember
+      .filter(
+        (value) =>
+          value.role === "student" &&
+          (value.status === "joined" ||
+            value.status === "accepted" ||
+            value.status === "agreed")
+      )
+      .forEach((value) => {
+        const curr = hasWbwUsers.find((value1) => value1?.id === value.user_id);
+        if (!curr && value.user) {
+          nonWbwUser.push(value.user);
+        }
+      });
+  }
+  console.debug("没交作业", courseMember, sentData, nonWbwUser);
+
+  const aaa = [...sentData].sort(
+    (a: IWidgetSentEditInner, b: IWidgetSentEditInner) => {
+      switch (order) {
+        case "progress":
+          if (a.wbwProgress && b.wbwProgress) {
+            return b.wbwProgress - a.wbwProgress;
+          } else {
+            return 0;
+          }
+          break;
+        case "updated":
+          if (a.origin && b.origin) {
+            if (
+              moment(b.origin[0].updateAt).isBefore(
+                moment(a.origin[0].updateAt)
+              )
+            ) {
+              return 1;
+            } else {
+              return -1;
+            }
+          } else {
+            return 0;
+          }
+          break;
+      }
+      if (a.wbwProgress && b.wbwProgress) {
+        return b.wbwProgress - a.wbwProgress;
+      } else {
+        return 0;
+      }
+    }
+  );
+
+  return (
+    <>
+      <List
+        loading={loading}
+        header={
+          <div style={{ display: "flex", justifyContent: "space-between" }}>
+            <span></span>
+            <Space>
+              <Select
+                disabled
+                defaultValue={"progress"}
+                options={[
+                  { value: "progress", label: "完成度" },
+                  { value: "updated", label: "更新时间" },
+                ]}
+                onChange={(value: string) => setOrder(value)}
+              />
+              <Button
+                type="link"
+                shape="round"
+                icon={<ReloadOutlined />}
+                onClick={load}
+              />
+            </Space>
+          </div>
+        }
+        itemLayout="horizontal"
+        split={false}
+        dataSource={aaa}
+        renderItem={(item, index) => (
+          <List.Item key={index}>
+            <SentEditInner
+              {...item}
+              readonly={isCourse}
+              answer={answer}
+              showWbwProgress={isCourse ?? wbwProgress}
+            />
+          </List.Item>
+        )}
+      />
+      <div>
+        {isCourseAnswer ? (
+          <Space style={{ flexWrap: "wrap" }}>
+            {"无作业:"}
+            {nonWbwUser.length > 0
+              ? nonWbwUser.map((item, id) => {
+                  return <User key={id} {...item} />;
+                })
+              : "无"}
+          </Space>
+        ) : undefined}
+      </div>
+    </>
+  );
+};
+
+export default SentWbwWidget;

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

@@ -0,0 +1,85 @@
+import { useMemo } from "react";
+import { useIntl } from "react-intl";
+import { Button, message } from "antd";
+import { EyeOutlined } from "@ant-design/icons";
+
+import type { ISentence } from "../../api/Corpus";
+
+import type { IWbw } from "../../types/wbw";
+import { sentSave } from "../../api/sentence";
+import WbwSentCtl from "../wbw/WbwSentCtl";
+
+interface IWidget {
+  data: ISentence;
+  onSave?: (newSent: ISentence) => void;
+  onClose?: () => void;
+}
+const SentWbwEditWidget = ({ data, onSave, onClose }: IWidget) => {
+  const intl = useIntl();
+
+  const wbwData = useMemo(() => {
+    if (data.contentType === "json" && data.content) {
+      return JSON.parse(data.content);
+    } else {
+      return [];
+    }
+  }, [data.content, data.contentType]);
+
+  return (
+    <div style={{ width: "100%" }}>
+      <WbwSentCtl
+        book={data.book}
+        para={data.para}
+        wordStart={data.wordStart}
+        wordEnd={data.wordEnd}
+        data={wbwData}
+        refreshable={true}
+        display="list"
+        layoutDirection="v"
+        fields={{
+          real: true,
+          meaning: true,
+          factors: false,
+          factorMeaning: false,
+          factorMeaning2: true,
+          case: false,
+        }}
+        channelId={data.channel.id}
+        channelType={data.channel.type}
+        channelLang={data.channel.lang}
+        onChange={(wbwData: IWbw[]) => {
+          const newSent = { ...data };
+          newSent.content = JSON.stringify(wbwData);
+          sentSave(newSent, intl)
+            .then((value) => {
+              if (value) {
+                newSent.html = value.html;
+                onSave?.(newSent);
+              } else {
+                console.error("返回数据失败");
+              }
+            })
+            .catch((error) => {
+              message.error(intl.formatMessage({ id: "errors.saveFailed" }));
+              console.error(error);
+            });
+        }}
+      />
+
+      <div>
+        <Button
+          size="small"
+          type="primary"
+          icon={<EyeOutlined />}
+          onClick={() => {
+            onClose?.();
+          }}
+        >
+          {intl.formatMessage({ id: "buttons.preview" })}
+        </Button>
+      </div>
+    </div>
+  );
+};
+
+export default SentWbwEditWidget;

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

@@ -0,0 +1,54 @@
+import { Button } from "antd";
+import { useEffect, useState } from "react";
+import { PlusOutlined } from "@ant-design/icons";
+
+import type { ISentence } from "../../api/Corpus";
+import SentCellEditable from "./SentCellEditable";
+
+interface IWidget {
+  data: ISentence;
+  onCreate?: () => void;
+}
+const SuggestionAddWidget = ({ data, onCreate }: IWidget) => {
+  const [isEditMode, setIsEditMode] = useState(false);
+  const [sentData, setSentData] = useState<ISentence>(data);
+  useEffect(() => {
+    setSentData(data);
+  }, [data]);
+  return (
+    <>
+      <div style={{ display: isEditMode ? "none" : "block" }}>
+        <Button
+          type="dashed"
+          style={{ width: 300 }}
+          icon={<PlusOutlined />}
+          onClick={() => {
+            setIsEditMode(true);
+          }}
+        >
+          添加修改建议
+        </Button>
+      </div>
+      <div>
+        {isEditMode ? (
+          <SentCellEditable
+            data={sentData}
+            isPr={true}
+            isCreatePr={true}
+            onClose={() => {
+              setIsEditMode(false);
+            }}
+            onCreate={() => {
+              setIsEditMode(false);
+              if (typeof onCreate !== "undefined") {
+                onCreate();
+              }
+            }}
+          />
+        ) : undefined}
+      </div>
+    </>
+  );
+};
+
+export default SuggestionAddWidget;

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

@@ -0,0 +1,117 @@
+import { useState } from "react";
+import { Alert, Button, Space } from "antd";
+
+import SuggestionList from "./SuggestionList";
+import SuggestionAdd from "./SuggestionAdd";
+import type { ISentence } from "../../api/Corpus";
+import Marked from "../general/Marked";
+import { useAppSelector } from "../../hooks";
+import { message } from "../../reducers/discussion";
+import { useIntl } from "react-intl";
+
+interface ISuggestionWidget {
+  data: ISentence;
+  openNotification: boolean;
+  enable?: boolean;
+  onNotificationChange?: (noRead: boolean) => void;
+  onPrChange?: (count: number) => void;
+}
+const Suggestion = ({
+  data,
+  enable = true,
+  openNotification,
+  onNotificationChange,
+  onPrChange,
+}: ISuggestionWidget) => {
+  const [reload, setReload] = useState(false);
+  const intl = useIntl();
+
+  return (
+    <Space orientation="vertical" style={{ width: "100%" }}>
+      {openNotification ? (
+        <Alert
+          message="温馨提示"
+          type="info"
+          showIcon
+          description={
+            <Marked
+              text="此处专为提交修改建议译文。您输入的应该是**译文**
+  而不是评论和问题。其他内容,请在讨论页面提交。"
+            />
+          }
+          action={
+            <Button
+              type="text"
+              onClick={() => {
+                localStorage.setItem("read_pr_Notification", "ok");
+                if (typeof onNotificationChange !== "undefined") {
+                  onNotificationChange(false);
+                }
+              }}
+            >
+              {intl.formatMessage({
+                id: "buttons.got.it",
+              })}
+            </Button>
+          }
+          closable
+        />
+      ) : undefined}
+
+      <SuggestionAdd
+        data={data}
+        onCreate={() => {
+          setReload(true);
+        }}
+      />
+      <SuggestionList
+        {...data}
+        enable={enable}
+        reload={reload}
+        onReload={() => {
+          setReload(false);
+        }}
+        onChange={(count: number) => {
+          if (typeof onPrChange !== "undefined") {
+            onPrChange(count);
+          }
+        }}
+      />
+    </Space>
+  );
+};
+
+export interface IAnswerCount {
+  id: string;
+  count: number;
+}
+
+const SuggestionBoxWidget = () => {
+  const [openNotification, setOpenNotification] = useState(false);
+  const [sentData, setSentData] = useState<ISentence>();
+  const discussionMessage = useAppSelector(message);
+  /**
+   * TODO useMemo
+   */
+  if (discussionMessage?.type === "pr") {
+    setSentData(discussionMessage.sent);
+  }
+  if (localStorage.getItem("read_pr_Notification") === "ok") {
+    setOpenNotification(false);
+  } else {
+    setOpenNotification(true);
+  }
+
+  return sentData ? (
+    <Suggestion
+      data={sentData}
+      enable={true}
+      openNotification={openNotification}
+      onNotificationChange={(value: boolean) => setOpenNotification(value)}
+    />
+  ) : (
+    <></>
+  );
+};
+
+export default SuggestionBoxWidget;

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

@@ -0,0 +1,48 @@
+import { Space, Tooltip } from "antd";
+
+import type { ISentence } from "../../api/Corpus";
+import { HandOutlinedIcon } from "../../assets/icon";
+import SuggestionPopover from "./SuggestionPopover";
+import { prOpen } from "./utils";
+
+interface IWidget {
+  data: ISentence;
+  hideCount?: boolean;
+  hideInZero?: boolean;
+}
+
+const SuggestionButton = ({
+  data,
+  hideCount = false,
+  hideInZero = false,
+}: IWidget) => {
+  const prNumber = data.suggestionCount?.suggestion;
+
+  return hideInZero && prNumber === 0 ? (
+    <></>
+  ) : (
+    <Space
+      style={{
+        cursor: "pointer",
+        color: prNumber && prNumber > 0 ? "#1890ff" : "unset",
+      }}
+      onClick={() => {
+        prOpen(data);
+      }}
+    >
+      <Tooltip title="修改建议">
+        <HandOutlinedIcon />
+      </Tooltip>
+      <SuggestionPopover
+        book={data.book}
+        para={data.para}
+        start={data.wordStart}
+        end={data.wordEnd}
+        channelId={data.channel.id}
+      />
+      {hideCount ? <></> : prNumber}
+    </Space>
+  );
+};
+
+export default SuggestionButton;

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

@@ -0,0 +1,60 @@
+import { useEffect, useRef, useState } from "react";
+import { useAppSelector } from "../../hooks";
+import { prInfo } from "../../reducers/pr-load";
+
+interface IWidget {
+  book: number;
+  para: number;
+  start: number;
+  end: number;
+  channelId: string;
+  children?: React.ReactNode;
+}
+const SuggestionFocusWidget = ({
+  book,
+  para,
+  start,
+  end,
+  channelId,
+  children,
+}: IWidget) => {
+  const pr = useAppSelector(prInfo);
+  const [highlight, setHighlight] = useState(false);
+  const divRef = useRef<HTMLDivElement>(null);
+
+  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);
+    }
+  }, [book, channelId, end, para, pr, start]);
+  return (
+    <div
+      ref={divRef}
+      style={{
+        backgroundColor: highlight ? "rgb(255 255 0 / 20%)" : undefined,
+        width: "100%",
+      }}
+    >
+      {children}
+    </div>
+  );
+};
+
+export default SuggestionFocusWidget;

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

@@ -0,0 +1,134 @@
+import { Button, List, message, Skeleton, Space, Switch } from "antd";
+import { useEffect, useState } from "react";
+import { ReloadOutlined } from "@ant-design/icons";
+
+import { get } from "../../request";
+import type { ISuggestionListResponse } from "../../api/Suggestion";
+
+import type { ISentence } from "../../api/Corpus";
+import SentCell from "./SentCell";
+import type { IChannel } from "../../api/Channel";
+interface IWidget {
+  book: number;
+  para: number;
+  wordStart: number;
+  wordEnd: number;
+  content?: string | null;
+  channel: IChannel;
+  enable?: boolean;
+  reload?: boolean;
+  onReload?: () => void;
+  onChange?: (count: number) => void;
+}
+const SuggestionListWidget = ({
+  book,
+  para,
+  wordStart,
+  wordEnd,
+  channel,
+  content,
+  reload = false,
+  enable = true,
+  onReload,
+  onChange,
+}: IWidget) => {
+  const [sentData, setSentData] = useState<ISentence[]>([]);
+  const [loading, setLoading] = useState(false);
+  const [showDiff, setShowDiff] = useState(true);
+  const load = () => {
+    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) => {
+        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,
+            };
+          });
+          setSentData(newData);
+          if (typeof onChange !== "undefined") {
+            onChange(json.data.count);
+          }
+        } else {
+          message.error(json.message);
+        }
+      })
+      .finally(() => {
+        setLoading(false);
+        if (reload && typeof onReload !== "undefined") {
+          onReload();
+        }
+      });
+  };
+  useEffect(() => {
+    load();
+  }, [book, channel.id, para, reload, wordEnd, wordStart]);
+  useEffect(() => {
+    if (reload) {
+      load();
+    }
+  }, [reload]);
+  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.Item>
+            )}
+          />
+        </>
+      )}
+    </>
+  );
+};
+
+export default SuggestionListWidget;

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

@@ -0,0 +1,78 @@
+import { Popover } from "antd";
+import { useEffect, useState } from "react";
+import SentCell from "./SentCell";
+import type { ISentence } from "../../api/Corpus";
+import { useAppSelector } from "../../hooks";
+import { prInfo, refresh } from "../../reducers/pr-load";
+import store from "../../store";
+
+interface IWidget {
+  book: number;
+  para: number;
+  start: number;
+  end: number;
+  channelId: string;
+}
+const SuggestionPopoverWidget = ({
+  book,
+  para,
+  start,
+  end,
+  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);
+      }
+    }
+  }, [book, channelId, end, para, pr, start]);
+
+  const handleOpenChange = (newOpen: boolean) => {
+    setOpen(newOpen);
+    if (newOpen === false) {
+      store.dispatch(refresh(null));
+    }
+  };
+  return (
+    <Popover
+      placement="bottomRight"
+      arrow={{ pointAtCenter: true }}
+      content={
+        <div>
+          <SentCell value={sentData} key={1} isPr={true} showDiff={false} />
+        </div>
+      }
+      title={`${sentData?.editor.nickName}提交的修改建议`}
+      trigger="click"
+      open={open}
+      onOpenChange={handleOpenChange}
+    >
+      <span></span>
+    </Popover>
+  );
+};
+
+export default SuggestionPopoverWidget;

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

@@ -0,0 +1,76 @@
+import { useState } from "react";
+import { type RadioChangeEvent, Space } from "antd";
+import { Radio } from "antd";
+
+import type { ISentence } from "../../api/Corpus";
+import { SuggestionIcon } from "../../assets/icon";
+import SuggestionAdd from "./SuggestionAdd";
+import SuggestionList from "./SuggestionList";
+
+interface IWidget {
+  data: ISentence;
+}
+const SuggestionTabsWidget = ({ data }: IWidget) => {
+  const [value, setValue] = useState("close");
+  const [showSuggestion, setShowSuggestion] = useState(false);
+
+  const onChange = ({ target: { value } }: RadioChangeEvent) => {
+    console.log("radio1 checked", value);
+    switch (value) {
+      case "suggestion":
+        setShowSuggestion(true);
+        break;
+    }
+    setValue(value);
+  };
+
+  return (
+    <div>
+      <div>
+        <Radio.Group
+          size="small"
+          optionType="button"
+          buttonStyle="solid"
+          onChange={onChange}
+          value={value}
+        >
+          <Radio
+            value="suggestion"
+            onClick={() => {
+              if (value === "suggestion") {
+                setValue("close");
+              }
+            }}
+            style={{
+              border: "none",
+              backgroundColor: "wheat",
+              borderRadius: 5,
+            }}
+          >
+            <Space>
+              <SuggestionIcon />
+              {data.suggestionCount?.suggestion}
+            </Space>
+          </Radio>
+          <Radio value="close" style={{ display: "none" }}></Radio>
+        </Radio.Group>
+      </div>
+      <div>
+        {showSuggestion ? (
+          <div style={{ paddingLeft: "1em" }}>
+            <div>
+              <SuggestionAdd data={data} />
+            </div>
+            <div>
+              <SuggestionList {...data} />
+            </div>
+          </div>
+        ) : (
+          <></>
+        )}
+      </div>
+    </div>
+  );
+};
+
+export default SuggestionTabsWidget;

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

@@ -0,0 +1,79 @@
+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 PrAcceptButton from "./PrAcceptButton";
+import InteractiveButton from "./InteractiveButton";
+
+const { Paragraph } = Typography;
+
+interface IWidget {
+  data: ISentence;
+  isPr?: boolean;
+  style?: React.CSSProperties;
+  compact?: boolean;
+  prOpen?: boolean;
+  onAccept?: (value: ISentence) => void;
+  onDelete?: () => void;
+  onPrClose?: () => void; /**TODO complete */
+}
+const SuggestionToolbarWidget = ({
+  data,
+  isPr = false,
+  onAccept,
+  style,
+  compact = false,
+  onDelete,
+}: IWidget) => {
+  const intl = useIntl();
+
+  return (
+    <Paragraph type="secondary" style={style}>
+      {isPr ? (
+        <Space>
+          <LikeOutlined />
+          <Divider orientation="vertical" />
+          <PrAcceptButton
+            data={data}
+            onAccept={(value: ISentence) => {
+              if (typeof onAccept !== "undefined") {
+                onAccept(value);
+              }
+            }}
+          />
+          <Popconfirm
+            title={intl.formatMessage({
+              id: "message.delete.confirm",
+            })}
+            placement="right"
+            onConfirm={() => {
+              if (typeof onDelete !== "undefined") {
+                onDelete();
+              }
+            }}
+            okType="danger"
+            okText={intl.formatMessage({
+              id: `buttons.delete`,
+            })}
+            cancelText={intl.formatMessage({
+              id: `buttons.no`,
+            })}
+          >
+            <Tooltip
+              title={intl.formatMessage({
+                id: `buttons.delete`,
+              })}
+            >
+              <DeleteOutlined />
+            </Tooltip>
+          </Popconfirm>
+        </Space>
+      ) : (
+        <InteractiveButton data={data} compact={compact} />
+      )}
+    </Paragraph>
+  );
+};
+
+export default SuggestionToolbarWidget;

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

@@ -0,0 +1,14 @@
+.sentence p {
+  margin: 0;
+}
+.sentence.ant-typography p {
+  margin: 0;
+}
+
+.cart_delete {
+  visibility: hidden;
+}
+
+.cart_item:hover .cart_delete {
+  visibility: unset;
+}

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

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

+ 131 - 0
dashboard-v6/src/components/sentence-history.tsx/SentHistory.tsx

@@ -0,0 +1,131 @@
+import { ProList } from "@ant-design/pro-components";
+import { Space, Typography } from "antd";
+
+import { get } from "../../request";
+import User from "../auth/User";
+import TimeShow from "../general/TimeShow";
+
+import { MergeIcon2 } from "../../assets/icon";
+import type {
+  ISentHistory,
+  ISentHistoryListResponse,
+} from "../../api/sentence-history";
+
+const { Paragraph } = Typography;
+
+interface IWidget {
+  sentId?: string;
+}
+const SentHistoryWidget = ({ sentId }: IWidget) => {
+  return (
+    <ProList<ISentHistory>
+      rowKey="id"
+      request={async (params = {}, sorter, filter) => {
+        if (typeof sentId === "undefined") {
+          return {
+            total: 0,
+            succcess: false,
+            data: [],
+          };
+        }
+        console.log(params, sorter, filter);
+
+        let url = `/v2/sent_history?view=sentence&id=${sentId}`;
+        const offset =
+          ((params.current ? params.current : 1) - 1) *
+          (params.pageSize ? params.pageSize : 20);
+        url += `&limit=${params.pageSize}&offset=${offset}`;
+        if (typeof params.keyword !== "undefined") {
+          url += "&search=" + (params.keyword ? params.keyword : "");
+        }
+        console.debug("sentence history list", url);
+        const res = await get<ISentHistoryListResponse>(url);
+        if (res.ok) {
+          console.debug("sentence history list", res.data);
+          const items: ISentHistory[] = res.data.rows.map((item) => {
+            return {
+              content: item.content,
+              editor: item.editor,
+              fork_from: item.fork_from,
+              pr_from: item.pr_from,
+              accepter: item.accepter,
+              createdAt: item.created_at,
+            };
+          });
+          console.debug(items);
+          return {
+            total: res.data.count,
+            succcess: true,
+            data: items,
+          };
+        } else {
+          console.error(res.message);
+          return {
+            total: 0,
+            succcess: false,
+            data: [],
+          };
+        }
+      }}
+      pagination={{
+        showQuickJumper: true,
+        showSizeChanger: true,
+      }}
+      metas={{
+        title: {
+          render: (_text, row) => {
+            return (
+              <Paragraph style={{ margin: 0 }} copyable={{ text: row.content }}>
+                {row.content}
+              </Paragraph>
+            );
+          },
+        },
+        avatar: {
+          dataIndex: "image",
+          editable: false,
+          render: (_text, row) => {
+            return <User {...row.editor} showName={false} />;
+          },
+        },
+        description: {
+          render: (_text, row) => {
+            return (
+              <Space style={{ fontSize: "80%" }}>
+                <User {...row.editor} showAvatar={false} />
+                <>{"edited"}</>
+
+                {row.accepter ? (
+                  <>
+                    <User {...row.accepter} showAvatar={false} /> {"accept"}
+                  </>
+                ) : (
+                  <></>
+                )}
+
+                {row.fork_from ? (
+                  <>
+                    <MergeIcon2 />
+                    {row.fork_from.name}
+                  </>
+                ) : (
+                  <></>
+                )}
+                <TimeShow
+                  type="secondary"
+                  createdAt={row.createdAt}
+                  showLabel={false}
+                />
+              </Space>
+            );
+          },
+        },
+        actions: {
+          render: () => [<></>],
+        },
+      }}
+    />
+  );
+};
+
+export default SentHistoryWidget;

+ 48 - 0
dashboard-v6/src/components/sentence-history.tsx/SentHistoryGroup.tsx

@@ -0,0 +1,48 @@
+import { Button } from "antd";
+import { useState } from "react";
+
+import SentHistoryItem from "./SentHistoryItem";
+import type { ISentHistoryData } from "../../api/sentence-history";
+
+interface IWidget {
+  data?: ISentHistoryData[];
+  oldContent?: string;
+}
+const SentHistoryGroupWidget = ({ data = [], oldContent }: IWidget) => {
+  const [compact, setCompact] = useState(true);
+  return (
+    <>
+      {data.length > 0 ? (
+        <div>
+          {data.length > 1 ? (
+            <Button type="link" onClick={() => setCompact(!compact)}>
+              {compact ? `显示全部修改记录-${data.length}` : "折叠"}
+            </Button>
+          ) : undefined}
+          {compact ? (
+            <SentHistoryItem
+              data={data[data.length - 1]}
+              oldContent={oldContent}
+            />
+          ) : (
+            <div>
+              {data.map((item, index) => {
+                return (
+                  <SentHistoryItem
+                    key={index}
+                    data={item}
+                    oldContent={
+                      index === 0 ? oldContent : data[index - 1].content
+                    }
+                  />
+                );
+              })}
+            </div>
+          )}
+        </div>
+      ) : undefined}
+    </>
+  );
+};
+
+export default SentHistoryGroupWidget;

+ 46 - 0
dashboard-v6/src/components/sentence-history.tsx/SentHistoryItem.tsx

@@ -0,0 +1,46 @@
+import { Space, Tooltip, Typography } from "antd";
+import { type Change, diffChars } from "diff";
+
+import User from "../auth/User";
+import TimeShow from "../general/TimeShow";
+import type { ISentHistoryData } from "../../api/sentence-history";
+
+const { Text, Paragraph } = Typography;
+
+interface IWidget {
+  data?: ISentHistoryData;
+  oldContent?: string;
+}
+const SentHistoryItemWidget = ({ data, oldContent }: IWidget) => {
+  let content = <Text>{data?.content}</Text>;
+  if (data?.content && oldContent) {
+    const diff: Change[] = diffChars(oldContent, data.content);
+    const diffResult = diff.map((item, id) => {
+      return (
+        <Text
+          key={id}
+          type={item.added ? "success" : item.removed ? "danger" : "secondary"}
+          delete={item.removed ? true : undefined}
+        >
+          {item.value}
+        </Text>
+      );
+    });
+    content = <Tooltip title={data.content}>{diffResult}</Tooltip>;
+  }
+  return (
+    <Paragraph style={{ paddingLeft: 12 }}>
+      <blockquote>
+        {content}
+        <div>
+          <Space style={{ fontSize: "80%" }}>
+            <User {...data?.editor} showAvatar={false} />
+            <TimeShow type="secondary" createdAt={data?.created_at} />
+          </Space>
+        </div>
+      </blockquote>
+    </Paragraph>
+  );
+};
+
+export default SentHistoryItemWidget;

+ 64 - 0
dashboard-v6/src/components/sentence-history.tsx/SentHistoryModal.tsx

@@ -0,0 +1,64 @@
+import React, { useEffect, useState } from "react";
+import { Modal } from "antd";
+import { useIntl } from "react-intl";
+
+import SentHistory from "./SentHistory";
+
+interface IWidget {
+  sentId?: string;
+  trigger?: React.ReactNode;
+  open?: boolean;
+  onClose?: () => void;
+}
+const SentHistoryModalWidget = ({
+  open = false,
+  sentId,
+  trigger,
+  onClose,
+}: IWidget) => {
+  const [isModalOpen, setIsModalOpen] = useState(open);
+  const intl = useIntl();
+
+  useEffect(() => {
+    setIsModalOpen(open);
+  }, [open]);
+
+  const showModal = () => {
+    setIsModalOpen(true);
+  };
+
+  const handleOk = () => {
+    setIsModalOpen(false);
+    if (typeof onClose !== "undefined") {
+      onClose();
+    }
+  };
+
+  const handleCancel = () => {
+    setIsModalOpen(false);
+    if (typeof onClose !== "undefined") {
+      onClose();
+    }
+  };
+
+  return (
+    <>
+      <span onClick={showModal}>{trigger}</span>
+      <Modal
+        style={{ top: 20 }}
+        width={"80%"}
+        title={intl.formatMessage({
+          id: "buttons.timeline",
+        })}
+        open={isModalOpen}
+        onOk={handleOk}
+        onCancel={handleCancel}
+        destroyOnHidden
+      >
+        <SentHistory sentId={sentId} />
+      </Modal>
+    </>
+  );
+};
+
+export default SentHistoryModalWidget;

+ 21 - 17
dashboard-v6/src/components/sentence/utils.ts

@@ -1,22 +1,26 @@
 import type { ISentence, ISentenceData } from "../../api/Corpus";
 import type { ISentence, ISentenceData } from "../../api/Corpus";
 
 
-export const toISentence = (apiData: ISentenceData): ISentence => {
+export const toISentence = (
+  item: ISentenceData,
+  channelsId?: string[]
+): ISentence => {
   return {
   return {
-    id: apiData.id,
-    content: apiData.content,
-    contentType: apiData.content_type,
-    html: apiData.html,
-    book: apiData.book,
-    para: apiData.paragraph,
-    wordStart: apiData.word_start,
-    wordEnd: apiData.word_end,
-    editor: apiData.editor,
-    studio: apiData.studio,
-    channel: apiData.channel,
-    updateAt: apiData.updated_at,
-    acceptor: apiData.acceptor,
-    prEditAt: apiData.pr_edit_at,
-    forkAt: apiData.fork_at,
-    suggestionCount: apiData.suggestionCount,
+    id: item.id,
+    content: item.content,
+    contentType: item.content_type,
+    html: item.html,
+    book: item.book,
+    para: item.paragraph,
+    wordStart: item.word_start,
+    wordEnd: item.word_end,
+    editor: item.editor,
+    studio: item.studio,
+    channel: item.channel,
+    updateAt: item.updated_at,
+    acceptor: item.acceptor,
+    prEditAt: item.pr_edit_at,
+    forkAt: item.fork_at,
+    suggestionCount: item.suggestionCount,
+    translationChannels: channelsId,
   };
   };
 };
 };

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

@@ -0,0 +1,27 @@
+import SentEdit from "./SentEdit";
+import Video from "./Video";
+import WbwSent from "./WbwSent";
+import Wd from "./Wd";
+
+interface IWidgetMdTpl {
+  tpl?: string;
+  props?: string;
+  children?: React.ReactNode | React.ReactNode[];
+}
+const Widget = ({ tpl, props }: IWidgetMdTpl) => {
+  switch (tpl) {
+    case "sentedit":
+      return <SentEdit props={props ? props : ""} />;
+    case "wbw_sent":
+      return <WbwSent props={props ? props : ""} />;
+    case "wd":
+      return <Wd props={props ? props : ""} />;
+
+    case "video":
+      return <Video props={props ? props : ""} />;
+    default:
+      return <>未定义模版({tpl})</>;
+  }
+};
+
+export default Widget;

+ 15 - 0
dashboard-v6/src/components/template/SentEdit.tsx

@@ -0,0 +1,15 @@
+import {
+  SentEditInner,
+  type IWidgetSentEditInner,
+} from "../sentence-editor/SentEdit";
+
+interface IWidgetSentEdit {
+  props: string;
+}
+const Widget = ({ props }: IWidgetSentEdit) => {
+  const prop = JSON.parse(atob(props)) as IWidgetSentEditInner;
+  //console.log("sent data", prop);
+  return <SentEditInner {...prop} />;
+};
+
+export default Widget;

+ 15 - 0
dashboard-v6/src/components/template/WbwSent.tsx

@@ -0,0 +1,15 @@
+import { memo, useMemo } from "react";
+import WbwSentCtl, { type IWbwSentCtl } from "../wbw/WbwSentCtl";
+
+interface IWidgetWbwSent {
+  props: string;
+}
+
+const WbwSentWidget = memo(({ props }: IWidgetWbwSent) => {
+  const prop = useMemo(() => JSON.parse(atob(props)) as IWbwSentCtl, [props]);
+  return <WbwSentCtl {...prop} />;
+});
+
+WbwSentWidget.displayName = "WbwSentWidget";
+
+export default WbwSentWidget;

+ 33 - 0
dashboard-v6/src/components/template/Wd.tsx

@@ -0,0 +1,33 @@
+import { lookup } from "../../reducers/command";
+import store from "../../store";
+import "./style.css";
+
+interface IWidgetWdCtl {
+  text?: string;
+}
+export const WdCtl = ({ text }: IWidgetWdCtl) => {
+  return (
+    <>
+      {text !== "ti" ? " " : undefined}
+      <span
+        className="pcd_word"
+        onClick={() => {
+          //发送点词查询消息
+          store.dispatch(lookup(text));
+        }}
+      >
+        {text}
+      </span>
+    </>
+  );
+};
+
+interface IWidgetTerm {
+  props: string;
+}
+const WdWidget = ({ props }: IWidgetTerm) => {
+  const prop = JSON.parse(atob(props)) as IWidgetWdCtl;
+  return <WdCtl {...prop} />;
+};
+
+export default WdWidget;

+ 114 - 0
dashboard-v6/src/components/template/style.css

@@ -0,0 +1,114 @@
+.pcd_word {
+  text-decoration: none;
+  text-underline-offset: 4px;
+  cursor: pointer;
+}
+.pcd_word:hover {
+  text-decoration: underline dotted;
+}
+.sent_read p {
+  display: inline;
+}
+
+.sent_read_translation:hover {
+  background-color: beige;
+}
+
+.sent_read_interactive_button:hover ~ .sent_read_translation {
+  background-color: beige;
+}
+
+.sent_read_translation:hover ~ .sent_read_interactive_button {
+  border: 1px solid black;
+}
+
+.sent-edit-inner {
+  border: 1px solid rgb(129 129 129 / 10%);
+  margin-top: 4px;
+  border-radius: 6px;
+  background-color: rgb(255 255 255 / 8%);
+  width: 100%;
+}
+.sent-focus {
+  border: 2px solid rgb(0 0 200 / 50%);
+}
+
+.sent-edit-inner .affix {
+  background-color: white;
+}
+.sent_tabs {
+  padding: 0 8px;
+  background-color: rgba(92, 164, 247, 0.1);
+}
+
+.sent_tabs.compact {
+  position: unset;
+  margin-top: -32px;
+  width: 100%;
+  margin-right: 10px;
+  background-color: unset;
+}
+
+.sent_tabs:hover .sent_tabs.compact {
+  background-color: rgba(128, 128, 128, 0.1);
+}
+.sent_tabs.compact.curr_close {
+  position: absolute;
+  background-color: rgba(128, 128, 128, 0.1);
+}
+
+.sent_tabs .content {
+  padding: 0 8px;
+}
+
+.sent_tabs .ant-tabs-tab {
+  background: #c6c5c5 !important;
+}
+.sent_tabs .ant-tabs-tab-active {
+  background: rgba(92, 164, 247, 0.1) !important;
+}
+
+/** 2 级 组件 */
+/*
+.sent-edit-inner .sent-edit-inner .sent_tabs {
+  background-color: rgba(128, 128, 128, 0.9);
+}
+.sent-edit-inner .sent-edit-inner .sent_tabs .ant-tabs-tab {
+  background: #ececec !important;
+}
+
+.sent-edit-inner .sent-edit-inner .sent_tabs .ant-tabs-tab-active {
+  background: rgba(128, 128, 128, 0.1) !important;
+}
+*/
+/** 3 级 组件 */
+/*
+.sent-edit-inner .sent-edit-inner .sent-edit-inner .sent_tabs {
+  background-color: rgb(200, 200, 200);
+}
+.sent-edit-inner .sent-edit-inner .sent-edit-inner .sent_tabs .ant-tabs-tab {
+  background: #c6c5c5 !important;
+}
+
+.sent-edit-inner
+  .sent-edit-inner
+  .sent-edit-inner
+  .sent_tabs
+  .ant-tabs-tab-active {
+  background: rgba(128, 128, 128, 0.9) !important;
+}
+  */
+.pcd_sent_commentary {
+  border: 2px dotted darkred;
+  border-radius: 8px;
+  padding: 4px;
+  margin: 6px;
+  background-color: #f5deb357;
+}
+
+.pcd_sent_commentary .pcd_sent_commentary {
+  background-color: #44c76167;
+}
+.pcd_sent_commentary .pcd_sent_commentary .pcd_sent_commentary {
+  display: none;
+}

+ 161 - 0
dashboard-v6/src/components/template/utilities.ts

@@ -0,0 +1,161 @@
+import React from "react";
+
+import ParserError from "../general/ParserError";
+import type { TCodeConvertor } from "../../types/template";
+import { my_to_roman, roman_to_my } from "../../utils/code/my";
+import { roman_to_si } from "../../utils/code/si";
+import { roman_to_thai } from "../../utils/code/thai";
+import { roman_to_taitham } from "../../utils/code/tai-tham";
+import { WdCtl } from "./Wd";
+import MdTpl from "./MdTpl";
+
+export function XmlToReact(
+  text: string,
+  wordWidget: boolean = false,
+  convertor?: TCodeConvertor
+): React.ReactNode[] | undefined {
+  //console.log("html string:", text);
+  const parser = new DOMParser();
+  const xmlDoc = parser.parseFromString(`<body>${text}</body>`, "text/html");
+  const x = xmlDoc.documentElement;
+  //console.log("解析成功", x);
+  return convert(x.getElementsByTagName("body")[0], wordWidget, convertor);
+
+  function getAttr(node: ChildNode, key: number): object {
+    const ele = node as Element;
+    const attr = ele.attributes;
+    //TODO fix it
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    const output: any = { key: key };
+    for (let i = 0; i < attr.length; i++) {
+      if (attr[i].nodeType === 2) {
+        const key: string = attr[i].nodeName;
+        if (key !== "style") {
+          if (key === "class") {
+            output["className"] = attr[i].nodeValue;
+          } else {
+            output[key] = attr[i].nodeValue;
+          }
+        } else {
+          //TODO 把css style 转换为react style
+        }
+      }
+    }
+    return output;
+  }
+
+  function convert(
+    node: ChildNode,
+    wordWidget: boolean = false,
+    convertor?: TCodeConvertor
+  ): React.ReactNode[] | undefined {
+    const output: React.ReactNode[] = [];
+    for (let i = 0; i < node.childNodes.length; i++) {
+      const value = node.childNodes[i];
+      //console.log(value.nodeName, value.nodeType, value.nodeValue);
+
+      switch (value.nodeType) {
+        case 1: {
+          //element node
+          const tagName = value.nodeName.toLowerCase();
+          //console.log("tag", value.nodeName, tagName);
+          switch (tagName) {
+            case "parsererror":
+              output.push(
+                React.createElement(
+                  ParserError,
+                  getAttr(value, i),
+                  convert(value, wordWidget, convertor)
+                )
+              );
+              break;
+            case "mdtpl":
+              output.push(
+                React.createElement(
+                  MdTpl,
+                  getAttr(value, i),
+                  convert(value, wordWidget, convertor)
+                )
+              );
+              break;
+            case "param":
+              output.push(
+                React.createElement(
+                  "span",
+                  getAttr(value, i),
+                  convert(value, wordWidget, convertor)
+                )
+              );
+              break;
+            default:
+              try {
+                output.push(
+                  React.createElement(
+                    tagName,
+                    getAttr(value, i),
+                    convert(value, wordWidget, convertor)
+                  )
+                );
+              } catch (error) {
+                console.log("ParserError", tagName, error);
+                output.push(React.createElement(ParserError, { key: i }, []));
+              }
+
+              break;
+          }
+
+          break;
+        }
+        case 2: //attribute node
+          return [];
+        case 3: {
+          //text node
+          let textValue = value.nodeValue ? value.nodeValue : undefined;
+          //编码转换
+          if (typeof convertor !== "undefined" && textValue !== null) {
+            switch (convertor) {
+              case "roman_to_my":
+                textValue = roman_to_my(textValue);
+                break;
+              case "my_to_roman":
+                textValue = my_to_roman(textValue);
+                break;
+              case "roman_to_si":
+                textValue = roman_to_si(textValue);
+                break;
+              case "roman_to_thai":
+                textValue = roman_to_thai(textValue);
+                break;
+              case "roman_to_taitham":
+                textValue = roman_to_taitham(textValue);
+                break;
+            }
+          }
+          if (wordWidget) {
+            //将单词按照空格拆开。用组件包裹
+            const wordList = textValue?.split(" ");
+            const wordWidget = wordList?.map((word, id) => {
+              // eslint-disable-next-line @typescript-eslint/no-explicit-any
+              const prop: any = { key: id, text: word };
+              return React.createElement(WdCtl, prop);
+            });
+            output.push(wordWidget);
+          } else {
+            output.push(textValue);
+          }
+
+          break;
+        }
+        case 8:
+          return [];
+        case 9:
+          return [];
+      }
+    }
+    if (output.length > 0) {
+      return output;
+    } else {
+      return undefined;
+    }
+  }
+}

+ 25 - 0
dashboard-v6/src/components/term/TermModal.tsx

@@ -0,0 +1,25 @@
+import type { ITermDataResponse } from "../../api/Term";
+
+interface IWidget {
+  trigger?: React.ReactNode;
+  open?: boolean;
+  id?: string;
+  word?: string;
+  tags?: string[];
+  studioName?: string;
+  channelId?: string;
+  parentChannelId?: string;
+  parentStudioId?: string;
+  community?: boolean;
+  onUpdate?: (value: ITermDataResponse) => void;
+  onClose?: () => void;
+}
+export const TermModalMock = ({ open, trigger }: IWidget) => {
+  console.debug("TermModalMock", open);
+
+  return (
+    <>
+      <span>{trigger}</span>
+    </>
+  );
+};

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

@@ -0,0 +1,29 @@
+import type { JSX } from "react";
+import type { ArticleType } from "../../api/Corpus";
+import type { TDisplayStyle } from "../../types/template";
+
+interface IWidget {
+  type?: ArticleType;
+  articleId?: string;
+  title?: string;
+  style?: TDisplayStyle;
+  channel?: string | null;
+}
+
+export const ArticleTplMock = ({ title }: IWidget) => {
+  return <span>{"ArticleTplMock" + title}</span>;
+};
+
+interface IModalWidget {
+  open?: boolean;
+  type?: ArticleType;
+  articleId?: string;
+  channelsId?: string | null;
+  title?: string;
+  style?: TDisplayStyle;
+  trigger?: JSX.Element;
+  onClose?: () => void;
+}
+export const ArticleTplModalMock = ({ trigger }: IModalWidget) => {
+  return <>{trigger}</>;
+};

+ 68 - 0
dashboard-v6/src/components/tpl-builder/Builder.tsx

@@ -0,0 +1,68 @@
+import { useEffect, useState } from "react";
+import { Modal, Tabs } from "antd";
+import type { ArticleType } from "../../api/Corpus";
+import { ArticleTplMock } from "./ArticleTpl";
+import { VideoTplMock } from "./VideoTpl";
+
+interface IWidget {
+  trigger?: React.ReactNode;
+  open?: boolean;
+  tpl?: ArticleType;
+  articleId?: string;
+  title?: string;
+  onClose?: () => void;
+}
+const TplBuilderWidget = ({
+  trigger,
+  open = false,
+  tpl,
+  articleId,
+  onClose,
+}: IWidget) => {
+  const [isModalOpen, setIsModalOpen] = useState(open);
+
+  useEffect(() => {
+    setIsModalOpen(open);
+  }, [open]);
+
+  const showModal = () => {
+    setIsModalOpen(true);
+  };
+
+  const handleCancel = () => {
+    if (onClose) {
+      onClose();
+    } else {
+      setIsModalOpen(false);
+    }
+  };
+
+  return (
+    <>
+      <span onClick={showModal}>{trigger}</span>
+      <Modal
+        style={{ top: 20 }}
+        width={900}
+        footer={false}
+        title="template builder"
+        open={isModalOpen}
+        onCancel={handleCancel}
+      >
+        <Tabs
+          tabPosition="left"
+          defaultActiveKey="article"
+          items={[
+            {
+              label: "article",
+              key: "article",
+              children: <ArticleTplMock articleId={articleId} type={tpl} />,
+            }, // 务必填写 key
+            { label: "video", key: "video", children: <VideoTplMock /> },
+          ]}
+        />
+      </Modal>
+    </>
+  );
+};
+
+export default TplBuilderWidget;

+ 21 - 0
dashboard-v6/src/components/tpl-builder/VideoTpl.tsx

@@ -0,0 +1,21 @@
+import type { JSX } from "react";
+import type { TDisplayStyle } from "../../types/template";
+
+interface IWidget {
+  url?: string;
+  title?: string;
+  style?: TDisplayStyle;
+}
+export const VideoTplMock = ({ title }: IWidget) => {
+  return <>{title}</>;
+};
+
+interface IModalWidget {
+  url?: string;
+  title?: string;
+  style?: TDisplayStyle;
+  trigger?: JSX.Element;
+}
+export const VideoTplModalMock = ({ trigger }: IModalWidget) => {
+  return <>{trigger}</>;
+};

+ 1128 - 0
dashboard-v6/src/components/wbw/WbwSentCtl.tsx

@@ -0,0 +1,1128 @@
+import { Alert, Button, Dropdown, message, Progress, Space, Tree } from "antd";
+import { useEffect, useState, useMemo, useCallback, memo } from "react";
+import { MoreOutlined, ExclamationCircleOutlined } from "@ant-design/icons";
+import { useIntl } from "react-intl";
+
+import { useAppSelector } from "../../hooks";
+import { mode as _mode } from "../../reducers/article-mode";
+import { delete_, get, post } from "../../request";
+
+import WbwWord from "./WbwWord";
+
+import { add } from "../../reducers/sent-word";
+import store from "../../store";
+import { settingInfo } from "../../reducers/setting";
+
+import { getGrammar } from "../../reducers/term-vocabulary";
+import modal from "antd/lib/modal";
+import { currentUser } from "../../reducers/current-user";
+
+import TimeShow from "../general/TimeShow";
+import dayjs from "dayjs";
+import { courseInfo } from "../../reducers/current-course";
+
+import { siteInfo } from "../../reducers/layout";
+import {
+  WbwStatus,
+  type IWbw,
+  type IWbwFields,
+  type TWbwDisplayMode,
+  type WbwElement,
+} from "../../types/wbw";
+import type { IChannel, TChannelType } from "../../api/Channel";
+import type { ArticleMode, ISentenceWbwListResponse } from "../../api/Corpus";
+import type { IStudio } from "../../api/Auth";
+import { useWbwStreamProcessor } from "../../hooks/useWbwStreamProcessor";
+import { GetUserSetting } from "../setting/default";
+import type { IDictRequest } from "../../api/Dict";
+import { UserWbwPost } from "../dict/utils";
+import type { IDeleteResponse } from "../../api/Group";
+import Studio from "../auth/Studio";
+import {
+  createSnIndexMap,
+  createSnKey,
+  getWbwProgress,
+  paraMark,
+} from "./utils";
+
+// ============ 接口定义 ============
+
+interface IMagicDictRequest {
+  book: number;
+  para: number;
+  word_start: number;
+  word_end: number;
+  data: IWbw[];
+  channel_id: string;
+  lang?: string[];
+}
+
+interface IMagicDictResponse {
+  ok: boolean;
+  message: string;
+  data: IWbw[];
+}
+
+interface IWbwXml {
+  id: string;
+  pali: WbwElement<string>;
+  real?: WbwElement<string | null>;
+  type?: WbwElement<string | null>;
+  gramma?: WbwElement<string | null>;
+  mean?: WbwElement<string | null>;
+  org?: WbwElement<string | null>;
+  om?: WbwElement<string | null>;
+  case?: WbwElement<string | null>;
+  parent?: WbwElement<string | null>;
+  pg?: WbwElement<string | null>;
+  parent2?: WbwElement<string | null>;
+  rela?: WbwElement<string | null>;
+  lock?: boolean;
+  bmt?: WbwElement<string | null>;
+  bmc?: WbwElement<number | null>;
+  cf: number;
+}
+
+interface IWbwUpdateResponse {
+  ok: boolean;
+  message: string;
+  data: { rows: IWbw[]; count: number };
+}
+
+interface IWbwWord {
+  words: IWbwXml[];
+  sn: number;
+}
+
+interface IWbwRequest {
+  book: number;
+  para: number;
+  sn: number;
+  channel_id: string;
+  data: IWbwWord[];
+}
+
+export interface IWbwSentCtl {
+  data: IWbw[];
+  answer?: IWbw[];
+  book: number;
+  para: number;
+  wordStart: number;
+  wordEnd: number;
+  channel?: IChannel;
+  channelId: string;
+  channelType?: TChannelType;
+  channelLang?: string;
+  display?: TWbwDisplayMode;
+  fields?: IWbwFields;
+  layoutDirection?: "h" | "v";
+  refreshable?: boolean;
+  mode?: ArticleMode;
+  wbwProgress?: boolean;
+  studio?: IStudio;
+  readonly?: boolean;
+  onMagicDictDone?: () => void;
+  onChange?: (data: IWbw[]) => void;
+}
+
+// ============ 主组件 ============
+
+const WbwSentCtl = memo(
+  ({
+    data,
+    answer,
+    channelId,
+    channelType,
+    channelLang,
+    book,
+    para,
+    wordStart,
+    wordEnd,
+    display = "block",
+    fields,
+    layoutDirection = "h",
+    mode,
+    refreshable = false,
+    wbwProgress = false,
+    readonly = false,
+    studio,
+    onChange,
+    onMagicDictDone,
+  }: IWbwSentCtl) => {
+    const intl = useIntl();
+
+    // ============ State ============
+    const [wordData, setWordData] = useState<IWbw[]>(() => paraMark(data));
+    const [wbwMode, setWbwMode] = useState(display);
+    const [fieldDisplay, setFieldDisplay] = useState(fields);
+    const [displayMode, setDisplayMode] = useState<ArticleMode>();
+    const [loading, setLoading] = useState(false);
+    const [showProgress, setShowProgress] = useState(false);
+    const [check, setCheck] = useState(answer ? true : false);
+    const [courseAnswer, setCourseAnswer] = useState<IWbw[]>();
+
+    const { processStream, isProcessing, wbwData, error } =
+      useWbwStreamProcessor();
+
+    // ============ Selectors ============
+    const user = useAppSelector(currentUser);
+    const course = useAppSelector(courseInfo);
+    const site = useAppSelector(siteInfo);
+    const settings = useAppSelector(settingInfo);
+    const newMode = useAppSelector(_mode);
+    const sysGrammar = useAppSelector(getGrammar)?.filter(
+      (value) => value.tag === ":collocation:"
+    );
+
+    // ============ Memoized Values ============
+
+    // 优化5: 缓存句子ID
+    const sentId = useMemo(
+      () => `${book}-${para}-${wordStart}-${wordEnd}`,
+      [book, para, wordStart, wordEnd]
+    );
+
+    // 优化6: 缓存模型配置
+    const wbwModel = useMemo(
+      () => site?.settings?.models?.wbw?.[0] ?? null,
+      [site?.settings?.models?.wbw]
+    );
+
+    // 优化7: 缓存进度计算
+    const progress = useMemo(
+      () => getWbwProgress(wordData, answer),
+      [wordData, answer]
+    );
+
+    // 优化8: 缓存更新时间
+    const updatedAt = useMemo(() => {
+      let latest = dayjs("1970-1-1");
+      data.forEach((value) => {
+        if (dayjs(value.updated_at).isAfter(latest)) {
+          latest = dayjs(value.updated_at);
+        }
+      });
+      return latest;
+    }, [data]);
+
+    // 优化9: 使用 Map 缓存语法匹配
+    const grammarMap = useMemo(() => {
+      if (!sysGrammar) return new Map();
+      const map = new Map<string, string>();
+      sysGrammar.forEach((g) => {
+        g.word.split("...").forEach((word) => {
+          map.set(word, g.guid ?? "1");
+        });
+      });
+      return map;
+    }, [sysGrammar]);
+
+    // ============ Callbacks ============
+
+    // 优化10: 使用 useCallback 缓存回调函数
+    const update = useCallback(
+      (data: IWbw[], replace: boolean = true) => {
+        if (replace) {
+          setWordData(paraMark(data));
+        } else {
+          setWordData((origin) => {
+            const dataMap = createSnIndexMap(data);
+            return origin.map((value) => {
+              const snKey = createSnKey(value.sn);
+              const newOne = dataMap.get(snKey);
+              if (newOne) return newOne;
+
+              // 检查 real.value 匹配
+              const byReal = data.find(
+                (d) => d.real.value === value.real.value
+              );
+              return byReal || value;
+            });
+          });
+        }
+
+        if (typeof onChange !== "undefined") {
+          onChange(data);
+        }
+      },
+      [onChange]
+    );
+
+    const wbwToXml = useCallback(
+      (item: IWbw) => {
+        return {
+          pali: item.word,
+          real: item.real,
+          id: `${book}-${para}-${createSnKey(item.sn).replace(/,/g, "-")}`,
+          type: item.type,
+          gramma: item.grammar,
+          mean: item.meaning
+            ? {
+                value: item.meaning.value,
+                status: item.meaning?.status,
+              }
+            : undefined,
+          org: item.factors,
+          om: item.factorMeaning,
+          case: item.case,
+          parent: item.parent,
+          pg: item.grammar2,
+          parent2: item.parent2,
+          rela: item.relation,
+          lock: item.locked,
+          note: item.note,
+          bmt: item.bookMarkText,
+          bmc: item.bookMarkColor,
+          attachments: JSON.stringify(item.attachments),
+          cf: item.confidence,
+        };
+      },
+      [book, para]
+    );
+
+    const postWord = useCallback((postParam: IWbwRequest) => {
+      const url = `/v2/wbw`;
+      console.info("wbw api request", url, postParam);
+      post<IWbwRequest, IWbwUpdateResponse>(url, postParam).then((json) => {
+        console.info("wbw api response", json);
+        if (json.ok) {
+          message.info(json.data.count + " updated");
+          setWordData(paraMark(json.data.rows));
+        } else {
+          message.error(json.message);
+        }
+      });
+    }, []);
+
+    const saveWbwAll = useCallback(
+      (wbwData: IWbw[]) => {
+        const snSet = new Set<number>();
+        wbwData.forEach((value) => {
+          snSet.add(value.sn[0]);
+        });
+        const arrSn = Array.from(snSet);
+
+        const postParam: IWbwRequest = {
+          book: book,
+          para: para,
+          channel_id: channelId,
+          sn: wbwData[0].sn[0],
+          data: arrSn.map((item) => {
+            return {
+              sn: item,
+              words: wbwData
+                .filter((value) => value.sn[0] === item)
+                .map(wbwToXml),
+            };
+          }),
+        };
+
+        postWord(postParam);
+      },
+      [book, para, channelId, wbwToXml, postWord]
+    );
+
+    const saveWord = useCallback(
+      (wbwData: IWbw[], sn: number) => {
+        if (channelType === "nissaya") {
+          return;
+        }
+
+        const data = wbwData.filter((value) => value.sn[0] === sn);
+
+        const postParam: IWbwRequest = {
+          book: book,
+          para: para,
+          channel_id: channelId,
+          sn: sn,
+          data: [
+            {
+              sn: sn,
+              words: data.map(wbwToXml),
+            },
+          ],
+        };
+
+        postWord(postParam);
+      },
+      [channelType, book, para, channelId, wbwToXml, postWord]
+    );
+
+    const magicDictLookup = useCallback(() => {
+      const _lang = GetUserSetting("setting.dict.lang", settings);
+      const url = `/v2/wbwlookup`;
+
+      post<IMagicDictRequest, IMagicDictResponse>(url, {
+        book: book,
+        para: para,
+        word_start: wordStart,
+        word_end: wordEnd,
+        data: wordData,
+        channel_id: channelId,
+        lang: _lang?.toString().split(","),
+      })
+        .then((json) => {
+          if (json.ok) {
+            console.log("magic dict result", json.data);
+            update(json.data);
+            if (channelType !== "nissaya") {
+              saveWbwAll(json.data);
+            }
+          } else {
+            console.error(json.message);
+          }
+        })
+        .finally(() => {
+          setLoading(false);
+          if (typeof onMagicDictDone !== "undefined") {
+            onMagicDictDone();
+          }
+        });
+    }, [
+      settings,
+      book,
+      para,
+      wordStart,
+      wordEnd,
+      wordData,
+      channelId,
+      channelType,
+      update,
+      saveWbwAll,
+      onMagicDictDone,
+    ]);
+
+    const wbwPublish = useCallback(
+      (wbwData: IWbw[], isPublic: boolean) => {
+        const wordData: IDictRequest[] = [];
+
+        wbwData.forEach((data) => {
+          if (
+            (typeof data.meaning?.value === "string" &&
+              data.meaning?.value.trim().length > 0) ||
+            (typeof data.factorMeaning?.value === "string" &&
+              data.factorMeaning.value.trim().length > 0)
+          ) {
+            const [wordType, wordGrammar] = data.case?.value
+              ? // eslint-disable-next-line no-unsafe-optional-chaining
+                data.case?.value?.split("#")
+              : ["", ""];
+            let conf = data.confidence * 100;
+            if (data.confidence.toString() === "0.5") {
+              conf = 100;
+            }
+            wordData.push({
+              word: data.real.value ? data.real.value : "",
+              type: wordType,
+              grammar: wordGrammar,
+              mean: data.meaning?.value,
+              parent: data.parent?.value,
+              factors: data.factors?.value,
+              factormean: data.factorMeaning?.value,
+              note: data.note?.value,
+              confidence: conf,
+              language: channelLang,
+              status: isPublic ? 30 : 5,
+            });
+          }
+        });
+
+        UserWbwPost(wordData, "wbw")
+          .finally(() => {
+            setLoading(false);
+          })
+          .then((json) => {
+            if (json.ok) {
+              message.success(
+                "wbw " + intl.formatMessage({ id: "flashes.success" })
+              );
+            } else {
+              message.error(json.message);
+            }
+          });
+      },
+      [channelLang, intl]
+    );
+
+    const resetWbw = useCallback(() => {
+      const newData: IWbw[] = [];
+      let count = 0;
+
+      wordData.forEach((value: IWbw) => {
+        if (
+          value.type?.value !== null &&
+          value.type?.value !== ".ctl." &&
+          value.real.value &&
+          value.real.value.length > 0
+        ) {
+          count++;
+          newData.push({
+            uid: value.uid,
+            book: value.book,
+            para: value.para,
+            sn: value.sn,
+            word: value.word,
+            real: value.real,
+            style: value.style,
+            meaning: { value: "", status: 7 },
+            type: { value: "", status: 7 },
+            grammar: { value: "", status: 7 },
+            grammar2: { value: "", status: 7 },
+            parent: { value: "", status: 7 },
+            parent2: { value: "", status: 7 },
+            case: { value: "", status: 7 },
+            factors: { value: "", status: 7 },
+            factorMeaning: { value: "", status: 7 },
+            confidence: value.confidence,
+          });
+        } else {
+          newData.push(value);
+        }
+      });
+
+      message.info(`已经重置${count}个`);
+      update(newData);
+      saveWbwAll(newData);
+    }, [wordData, update, saveWbwAll]);
+
+    const deleteWbw = useCallback(() => {
+      const url = `/v2/wbw-sentence/${sentId}?channel=${channelId}`;
+      console.info("api request", url);
+      setLoading(true);
+      delete_<IDeleteResponse>(url)
+        .then((json) => {
+          console.debug("api response", json);
+          if (json.ok) {
+            message.success(
+              intl.formatMessage(
+                { id: "message.delete.success" },
+                { count: json.data }
+              )
+            );
+          } else {
+            message.error(json.message);
+          }
+        })
+        .finally(() => setLoading(false))
+        .catch((e) => console.log("Oops errors!", e));
+    }, [sentId, channelId, intl]);
+
+    const loadAnswer = useCallback(() => {
+      if (courseAnswer || !course) {
+        return;
+      }
+
+      let url = `/v2/wbw-sentence?view=course-answer`;
+      url += `&book=${book}&para=${para}&wordStart=${wordStart}&wordEnd=${wordEnd}`;
+      url += `&course=${course.courseId}`;
+
+      setLoading(true);
+      console.info("wbw sentence api request", url);
+      get<ISentenceWbwListResponse>(url)
+        .then((json) => {
+          console.info("wbw sentence api response", json);
+          if (json.ok) {
+            if (json.data.rows.length > 0 && json.data.rows[0].origin) {
+              const response = json.data.rows[0].origin[0];
+              setCourseAnswer(
+                response ? JSON.parse(response.content ?? "") : undefined
+              );
+            }
+          }
+        })
+        .finally(() => setLoading(false));
+    }, [courseAnswer, course, book, para, wordStart, wordEnd]);
+
+    // ============ Effects ============
+
+    // 优化11: 合并 AI 数据更新到单个 effect
+    useEffect(() => {
+      if (wbwData.length === 0) return;
+
+      setWordData((origin) => {
+        const wbwMap = createSnIndexMap(wbwData);
+
+        return origin.map((item) => {
+          const snKey = createSnKey(item.sn);
+          const aiWbw =
+            wbwMap.get(snKey) ||
+            wbwData.find((v) => v.real.value === item.real.value);
+
+          if (!aiWbw) return item;
+
+          const newItem = { ...item };
+
+          if (newItem.meaning && aiWbw.meaning) {
+            newItem.meaning = {
+              ...newItem.meaning,
+              value: aiWbw.meaning.value,
+            };
+          }
+          if (newItem.factors && aiWbw.factors) {
+            newItem.factors = {
+              ...newItem.factors,
+              value: aiWbw.factors.value,
+            };
+          }
+          if (newItem.factorMeaning && aiWbw.factorMeaning) {
+            newItem.factorMeaning = {
+              ...newItem.factorMeaning,
+              value: aiWbw.factorMeaning.value,
+            };
+          }
+          if (newItem.parent && aiWbw.parent?.value) {
+            newItem.parent = { ...newItem.parent, value: aiWbw.parent.value };
+          }
+          if (newItem.type && aiWbw.type?.value) {
+            newItem.type = {
+              ...newItem.type,
+              value: aiWbw.type.value.replaceAll(" ", ""),
+            };
+
+            if (newItem.grammar && aiWbw.grammar?.value) {
+              newItem.grammar = {
+                ...newItem.grammar,
+                value: aiWbw.grammar.value.replaceAll(" ", ""),
+              };
+
+              if (newItem.case?.value === "") {
+                newItem.case = {
+                  ...newItem.case,
+                  value: `${aiWbw.type.value}#${aiWbw.grammar.value}`,
+                };
+              }
+            }
+          }
+
+          return newItem;
+        });
+      });
+    }, [wbwData]);
+
+    useEffect(() => setShowProgress(wbwProgress), [wbwProgress]);
+
+    useEffect(() => {
+      if (refreshable) {
+        setWordData(paraMark(data));
+      }
+    }, [data, refreshable]);
+
+    // 优化12: 优化单词发布逻辑
+    useEffect(() => {
+      const words = new Set<string>();
+
+      wordData
+        .filter(
+          (value) =>
+            value.type?.value !== null &&
+            value.type?.value !== ".ctl." &&
+            value.real.value &&
+            value.real.value.length > 0
+        )
+        .forEach((value) => {
+          if (value.real.value) {
+            words.add(value.real.value);
+          }
+          if (value.parent?.value) {
+            words.add(value.parent.value);
+          }
+        });
+
+      const pubWords = Array.from(words);
+      store.dispatch(add({ sentId, words: pubWords }));
+    }, [sentId, wordData]);
+
+    useEffect(() => {
+      let currMode: ArticleMode | undefined;
+      if (typeof mode !== "undefined") {
+        currMode = mode;
+      } else if (typeof newMode !== "undefined") {
+        if (typeof newMode.id === "undefined") {
+          currMode = newMode.mode;
+        } else {
+          const sentId = newMode.id.split("-");
+          if (sentId.length === 2) {
+            if (book === parseInt(sentId[0]) && para === parseInt(sentId[1])) {
+              currMode = newMode.mode;
+            }
+          }
+        }
+      }
+
+      setDisplayMode(currMode);
+
+      switch (currMode) {
+        case "edit":
+          if (typeof display === "undefined") {
+            setWbwMode("block");
+          }
+          if (typeof fields === "undefined") {
+            setFieldDisplay({
+              meaning: true,
+              factors: false,
+              factorMeaning: false,
+              case: false,
+            });
+          }
+          break;
+        case "wbw":
+          if (typeof display === "undefined") {
+            setWbwMode("block");
+          }
+          if (typeof fields === "undefined") {
+            setFieldDisplay({
+              meaning: true,
+              factors: true,
+              factorMeaning: true,
+              case: true,
+            });
+          }
+          break;
+      }
+    }, [newMode, mode, book, para, display, fields]);
+
+    // ============ Render Logic ============
+
+    const wordSplit = useCallback(
+      (id: number, hyphen = "-") => {
+        let factors = wordData[id]?.factors?.value;
+        if (typeof factors !== "string") return;
+
+        let sFm = wordData[id]?.factorMeaning?.value;
+        if (typeof sFm === "undefined" || sFm === null) {
+          sFm = new Array(factors.split("+").length).fill("").join("+");
+        }
+
+        if (wordData[id].case?.value?.split("#")[0] === ".un.") {
+          factors = `[+${factors}+]`;
+          sFm = `+${sFm}+`;
+        } else if (hyphen !== "") {
+          factors = factors.replaceAll("+", `+${hyphen}+`);
+          sFm = sFm.replaceAll("+", `+${hyphen}+`);
+        }
+
+        const fm = sFm.split("+");
+
+        const children: IWbw[] = factors.split("+").map((item, index) => {
+          return {
+            word: { value: item, status: 5 },
+            real: {
+              value: item
+                .replaceAll("-", "")
+                .replaceAll("[", "")
+                .replaceAll("]", ""),
+              status: 5,
+            },
+            meaning: { value: fm[index], status: 5 },
+            book: wordData[id].book,
+            para: wordData[id].para,
+            sn: [...wordData[id].sn, index],
+            confidence: 1,
+          };
+        });
+
+        console.log("children", children);
+        const newData: IWbw[] = [...wordData];
+        newData.splice(id + 1, 0, ...children);
+        console.log("new-data", newData);
+        update(newData);
+        saveWord(newData, wordData[id].sn[0]);
+      },
+      [wordData, update, saveWord]
+    );
+
+    // 优化13: 使用 memo 包装 WbwWord 渲染
+    const wbwRender = useCallback(
+      (
+        item: IWbw,
+        id: number,
+        options?: { studio?: IStudio; answer?: IWbw }
+      ) => {
+        console.log("test wbw word render", item.word.value);
+        return (
+          <WbwWord
+            data={item}
+            answer={options?.answer}
+            channelId={channelId}
+            key={id}
+            mode={displayMode}
+            display={wbwMode}
+            fields={fieldDisplay}
+            studio={studio}
+            readonly={readonly}
+            onChange={(e: IWbw, isPublish?: boolean, isPublic?: boolean) => {
+              setWordData((origin) => {
+                const newData = [...origin];
+                const snKey = createSnKey(e.sn);
+
+                // 更新当前单词
+                const index = newData.findIndex(
+                  (v) => createSnKey(v.sn) === snKey
+                );
+                if (index !== -1) {
+                  newData[index] = e;
+                }
+
+                // 如果是拆分后的单词,更新父单词的 factorMeaning
+                if (e.sn.length > 1) {
+                  const parentSn = e.sn.slice(0, e.sn.length - 1);
+                  const parentSnKey = createSnKey(parentSn);
+
+                  const factorMeaning = newData
+                    .filter(
+                      (value) =>
+                        value.sn.length === e.sn.length &&
+                        createSnKey(value.sn.slice(0, e.sn.length - 1)) ===
+                          parentSnKey &&
+                        value.real.value &&
+                        value.real.value.length > 0
+                    )
+                    .map((item) => item.meaning?.value)
+                    .join("+");
+
+                  const parentIndex = newData.findIndex(
+                    (v) => createSnKey(v.sn) === parentSnKey
+                  );
+                  if (parentIndex !== -1) {
+                    newData[parentIndex] = {
+                      ...newData[parentIndex],
+                      factorMeaning: {
+                        value: factorMeaning,
+                        status: 5,
+                      },
+                    };
+
+                    if (
+                      newData[parentIndex].meaning?.status !== WbwStatus.manual
+                    ) {
+                      newData[parentIndex].meaning = {
+                        value: factorMeaning.replaceAll("+", " "),
+                        status: 5,
+                      };
+                    }
+                  }
+                }
+
+                return newData;
+              });
+
+              // 延迟保存以批量处理
+              setTimeout(() => {
+                saveWord(wordData, e.sn[0]);
+              }, 100);
+
+              if (isPublish === true) {
+                wbwPublish([e], isPublic ?? false);
+              }
+            }}
+            onSplit={() => {
+              const hasChildren =
+                id < wordData.length - 1 &&
+                createSnKey(wordData[id + 1].sn).startsWith(
+                  createSnKey(wordData[id].sn) + ","
+                );
+
+              if (hasChildren) {
+                // 合并
+                console.log("合并");
+                const parentSnKey = createSnKey(wordData[id].sn);
+                const compactData = wordData.filter((value, index) => {
+                  if (index === id) return true;
+                  return !createSnKey(value.sn).startsWith(parentSnKey + ",");
+                });
+                update(compactData);
+                saveWord(compactData, wordData[id].sn[0]);
+              } else {
+                // 拆开
+                console.log("拆开");
+                wordSplit(id);
+              }
+            }}
+          />
+        );
+      },
+      [
+        channelId,
+        displayMode,
+        wbwMode,
+        fieldDisplay,
+        studio,
+        readonly,
+        wordData,
+        saveWord,
+        wbwPublish,
+        update,
+        wordSplit,
+      ]
+    );
+
+    // 优化14: 缓存处理后的渲染数据
+    const enrichedWordData = useMemo(() => {
+      return wordData.map((item) => {
+        // 检查是否有 AI 更新
+        const snKey = createSnKey(item.sn);
+        const newData = wbwData.find(
+          (v) => createSnKey(v.sn) === snKey || v.real.value === item.real.value
+        );
+
+        let enrichedItem = newData ?? item;
+
+        // 添加语法匹配
+        const spell = enrichedItem.real.value;
+        if (spell) {
+          const grammarId = grammarMap.get(spell);
+          if (grammarId) {
+            enrichedItem = { ...enrichedItem, grammarId };
+          }
+        }
+
+        return enrichedItem;
+      });
+    }, [wordData, wbwData, grammarMap]);
+
+    // 优化15: 缓存水平布局渲染
+    const horizontalLayout = useMemo(() => {
+      if (layoutDirection !== "h") return null;
+
+      const aa = courseAnswer ?? answer;
+      const answerMap = aa ? createSnIndexMap(aa) : null;
+
+      return enrichedWordData.map((item, id) => {
+        const currAnswer = answerMap?.get(createSnKey(item.sn));
+        return wbwRender(item, id, {
+          studio: studio,
+          answer: check ? currAnswer : undefined,
+        });
+      });
+    }, [
+      layoutDirection,
+      enrichedWordData,
+      courseAnswer,
+      answer,
+      check,
+      studio,
+      wbwRender,
+    ]);
+
+    // 优化16: 缓存树形布局数据
+    const treeData = useMemo(() => {
+      if (layoutDirection !== "v") return null;
+
+      return wordData
+        .filter((value) => value.sn.length === 1)
+        .map((item, id) => {
+          const children = wordData.filter(
+            (value) => value.sn.length === 2 && value.sn[0] === item.sn[0]
+          );
+
+          return {
+            title: wbwRender(item, id),
+            key: createSnKey(item.sn),
+            isLeaf: !item.factors?.value?.includes("+"),
+            children:
+              children.length > 0
+                ? children.map((childItem, childId) => ({
+                    title: wbwRender(childItem, childId),
+                    key: createSnKey(childItem.sn),
+                    isLeaf: true,
+                  }))
+                : undefined,
+          };
+        });
+    }, [layoutDirection, wordData, wbwRender]);
+
+    // 菜单项配置
+    const menuItems = useMemo(
+      () => [
+        {
+          key: "magic-dict-current",
+          label: intl.formatMessage({ id: "buttons.magic-dict" }),
+        },
+        {
+          key: "ai-magic-dict-current",
+          label: "ai-magic-dict",
+          disabled: !wbwModel,
+        },
+        {
+          key: "progress",
+          label: "显示/隐藏进度条",
+        },
+        {
+          key: "check",
+          label: "显示/隐藏错误提示",
+        },
+        {
+          key: "wbw-dict-publish-all",
+          label: "发布全部单词",
+        },
+        {
+          type: "divider" as const,
+        },
+        {
+          key: "copy-text",
+          label: intl.formatMessage({ id: "buttons.copy.pali.text" }),
+        },
+        {
+          key: "reset",
+          label: intl.formatMessage({ id: "buttons.reset.wbw" }),
+          danger: true,
+        },
+        {
+          type: "divider" as const,
+        },
+        {
+          key: "delete",
+          label: intl.formatMessage({ id: "buttons.delete.wbw.sentence" }),
+          danger: true,
+          disabled: true,
+        },
+      ],
+      [intl, wbwModel]
+    );
+
+    const handleMenuClick = useCallback(
+      ({ key }: { key: string }) => {
+        console.log(`Click on item ${key}`);
+        switch (key) {
+          case "magic-dict-current":
+            setLoading(true);
+            magicDictLookup();
+            break;
+          case "ai-magic-dict-current":
+            if (wbwModel) {
+              processStream(wbwModel.uid, wordData);
+            }
+            break;
+          case "wbw-dict-publish-all":
+            wbwPublish(wordData, user?.roles?.includes("basic") ? false : true);
+            break;
+          case "copy-text": {
+            const paliText = wordData
+              .filter((value) => value.type?.value !== ".ctl.")
+              .map((item) => item.word.value)
+              .join(" ");
+            navigator.clipboard.writeText(paliText).then(() => {
+              message.success("已经拷贝到剪贴板");
+            });
+            break;
+          }
+          case "progress":
+            setShowProgress((origin) => !origin);
+            break;
+          case "check":
+            loadAnswer();
+            setCheck(!check);
+            break;
+          case "reset":
+            modal.confirm({
+              title: "清除逐词解析数据",
+              icon: <ExclamationCircleOutlined />,
+              content: "清除这个句子的逐词解析数据,此操作不可恢复",
+              okText: "确认",
+              cancelText: "取消",
+              onOk: resetWbw,
+            });
+            break;
+          case "delete":
+            modal.confirm({
+              title: "清除逐词解析数据",
+              icon: <ExclamationCircleOutlined />,
+              content: "删除整句的逐词解析数据,此操作不可恢复",
+              okText: "确认",
+              cancelText: "取消",
+              onOk: deleteWbw,
+            });
+            break;
+        }
+      },
+      [
+        magicDictLookup,
+        wbwModel,
+        processStream,
+        wordData,
+        wbwPublish,
+        user,
+        loadAnswer,
+        check,
+        resetWbw,
+        deleteWbw,
+      ]
+    );
+
+    // ============ Render ============
+    return (
+      <div style={{ width: "100%" }}>
+        <div
+          style={{
+            display: showProgress ? "flex" : "none",
+            justifyContent: "space-between",
+          }}
+        >
+          <div className="progress" style={{ width: 400 }}>
+            <Progress percent={progress} size="small" />
+          </div>
+
+          <Space>
+            <Studio data={studio} hideAvatar />
+            <TimeShow updatedAt={updatedAt.toString()} />
+          </Space>
+        </div>
+
+        {error && <Alert message={error} />}
+
+        {isProcessing && (
+          <div>
+            <Progress
+              percent={Math.round((wbwData.length * 100) / wordData.length)}
+              size="small"
+            />
+          </div>
+        )}
+
+        <div className={`layout-${layoutDirection}`}>
+          <Dropdown
+            menu={{
+              items: menuItems,
+              onClick: handleMenuClick,
+            }}
+            placement="bottomLeft"
+          >
+            <Button
+              loading={loading}
+              onClick={(e) => e.preventDefault()}
+              icon={<MoreOutlined />}
+              size="small"
+              type="text"
+              style={{ backgroundColor: "lightblue", opacity: 0.3 }}
+            />
+          </Dropdown>
+
+          {layoutDirection === "h" ? (
+            horizontalLayout
+          ) : (
+            <Tree
+              selectable={true}
+              blockNode
+              treeData={treeData || []}
+              loadData={({ key }) =>
+                new Promise<void>((resolve) => {
+                  const index = wordData.findIndex(
+                    (item) => createSnKey(item.sn) === key
+                  );
+                  if (index !== -1) {
+                    wordSplit(index, "");
+                  }
+                  resolve();
+                })
+              }
+            />
+          )}
+        </div>
+      </div>
+    );
+  }
+);
+
+WbwSentCtl.displayName = "WbwSentCtl";
+
+export default WbwSentCtl;

+ 132 - 0
dashboard-v6/src/components/wbw/utils.ts

@@ -132,3 +132,135 @@ export const errorClass = (
 export const relationWordId = (word: IWbw) => {
 export const relationWordId = (word: IWbw) => {
   return `${word.book}-${word.para}-` + word.sn.join("-");
   return `${word.book}-${word.para}-` + word.sn.join("-");
 };
 };
+
+// ============ 优化工具函数 ============
+
+// 优化1: 使用 Map 缓存 sn 索引,提升查找性能从 O(n) 到 O(1)
+export const createSnIndexMap = (data: IWbw[]): Map<string, IWbw> => {
+  const map = new Map<string, IWbw>();
+  data.forEach((item) => {
+    map.set(item.sn.join(), item);
+  });
+  return map;
+};
+
+// 优化2: 缓存字符串拼接结果
+export const createSnKey = (sn: number[]): string => sn.join();
+
+// 优化3: 提取 paraMark 为纯函数,便于 memoization
+export const paraMark = (wbwData: IWbw[]): IWbw[] => {
+  if (!wbwData || wbwData.length === 0) return wbwData;
+
+  let start = false;
+  let bookCode = "";
+  let count = 0;
+  let bookCodeStack: string[] = [];
+
+  // 使用浅拷贝而非深拷贝
+  const result = [...wbwData];
+
+  result.forEach((value: IWbw, index: number) => {
+    if (value.word.value === "(") {
+      start = true;
+      bookCode = "";
+      bookCodeStack = [];
+      return;
+    }
+    if (start) {
+      if (!isNaN(Number(value.word.value.replaceAll("-", "")))) {
+        if (bookCode === "" && bookCodeStack.length > 0) {
+          bookCode = bookCodeStack[0];
+        }
+        const dot = bookCode.lastIndexOf(".");
+        let bookName = "";
+        if (dot === -1) {
+          bookName = bookCode;
+        } else {
+          bookName = bookCode.substring(0, dot + 1);
+        }
+        bookName = bookName.substring(0, 64).toLowerCase();
+        if (!bookCodeStack.includes(bookName)) {
+          bookCodeStack.push(bookName);
+        }
+        if (bookName !== "") {
+          result[index] = { ...result[index], bookName };
+          count++;
+        }
+      } else if (value.word.value === ";") {
+        bookCode = "";
+        return;
+      } else if (value.word.value === ")") {
+        start = false;
+        return;
+      }
+      bookCode += value.word.value;
+    }
+  });
+
+  if (count > 0) {
+    console.debug("para mark", count);
+  }
+  return result;
+};
+// 优化4: 提取进度计算为纯函数
+export const getWbwProgress = (data: IWbw[], answer?: IWbw[]): number => {
+  const allWord = data.filter(
+    (value) =>
+      value.real.value &&
+      value.real.value?.length > 0 &&
+      value.type?.value !== ".ctl."
+  );
+
+  if (allWord.length === 0) return 0;
+
+  let final: IWbw[];
+  if (answer) {
+    // 使用 Map 优化查找
+    const answerMap = createSnIndexMap(answer);
+
+    final = allWord.filter((value: IWbw) => {
+      const snKey = createSnKey(value.sn);
+      const currAnswer = answerMap.get(snKey);
+
+      if (!currAnswer) return false;
+
+      const checks = [
+        ["meaning", currAnswer.meaning?.value, value.meaning?.value],
+        ["factors", currAnswer.factors?.value, value.factors?.value],
+        [
+          "factorMeaning",
+          currAnswer.factorMeaning?.value,
+          value.factorMeaning?.value,
+        ],
+        ["case", currAnswer.case?.value, value.case?.value],
+        ["parent", currAnswer.parent?.value, value.parent?.value],
+      ];
+
+      return checks.every(([value, answerVal, valueVal]) => {
+        //TODO remove value
+        console.debug("checks", value);
+        if (!answerVal) return true;
+        return valueVal && valueVal.trim().length > 0;
+      });
+    });
+  } else {
+    final = allWord.filter(
+      (value) =>
+        value.meaning?.value &&
+        value.factors?.value &&
+        value.factorMeaning?.value &&
+        value.case?.value
+    );
+  }
+
+  const finalLen = final.reduce(
+    (sum, v) => sum + (v.real.value?.length || 0),
+    0
+  );
+  const allLen = allWord.reduce(
+    (sum, v) => sum + (v.real.value?.length || 0),
+    0
+  );
+
+  return allLen > 0 ? Math.round((finalLen * 100) / allLen) : 0;
+};

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

@@ -1,11 +1,25 @@
 import type { CSSProperties } from "react";
 import type { CSSProperties } from "react";
 import { ClockCircleOutlined } from "@ant-design/icons";
 import { ClockCircleOutlined } from "@ant-design/icons";
 import type { RecentItem as RecentItemType } from "../../../api/workspace";
 import type { RecentItem as RecentItemType } from "../../../api/workspace";
+import type { ArticleType } from "../../../api/Corpus";
 
 
-const typeColor: Record<string, string> = {
-  tipitaka: "#b5854a",
+const typeColor: Record<ArticleType, string> = {
+  chapter: "#b5854a",
   article: "#4a7fb5",
   article: "#4a7fb5",
+  anthology: "#4ab58a",
+  series: "#4ab58a",
+  para: "#4ab58a",
+  sent: "#4ab58a",
+  sim: "#4ab58a",
+  page: "#4ab58a",
+  textbook: "#4ab58a",
+  term: "#4ab58a",
   task: "#4ab58a",
   task: "#4ab58a",
+  "cs-para": "#4ab58a",
+  "sent-original": "#4ab58a",
+  "sent-commentary": "#4ab58a",
+  "sent-nissaya": "#4ab58a",
+  "sent-translation": "#4ab58a",
 };
 };
 
 
 type RecentItemProps = RecentItemType & {
 type RecentItemProps = RecentItemType & {
@@ -24,7 +38,11 @@ export default function RecentItem({
 
 
   return (
   return (
     <>
     <>
-      <div style={styles.row} className="workspace-recent-item" onClick={onClick}>
+      <div
+        style={styles.row}
+        className="workspace-recent-item"
+        onClick={onClick}
+      >
         <div style={styles.emoji}>{emoji}</div>
         <div style={styles.emoji}>{emoji}</div>
         <div style={styles.info}>
         <div style={styles.info}>
           <span style={styles.title}>{title}</span>
           <span style={styles.title}>{title}</span>

+ 142 - 0
dashboard-v6/src/hooks/useSentSim.ts

@@ -0,0 +1,142 @@
+import { message } from "antd";
+import { useEffect, useRef, useState } from "react";
+
+import {
+  fetchSentSim as defaultFetcher,
+  type ISimSent,
+  type ISentenceSimListResponse,
+  type ISentSimParams,
+} from "../api/sent-sim";
+
+type Fetcher = (params: ISentSimParams) => Promise<ISentenceSimListResponse>;
+
+interface IUseSentSimOptions {
+  book: number;
+  para: number;
+  wordStart: number;
+  wordEnd: number;
+  limit?: number;
+  channelsId?: string[];
+  /** 可替换为 mock 函数,默认使用真实 fetchSentSim */
+  fetcher?: Fetcher;
+}
+
+interface IUseSentSimResult {
+  sentData: ISimSent[];
+  remain: number;
+  initLoading: boolean;
+  loading: boolean;
+  toggleSim: (checked: boolean) => void;
+  loadMore: () => void;
+  reload: () => void;
+}
+
+export function useSentSim({
+  book,
+  para,
+  wordStart,
+  wordEnd,
+  limit = 5,
+  channelsId,
+  fetcher = defaultFetcher,
+}: IUseSentSimOptions): IUseSentSimResult {
+  const [sim, setSim] = useState(0);
+  const [offset, setOffset] = useState(0);
+  const [reloadKey, setReloadKey] = useState(0);
+  const [sentData, setSentData] = useState<ISimSent[]>([]);
+  const [remain, setRemain] = useState(0);
+  const [initLoading, setInitLoading] = useState(true);
+  const [loading, setLoading] = useState(false);
+
+  const isFirstFetch = useRef(true);
+  // 用 ref 持有 fetcher,避免函数引用变化触发 effect
+  const fetcherRef = useRef<Fetcher>(fetcher);
+  useEffect(() => {
+    fetcherRef.current = fetcher;
+  }, [fetcher]);
+
+  useEffect(() => {
+    let cancelled = false;
+
+    setLoading(true);
+
+    fetcherRef
+      .current({
+        book,
+        para,
+        wordStart,
+        wordEnd,
+        limit,
+        offset,
+        sim,
+        channelsId,
+      })
+      .then((json) => {
+        if (cancelled) return;
+
+        if (json.ok) {
+          setSentData((prev) => {
+            const next =
+              offset === 0 ? [...json.data.rows] : [...prev, ...json.data.rows];
+            setRemain(json.data.count - next.length);
+            return next;
+          });
+        } else {
+          message.error(json.message);
+        }
+      })
+      .finally(() => {
+        if (cancelled) return;
+        setLoading(false);
+        if (isFirstFetch.current) {
+          setInitLoading(false);
+          isFirstFetch.current = false;
+        }
+      });
+
+    return () => {
+      cancelled = true;
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [
+    book,
+    para,
+    wordStart,
+    wordEnd,
+    limit,
+    sim,
+    offset,
+    reloadKey,
+    JSON.stringify(channelsId),
+  ]);
+
+  const toggleSim = (checked: boolean) => {
+    setSim(checked ? 1 : 0);
+    setOffset(0);
+    setSentData([]);
+    isFirstFetch.current = true;
+    setInitLoading(true);
+  };
+
+  const loadMore = () => {
+    setOffset((prev) => prev + limit);
+  };
+
+  const reload = () => {
+    setSentData([]);
+    setOffset(0);
+    isFirstFetch.current = true;
+    setInitLoading(true);
+    setReloadKey((prev) => prev + 1);
+  };
+
+  return {
+    sentData,
+    remain,
+    initLoading,
+    loading,
+    toggleSim,
+    loadMore,
+    reload,
+  };
+}

+ 333 - 0
dashboard-v6/src/hooks/useWbwStreamProcessor.ts

@@ -0,0 +1,333 @@
+import { useState, useCallback } from "react";
+import { useIntl } from "react-intl";
+import type { IWbw } from "../types/wbw";
+import {
+  paliEndingGrammar,
+  paliEndingType,
+} from "../components/general/PaliEnding";
+
+// 类型定义
+export interface WbwElement<T> {
+  value: T;
+  status: number;
+}
+
+interface StreamController {
+  addData: (jsonlLine: string) => void;
+  complete: () => void;
+}
+
+interface ProcessWbwStreamOptions {
+  modelId: string;
+  data: IWbw[];
+  endingType: string[];
+  endingGrammar: string[];
+  onProgress?: (data: IWbw[], isComplete: boolean) => void;
+  onComplete?: (finalData: IWbw[]) => void;
+  onError?: (error: string) => void;
+}
+
+/**
+ * 处理JSONL流式输出的函数
+ */
+export const processWbwStream = async ({
+  modelId,
+  data,
+  endingType,
+  endingGrammar,
+  onProgress,
+  onComplete,
+  onError,
+}: ProcessWbwStreamOptions): Promise<{
+  success: boolean;
+  data?: IWbw[];
+  error?: string;
+}> => {
+  if (typeof import.meta.env.REACT_APP_OPENAI_PROXY === "undefined") {
+    console.error("no REACT_APP_OPENAI_PROXY");
+    const error = "API配置错误";
+    onError?.(error);
+    return { success: false, error };
+  }
+  const sys_prompt = `
+  你是一个巴利语专家。用户提供的jsonl 数据 是巴利文句子的全部单词
+请根据每个单词的拼写 real.value 填写如下字段
+巴利单词的词典原型:parent.value  
+单词的中文意思:meaning.value
+巴利单词的拆分:factors.value  
+语尾请加[]
+拆分后每个组成部分的中文意思factorMeaning.value
+请按照下表填写巴利语单词的类型 type.value
+\`\`\`csv
+${endingType.join("\n")}
+\`\`\`
+
+请按照下表填写巴利语单词的语法信息 grammar.value
+名词和形容词填写 性,数,格
+动词填写 人称,数,时态语气
+用 $ 作为分隔符
+\`\`\`csv
+${endingGrammar.join("\n")}
+\`\`\`
+ 直接输出JSONL格式数据
+`;
+
+  const jsonl = data.map((obj) => JSON.stringify(obj)).join("\n");
+  const prompt = `
+\`\`\`jsonl
+${jsonl}
+\`\`\`
+`;
+  console.debug("ai wbw system prompt", sys_prompt, prompt);
+  try {
+    const payload = {
+      model: "grok-3", // 或者从models数组中获取实际模型名称
+      messages: [
+        {
+          role: "system",
+          content: sys_prompt,
+        },
+        { role: "user", content: prompt },
+      ],
+      stream: true,
+      temperature: 0.3,
+      max_tokens: 4000,
+    };
+
+    const url = import.meta.env.REACT_APP_OPENAI_PROXY;
+    const requestData = {
+      model_id: modelId,
+      payload: payload,
+    };
+
+    console.info("api request", url, requestData);
+
+    const response = await fetch(url, {
+      method: "POST",
+      headers: {
+        "Content-Type": "application/json",
+        Authorization: `Bearer AIzaSyCzr8KqEdaQ3cRCxsFwSHh8c7kF3RZTZWw`,
+      },
+      body: JSON.stringify(requestData),
+    });
+
+    if (!response.ok) {
+      throw new Error(`HTTP error! status: ${response.status}`);
+    }
+
+    const reader = response.body?.getReader();
+    if (!reader) {
+      throw new Error("无法获取响应流");
+    }
+
+    const decoder = new TextDecoder();
+    let buffer = "";
+    let jsonlBuffer = ""; // 用于累积JSONL内容
+    const resultData: IWbw[] = [];
+
+    // 创建流控制器
+    const streamController: StreamController = {
+      addData: (jsonlLine: string) => {
+        try {
+          // 解析JSONL行
+          const parsedData = JSON.parse(jsonlLine.trim());
+          console.info("ai wbw stream ok", parsedData);
+          // 转换为IWbw格式
+          const wbwData: IWbw = {
+            book: parsedData.book || 0,
+            para: parsedData.para || 0,
+            sn: parsedData.sn || [],
+            word: parsedData.word || { value: "", status: 0 },
+            real: parsedData.real || { value: null, status: 0 },
+            meaning: parsedData.meaning,
+            type: parsedData.type,
+            grammar: parsedData.grammar,
+            style: parsedData.style,
+            case: parsedData.case,
+            parent: parsedData.parent,
+            parent2: parsedData.parent2,
+            grammar2: parsedData.grammar2,
+            factors: parsedData.factors,
+            factorMeaning: parsedData.factorMeaning,
+            relation: parsedData.relation,
+            note: parsedData.note,
+            bookMarkColor: parsedData.bookMarkColor,
+            bookMarkText: parsedData.bookMarkText,
+            locked: parsedData.locked || false,
+            confidence: parsedData.confidence || 0.5,
+            attachments: parsedData.attachments,
+            hasComment: parsedData.hasComment,
+            grammarId: parsedData.grammarId,
+            bookName: parsedData.bookName,
+            editor: parsedData.editor,
+            created_at: parsedData.created_at,
+            updated_at: parsedData.updated_at,
+          };
+
+          resultData.push(wbwData);
+
+          // 调用进度回调
+          onProgress?.(resultData, false);
+        } catch (e) {
+          console.warn("解析JSONL行失败:", e, "内容:", jsonlLine);
+        }
+      },
+      complete: () => {
+        onProgress?.(resultData, true);
+        onComplete?.(resultData);
+      },
+    };
+
+    try {
+      while (true) {
+        const { done, value } = await reader.read();
+
+        if (done) {
+          // 处理最后的缓冲内容
+          if (jsonlBuffer.trim()) {
+            const lines = jsonlBuffer.trim().split("\n");
+            for (const line of lines) {
+              if (line.trim()) {
+                streamController.addData(line);
+              }
+            }
+          }
+          streamController.complete();
+          return { success: true, data: resultData };
+        }
+
+        buffer += decoder.decode(value, { stream: true });
+        const lines = buffer.split("\n");
+        buffer = lines.pop() || "";
+
+        for (const line of lines) {
+          if (line.trim() === "") continue;
+
+          if (line.startsWith("data: ")) {
+            const data = line.slice(6);
+
+            if (data === "[DONE]") {
+              // 处理剩余的JSONL内容
+              if (jsonlBuffer.trim()) {
+                const jsonlLines = jsonlBuffer.trim().split("\n");
+                for (const jsonlLine of jsonlLines) {
+                  if (jsonlLine.trim()) {
+                    streamController.addData(jsonlLine);
+                  }
+                }
+              }
+              streamController.complete();
+              return { success: true, data: resultData };
+            }
+
+            try {
+              const parsed = JSON.parse(data);
+              const delta = parsed.choices?.[0]?.delta;
+
+              if (delta?.content) {
+                // 累积内容到JSONL缓冲区
+                jsonlBuffer += delta.content;
+
+                // 检查是否有完整的JSONL行
+                const jsonlLines = jsonlBuffer.split("\n");
+
+                // 保留最后一行(可能不完整)
+                jsonlBuffer = jsonlLines.pop() || "";
+
+                // 处理完整的行
+                for (const jsonlLine of jsonlLines) {
+                  if (jsonlLine.trim()) {
+                    streamController.addData(jsonlLine);
+                  }
+                }
+              }
+            } catch (e) {
+              console.warn("解析SSE数据失败:", e);
+            }
+          }
+        }
+      }
+    } catch (error) {
+      console.error("读取流数据失败:", error);
+      const errorMessage = "读取响应流失败";
+      onError?.(errorMessage);
+      return { success: false, error: errorMessage };
+    }
+  } catch (error) {
+    console.error("API调用失败:", error);
+    const errorMessage = "API调用失败,请重试";
+    onError?.(errorMessage);
+    return { success: false, error: errorMessage };
+  }
+};
+
+/**
+ * React Hook 用法示例
+ */
+export const useWbwStreamProcessor = () => {
+  const [isProcessing, setIsProcessing] = useState<boolean>(false);
+  const [wbwData, setWbwData] = useState<IWbw[]>([]);
+  const [error, setError] = useState<string>();
+
+  const intl = useIntl(); // 在Hook中使用
+
+  const endingType = paliEndingType.map((item) => {
+    return (
+      intl.formatMessage({ id: `dict.fields.type.${item}.label` }) +
+      `:.${item}.`
+    );
+  });
+
+  const endingGrammar = paliEndingGrammar.map((item) => {
+    return (
+      intl.formatMessage({ id: `dict.fields.type.${item}.label` }) +
+      `:.${item}.`
+    );
+  });
+
+  const processStream = useCallback(
+    async (modelId: string, data: IWbw[]) => {
+      setIsProcessing(true);
+      setWbwData([]);
+      setError(undefined);
+
+      const result = await processWbwStream({
+        modelId,
+        data,
+        endingType,
+        endingGrammar,
+        onProgress: (data) => {
+          console.info("onProgress", data);
+          setWbwData([...data]); // 创建新数组触发重渲染
+        },
+        onComplete: (finalData) => {
+          setWbwData(finalData);
+          setIsProcessing(false);
+        },
+        onError: (errorMessage) => {
+          setError(errorMessage);
+          setIsProcessing(false);
+        },
+      });
+
+      if (!result.success) {
+        setError(result.error || "处理失败");
+        setIsProcessing(false);
+      }
+
+      return result;
+    },
+    [endingGrammar, endingType]
+  );
+
+  return {
+    processStream,
+    isProcessing,
+    wbwData,
+    error,
+    clearData: useCallback(() => {
+      setWbwData([]);
+      setError(undefined);
+    }, []),
+  };
+};

+ 9 - 2
dashboard-v6/src/layouts/workspace/home.tsx → dashboard-v6/src/pages/workspace/home.tsx

@@ -5,18 +5,25 @@ import ModuleGrid from "../../components/workspace/home/ModuleGrid";
 import RecentList from "../../components/workspace/home/RecentList";
 import RecentList from "../../components/workspace/home/RecentList";
 import { fetchModules, fetchRecentItems } from "../../api/workspace";
 import { fetchModules, fetchRecentItems } from "../../api/workspace";
 import type { ModuleItem, RecentItem } from "../../api/workspace";
 import type { ModuleItem, RecentItem } from "../../api/workspace";
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
 
 
 export default function WorkspaceHome() {
 export default function WorkspaceHome() {
   const [modules, setModules] = useState<ModuleItem[]>([]);
   const [modules, setModules] = useState<ModuleItem[]>([]);
   const [recentItems, setRecentItems] = useState<RecentItem[]>([]);
   const [recentItems, setRecentItems] = useState<RecentItem[]>([]);
+  const user = useAppSelector(currentUser);
 
 
   useEffect(() => {
   useEffect(() => {
+    if (!user) {
+      return;
+    }
     fetchModules().then(setModules);
     fetchModules().then(setModules);
-    fetchRecentItems().then(setRecentItems);
-  }, []);
+    fetchRecentItems(user?.id).then(setRecentItems);
+  }, [user]);
 
 
   return (
   return (
     <div style={styles.page}>
     <div style={styles.page}>
+      <title>欢迎来到wikipali</title>
       <WorkspaceHero />
       <WorkspaceHero />
 
 
       <div style={styles.content}>
       <div style={styles.content}>

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

@@ -4,6 +4,9 @@ import type { ComponentType } from "react";
 const TestVideoPlayerTest = lazy(
 const TestVideoPlayerTest = lazy(
   () => import("../components/video/VideoPlayerTest")
   () => import("../components/video/VideoPlayerTest")
 );
 );
+const SentSimTest = lazy(
+  () => import("../components/sentence-editor/SentSimTest")
+);
 
 
 // 你可以继续添加更多测试组件
 // 你可以继续添加更多测试组件
 // const TestButtonDemo = lazy(() => import("../components/button/ButtonDemo"));
 // const TestButtonDemo = lazy(() => import("../components/button/ButtonDemo"));
@@ -22,6 +25,11 @@ export const testRoutes: TestRouteObject[] = [
     label: "视频播放器",
     label: "视频播放器",
     Component: TestVideoPlayerTest,
     Component: TestVideoPlayerTest,
   },
   },
+  {
+    path: "SimSentence",
+    label: "相似句",
+    Component: SentSimTest,
+  },
   // 示例:嵌套结构
   // 示例:嵌套结构
   // {
   // {
   //   path: "button",
   //   path: "button",

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

@@ -1,4 +1,5 @@
 import type { ArticleMode, ArticleType } from "../api/Corpus";
 import type { ArticleMode, ArticleType } from "../api/Corpus";
+export const SENTENCE_FIX_WIDTH = 800;
 
 
 export interface IArticleParam {
 export interface IArticleParam {
   type: ArticleType;
   type: ArticleType;