visuddhinanda 1 mese fa
parent
commit
62df515127
23 ha cambiato i file con 2142 aggiunte e 77 eliminazioni
  1. 1 0
      dashboard-v6/backup/components/task/TaskBuilderChapter.tsx
  2. 7 24
      dashboard-v6/documents/development/frontend-standards.md
  3. 19 3
      dashboard-v6/src/Router.tsx
  4. 36 0
      dashboard-v6/src/api/Article.ts
  5. 257 0
      dashboard-v6/src/components/article/TypePali.tsx
  6. 298 0
      dashboard-v6/src/components/article/TypePaliTest.tsx
  7. 31 7
      dashboard-v6/src/components/article/TypeTerm.tsx
  8. 17 0
      dashboard-v6/src/components/article/components/ArticleHeader.tsx
  9. 6 32
      dashboard-v6/src/components/article/components/ArticleLayout.tsx
  10. 431 0
      dashboard-v6/src/components/article/components/EditableTree.tsx
  11. 66 0
      dashboard-v6/src/components/article/components/EditableTreeNode.tsx
  12. 292 0
      dashboard-v6/src/components/article/components/EditableTreeTest.tsx
  13. 80 0
      dashboard-v6/src/components/article/components/Navigate.tsx
  14. 134 0
      dashboard-v6/src/components/article/components/NavigateButton.tsx
  15. 215 0
      dashboard-v6/src/components/article/components/TocTree.tsx
  16. 5 8
      dashboard-v6/src/components/article/hooks/useTerm.ts
  17. 29 0
      dashboard-v6/src/components/task/TaskBuilderChapterModal.tsx
  18. 8 0
      dashboard-v6/src/components/term/TermList.tsx
  19. 1 0
      dashboard-v6/src/components/tpl-builder/TplBuilder.tsx
  20. 165 0
      dashboard-v6/src/hooks/useTipitaka.ts
  21. 11 3
      dashboard-v6/src/pages/workspace/term/edit.tsx
  22. 19 0
      dashboard-v6/src/pages/workspace/term/show.tsx
  23. 14 0
      dashboard-v6/src/routes/testRoutes.tsx

+ 1 - 0
dashboard-v6/backup/components/task/TaskBuilderChapter.tsx

@@ -76,6 +76,7 @@ export const TaskBuilderChapterModal = ({
     </>
   );
 };
