visuddhinanda 1 mese fa
parent
commit
7a7d4ac8b5
81 ha cambiato i file con 3520 aggiunte e 625 eliminazioni
  1. 1 1
      dashboard-v6/src/Router.tsx
  2. 1 1
      dashboard-v6/src/api/Article.ts
  3. 1 1
      dashboard-v6/src/api/Course.ts
  4. 1 1
      dashboard-v6/src/api/Suggestion.ts
  5. 1 1
      dashboard-v6/src/api/Term.ts
  6. 1 1
      dashboard-v6/src/api/Transfer.ts
  7. 54 1
      dashboard-v6/src/api/channel.ts
  8. 1 1
      dashboard-v6/src/api/notification.ts
  9. 1 1
      dashboard-v6/src/api/progress.ts
  10. 1 1
      dashboard-v6/src/api/sentence-history.ts
  11. 1 1
      dashboard-v6/src/api/sentence-pr.ts
  12. 1 1
      dashboard-v6/src/api/sentence.ts
  13. 67 34
      dashboard-v6/src/assets/icon/index.tsx
  14. 1 1
      dashboard-v6/src/components/anthology/AnthologyInfoEdit.tsx
  15. 1 1
      dashboard-v6/src/components/channel/Channel.tsx
  16. 1 1
      dashboard-v6/src/components/channel/ChannelAlert.tsx
  17. 1 1
      dashboard-v6/src/components/channel/ChannelCreate.tsx
  18. 1 1
      dashboard-v6/src/components/channel/ChannelList.tsx
  19. 1 1
      dashboard-v6/src/components/channel/ChannelListItem.tsx
  20. 271 378
      dashboard-v6/src/components/channel/ChannelMy.tsx
  21. 1 1
      dashboard-v6/src/components/channel/ChannelPicker.tsx
  22. 6 26
      dashboard-v6/src/components/channel/ChannelPickerTable.tsx
  23. 1 1
      dashboard-v6/src/components/channel/ChannelSelect.tsx
  24. 1 1
      dashboard-v6/src/components/channel/ChannelSelectWithToken.tsx
  25. 1 1
      dashboard-v6/src/components/channel/ChannelSentDiff.tsx
  26. 1 1
      dashboard-v6/src/components/channel/ChannelTable.tsx
  27. 1 1
      dashboard-v6/src/components/channel/ChannelTableModal.tsx
  28. 1 1
      dashboard-v6/src/components/channel/CopyToModal.tsx
  29. 1 1
      dashboard-v6/src/components/channel/CopyToStep.tsx
  30. 1 1
      dashboard-v6/src/components/channel/Edit.tsx
  31. 1 1
      dashboard-v6/src/components/channel/ProgressSvg.tsx
  32. 130 0
      dashboard-v6/src/components/channel/hooks/useChannelProgress.ts
  33. 2 2
      dashboard-v6/src/components/dict/Confidence.tsx
  34. 57 0
      dashboard-v6/src/components/discussion/AnchorCard.tsx
  35. 134 0
      dashboard-v6/src/components/discussion/Discussion.tsx
  36. 109 0
      dashboard-v6/src/components/discussion/DiscussionAnchor.tsx
  37. 66 0
      dashboard-v6/src/components/discussion/DiscussionBox.tsx
  38. 47 0
      dashboard-v6/src/components/discussion/DiscussionCount.tsx
  39. 232 0
      dashboard-v6/src/components/discussion/DiscussionCreate.tsx
  40. 98 0
      dashboard-v6/src/components/discussion/DiscussionDrawer.tsx
  41. 113 0
      dashboard-v6/src/components/discussion/DiscussionEdit.tsx
  42. 104 0
      dashboard-v6/src/components/discussion/DiscussionItem.tsx
  43. 63 0
      dashboard-v6/src/components/discussion/DiscussionList.tsx
  44. 343 0
      dashboard-v6/src/components/discussion/DiscussionListCard.tsx
  45. 5 0
      dashboard-v6/src/components/discussion/DiscussionListItem.tsx
  46. 369 0
      dashboard-v6/src/components/discussion/DiscussionShow.tsx
  47. 90 0
      dashboard-v6/src/components/discussion/DiscussionTopic.tsx
  48. 261 0
      dashboard-v6/src/components/discussion/DiscussionTopicChildren.tsx
  49. 118 0
      dashboard-v6/src/components/discussion/DiscussionTopicInfo.tsx
  50. 113 0
      dashboard-v6/src/components/discussion/InteractiveArea.tsx
  51. 104 0
      dashboard-v6/src/components/discussion/QaBox.tsx
  52. 88 0
      dashboard-v6/src/components/discussion/QaList.tsx
  53. 39 1
      dashboard-v6/src/components/discussion/utils.ts
  54. 126 0
      dashboard-v6/src/components/editor/Editor.tsx
  55. 2 0
      dashboard-v6/src/components/editor/index.ts
  56. 27 0
      dashboard-v6/src/components/editor/panels/ChannelPanel.tsx
  57. 22 0
      dashboard-v6/src/components/editor/panels/ChatPanel.tsx
  58. 14 0
      dashboard-v6/src/components/editor/panels/DictPanel.tsx
  59. 21 0
      dashboard-v6/src/components/editor/panels/GrammarBookPanel.tsx
  60. 20 0
      dashboard-v6/src/components/editor/panels/SearchPanel.tsx
  61. 21 0
      dashboard-v6/src/components/editor/panels/SuggestionPanel.tsx
  62. 18 9
      dashboard-v6/src/components/general/SplitLayout/SplitLayout.tsx
  63. 5 4
      dashboard-v6/src/components/navigation/MainMenu.tsx
  64. 1 1
      dashboard-v6/src/components/nissaya/NissayaAlignerModal.tsx
  65. 1 1
      dashboard-v6/src/components/sentence-editor/SentAdd.tsx
  66. 1 1
      dashboard-v6/src/components/sentence-editor/SentCanRead.tsx
  67. 1 1
      dashboard-v6/src/components/sentence-editor/SentEdit.tsx
  68. 1 1
      dashboard-v6/src/components/sentence-editor/SentTab.tsx
  69. 8 15
      dashboard-v6/src/components/sentence-editor/SuggestionBox.tsx
  70. 1 1
      dashboard-v6/src/components/sentence-editor/SuggestionList.tsx
  71. 1 1
      dashboard-v6/src/components/term/GrammarBook.tsx
  72. 1 1
      dashboard-v6/src/components/term/TermList.tsx
  73. 1 1
      dashboard-v6/src/components/term/TermTest.tsx
  74. 1 1
      dashboard-v6/src/components/tipitaka/ChapterInChannel.tsx
  75. 1 1
      dashboard-v6/src/components/transfer/TransferList.tsx
  76. 1 1
      dashboard-v6/src/components/wbw/WbwSentCtl.tsx
  77. 78 0
      dashboard-v6/src/features/editor/Article.tsx
  78. 33 113
      dashboard-v6/src/pages/workspace/article/show.tsx
  79. 1 1
      dashboard-v6/src/pages/workspace/channel/list.tsx
  80. 1 1
      dashboard-v6/src/pages/workspace/channel/setting.tsx
  81. 1 1
      dashboard-v6/src/pages/workspace/channel/show.tsx

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

@@ -1,7 +1,7 @@
 import { lazy } from "react";
 import { createBrowserRouter } from "react-router";
 import { RouterProvider } from "react-router/dom";
-import { channelLoader } from "./api/Channel";
+import { channelLoader } from "./api/channel";
 import { testRoutes } from "./routes/testRoutes";
 import { buildRouteConfig } from "./routes/buildRoutes";
 import { anthologyLoader, articleLoader } from "./api/Article";

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

@@ -1,7 +1,7 @@
 //src/api/article.ts
 
 import type { IStudio, IStudioApiResponse, IUser, TRole } from "./Auth";
-import type { IChannel } from "./Channel";
+import type { IChannel } from "./channel";
 import { delete_, get, post, put } from "../request";
 import type { ITocPathNode } from "./pali-text";
 import type { LoaderFunctionArgs } from "react-router";

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

@@ -1,5 +1,5 @@
 import type { IStudio, IUser } from "./Auth";