+
 type NotificationType = "success" | "info" | "warning" | "error";
 interface IWidget {
   studioName?: string;

+ 7 - 24
dashboard-v6/documents/development/frontend-standards.md

@@ -105,7 +105,6 @@ components/VideoPlayer/core/
 
 ```text
 api 层              抛出结构化错误,不处理 UI
-QueryClient 全局    处理通用错误(401 / 403 / 500)
 hook onError        处理业务特定错误(该 feature 内有特殊含义的错误码)
 组件层 try/catch    处理仅影响当前组件交互的错误(如表单校验)
 ```
@@ -115,27 +114,12 @@ hook onError        处理业务特定错误(该 feature 内有特殊含义的
 ```text
 收到错误
   ├── 所有页面都一样处理?(401/403/500)
-  │     → QueryClient 全局 onError
+  │
   └── 只在这个业务场景特殊处理?
         ├── 影响整个 feature 的逻辑  → hook 的 onError
         └── 只影响当前组件交互       → 组件内 try/catch
 ```
 
-**全局错误处理示例:**
-
-```ts
-const queryClient = new QueryClient({
-  queryCache: new QueryCache({
-    onError: (error) => {
-      if (error.code === 403) notification.error({ message: "权限不足" });
-      if (error.code === 401) authStore.logout();
-      if (error.code >= 500) notification.error({ message: "服务器异常" });
-      // 其他 code 不处理,交给业务层
-    },
-  }),
-});
-```
-
 **⚠️ antd v6 notification 问题**:`App.useApp()` 取到的实例无法在 QueryClient 回调中直接使用,需挂载单例:
 
 ```ts
@@ -183,13 +167,12 @@ useEffect(() => {
 
 ## 推荐技术栈
 
-| 职责         | 方案                                |
-| ------------ | ----------------------------------- |
-| 异步状态管理 | TanStack Query v5                   |
-| 全局同步状态 | Zustand                             |
-| 路由         | React Router v7                     |
-| HTTP 客户端  | Axios(拦截器统一处理 token、错误) |
-| UI 组件库    | Ant Design v6                       |
+| 职责         | 方案                |
+| ------------ | ------------------- |
+| 全局同步状态 | redux               |
+| 路由         | React Router v7     |
+| HTTP 客户端  | fetch token、错误) |
+| UI 组件库    | Ant Design v6       |
 
 ---
 

+ 19 - 3
dashboard-v6/src/Router.tsx

@@ -31,6 +31,7 @@ const WorkspaceHome = lazy(() => import("./pages/workspace/home"));
 const WorkspaceChat = lazy(() => import("./pages/workspace/chat"));
 
 const WorkspaceTerm = lazy(() => import("./pages/workspace/term/list"));
+const WorkspaceTermShow = lazy(() => import("./pages/workspace/term/show"));
 const WorkspaceTermEdit = lazy(() => import("./pages/workspace/term/edit"));
 
 // ↓ 新增:TestLayout
@@ -149,7 +150,16 @@ const router = createBrowserRouter(
               children: [
                 {
                   path: "article",
-                  children: [{ path: ":id" }],
+                  children: [
+                    {
+                      path: ":id",
+                      children: [
+                        {
+                          path: "edit",
+                        },
+                      ],
+                    },
+                  ],
                 },
                 {
                   path: "anthology",
@@ -173,11 +183,17 @@ const router = createBrowserRouter(
                 },
                 {
                   path: "wiki",
+                  handle: { crumb: "wiki" },
                   children: [
                     {
                       path: ":id",
-                      Component: WorkspaceTermEdit,
-                      children: [{ index: true, Component: WorkspaceTermEdit }],
+                      children: [
+                        { index: true, Component: WorkspaceTermShow },
+                        {
+                          path: "edit",
+                          Component: WorkspaceTermEdit,
+                        },
+                      ],
                     },
                   ],
                 },

+ 36 - 0
dashboard-v6/src/api/Article.ts

@@ -1,5 +1,6 @@
 import type { IStudio, IStudioApiResponse, IUser, TRole } from "./Auth";
 import type { IChannel } from "./Channel";
+import { get } from "../request";
 import type { ITocPathNode } from "./pali-text";
 
 export type TContentType = "text" | "markdown" | "html" | "json";
@@ -276,3 +277,38 @@ export interface IArticleFtsListResponse {
     page: { size: number; current: number; total: number };
   };
 }
+
+// src/api/Article.ts 新增部分
+
+export const fetchChapterArticle = (
+  articleId: string,
+  mode: "read" | "edit",
+  channelId?: string | null
+): Promise<IArticleResponse> => {
+  let url = `/api/v2/corpus-chapter/${articleId}?mode=${mode}`;
+  if (channelId) url += `&channels=${channelId}`;
+  return get<IArticleResponse>(url);
+};
+
+export const fetchParaArticle = (
+  book: string,
+  para: string,
+  mode: "read" | "edit",
+  channelId?: string | null
+): Promise<IArticleResponse> => {
+  let url = `/api/v2/corpus?view=para&book=${book}&par=${para}&mode=${mode}`;
+  if (channelId) url += `&channels=${channelId}`;
+  return get<IArticleResponse>(url);
+};
+
+export const fetchNextParaChunk = (
+  paraId: string,
+  mode: string,
+  from: number,
+  to: number,
+  channelId?: string | null
+): Promise<IArticleResponse> => {
+  let url = `/api/v2/corpus-chapter/${paraId}?mode=${mode}&from=${from}&to=${to}`;
+  if (channelId) url += `&channels=${channelId}`;
+  return get<IArticleResponse>(url);
+};

+ 257 - 0
dashboard-v6/src/components/article/TypePali.tsx

@@ -0,0 +1,257 @@
+// src/features/TypePaliWidget.tsx
+
+import { Divider, Dropdown, Button, Space, Tag } from "antd";
+import { MoreOutlined } from "@ant-design/icons";
+import { useEffect } from "react";
+import type {
+  ArticleMode,
+  ArticleType,
+  IArticleDataResponse,
+} from "../../api/Article";
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
+import store from "../../store";
+import { refresh as focusRefresh } from "../../reducers/focus";
+import useTipitaka from "../../hooks/useTipitaka";
+
+import ArticleLayout from "./components/ArticleLayout";
+
+import { useState } from "react";
+import type { ITocPathNode } from "../../api/pali-text";
+import TocTree from "./components/TocTree";
+import PaliText from "../general/PaliText";
+import Navigate from "./components/Navigate";
+import TplBuilder from "../tpl-builder/TplBuilder";
+import ArticleHeader from "./components/ArticleHeader";
+import { TaskBuilderChapterModal } from "../task/TaskBuilderChapterModal";
+
+interface ISearchParams {
+  key: string;
+  value: string;
+}
+
+interface IWidget {
+  type?: ArticleType;
+  id?: string;
+  mode?: ArticleMode | null;
+  channelId?: string | null;
+  book?: string | null;
+  para?: string | null;
+  active?: boolean;
+  focus?: string | null;
+  hideNav?: boolean;
+  onArticleChange?: (
+    type: ArticleType,
+    id: string,
+    target: string,
+    param?: ISearchParams[]
+  ) => void;
+  onLoad?: (data: IArticleDataResponse) => void;
+  onTitle?: (title: string) => void;
+}
+
+const TypePali = ({
+  type,
+  id,
+  mode = "read",
+  channelId,
+  book,
+  para,
+  active = true,
+  focus,
+  hideNav = false,
+  onArticleChange,
+}: IWidget) => {
+  const user = useAppSelector(currentUser);
+  const channels = channelId?.split("_");
+
+  const [taskBuilderModalOpen, setTaskBuilderModalOpen] = useState(false);
+  const [tplOpen, setTplOpen] = useState(false);
+
+  const {
+    articleData,
+    articleHtml,
+    toc,
+    loading,
+    errorCode,
+    remains,
+    loadNextChunk,
+  } = useTipitaka({ type, id, mode, channelId, book, para, active });
+
+  // focus 副作用保留在 feature 层,因为它与 Redux store 耦合属于业务交互
+  useEffect(() => {
+    const parts = focus?.split("-");
+    if (parts?.length === 2) {
+      store.dispatch(focusRefresh({ type: "para", id: focus }));
+    } else if (parts?.length === 4) {
+      store.dispatch(focusRefresh({ type: "sentence", id: focus }));
+    }
+  }, [focus]);
+
+  // 派生展示数据
+  let title = "";
+  if (articleData) {
+    if (type === "chapter") {
+      title = articleData.title_text ?? articleData.title;
+    } else {
+      const chapterId = id?.split("-");
+      title = chapterId
+        ? chapterId.length > 1
+          ? chapterId[1]
+          : "unknown"
+        : "unknown";
+    }
+  }
+
+  let mBook = "0",
+    mPara = "0";
+  if (typeof id === "string") {
+    [mBook, mPara] = id.split("-");
+  }
+
+  let fullPath: ITocPathNode[] = [];
+  if (articleData?.path && articleData.path.length > 0) {
+    const currNode: ITocPathNode = {
+      book: parseInt(mBook),
+      paragraph: parseInt(mPara),
+      title: title ?? "",
+      level: articleData.path[articleData.path.length - 1].level + 1,
+    };
+    fullPath = [...articleData.path, currNode];
+  }
+  /*
+  const handlePathChange = (
+    node: ITocPathNode,
+    e: React.MouseEvent<HTMLSpanElement | HTMLAnchorElement, MouseEvent>
+  ) => {
+    let newType = type;
+    let newArticle = "";
+    if (node.level === 0) {
+      newType = "series";
+      newArticle = node.title;
+    } else {
+      newType = "chapter";
+      newArticle = node.key ? node.key : `${node.book}-${node.paragraph}`;
+    }
+    const target = e.ctrlKey || e.metaKey ? "_blank" : "self";
+    onArticleChange?.(newType, newArticle, target);
+  };
+*/
+  return (
+    <div>
+      <TaskBuilderChapterModal
+        studioName={user?.realName}
+        book={parseInt(mBook ?? "0")}
+        para={parseInt(mPara ?? "0")}
+        channels={channels}
+        open={taskBuilderModalOpen}
+        onClose={() => setTaskBuilderModalOpen(false)}
+      />
+      <TplBuilder
+        title={title}
+        tpl="chapter"
+        articleId={id}
+        channelsId={channelId}
+        open={tplOpen}
+        onClose={() => setTplOpen(false)}
+      />
+
+      <div></div>
+      <ArticleHeader
+        action={
+          <Dropdown
+            menu={{
+              items: [
+                { key: "tpl", label: "获取模板" },
+                { key: "task", label: "生成任务" },
+              ],
+              onClick: ({ key }) => {
+                if (key === "task") setTaskBuilderModalOpen(true);
+                if (key === "tpl") setTplOpen(true);
+              },
+            }}
+            placement="bottomRight"
+          >
+            <Button shape="circle" size="small" icon={<MoreOutlined />} />
+          </Dropdown>
+        }
+      />
+      <ArticleLayout
+        title={title}
+        subTitle={articleData?.subtitle}
+        summary={articleData?.summary}
+        content={articleData?.content}
+        html={articleHtml}
+        loading={loading}
+        errorCode={errorCode}
+        remains={remains}
+        onEnd={() => {
+          if (type === "chapter") loadNextChunk();
+        }}
+      />
+
+      <Divider />
+
+      <TocTree
+        treeData={toc?.map((item) => {
+          const strTitle = item.title ?? item.pali_title;
+          const key = item.key ?? `${item.book}-${item.paragraph}`;
+          const progress = item.progress?.map((p, id) => (
+            <Tag key={id}>{Math.round(p * 100) + "%"}</Tag>
+          ));
+          return {
+            key,
+            title: (
+              <Space>
+                <PaliText text={strTitle === "" ? "[unnamed]" : strTitle} />
+                {progress}
+              </Space>
+            ),
+            level: item.level,
+          };
+        })}
+        onClick={(
+          id: string,
+          e: React.MouseEvent<HTMLSpanElement, MouseEvent>
+        ) => {
+          if (e.ctrlKey || e.metaKey) {
+            onArticleChange?.("chapter", id, "_blank");
+          } else {
+            onArticleChange?.("chapter", id, "_self");
+          }
+        }}
+      />
+
+      <Divider />
+
+      {!hideNav && (
+        <Navigate
+          type={type}
+          articleId={id}
+          path={fullPath}
+          onPathChange={(key: string) => {
+            const node = articleData?.path?.find((v) => v.title === key);
+            if (node) {
+              const newType = node.level === 0 ? "series" : "chapter";
+              const newArticle = node.key ?? `${node.book}-${node.paragraph}`;
+              onArticleChange?.(newType, newArticle, "self");
+            }
+          }}
+          onChange={(
+            event: React.MouseEvent<HTMLElement, MouseEvent>,
+            newId: string
+          ) => {
+            const target = event.ctrlKey || event.metaKey ? "_blank" : "_self";
+            let param: ISearchParams[] = [];
+            if (type === "para" && newId?.split("-").length > 1) {
+              param = [{ key: "par", value: newId.split("-")[1] }];
+            }
+            onArticleChange?.(type as ArticleType, newId, target, param);
+          }}
+        />
+      )}
+    </div>
+  );
+};
+
+export default TypePali;

+ 298 - 0
dashboard-v6/src/components/article/TypePaliTest.tsx

@@ -0,0 +1,298 @@
+// TypePaliTestPage.tsx
+// 用于测试 TypePali 组件的调试页面
+// 颜色完全跟随 antd 主题(通过 theme.useToken()),支持亮/暗主题切换
+
+import { useState } from "react";
+import {
+  Card,
+  Form,
+  Input,
+  Select,
+  Button,
+  Space,
+  Divider,
+  Tag,
+  Typography,
+  theme,
+} from "antd";
+import { PlayCircleOutlined, ReloadOutlined } from "@ant-design/icons";
+import TypePali from "./TypePali"; // 根据实际路径调整
+import type { ArticleMode, ArticleType } from "../../api/Article";
+
+const { Title, Text } = Typography;
+const { useToken } = theme;
+
+interface TestParams {
+  type: ArticleType;
+  id: string;
+  mode: ArticleMode;
+  channelId: string;
+  book: string;
+  para: string;
+  focus: string;
+}
+
+const DEFAULT_PARAMS: TestParams = {
+  type: "chapter",
+  id: "",
+  mode: "read",
+  channelId: "",
+  book: "",
+  para: "",
+  focus: "",
+};
+
+const TypePaliTestPage = () => {
+  const { token } = useToken();
+  const [form] = Form.useForm<TestParams>();
+  const [activeParams, setActiveParams] = useState<TestParams | null>(null);
+  const [runCount, setRunCount] = useState(0);
+
+  const handleRun = () => {
+    const values = form.getFieldsValue();
+    setActiveParams({ ...values });
+    setRunCount((c) => c + 1);
+  };
+
+  const handleReset = () => {
+    form.resetFields();
+    setActiveParams(null);
+    setRunCount(0);
+  };
+
+  return (
+    <div
+      style={{
+        minHeight: "100vh",
+        background: token.colorBgLayout,
+        color: token.colorText,
+        padding: token.paddingLG,
+      }}
+    >
+      {/* Header */}
+      <div style={{ marginBottom: token.marginXL }}>
+        <Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
+          Dev Harness
+        </Text>
+        <Title level={3} style={{ margin: 0 }}>
+          TypePali Widget Testbed
+        </Title>
+      </div>
+
+      <div
+        style={{
+          display: "grid",
+          gridTemplateColumns: "320px 1fr",
+          gap: token.marginLG,
+          alignItems: "start",
+        }}
+      >
+        {/* Control Panel */}
+        <Card
+          size="small"
+          title={
+            <Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
+              Parameters
+            </Text>
+          }
+          style={{ position: "sticky", top: token.marginLG }}
+        >
+          <Form
+            form={form}
+            layout="vertical"
+            initialValues={DEFAULT_PARAMS}
+            size="small"
+          >
+            <Form.Item
+              label="type"
+              name="type"
+              style={{ marginBottom: token.marginSM }}
+            >
+              <Select
+                options={[
+                  { value: "chapter", label: "chapter" },
+                  { value: "para", label: "para" },
+                ]}
+              />
+            </Form.Item>
+
+            <Form.Item
+              label="id"
+              name="id"
+              style={{ marginBottom: token.marginSM }}
+            >
+              <Input placeholder="e.g. 12-34" />
+            </Form.Item>
+
+            <Form.Item
+              label="mode"
+              name="mode"
+              style={{ marginBottom: token.marginSM }}
+            >
+              <Select
+                options={[
+                  { value: "read", label: "read" },
+                  { value: "edit", label: "edit" },
+                ]}
+              />
+            </Form.Item>
+
+            <Form.Item
+              label="channelId"
+              name="channelId"
+              style={{ marginBottom: token.marginSM }}
+            >
+              <Input placeholder="e.g. ch1_ch2" />
+            </Form.Item>
+
+            <Form.Item
+              label="book"
+              name="book"
+              style={{ marginBottom: token.marginSM }}
+            >
+              <Input placeholder="e.g. 12" />
+            </Form.Item>
+
+            <Form.Item
+              label="para"
+              name="para"
+              style={{ marginBottom: token.marginSM }}
+            >
+              <Input placeholder="e.g. 34" />
+            </Form.Item>
+
+            <Form.Item
+              label="focus"
+              name="focus"
+              style={{ marginBottom: token.margin }}
+            >
+              <Input placeholder="e.g. 12-34 or 1-2-3-4" />
+            </Form.Item>
+
+            <Space
+              style={{ width: "100%" }}
+              direction="vertical"
+              size={token.marginXS}
+            >
+              <Button
+                type="primary"
+                icon={<PlayCircleOutlined />}
+                onClick={handleRun}
+                block
+              >
+                Run Test
+              </Button>
+              <Button icon={<ReloadOutlined />} onClick={handleReset} block>
+                Reset
+              </Button>
+            </Space>
+          </Form>
+
+          {/* Active params snapshot */}
+          {activeParams && (
+            <>
+              <Divider style={{ margin: `${token.margin}px 0` }} />
+              <Text
+                type="secondary"
+                style={{
+                  fontSize: token.fontSizeSM,
+                  display: "block",
+                  marginBottom: token.marginXS,
+                }}
+              >
+                Active — Run #{runCount}
+              </Text>
+              <div
+                style={{
+                  background: token.colorFillQuaternary,
+                  borderRadius: token.borderRadiusSM,
+                  padding: `${token.paddingXS}px ${token.paddingSM}px`,
+                  fontSize: token.fontSizeSM,
+                  lineHeight: 2,
+                  border: `1px solid ${token.colorBorderSecondary}`,
+                }}
+              >
+                {Object.entries(activeParams).map(([k, v]) =>
+                  v ? (
+                    <div
+                      key={k}
+                      style={{ display: "flex", alignItems: "center", gap: 6 }}
+                    >
+                      <Text
+                        type="secondary"
+                        style={{ fontSize: token.fontSizeSM, minWidth: 72 }}
+                      >
+                        {k}:
+                      </Text>
+                      <Tag color="processing" style={{ margin: 0 }}>
+                        {v}
+                      </Tag>
+                    </div>
+                  ) : null
+                )}
+              </div>
+            </>
+          )}
+        </Card>
+
+        {/* Render Area */}
+        <Card
+          size="small"
+          title={
+            <Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
+              Render Output
+            </Text>
+          }
+          style={{ minHeight: 400 }}
+        >
+          {!activeParams ? (
+            <div
+              style={{
+                display: "flex",
+                flexDirection: "column",
+                alignItems: "center",
+                justifyContent: "center",
+                minHeight: 300,
+                gap: token.marginSM,
+              }}
+            >
+              <div
+                style={{
+                  fontSize: 36,
+                  lineHeight: 1,
+                  color: token.colorTextDisabled,
+                }}
+              >
+                ◎
+              </div>
+              <Text type="secondary">Set params and click Run Test</Text>
+            </div>
+          ) : (
+            <div key={runCount}>
+              <TypePali
+                type={activeParams.type || undefined}
+                id={activeParams.id || undefined}
+                mode={activeParams.mode || "read"}
+                channelId={activeParams.channelId || undefined}
+                book={activeParams.book || undefined}
+                para={activeParams.para || undefined}
+                focus={activeParams.focus || undefined}
+                onArticleChange={(type, id, target, param) => {
+                  console.log("[onArticleChange]", { type, id, target, param });
+                }}
+                onLoad={(data) => {
+                  console.log("[onLoad]", data);
+                }}
+                onTitle={(title) => {
+                  console.log("[onTitle]", title);
+                }}
+              />
+            </div>
+          )}
+        </Card>
+      </div>
+    </div>
+  );
+};
+
+export default TypePaliTestPage;

+ 31 - 7
dashboard-v6/src/components/article/TypeTerm.tsx

@@ -1,6 +1,10 @@
+import { Breadcrumb, Button } from "antd";
 import type { ArticleMode } from "../../api/Article";
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
 
 import "./article.css";
+import ArticleHeader from "./components/ArticleHeader";
 import ArticleLayout from "./components/ArticleLayout";
 import { useTerm } from "./hooks/useTerm";
 
@@ -8,29 +12,49 @@ interface IWidget {
   id?: string;
   mode?: ArticleMode | null;
   channelId?: string | null;
+  onEdit?: () => void;
 }
 
-const TypeTermWidget = ({ channelId, id, mode = "read" }: IWidget) => {
-  const { articleData, articleHtml, errorCode, loading } = useTerm({
+const TypeTermWidget = ({ channelId, id, mode = "read", onEdit }: IWidget) => {
+  const { articleData, term, errorCode, loading } = useTerm({
     id,
     channelId,
     mode,
   });
+  const currUser = useAppSelector(currentUser);
 
-  const channels = channelId?.split("_");
-
+  const path = [
+    { title: currUser?.nickName },
+    { title: term?.channel?.name ?? "通用" },
+    { title: term?.word },
+  ];
   return (
     <div>
+      <title>{articleData?.title}-百科</title>
+      <ArticleHeader
+        header={
+          <Breadcrumb
+            items={path}
+            style={{
+              whiteSpace: "nowrap",
+              width: "100%",
+            }}
+          />
+        }
+        action={
+          <Button type="primary" onClick={onEdit}>
+            Edit
+          </Button>
+        }
+      />
       <ArticleLayout
         title={articleData?.title}
         subTitle={articleData?.subtitle}
         content={articleData?.content ?? ""}
-        html={articleHtml}
-        path={articleData?.path}
+        html={[articleData?.html ?? ""]}
         editor={articleData?.editor}
         created_at={articleData?.created_at}
         updated_at={articleData?.updated_at}
-        channels={channels}
         loading={loading}
         errorCode={errorCode}
       />

+ 17 - 0
dashboard-v6/src/components/article/components/ArticleHeader.tsx

@@ -0,0 +1,17 @@
+import type React from "react";
+
+interface IWidget {
+  header?: React.ReactNode;
+  action?: React.ReactNode;
+}
+const ArticleHeader = ({ header, action }: IWidget) => {
+  return (
+    <div style={{ display: "flex", justifyContent: "space-between" }}>
+      <div>{header}</div>
+      {/**action */}
+      <div>{action}</div>
+    </div>
+  );
+};
+
+export default ArticleHeader;

+ 6 - 32
dashboard-v6/src/components/article/components/ArticleLayout.tsx

@@ -1,7 +1,5 @@
 import { Typography, Divider, Skeleton, Space } from "antd";
-import type { ITocPathNode } from "../../../api/pali-text";
 import type { IStudio, IUser } from "../../../api/Auth";
-import TocPath from "../../tipitaka/TocPath";
 import VisibleObserver from "../../general/VisibleObserver";
 import MdView from "../../general/MdView";
 import type { JSX } from "react";
@@ -10,34 +8,32 @@ import ErrorResult from "../../general/ErrorResult";
 import User from "../../auth/User";
 
 const { Paragraph, Title, Text } = Typography;
+
+export interface IPath {
+  title: JSX.Element | string;
+}
 export interface IFirstAnthology {
   id: string;
   title: string;
   count: number;
 }
-export interface IWidgetArticleData {
+export interface IArticleLayout {
   title?: string;
   subTitle?: string;
   summary?: string | null;
   content?: string;
   html?: string[];
-  path?: ITocPathNode[];
   resList?: JSX.Element;
   created_at?: string;
   updated_at?: string;
   editor?: IUser;
   owner?: IStudio;
-  channels?: string[];
   loading?: boolean;
   errorCode?: number;
   remains?: boolean;
   anthology?: IFirstAnthology;
   hideTitle?: boolean;
   onEnd?: () => void;
-  onPathChange?: (
-    node: ITocPathNode,
-    e: React.MouseEvent<HTMLSpanElement | HTMLAnchorElement, MouseEvent>
-  ) => void;
 }
 
 const ArticleLayout = ({
@@ -46,18 +42,15 @@ const ArticleLayout = ({
   summary,
   content,
   html = [],
-  path = [],
   editor,
   updated_at,
   resList,
-  channels,
   loading,
   errorCode,
   hideTitle,
   remains,
   onEnd,
-  onPathChange,
-}: IWidgetArticleData) => {
+}: IArticleLayout) => {
   console.log("ArticleViewWidget render");
 
   return (
@@ -69,25 +62,6 @@ const ArticleLayout = ({
       ) : (
         <div>
           <Space orientation="vertical">
-            {hideTitle ? (
-              <></>
-            ) : (
-              <TocPath
-                data={path}
-                channels={channels}
-                onChange={(
-                  node: ITocPathNode,
-                  e: React.MouseEvent<
-                    HTMLSpanElement | HTMLAnchorElement,
-                    MouseEvent
-                  >
-                ) => {
-                  if (typeof onPathChange !== "undefined") {
-                    onPathChange(node, e);
-                  }
-                }}
-              />
-            )}
             {hideTitle ? (
               <></>
             ) : (

+ 431 - 0
dashboard-v6/src/components/article/components/EditableTree.tsx

@@ -0,0 +1,431 @@
+import React, { useState, useCallback } from "react";
+import { message, Modal, Tree } from "antd";
+import type { DataNode, TreeProps } from "antd/es/tree";
+import type { Key } from "antd/lib/table/interface";
+import { DeleteOutlined, SaveOutlined } from "@ant-design/icons";
+import { FileAddOutlined, LinkOutlined } from "@ant-design/icons";
+import { Button, Divider, Space } from "antd";
+import { useIntl } from "react-intl";
+import { randomString } from "../../../utils";
+import EditableTreeNode from "./EditableTreeNode";
+
+export interface TreeNodeData {
+  key: string;
+  id: string;
+  title: string | React.ReactNode;
+  title_text?: string;
+  icon?: React.ReactNode;
+  children: TreeNodeData[];
+  status?: number;
+  deletedAt?: string | null;
+  level: number;
+}
+
+export type ListNodeData = {
+  key: string;
+  title: string | React.ReactNode;
+  title_text?: string;
+  level: number;
+  status?: number;
+  children?: number;
+  deletedAt?: string | null;
+};
+
+let tocActivePath: TreeNodeData[] = [];
+
+function tocGetTreeData(articles: ListNodeData[], active = "") {
+  const treeData = [];
+  const treeParents = [];
+
+  const rootNode: TreeNodeData = {
+    key: randomString(),
+    id: "0",
+    title: "root",
+    title_text: "root",
+    level: 0,
+    children: [],
+  };
+  treeData.push(rootNode);
+  let lastInsNode: TreeNodeData = rootNode;
+
+  let iCurrLevel = 0;
+  const keys: string[] = [];
+
+  for (let index = 0; index < articles.length; index++) {
+    const element = articles[index];
+
+    const newNode: TreeNodeData = {
+      key: randomString(),
+      id: element.key,
+      title: element.title,
+      title_text: element.title_text,
+      children: [],
+      icon: keys.includes(element.key) ? <LinkOutlined /> : undefined,
+      status: element.status,
+      level: element.level,
+      deletedAt: element.deletedAt,
+    };
+
+    if (!keys.includes(element.key)) {
+      keys.push(element.key);
+    }
+
+    if (newNode.level > iCurrLevel) {
+      treeParents.push(lastInsNode);
+      lastInsNode.children.push(newNode);
+    } else if (newNode.level === iCurrLevel) {
+      treeParents[treeParents.length - 1].children.push(newNode);
+    } else {
+      while (treeParents.length > 1) {
+        treeParents.pop();
+        if (treeParents[treeParents.length - 1].level < newNode.level) {
+          break;
+        }
+      }
+      treeParents[treeParents.length - 1].children.push(newNode);
+    }
+
+    lastInsNode = newNode;
+    iCurrLevel = newNode.level;
+
+    if (active === element.key) {
+      tocActivePath = [];
+      for (let i = 1; i < treeParents.length; i++) {
+        tocActivePath.push(treeParents[i]);
+      }
+    }
+  }
+
+  return treeData[0].children;
+}
+
+function treeToList(treeNode: TreeNodeData[]): ListNodeData[] {
+  let iTocTreeCurrLevel = 1;
+  const arrTocTree: ListNodeData[] = [];
+
+  for (const iterator of treeNode) {
+    getTreeNodeData(iterator);
+  }
+
+  function getTreeNodeData(node: TreeNodeData) {
+    const children = node.children?.length ?? 0;
+    arrTocTree.push({
+      key: node.id,
+      title: node.title,
+      title_text: node.title_text,
+      level: iTocTreeCurrLevel,
+      children,
+      deletedAt: node.deletedAt,
+    });
+    if (children > 0) {
+      iTocTreeCurrLevel++;
+      for (const iterator of node.children) {
+        getTreeNodeData(iterator);
+      }
+      iTocTreeCurrLevel--;
+    }
+  }
+
+  return arrTocTree;
+}
+
+interface IWidget {
+  initValue?: ListNodeData[];
+  value?: ListNodeData[];
+  addFileButton?: React.ReactNode;
+  addOnArticle?: TreeNodeData;
+  updatedNode?: TreeNodeData;
+  onChange?: (listTreeData?: ListNodeData[]) => void;
+  onSelect?: (selectedKeys: React.Key[]) => void;
+  onSave?: (listTreeData?: ListNodeData[]) => void;
+  onAppend?: (parent: TreeNodeData) => Promise<TreeNodeData>;
+  onTitleClick?: (
+    e: React.MouseEvent<HTMLElement, MouseEvent>,
+    node: TreeNodeData
+  ) => void;
+}
+
+const EditableTreeWidget = ({
+  initValue,
+  value,
+  addFileButton,
+  addOnArticle,
+  updatedNode,
+  onChange,
+  onSelect,
+  onSave,
+  onAppend,
+  onTitleClick,
+}: IWidget) => {
+  const intl = useIntl();
+  const isControlled = value !== undefined;
+
+  const [checkKeys, setCheckKeys] = useState<string[]>([]);
+  const [checkNodes, setCheckNodes] = useState<TreeNodeData[]>([]);
+
+  // 非受控模式的内部树数据
+  const [internalGData, setInternalGData] = useState<TreeNodeData[]>(() =>
+    tocGetTreeData(initValue ?? [])
+  );
+
+  // 用 state 存上一次的 prop 值,用于在 render 阶段对比变化
+  // 这是 React 官方文档推荐的派生 state 模式
+  // https://react.dev/reference/react/useState#storing-information-from-previous-renders
+  const [prevAddOnArticle, setPrevAddOnArticle] = useState<
+    TreeNodeData | undefined
+  >(undefined);
+  const [prevUpdatedNode, setPrevUpdatedNode] = useState<
+    TreeNodeData | undefined
+  >(undefined);
+
+  // 受控模式从 value 实时派生,非受控用内部 state
+  const gData = isControlled ? tocGetTreeData(value) : internalGData;
+
+  // 统一写入:非受控更新内部 state,始终触发 onChange
+  const applyChange = useCallback(
+    (newTree: TreeNodeData[]) => {
+      if (!isControlled) {
+        setInternalGData(newTree);
+      }
+      onChange?.(treeToList(newTree));
+    },
+    [isControlled, onChange]
+  );
+
+  // 处理 addOnArticle 变化:render 阶段对比 state,有变化则更新
+  if (addOnArticle !== undefined && prevAddOnArticle !== addOnArticle) {
+    setPrevAddOnArticle(addOnArticle);
+    const newTree = [...gData, addOnArticle];
+    if (!isControlled) {
+      setInternalGData(newTree);
+    }
+    onChange?.(treeToList(newTree));
+  }
+
+  // 处理 updatedNode 变化
+  if (updatedNode !== undefined && prevUpdatedNode !== updatedNode) {
+    setPrevUpdatedNode(updatedNode);
+    const newTree = [...gData];
+    const update = (_node: TreeNodeData[]) => {
+      _node.forEach((item, index, array) => {
+        if (item.id === updatedNode.id) {
+          array[index].title = updatedNode.title;
+          array[index].title_text = updatedNode.title_text;
+        } else {
+          update(array[index].children);
+        }
+      });
+    };
+    update(newTree);
+    if (!isControlled) {
+      setInternalGData(newTree);
+    }
+    onChange?.(treeToList(newTree));
+  }
+
+  const appendNode = (key: string, node: TreeNodeData) => {
+    const newTree = [...gData];
+    const append = (_node: TreeNodeData[]) => {
+      _node.forEach((item, index, array) => {
+        if (item.key === key) {
+          array[index].children.push(node);
+        } else {
+          append(array[index].children);
+        }
+      });
+    };
+    append(newTree);
+    applyChange(newTree);
+  };
+
+  const onCheck: TreeProps["onCheck"] = (checkedKeys, info) => {
+    setCheckKeys(checkedKeys as string[]);
+    setCheckNodes(info.checkedNodes as TreeNodeData[]);
+  };
+
+  const onDragEnter: TreeProps["onDragEnter"] = () => {
+    // expandedKeys 需要受控时在此设置
+  };
+
+  const onDrop: TreeProps["onDrop"] = (info) => {
+    const dropKey = info.node.key;
+    const dragKey = info.dragNode.key;
+    const dropPos = info.node.pos.split("-");
+    const dropPosition =
+      info.dropPosition - Number(dropPos[dropPos.length - 1]);
+
+    const loop = (
+      data: DataNode[],
+      key: React.Key,
+      callback: (node: DataNode, i: number, data: DataNode[]) => void
+    ) => {
+      for (let i = 0; i < data.length; i++) {
+        if (data[i].key === key) {
+          return callback(data[i], i, data);
+        }
+        if (data[i].children) {
+          loop(data[i].children!, key, callback);
+        }
+      }
+    };
+
+    const data = [...gData];
+    let dragObj: DataNode;
+
+    loop(data, dragKey, (item, index, arr) => {
+      arr.splice(index, 1);
+      dragObj = item;
+    });
+
+    if (!info.dropToGap) {
+      loop(data, dropKey, (item) => {
+        item.children = item.children || [];
+        item.children.unshift(dragObj);
+      });
+    } else if (
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      ((info.node as any).props.children || []).length > 0 &&
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      (info.node as any).props.expanded &&
+      dropPosition === 1
+    ) {
+      loop(data, dropKey, (item) => {
+        item.children = item.children || [];
+        item.children.unshift(dragObj);
+      });
+    } else {
+      let ar: DataNode[] = [];
+      let i: number;
+      loop(data, dropKey, (_item, index, arr) => {
+        ar = arr;
+        i = index;
+      });
+      if (dropPosition === -1) {
+        ar.splice(i!, 0, dragObj!);
+      } else {
+        ar.splice(i! + 1, 0, dragObj!);
+      }
+    }
+
+    applyChange(data);
+  };
+
+  return (
+    <>
+      <Space>
+        {addFileButton}
+        <Button
+          icon={<FileAddOutlined />}
+          onClick={async () => {
+            if (typeof onAppend !== "undefined") {
+              const newNode: TreeNodeData = await onAppend({
+                key: "",
+                id: "",
+                title: "",
+                children: [],
+                level: 0,
+              });
+              if (newNode) {
+                const newTree = [...gData, newNode];
+                applyChange(newTree);
+                return true;
+              } else {
+                message.error("添加失败");
+                return false;
+              }
+            }
+            return false;
+          }}
+        >
+          {intl.formatMessage({ id: "buttons.create" })}
+        </Button>
+        <Button
+          icon={<DeleteOutlined />}
+          danger
+          disabled={checkKeys.length === 0}
+          onClick={() => {
+            const delTree = (node: TreeNodeData[]): boolean => {
+              for (let index = 0; index < node.length; index++) {
+                if (checkKeys.includes(node[index].key)) {
+                  node.splice(index, 1);
+                  return true;
+                } else {
+                  const cf = delTree(node[index].children);
+                  if (cf) return cf;
+                }
+              }
+              return false;
+            };
+
+            Modal.confirm({
+              title: "从文集移除下列文章吗?(文章不会被删除)",
+              content: (
+                <>
+                  {checkNodes.map((item, id) => (
+                    <div key={id}>
+                      {id + 1} {item.title}
+                    </div>
+                  ))}
+                </>
+              ),
+              onOk() {
+                const tmp = [...gData];
+                delTree(tmp);
+                applyChange(tmp);
+              },
+            });
+          }}
+        >
+          {intl.formatMessage({ id: "buttons.remove" })}
+        </Button>
+        <Button
+          icon={<SaveOutlined />}
+          onClick={() => onSave?.(treeToList(gData))}
+          type="primary"
+        >
+          {intl.formatMessage({ id: "buttons.save" })}
+        </Button>
+      </Space>
+      <Divider />
+      <Tree
+        showLine
+        showIcon
+        checkable
+        rootClassName="draggable-tree"
+        draggable
+        blockNode
+        selectable={false}
+        onDragEnter={onDragEnter}
+        onDrop={onDrop}
+        onCheck={onCheck}
+        onSelect={(selectedKeys: Key[]) => {
+          onSelect?.(selectedKeys);
+        }}
+        treeData={gData}
+        titleRender={(node: TreeNodeData) => (
+          <EditableTreeNode
+            node={node}
+            onAdd={async () => {
+              if (typeof onAppend !== "undefined") {
+                const newNode = await onAppend(node);
+                if (newNode) {
+                  appendNode(node.key, newNode);
+                  return true;
+                } else {
+                  message.error("添加失败");
+                  return false;
+                }
+              }
+              return false;
+            }}
+            onTitleClick={(e: React.MouseEvent<HTMLElement, MouseEvent>) => {
+              onTitleClick?.(e, node);
+            }}
+          />
+        )}
+      />
+    </>
+  );
+};
+
+export default EditableTreeWidget;

+ 66 - 0
dashboard-v6/src/components/article/components/EditableTreeNode.tsx

@@ -0,0 +1,66 @@
+import { Button, message, Space, Typography } from "antd";
+import { useState } from "react";
+import { PlusOutlined } from "@ant-design/icons";
+
+import type { TreeNodeData } from "./EditableTree";
+const { Text } = Typography;
+
+interface IWidget {
+  node: TreeNodeData;
+  onAdd?: () => Promise<boolean>;
+  onTitleClick?: (e: React.MouseEvent<HTMLElement, MouseEvent>) => void;
+}
+const EditableTreeNodeWidget = ({ node, onAdd, onTitleClick }: IWidget) => {
+  const [showNodeMenu, setShowNodeMenu] = useState(false);
+  const [loading, setLoading] = useState(false);
+
+  const title = (
+    <Text type={node.status === 10 ? "secondary" : undefined}>
+      {node.title_text ? node.title_text : node.title}
+    </Text>
+  );
+
+  return (
+    <Space
+      onMouseEnter={() => setShowNodeMenu(true)}
+      onMouseLeave={() => setShowNodeMenu(false)}
+    >
+      {node.deletedAt ? (
+        <Text delete disabled>
+          {title}
+        </Text>
+      ) : (
+        <Text
+          onClick={(e: React.MouseEvent<HTMLElement, MouseEvent>) => {
+            if (typeof onTitleClick !== "undefined") {
+              onTitleClick(e);
+            }
+          }}
+        >
+          {title}
+        </Text>
+      )}
+
+      <Space style={{ visibility: showNodeMenu ? "visible" : "hidden" }}>
+        <Button
+          loading={loading}
+          size="middle"
+          icon={<PlusOutlined />}
+          type="text"
+          onClick={async () => {
+            if (typeof onAdd !== "undefined") {
+              setLoading(true);
+              const ok = await onAdd();
+              setLoading(false);
+              if (!ok) {
+                message.error("error");
+              }
+            }
+          }}
+        />
+      </Space>
+    </Space>
+  );
+};
+
+export default EditableTreeNodeWidget;

+ 292 - 0
dashboard-v6/src/components/article/components/EditableTreeTest.tsx

@@ -0,0 +1,292 @@
+import { useState } from "react";
+import { Card, Typography, Divider, Tag, Button } from "antd";
+import EditableTreeWidget, {
+  type ListNodeData,
+  type TreeNodeData,
+} from "./EditableTree";
+
+const { Title, Text, Paragraph } = Typography;
+
+const mockInitValue: ListNodeData[] = [
+  { key: "1", title: "第一章", title_text: "第一章", level: 1 },
+  { key: "2", title: "1.1 节", title_text: "1.1 节", level: 2 },
+  { key: "3", title: "1.2 节", title_text: "1.2 节", level: 2 },
+  { key: "4", title: "第二章", title_text: "第二章", level: 1 },
+  { key: "5", title: "2.1 节", title_text: "2.1 节", level: 2 },
+  { key: "6", title: "2.2 节", title_text: "2.2 节", level: 2 },
+  { key: "7", title: "2.2.1 小节", title_text: "2.2.1 小节", level: 3 },
+];
+
+// ─── 非受控模式 Demo ───────────────────────────────────────────────
+const UncontrolledDemo = () => {
+  const [log, setLog] = useState<string[]>([]);
+
+  const addLog = (msg: string) =>
+    setLog((prev) => [
+      `[${new Date().toLocaleTimeString()}] ${msg}`,
+      ...prev.slice(0, 9),
+    ]);
+
+  return (
+    <Card title="非受控模式(initValue)" style={{ marginBottom: 24 }}>
+      <Paragraph type="secondary">
+        树结构由组件内部管理,父组件只监听 onChange 事件。
+      </Paragraph>
+      <EditableTreeWidget
+        initValue={mockInitValue}
+        onChange={(list) => addLog(`onChange: ${list?.length ?? 0} 个节点`)}
+        onSave={(list) =>
+          addLog(`onSave: ${JSON.stringify(list?.map((i) => i.title_text))}`)
+        }
+        onAppend={async (node) => {
+          const id = `new-${Date.now()}`;
+          addLog(`onAppend: 在 "${node.title_text || "根"}" 下新增`);
+          return {
+            key: id,
+            id,
+            title: `新文章 ${id.slice(-4)}`,
+            title_text: `新文章 ${id.slice(-4)}`,
+            children: [],
+            level: 1,
+          };
+        }}
+        onTitleClick={(_e, node) =>
+          addLog(`onTitleClick: "${node.title_text}"`)
+        }
+        onSelect={(keys) => addLog(`onSelect: ${JSON.stringify(keys)}`)}
+      />
+      <Divider />
+      <Title level={5}>事件日志</Title>
+      {log.length === 0 ? (
+        <Text type="secondary">暂无事件</Text>
+      ) : (
+        log.map((item, i) => (
+          <div key={i}>
+            <Text code>{item}</Text>
+          </div>
+        ))
+      )}
+    </Card>
+  );
+};
+
+// ─── 受控模式 Demo ─────────────────────────────────────────────────
+const ControlledDemo = () => {
+  const [value, setValue] = useState<ListNodeData[]>(mockInitValue);
+  const [log, setLog] = useState<string[]>([]);
+
+  const addLog = (msg: string) =>
+    setLog((prev) => [
+      `[${new Date().toLocaleTimeString()}] ${msg}`,
+      ...prev.slice(0, 9),
+    ]);
+
+  const handleExternalAdd = () => {
+    const id = `ext-${Date.now()}`;
+    setValue((prev) => [
+      ...prev,
+      {
+        key: id,
+        title: `外部添加 ${id.slice(-4)}`,
+        title_text: `外部添加 ${id.slice(-4)}`,
+        level: 1,
+      },
+    ]);
+    addLog("父组件外部添加了一个节点");
+  };
+
+  const handleReset = () => {
+    setValue(mockInitValue);
+    addLog("父组件重置了数据");
+  };
+
+  const handleClear = () => {
+    setValue([]);
+    addLog("父组件清空了数据");
+  };
+
+  return (
+    <Card title="受控模式(value)" style={{ marginBottom: 24 }}>
+      <Paragraph type="secondary">
+        树结构由父组件通过 value 控制,onChange 时父组件更新 value。
+      </Paragraph>
+      <div
+        style={{ marginBottom: 12, display: "flex", gap: 8, flexWrap: "wrap" }}
+      >
+        <Button onClick={handleExternalAdd}>外部添加节点</Button>
+        <Button onClick={handleReset}>重置数据</Button>
+        <Button danger onClick={handleClear}>
+          清空数据
+        </Button>
+        <Tag color="blue">当前节点数:{value.length}</Tag>
+      </div>
+      <EditableTreeWidget
+        value={value}
+        onChange={(list) => {
+          setValue(list ?? []);
+          addLog(`onChange: ${list?.length ?? 0} 个节点`);
+        }}
+        onSave={(list) =>
+          addLog(`onSave: ${JSON.stringify(list?.map((i) => i.title_text))}`)
+        }
+        onAppend={async (node) => {
+          const id = `new-${Date.now()}`;
+          addLog(`onAppend: 在 "${node.title_text || "根"}" 下新增`);
+          return {
+            key: id,
+            id,
+            title: `新文章 ${id.slice(-4)}`,
+            title_text: `新文章 ${id.slice(-4)}`,
+            children: [],
+            level: 1,
+          };
+        }}
+        onTitleClick={(_e, node) =>
+          addLog(`onTitleClick: "${node.title_text}"`)
+        }
+        onSelect={(keys) => addLog(`onSelect: ${JSON.stringify(keys)}`)}
+      />
+      <Divider />
+      <Title level={5}>事件日志</Title>
+      {log.length === 0 ? (
+        <Text type="secondary">暂无事件</Text>
+      ) : (
+        log.map((item, i) => (
+          <div key={i}>
+            <Text code>{item}</Text>
+          </div>
+        ))
+      )}
+    </Card>
+  );
+};
+
+// ─── updatedNode / addOnArticle 注入 Demo ─────────────────────────
+const InjectionDemo = () => {
+  const [addOnArticle, setAddOnArticle] = useState<TreeNodeData | undefined>(
+    undefined
+  );
+  const [updatedNode, setUpdatedNode] = useState<TreeNodeData | undefined>(
+    undefined
+  );
+  const [log, setLog] = useState<string[]>([]);
+
+  const addLog = (msg: string) =>
+    setLog((prev) => [
+      `[${new Date().toLocaleTimeString()}] ${msg}`,
+      ...prev.slice(0, 9),
+    ]);
+
+  const handleInjectArticle = () => {
+    const id = `inject-${Date.now()}`;
+    const node: TreeNodeData = {
+      key: id,
+      id,
+      title: `注入文章 ${id.slice(-4)}`,
+      title_text: `注入文章 ${id.slice(-4)}`,
+      children: [],
+      level: 1,
+    };
+    setAddOnArticle(node);
+    addLog(`注入 addOnArticle: "${node.title_text}"`);
+  };
+
+  const handleUpdateNode = () => {
+    const node: TreeNodeData = {
+      key: "1",
+      id: "1",
+      title: `第一章(已更新 ${Date.now().toString().slice(-4)})`,
+      title_text: `第一章(已更新)`,
+      children: [],
+      level: 1,
+    };
+    setUpdatedNode(node);
+    addLog(`注入 updatedNode: id=1 标题已更新`);
+  };
+
+  return (
+    <Card title="addOnArticle / updatedNode 注入 Demo">
+      <Paragraph type="secondary">
+        测试通过 props 向组件注入新节点或更新已有节点标题。
+      </Paragraph>
+      <div style={{ marginBottom: 12, display: "flex", gap: 8 }}>
+        <Button onClick={handleInjectArticle}>注入 addOnArticle</Button>
+        <Button onClick={handleUpdateNode}>更新节点 id=1 标题</Button>
+      </div>
+      <EditableTreeWidget
+        initValue={mockInitValue}
+        addOnArticle={addOnArticle}
+        updatedNode={updatedNode}
+        onChange={(list) => addLog(`onChange: ${list?.length ?? 0} 个节点`)}
+        onSave={(list) => addLog(`onSave: ${list?.length ?? 0} 个节点`)}
+        onAppend={async (parent) => {
+          const id = `new-${Date.now()}`;
+          return {
+            key: id,
+            id,
+            title: `新文章 ${id.slice(-4)}`,
+            title_text: `新文章 ${id.slice(-4)}`,
+            children: [],
+            level: parent.level + 1,
+          };
+        }}
+        onTitleClick={(_e, node) =>
+          addLog(`onTitleClick: "${node.title_text}"`)
+        }
+      />
+      <Divider />
+      <Title level={5}>事件日志</Title>
+      {log.length === 0 ? (
+        <Text type="secondary">暂无事件</Text>
+      ) : (
+        log.map((item, i) => (
+          <div key={i}>
+            <Text code>{item}</Text>
+          </div>
+        ))
+      )}
+    </Card>
+  );
+};
+
+// ─── 主测试页面 ────────────────────────────────────────────────────
+const EditableTreeTestPage = () => {
+  const [activeDemo, setActiveDemo] = useState<
+    "uncontrolled" | "controlled" | "injection"
+  >("uncontrolled");
+
+  const demos = {
+    uncontrolled: <UncontrolledDemo />,
+    controlled: <ControlledDemo />,
+    injection: <InjectionDemo />,
+  };
+
+  return (
+    <div style={{ padding: 24, maxWidth: 800, margin: "0 auto" }}>
+      <Title level={3}>EditableTreeWidget 测试页</Title>
+      <div style={{ marginBottom: 24, display: "flex", gap: 8 }}>
+        <Button
+          type={activeDemo === "uncontrolled" ? "primary" : "default"}
+          onClick={() => setActiveDemo("uncontrolled")}
+        >
+          非受控模式
+        </Button>
+        <Button
+          type={activeDemo === "controlled" ? "primary" : "default"}
+          onClick={() => setActiveDemo("controlled")}
+        >
+          受控模式
+        </Button>
+        <Button
+          type={activeDemo === "injection" ? "primary" : "default"}
+          onClick={() => setActiveDemo("injection")}
+        >
+          Props 注入
+        </Button>
+      </div>
+      {demos[activeDemo]}
+    </div>
+  );
+};
+
+export default EditableTreeTestPage;

+ 80 - 0
dashboard-v6/src/components/article/components/Navigate.tsx

@@ -0,0 +1,80 @@
+import { useEffect, useState } from "react";
+
+import React from "react";
+import NavigateButton from "./NavigateButton";
+import type { ArticleType } from "../../../api/Article";
+import type { ITocPathNode } from "../../../api/pali-text";
+import { get } from "../../../request";
+
+interface INavButton {
+  title: string;
+  subtitle: string;
+  id: string;
+}
+interface INavResponse {
+  ok: boolean;
+  message: string;
+  data: {
+    prev?: INavButton;
+    next?: INavButton;
+  };
+}
+
+interface IWidget {
+  type?: ArticleType;
+  articleId?: string;
+  path?: ITocPathNode[];
+  onChange?: (
+    event: React.MouseEvent<HTMLElement, MouseEvent>,
+    id: string
+  ) => void;
+  onPathChange?: (key: string) => void;
+}
+const NavigateWidget = ({
+  type,
+  articleId,
+  path,
+  onChange,
+  onPathChange,
+}: IWidget) => {
+  const [prev, setPrev] = useState<INavButton>();
+  const [next, setNext] = useState<INavButton>();
+
+  useEffect(() => {
+    if (type && articleId) {
+      get<INavResponse>(`/v2/article-nav?type=${type}&id=${articleId}`).then(
+        (json) => {
+          if (json.ok) {
+            setPrev(json.data.prev);
+            setNext(json.data.next);
+          }
+        }
+      );
+    }
+  }, [articleId, type]);
+
+  return (
+    <NavigateButton
+      prevTitle={prev?.title}
+      nextTitle={next?.title}
+      path={path}
+      onPrev={(event: React.MouseEvent<HTMLElement, MouseEvent>) => {
+        if (typeof onChange !== "undefined" && prev) {
+          onChange(event, prev.id);
+        }
+      }}
+      onNext={(event: React.MouseEvent<HTMLElement, MouseEvent>) => {
+        if (typeof onChange !== "undefined" && next) {
+          onChange(event, next.id);
+        }
+      }}
+      onPathChange={(key: string) => {
+        if (typeof onPathChange !== "undefined") {
+          onPathChange(key);
+        }
+      }}
+    />
+  );
+};
+
+export default NavigateWidget;

+ 134 - 0
dashboard-v6/src/components/article/components/NavigateButton.tsx

@@ -0,0 +1,134 @@
+import { Button, Dropdown, Modal, Space, Typography } from "antd";
+import { DoubleRightOutlined, DoubleLeftOutlined } from "@ant-design/icons";
+import { FolderOutlined } from "@ant-design/icons";
+import type { ITocPathNode } from "../../../api/pali-text";
+
+const { Paragraph, Text } = Typography;
+
+const EllipsisMiddle: React.FC<{
+  suffixCount: number;
+  maxWidth: number;
+  text?: string;
+}> = ({ suffixCount, maxWidth = 500, text = "" }) => {
+  const start = text.slice(0, text.length - suffixCount).trim();
+  const suffix = text.slice(-suffixCount).trim();
+  return (
+    <Text style={{ maxWidth: maxWidth }} ellipsis={{ suffix }}>
+      {start}
+    </Text>
+  );
+};
+
+interface IWidget {
+  prevTitle?: string;
+  nextTitle?: string;
+  path?: ITocPathNode[];
+  topOfChapter?: boolean;
+  endOfChapter?: boolean;
+  onPrev?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
+  onNext?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
+  onPathChange?: (key: string) => void;
+}
+const NavigateButtonWidget = ({
+  prevTitle,
+  nextTitle,
+  topOfChapter = false,
+  endOfChapter = false,
+  path,
+  onPrev,
+  onNext,
+  onPathChange,
+}: IWidget) => {
+  const currTitle = path && path.length > 0 ? path[path.length - 1].title : "";
+
+  return (
+    <Paragraph
+      style={{
+        display: "flex",
+        justifyContent: "space-between",
+        backdropFilter: "blur(5px)",
+        backgroundColor: "rgba(200,200,200,0.2)",
+        padding: 4,
+      }}
+    >
+      <Button
+        size="middle"
+        icon={topOfChapter ? <FolderOutlined /> : undefined}
+        disabled={typeof prevTitle === "undefined"}
+        onClick={(event: React.MouseEvent<HTMLElement, MouseEvent>) => {
+          if (typeof onPrev !== "undefined") {
+            if (topOfChapter) {
+              Modal.confirm({
+                content: "已经到达章节开头,去上一个章节吗?",
+                okText: "确认",
+                cancelText: "取消",
+                onOk: () => {
+                  onPrev(event);
+                },
+              });
+            } else {
+              onPrev(event);
+            }
+          }
+        }}
+      >
+        <Space>
+          <DoubleLeftOutlined key="icon" />
+          <EllipsisMiddle maxWidth={250} suffixCount={7} text={prevTitle} />
+        </Space>
+      </Button>
+      <div>
+        <Dropdown
+          placement="top"
+          trigger={["hover"]}
+          menu={{
+            items: path?.map((item) => {
+              return { label: item.title, key: item.key ?? item.title };
+            }),
+            onClick: (e) => {
+              console.debug("onPathChange", e.key);
+              if (typeof onPathChange !== "undefined") {
+                onPathChange(e.key);
+              }
+            },
+          }}
+        >
+          <span>{currTitle}</span>
+        </Dropdown>
+      </div>
+      <Button
+        icon={endOfChapter ? <FolderOutlined /> : undefined}
+        size="middle"
+        disabled={typeof nextTitle === "undefined"}
+        onClick={(event: React.MouseEvent<HTMLElement, MouseEvent>) => {
+          if (typeof onNext !== "undefined") {
+            if (endOfChapter) {
+              Modal.confirm({
+                content: "已经到达章节末尾,去下一个章节吗?",
+                okText: "确认",
+                cancelText: "取消",
+                onOk: () => {
+                  onNext(event);
+                },
+              });
+            } else {
+              onNext(event);
+            }
+          }
+        }}
+      >
+        <Space>
+          <EllipsisMiddle
+            key="title"
+            maxWidth={250}
+            suffixCount={7}
+            text={nextTitle?.substring(0, 20)}
+          />
+          <DoubleRightOutlined key="icon" />
+        </Space>
+      </Button>
+    </Paragraph>
+  );
+};
+
+export default NavigateButtonWidget;

+ 215 - 0
dashboard-v6/src/components/article/components/TocTree.tsx

@@ -0,0 +1,215 @@
+import { Tree, Typography } from "antd";
+import { useMemo, useState } from "react";
+
+import type { Key } from "antd/lib/table/interface";
+
+import type { DataNode, EventDataNode } from "antd/es/tree";
+import type { ListNodeData } from "./EditableTree";
+import { randomString } from "../../../utils";
+import PaliText from "../../general/PaliText";
+
+const { Text } = Typography;
+
+interface IIdMap {
+  key: string;
+  id: string;
+}
+export interface TreeNodeData {
+  key: string;
+  id: string;
+  title: string | React.ReactNode;
+  isLeaf?: boolean;
+  children?: TreeNodeData[];
+  level: number;
+  status?: number;
+  deletedAt?: string | null;
+}
+
+function tocGetTreeData(
+  listData: ListNodeData[],
+  active = ""
+): [TreeNodeData[] | undefined, IIdMap[]] {
+  const treeData: TreeNodeData[] = [];
+  let tocActivePath: TreeNodeData[] = [];
+  const treeParents = [];
+  const rootNode: TreeNodeData = {
+    key: randomString(),
+    id: "0",
+    title: "root",
+    level: 0,
+    children: [],
+  };
+  const idMap: IIdMap[] = [];
+  treeData.push(rootNode);
+  let lastInsNode: TreeNodeData = rootNode;
+
+  let iCurrLevel = 0;
+  for (let index = 0; index < listData.length; index++) {
+    const element = listData[index];
+    const newNode: TreeNodeData = {
+      key: randomString(),
+      id: element.key,
+      isLeaf: element.children === 0,
+      title: element.title,
+      level: element.level,
+      status: element.status,
+      deletedAt: element.deletedAt,
+    };
+    idMap.push({
+      key: newNode.key,
+      id: newNode.id,
+    });
+    if (newNode.level > iCurrLevel) {
+      //新的层级比较大,为上一个的子目录
+      treeParents.push(lastInsNode);
+      if (typeof lastInsNode.children === "undefined") {
+        lastInsNode.children = [];
+      }
+      lastInsNode.children.push(newNode);
+    } else if (newNode.level === iCurrLevel) {
+      //目录层级相同,为平级
+      const parentNode = treeParents[treeParents.length - 1];
+      if (typeof parentNode !== "undefined") {
+        if (typeof parentNode.children === "undefined") {
+          parentNode.children = [];
+        }
+        parentNode.children.push(newNode);
+      }
+    } else {
+      // 小于 挂在上一个层级
+      while (treeParents.length > 1) {
+        treeParents.pop();
+        if (treeParents[treeParents.length - 1].level < newNode.level) {
+          break;
+        }
+      }
+      const parentNode = treeParents[treeParents.length - 1];
+      if (typeof parentNode !== "undefined") {
+        if (typeof parentNode.children === "undefined") {
+          parentNode.children = [];
+        }
+        parentNode.children.push(newNode);
+      }
+    }
+    lastInsNode = newNode;
+    iCurrLevel = newNode.level;
+
+    if (active === element.key) {
+      tocActivePath = [];
+      for (let index = 1; index < treeParents.length; index++) {
+        //treeParents[index]["expanded"] = true;
+        tocActivePath.push(treeParents[index]);
+      }
+    }
+  }
+
+  return [treeData[0].children, idMap];
+}
+
+interface IWidgetTocTree {
+  treeData?: ListNodeData[];
+  expandedKeys?: Key[];
+  selectedKeys?: Key[];
+  onSelect?: (selectedId?: string[]) => void;
+  onClick?: (
+    selectedId: string,
+    e: React.MouseEvent<HTMLSpanElement, MouseEvent>
+  ) => void;
+}
+
+const TocTreeWidget = ({
+  treeData,
+  expandedKeys,
+  selectedKeys,
+  onSelect,
+  onClick,
+}: IWidgetTocTree) => {
+  // 仅保留真正需要组件内部管理的交互状态
+  const [localExpanded, setLocalExpanded] = useState<Key[]>();
+
+  // 用 useMemo 替代 useEffect + setState 派生 tree 和 keyIdMap
+  const [tree, keyIdMap] = useMemo(() => {
+    if (treeData && treeData.length > 0) {
+      return tocGetTreeData(treeData, "");
+    }
+    return [[], []];
+  }, [treeData]);
+
+  // 用 useMemo 派生 selected keys(props id → 内部随机 key 的映射)
+  const selected = useMemo(() => {
+    return selectedKeys?.map((item) => {
+      const found = keyIdMap.find((value) => value.id === item);
+      return found?.key ?? "";
+    });
+  }, [keyIdMap, selectedKeys]);
+
+  // expanded 同理,但需要合并外部传入和本地交互,优先使用本地状态
+  const expanded = useMemo(() => {
+    if (localExpanded !== undefined) return localExpanded;
+    return expandedKeys?.map((item) => {
+      const found = keyIdMap.find((value) => value.id === item);
+      return found?.key ?? "";
+    });
+  }, [expandedKeys, keyIdMap, localExpanded]);
+
+  return (
+    <Tree
+      treeData={tree}
+      selectedKeys={selected}
+      expandedKeys={expanded}
+      autoExpandParent
+      onExpand={(keys: Key[]) => {
+        setLocalExpanded(keys); // 用户手动展开/收起,只需本地管理
+      }}
+      onClick={(
+        e: React.MouseEvent<HTMLSpanElement, MouseEvent>,
+        node: EventDataNode<DataNode>
+      ) => {
+        if (typeof onClick !== "undefined") {
+          const selectedId = keyIdMap.find(
+            (value) => node.key === value.key
+          )?.id;
+          if (selectedId) {
+            onClick(selectedId, e);
+          }
+        }
+      }}
+      onSelect={(keys: Key[]) => {
+        if (typeof onSelect !== "undefined") {
+          const selectedId = keyIdMap
+            .filter((value) => keys.includes(value.key))
+            .map((item) => item.id);
+          onSelect(selectedId);
+        }
+      }}
+      blockNode
+      titleRender={(node: TreeNodeData) => {
+        const currNode =
+          typeof node.title === "string" ? (
+            node.title === "" ? (
+              "[unnamed]"
+            ) : (
+              <PaliText
+                textType={node.status === 10 ? "secondary" : undefined}
+                text={node.title}
+              />
+            )
+          ) : (
+            node.title
+          );
+
+        return (
+          <Text
+            delete={node.deletedAt ? true : false}
+            disabled={node.deletedAt ? true : false}
+            type={node.status === 10 ? "secondary" : undefined}
+          >
+            {currNode}
+          </Text>
+        );
+      }}
+    />
+  );
+};
+
+export default TocTreeWidget;

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

@@ -1,7 +1,7 @@
 import { useEffect, useState, useTransition } from "react";
 
 import type { ArticleMode, IArticleDataResponse } from "../../../api/Article";
-import { getTerm } from "../../../api/Term";
+import { getTerm, type ITermDataResponse } from "../../../api/Term";
 import { message } from "antd";
 
 interface IUseTermOptions {
@@ -12,7 +12,7 @@ interface IUseTermOptions {
 
 export function useTerm({ id, mode = "read", channelId }: IUseTermOptions) {
   const [articleData, setArticleData] = useState<IArticleDataResponse>();
-  const [articleHtml, setArticleHtml] = useState<string[]>(["<span />"]);
+  const [term, setTerm] = useState<ITermDataResponse>();
   const [errorCode, setErrorCode] = useState<number>();
   const [isPending, startTransition] = useTransition();
 
@@ -43,8 +43,7 @@ export function useTerm({ id, mode = "read", channelId }: IUseTermOptions) {
           summary: data.note,
           content: data.note ?? "",
           content_type: "markdown",
-          html: data.html,
-          path: [],
+          html: data.html ?? data.note ?? "<span />",
           editor: data.editor,
           status: 30,
           lang: data.language,
@@ -52,14 +51,12 @@ export function useTerm({ id, mode = "read", channelId }: IUseTermOptions) {
           updated_at: data.updated_at,
         });
 
-        setArticleHtml(
-          data.html ? [data.html] : data.note ? [data.note] : ["<span />"]
-        );
+        setTerm(data);
       } catch (e) {
         setErrorCode(e as number);
       }
     });
   }, [id, channelId, mode]);
 
-  return { articleData, articleHtml, errorCode, loading: isPending };
+  return { articleData, term, errorCode, loading: isPending };
 }

+ 29 - 0
dashboard-v6/src/components/task/TaskBuilderChapterModal.tsx

@@ -0,0 +1,29 @@
+import { Modal } from "antd";
+
+interface IModal {
+  studioName?: string;
+  channels?: string[];
+  book?: number;
+  para?: number;
+  open?: boolean;
+  onClose?: () => void;
+}
+export const TaskBuilderChapterModal = ({ open = false, onClose }: IModal) => {
+  return (
+    <>
+      <Modal
+        destroyOnHidden={true}
+        maskClosable={false}
+        width={1400}
+        style={{ top: 10 }}
+        title={""}
+        footer={false}
+        open={open}
+        onOk={onClose}
+        onCancel={onClose}
+      >
+        mock
+      </Modal>
+    </>
+  );
+};

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

@@ -20,6 +20,7 @@ import { useAppSelector } from "../../hooks";
 import { currentUser } from "../../reducers/current-user";
 import type { IChannel } from "../../api/Channel";
 import DataImport from "../general/DataImport";
+import { Link } from "react-router";
 
 interface IItem {
   sn: number;
@@ -106,6 +107,13 @@ const TermListWidget = ({ studioName, channelId }: IWidget) => {
             key: "word",
             tooltip: "单词过长会自动收缩",
             ellipsis: true,
+            render(_dom, entity) {
+              return (
+                <Link to={`/workspace/edit/wiki/${entity.id}`}>
+                  {entity.word}
+                </Link>
+              );
+            },
           },
           {
             title: intl.formatMessage({

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

@@ -9,6 +9,7 @@ interface IWidget {
   open?: boolean;
   tpl?: ArticleType;
   articleId?: string;
+  channelsId?: string | null;
   title?: string;
   onClose?: () => void;
 }

+ 165 - 0
dashboard-v6/src/hooks/useTipitaka.ts

@@ -0,0 +1,165 @@
+// src/hooks/useTipitaka.ts
+
+import { useEffect, useState, useCallback, useRef } from "react";
+import type {
+  ArticleMode,
+  ArticleType,
+  IArticleDataResponse,
+  IChapterToc,
+} from "../api/Article";
+import {
+  fetchChapterArticle,
+  fetchParaArticle,
+  fetchNextParaChunk,
+} from "../api/Article";
+
+interface IUseTipitakaProps {
+  type?: ArticleType;
+  id?: string;
+  mode?: ArticleMode | null;
+  channelId?: string | null;
+  book?: string | null;
+  para?: string | null;
+  active?: boolean;
+}
+
+interface IUseTipitakaReturn {
+  articleData: IArticleDataResponse | undefined;
+  articleHtml: string[];
+  toc: IChapterToc[] | undefined;
+  loading: boolean;
+  errorCode: number | undefined;
+  remains: boolean;
+  loadNextChunk: () => void;
+  refresh: () => void;
+}
+
+const useTipitaka = ({
+  type,
+  id,
+  mode = "read",
+  channelId,
+  book,
+  para,
+  active = true,
+}: IUseTipitakaProps): IUseTipitakaReturn => {
+  const [articleData, setArticleData] = useState<IArticleDataResponse>();
+  const [articleHtml, setArticleHtml] = useState<string[]>(["<span />"]);
+  const [toc, setToc] = useState<IChapterToc[]>();
+  const [loading, setLoading] = useState(false);
+  const [errorCode, setErrorCode] = useState<number>();
+  const [remains, setRemains] = useState(false);
+  const [refreshCount, setRefreshCount] = useState(0);
+
+  const srcDataMode = mode === "edit" || mode === "wbw" ? "edit" : "read";
+
+  // 用 ref 追踪最新的 articleData,供 loadNextChunk 使用,避免闭包陷阱
+  const articleDataRef = useRef<IArticleDataResponse | undefined>(undefined);
+  articleDataRef.current = articleData;
+
+  useEffect(() => {
+    let ignore = false;
+
+    const load = async () => {
+      if (!active || !type || !id) return;
+
+      setLoading(true);
+
+      try {
+        let response;
+        if (type === "chapter") {
+          response = await fetchChapterArticle(id, srcDataMode, channelId);
+        } else if (type === "para") {
+          const _book = book ?? id;
+          response = await fetchParaArticle(
+            _book,
+            para ?? "",
+            srcDataMode,
+            channelId
+          );
+        } else {
+          return;
+        }
+
+        if (ignore) return;
+
+        if (response.ok) {
+          setArticleData(response.data);
+          setArticleHtml([
+            response.data.html ?? response.data.content ?? "<span />",
+          ]);
+          setToc(response.data.toc);
+          setRemains(response.data.from !== undefined);
+          setErrorCode(undefined);
+        }
+      } catch (e) {
+        if (!ignore) {
+          setErrorCode(e as number);
+        }
+      } finally {
+        if (!ignore) {
+          setLoading(false);
+        }
+      }
+    };
+
+    load();
+
+    return () => {
+      ignore = true;
+    };
+  }, [active, type, id, srcDataMode, book, para, channelId, refreshCount]);
+
+  const loadNextChunk = useCallback(async () => {
+    const current = articleDataRef.current;
+    if (
+      !current ||
+      current.paraId === undefined ||
+      current.mode === undefined ||
+      current.from === undefined ||
+      current.to === undefined
+    ) {
+      setRemains(false);
+      return;
+    }
+
+    try {
+      const response = await fetchNextParaChunk(
+        current.paraId,
+        current.mode,
+        current.from,
+        current.to,
+        channelId
+      );
+
+      if (response.ok && typeof response.data.content === "string") {
+        setArticleData((prev) => {
+          if (prev) {
+            return { ...prev, from: response.data.from };
+          }
+          return prev;
+        });
+        setArticleHtml((prev) => [...prev, response.data.content as string]);
+      }
+    } catch (e) {
+      console.error("loadNextChunk error", e);
+    }
+  }, [channelId]);
+
+  const refresh = useCallback(() => {
+    setRefreshCount((c) => c + 1);
+  }, []);
+
+  return {
+    articleData,
+    articleHtml,
+    toc,
+    loading,
+    errorCode,
+    remains,
+    loadNextChunk,
+    refresh,
+  };
+};
+
+export default useTipitaka;

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

@@ -1,11 +1,19 @@
-import { useParams } from "react-router";
+import { useNavigate, useParams } from "react-router";
 
-import TypeTerm from "../../../components/article/TypeTerm";
+import TermEdit from "../../../components/term/TermEdit";
 
 const Widget = () => {
   const { id } = useParams();
+  const navigate = useNavigate();
 
-  return <TypeTerm id={id} />;
+  return (
+    <TermEdit
+      id={id}
+      onUpdate={() => {
+        navigate(`/workspace/edit/wiki/${id}`);
+      }}
+    />
+  );
 };
 
 export default Widget;

+ 19 - 0
dashboard-v6/src/pages/workspace/term/show.tsx

@@ -0,0 +1,19 @@
+import { useNavigate, useParams } from "react-router";
+
+import TypeTerm from "../../../components/article/TypeTerm";
+
+const Widget = () => {
+  const { id } = useParams();
+  const navigate = useNavigate();
+
+  return (
+    <TypeTerm
+      id={id}
+      onEdit={() => {
+        navigate(`/workspace/edit/wiki/${id}/edit`);
+      }}
+    />
+  );
+};
+
+export default Widget;

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

@@ -10,8 +10,12 @@ const SentSimTest = lazy(
 const SentEditInnerDemo = lazy(
   () => import("../components/sentence-editor/SentEditInnerDemo")
 );
+const EditableTreeTest = lazy(
+  () => import("../components/article/components/EditableTreeTest")
+);
 
 const TermTest = lazy(() => import("../components/term/TermTest"));
+const TypePaliTest = lazy(() => import("../components/article/TypePaliTest"));
 
 // 你可以继续添加更多测试组件
 // const TestButtonDemo = lazy(() => import("../components/button/ButtonDemo"));
@@ -45,6 +49,16 @@ export const testRoutes: TestRouteObject[] = [
     label: "TermTest",
     Component: TermTest,
   },
+  {
+    path: "EditableTreeTest",
+    label: "EditableTreeTest",
+    Component: EditableTreeTest,
+  },
+  {
+    path: "TypePaliTest",
+    label: "TypePaliTest",
+    Component: TypePaliTest,
+  },
 
   // 示例:嵌套结构
   // {