-import type { IChannel } from "./Channel";
+import type { IChannel } from "./channel";
 
 export interface ICourseListApiResponse {
   article: string;

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

@@ -1,5 +1,5 @@
 import type { IUser } from "./Auth";
-import type { IChannelApiData } from "./Channel";
+import type { IChannelApiData } from "./channel";
 
 export interface ISuggestionCount {
   suggestion?: number;

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

@@ -1,5 +1,5 @@
 import type { IStudio, IUser, TRole } from "./Auth";
-import type { IChannel } from "./Channel";
+import type { IChannel } from "./channel";
 import { get } from "../request";
 
 export interface ITerm {

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

@@ -1,5 +1,5 @@
 import type { IStudio, IUser } from "./Auth";
-import type { IChannel } from "./Channel";
+import type { IChannel } from "./channel";
 import type { TResType } from "./discussion";
 
 export type ITransferStatus = "transferred" | "accept" | "refuse" | "cancel";

+ 54 - 1
dashboard-v6/src/api/Channel.ts → dashboard-v6/src/api/channel.ts

@@ -1,6 +1,7 @@
+// src/api/channel.ts
 import type { LoaderFunctionArgs } from "react-router";
 import type { IStudio, TRole } from "./Auth";
-import { get } from "../request";
+import { get, post } from "../request";
 export interface IChannel {
   id: string;
   name: string;
@@ -98,6 +99,28 @@ export interface IResNumber {
   sim?: number;
 }
 
+export interface IProgressRequest {
+  sentence: string[];
+  owner?: string;
+}
+
+export interface IChannelItem {
+  id: number;
+  uid: string;
+  title: string;
+  summary: string;
+  type: TChannelType;
+  studio: IStudio;
+  shareType: string;
+  role?: string;
+  publicity: number;
+  final?: IFinal[];
+  progress: number;
+  createdAt: number;
+  content_created_at?: string;
+  content_updated_at?: string;
+}
+
 export async function channelLoader({ params }: LoaderFunctionArgs) {
   const channelId = params.channelId;
 
@@ -113,3 +136,33 @@ export async function channelLoader({ params }: LoaderFunctionArgs) {
 
   return res.data;
 }
+
+// ─── 获取章节内所有句子 ID ───────────────────────────────────────────────────
+
+/**
+ * 按 book + paragraph 拉取章节内所有句子坐标
+ */
+export const fetchSentencesInChapter = (
+  bookId: string,
+  para: string
+): Promise<ISentInChapterListResponse> =>
+  get<ISentInChapterListResponse>(
+    `/api/v2/sentences-in-chapter?book=${bookId}&para=${para}`
+  );
+
+// ─── 获取频道进度列表 ────────────────────────────────────────────────────────
+
+/**
+ * 批量查询句子在各频道的翻译进度
+ *
+ * @param sentences 句子 ID 列表,格式 `book-para-wordBegin-wordEnd`
+ * @param owner     "all" = 全部可见频道;"my" = 仅自己拥有的
+ */
+export const fetchChannelProgress = (
+  sentences: string[],
+  owner: "all" | "my" = "all"
+): Promise<IApiResponseChannelList> =>
+  post<IProgressRequest, IApiResponseChannelList>("/api/v2/channel-progress", {
+    sentence: sentences,
+    owner,
+  });

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

@@ -1,5 +1,5 @@
 import type { IUser } from "./Auth";
-import type { IChannel } from "./Channel";
+import type { IChannel } from "./channel";
 
 export interface INotificationPutResponse {
   ok: boolean;

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

@@ -1,5 +1,5 @@
 import type { IStudio } from "./Auth";
-import type { TChannelType } from "./Channel";
+import type { TChannelType } from "./channel";
 import type { TagNode } from "./Tag";
 
 export interface IApiResponseChannelListData {

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

@@ -1,6 +1,6 @@
 import { get } from "../request";
 import type { IStudio, IUser } from "./Auth";
-import type { IChannel } from "./Channel";
+import type { IChannel } from "./channel";
 
 // ─── 原有类型定义,保持不动 ─────────────────────────────────────────────────────
 

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

@@ -1,6 +1,6 @@
 import { get, post, put, delete_ } from "../request";
 import type { ISentence } from "./sentence";
-import type { IChannel } from "./Channel";
+import type { IChannel } from "./channel";
 import type { IUser } from "./Auth";
 
 // ─── 原有类型定义,保持不动 ─────────────────────────────────────────────────────

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

@@ -5,7 +5,7 @@ import { get, put } from "../request";
 import { message } from "antd";
 import { toISentence } from "../components/sentence/utils";
 import type { IStudio, IUser } from "./Auth";
-import type { IChannel } from "./Channel";
+import type { IChannel } from "./channel";
 import type { ISuggestionCount } from "./Suggestion";
 import type { ITocPathNode } from "./pali-text";
 

+ 67 - 34
dashboard-v6/src/assets/icon/index.tsx

@@ -31,13 +31,24 @@ const TermSvg = () => (
 );
 
 const SuggestionSvg = () => (
-  <svg width="1em" height="1em" fill="currentColor" viewBox="0 0 235 235">
-    <path d="M 159.635,82.894 C 148.467,72.784 133.286,65 117,65 100.715,65 85.656,72.783 74.487,82.894 62.844,93.434 56.431,106.871 56.431,123 c 0,27.668 15.389,43.912 20.445,48.839 1.361,1.326 4.104,4.766 7.428,9.145 -2.43,2.298 -3.934,5.412 -3.934,8.844 0,4.016 2.053,7.599 5.249,9.943 -0.298,0.974 -0.47,1.995 -0.47,3.055 0,3.836 2.096,7.228 5.292,9.293 -0.499,1.149 -0.781,2.602 -0.781,3.911 0,5.556 4.935,8.97 11,8.97 h 32.802 c 6.064,0 10.999,-3.414 10.999,-8.97 0,-1.309 -0.283,-2.66 -0.781,-3.808 3.196,-2.064 5.293,-5.508 5.293,-9.344 0,-1.06 -0.172,-2.107 -0.47,-3.081 3.196,-2.344 5.248,-5.94 5.248,-9.956 0,-3.36 -1.445,-6.419 -3.786,-8.702 3.46,-4.511 6.319,-8.068 7.721,-9.43 13.461,-13.067 20.005,-29.843 20.005,-48.709 0,-16.129 -6.413,-29.566 -18.056,-40.106 z M 117,80 l 43,55 h -29.273 v 75 h -28 V 135 H 74 Z" />
-    <path d="m 117,60.8955 c 6.56,0 16,-5.317 16,-11.877 V 19.688 c 0,-6.56 -9.44,-11.877 -16,-11.877 -6.56,0 -16,5.317 -16,11.877 v 29.3315 c 0,6.559 9.44,11.876 16,11.876 z" />
-    <path d="m 222.244,106 h -29.3305 c -6.56,0 -11.877,10.44 -11.877,17 0,6.56 5.317,17 11.877,17 h 29.3305 c 6.56,0 11.877,-10.44 11.877,-17 0,-6.56 -5.317,-17 -11.877,-17 z" />
-    <path d="M 41.2085,106 H 11.877 C 5.317,106 0,116.44 0,123 c 0,6.56 5.317,17 11.877,17 h 29.3315 c 6.56,0 11.877,-10.44 11.877,-17 0,-6.56 -5.317,-17 -11.877,-17 z" />
-    <path d="M 72.31325,55.08925 49.63875,33.98275 C 44.76375,29.59475 34.2525,32.991 29.8655,37.866 c -4.388,4.876 -6.99275,15.38625 -2.11675,19.77325 l 22.6745,21.1055 c 2.27,2.043 5.84469,1.464413 8.67569,1.464413 3.25,0 8.75206,-2.741663 11.09706,-5.347663 4.387,-4.875 6.99225,-15.38425 2.11725,-19.77225 z" />
-    <path d="m 204.2555,37.8645 c -4.39,-4.877 -13.898,-8.27125 -18.773,-3.88325 l -22.673,21.1075 c -4.876,4.389 -3.271,14.89825 1.117,19.77325 2.346,2.606 5.582,5.347663 8.832,5.347663 2.831,0 8.672,0.578587 10.941,-1.464413 l 22.673,-21.1065 c 4.876,-4.389 2.271,-14.89925 -2.117,-19.77425 z" />
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    width="1em"
+    height="1em"
+    fill="none"
+    viewBox="0 0 24 24"
+    stroke="currentColor"
+    stroke-width="1.8"
+    stroke-linecap="round"
+    stroke-linejoin="round"
+  >
+    <rect x="3" y="3" width="14" height="18" rx="2" />
+
+    <path d="M6 8h8" />
+    <path d="M6 12h6" />
+
+    <path d="M14 14l6-6 2 2-6 6-3 1z" />
+    <path d="M19 9l2 2" />
   </svg>
 );
 
@@ -921,33 +932,6 @@ const TaskSvg = () => (
   </svg>
 );
 
-const ChannelSvg = () => (
-  <svg
-    xmlns="http://www.w3.org/2000/svg"
-    width="1em"
-    height="1em"
-    fill="none"
-    viewBox="0 0 24 24"
-    stroke="currentColor"
-    stroke-width="1.8"
-    stroke-linecap="round"
-    stroke-linejoin="round"
-  >
-    <rect x="3" y="4" width="13" height="17" rx="2" />
-
-    <rect x="8" y="3" width="13" height="17" rx="2" />
-
-    <path d="M11 8h7" />
-    <path d="M11 12h7" />
-
-    <path d="M6 9l-2 2 2 2" />
-    <path d="M4 11h4" />
-
-    <path d="M6 15l-2 2 2 2" />
-    <path d="M4 17h4" />
-  </svg>
-);
-
 const DocumentSvg = () => (
   <svg
     xmlns="http://www.w3.org/2000/svg"
@@ -1023,6 +1007,51 @@ const RobotSvg = () => (
   </svg>
 );
 
+const ChannelSvg = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    width="1em"
+    height="1em"
+    fill="none"
+    viewBox="0 0 24 24"
+    stroke="currentColor"
+    stroke-width="1.8"
+    stroke-linecap="round"
+    stroke-linejoin="round"
+  >
+    <rect x="9" y="2.5" width="6" height="7" rx="1.5" />
+
+    <path d="M12 9.5v2" />
+    <path d="M12 11.5L5 14" />
+    <path d="M12 11.5L19 14" />
+
+    <rect x="2.5" y="14" width="7" height="7" rx="1.5" />
+    <path d="M4.5 17h3" />
+
+    <rect x="14.5" y="14" width="7" height="7" rx="1.5" />
+    <path d="M16.5 17h3" />
+  </svg>
+);
+
+const GrammarSvg = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    width="1em"
+    height="1em"
+    fill="none"
+    viewBox="0 0 24 24"
+    stroke="currentColor"
+    stroke-width="1.8"
+    stroke-linecap="round"
+    stroke-linejoin="round"
+  >
+    <rect x="3" y="3" width="18" height="18" rx="2" />
+
+    <path d="M9 8c-2 0-2 2-2 2v1c0 1-1 2-2 2 1 0 2 1 2 2v1c0 0 0 2 2 2" />
+    <path d="M15 8c2 0 2 2 2 2v1c0 1 1 2 2 2-1 0-2 1-2 2v1c0 0 0 2-2 2" />
+  </svg>
+);
+
 export const DictIcon = (props: Partial<CustomIconComponentProps>) => (
   <Icon component={DictSvg} {...props} />
 );
@@ -1215,3 +1244,7 @@ export const TipitakaIcon = (props: Partial<CustomIconComponentProps>) => (
 export const RobotIcon = (props: Partial<CustomIconComponentProps>) => (
   <Icon component={RobotSvg} {...props} />
 );
+
+export const GrammarIcon = (props: Partial<CustomIconComponentProps>) => (
+  <Icon component={GrammarSvg} {...props} />
+);

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

@@ -18,7 +18,7 @@ import LangSelect from "../general/LangSelect";
 import PublicitySelect from "../studio/PublicitySelect";
 import { useState } from "react";
 import type { DefaultOptionType } from "antd/lib/select";
-import type { IApiResponseChannelList } from "../../api/Channel";
+import type { IApiResponseChannelList } from "../../api/channel";
 import { useAppSelector } from "../../hooks";
 import { currentUser } from "../../reducers/current-user";
 

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

@@ -1,4 +1,4 @@
-import type { IChannel } from "../../../src/api/Channel";
+import type { IChannel } from "../../api/channel";
 
 const ChannelWidget = ({ name }: IChannel) => {
   return <span>{name}</span>;

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

@@ -5,7 +5,7 @@ import { Alert, Button } from "antd";
 import ChannelPicker from "./ChannelPicker";
 import store from "../../store";
 import { openPanel } from "../../reducers/right-panel";
-import type { IChannel } from "../../api/Channel";
+import type { IChannel } from "../../api/channel";
 
 interface IWidget {
   channels?: string | null;

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

@@ -7,7 +7,7 @@ import {
 import { message } from "antd";
 
 import { post } from "../../../src/request";
-import type { IApiResponseChannel } from "../../../src/api/Channel";
+import type { IApiResponseChannel } from "../../api/channel";
 import ChannelTypeSelect from "./ChannelTypeSelect";
 import LangSelect from "../../../src/components/general/LangSelect";
 import { useRef } from "react";

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

@@ -2,7 +2,7 @@ import { useIntl } from "react-intl";
 import { useState, useEffect } from "react";
 import { Card, List, message, Space, Tag } from "antd";
 
-import type { IChannelApiData } from "../../api/Channel";
+import type { IChannelApiData } from "../../api/channel";
 
 import { get } from "../../request";
 import ChannelListItem from "./ChannelListItem";

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

@@ -1,6 +1,6 @@
 import { Space } from "antd";
 
-import type { IChannelApiData } from "../../../src/api/Channel";
+import type { IChannelApiData } from "../../api/channel";
 import Studio from "../../../src/components/auth/Studio";
 import type { IStudio } from "../../api/Auth";
 

+ 271 - 378
dashboard-v6/src/components/channel/ChannelMy.tsx

@@ -1,6 +1,7 @@
-import { useCallback, useEffect, useState } from "react";
+// src/components/channel/ChannelMy.tsx
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
 import { useIntl } from "react-intl";
-import type { Key } from "antd/es/table/interface";
 import {
   Badge,
   Button,
@@ -14,6 +15,7 @@ import {
   Tooltip,
   Tree,
 } from "antd";
+import type { Key } from "antd/es/table/interface";
 import {
   GlobalOutlined,
   EditOutlined,
@@ -23,28 +25,21 @@ import {
   InfoCircleOutlined,
 } from "@ant-design/icons";
 
-import { get, post } from "../../request";
-import type {
-  IApiResponseChannelList,
-  IChannel,
-  ISentInChapterListResponse,
-} from "../../api/Channel";
-import type { IItem, IProgressRequest } from "./ChannelPickerTable";
 import { LockFillIcon, LockIcon } from "../../assets/icon";
 import StudioName from "../auth/Studio";
 import ProgressSvg from "./ProgressSvg";
-
 import CopyToModal from "./CopyToModal";
-
 import { ChannelInfoModal } from "./ChannelInfo";
-
 import type { ArticleType } from "../../api/Article";
-import { getSentIdInArticle } from "./utils";
 import TokenModal from "../token/TokenModal";
 import NissayaAlignerModal from "../nissaya/NissayaAlignerModal";
+import { useChannelProgress } from "./hooks/useChannelProgress";
+import type { IChannel, IChannelItem } from "../../api/channel";
 
 const { Search } = Input;
 
+// ─── 类型 ────────────────────────────────────────────────────────────────────
+
 interface IToken {
   channelId?: string;
   articleId?: string;
@@ -54,7 +49,7 @@ interface IToken {
 interface ChannelTreeNode {
   key: string;
   title: string | React.ReactNode;
-  channel: IItem;
+  channel: IChannelItem;
   icon?: React.ReactNode;
   children?: ChannelTreeNode[];
 }
@@ -66,6 +61,55 @@ interface IWidget {
   style?: React.CSSProperties;
   onSelect?: (selected: IChannel[]) => void;
 }
+
+// ─── 纯函数:对频道列表排序 / 过滤,不依赖任何 state ────────────────────────
+
+function buildTreeData(
+  channelList: IChannelItem[],
+  selectedRowKeys: React.Key[],
+  owner: string,
+  search?: string
+): ChannelTreeNode[] {
+  let ordered: IChannelItem[];
+
+  if (owner === "my") {
+    ordered = channelList.filter((v) => v.role === "owner");
+  } else {
+    const selectedSet = new Set(selectedRowKeys.map(String));
+
+    const selected = channelList.filter((v) => selectedSet.has(v.uid));
+    const seen = new Set(selected.map((v) => v.uid));
+
+    const progressing = channelList.filter(
+      (v) => v.progress > 0 && !seen.has(v.uid)
+    );
+    progressing.forEach((v) => seen.add(v.uid));
+
+    const mine = channelList.filter(
+      (v) => v.role === "owner" && !seen.has(v.uid)
+    );
+    mine.forEach((v) => seen.add(v.uid));
+
+    const others = channelList.filter(
+      (v) => !seen.has(v.uid) && v.role !== "member"
+    );
+
+    ordered = [...selected, ...progressing, ...mine, ...others];
+  }
+
+  if (search) {
+    ordered = ordered.filter((v) => v.title.includes(search));
+  }
+
+  return ordered.map((item) => ({
+    key: item.uid,
+    title: item.title,
+    channel: item,
+  }));
+}
+
+// ─── 组件 ────────────────────────────────────────────────────────────────────
+
 const ChannelMy = ({
   type,
   articleId,
@@ -74,176 +118,232 @@ const ChannelMy = ({
   onSelect,
 }: IWidget) => {
   const intl = useIntl();
+  console.debug("ChannelMy render");
+  // ── 远程数据(hook 负责全部异步逻辑)──────────────────────────────────────
+  const { channels, sentencesId, sentenceCount, loading, refresh } =
+    useChannelProgress(type, articleId);
+
+  // ── 局部 UI 状态 ──────────────────────────────────────────────────────────
   const [selectedRowKeys, setSelectedRowKeys] =
     useState<React.Key[]>(selectedKeys);
-  const [treeData, setTreeData] = useState<ChannelTreeNode[]>();
   const [dirty, setDirty] = useState(false);
-  const [channels, setChannels] = useState<IItem[]>([]);
   const [owner, setOwner] = useState("all");
   const [search, setSearch] = useState<string>();
-  const [loading, setLoading] = useState(true);
+
+  // modal 状态
   const [copyChannel, setCopyChannel] = useState<IChannel>();
   const [nissayaOpen, setNissayaOpen] = useState(false);
-  const [copyOpen, setCopyOpen] = useState<boolean>(false);
-  const [infoOpen, setInfoOpen] = useState<boolean>(false);
-  const [statistic, setStatistic] = useState<IItem>();
-  const [sentenceCount, setSentenceCount] = useState<number>(0);
-  const [sentencesId, setSentencesId] = useState<string[]>();
-  const [token, SetToken] = useState<IToken>();
+  const [copyOpen, setCopyOpen] = useState(false);
+  const [infoOpen, setInfoOpen] = useState(false);
+  const [statistic, setStatistic] = useState<IChannelItem>();
+  const [token, setToken] = useState<IToken>();
   const [tokenOpen, setTokenOpen] = useState(false);
 
-  console.debug("ChannelMy render", type, articleId);
+  // ── selectedKeys prop 同步:用 JSON 序列化做稳定比较,避免数组引用变化触发循环 ──
+  // 父组件每次 render 传入新数组字面量(如 selectedKeys={[]})时,
+  // 若直接放进 useEffect 依赖,引用每次都不同,会无限触发。
+  const selectedKeysKey = JSON.stringify(selectedKeys);
+  const selectedKeysRef = useRef(selectedKeys);
+  useEffect(() => {
+    selectedKeysRef.current = selectedKeys;
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [selectedKeysKey]);
 
-  //TODO remove useEffect
-  const loadChannel = useCallback(async (sentences: string[]) => {
-    setSentenceCount(sentences.length);
-    setLoading(true);
+  useEffect(() => {
+    setSelectedRowKeys(selectedKeysRef.current);
+  }, [selectedKeysKey]);
 
-    try {
-      const res = await post<IProgressRequest, IApiResponseChannelList>(
-        "/api/v2/channel-progress",
-        {
-          sentence: sentences,
-          owner: "all",
-        }
-      );
+  // ── 派生数据:useMemo 替代 useEffect + setState,不产生额外渲染轮次 ──────
+  const treeData = useMemo(
+    () => buildTreeData(channels, selectedRowKeys, owner, search),
+    [channels, selectedRowKeys, owner, search]
+  );
 
-      const items: IItem[] = res.data.rows
-        .filter((v) => !v.name.startsWith("_sys"))
-        .map((item, id) => {
-          const date = new Date(item.created_at);
-
-          let all = 0;
-          let finished = 0;
-
-          item.final?.forEach((v) => {
-            all += v[0];
-            if (v[1]) finished += v[0];
-          });
-
-          return {
-            id,
-            uid: item.uid,
-            title: item.name,
-            summary: item.summary,
-            studio: item.studio,
-            shareType: "my",
-            role: item.role,
-            type: item.type,
-            publicity: item.status,
-            createdAt: date.getTime(),
-            final: item.final,
-            progress: all ? finished / all : 0,
-            content_created_at: item.content_created_at,
-            content_updated_at: item.content_updated_at,
-          };
-        });
-
-      setChannels(items);
-    } finally {
-      setLoading(false);
-    }
-  }, []);
+  // ── 回调 ──────────────────────────────────────────────────────────────────
 
-  const load = useCallback(async () => {
-    let sentList: string[] = [];
+  const handleConfirm = useCallback(() => {
+    if (!onSelect) return;
+    setDirty(false);
+    const selected: IChannel[] = selectedRowKeys.map((item) => ({
+      id: item.toString(),
+      name: treeData.find((v) => v.channel.uid === item)?.channel.title ?? "",
+    }));
+    onSelect(selected);
+  }, [onSelect, selectedRowKeys, treeData]);
 
-    if (type === "chapter") {
-      const id = articleId?.split("-");
-      if (id?.length === 2) {
-        const url = `/api/v2/sentences-in-chapter?book=${id[0]}&para=${id[1]}`;
+  const handleCancel = useCallback(() => {
+    setSelectedRowKeys(selectedKeysRef.current);
+    setDirty(false);
+  }, []);
 
-        try {
-          const res = await get<ISentInChapterListResponse>(url);
-          if (!res?.ok) return;
+  const handleCheck = useCallback(
+    (checked: Key[] | { checked: Key[]; halfChecked: Key[] }) => {
+      setDirty(true);
+      if (!Array.isArray(checked)) return;
 
-          sentList = res.data.rows.map(
-            (item) =>
-              `${item.book}-${item.paragraph}-${item.word_begin}-${item.word_end}`
-          );
-        } catch (err) {
-          console.error(err);
-          return;
+      if (checked.length > selectedRowKeys.length) {
+        const add = checked.filter(
+          (v) => !selectedRowKeys.includes(v.toString())
+        );
+        if (add.length > 0) {
+          setSelectedRowKeys((prev) => [...prev, add[0]]);
         }
+      } else {
+        setSelectedRowKeys(selectedRowKeys.filter((v) => checked.includes(v)));
       }
-    } else {
-      sentList = getSentIdInArticle();
-    }
+    },
+    [selectedRowKeys]
+  );
 
-    setSentencesId(sentList);
-    await loadChannel(sentList);
-  }, [type, articleId, loadChannel]);
+  const handleNodeClick = useCallback(
+    (node: ChannelTreeNode) => {
+      setDirty(false);
+      onSelect?.([{ id: node.key, name: node.channel.title }]);
+    },
+    [onSelect]
+  );
 
-  useEffect(() => {
-    load();
-  }, [load]);
+  // ── titleRender ───────────────────────────────────────────────────────────
 
-  useEffect(() => {
-    setSelectedRowKeys(selectedKeys);
-  }, [selectedKeys]);
+  const titleRender = useCallback(
+    (node: ChannelTreeNode) => {
+      let pIcon = <></>;
+      switch (node.channel.publicity) {
+        case 5:
+          pIcon = (
+            <Tooltip title={"私有不可公开"}>
+              <LockFillIcon />
+            </Tooltip>
+          );
+          break;
+        case 10:
+          pIcon = (
+            <Tooltip title={"私有"}>
+              <LockIcon />
+            </Tooltip>
+          );
+          break;
+        case 30:
+          pIcon = (
+            <Tooltip title={"公开"}>
+              <GlobalOutlined />
+            </Tooltip>
+          );
+          break;
+      }
 
-  useEffect(() => {
-    sortChannels(channels);
-  }, [channels, selectedRowKeys, owner]);
+      const badgeIndex = selectedRowKeys.findIndex(
+        (v) => v === node.channel.uid
+      );
 
-  interface IChannelFilter {
-    key?: string;
-    owner?: string;
-    selectedRowKeys?: React.Key[];
-  }
+      return (
+        <div
+          style={{
+            display: "flex",
+            justifyContent: "space-between",
+            width: "100%",
+          }}
+        >
+          {/* 左侧:频道信息 + 进度 */}
+          <div
+            style={{ width: "100%", borderRadius: 5, padding: "0 5px" }}
+            onClick={() => handleNodeClick(node)}
+          >
+            <div key="info" style={{ overflowX: "clip", display: "flex" }}>
+              <Space>
+                {pIcon}
+                {node.channel.role !== "member" ? <EditOutlined /> : undefined}
+              </Space>
+              <Button type="link">
+                <Space>
+                  <StudioName data={node.channel.studio} hideName />
+                  <>{node.channel.title}</>
+                  <Tag>
+                    {intl.formatMessage({
+                      id: `channel.type.${node.channel.type}.label`,
+                    })}
+                  </Tag>
+                </Space>
+              </Button>
+            </div>
+            <div key="progress">
+              <ProgressSvg data={node.channel.final} width={200} />
+            </div>
+          </div>
 
-  const sortChannels = (channelList: IItem[], filter?: IChannelFilter) => {
-    const mOwner = filter?.owner ?? owner;
-    if (mOwner === "my") {
-      //我自己的
-      const myChannel = channelList.filter((value) => value.role === "owner");
-      const data = myChannel.map((item) => {
-        return { key: item.uid, title: item.title, channel: item };
-      });
-      setTreeData(data);
-    } else {
-      //当前被选择的
-      const selectedChannel: IItem[] = [];
-      const mSelectedRowKeys = filter?.selectedRowKeys ?? selectedRowKeys;
-      mSelectedRowKeys.forEach((channelId) => {
-        const channel = channelList.find((value) => value.uid === channelId);
-        if (channel) {
-          selectedChannel.push(channel);
-        }
-      });
-      let show = mSelectedRowKeys;
-      //有进度的
-      const progressing = channelList.filter(
-        (value) => value.progress > 0 && !show.includes(value.uid)
-      );
-      show = [...show, ...progressing.map((item) => item.uid)];
-      //我自己的
-      const myChannel = channelList.filter(
-        (value) => value.role === "owner" && !show.includes(value.uid)
-      );
-      show = [...show, ...myChannel.map((item) => item.uid)];
-      //其他的
-      const others = channelList.filter(
-        (value) => !show.includes(value.uid) && value.role !== "member"
+          {/* 右侧:更多菜单 */}
+          <Badge count={dirty ? badgeIndex + 1 : 0}>
+            <div>
+              <Dropdown
+                trigger={["click"]}
+                menu={{
+                  items: [
+                    {
+                      key: "copy-to",
+                      label: intl.formatMessage({ id: "buttons.copy.to" }),
+                      icon: <CopyOutlined />,
+                    },
+                    {
+                      key: "import-nissaya",
+                      label: intl.formatMessage({ id: "buttons.import" }),
+                      icon: <CopyOutlined />,
+                    },
+                    {
+                      key: "statistic",
+                      label: intl.formatMessage({ id: "buttons.statistic" }),
+                      icon: <InfoCircleOutlined />,
+                    },
+                    {
+                      key: "token",
+                      label: intl.formatMessage({
+                        id: "buttons.access-token.get",
+                      }),
+                      icon: <InfoCircleOutlined />,
+                    },
+                  ],
+                  onClick: (e) => {
+                    const ch: IChannel = {
+                      id: node.channel.uid,
+                      name: node.channel.title,
+                      type: node.channel.type,
+                    };
+                    switch (e.key) {
+                      case "copy-to":
+                        setCopyChannel(ch);
+                        setCopyOpen(true);
+                        break;
+                      case "import-nissaya":
+                        setCopyChannel(ch);
+                        setNissayaOpen(true);
+                        break;
+                      case "statistic":
+                        setStatistic(node.channel);
+                        setInfoOpen(true);
+                        break;
+                      case "token":
+                        setToken({
+                          channelId: node.channel.uid,
+                          type: type as ArticleType,
+                          articleId,
+                        });
+                        setTokenOpen(true);
+                        break;
+                    }
+                  },
+                }}
+                placement="bottomRight"
+              >
+                <Button type="link" size="small" icon={<MoreOutlined />} />
+              </Dropdown>
+            </div>
+          </Badge>
+        </div>
       );
-      let channelData = [
-        ...selectedChannel,
-        ...progressing,
-        ...myChannel,
-        ...others,
-      ];
-
-      const key = filter?.key ?? search;
-      if (key) {
-        channelData = channelData.filter((value) => value.title.includes(key));
-      }
+    },
+    [dirty, selectedRowKeys, handleNodeClick, intl, type, articleId]
+  );
 
-      const data = channelData.map((item) => {
-        return { key: item.uid, title: item.title, channel: item };
-      });
-      setTreeData(data);
-    }
-  };
+  // ── 渲染 ──────────────────────────────────────────────────────────────────
 
   return (
     <div style={style}>
@@ -252,6 +352,7 @@ const ChannelMy = ({
         open={tokenOpen}
         onClose={() => setTokenOpen(false)}
       />
+
       <Card
         size="small"
         title={
@@ -259,16 +360,14 @@ const ChannelMy = ({
             <Search
               placeholder="版本名称"
               onSearch={(value) => {
-                console.debug(value);
                 setSearch(value);
-                sortChannels(channels, { key: value });
               }}
               style={{ width: 120 }}
             />
             <Select
               defaultValue="all"
               style={{ width: 80 }}
-              bordered={false}
+              variant="borderless"
               options={[
                 {
                   value: "all",
@@ -279,57 +378,33 @@ const ChannelMy = ({
                   label: intl.formatMessage({ id: "buttons.channel.my" }),
                 },
               ]}
-              onSelect={(value: string) => {
-                setOwner(value);
-              }}
+              onSelect={(value: string) => setOwner(value)}
             />
           </Space>
         }
         extra={
-          <Space size={"small"}>
+          <Space size="small">
             <Button
               size="small"
               type="link"
               disabled={!dirty}
-              onClick={() => {
-                if (typeof onSelect !== "undefined") {
-                  setDirty(false);
-                  const selected: IChannel[] = selectedRowKeys.map((item) => {
-                    return {
-                      id: item.toString(),
-                      name:
-                        treeData?.find((value) => value.channel.uid === item)
-                          ?.channel.title ?? "",
-                    };
-                  });
-                  onSelect(selected);
-                }
-              }}
+              onClick={handleConfirm}
             >
-              {intl.formatMessage({
-                id: "buttons.ok",
-              })}
+              {intl.formatMessage({ id: "buttons.ok" })}
             </Button>
             <Button
               size="small"
               type="link"
               disabled={!dirty}
-              onClick={() => {
-                setSelectedRowKeys(selectedKeys);
-                setDirty(false);
-              }}
+              onClick={handleCancel}
             >
-              {intl.formatMessage({
-                id: "buttons.cancel",
-              })}
+              {intl.formatMessage({ id: "buttons.cancel" })}
             </Button>
             <Button
               type="link"
               size="small"
               icon={<ReloadOutlined />}
-              onClick={() => {
-                load();
-              }}
+              onClick={refresh}
             />
           </Space>
         }
@@ -344,196 +419,13 @@ const ChannelMy = ({
             checkable
             treeData={treeData}
             blockNode
-            onCheck={(
-              checked: Key[] | { checked: Key[]; halfChecked: Key[] }
-            ) => {
-              setDirty(true);
-              if (Array.isArray(checked)) {
-                if (checked.length > selectedRowKeys.length) {
-                  const add = checked.filter(
-                    (value) => !selectedRowKeys.includes(value.toString())
-                  );
-                  if (add.length > 0) {
-                    setSelectedRowKeys([...selectedRowKeys, add[0]]);
-                  }
-                } else {
-                  setSelectedRowKeys(
-                    selectedRowKeys.filter((value) => checked.includes(value))
-                  );
-                }
-              }
-            }}
+            onCheck={handleCheck}
             onSelect={() => {}}
-            titleRender={(node: ChannelTreeNode) => {
-              let pIcon = <></>;
-              switch (node.channel.publicity) {
-                case 5:
-                  pIcon = (
-                    <Tooltip title={"私有不可公开"}>
-                      <LockFillIcon />
-                    </Tooltip>
-                  );
-                  break;
-                case 10:
-                  pIcon = (
-                    <Tooltip title={"私有"}>
-                      <LockIcon />
-                    </Tooltip>
-                  );
-                  break;
-                case 30:
-                  pIcon = (
-                    <Tooltip title={"公开"}>
-                      <GlobalOutlined />
-                    </Tooltip>
-                  );
-                  break;
-              }
-              const badge = selectedRowKeys.findIndex(
-                (value) => value === node.channel.uid
-              );
-              return (
-                <div
-                  style={{
-                    display: "flex",
-                    justifyContent: "space-between",
-                    width: "100%",
-                  }}
-                >
-                  <div
-                    style={{
-                      width: "100%",
-                      borderRadius: 5,
-                      padding: "0 5px",
-                    }}
-                    onClick={() => {
-                      console.log(node);
-                      if (channels) {
-                        sortChannels(channels);
-                      }
-                      setDirty(false);
-                      if (typeof onSelect !== "undefined") {
-                        onSelect([
-                          {
-                            id: node.key,
-                            name: node.channel.title,
-                          },
-                        ]);
-                      }
-                    }}
-                  >
-                    <div
-                      key="info"
-                      style={{ overflowX: "clip", display: "flex" }}
-                    >
-                      <Space>
-                        {pIcon}
-                        {node.channel.role !== "member" ? (
-                          <EditOutlined />
-                        ) : undefined}
-                      </Space>
-                      <Button type="link">
-                        <Space>
-                          <StudioName data={node.channel.studio} hideName />
-                          <>{node.channel.title}</>
-                          <Tag>
-                            {intl.formatMessage({
-                              id: `channel.type.${node.channel.type}.label`,
-                            })}
-                          </Tag>
-                        </Space>
-                      </Button>
-                    </div>
-                    <div key="progress">
-                      <ProgressSvg data={node.channel.final} width={200} />
-                    </div>
-                  </div>
-                  <Badge count={dirty ? badge + 1 : 0}>
-                    <div>
-                      <Dropdown
-                        trigger={["click"]}
-                        menu={{
-                          items: [
-                            {
-                              key: "copy-to",
-                              label: intl.formatMessage({
-                                id: "buttons.copy.to",
-                              }),
-                              icon: <CopyOutlined />,
-                            },
-                            {
-                              key: "import-nissaya",
-                              label: intl.formatMessage({
-                                id: "buttons.import",
-                              }),
-                              icon: <CopyOutlined />,
-                            },
-                            {
-                              key: "statistic",
-                              label: intl.formatMessage({
-                                id: "buttons.statistic",
-                              }),
-                              icon: <InfoCircleOutlined />,
-                            },
-                            {
-                              key: "token",
-                              label: intl.formatMessage({
-                                id: "buttons.access-token.get",
-                              }),
-                              icon: <InfoCircleOutlined />,
-                            },
-                          ],
-                          onClick: (e) => {
-                            switch (e.key) {
-                              case "copy-to":
-                                setCopyChannel({
-                                  id: node.channel.uid,
-                                  name: node.channel.title,
-                                  type: node.channel.type,
-                                });
-                                setCopyOpen(true);
-                                break;
-                              case "import-nissaya":
-                                setCopyChannel({
-                                  id: node.channel.uid,
-                                  name: node.channel.title,
-                                  type: node.channel.type,
-                                });
-                                setNissayaOpen(true);
-                                break;
-                              case "statistic":
-                                setInfoOpen(true);
-                                setStatistic(node.channel);
-                                break;
-                              case "token":
-                                SetToken({
-                                  channelId: node.channel.uid,
-                                  type: type as ArticleType,
-                                  articleId: articleId,
-                                });
-                                setTokenOpen(true);
-                                break;
-                              default:
-                                break;
-                            }
-                          },
-                        }}
-                        placement="bottomRight"
-                      >
-                        <Button
-                          type="link"
-                          size="small"
-                          icon={<MoreOutlined />}
-                        ></Button>
-                      </Dropdown>
-                    </div>
-                  </Badge>
-                </div>
-              );
-            }}
+            titleRender={titleRender}
           />
         )}
       </Card>
+
       <CopyToModal
         sentencesId={sentencesId}
         channel={copyChannel}
@@ -555,4 +447,5 @@ const ChannelMy = ({
     </div>
   );
 };
+
 export default ChannelMy;

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

@@ -5,7 +5,7 @@ import ChannelPickerTable from "./ChannelPickerTable";
 
 import { useIntl } from "react-intl";
 import type { ArticleType } from "../../api/Article";
-import type { IChannel } from "../../api/Channel";
+import type { IChannel } from "../../api/channel";
 
 interface IWidget {
   trigger?: React.ReactNode;

+ 6 - 26
dashboard-v6/src/components/channel/ChannelPickerTable.tsx

@@ -14,16 +14,16 @@ import {
 import type {
   IApiResponseChannelList,
   IChannel,
-  IFinal,
-  TChannelType,
-} from "../../../src/api/Channel";
+  IChannelItem,
+  IProgressRequest,
+} from "../../api/channel";
 import { post } from "../../../src/request";
 import { LockIcon } from "../../../src/assets/icon";
 
 import ProgressSvg from "./ProgressSvg";
 
 import CopyToModal from "./CopyToModal";
-import type { IStudio } from "../../../src/api/Auth";
+
 import type { ArticleType } from "../../api/Article";
 import Studio from "../../../src/components/auth/Studio";
 
@@ -33,26 +33,6 @@ interface IParams {
   owner?: string;
 }
 
-export interface IProgressRequest {
-  sentence: string[];
-  owner?: string;
-}
-export interface IItem {
-  id: number;
-  uid: string;
-  title: string;
-  summary: string;
-  type: TChannelType;
-  studio: IStudio;
-  shareType: string;
-  role?: string;
-  publicity: number;
-  final?: IFinal[];
-  progress: number;
-  createdAt: number;
-  content_created_at?: string;
-  content_updated_at?: string;
-}
 interface IWidget {
   type?: ArticleType | "editable";
   articleId?: string;
@@ -118,7 +98,7 @@ const ChannelPickerTableWidget = ({
           }
         />
       ) : undefined}
-      <ProList<IItem, IParams>
+      <ProList<IChannelItem, IParams>
         actionRef={ref}
         rowSelection={
           showCheckBox
@@ -211,7 +191,7 @@ const ChannelPickerTableWidget = ({
             }
           );
           console.debug("progress data", res.data.rows);
-          const items: IItem[] = res.data.rows
+          const items: IChannelItem[] = res.data.rows
             .filter((value) => value.name.substring(0, 4) !== "_Sys")
             .map((item, id) => {
               const date = new Date(item.created_at);

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

@@ -4,7 +4,7 @@ import { useAppSelector } from "../../../src/hooks";
 import { currentUser } from "../../../src/reducers/current-user";
 
 import { get } from "../../../src/request";
-import type { IApiResponseChannelList } from "../../../src/api/Channel";
+import type { IApiResponseChannelList } from "../../api/channel";
 
 import { useIntl } from "react-intl";
 import type { IStudio } from "../../../src/api/Auth";

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

@@ -7,7 +7,7 @@ import {
   WarningTwoTone,
 } from "@ant-design/icons";
 
-import type { IChannel, TChannelType } from "../../../src/api/Channel";
+import type { IChannel, TChannelType } from "../../api/channel";
 import { post } from "../../../src/request";
 import ChannelTableModal from "./ChannelTableModal";
 

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

@@ -14,7 +14,7 @@ import type {
 
 import store from "../../store";
 import { accept } from "../../reducers/accept-pr";
-import type { IChannel } from "../../api/Channel";
+import type { IChannel } from "../../api/channel";
 import { toISentence } from "../sentence/utils";
 
 const { Text } = Typography;

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

@@ -16,7 +16,7 @@ import type {
   IApiResponseChannelList,
   IChannel,
   TChannelType,
-} from "../../../src/api/Channel";
+} from "../../api/channel";
 import { PublicityValueEnum } from "../studio/table";
 import type { IDeleteResponse } from "../../../src/api/Article";
 import { useEffect, useRef, useState } from "react";

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

@@ -5,7 +5,7 @@ import ChannelTable, { type IChapter } from "./ChannelTable";
 import { useAppSelector } from "../../../src/hooks";
 import { currentUser as _currentUser } from "../../reducers/current-user";
 
-import type { IChannel, TChannelType } from "../../api/Channel";
+import type { IChannel, TChannelType } from "../../api/channel";
 import { useIntl } from "react-intl";
 import type { ArticleType } from "../../api/Article";
 

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

@@ -2,7 +2,7 @@ import { useEffect, useState, type JSX } from "react";
 import { Modal } from "antd";
 
 import CopyToStep from "./CopyToStep";
-import type { IChannel } from "../../api/Channel";
+import type { IChannel } from "../../api/channel";
 
 interface IWidget {
   trigger?: JSX.Element | string;

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

@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
 import ChannelPickerTable from "./ChannelPickerTable";
 import ChannelSentDiff from "./ChannelSentDiff";
 import CopyToResult from "./CopyToResult";
-import type { IChannel } from "../../api/Channel";
+import type { IChannel } from "../../api/channel";
 import type { ArticleType } from "../../api/Article";
 
 interface IWidget {

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

@@ -9,7 +9,7 @@ import { Alert, message } from "antd";
 import type {
   IApiResponseChannel,
   IApiResponseChannelData,
-} from "../../api/Channel";
+} from "../../api/channel";
 import { get, put } from "../../request";
 import ChannelTypeSelect from "./ChannelTypeSelect";
 import LangSelect from "../general/LangSelect";

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

@@ -1,4 +1,4 @@
-import type { IFinal } from "../../../src/api/Channel";
+import type { IFinal } from "../../api/channel";
 
 interface IWidget {
   data?: IFinal[];

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

@@ -0,0 +1,130 @@
+// src/components/channel/hooks/useChannelProgress.ts
+// ─────────────────────────────────────────────────────────────────────────────
+/**
+ * useChannelProgress
+ *
+ * 根据文章类型与 articleId 计算出句子 ID 列表,再批量请求各频道的翻译进度。
+ *
+ * @param type      文章类型,"chapter" 时从服务器拉取句子列表,否则从 DOM 解析
+ * @param articleId 文章/章节 ID
+ *
+ * @returns
+ *   - channels      格式化后的频道列表
+ *   - sentencesId   当前文章的句子 ID 列表
+ *   - sentenceCount 句子数量
+ *   - loading       请求进行中
+ *   - refresh       手动重新请求
+ */
+// ─────────────────────────────────────────────────────────────────────────────
+
+import { useState, useEffect, useCallback } from "react";
+import {
+  fetchChannelProgress,
+  fetchSentencesInChapter,
+  type IChannelItem,
+} from "../../../api/channel";
+import type { ArticleType } from "../../../api/Article";
+import { getSentIdInArticle } from "../utils";
+
+interface IUseChannelProgressReturn {
+  channels: IChannelItem[];
+  sentencesId: string[];
+  sentenceCount: number;
+  loading: boolean;
+  refresh: () => void;
+}
+
+export const useChannelProgress = (
+  type?: ArticleType | "editable",
+  articleId?: string
+): IUseChannelProgressReturn => {
+  const [channels, setChannels] = useState<IChannelItem[]>([]);
+  const [sentencesId, setSentencesId] = useState<string[]>([]);
+  const [sentenceCount, setSentenceCount] = useState(0);
+  const [loading, setLoading] = useState(true);
+  const [tick, setTick] = useState(0);
+
+  // refresh 引用永远稳定,不会触发任何 re-render
+  const refresh = useCallback(() => setTick((t) => t + 1), []);
+
+  useEffect(() => {
+    let active = true;
+
+    const load = async () => {
+      setLoading(true);
+
+      try {
+        // ── 1. 解析出句子 ID 列表 ────────────────────────────────────────────
+        let sentList: string[] = [];
+
+        if (type === "chapter") {
+          const id = articleId?.split("-");
+          if (id?.length === 2) {
+            const res = await fetchSentencesInChapter(id[0], id[1]);
+            if (!active) return;
+            if (!res?.ok) return;
+
+            sentList = res.data.rows.map(
+              (item) =>
+                `${item.book}-${item.paragraph}-${item.word_begin}-${item.word_end}`
+            );
+          }
+        } else {
+          sentList = getSentIdInArticle();
+        }
+
+        if (!active) return;
+        setSentencesId(sentList);
+        setSentenceCount(sentList.length);
+
+        // ── 2. 拉取频道进度 ──────────────────────────────────────────────────
+        const res = await fetchChannelProgress(sentList, "all");
+        if (!active) return;
+
+        const items: IChannelItem[] = res.data.rows
+          .filter((v) => !v.name.startsWith("_sys"))
+          .map((item, id) => {
+            let all = 0;
+            let finished = 0;
+            item.final?.forEach((v) => {
+              all += v[0];
+              if (v[1]) finished += v[0];
+            });
+
+            return {
+              id,
+              uid: item.uid,
+              title: item.name,
+              summary: item.summary,
+              studio: item.studio,
+              shareType: "my",
+              role: item.role,
+              type: item.type,
+              publicity: item.status,
+              createdAt: new Date(item.created_at).getTime(),
+              final: item.final,
+              progress: all ? finished / all : 0,
+              content_created_at: item.content_created_at,
+              content_updated_at: item.content_updated_at,
+            };
+          });
+
+        setChannels(items);
+      } catch (err) {
+        console.error("useChannelProgress fetch error", err);
+      } finally {
+        if (active) setLoading(false);
+      }
+    };
+
+    load();
+
+    return () => {
+      active = false;
+    };
+    // type / articleId 是基本类型(string | undefined),值相同时 React 不会重跑
+    // tick 由 refresh() 手动递增,是唯一的主动刷新入口
+  }, [type, articleId, tick]);
+
+  return { channels, sentencesId, sentenceCount, loading, refresh };
+};

+ 2 - 2
dashboard-v6/src/components/dict/Confidence.tsx

@@ -2,7 +2,7 @@ import { useIntl } from "react-intl";
 import { ProFormSlider } from "@ant-design/pro-components";
 import type { SliderMarks } from "antd/es/slider";
 
-const ConfidenceWidget = () => {
+const Confidence = () => {
   const intl = useIntl();
   const marks: SliderMarks = {
     0: intl.formatMessage({ id: "forms.fields.confidence.0.label" }),
@@ -21,4 +21,4 @@ const ConfidenceWidget = () => {
   );
 };
 
-export default ConfidenceWidget;
+export default Confidence;

+ 57 - 0
dashboard-v6/src/components/discussion/AnchorCard.tsx

@@ -0,0 +1,57 @@
+import { useIntl } from "react-intl";
+import { useState } from "react";
+import { Card, Space, Segmented } from "antd";
+
+import store from "../../store";
+import { modeChange } from "../../reducers/article-mode";
+import type { ArticleMode } from "../../api/Article";
+
+interface IWidgetArticleCard {
+  title?: React.ReactNode;
+  children?: React.ReactNode;
+  onModeChange?: (newMode: string) => void;
+}
+const AnchorCardWidget = ({
+  title,
+  children,
+  onModeChange,
+}: IWidgetArticleCard) => {
+  const intl = useIntl();
+  const [mode, setMode] = useState<string>("read");
+
+  const modeSwitch = (
+    <Segmented
+      size="middle"
+      options={[
+        {
+          label: intl.formatMessage({ id: "buttons.translate" }),
+          value: "edit",
+        },
+        {
+          label: intl.formatMessage({ id: "buttons.wbw" }),
+          value: "wbw",
+        },
+      ]}
+      value={mode}
+      onChange={(value) => {
+        const newMode = value.toString();
+        if (typeof onModeChange !== "undefined") {
+          if (mode === "read" || newMode === "read") {
+            onModeChange(newMode);
+          }
+        }
+        setMode(newMode);
+        //发布mode变更
+        store.dispatch(modeChange({ mode: newMode as ArticleMode }));
+      }}
+    />
+  );
+
+  return (
+    <Card size="small" title={title} extra={<Space>{modeSwitch}</Space>}>
+      {children}
+    </Card>
+  );
+};
+
+export default AnchorCardWidget;

+ 134 - 0
dashboard-v6/src/components/discussion/Discussion.tsx

@@ -0,0 +1,134 @@
+import { useEffect, useState } from "react";
+import { ArrowLeftOutlined } from "@ant-design/icons";
+
+import DiscussionTopic from "./DiscussionTopic";
+import DiscussionListCard from "./DiscussionListCard";
+
+import { countChange } from "../../reducers/discussion";
+import { Button, Space, Typography } from "antd";
+import store from "../../store";
+import type { IComment, TDiscussionType, TResType } from "../../api/discussion";
+
+const { Text } = Typography;
+
+export interface IAnswerCount {
+  id: string;
+  count: number;
+}
+
+interface IWidget {
+  resId?: string;
+  resType?: TResType;
+  showTopicId?: string;
+  focus?: string;
+  type?: TDiscussionType;
+  showStudent?: boolean;
+  onTopicReady?: (value: IComment) => void;
+}
+
+const DiscussionWidget = ({
+  resId,
+  resType,
+  showTopicId,
+  showStudent = false,
+  focus,
+  type = "discussion",
+  onTopicReady,
+}: IWidget) => {
+  const [childrenDrawer, setChildrenDrawer] = useState(false);
+  const [topicId, setTopicId] = useState<string>();
+  const [topic, setTopic] = useState<IComment>();
+  const [answerCount, setAnswerCount] = useState<IAnswerCount>();
+  const [topicTitle, setTopicTitle] = useState<string>();
+
+  useEffect(() => {
+    if (showTopicId) {
+      setChildrenDrawer(true);
+      setTopicId(showTopicId);
+    } else {
+      setChildrenDrawer(false);
+    }
+  }, [showTopicId]);
+
+  useEffect(() => {
+    setChildrenDrawer(false);
+  }, [resId]);
+
+  const showChildrenDrawer = (comment: IComment) => {
+    console.debug("discussion comment", comment);
+    setChildrenDrawer(true);
+    if (comment.id) {
+      setTopicId(comment.id);
+      setTopic(undefined);
+    } else {
+      setTopicId(undefined);
+      setTopic(comment);
+    }
+  };
+
+  return (
+    <>
+      {childrenDrawer ? (
+        <div>
+          <Space>
+            <Button
+              shape="circle"
+              icon={<ArrowLeftOutlined />}
+              onClick={() => setChildrenDrawer(false)}
+            />
+            <Text strong style={{ fontSize: 16 }}>
+              {topic ? topic.title : topicTitle}
+            </Text>
+          </Space>
+          <DiscussionTopic
+            resType={resType}
+            topicId={topicId}
+            topic={topic}
+            focus={focus}
+            hideTitle
+            onItemCountChange={(count: number, parent: string) => {
+              setAnswerCount({ id: parent, count: count });
+            }}
+            onTopicReady={(value: IComment) => {
+              setTopicTitle(value.title);
+              if (typeof onTopicReady !== "undefined") {
+                onTopicReady(value);
+              }
+            }}
+            onTopicDelete={() => {
+              setChildrenDrawer(false);
+            }}
+            onConvert={() => {
+              setChildrenDrawer(false);
+            }}
+          />
+        </div>
+      ) : (
+        <DiscussionListCard
+          resId={resId}
+          resType={resType}
+          type={type}
+          showStudent={showStudent}
+          onSelect={(
+            _e: React.MouseEvent<HTMLSpanElement, MouseEvent>,
+            comment: IComment
+          ) => showChildrenDrawer(comment)}
+          onReply={(comment: IComment) => showChildrenDrawer(comment)}
+          onReady={() => {}}
+          changedAnswerCount={answerCount}
+          onItemCountChange={(count: number) => {
+            store.dispatch(
+              countChange({
+                count: count,
+                resId: resId,
+                resType: resType,
+              })
+            );
+          }}
+        />
+      )}
+    </>
+  );
+};
+
+export default DiscussionWidget;

+ 109 - 0
dashboard-v6/src/components/discussion/DiscussionAnchor.tsx

@@ -0,0 +1,109 @@
+import { Skeleton } from "antd";
+import { useEffect, useState } from "react";
+import { get } from "../../request";
+import type { IArticleResponse } from "../../api/Article";
+import type { ICommentAnchorResponse } from "../../api/Comment";
+
+import AnchorCard from "./AnchorCard";
+
+import { Link } from "react-router";
+import type { TResType } from "../../api/discussion";
+import type { ISentenceData, ISentenceResponse } from "../../api/sentence";
+import MdView from "../general/MdView";
+
+export interface IAnchor {
+  type: TResType;
+  sentence?: ISentenceData;
+}
+
+interface IWidget {
+  resId?: string;
+  resType?: TResType;
+  topicId?: string;
+  onLoad?: Function;
+}
+const DiscussionAnchorWidget = ({
+  resId,
+  resType,
+  topicId,
+  onLoad,
+}: IWidget) => {
+  const [title, setTitle] = useState<React.ReactNode>();
+  const [content, setContent] = useState<string>();
+  const [loading, setLoading] = useState(false);
+
+  useEffect(() => {
+    if (typeof topicId === "string") {
+      const url = `/v2/discussion-anchor/${topicId}`;
+      console.info("api request", url);
+      get<ICommentAnchorResponse>(url).then((json) => {
+        console.debug("api response", json);
+        if (json.ok) {
+          setContent(json.data);
+        }
+      });
+    }
+  }, [topicId]);
+
+  useEffect(() => {
+    let url: string;
+    switch (resType) {
+      case "sentence":
+        url = `/v2/sentence/${resId}`;
+        console.info("api request", url);
+        setLoading(true);
+        get<ISentenceResponse>(url)
+          .then((json) => {
+            console.info("api response", json);
+            if (json.ok) {
+              const id = `${json.data.book}-${json.data.paragraph}-${json.data.word_start}-${json.data.word_end}`;
+              const channel = json.data.channel.id;
+              const url = `/v2/corpus-sent/${id}?mode=edit&channels=${channel}`;
+              console.log("url", url);
+              get<IArticleResponse>(url).then((json) => {
+                if (json.ok) {
+                  setContent(json.data.content);
+                }
+              });
+              if (typeof onLoad !== "undefined") {
+                onLoad({ type: resType, sentence: json.data });
+              }
+            }
+          })
+          .finally(() => setLoading(false));
+        break;
+      case "article":
+        url = `/v2/article/${resId}`;
+        console.info("url", url);
+        setLoading(true);
+
+        get<IArticleResponse>(url)
+          .then((json) => {
+            if (json.ok) {
+              setTitle(
+                <Link to={`/article/article/${resId}`}>{json.data.title}</Link>
+              );
+              setContent(json.data.content?.substring(0, 200));
+            }
+          })
+          .finally(() => setLoading(false));
+        break;
+      default:
+        break;
+    }
+  }, [resId, resType]);
+
+  return (
+    <AnchorCard title={title}>
+      {loading ? (
+        <Skeleton title={{ width: 200 }} paragraph={{ rows: 4 }} active />
+      ) : (
+        <div>
+          <MdView html={content} />
+        </div>
+      )}
+    </AnchorCard>
+  );
+};
+
+export default DiscussionAnchorWidget;

+ 66 - 0
dashboard-v6/src/components/discussion/DiscussionBox.tsx

@@ -0,0 +1,66 @@
+import { useEffect, useState } from "react";
+
+import { useAppSelector } from "../../hooks";
+import {
+  type IShowDiscussion,
+  message,
+  show,
+  showAnchor,
+} from "../../reducers/discussion";
+import { Button } from "antd";
+import store from "../../store";
+import Discussion from "./Discussion";
+import type { IComment } from "../../api/discussion";
+
+export interface IAnswerCount {
+  id: string;
+  count: number;
+}
+
+const DiscussionBoxWidget = () => {
+  const [topicId, setTopicId] = useState<string>();
+  const [currTopic, setCurrTopic] = useState<IComment>();
+
+  const discussionMessage = useAppSelector(message);
+
+  useEffect(() => {
+    if (discussionMessage) {
+      if (discussionMessage.topic) {
+        setTopicId(discussionMessage.topic);
+      }
+    }
+  }, [discussionMessage]);
+
+  return (
+    <>
+      <Button
+        type="link"
+        onClick={() => {
+          const anchorInfo: IShowDiscussion = {
+            type: "discussion",
+            resId: discussionMessage?.resId
+              ? discussionMessage?.resId
+              : currTopic?.resId,
+            resType: discussionMessage?.resType,
+          };
+          store.dispatch(show(anchorInfo));
+          store.dispatch(showAnchor(anchorInfo));
+        }}
+      >
+        显示译文
+      </Button>
+      <Discussion
+        resId={discussionMessage?.resId}
+        resType={discussionMessage?.resType}
+        focus={discussionMessage?.comment}
+        showStudent={discussionMessage?.withStudent}
+        showTopicId={topicId}
+        onTopicReady={(value: IComment) => {
+          setCurrTopic(value);
+        }}
+      />
+    </>
+  );
+};
+
+export default DiscussionBoxWidget;

+ 47 - 0
dashboard-v6/src/components/discussion/DiscussionCount.tsx

@@ -0,0 +1,47 @@
+import { useEffect } from "react";
+import { useAppSelector } from "../../hooks";
+import { discussions, tags } from "../../reducers/discussion-count";
+import { sentenceList } from "../../reducers/sentence";
+import type {
+  IDiscussionCountRequest,
+  IDiscussionCountResponse,
+} from "../../api/Comment";
+import { post } from "../../request";
+import store from "../../store";
+
+interface IWidget {
+  courseId?: string | null;
+}
+
+const DiscussionCount = ({ courseId }: IWidget) => {
+  const sentences = useAppSelector(sentenceList);
+
+  console.debug("sentences", sentences);
+
+  useEffect(() => {
+    const sentId: string[] = sentences
+      .filter((value) => typeof value.id != "undefined")
+      .map((item) => item.id);
+    if (sentId.length === 0) {
+      return;
+    }
+    const url = "/v2/discussion-count";
+    const data: IDiscussionCountRequest = {
+      course_id: courseId ?? undefined,
+      sentences: sentId.map((item) => item.split("-")),
+    };
+    console.info("discussion-count api request", url, data);
+    post<IDiscussionCountRequest, IDiscussionCountResponse>(url, data).then(
+      (json) => {
+        console.debug("discussion-count api response", json);
+        if (json.ok) {
+          store.dispatch(discussions(json.data.discussions));
+          store.dispatch(tags(json.data.tags));
+        }
+      }
+    );
+  }, [courseId, sentences]);
+  return <></>;
+};
+
+export default DiscussionCount;

+ 232 - 0
dashboard-v6/src/components/discussion/DiscussionCreate.tsx

@@ -0,0 +1,232 @@
+import { useIntl } from "react-intl";
+import { Form, message } from "antd";
+import {
+  ProForm,
+  type ProFormInstance,
+  ProFormText,
+  ProFormTextArea,
+} from "@ant-design/pro-components";
+
+import { post } from "../../request";
+import type { ICommentRequest, ICommentResponse } from "../../api/Comment";
+import { useAppSelector } from "../../hooks";
+import { currentUser as _currentUser } from "../../reducers/current-user";
+import { useEffect, useRef, useState } from "react";
+import MDEditor from "@uiw/react-md-editor";
+import type { IComment, TDiscussionType } from "../../api/discussion";
+import type { TContentType } from "../../api/Article";
+import { discussionCountUpgrade, toIComment } from "./utils";
+
+interface IWidget {
+  resId?: string;
+  resType?: string; //TODO change
+  parent?: string;
+  topicId?: string;
+  type?: TDiscussionType;
+  topic?: IComment;
+  contentType?: TContentType;
+  onCreated?: (value: IComment) => void;
+  onTopicCreated?: (value: IComment) => void;
+}
+const DiscussionCreateWidget = ({
+  resId,
+  resType,
+  contentType = "html",
+  parent,
+  topicId,
+  topic,
+  type = "discussion",
+  onCreated,
+  onTopicCreated,
+}: IWidget) => {
+  const intl = useIntl();
+  const formRef = useRef<ProFormInstance | undefined>(undefined);
+  const _currUser = useAppSelector(_currentUser);
+  const [currParent, setCurrParent] = useState(parent);
+
+  useEffect(() => {
+    setCurrParent(parent);
+  }, [parent]);
+
+  if (typeof _currUser === "undefined") {
+    return <></>;
+  } else {
+    return (
+      <div>
+        <div>{_currUser?.nickName}:</div>
+        <div>
+          <ProForm<IComment>
+            formRef={formRef}
+            autoFocusFirstInput={false}
+            onFinish={async (values) => {
+              //新建
+              console.log("create", resId, resType, currParent, topic);
+              console.log("value", values);
+              let newParent: string | undefined;
+              if (typeof currParent === "undefined") {
+                if (typeof topic !== "undefined" && topic.tplId) {
+                  /**
+                   * 在模版下跟帖
+                   * 先建立模版topic,再建立跟帖
+                   */
+                  const topicData: ICommentRequest = {
+                    res_id: resId,
+                    res_type: resType,
+                    title: topic.title,
+                    tpl_id: topic.tplId,
+                    content: topic.content,
+                    content_type: "markdown",
+                    type: topic.type,
+                  };
+                  const url = `/v2/discussion`;
+                  console.log("create topic api request", url, topicData);
+                  const newTopic = await post<
+                    ICommentRequest,
+                    ICommentResponse
+                  >(url, topicData);
+                  if (newTopic.ok) {
+                    discussionCountUpgrade(resId);
+                    setCurrParent(newTopic.data.id);
+                    newParent = newTopic.data.id;
+                    if (typeof onTopicCreated !== "undefined") {
+                      onTopicCreated(toIComment(newTopic.data));
+                    }
+                  } else {
+                    console.error("no parent id");
+                    return;
+                  }
+                }
+              }
+              const url = `/v2/discussion`;
+              const data: ICommentRequest = {
+                res_id: resId,
+                res_type: resType,
+                parent: newParent ? newParent : currParent,
+                topicId: topicId,
+                title: values.title,
+                content: values.content,
+                content_type: contentType,
+                type: topic ? topic.type : type,
+              };
+              console.info("api request", url, data);
+              post<ICommentRequest, ICommentResponse>(url, data)
+                .then((json) => {
+                  console.debug("new discussion api response", json);
+                  if (json.ok) {
+                    formRef.current?.resetFields();
+                    discussionCountUpgrade(resId);
+                    if (typeof onCreated !== "undefined") {
+                      onCreated(toIComment(json.data));
+                    }
+                  } else {
+                    message.error(json.message);
+                  }
+                })
+                .catch((e) => {
+                  message.error(e.message);
+                });
+            }}
+            params={{}}
+          >
+            <ProForm.Group>
+              <ProFormText
+                name="title"
+                width={"lg"}
+                hidden={
+                  typeof currParent !== "undefined" ||
+                  typeof topic?.tplId !== "undefined"
+                }
+                label={intl.formatMessage({ id: "forms.fields.title.label" })}
+                tooltip="最长为 24 位"
+                placeholder={intl.formatMessage({
+                  id: "forms.message.question.required",
+                })}
+                rules={[
+                  { required: currParent || topic?.tplId ? false : true },
+                ]}
+              />
+            </ProForm.Group>
+            <ProForm.Group>
+              {contentType === "text" ? (
+                <ProFormTextArea
+                  name="content"
+                  label={intl.formatMessage({
+                    id: "forms.fields.content.label",
+                  })}
+                  placeholder={intl.formatMessage({
+                    id: "forms.fields.content.placeholder",
+                  })}
+                />
+              ) : contentType === "html" ? (
+                <Form.Item
+                  name="content"
+                  label={intl.formatMessage({
+                    id: "forms.fields.content.label",
+                  })}
+                  tooltip="可以直接粘贴屏幕截图"
+                >
+                  <ReactQuill
+                    theme="snow"
+                    style={{ height: 180 }}
+                    modules={{
+                      toolbar: [
+                        ["bold", "italic", "underline", "strike"],
+                        ["blockquote", "code-block"],
+                        [{ header: 1 }, { header: 2 }],
+                        [{ list: "ordered" }, { list: "bullet" }],
+                        [{ indent: "-1" }, { indent: "+1" }],
+                        [{ size: ["small", false, "large", "huge"] }],
+                        [{ header: [1, 2, 3, 4, 5, 6, false] }],
+                        ["link", "image", "video"],
+                        [{ color: [] }, { background: [] }],
+                        [{ font: [] }],
+                        [{ align: [] }],
+                      ],
+                    }}
+                  />
+                </Form.Item>
+              ) : contentType === "markdown" ? (
+                <Form.Item
+                  name="content"
+                  rules={[
+                    {
+                      required:
+                        typeof currParent !== "undefined" ||
+                        typeof topic?.tplId !== "undefined",
+                    },
+                  ]}
+                  label={
+                    typeof currParent === "undefined" &&
+                    typeof topic?.tplId === "undefined"
+                      ? intl.formatMessage({
+                          id: "forms.message.question.description.option",
+                        })
+                      : intl.formatMessage({
+                          id: "forms.fields.replay.label",
+                        })
+                  }
+                >
+                  <MDEditor
+                    textareaProps={{
+                      placeholder:
+                        "问题的详细描述" +
+                        (typeof currParent !== "undefined" &&
+                        typeof topic?.tplId !== "undefined"
+                          ? ""
+                          : "(选填)"),
+                      maxLength: 10000,
+                    }}
+                  />
+                </Form.Item>
+              ) : (
+                <></>
+              )}
+            </ProForm.Group>
+          </ProForm>
+        </div>
+      </div>
+    );
+  }
+};
+
+export default DiscussionCreateWidget;

+ 98 - 0
dashboard-v6/src/components/discussion/DiscussionDrawer.tsx

@@ -0,0 +1,98 @@
+import { useEffect, useState, type JSX } from "react";
+import { Button, Drawer, Space } from "antd";
+import { FullscreenOutlined, FullscreenExitOutlined } from "@ant-design/icons";
+
+import { Link } from "react-router";
+import { useIntl } from "react-intl";
+import Discussion from "./Discussion";
+import type { TResType } from "../../api/discussion";
+
+export interface IAnswerCount {
+  id: string;
+  count: number;
+}
+interface IWidget {
+  trigger?: JSX.Element;
+  open?: boolean;
+  onClose?: () => void;
+  resId?: string;
+  resType?: TResType;
+}
+const DiscussionDrawerWidget = ({
+  trigger,
+  open,
+  onClose,
+  resId,
+  resType,
+}: IWidget) => {
+  const intl = useIntl();
+  const [openDrawer, setOpenDrawer] = useState(open);
+
+  useEffect(() => {
+    setOpenDrawer(open);
+  }, [open]);
+
+  const drawerMinWidth = 600;
+  const drawerMaxWidth = 1100;
+
+  const [drawerWidth, setDrawerWidth] = useState(drawerMinWidth);
+
+  return (
+    <>
+      <span
+        onClick={() => {
+          setOpenDrawer(true);
+        }}
+      >
+        {trigger}
+      </span>
+      <Drawer
+        title="Discussion"
+        destroyOnHidden
+        extra={
+          <Space>
+            <Link to={`/discussion/show/${resType}/${resId}`} target="_blank">
+              {intl.formatMessage(
+                {
+                  id: "buttons.open.in.new.tab",
+                },
+                { item: "" }
+              )}
+            </Link>
+            {drawerWidth === drawerMinWidth ? (
+              <Button
+                type="link"
+                icon={<FullscreenOutlined />}
+                onClick={() => setDrawerWidth(drawerMaxWidth)}
+              />
+            ) : (
+              <Button
+                type="link"
+                icon={<FullscreenExitOutlined />}
+                onClick={() => setDrawerWidth(drawerMinWidth)}
+              />
+            )}
+          </Space>
+        }
+        width={drawerWidth}
+        onClose={() => {
+          if (onClose) {
+            onClose();
+          } else {
+            setOpenDrawer(false);
+          }
+
+          if (document.getElementsByTagName("body")[0].hasAttribute("style")) {
+            document.getElementsByTagName("body")[0].removeAttribute("style");
+          }
+        }}
+        open={openDrawer}
+        maskClosable={false}
+      >
+        <Discussion resId={resId} resType={resType} showStudent={false} />
+      </Drawer>
+    </>
+  );
+};
+
+export default DiscussionDrawerWidget;

+ 113 - 0
dashboard-v6/src/components/discussion/DiscussionEdit.tsx

@@ -0,0 +1,113 @@
+import { useIntl } from "react-intl";
+import { Button, Card, Form } from "antd";
+import { message } from "antd";
+import { ProForm, ProFormText } from "@ant-design/pro-components";
+import { Col, Row, Space } from "antd";
+import { CloseOutlined } from "@ant-design/icons";
+
+import { put } from "../../request";
+import type { ICommentRequest, ICommentResponse } from "../../api/Comment";
+import MDEditor from "@uiw/react-md-editor";
+import type { IComment } from "../../api/discussion";
+
+interface IWidget {
+  data: IComment;
+  onUpdated?: Function;
+  onClose?: Function;
+}
+const DiscussionEditWidget = ({ data, onUpdated, onClose }: IWidget) => {
+  const intl = useIntl();
+
+  return (
+    <Card
+      title={<span>{data.user.nickName}</span>}
+      extra={
+        <Button
+          shape="circle"
+          size="small"
+          icon={<CloseOutlined />}
+          onClick={() => {
+            if (typeof onClose !== "undefined") {
+              onClose();
+            }
+          }}
+        />
+      }
+      style={{ width: "auto" }}
+    >
+      <ProForm<IComment>
+        submitter={{
+          render: (_props, doms) => {
+            return (
+              <Row>
+                <Col span={14} offset={4}>
+                  <Space>{doms}</Space>
+                </Col>
+              </Row>
+            );
+          },
+        }}
+        onFinish={async (values) => {
+          const url = `/v2/discussion/${data.id}`;
+          const newData: ICommentRequest = {
+            title: values.title,
+            content: values.content,
+          };
+          console.info("DiscussionEdit api request", url, newData);
+          put<ICommentRequest, ICommentResponse>(url, newData)
+            .then((json) => {
+              console.debug("DiscussionEdit api response", json);
+              if (json.ok) {
+                console.log(intl.formatMessage({ id: "flashes.success" }));
+                if (typeof onUpdated !== "undefined") {
+                  const newData = {
+                    id: json.data.id, //id未提供为新建
+                    resId: json.data.res_id,
+                    resType: json.data.res_type,
+                    user: json.data.editor,
+                    parent: json.data.parent,
+                    title: json.data.title,
+                    content: json.data.content,
+                    status: json.data.status,
+                    childrenCount: json.data.children_count,
+                    createdAt: json.data.created_at,
+                    updatedAt: json.data.updated_at,
+                  };
+                  onUpdated(newData);
+                }
+              } else {
+                message.error(json.message);
+              }
+            })
+            .catch((e) => {
+              message.error(e.message);
+            });
+        }}
+        params={{}}
+        request={async () => {
+          return data;
+        }}
+      >
+        <ProForm.Group>
+          <ProFormText
+            name="title"
+            hidden={data.parent ? true : false}
+            label={intl.formatMessage({ id: "forms.fields.title.label" })}
+            tooltip="最长为 24 位"
+            rules={[{ required: data.parent ? false : true }]}
+          />
+        </ProForm.Group>
+        <ProForm.Group>
+          <Form.Item
+            name="content"
+            label={intl.formatMessage({ id: "forms.fields.content.label" })}
+          >
+            <MDEditor style={{ width: "100%" }} />
+          </Form.Item>
+        </ProForm.Group>
+      </ProForm>
+    </Card>
+  );
+};
+
+export default DiscussionEditWidget;

+ 104 - 0
dashboard-v6/src/components/discussion/DiscussionItem.tsx

@@ -0,0 +1,104 @@
+import { Avatar } from "antd";
+import { useEffect, useState } from "react";
+import type { IUser } from "../auth/User";
+import DiscussionShow from "./DiscussionShow";
+import DiscussionEdit from "./DiscussionEdit";
+import type { TResType } from "./DiscussionListCard";
+import type { TDiscussionType } from "./Discussion";
+
+interface IWidget {
+  data: IComment;
+  isFocus?: boolean;
+  hideTitle?: boolean;
+  onSelect?: Function;
+  onCreated?: Function;
+  onDelete?: Function;
+  onReply?: Function;
+  onClose?: Function;
+  onConvert?: Function;
+}
+const DiscussionItemWidget = ({
+  data,
+  isFocus = false,
+  hideTitle = false,
+  onSelect,
+  onCreated,
+  onDelete,
+  onReply,
+  onClose,
+  onConvert,
+}: IWidget) => {
+  const [edit, setEdit] = useState(false);
+  const [currData, setCurrData] = useState<IComment>(data);
+  useEffect(() => {
+    setCurrData(data);
+  }, [data]);
+  return (
+    <div
+      id={`answer-${data.id}`}
+      style={{
+        display: "flex",
+        width: "100%",
+        border: isFocus ? "2px solid blue" : "unset",
+        borderRadius: 10,
+        padding: 5,
+      }}
+    >
+      <div style={{ width: "2em", display: "none" }}>
+        <Avatar size="small">{data.user?.nickName?.slice(0, 1)}</Avatar>
+      </div>
+      <div style={{ width: "100%" }}>
+        {edit ? (
+          <DiscussionEdit
+            data={currData}
+            onUpdated={(e: IComment) => {
+              setCurrData(e);
+              setEdit(false);
+            }}
+            onCreated={(e: IComment) => {
+              if (typeof onCreated !== "undefined") {
+                onCreated(e);
+              }
+            }}
+            onClose={() => setEdit(false)}
+          />
+        ) : (
+          <DiscussionShow
+            data={currData}
+            hideTitle={hideTitle}
+            onEdit={() => {
+              setEdit(true);
+            }}
+            onSelect={(e: React.MouseEvent<HTMLSpanElement, MouseEvent>) => {
+              if (typeof onSelect !== "undefined") {
+                onSelect(e, currData);
+              }
+            }}
+            onDelete={(_id: string) => {
+              if (typeof onDelete !== "undefined") {
+                onDelete();
+              }
+            }}
+            onReply={() => {
+              if (typeof onReply !== "undefined") {
+                onReply(currData);
+              }
+            }}
+            onClose={(value: boolean) => {
+              if (typeof onClose !== "undefined") {
+                onClose(value);
+              }
+            }}
+            onConvert={(value: TDiscussionType) => {
+              if (typeof onConvert !== "undefined") {
+                onConvert(value);
+              }
+            }}
+          />
+        )}
+      </div>
+    </div>
+  );
+};
+
+export default DiscussionItemWidget;

+ 63 - 0
dashboard-v6/src/components/discussion/DiscussionList.tsx

@@ -0,0 +1,63 @@
+import { List } from "antd";
+
+import DiscussionItem, { type IComment } from "./DiscussionItem";
+
+interface IWidget {
+  data: IComment[];
+  onSelect?: Function;
+  onDelete?: Function;
+  onReply?: Function;
+  onClose?: Function;
+}
+const DiscussionListWidget = ({
+  data,
+  onSelect,
+  onDelete,
+  onReply,
+  onClose,
+}: IWidget) => {
+  return (
+    <List
+      pagination={{
+        onChange: (page) => {
+          console.log(page);
+        },
+        pageSize: 10,
+      }}
+      itemLayout="horizontal"
+      dataSource={data}
+      renderItem={(item) => (
+        <List.Item>
+          <DiscussionItem
+            data={item}
+            onSelect={(
+              e: React.MouseEvent<HTMLSpanElement, MouseEvent>,
+              data: IComment
+            ) => {
+              if (typeof onSelect !== "undefined") {
+                onSelect(e, data);
+              }
+            }}
+            onDelete={() => {
+              if (typeof onDelete !== "undefined") {
+                onDelete(item.id);
+              }
+            }}
+            onReply={() => {
+              if (typeof onReply !== "undefined") {
+                onReply(item);
+              }
+            }}
+            onClose={() => {
+              if (typeof onClose !== "undefined") {
+                onClose(item);
+              }
+            }}
+          />
+        </List.Item>
+      )}
+    />
+  );
+};
+
+export default DiscussionListWidget;

+ 343 - 0
dashboard-v6/src/components/discussion/DiscussionListCard.tsx

@@ -0,0 +1,343 @@
+import { useEffect, useRef, useState } from "react";
+import { Button, Space, Typography } from "antd";
+import { LinkOutlined } from "@ant-design/icons";
+
+import { get } from "../../request";
+import type { ICommentListResponse } from "../../api/Comment";
+import type { IComment } from "./DiscussionItem";
+import type { IAnswerCount } from "./DiscussionDrawer";
+import { type ActionType, ProList } from "@ant-design/pro-components";
+import { renderBadge } from "../channel/ChannelTable";
+import DiscussionCreate from "./DiscussionCreate";
+import User from "../auth/User";
+import type { IArticleListResponse } from "../../api/Article";
+import { useAppSelector } from "../../hooks";
+import { currentUser as _currentUser } from "../../reducers/current-user";
+import { CommentOutlinedIcon, TemplateOutlinedIcon } from "../../assets/icon";
+import type { ISentenceResponse } from "../../api/Corpus";
+import type { TDiscussionType } from "./Discussion";
+import { courseInfo } from "../../reducers/current-course";
+import { courseUser } from "../../reducers/course-user";
+import TimeShow from "../general/TimeShow";
+
+const { Paragraph } = Typography;
+
+interface IWidget {
+  resId?: string;
+  resType?: TResType;
+  topicId?: string;
+  userId?: string;
+  changedAnswerCount?: IAnswerCount;
+  type?: TDiscussionType;
+  pageSize?: number;
+  showStudent?: boolean; //在课程中是否显示学生discussions
+  onSelect?: Function;
+  onItemCountChange?: Function;
+  onReply?: Function;
+  onReady?: Function;
+}
+const DiscussionListCardWidget = ({
+  resId,
+  resType,
+  topicId,
+  userId,
+  showStudent = false,
+  onSelect,
+  changedAnswerCount,
+  type = "discussion",
+  pageSize = 10,
+  onItemCountChange,
+  ___onReply,
+  onReady,
+}: IWidget) => {
+  const ref = useRef<ActionType | null>(null);
+  const [activeKey, setActiveKey] = useState<React.Key | undefined>("active");
+  const [activeNumber, setActiveNumber] = useState<number>(0);
+  const [closeNumber, setCloseNumber] = useState<number>(0);
+  const [count, setCount] = useState<number>(0);
+  const [canCreate, setCanCreate] = useState(false);
+
+  const course = useAppSelector(courseInfo);
+  const myCourse = useAppSelector(courseUser);
+
+  const user = useAppSelector(_currentUser);
+
+  useEffect(() => {
+    ref.current?.reload();
+  }, [resId, resType]);
+
+  useEffect(() => {
+    console.log("changedAnswerCount", changedAnswerCount);
+    ref.current?.reload();
+  }, [changedAnswerCount]);
+
+  if (
+    typeof resId === "undefined" &&
+    typeof topicId === "undefined" &&
+    typeof userId === "undefined"
+  ) {
+    return (
+      <Typography.Paragraph>
+        该资源尚未创建,不能发表讨论。
+      </Typography.Paragraph>
+    );
+  }
+  return (
+    <>
+      <ProList<IComment>
+        rowKey="id"
+        actionRef={ref}
+        metas={{
+          avatar: {
+            render(_dom, entity, _index, _action, _schema) {
+              return (
+                <>
+                  <User {...entity.user} showName={false} />
+                </>
+              );
+            },
+          },
+          title: {
+            render(_dom, entity, index, _action, _schema) {
+              return (
+                <>
+                  <div>
+                    {entity.resId !== resId ? <LinkOutlined /> : <></>}
+                    <Button
+                      key={index}
+                      size="small"
+                      type="link"
+                      icon={
+                        entity.newTpl ? <TemplateOutlinedIcon /> : undefined
+                      }
+                      onClick={(event) => {
+                        if (typeof onSelect !== "undefined") {
+                          onSelect(event, entity);
+                        }
+                      }}
+                    >
+                      {entity.title}
+                    </Button>
+                  </div>
+                  <div>
+                    <TimeShow
+                      type="secondary"
+                      showIcon={false}
+                      createdAt={entity.createdAt}
+                      updatedAt={entity.updatedAt}
+                    />
+                  </div>
+                </>
+              );
+            },
+          },
+          description: {
+            dataIndex: "content",
+            search: false,
+            render(_dom, entity, index, _action, _schema) {
+              const content = entity.summary ?? entity.content;
+              return (
+                <div key={index}>
+                  <Paragraph
+                    type="secondary"
+                    ellipsis={{
+                      rows: 2,
+                      expandable: true,
+                      onEllipsis: (ellipsis) => {
+                        console.log("Ellipsis changed:", ellipsis);
+                      },
+                    }}
+                    title={content}
+                  >
+                    {content}
+                  </Paragraph>
+                </div>
+              );
+            },
+          },
+          actions: {
+            render: (_text, row, index, _action) => [
+              row.childrenCount ? (
+                <Space key={index}>
+                  <CommentOutlinedIcon key={"icon"} />
+                  <span key={"count"}>{row.childrenCount}</span>
+                </Space>
+              ) : (
+                <></>
+              ),
+            ],
+          },
+        }}
+        request={async (params = {}, _sorter, _filter) => {
+          let url: string = `/v2/discussion?type=${type}&res_type=${resType}&`;
+          if (typeof topicId !== "undefined") {
+            url += `view=question-by-topic&id=${topicId}`;
+          } else if (typeof resId !== "undefined") {
+            url += `view=question&id=${resId}`;
+          } else if (typeof userId !== "undefined") {
+            url += `view=topic-by-user`;
+          } else {
+            return {
+              total: 0,
+              succcess: false,
+            };
+          }
+          const offset =
+            ((params.current ? params.current : 1) - 1) *
+            (params.pageSize ? params.pageSize : pageSize);
+          url += `&limit=${params.pageSize}&offset=${offset}`;
+          url += params.keyword ? "&search=" + params.keyword : "";
+          url += activeKey ? "&status=" + activeKey : "";
+
+          if (myCourse && course) {
+            if (myCourse.role !== "student") {
+              url += `&course=${course.courseId}`;
+            }
+          }
+          if (showStudent) {
+            url += `&show_student=true`;
+          }
+          console.info("DiscussionListCard api request", url);
+          const res = await get<ICommentListResponse>(url);
+          console.info("DiscussionListCard api response", res);
+          setCount(res.data.active);
+          setCanCreate(res.data.can_create);
+          const items: IComment[] = res.data.rows.map((item, _id) => {
+            return {
+              id: item.id,
+              resId: item.res_id,
+              resType: item.res_type,
+              type: item.type,
+              user: item.editor,
+              title: item.title,
+              parent: item.parent,
+              tplId: item.tpl_id,
+              content: item.content,
+              summary: item.summary,
+              status: item.status,
+              childrenCount: item.children_count,
+              createdAt: item.created_at,
+              updatedAt: item.updated_at,
+            };
+          });
+
+          let topicTpl: IComment[] = [];
+          if (
+            activeKey !== "close" &&
+            user?.roles?.includes("basic") === false
+          ) {
+            //获取channel模版
+            let studioName: string | undefined;
+            switch (resType) {
+              case "sentence":
+                const url = `/v2/sentence/${resId}`;
+                console.info("api request", url);
+                const sentInfo = await get<ISentenceResponse>(url);
+                console.info("api response", sentInfo);
+                studioName = sentInfo.data.studio.realName;
+                break;
+            }
+            const urlTpl = `/v2/article?view=template&studio_name=${studioName}&subtitle=_template_discussion_topic_&content=true`;
+            const resTpl = await get<IArticleListResponse>(urlTpl);
+            if (resTpl.ok) {
+              console.log("resTpl.data.rows", resTpl.data.rows);
+              topicTpl = resTpl.data.rows
+                .filter(
+                  (value) =>
+                    items.findIndex((old) => old.tplId === value.uid) === -1
+                )
+                .map((item, _index) => {
+                  return {
+                    tplId: item.uid,
+                    resId: resId,
+                    resType: resType,
+                    type: "discussion",
+                    user: item.editor
+                      ? item.editor
+                      : { id: "", userName: "", nickName: "" },
+                    title: item.title,
+                    parent: null,
+                    content: item.content,
+                    html: item.html,
+                    summary: item.summary ? item.summary : item._summary,
+                    status: "active",
+                    childrenCount: 0,
+                    newTpl: true,
+                    createdAt: item.created_at,
+                    updatedAt: item.updated_at,
+                  };
+                });
+            }
+          }
+
+          setActiveNumber(res.data.active);
+          setCloseNumber(res.data.close);
+          if (typeof onReady !== "undefined") {
+            onReady();
+          }
+          return {
+            total: res.data.count,
+            succcess: true,
+            data: [...topicTpl, ...items],
+          };
+        }}
+        bordered
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+          pageSize: pageSize,
+        }}
+        search={false}
+        options={{
+          search: false,
+        }}
+        toolbar={{
+          menu: {
+            activeKey,
+            items: [
+              {
+                key: "active",
+                label: (
+                  <span>
+                    active
+                    {renderBadge(activeNumber, activeKey === "active")}
+                  </span>
+                ),
+              },
+              {
+                key: "close",
+                label: (
+                  <span>
+                    close
+                    {renderBadge(closeNumber, activeKey === "close")}
+                  </span>
+                ),
+              },
+            ],
+            onChange(key) {
+              setActiveKey(key);
+              ref.current?.reload();
+            },
+          },
+        }}
+      />
+
+      {canCreate && resId && resType ? (
+        <DiscussionCreate
+          contentType="markdown"
+          resId={resId}
+          resType={resType}
+          type={type}
+          onCreated={(_e: IComment) => {
+            if (typeof onItemCountChange !== "undefined") {
+              onItemCountChange(count + 1);
+            }
+            ref.current?.reload();
+          }}
+        />
+      ) : undefined}
+    </>
+  );
+};
+
+export default DiscussionListCardWidget;

+ 5 - 0
dashboard-v6/src/components/discussion/DiscussionListItem.tsx

@@ -0,0 +1,5 @@
+const DiscussionListItemWidget = () => {
+  return <div>change password</div>;
+};
+
+export default DiscussionListItemWidget;

+ 369 - 0
dashboard-v6/src/components/discussion/DiscussionShow.tsx

@@ -0,0 +1,369 @@
+import { useIntl } from "react-intl";
+import {
+  Button,
+  Card,
+  Dropdown,
+  message,
+  Modal,
+  notification,
+  Space,
+  Tag,
+  Typography,
+} from "antd";
+import {
+  MoreOutlined,
+  EditOutlined,
+  DeleteOutlined,
+  LinkOutlined,
+  CheckOutlined,
+  MessageOutlined,
+  ExclamationCircleOutlined,
+  CloseOutlined,
+  SyncOutlined,
+} from "@ant-design/icons";
+import type { MenuProps } from "antd";
+
+import type { IComment } from "./DiscussionItem";
+import TimeShow from "../general/TimeShow";
+import Marked from "../general/Marked";
+import { delete_, put } from "../../request";
+import type { IDeleteResponse } from "../../api/Article";
+import { fullUrl } from "../../utils";
+import type { ICommentRequest, ICommentResponse } from "../../api/Comment";
+import { useState } from "react";
+import MdView from "../template/MdView";
+import type { TDiscussionType } from "./Discussion";
+import { discussionCountUpgrade } from "./DiscussionCount";
+
+const { Text } = Typography;
+
+interface IWidget {
+  data: IComment;
+  hideTitle?: boolean;
+  onEdit?: Function;
+  onSelect?: Function;
+  onDelete?: Function;
+  onReply?: Function;
+  onClose?: Function;
+  onConvert?: Function;
+}
+const DiscussionShowWidget = ({
+  data,
+  hideTitle = false,
+  onEdit,
+  onSelect,
+  onDelete,
+  ___onReply,
+  onClose,
+  onConvert,
+}: IWidget) => {
+  const intl = useIntl();
+  const [closed, setClosed] = useState(data.status);
+  const showDeleteConfirm = (id: string, resId: string, title: string) => {
+    Modal.confirm({
+      icon: <ExclamationCircleOutlined />,
+      title:
+        intl.formatMessage({
+          id: "message.delete.confirm",
+        }) +
+        intl.formatMessage({
+          id: "message.irrevocable",
+        }),
+
+      content: title,
+      okText: intl.formatMessage({
+        id: "buttons.delete",
+      }),
+      okType: "danger",
+      cancelText: intl.formatMessage({
+        id: "buttons.no",
+      }),
+      onOk() {
+        const url = `/v2/discussion/${id}`;
+        console.info("Discussion delete api request", url);
+        return delete_<IDeleteResponse>(url)
+          .then((json) => {
+            console.debug("api response", json);
+            if (json.ok) {
+              message.success("删除成功");
+              discussionCountUpgrade(resId);
+              if (typeof onDelete !== "undefined") {
+                onDelete(id);
+              }
+            } else {
+              message.error(json.message);
+            }
+          })
+          .catch((e) => console.log("Oops errors!", e));
+      },
+    });
+  };
+
+  const close = (value: boolean) => {
+    const url = `/v2/discussion/${data.id}`;
+    const newData: ICommentRequest = {
+      title: data.title,
+      content: data.content,
+      status: value ? "close" : "active",
+    };
+    console.info("api request", url, newData);
+    put<ICommentRequest, ICommentResponse>(url, newData).then((json) => {
+      console.log(json);
+      if (json.ok) {
+        setClosed(json.data.status);
+        discussionCountUpgrade(data.resId);
+        if (typeof onClose !== "undefined") {
+          onClose(value);
+        }
+      } else {
+        message.error(json.message);
+      }
+    });
+  };
+
+  const convert = (newType: TDiscussionType) => {
+    const url = `/v2/discussion/${data.id}`;
+    const newData: ICommentRequest = {
+      title: data.title,
+      content: data.content,
+      status: data.status,
+      type: newType,
+    };
+    console.info("api response", url, newData);
+    put<ICommentRequest, ICommentResponse>(url, newData).then((json) => {
+      console.info("api response", json);
+      if (json.ok) {
+        notification.info({ message: "转换成功" });
+        if (typeof onConvert !== "undefined") {
+          onConvert(newType);
+        }
+      }
+    });
+  };
+
+  const onClick: MenuProps["onClick"] = (e) => {
+    console.log("click ", e);
+    switch (e.key) {
+      case "copy-link":
+        let url = `/discussion/topic/`;
+        if (data.id) {
+          if (data.parent) {
+            url += `${data.parent}#${data.id}`;
+          } else {
+            url += data.id;
+          }
+        } else {
+          url += `${data.tplId}?tpl=true&resId=${data.resId}&resType=${data.resType}`;
+        }
+
+        navigator.clipboard.writeText(fullUrl(url)).then(() => {
+          message.success("链接地址已经拷贝到剪贴板");
+        });
+        break;
+      case "copy-tpl":
+        const tpl = `{{qa|id=${data.id}|style=collapse}}`;
+        navigator.clipboard.writeText(tpl).then(() => {
+          notification.success({ message: "链接地址已经拷贝到剪贴板" });
+        });
+        break;
+      case "edit":
+        if (typeof onEdit !== "undefined") {
+          onEdit();
+        }
+        break;
+      case "close":
+        close(true);
+        break;
+      case "reopen":
+        close(false);
+        break;
+      case "convert_qa":
+        convert("qa");
+        break;
+      case "convert_help":
+        convert("help");
+        break;
+      case "convert_discussion":
+        convert("discussion");
+        break;
+      case "delete":
+        if (data.id && data.resId) {
+          showDeleteConfirm(data.id, data.resId, data.title ?? "");
+        }
+        break;
+      default:
+        break;
+    }
+  };
+  console.log("children", data.childrenCount);
+  const items: MenuProps["items"] = [
+    {
+      key: "copy-link",
+      label: intl.formatMessage({
+        id: "buttons.copy.link",
+      }),
+      icon: <LinkOutlined />,
+    },
+    {
+      key: "copy-tpl",
+      label: intl.formatMessage({
+        id: "buttons.copy.tpl",
+      }),
+      icon: <LinkOutlined />,
+      disabled: data.type !== "qa",
+    },
+    {
+      type: "divider",
+    },
+    {
+      key: "edit",
+      label: intl.formatMessage({
+        id: "buttons.edit",
+      }),
+      icon: <EditOutlined />,
+    },
+    {
+      key: "close",
+      label: intl.formatMessage({
+        id: "buttons.close",
+      }),
+      icon: <CloseOutlined />,
+      disabled: closed === "close",
+    },
+    {
+      key: "reopen",
+      label: intl.formatMessage({
+        id: "buttons.open",
+      }),
+      icon: <CheckOutlined />,
+      disabled: closed === "active",
+    },
+    {
+      type: "divider",
+    },
+    {
+      key: "convert",
+      label: intl.formatMessage({
+        id: "buttons.convert",
+      }),
+      icon: <SyncOutlined />,
+      disabled: data.parent ? true : false,
+      children: [
+        { key: "convert_qa", label: "qa", disabled: data.type === "qa" },
+        { key: "convert_help", label: "help", disabled: data.type === "help" },
+        {
+          key: "convert_discussion",
+          label: "discussion",
+          disabled: data.type === "discussion",
+        },
+      ],
+    },
+    {
+      type: "divider",
+    },
+    {
+      key: "delete",
+      label: intl.formatMessage({
+        id: "buttons.delete",
+      }),
+      icon: <DeleteOutlined />,
+      danger: true,
+      disabled: data.childrenCount && data.childrenCount > 0 ? true : false,
+    },
+  ];
+
+  const editInfo = () => {
+    return (
+      <Space orientation="vertical" size={"small"}>
+        {data.title && !hideTitle ? (
+          <Text
+            style={{ fontSize: 16 }}
+            strong
+            onClick={(e) => {
+              if (typeof onSelect !== "undefined") {
+                onSelect(e);
+              }
+            }}
+          >
+            {data.title}
+          </Text>
+        ) : undefined}
+        <Text type="secondary" style={{ fontSize: "80%" }}>
+          <Space>
+            {!data.parent && closed === "close" ? (
+              <Tag style={{ backgroundColor: "#8250df", color: "white" }}>
+                {"closed"}
+              </Tag>
+            ) : undefined}
+            {data.user.nickName}
+            <TimeShow
+              type="secondary"
+              updatedAt={data.updatedAt}
+              createdAt={data.createdAt}
+            />
+          </Space>
+        </Text>
+      </Space>
+    );
+  };
+
+  const editMenu = () => {
+    return (
+      <Space>
+        <span
+          style={{
+            display: data.childrenCount === 0 ? "none" : "inline",
+            cursor: "pointer",
+          }}
+          onClick={(e) => {
+            if (typeof onSelect !== "undefined") {
+              onSelect(e, data);
+            }
+          }}
+        >
+          {data.childrenCount ? (
+            <>
+              <MessageOutlined /> {data.childrenCount}
+            </>
+          ) : undefined}
+        </span>
+        <Dropdown
+          menu={{ items, onClick }}
+          placement="bottomRight"
+          trigger={["click"]}
+        >
+          <Button shape="circle" size="small" icon={<MoreOutlined />}></Button>
+        </Dropdown>
+      </Space>
+    );
+  };
+  return (
+    <Card
+      size="small"
+      title={data.type === "qa" && data.parent ? undefined : editInfo()}
+      extra={data.type === "qa" && data.parent ? undefined : editMenu()}
+      style={{ width: "100%" }}
+    >
+      <div>
+        {data.html ? (
+          <MdView html={data.html} />
+        ) : (
+          <Marked text={data.content} />
+        )}
+      </div>
+      {data.type === "qa" && data.parent ? (
+        <div style={{ display: "flex", justifyContent: "space-between" }}>
+          <div></div>
+          <div>
+            {editInfo()}
+            {editMenu()}
+          </div>
+        </div>
+      ) : (
+        <></>
+      )}
+    </Card>
+  );
+};
+
+export default DiscussionShowWidget;

+ 90 - 0
dashboard-v6/src/components/discussion/DiscussionTopic.tsx

@@ -0,0 +1,90 @@
+import { useEffect, useState } from "react";
+
+import DiscussionTopicInfo from "./DiscussionTopicInfo";
+import DiscussionTopicChildren from "./DiscussionTopicChildren";
+import type { IComment } from "./DiscussionItem"
+import type { TResType } from "./DiscussionListCard"
+import type { TDiscussionType } from "./Discussion"
+
+interface IWidget {
+  resType?: TResType;
+  topicId?: string;
+  topic?: IComment;
+  focus?: string;
+  hideTitle?: boolean;
+  hideReply?: boolean;
+  onItemCountChange?: Function;
+  onTopicReady?: Function;
+  onTopicDelete?: Function;
+  onConvert?: Function;
+}
+const DiscussionTopicWidget = ({
+  resType,
+  topicId,
+  topic,
+  focus,
+  hideTitle = false,
+  hideReply = false,
+  onTopicReady,
+  onItemCountChange,
+  onTopicDelete,
+  onConvert,
+}: IWidget) => {
+  const [count, setCount] = useState<number>();
+  const [_currResId, setCurrResId] = useState<string>();
+  const [currTopicId, setCurrTopicId] = useState(topicId);
+  const [currTopic, setCurrTopic] = useState<IComment | undefined>(topic);
+  useEffect(() => {
+    setCurrTopic(topic);
+  }, [topic]);
+
+  return (
+    <>
+      <DiscussionTopicInfo
+        topicId={currTopicId}
+        topic={currTopic}
+        hideTitle={hideTitle}
+        childrenCount={count}
+        onReady={(value: IComment) => {
+          setCurrResId(value.resId);
+          setCurrTopic(value);
+          console.log("discussion onReady", value);
+          if (typeof onTopicReady !== "undefined") {
+            onTopicReady(value);
+          }
+        }}
+        onDelete={() => {
+          if (typeof onTopicDelete !== "undefined") {
+            onTopicDelete();
+          }
+        }}
+        onConvert={(value: TDiscussionType) => {
+          if (typeof onConvert !== "undefined") {
+            onConvert(value);
+          }
+        }}
+      />
+      <DiscussionTopicChildren
+        topic={currTopic}
+        resId={currTopic?.resId}
+        resType={resType}
+        focus={focus}
+        topicId={topicId}
+        hideReply={hideReply}
+        onItemCountChange={(count: number, e: string) => {
+          //把新建回答的消息传出去。
+          setCount(count);
+          if (typeof onItemCountChange !== "undefined") {
+            onItemCountChange(count, e);
+          }
+        }}
+        onTopicCreate={(value: IComment) => {
+          console.log("onTopicCreate", value);
+          setCurrTopicId(value.id);
+        }}
+      />
+    </>
+  );
+};
+
+export default DiscussionTopicWidget;

+ 261 - 0
dashboard-v6/src/components/discussion/DiscussionTopicChildren.tsx

@@ -0,0 +1,261 @@
+import { List, message, Skeleton } from "antd";
+import { IconType } from "antd/lib/notification";
+import { useEffect, useState } from "react";
+import { useIntl } from "react-intl";
+
+import { get } from "../../request";
+import type { ICommentListResponse } from "../../api/Comment";
+import type {
+  ISentHistoryData,
+  ISentHistoryListResponse,
+} from "../corpus/SentHistory";
+import SentHistoryGroup from "../corpus/SentHistoryGroup";
+import DiscussionCreate from "./DiscussionCreate";
+import DiscussionItem, { type IComment } from "./DiscussionItem";
+import type { TResType } from "./DiscussionListCard";
+
+interface IItem {
+  type: "comment" | "sent";
+  comment?: IComment;
+  sent?: ISentHistoryData[];
+  oldSent?: string;
+  date: number;
+}
+
+interface IWidget {
+  topic?: IComment;
+  resId?: string;
+  resType?: TResType;
+  topicId?: string;
+  focus?: string;
+  hideReply?: boolean;
+  onItemCountChange?: Function;
+  onTopicCreate?: Function;
+}
+const DiscussionTopicChildrenWidget = ({
+  topic,
+  resId,
+  resType,
+  topicId,
+  focus,
+  hideReply = false,
+  onItemCountChange,
+  onTopicCreate,
+}: IWidget) => {
+  const intl = useIntl();
+  const [data, setData] = useState<IComment[]>([]);
+  const [loading, setLoading] = useState(false);
+  const [history, setHistory] = useState<ISentHistoryData[]>([]);
+  const [items, setItems] = useState<IItem[]>();
+
+  useEffect(() => {
+    if (loading === false) {
+      const ele = document.getElementById(`answer-${focus}`);
+      ele?.scrollIntoView({
+        behavior: "smooth",
+        block: "center",
+      });
+      console.log("after render");
+    }
+  });
+
+  useEffect(() => {
+    const comment: IItem[] = data.map((item) => {
+      const date = new Date(item.createdAt ? item.createdAt : "").getTime();
+      return {
+        type: "comment",
+        comment: item,
+        date: date,
+      };
+    });
+    const topicTime = new Date(
+      topic?.createdAt ? topic?.createdAt : ""
+    ).getTime();
+    let firstHis = history.findIndex(
+      (value) =>
+        new Date(value.created_at ? value.created_at : "").getTime() > topicTime
+    );
+    if (firstHis < 0) {
+      firstHis = history.length;
+    }
+    const hisFiltered = history.filter(
+      (_value, index) => index >= firstHis - 1
+    );
+    const his: IItem[] = hisFiltered.map((item, index) => {
+      return {
+        type: "sent",
+        sent: [item],
+        date: new Date(item.created_at ? item.created_at : "").getTime(),
+        oldSent: index > 0 ? hisFiltered[index - 1].content : undefined,
+      };
+    });
+    const mixItems = [...comment, ...his];
+    mixItems.sort((a, b) => a.date - b.date);
+    console.log("mixItems", mixItems);
+    const newMixItems: IItem[] = [];
+    let currSent: ISentHistoryData[] = [];
+    let currOldSent: string | undefined;
+    let sentBegin = false;
+    mixItems.forEach((value, _index, _array) => {
+      if (value.type === "comment") {
+        if (sentBegin) {
+          sentBegin = false;
+          newMixItems.push({
+            type: "sent",
+            sent: currSent,
+            date: 0,
+            oldSent: currOldSent ? currOldSent : currSent[0].content,
+          });
+        }
+        newMixItems.push(value);
+      } else {
+        if (value.sent && value.sent.length > 0) {
+          if (sentBegin) {
+            currSent.push(value.sent[0]);
+          } else {
+            sentBegin = true;
+            currSent = value.sent;
+            currOldSent = value.oldSent;
+          }
+        }
+      }
+    });
+    if (sentBegin) {
+      sentBegin = false;
+      newMixItems.push({
+        type: "sent",
+        sent: currSent,
+        date: 0,
+        oldSent: currOldSent ? currOldSent : currSent[0].content,
+      });
+    }
+    setItems(newMixItems);
+  }, [data, history, topic?.createdAt]);
+
+  useEffect(() => {
+    if (resType === "sentence" && resId) {
+      const url = `/v2/sent_history?view=sentence&id=${resId}&order=created_at&dir=asc`;
+      setLoading(true);
+      get<ISentHistoryListResponse>(url)
+        .then((res) => {
+          if (res.ok) {
+            setHistory(res.data.rows);
+          }
+        })
+        .finally(() => setLoading(false));
+    }
+  }, [resId, resType]);
+
+  useEffect(() => {
+    console.log("topicId", topicId);
+    if (typeof topicId === "undefined") {
+      return;
+    }
+    setLoading(true);
+    const url = `/v2/discussion?view=answer&id=${topicId}`;
+    console.info("api request", url);
+    get<ICommentListResponse>(url)
+      .then((json) => {
+        console.debug("api response", json);
+        if (json.ok) {
+          const discussions: IComment[] = json.data.rows.map((item) => {
+            return {
+              id: item.id,
+              resId: item.res_id,
+              resType: item.res_type,
+              type: item.type,
+              user: item.editor,
+              parent: item.parent,
+              title: item.title,
+              content: item.content,
+              status: item.status,
+              childrenCount: item.children_count,
+              createdAt: item.created_at,
+              updatedAt: item.updated_at,
+            };
+          });
+          setData(discussions);
+        } else {
+          message.error(json.message);
+        }
+      })
+      .finally(() => {
+        setLoading(false);
+      })
+      .catch((e) => {
+        message.error(e.message);
+      });
+  }, [intl, topicId]);
+  return (
+    <div>
+      {loading ? (
+        <Skeleton title={{ width: 200 }} paragraph={{ rows: 1 }} active />
+      ) : (
+        <List
+          pagination={false}
+          size="small"
+          itemLayout="horizontal"
+          dataSource={items}
+          renderItem={(item) => {
+            return (
+              <List.Item>
+                {item.type === "comment" ? (
+                  item.comment ? (
+                    <DiscussionItem
+                      data={item.comment}
+                      isFocus={item.comment.id === focus ? true : false}
+                      onDelete={() => {
+                        if (typeof onItemCountChange !== "undefined") {
+                          onItemCountChange(
+                            data.length - 1,
+                            item.comment?.parent
+                          );
+                        }
+                        setData((origin) => {
+                          return origin.filter(
+                            (value) => value.id !== item.comment?.id
+                          );
+                        });
+                      }}
+                    />
+                  ) : undefined
+                ) : (
+                  <SentHistoryGroup
+                    data={item.sent}
+                    oldContent={item.oldSent}
+                  />
+                )}
+              </List.Item>
+            );
+          }}
+        />
+      )}
+      {hideReply ? (
+        <></>
+      ) : (
+        <DiscussionCreate
+          resId={resId}
+          resType={resType}
+          contentType="markdown"
+          parent={topicId}
+          topicId={topicId}
+          topic={topic}
+          onCreated={(value: IComment) => {
+            const newData = JSON.parse(JSON.stringify(value));
+            setData([...data, newData]);
+            if (typeof onItemCountChange !== "undefined") {
+              onItemCountChange(data.length + 1, value.parent);
+            }
+          }}
+          onTopicCreated={(value: IconType) => {
+            if (typeof onTopicCreate !== "undefined") {
+              onTopicCreate(value);
+            }
+          }}
+        />
+      )}
+    </div>
+  );
+};
+
+export default DiscussionTopicChildrenWidget;

+ 118 - 0
dashboard-v6/src/components/discussion/DiscussionTopicInfo.tsx

@@ -0,0 +1,118 @@
+import { message } from "antd";
+import { useEffect, useState } from "react";
+
+import { get } from "../../request";
+import type { ICommentResponse } from "../../api/Comment";
+import DiscussionItem, { type IComment } from "./DiscussionItem";
+import type { TDiscussionType } from "./Discussion";
+
+interface IWidget {
+  topicId?: string;
+  topic?: IComment;
+  childrenCount?: number;
+  hideTitle?: boolean;
+  onDelete?: Function;
+  onReply?: Function;
+  onClose?: Function;
+  onReady?: Function;
+  onConvert?: Function;
+}
+const DiscussionTopicInfoWidget = ({
+  topicId,
+  topic,
+  childrenCount,
+  hideTitle = false,
+  onReady,
+  onDelete,
+  onReply,
+  onClose,
+  onConvert,
+}: IWidget) => {
+  const [data, setData] = useState<IComment | undefined>(topic);
+  useEffect(() => {
+    setData(topic);
+  }, [topic]);
+  useEffect(() => {
+    setData((origin) => {
+      if (typeof origin !== "undefined") {
+        origin.childrenCount = childrenCount;
+        return origin;
+      }
+    });
+  }, [childrenCount]);
+
+  useEffect(() => {
+    if (typeof topicId === "undefined") {
+      return;
+    }
+    const url = `/v2/discussion/${topicId}`;
+    console.info("discussion api request", url);
+    get<ICommentResponse>(url)
+      .then((json) => {
+        console.debug("api response", json);
+        if (json.ok) {
+          const item = json.data;
+          const discussion: IComment = {
+            id: item.id,
+            resId: item.res_id,
+            resType: item.res_type,
+            type: item.type,
+            parent: item.parent,
+            user: item.editor,
+            title: item.title,
+            content: item.content,
+            html: item.html,
+            status: item.status,
+            childrenCount: item.children_count,
+            createdAt: item.created_at,
+            updatedAt: item.updated_at,
+          };
+          setData(discussion);
+          if (typeof onReady !== "undefined") {
+            console.log("discussion on ready");
+            onReady(discussion);
+          }
+        } else {
+          message.error(json.message);
+        }
+      })
+      .catch((e) => {
+        message.error(e.message);
+      });
+  }, [topicId]);
+
+  return (
+    <div>
+      {data ? (
+        <DiscussionItem
+          data={data}
+          hideTitle={hideTitle}
+          onDelete={() => {
+            if (typeof onDelete !== "undefined") {
+              onDelete(data.id);
+            }
+          }}
+          onReply={() => {
+            if (typeof onReply !== "undefined") {
+              onReply(data);
+            }
+          }}
+          onClose={() => {
+            if (typeof onClose !== "undefined") {
+              onClose(data);
+            }
+          }}
+          onConvert={(value: TDiscussionType) => {
+            if (typeof onConvert !== "undefined") {
+              onConvert(value);
+            }
+          }}
+        />
+      ) : (
+        <></>
+      )}
+    </div>
+  );
+};
+
+export default DiscussionTopicInfoWidget;

+ 113 - 0
dashboard-v6/src/components/discussion/InteractiveArea.tsx

@@ -0,0 +1,113 @@
+import { useEffect, useState } from "react";
+import { Tabs } from "antd";
+
+import type { TResType } from "./DiscussionListCard"
+import Discussion from "./Discussion";
+import { get } from "../../request";
+import QaBox from "./QaBox";
+
+interface IInteractive {
+  ok: boolean;
+  data: ITypeData;
+  message: string;
+}
+
+interface ITypeData {
+  qa: IPower;
+  help: IPower;
+  discussion: IPower;
+}
+
+interface IPower {
+  can_create: boolean;
+  can_reply: boolean;
+  count: number;
+}
+
+interface IWidget {
+  resId?: string;
+  resType?: TResType;
+}
+const InteractiveAreaWidget = ({ resId, resType }: IWidget) => {
+  const [showQa, setShowQa] = useState(false);
+  const [_qaCanEdit, setQaCanEdit] = useState(false);
+  const [showHelp, setShowHelp] = useState(false);
+  const [showDiscussion, setShowDiscussion] = useState(false);
+
+  useEffect(() => {
+    get<IInteractive>(`/v2/interactive/${resId}?res_type=${resType}`).then(
+      (json) => {
+        if (json.ok) {
+          console.debug("interactive", json);
+          if (json.data.qa.can_create || json.data.qa.can_reply) {
+            setShowQa(true);
+            setQaCanEdit(true);
+          } else if (json.data.qa.count > 0) {
+            setShowQa(true);
+          } else {
+            setShowQa(false);
+          }
+
+          if (json.data.help.can_create) {
+            setShowHelp(true);
+          } else if (json.data.help.can_reply) {
+            if (json.data.help.count > 0) {
+              setShowHelp(true);
+            }
+          } else {
+            setShowHelp(false);
+          }
+
+          if (
+            json.data.discussion.can_create ||
+            json.data.discussion.can_reply ||
+            json.data.discussion.count > 0
+          ) {
+            setShowDiscussion(true);
+          } else {
+            setShowDiscussion(false);
+          }
+        }
+      }
+    );
+  }, [resId, resType]);
+
+  return showQa || showHelp || showDiscussion ? (
+    <Tabs
+      size="small"
+      items={[
+        {
+          label: `问答`,
+          key: "qa",
+          children: <QaBox resId={resId} resType={resType} />,
+        },
+        {
+          label: `求助`,
+          key: "help",
+          children: <Discussion resId={resId} resType={resType} type="help" />,
+        },
+        {
+          label: `讨论`,
+          key: "discussion",
+          children: (
+            <Discussion resId={resId} resType={resType} type="discussion" />
+          ),
+        },
+      ].filter((value) => {
+        if (value.key === "qa") {
+          return showQa;
+        } else if (value.key === "help") {
+          return showHelp;
+        } else if (value.key === "discussion") {
+          return showDiscussion;
+        } else {
+          return false;
+        }
+      })}
+    />
+  ) : (
+    <></>
+  );
+};
+
+export default InteractiveAreaWidget;

+ 104 - 0
dashboard-v6/src/components/discussion/QaBox.tsx

@@ -0,0 +1,104 @@
+import { useEffect, useState } from "react";
+import { ArrowLeftOutlined } from "@ant-design/icons";
+
+import DiscussionTopic from "./DiscussionTopic";
+import type { TResType } from "./DiscussionListCard"
+import type { IComment } from "./DiscussionItem"
+
+import { Button, Space, Typography } from "antd";
+import type { TDiscussionType } from "./Discussion"
+import QaList from "./QaList";
+
+const { Text } = Typography;
+
+interface IWidget {
+  resId?: string;
+  resType?: TResType;
+  showTopicId?: string;
+  focus?: string;
+  onTopicReady?: Function;
+}
+
+const DiscussionWidget = ({
+  resId,
+  resType,
+  showTopicId,
+  focus,
+  onTopicReady,
+}: IWidget) => {
+  const [childrenDrawer, setChildrenDrawer] = useState(false);
+  const [topicId, setTopicId] = useState<string>();
+  const [topic, setTopic] = useState<IComment>();
+  const [topicTitle, setTopicTitle] = useState<string>();
+
+  useEffect(() => {
+    if (showTopicId) {
+      setChildrenDrawer(true);
+      setTopicId(showTopicId);
+    } else {
+      setChildrenDrawer(false);
+    }
+  }, [showTopicId]);
+
+  const showChildrenDrawer = (comment: IComment) => {
+    console.debug("discussion comment", comment);
+    setChildrenDrawer(true);
+    if (comment.id) {
+      setTopicId(comment.id);
+      setTopic(undefined);
+    } else {
+      setTopicId(undefined);
+      setTopic(comment);
+    }
+  };
+
+  return (
+    <>
+      {childrenDrawer ? (
+        <div>
+          <Space>
+            <Button
+              shape="circle"
+              icon={<ArrowLeftOutlined />}
+              onClick={() => setChildrenDrawer(false)}
+            />
+            <Text strong style={{ fontSize: 16 }}>
+              {topic ? topic.title : topicTitle}
+            </Text>
+          </Space>
+          <DiscussionTopic
+            resType={resType}
+            topicId={topicId}
+            topic={topic}
+            focus={focus}
+            hideTitle
+            onTopicReady={(value: IComment) => {
+              setTopicTitle(value.title);
+              if (typeof onTopicReady !== "undefined") {
+                onTopicReady(value);
+              }
+            }}
+            onTopicDelete={() => {
+              setChildrenDrawer(false);
+            }}
+            onConvert={(_value: TDiscussionType) => {
+              setChildrenDrawer(false);
+            }}
+          />
+        </div>
+      ) : (
+        <QaList
+          resId={resId}
+          resType={resType}
+          onSelect={(
+            _e: React.MouseEvent<HTMLSpanElement, MouseEvent>,
+            comment: IComment
+          ) => showChildrenDrawer(comment)}
+          onReply={(comment: IComment) => showChildrenDrawer(comment)}
+        />
+      )}
+    </>
+  );
+};
+
+export default DiscussionWidget;

+ 88 - 0
dashboard-v6/src/components/discussion/QaList.tsx

@@ -0,0 +1,88 @@
+import { useEffect, useState } from "react";
+import type { TResType } from "./DiscussionListCard";
+import { get } from "../../request";
+import type { ICommentListResponse } from "../../api/Comment";
+import DiscussionItem, { type IComment } from "./DiscussionItem";
+
+interface IWidget {
+  resId?: string;
+  resType?: TResType;
+  onSelect?: Function;
+  onReply?: Function;
+}
+const QaListWidget = ({ resId, resType, onSelect, _____onReply }: IWidget) => {
+  const [data, setData] = useState<IComment[]>();
+
+  useEffect(() => {
+    if (!resType || !resType) {
+      return;
+    }
+    let url: string = `/v2/discussion?res_type=${resType}&view=res_id&id=${resId}`;
+    url += "&dir=asc&type=qa&status=active,close";
+    console.info("api request", url);
+    get<ICommentListResponse>(url).then((json) => {
+      if (json.ok) {
+        console.debug("discussion api response", json);
+        const items: IComment[] = json.data.rows.map((item, _id) => {
+          return {
+            id: item.id,
+            resId: item.res_id,
+            resType: item.res_type,
+            type: item.type,
+            user: item.editor,
+            title: item.title,
+            parent: item.parent,
+            tplId: item.tpl_id,
+            content: item.content,
+            summary: item.summary,
+            status: item.status,
+            childrenCount: item.children_count,
+            createdAt: item.created_at,
+            updatedAt: item.updated_at,
+          };
+        });
+
+        setData(items);
+      }
+    });
+  }, []);
+  return (
+    <>
+      {data
+        ?.filter((value) => !value.parent)
+        .map((question, index) => {
+          return (
+            <div key={`div_${index}`}>
+              <DiscussionItem
+                key={index}
+                data={question}
+                onSelect={(
+                  e: React.MouseEvent<HTMLSpanElement, MouseEvent>,
+                  value: IComment
+                ) => {
+                  if (typeof onSelect !== "undefined") {
+                    onSelect(e, value);
+                  }
+                }}
+              />
+              <div
+                style={{
+                  marginLeft: 16,
+                  borderLeft: "2px solid gray",
+                  padding: 4,
+                }}
+              >
+                {data
+                  ?.filter((value) => value.parent === question.id)
+                  .map((item, id) => {
+                    return <DiscussionItem key={id} data={item} />;
+                  })}
+              </div>
+            </div>
+          );
+        })}
+    </>
+  );
+};
+
+export default QaListWidget;

+ 39 - 1
dashboard-v6/src/components/discussion/utils.ts

@@ -1,6 +1,12 @@
-import type { TResType } from "../../api/discussion";
+import type {
+  ICommentApiData,
+  IDiscussionCountResponse,
+} from "../../api/Comment";
+import type { IComment, TResType } from "../../api/discussion";
 import { show, type IShowDiscussion } from "../../reducers/discussion";
+import { upgrade } from "../../reducers/discussion-count";
 import { openPanel } from "../../reducers/right-panel";
+import { get } from "../../request";
 import store from "../../store";
 
 export const openDiscussion = (
@@ -18,3 +24,35 @@ export const openDiscussion = (
   store.dispatch(show(data));
   store.dispatch(openPanel("discussion"));
 };
+
+export const discussionCountUpgrade = (resId?: string) => {
+  if (typeof resId === "undefined") {
+    return;
+  }
+  const url = `/v2/discussion-count/${resId}`;
+  console.info("discussion-count api request", url);
+  get<IDiscussionCountResponse>(url).then((json) => {
+    console.debug("discussion-count api response", json);
+    if (json.ok) {
+      store.dispatch(upgrade({ resId: resId, data: json.data.discussions }));
+    } else {
+      console.error(json.message);
+    }
+  });
+};
+
+export const toIComment = (value: ICommentApiData): IComment => {
+  return {
+    id: value.id,
+    resId: value.res_id,
+    resType: value.res_type,
+    type: value.type,
+    user: value.editor,
+    title: value.title,
+    parent: value.parent,
+    tplId: value.tpl_id,
+    content: value.content,
+    createdAt: value.created_at,
+    updatedAt: value.updated_at,
+  };
+};

+ 126 - 0
dashboard-v6/src/components/editor/Editor.tsx

@@ -0,0 +1,126 @@
+import { CommentOutlined, SearchOutlined } from "@ant-design/icons";
+import type { ReactNode } from "react";
+import SplitLayout, { type RightToolbarTab } from "../general/SplitLayout";
+import ChannelPanel from "./panels/ChannelPanel";
+import DictPanel from "./panels/DictPanel";
+import SearchPanel from "./panels/SearchPanel";
+import {
+  ChannelIcon,
+  DictIcon,
+  GrammarIcon,
+  RobotIcon,
+  SuggestionIcon,
+} from "../../assets/icon";
+import ChatPanel from "./panels/ChatPanel";
+import SuggestionPanel from "./panels/SuggestionPanel";
+import GrammarBookPanel from "./panels/GrammarBookPanel";
+
+// ─────────────────────────────────────────────
+// Props
+// ─────────────────────────────────────────────
+
+export interface EditorProps {
+  /** 左边栏标题 */
+  sidebarTitle?: ReactNode;
+  /**
+   * 左边栏内容。
+   * 不传则左边栏为空(仍会渲染,可用于占位)。
+   */
+  sidebar?: ReactNode;
+  /**
+   * 中间内容区(render prop)。
+   * expandButton 在左边栏收起时为真实按钮节点,展开时为 null,
+   * 由中间内容自行决定放置位置(通常放在 header 左侧)。
+   *
+   * ```tsx
+   * <Editor ...>
+   *   {({ expandButton }) => (
+   *     <TypeArticle headerExtra={expandButton} ... />
+   *   )}
+   * </Editor>
+   * ```
+   */
+  children: (ctx: { expandButton: ReactNode }) => ReactNode;
+
+  // ── 业务参数(透传给右边栏面板)──
+  articleId?: string;
+  anthologyId?: string;
+  /** 多个 channelId 用 "_" 拼接的原始字符串,Editor 内部负责解析 */
+  channelId?: string | null;
+}
+
+// ─────────────────────────────────────────────
+// Component
+// ─────────────────────────────────────────────
+
+export default function Editor({
+  sidebarTitle = "目录",
+  sidebar,
+  children,
+  articleId,
+  anthologyId,
+  channelId,
+}: EditorProps) {
+  const channels = channelId ? channelId.split("_") : undefined;
+
+  // ── 右边栏 tabs(固定业务定义)──
+  // content 用独立组件而非内联 JSX,保证 articleId 等 props 变化时正常 re-render
+  const rightTabs: RightToolbarTab[] = [
+    {
+      key: "search",
+      icon: <SearchOutlined />,
+      label: "搜索",
+      content: <SearchPanel articleId={articleId} anthologyId={anthologyId} />,
+    },
+    {
+      key: "dict",
+      icon: <DictIcon />,
+      label: "字典",
+      content: <DictPanel />,
+    },
+    {
+      key: "channel",
+      icon: <ChannelIcon />,
+      label: "版本",
+      content: <ChannelPanel articleId={articleId} channels={channels} />,
+    },
+    {
+      key: "discussion",
+      icon: <CommentOutlined />,
+      label: "讨论",
+      content: <SearchPanel articleId={articleId} anthologyId={anthologyId} />,
+    },
+    {
+      key: "suggestion",
+      icon: <SuggestionIcon />,
+      label: "修改建议",
+      content: (
+        <SuggestionPanel articleId={articleId} anthologyId={anthologyId} />
+      ),
+    },
+    {
+      key: "grammar",
+      icon: <GrammarIcon />,
+      label: "语法手册",
+      content: (
+        <GrammarBookPanel articleId={articleId} anthologyId={anthologyId} />
+      ),
+    },
+    {
+      key: "ai",
+      icon: <RobotIcon />,
+      label: "人工智能",
+      content: <ChatPanel articleId={articleId} anthologyId={anthologyId} />,
+    },
+  ];
+
+  return (
+    <SplitLayout
+      sidebarTitle={sidebarTitle}
+      sidebar={sidebar}
+      rightTabs={rightTabs}
+    >
+      {({ expandButton }) => children({ expandButton })}
+    </SplitLayout>
+  );
+}

+ 2 - 0
dashboard-v6/src/components/editor/index.ts

@@ -0,0 +1,2 @@
+export { default } from "./Editor";
+export type { EditorProps } from "./Editor";

+ 27 - 0
dashboard-v6/src/components/editor/panels/ChannelPanel.tsx

@@ -0,0 +1,27 @@
+import ChannelMy from "../../channel/ChannelMy";
+import type { IChannel } from "../../../api/channel";
+
+interface ChannelPanelProps {
+  articleId?: string;
+  channels?: string[];
+}
+
+/**
+ * 频道面板
+ * 封装成独立组件,articleId / channels 变化时正常 re-render,
+ * 不受 rightTabs 数组重建影响。
+ */
+export default function ChannelPanel({ articleId, channels }: ChannelPanelProps) {
+  const handleSelect = (selected: IChannel[]) => {
+    console.log("channel selected:", selected);
+  };
+
+  return (
+    <ChannelMy
+      type="article"
+      articleId={articleId}
+      selectedKeys={channels}
+      onSelect={handleSelect}
+    />
+  );
+}

+ 22 - 0
dashboard-v6/src/components/editor/panels/ChatPanel.tsx

@@ -0,0 +1,22 @@
+import Chat from "../../ai/Chat";
+
+interface SearchPanelProps {
+  articleId?: string;
+  anthologyId?: string;
+}
+
+/**
+ * 搜索面板(占位,按实际业务填充)
+ */
+export default function ChatPanel({
+  articleId,
+  anthologyId,
+}: SearchPanelProps) {
+  console.debug("panel render", articleId, anthologyId);
+  return (
+    <div style={{ padding: 16 }}>
+      {/* TODO: 实现搜索业务逻辑 */}
+      <Chat />
+    </div>
+  );
+}

+ 14 - 0
dashboard-v6/src/components/editor/panels/DictPanel.tsx

@@ -0,0 +1,14 @@
+import DictComponent from "../../dict/DictComponent";
+
+/**
+ * 字典面板
+ * 封装成独立组件,让 React 管理生命周期,
+ * 避免内联 JSX 在 rightTabs 重建时丢失内部状态。
+ */
+export default function DictPanel() {
+  return (
+    <div className="dict_component">
+      <DictComponent />
+    </div>
+  );
+}

+ 21 - 0
dashboard-v6/src/components/editor/panels/GrammarBookPanel.tsx

@@ -0,0 +1,21 @@
+import GrammarBook from "../../term/GrammarBook";
+
+interface SearchPanelProps {
+  articleId?: string;
+  anthologyId?: string;
+}
+
+/**
+ * 搜索面板(占位,按实际业务填充)
+ */
+export default function GrammarBookPanel({
+  articleId,
+  anthologyId,
+}: SearchPanelProps) {
+  console.debug("panel render", articleId, anthologyId);
+  return (
+    <div style={{ padding: 16 }}>
+      <GrammarBook />
+    </div>
+  );
+}

+ 20 - 0
dashboard-v6/src/components/editor/panels/SearchPanel.tsx

@@ -0,0 +1,20 @@
+interface SearchPanelProps {
+  articleId?: string;
+  anthologyId?: string;
+}
+
+/**
+ * 搜索面板(占位,按实际业务填充)
+ */
+export default function SearchPanel({
+  articleId,
+  anthologyId,
+}: SearchPanelProps) {
+  console.debug("panel render", articleId, anthologyId);
+  return (
+    <div style={{ padding: 16 }}>
+      {/* TODO: 实现搜索业务逻辑 */}
+      <div style={{ marginTop: 12 }} />
+    </div>
+  );
+}

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

@@ -0,0 +1,21 @@
+import SuggestionBox from "../../sentence-editor/SuggestionBox";
+
+interface SearchPanelProps {
+  articleId?: string;
+  anthologyId?: string;
+}
+
+/**
+ * 搜索面板(占位,按实际业务填充)
+ */
+export default function SuggestionPanel({
+  articleId,
+  anthologyId,
+}: SearchPanelProps) {
+  console.debug("panel render", articleId, anthologyId);
+  return (
+    <div style={{ padding: 4 }}>
+      <SuggestionBox />
+    </div>
+  );
+}

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

@@ -1,5 +1,5 @@
 import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
-import { Button, Splitter } from "antd";
+import { Button, Popover, Splitter } from "antd";
 import { useCallback, useState, type ReactNode } from "react";
 import RightToolbar, { type RightToolbarTab } from "./RightToolbar";
 import styles from "./SplitLayout.module.css";
@@ -103,14 +103,23 @@ export default function SplitLayout({
   }, []);
 
   const expandButton = collapsed ? (
-    <Button
-      type="text"
-      size="small"
-      icon={<MenuUnfoldOutlined />}
-      onClick={toggle}
-      className={styles.expandBtn}
-      title="展开侧边栏"
-    />
+    <Popover
+      placement="bottomLeft"
+      content={
+        <div style={{ width: 300, height: 500, overflowY: "auto" }}>
+          {sidebar}
+        </div>
+      }
+    >
+      <Button
+        type="text"
+        size="small"
+        icon={<MenuUnfoldOutlined />}
+        onClick={toggle}
+        className={styles.expandBtn}
+        title="展开侧边栏"
+      />
+    </Popover>
   ) : null;
 
   // ── 右边栏状态 ──

+ 5 - 4
dashboard-v6/src/components/navigation/MainMenu.tsx

@@ -13,6 +13,7 @@ import {
   DocumentIcon,
   RobotIcon,
   TaskIcon,
+  TermIcon,
   TipitakaIcon,
 } from "../../assets/icon";
 import React, { useState } from "react";
@@ -180,14 +181,14 @@ const Widget = ({ onSearch }: Props) => {
     {
       key: "/workspace/channel",
       icon: <ChannelIcon />,
-      label: "频道",
+      label: "版本",
       activeId: "workspace.channel",
     },
 
     {
       key: "/workspace/term",
-      icon: <ChannelIcon />,
-      label: "Term",
+      icon: <TermIcon />,
+      label: "术语",
       activeId: "workspace.term",
     },
 
@@ -200,7 +201,7 @@ const Widget = ({ onSearch }: Props) => {
     {
       key: "/workspace/task",
       icon: <TaskIcon />,
-      label: "Task",
+      label: "任务",
       activeId: "workspace.task",
       children: [
         {

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

@@ -1,7 +1,7 @@
 import { Modal } from "antd";
 import NissayaAligner from "./NissayaAligner";
 import { useState, type JSX } from "react";
-import type { IChannel } from "../../api/Channel";
+import type { IChannel } from "../../api/channel";
 
 interface IWidget {
   trigger?: JSX.Element | string;

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

@@ -3,7 +3,7 @@ import { useState } from "react";
 import { PlusOutlined } from "@ant-design/icons";
 
 import { useIntl } from "react-intl";
-import type { IChannel, TChannelType } from "../../api/Channel";
+import type { IChannel, TChannelType } from "../../api/channel";
 import ChannelTableModal from "../channel/ChannelTableModal";
 
 interface IWidget {

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

@@ -3,7 +3,7 @@ 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 { IChannel, TChannelType } from "../../api/channel";
 import type { ISentence, ISentenceListResponse } from "../../api/sentence";
 
 import SentCell from "./SentCell";

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

@@ -1,7 +1,7 @@
 import { Affix } from "antd";
 import { useEffect, useRef, useState, useMemo } from "react";
 
-import type { TChannelType } from "../../api/Channel";
+import type { TChannelType } from "../../api/channel";
 import { useAppSelector } from "../../hooks";
 import { currFocus } from "../../reducers/focus";
 

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

@@ -20,7 +20,7 @@ import SentWbw from "./SentWbw";
 import SentTabButtonWbw from "./SentTabButtonWbw";
 
 import type { IWbw } from "../../types/wbw";
-import type { IResNumber } from "../../api/Channel";
+import type { IResNumber } from "../../api/channel";
 import RelaGraphic from "../wbw/RelaGraphic";
 import type { ITocPathNode } from "../../api/pali-text";
 import type { ArticleMode } from "../../api/Article";

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

@@ -87,20 +87,14 @@ export interface IAnswerCount {
 }
 
 const SuggestionBoxWidget = () => {
-  const [openNotification, setOpenNotification] = useState(false);
-  const [sentData, setSentData] = useState<ISentence>();
+  const [openNotification, setOpenNotification] = useState(
+    localStorage.getItem("read_pr_Notification") !== "ok"
+  );
   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);
-  }
+
+  // ✅ 直接派生,无需 useState + useEffect
+  const sentData =
+    discussionMessage?.type === "pr" ? discussionMessage.sent : undefined;
 
   return sentData ? (
     <Suggestion
@@ -110,8 +104,7 @@ const SuggestionBoxWidget = () => {
       onNotificationChange={(value: boolean) => setOpenNotification(value)}
     />
   ) : (
-    <></>
+    <>没有指定句子</>
   );
 };
-
 export default SuggestionBoxWidget;

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

@@ -7,7 +7,7 @@ import type { ISuggestionListResponse } from "../../api/Suggestion";
 
 import type { ISentence } from "../../api/sentence";
 import SentCell from "./SentCell";
-import type { IChannel } from "../../api/Channel";
+import type { IChannel } from "../../api/channel";
 interface IWidget {
   book: number;
   para: number;

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

@@ -24,7 +24,7 @@ import { get } from "../../request";
 import type {
   IApiResponseChannelData,
   IApiResponseChannelList,
-} from "../../api/Channel";
+} from "../../api/channel";
 import { grammarTermFetch } from "../../load";
 import TermModal from "./TermModal";
 import { popRecent, pushRecent } from "./utils";

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

@@ -18,7 +18,7 @@ import TermModal from "./TermModal";
 import { getSorterUrl } from "../../utils";
 import { useAppSelector } from "../../hooks";
 import { currentUser } from "../../reducers/current-user";
-import type { IChannel } from "../../api/Channel";
+import type { IChannel } from "../../api/channel";
 import DataImport from "../general/DataImport";
 import { Link } from "react-router";
 

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

@@ -22,7 +22,7 @@ import {
 import TermModal from "./TermModal";
 import TermEdit from "./TermEdit";
 import type { ITermDataResponse } from "../../api/Term";
-import type { IChannel } from "../../api/Channel";
+import type { IChannel } from "../../api/channel";
 
 const { Title, Text, Paragraph } = Typography;
 

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

@@ -3,7 +3,7 @@ import { Typography } from "antd";
 import { LikeOutlined, EyeOutlined } from "@ant-design/icons";
 import { Tiny } from "@ant-design/plots";
 
-import type { IChannelApiData } from "../../api/Channel";
+import type { IChannelApiData } from "../../api/channel";
 import ChannelListItem from "../channel/ChannelListItem";
 import TimeShow from "../general/TimeShow";
 import { useIntl } from "react-intl";

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

@@ -17,7 +17,7 @@ import { useIntl } from "react-intl";
 import type { BaseType } from "antd/lib/typography/Base";
 import type { IStudio, IUser } from "../../api/Auth";
 import type { TResType } from "../../api/discussion";
-import type { IChannel } from "../../api/Channel";
+import type { IChannel } from "../../api/channel";
 import StatusBadge from "../general/StatusBadge";
 
 const { Text } = Typography;

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

@@ -29,7 +29,7 @@ import {
   type TWbwDisplayMode,
   type WbwElement,
 } from "../../types/wbw";
-import type { IChannel, TChannelType } from "../../api/Channel";
+import type { IChannel, TChannelType } from "../../api/channel";
 import type { ArticleMode } from "../../api/Article";
 import type { ISentenceWbwListResponse } from "../../api/sentence";
 import type { IStudio } from "../../api/Auth";

+ 78 - 0
dashboard-v6/src/features/editor/Article.tsx

@@ -0,0 +1,78 @@
+// ─────────────────────────────────────────────
+// Props
+// ─────────────────────────────────────────────
+
+import type { ArticleMode } from "../../api/Article";
+import AnthologyTocTree from "../../components/anthology/AnthologyTocTree";
+import TypeArticle from "../../components/article/TypeArticle";
+import Editor from "../../components/editor";
+
+export interface ArticleEditorProps {
+  articleId?: string;
+  anthologyId?: string;
+  /** 来自 query param "anthology"(无 anthologyId 路由参数时的备用) */
+  anthology?: string | null;
+  mode?: ArticleMode;
+  channelId?: string | null;
+
+  // ── 路由事件回调(由 page 层处理导航)──
+  /** 点击目录树中的文章时触发 */
+  onArticleClick?: (
+    anthologyId: string,
+    articleId: string,
+    target?: string
+  ) => void;
+  /** 选择了新的 anthology 时触发 */
+  onAnthologySelect?: (anthologyId: string) => void;
+  /** 文章内部触发跳转(type: 'article' | 'anthology' 等) */
+  onArticleChange?: (type: string, id: string) => void;
+}
+
+// ─────────────────────────────────────────────
+// Component
+// ─────────────────────────────────────────────
+
+export default function ArticleEditor({
+  articleId,
+  anthologyId,
+  anthology,
+  mode = "read",
+  channelId,
+  onArticleClick,
+  onAnthologySelect,
+  onArticleChange,
+}: ArticleEditorProps) {
+  const channels = channelId ? channelId.split("_") : undefined;
+
+  return (
+    <Editor
+      sidebarTitle="table of content"
+      sidebar={
+        anthologyId ? (
+          <AnthologyTocTree
+            anthologyId={anthologyId}
+            channels={channels}
+            onClick={(tocAnthology, article, target) => {
+              onArticleClick?.(tocAnthology, article, target);
+            }}
+          />
+        ) : undefined
+      }
+      articleId={articleId}
+      anthologyId={anthologyId}
+      channelId={channelId}
+    >
+      {({ expandButton }) => (
+        <TypeArticle
+          articleId={articleId}
+          mode={mode}
+          anthologyId={anthologyId ?? anthology ?? undefined}
+          channelId={channelId}
+          headerExtra={expandButton}
+          onAnthologySelect={onAnthologySelect}
+          onArticleChange={onArticleChange}
+        />
+      )}
+    </Editor>
+  );
+}

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

@@ -1,128 +1,48 @@
 import { useNavigate, useParams, useSearchParams } from "react-router";
-import TypeArticle from "../../../components/article/TypeArticle";
-import SplitLayout, {
-  type RightToolbarTab,
-} from "../../../components/general/SplitLayout";
 import type { ArticleMode } from "../../../api/Article";
-import AnthologyTocTree from "../../../components/anthology/AnthologyTocTree";
-
-import {
-  BugOutlined,
-  SearchOutlined,
-  CommentOutlined,
-} from "@ant-design/icons";
-import DictComponent from "../../../components/dict/DictComponent";
+import ArticleEditor from "../../../features/editor/Article";
 
 const Widget = () => {
   const { articleId, anthologyId } = useParams();
   const [searchParams] = useSearchParams();
   const navigate = useNavigate();
+
   const mode = searchParams.get("mode") ?? "read";
   const channelId = searchParams.get("channel");
   const anthology = searchParams.get("anthology");
 
-  // ─────────────────────────────────────────────
-  // 右边栏 tabs 配置
-  // ─────────────────────────────────────────────
-
-  const rightTabs: RightToolbarTab[] = [
-    {
-      key: "dict",
-      icon: <SearchOutlined />,
-      label: "字典",
-      content: (
-        <div className="dict_component">
-          <DictComponent />
-        </div>
-      ),
-    },
-    {
-      key: "search",
-      icon: <CommentOutlined />,
-      label: "搜索",
-      content: (
-        <div style={{ padding: 16 }}>
-          <div style={{ marginTop: 12 }}></div>
-        </div>
-      ),
-    },
-    {
-      key: "debug",
-      icon: <BugOutlined />,
-      label: "调试",
-      content: (
-        <div style={{ padding: 16 }}>
-          <pre
-            style={{
-              marginTop: 8,
-              fontSize: 12,
-              background: "var(--ant-color-fill-quaternary, #f5f5f5)",
-              borderRadius: 6,
-              padding: 12,
-              overflow: "auto",
-            }}
-          >
-            {JSON.stringify(
-              { env: "production", workers: 3, status: "running" },
-              null,
-              2
-            )}
-          </pre>
-        </div>
-      ),
-    },
-  ];
-
   return (
-    <SplitLayout
-      key="mode-a"
-      sidebarTitle="table of content"
-      sidebar={
-        anthologyId ? (
-          <AnthologyTocTree
-            anthologyId={anthologyId}
-            channels={channelId ? channelId.split("_") : undefined}
-            onClick={(anthology, article, target) => {
-              if (target && target === "_blank") {
-                window.open(
-                  `${window.location.origin}${import.meta.env.BASE_URL}workspace/anthology/${anthology}/${article}`,
-                  "_blank"
-                );
-              } else {
-                navigate(`/workspace/anthology/${anthology}/${article}`);
-              }
-            }}
-          />
-        ) : (
-          <></>
-        )
-      }
-      rightTabs={rightTabs}
-    >
-      {({ expandButton }) => (
-        <TypeArticle
-          articleId={articleId}
-          mode={mode as ArticleMode}
-          anthologyId={anthologyId ?? anthology}
-          channelId={channelId}
-          headerExtra={expandButton}
-          onAnthologySelect={(id) => {
-            navigate(`/workspace/anthology/${id}/${articleId}`);
-          }}
-          onArticleChange={(type, id) => {
-            if (anthologyId) {
-              if (type === "article") {
-                navigate(`/workspace/anthology/${anthologyId}/${id}`);
-              } else {
-                navigate(`/workspace/${type}/${id}`);
-              }
-            } else {
-              navigate(`/workspace/${type}/${id}`);
-            }
-          }}
-        />
-      )}
-    </SplitLayout>
+    <ArticleEditor
+      articleId={articleId}
+      anthologyId={anthologyId}
+      anthology={anthology}
+      mode={mode as ArticleMode}
+      channelId={channelId}
+      onArticleClick={(tocAnthology, article, target) => {
+        if (target === "_blank") {
+          window.open(
+            `${window.location.origin}${import.meta.env.BASE_URL}workspace/anthology/${tocAnthology}/${article}`,
+            "_blank"
+          );
+        } else {
+          navigate(`/workspace/anthology/${tocAnthology}/${article}`);
+        }
+      }}
+      onAnthologySelect={(id) => {
+        navigate(`/workspace/anthology/${id}/${articleId}`);
+      }}
+      onArticleChange={(type, id) => {
+        if (anthologyId) {
+          if (type === "article") {
+            navigate(`/workspace/anthology/${anthologyId}/${id}`);
+          } else {
+            navigate(`/workspace/${type}/${id}`);
+          }
+        } else {
+          navigate(`/workspace/${type}/${id}`);
+        }
+      }}
+    />
   );
 };
 

+ 1 - 1
dashboard-v6/src/pages/workspace/channel/list.tsx

@@ -1,7 +1,7 @@
 import { useNavigate } from "react-router";
 
 import ChannelTable from "../../../components/channel/ChannelTable";
-import type { IChannel } from "../../../api/Channel";
+import type { IChannel } from "../../../api/channel";
 import { useAppSelector } from "../../../hooks";
 import { currentUser } from "../../../reducers/current-user";
 

+ 1 - 1
dashboard-v6/src/pages/workspace/channel/setting.tsx

@@ -11,7 +11,7 @@ import Edit from "../../../components/channel/Edit";
 import WebhookList from "../../../components/webhook/WebhookList";
 import WebhookEdit from "../../../components/webhook/WebhookEdit";
 import { EResType } from "../../../components/share/utils";
-import type { IApiResponseChannelData } from "../../../api/Channel";
+import type { IApiResponseChannelData } from "../../../api/channel";
 import { useAppSelector } from "../../../hooks";
 import { currentUser } from "../../../reducers/current-user";
 

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

@@ -12,7 +12,7 @@ import ShareModal from "../../../components/share/ShareModal";
 import { fullUrl } from "../../../utils";
 import type { IArticleParam } from "../../../types/article";
 import { EResType } from "../../../components/share/utils";
-import type { IApiResponseChannel } from "../../../api/Channel";
+import type { IApiResponseChannel } from "../../../api/channel";
 
 const Widget = () => {
   const { channelId } = useParams(); //url 参数