visuddhinanda 1 miesiąc temu
rodzic
commit
68123302bd
73 zmienionych plików z 7674 dodań i 37 usunięć
  1. 4 14
      dashboard-v6/backup/hooks/useSetting.ts
  2. 54 1
      dashboard-v6/src/Router.tsx
  3. 18 0
      dashboard-v6/src/api/Attachments.ts
  4. 69 0
      dashboard-v6/src/components/attachment/AttachmentDialog.tsx
  5. 112 0
      dashboard-v6/src/components/attachment/AttachmentImport.tsx
  6. 510 0
      dashboard-v6/src/components/attachment/AttachmentList.tsx
  7. 25 0
      dashboard-v6/src/components/dict/Lookup.tsx
  8. 29 0
      dashboard-v6/src/components/right-panel/RightPanel.tsx
  9. 131 0
      dashboard-v6/src/components/tag/TagCreate.tsx
  10. 160 0
      dashboard-v6/src/components/tag/TagList.tsx
  11. 50 0
      dashboard-v6/src/components/tag/TagSelect.tsx
  12. 62 0
      dashboard-v6/src/components/tag/TagSelectButton.tsx
  13. 95 0
      dashboard-v6/src/components/tag/TagShow.tsx
  14. 68 0
      dashboard-v6/src/components/tag/TagsArea.tsx
  15. 60 0
      dashboard-v6/src/components/tag/TagsManager.tsx
  16. 205 0
      dashboard-v6/src/components/tag/TagsOnItem.tsx
  17. 192 0
      dashboard-v6/src/components/template/Video.tsx
  18. 7 12
      dashboard-v6/src/components/video/Video.tsx
  19. 0 0
      dashboard-v6/src/components/video/VideoModal.tsx
  20. 17 8
      dashboard-v6/src/components/video/VideoPlayer.tsx
  21. 216 0
      dashboard-v6/src/components/video/VideoPlayerTest.tsx
  22. 93 0
      dashboard-v6/src/components/wbw/CaseFormula.tsx
  23. 112 0
      dashboard-v6/src/components/wbw/RelaGraphic.tsx
  24. 400 0
      dashboard-v6/src/components/wbw/SelectCase.tsx
  25. 153 0
      dashboard-v6/src/components/wbw/WbwCase.tsx
  26. 368 0
      dashboard-v6/src/components/wbw/WbwDetail.tsx
  27. 46 0
      dashboard-v6/src/components/wbw/WbwDetailAdvance.tsx
  28. 41 0
      dashboard-v6/src/components/wbw/WbwDetailAttachment.tsx
  29. 273 0
      dashboard-v6/src/components/wbw/WbwDetailBasic.tsx
  30. 78 0
      dashboard-v6/src/components/wbw/WbwDetailBasicRelation.tsx
  31. 75 0
      dashboard-v6/src/components/wbw/WbwDetailBookMark.tsx
  32. 60 0
      dashboard-v6/src/components/wbw/WbwDetailCase.tsx
  33. 91 0
      dashboard-v6/src/components/wbw/WbwDetailFactor.tsx
  34. 175 0
      dashboard-v6/src/components/wbw/WbwDetailFm.tsx
  35. 33 0
      dashboard-v6/src/components/wbw/WbwDetailNote.tsx
  36. 58 0
      dashboard-v6/src/components/wbw/WbwDetailOrder.tsx
  37. 68 0
      dashboard-v6/src/components/wbw/WbwDetailParent.tsx
  38. 96 0
      dashboard-v6/src/components/wbw/WbwDetailParent2.tsx
  39. 272 0
      dashboard-v6/src/components/wbw/WbwDetailRelation.tsx
  40. 72 0
      dashboard-v6/src/components/wbw/WbwDetailUpload.tsx
  41. 130 0
      dashboard-v6/src/components/wbw/WbwFactorMeaning.tsx
  42. 161 0
      dashboard-v6/src/components/wbw/WbwFactorMeaningItem.tsx
  43. 112 0
      dashboard-v6/src/components/wbw/WbwFactors.tsx
  44. 42 0
      dashboard-v6/src/components/wbw/WbwFactorsEditor.tsx
  45. 76 0
      dashboard-v6/src/components/wbw/WbwLookup.tsx
  46. 218 0
      dashboard-v6/src/components/wbw/WbwMeaning.tsx
  47. 217 0
      dashboard-v6/src/components/wbw/WbwMeaningSelect.tsx
  48. 16 0
      dashboard-v6/src/components/wbw/WbwPage.tsx
  49. 463 0
      dashboard-v6/src/components/wbw/WbwPali.tsx
  50. 61 0
      dashboard-v6/src/components/wbw/WbwPaliDiscussionIcon.tsx
  51. 12 0
      dashboard-v6/src/components/wbw/WbwPara.tsx
  52. 109 0
      dashboard-v6/src/components/wbw/WbwParent.tsx
  53. 32 0
      dashboard-v6/src/components/wbw/WbwParent2.tsx
  54. 42 0
      dashboard-v6/src/components/wbw/WbwParentEditor.tsx
  55. 38 0
      dashboard-v6/src/components/wbw/WbwParentIcon.tsx
  56. 56 0
      dashboard-v6/src/components/wbw/WbwReal.tsx
  57. 64 0
      dashboard-v6/src/components/wbw/WbwRelationAdd.tsx
  58. 20 0
      dashboard-v6/src/components/wbw/WbwVideoButton.tsx
  59. 311 0
      dashboard-v6/src/components/wbw/WbwWord.tsx
  60. 134 0
      dashboard-v6/src/components/wbw/utils.ts
  61. 126 0
      dashboard-v6/src/components/wbw/wbw.css
  62. 181 0
      dashboard-v6/src/layouts/test/index.tsx
  63. 32 0
      dashboard-v6/src/layouts/workspace/editor.tsx
  64. 1 1
      dashboard-v6/src/layouts/workspace/index.tsx
  65. 13 0
      dashboard-v6/src/routes/buildRoutes.ts
  66. 37 0
      dashboard-v6/src/routes/testRoutes.tsx
  67. 8 0
      dashboard-v6/src/types/template.ts
  68. 86 0
      dashboard-v6/src/types/wbw.ts
  69. 20 1
      dashboard-v6/src/utils.ts
  70. 33 0
      dashboard-v6/src/utils/code/core/buildConverter.ts
  71. 137 0
      dashboard-v6/src/utils/code/index.ts
  72. 22 0
      dashboard-v6/src/utils/code/scripts/my.ts
  73. 16 0
      dashboard-v6/src/utils/code/scripts/thai.ts

+ 4 - 14
dashboard-v6/backup/hooks/useSetting.ts

@@ -1,19 +1,9 @@
-import { useEffect, useState } from "react";
+import { useMemo } from "react";
 import { GetUserSetting } from "../components/auth/setting/default";
 import { GetUserSetting } from "../components/auth/setting/default";
-import { useAppSelector } from "../hooks";
-import { settingInfo } from "../reducers/setting";
+import { useAppSelector } from "../../src/hooks";
+import { settingInfo } from "../../src/reducers/setting";
 
 
 export function useSetting(key: string) {
 export function useSetting(key: string) {
-  const [commentaryLayout, setCommentaryLayout] = useState<
-    string | number | boolean | string[] | undefined
-  >();
   const settings = useAppSelector(settingInfo);
   const settings = useAppSelector(settingInfo);
-
-  useEffect(() => {
-    const layoutCommentary = GetUserSetting(key, settings);
-
-    setCommentaryLayout(layoutCommentary);
-  }, [key, settings]);
-
-  return commentaryLayout;
+  return useMemo(() => GetUserSetting(key, settings), [key, settings]);
 }
 }

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

@@ -2,6 +2,8 @@ import { lazy } from "react";
 import { createBrowserRouter } from "react-router";
 import { createBrowserRouter } from "react-router";
 import { RouterProvider } from "react-router/dom";
 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";
 
 
 const UsersSignIn = lazy(() => import("./pages/users/sign-in"));
 const UsersSignIn = lazy(() => import("./pages/users/sign-in"));
 const UsersSignUp = lazy(() => import("./pages/users/sign-up"));
 const UsersSignUp = lazy(() => import("./pages/users/sign-up"));
@@ -25,6 +27,10 @@ const RootLayout = lazy(() => import("./layouts/Root"));
 const AnonymousLayout = lazy(() => import("./layouts/anonymous"));
 const AnonymousLayout = lazy(() => import("./layouts/anonymous"));
 const DashboardLayout = lazy(() => import("./layouts/dashboard"));
 const DashboardLayout = lazy(() => import("./layouts/dashboard"));
 const WorkspaceLayout = lazy(() => import("./layouts/workspace"));
 const WorkspaceLayout = lazy(() => import("./layouts/workspace"));
+const WorkspaceEditorLayout = lazy(() => import("./layouts/workspace/editor"));
+
+// ↓ 新增:TestLayout
+const TestLayout = lazy(() => import("./layouts/test"));
 
 
 const router = createBrowserRouter(
 const router = createBrowserRouter(
   [
   [
@@ -124,13 +130,60 @@ const router = createBrowserRouter(
                     },
                     },
                     {
                     {
                       path: "setting",
                       path: "setting",
-                      Component: WorkspaceChannelSetting, // ← 新页面组件
+                      Component: WorkspaceChannelSetting,
                       handle: { crumb: "setting" },
                       handle: { crumb: "setting" },
                     },
                     },
                   ],
                   ],
                 },
                 },
               ],
               ],
             },
             },
+            {
+              path: "edit",
+              Component: WorkspaceEditorLayout,
+              handle: { crumb: "edit" },
+              children: [
+                {
+                  path: "article",
+                  children: [{ path: ":id" }],
+                },
+                {
+                  path: "anthology",
+                  children: [{ path: ":id" }],
+                },
+                {
+                  path: "series",
+                  children: [{ path: ":id" }],
+                },
+                {
+                  path: "chapter",
+                  children: [{ path: ":id" }],
+                },
+                {
+                  path: "para",
+                  children: [{ path: ":id" }],
+                },
+                {
+                  path: "cs-para",
+                  children: [{ path: ":id" }],
+                },
+                {
+                  path: "wiki",
+                  children: [{ path: ":id" }],
+                },
+              ],
+            },
+          ],
+        },
+
+        // ─── Test 路由:使用 TestLayout + 自动注册 testRoutes ───────────────
+        {
+          path: "test",
+          Component: TestLayout,
+          children: [
+            // index: 访问 /test 时显示欢迎页(由 TestLayout 内部处理)
+            { index: true },
+            // 自动将 testRoutes 转换为路由配置
+            ...buildRouteConfig(testRoutes),
           ],
           ],
         },
         },
       ],
       ],

+ 18 - 0
dashboard-v6/src/api/Attachments.ts

@@ -1,3 +1,7 @@
+import { message } from "antd";
+import { delete_ } from "../request";
+import type { IDeleteResponse } from "./Group";
+
 export interface IAttachmentRequest {
 export interface IAttachmentRequest {
   id: string;
   id: string;
   name: string;
   name: string;
@@ -36,3 +40,17 @@ export interface IResAttachmentListResponse {
   message: string;
   message: string;
   data: { rows: IResAttachmentData[]; count: number };
   data: { rows: IResAttachmentData[]; count: number };
 }
 }
+
+export const deleteRes = (id: string) => {
+  const url = `/v2/attachment/${id}`;
+  console.info("attachment delete url", url);
+  delete_<IDeleteResponse>(url)
+    .then((json) => {
+      if (json.ok) {
+        message.success("删除成功");
+      } else {
+        message.error(json.message);
+      }
+    })
+    .catch((e) => console.log("Oops errors!", e));
+};

+ 69 - 0
dashboard-v6/src/components/attachment/AttachmentDialog.tsx

@@ -0,0 +1,69 @@
+import { Modal } from "antd";
+import { useState } from "react";
+import AttachmentList from "./AttachmentList";
+import type { IAttachmentRequest } from "../../api/Attachments";
+
+interface IWidget {
+  open?: boolean;
+  trigger?: React.ReactNode;
+  studioName?: string;
+  onOpenChange?: (open: boolean) => void;
+  onSelect?: (value: IAttachmentRequest) => void;
+}
+const AttachmentDialog = ({
+  open,
+  trigger,
+  studioName,
+  onOpenChange,
+  onSelect,
+}: IWidget) => {
+  const [innerOpen, setInnerOpen] = useState(false);
+
+  const isModalOpen = open ?? innerOpen;
+
+  const showModal = () => {
+    setInnerOpen(true);
+    if (typeof onOpenChange !== "undefined") {
+      onOpenChange(true);
+    }
+  };
+
+  const handleOk = () => {
+    setInnerOpen(false);
+    if (typeof onOpenChange !== "undefined") {
+      onOpenChange(false);
+    }
+  };
+
+  const handleCancel = () => {
+    setInnerOpen(false);
+    if (typeof onOpenChange !== "undefined") {
+      onOpenChange(false);
+    }
+  };
+  return (
+    <>
+      <span onClick={showModal}>{trigger}</span>
+      <Modal
+        width={700}
+        title="加入文集"
+        open={isModalOpen}
+        onOk={handleOk}
+        onCancel={handleCancel}
+        maskClosable={false}
+      >
+        <AttachmentList
+          studioName={studioName}
+          onClick={(value: IAttachmentRequest) => {
+            if (typeof onSelect !== "undefined") {
+              onSelect(value);
+            }
+            handleOk();
+          }}
+        />
+      </Modal>
+    </>
+  );
+};
+
+export default AttachmentDialog;

+ 112 - 0
dashboard-v6/src/components/attachment/AttachmentImport.tsx

@@ -0,0 +1,112 @@
+import { Modal, Upload, type UploadProps, message } from "antd";
+import { useEffect, useState } from "react";
+import { InboxOutlined, ExclamationCircleOutlined } from "@ant-design/icons";
+
+import { get as getToken } from "../../reducers/current-user";
+import type { UploadFile } from "antd/es/upload/interface";
+
+import modal from "antd/lib/modal";
+import { deleteRes, type IAttachmentResponse } from "../../api/Attachments";
+
+const { Dragger } = Upload;
+
+interface IWidget {
+  replaceId?: string;
+  open?: boolean;
+  onOpenChange?: (ok: boolean) => void;
+}
+const AttachmentImportWidget = ({
+  replaceId,
+  open = false,
+  onOpenChange,
+}: IWidget) => {
+  const [isOpen, setIsOpen] = useState(open);
+
+  useEffect(() => {
+    setIsOpen(open);
+  }, [open]);
+
+  const props: UploadProps = {
+    name: "file",
+    listType: "picture",
+    multiple: replaceId ? false : true,
+    action: `${import.meta.env.BASE_URL}/api/v2/attachment?id=${replaceId}`,
+    headers: {
+      Authorization: `Bearer ${getToken()}`,
+    },
+    onChange(info) {
+      const { status } = info.file;
+      if (status !== "uploading") {
+        console.log(info.file, info.fileList);
+      }
+      if (status === "done") {
+        message.success(`${info.file.name} file uploaded successfully.`);
+      } else if (status === "error") {
+        message.error(`${info.file.name} file upload failed.`);
+      }
+    },
+    onDrop(e) {
+      console.log("Dropped files", e.dataTransfer.files);
+    },
+    onRemove: (file: UploadFile<IAttachmentResponse>): boolean => {
+      console.log("remove", file);
+      if (file.response) {
+        deleteRes(file.response.data.id);
+        return true;
+      } else {
+        return false;
+      }
+    },
+  };
+
+  const handleOk = () => {
+    setIsOpen(false);
+    if (typeof onOpenChange !== "undefined") {
+      onOpenChange(false);
+    }
+  };
+
+  const handleCancel = () => {
+    modal.confirm({
+      title: "关闭上传窗口",
+      icon: <ExclamationCircleOutlined />,
+      content: "所有正在上传文件将取消上传。",
+      okText: "确认",
+      cancelText: "取消",
+      onOk: () => {
+        setIsOpen(false);
+        if (typeof onOpenChange !== "undefined") {
+          onOpenChange(false);
+        }
+      },
+    });
+  };
+
+  return (
+    <Modal
+      destroyOnHidden={true}
+      width={700}
+      title="Upload"
+      footer={false}
+      maskClosable={false}
+      open={isOpen}
+      onOk={handleOk}
+      onCancel={handleCancel}
+    >
+      <Dragger {...props}>
+        <p className="ant-upload-drag-icon">
+          <InboxOutlined />
+        </p>
+        <p className="ant-upload-text">
+          Click or drag file to this area to upload
+        </p>
+        <p className="ant-upload-hint">
+          Support for a single {replaceId ? "" : "or bulk"} upload. Strictly
+          prohibit from uploading company data or other band files
+        </p>
+      </Dragger>
+    </Modal>
+  );
+};
+
+export default AttachmentImportWidget;

+ 510 - 0
dashboard-v6/src/components/attachment/AttachmentList.tsx

@@ -0,0 +1,510 @@
+import { useIntl } from "react-intl";
+import {
+  Button,
+  Space,
+  Table,
+  Dropdown,
+  message,
+  Modal,
+  Typography,
+  Image,
+  Segmented,
+} from "antd";
+import {
+  PlusOutlined,
+  ExclamationCircleOutlined,
+  FileOutlined,
+  AudioOutlined,
+  FileImageOutlined,
+  MoreOutlined,
+  BarsOutlined,
+  AppstoreOutlined,
+} from "@ant-design/icons";
+
+import { type ActionType, ProList } from "@ant-design/pro-components";
+
+import type { IUserDictDeleteRequest } from "../../api/Dict";
+import { delete_2, get, put } from "../../request";
+import { useRef, useState } from "react";
+import type { IDeleteResponse } from "../../api/Article";
+import TimeShow from "../general/TimeShow";
+import { getSorterUrl } from "../../utils";
+import {
+  deleteRes,
+  type IAttachmentListResponse,
+  type IAttachmentRequest,
+  type IAttachmentResponse,
+  type IAttachmentUpdate,
+} from "../../api/Attachments";
+import { VideoIcon } from "../../assets/icon";
+import AttachmentImport from "./AttachmentImport";
+
+import FileSize from "../general/FileSize";
+import modal from "antd/lib/modal";
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
+import VideoModal from "../video/VideoModal";
+
+const { Text } = Typography;
+
+export interface IAttachment {
+  id: string;
+  name: string;
+  filename: string;
+  title: string;
+  size: number;
+  content_type: string;
+  url: string;
+}
+interface IParams {
+  content_type?: string;
+}
+interface IWidget {
+  studioName?: string;
+  view?: "studio" | "all";
+  multiSelect?: boolean;
+  onClick?: (record: IAttachmentRequest) => void;
+}
+const AttachmentWidget = ({
+  studioName,
+  view = "studio",
+  multiSelect = false,
+  onClick,
+}: IWidget) => {
+  const intl = useIntl();
+  const [replaceId, setReplaceId] = useState<string>();
+  const [importOpen, setImportOpen] = useState(false);
+  const [imgVisible, setImgVisible] = useState(false);
+  const [imgPrev, setImgPrev] = useState<string>();
+  const [list, setList] = useState("list");
+
+  const [videoVisible, setVideoVisible] = useState(false);
+  const [videoUrl, setVideoUrl] = useState<string>();
+
+  const user = useAppSelector(currentUser);
+
+  const currStudio = studioName ? studioName : user?.realName;
+
+  const showDeleteConfirm = (id: string[], title: string) => {
+    Modal.confirm({
+      icon: <ExclamationCircleOutlined />,
+      title:
+        intl.formatMessage({
+          id: "message.delete.confirm",
+        }) +
+        intl.formatMessage({
+          id: "message.irrevocable",
+        }),
+
+      content: title,
+      okText: intl.formatMessage({
+        id: "buttons.delete",
+      }),
+      okType: "danger",
+      cancelText: intl.formatMessage({
+        id: "buttons.no",
+      }),
+      onOk() {
+        console.log("delete", id);
+        return delete_2<IUserDictDeleteRequest, IDeleteResponse>(
+          `/v2/userdict/${id}`,
+          {
+            id: JSON.stringify(id),
+          }
+        )
+          .then((json) => {
+            if (json.ok) {
+              message.success("删除成功");
+              ref.current?.reload();
+            } else {
+              message.error(json.message);
+            }
+          })
+          .catch((e: unknown) => console.log("Oops errors!", e));
+      },
+    });
+  };
+
+  const ref = useRef<ActionType | null>(null);
+
+  return (
+    <>
+      <ProList<IAttachmentRequest, IParams>
+        actionRef={ref}
+        editable={{
+          onSave: async (key, record, originRow) => {
+            console.log(key, record, originRow);
+            const url = `/v2/attachment/${key}`;
+            const res = await put<IAttachmentUpdate, IAttachmentResponse>(url, {
+              title: record.title,
+            });
+            return res.ok;
+          },
+        }}
+        ghost={list === "list" ? false : true}
+        onItem={(record: IAttachmentRequest) => {
+          return {
+            onClick: () => {
+              // 点击行
+              if (typeof onClick !== "undefined") {
+                onClick(record);
+              }
+            },
+          };
+        }}
+        //TODO remove meta
+        metas={{
+          title: {
+            dataIndex: "title",
+            search: false,
+            render: (_dom, entity) => {
+              return (
+                <Button
+                  type="link"
+                  onClick={() => {
+                    const ct = entity.content_type.split("/");
+                    switch (ct[0]) {
+                      case "image":
+                        setImgPrev(entity.url);
+                        setImgVisible(true);
+                        break;
+                      case "video":
+                        setVideoUrl(entity.url);
+                        setVideoVisible(true);
+                        break;
+                      default:
+                        break;
+                    }
+                  }}
+                >
+                  {entity.title}
+                </Button>
+              );
+            },
+          },
+          description: {
+            editable: false,
+            search: false,
+            render: (_dom, entity) => {
+              return (
+                <Text type="secondary">
+                  <Space>
+                    {entity.content_type}
+                    <FileSize size={entity.size} />
+                    <TimeShow
+                      type="secondary"
+                      createdAt={entity.created_at}
+                      updatedAt={entity.updated_at}
+                    />
+                  </Space>
+                </Text>
+              );
+            },
+          },
+          content:
+            list === "list"
+              ? { editable: false, search: false }
+              : {
+                  editable: false,
+                  search: false,
+                  render: (_dom, entity) => {
+                    const thumbnail = entity.thumbnail
+                      ? entity.thumbnail.middle
+                      : entity.url;
+
+                    return (
+                      <Image
+                        src={thumbnail}
+                        preview={{
+                          src: entity.url,
+                        }}
+                      />
+                    );
+                  },
+                },
+          avatar: {
+            editable: false,
+            search: false,
+            render: (_dom, entity) => {
+              const ct = entity.content_type.split("/");
+              let icon = <FileOutlined />;
+              switch (ct[0]) {
+                case "video":
+                  icon = <VideoIcon />;
+                  break;
+                case "audio":
+                  icon = <AudioOutlined />;
+                  break;
+                case "image":
+                  icon = <FileImageOutlined />;
+
+                  break;
+              }
+              return icon;
+            },
+          },
+          actions: {
+            render: (_text, row, _index, action) => {
+              return [
+                <Button
+                  type="link"
+                  size="small"
+                  onClick={() => {
+                    action?.startEditable(row.id);
+                  }}
+                >
+                  编辑
+                </Button>,
+                <Dropdown
+                  menu={{
+                    items: [
+                      { label: "链接", key: "link" },
+                      {
+                        label: "Markdown",
+                        key: "markdown",
+                        disabled: row.content_type.includes("image")
+                          ? false
+                          : true,
+                      },
+                      { label: "替换", key: "replace" },
+                      { label: "引用模版", key: "tpl" },
+                      { label: "删除", key: "delete", danger: true },
+                    ],
+                    onClick: (e) => {
+                      console.log("click ", e.key);
+                      switch (e.key) {
+                        case "link": {
+                          const link = `/attachments/${row.filename}`;
+                          navigator.clipboard.writeText(link).then(() => {
+                            message.success("已经拷贝到剪贴板");
+                          });
+                          break;
+                        }
+                        case "markdown": {
+                          const markdown = `![${row.title}](/attachments/${row.filename})`;
+                          navigator.clipboard.writeText(markdown).then(() => {
+                            message.success("已经拷贝到剪贴板");
+                          });
+                          break;
+                        }
+                        case "replace":
+                          setReplaceId(row.id);
+                          setImportOpen(true);
+                          break;
+                        case "delete":
+                          modal.confirm({
+                            title: intl.formatMessage({
+                              id: "message.delete.confirm",
+                            }),
+                            icon: <ExclamationCircleOutlined />,
+                            content: intl.formatMessage({
+                              id: "message.irrevocable",
+                            }),
+                            okText: "确认",
+                            cancelText: "取消",
+                            okType: "danger",
+                            onOk: () => {
+                              deleteRes(row.id);
+                              ref.current?.reload();
+                            },
+                          });
+                          break;
+                        default:
+                          break;
+                      }
+                    },
+                  }}
+                  placement="bottomRight"
+                >
+                  <Button
+                    type="link"
+                    size="small"
+                    icon={<MoreOutlined />}
+                    onClick={(e) => e.preventDefault()}
+                  />
+                </Dropdown>,
+              ];
+            },
+          },
+          content_type: {
+            // 自己扩展的字段,主要用于筛选,不在列表中显示
+            editable: false,
+            title: "类型",
+            valueType: "select",
+            valueEnum: {
+              all: { text: "全部", status: "Default" },
+              image: {
+                text: "图片",
+                status: "Error",
+              },
+              video: {
+                text: "视频",
+                status: "Success",
+              },
+              audio: {
+                text: "音频",
+                status: "Processing",
+              },
+            },
+          },
+        }}
+        rowSelection={
+          view === "all"
+            ? undefined
+            : multiSelect
+              ? {
+                  // 自定义选择项参考: https://ant.design/components/table-cn/#components-table-demo-row-selection-custom
+                  // 注释该行则默认不显示下拉选项
+                  selections: [Table.SELECTION_ALL, Table.SELECTION_INVERT],
+                }
+              : undefined
+        }
+        tableAlertRender={
+          view === "all"
+            ? undefined
+            : ({ selectedRowKeys, onCleanSelected }) => (
+                <Space size={24}>
+                  <span>
+                    {intl.formatMessage({ id: "buttons.selected" })}
+                    {selectedRowKeys.length}
+                    <Button
+                      type="link"
+                      style={{ marginInlineStart: 8 }}
+                      onClick={onCleanSelected}
+                    >
+                      {intl.formatMessage({ id: "buttons.unselect" })}
+                    </Button>
+                  </span>
+                </Space>
+              )
+        }
+        tableAlertOptionRender={
+          view === "all"
+            ? undefined
+            : ({ selectedRowKeys, onCleanSelected }) => {
+                return (
+                  <Space size={16}>
+                    <Button
+                      type="link"
+                      onClick={() => {
+                        console.log(selectedRowKeys);
+                        showDeleteConfirm(
+                          selectedRowKeys.map((item) => item.toString()),
+                          selectedRowKeys.length + "个单词"
+                        );
+                        onCleanSelected();
+                      }}
+                    >
+                      批量删除
+                    </Button>
+                  </Space>
+                );
+              }
+        }
+        request={async (params = {}, sorter, filter) => {
+          console.log(params, sorter, filter);
+          const offset =
+            ((params.current ? params.current : 1) - 1) *
+            (params.pageSize ? params.pageSize : 20);
+
+          let url = "/v2/attachment?";
+          switch (view) {
+            case "studio":
+              url += `view=studio&studio=${currStudio}`;
+              break;
+            case "all":
+              url += `view=all`;
+              break;
+            default:
+              break;
+          }
+          url += `&limit=${params.pageSize}&offset=${offset}`;
+
+          url += params.keyword ? "&search=" + params.keyword : "";
+          if (params.content_type && params.content_type !== "all") {
+            url += "&content_type=" + params.content_type;
+          }
+
+          url += getSorterUrl(sorter);
+
+          console.log(url);
+          const res = await get<IAttachmentListResponse>(url);
+          return {
+            total: res.data.count,
+            success: res.ok,
+            data: res.data.rows,
+          };
+        }}
+        rowKey="id"
+        bordered
+        pagination={{
+          showQuickJumper: true,
+          showSizeChanger: true,
+        }}
+        search={{
+          filterType: "light",
+        }}
+        options={{
+          search: true,
+        }}
+        grid={list === "list" ? undefined : { gutter: 16, column: 3 }}
+        headerTitle=""
+        toolBarRender={() => [
+          <Segmented
+            options={[
+              { label: "List", value: "list", icon: <BarsOutlined /> },
+              {
+                label: "Thumbnail",
+                value: "thumbnail",
+                icon: <AppstoreOutlined />,
+              },
+            ]}
+            onChange={(value) => {
+              console.log(value); // string
+              setList(value.toString());
+            }}
+          />,
+          <Button
+            key="button"
+            icon={<PlusOutlined />}
+            type="primary"
+            onClick={() => {
+              setReplaceId(undefined);
+              setImportOpen(true);
+            }}
+            disabled={view === "all"}
+          >
+            {intl.formatMessage({ id: "buttons.import" })}
+          </Button>,
+        ]}
+      />
+      <AttachmentImport
+        replaceId={replaceId}
+        open={importOpen}
+        onOpenChange={(open: boolean) => {
+          setImportOpen(open);
+          ref.current?.reload();
+        }}
+      />
+
+      <Image
+        width={200}
+        style={{ display: "none" }}
+        preview={{
+          visible: imgVisible,
+          src: imgPrev,
+          onVisibleChange: (value) => {
+            setImgVisible(value);
+          },
+        }}
+      />
+      <VideoModal
+        src={videoUrl}
+        open={videoVisible}
+        onOpenChange={(open: boolean) => setVideoVisible(open)}
+      />
+    </>
+  );
+};
+
+export default AttachmentWidget;

+ 25 - 0
dashboard-v6/src/components/dict/Lookup.tsx

@@ -0,0 +1,25 @@
+import { Typography } from "antd";
+import { lookup } from "../../reducers/command";
+import store from "../../store";
+
+interface IWidget {
+  search?: string;
+  children?: React.ReactNode;
+}
+const LookupWidget = ({ search, children }: IWidget) => {
+  return (
+    <Typography.Text
+      style={{ cursor: "pointer" }}
+      onClick={() => {
+        //发送点词查询消息
+        if (typeof search === "string") {
+          store.dispatch(lookup(search));
+        }
+      }}
+    >
+      {children}
+    </Typography.Text>
+  );
+};
+
+export default LookupWidget;

+ 29 - 0
dashboard-v6/src/components/right-panel/RightPanel.tsx

@@ -0,0 +1,29 @@
+import React from "react";
+import { AndroidOutlined, AppleOutlined } from "@ant-design/icons";
+import { Tabs, type TabsProps } from "antd";
+
+const RightPanel: React.FC = () => {
+  const items: TabsProps["items"] = [
+    {
+      key: "1",
+      label: "",
+      children: "Content of Tab Pane 1",
+      icon: <AndroidOutlined />,
+    },
+    {
+      key: "2",
+      label: "",
+      icon: <AppleOutlined />,
+      children: "Content of Tab Pane 2",
+    },
+    {
+      key: "3",
+      label: "",
+      icon: <AndroidOutlined />,
+      children: "Content of Tab Pane 3",
+    },
+  ];
+  return <Tabs defaultActiveKey="2" tabPlacement="end" items={items} />;
+};
+
+export default RightPanel;

+ 131 - 0
dashboard-v6/src/components/tag/TagCreate.tsx

@@ -0,0 +1,131 @@
+import { useIntl } from "react-intl";
+import {
+  ProForm,
+  type ProFormInstance,
+  ProFormSelect,
+  ProFormText,
+} from "@ant-design/pro-components";
+import { Tag, message } from "antd";
+
+import { get, post, put } from "../../request";
+import { useRef } from "react";
+import type { ITagRequest, ITagResponse } from "../../api/Tag";
+
+interface IWidgetCourseCreate {
+  studio?: string;
+  tagId?: string;
+  onCreate?: () => void;
+}
+const TagCreateWidget = ({ studio, tagId, onCreate }: IWidgetCourseCreate) => {
+  const intl = useIntl();
+  const formRef = useRef<ProFormInstance | undefined>(undefined);
+
+  const _color = [
+    "b60205",
+    "d93f0b",
+    "fbca04",
+    "0e8a16",
+    "006b75",
+    "1d76db",
+    "0052cc",
+    "5319e7",
+    "e99695",
+    "f9d0c4",
+    "fef2c0",
+    "c2e0c6",
+    "bfdadc",
+    "c5def5",
+    "bfd4f2",
+    "d4c5f9",
+  ];
+  const colorOptions = _color.map((item) => {
+    return {
+      value: parseInt(item, 16),
+      label: <Tag color={`#${item}`}>{item}</Tag>,
+    };
+  });
+
+  return (
+    <ProForm<ITagRequest>
+      formRef={formRef}
+      onFinish={async (values: ITagRequest) => {
+        console.log(values);
+        if (studio) {
+          values.studio = studio;
+          let url = `/v2/tag`;
+          if (tagId) {
+            url += `/${tagId}`;
+          }
+          console.info("CourseCreateWidget api request", url, values);
+          let res: ITagResponse;
+          if (tagId) {
+            res = await put<ITagRequest, ITagResponse>(url, values);
+          } else {
+            res = await post<ITagRequest, ITagResponse>(url, values);
+          }
+
+          console.info("CourseCreateWidget api response", res);
+          if (res.ok) {
+            message.success(intl.formatMessage({ id: "flashes.success" }));
+            formRef.current?.resetFields(["title"]);
+            if (typeof onCreate !== "undefined") {
+              onCreate();
+            }
+          } else {
+            message.error(res.message);
+          }
+        } else {
+          console.error("no studio");
+        }
+      }}
+      request={
+        tagId
+          ? async () => {
+              const url = `/v2/tag/${tagId}`;
+              console.info("api request", url);
+              const res = await get<ITagResponse>(url);
+              console.info("api response", res);
+              return res.data;
+            }
+          : undefined
+      }
+    >
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="name"
+          required
+          label={intl.formatMessage({ id: "forms.fields.name.label" })}
+          rules={[
+            {
+              max: 32,
+              min: 1,
+            },
+          ]}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormText
+          width="md"
+          name="description"
+          label={intl.formatMessage({ id: "forms.fields.description.label" })}
+          rules={[
+            {
+              max: 256,
+            },
+          ]}
+        />
+      </ProForm.Group>
+      <ProForm.Group>
+        <ProFormSelect
+          width="md"
+          name="color"
+          label={intl.formatMessage({ id: "forms.fields.color.label" })}
+          options={colorOptions}
+        />
+      </ProForm.Group>
+    </ProForm>
+  );
+};
+
+export default TagCreateWidget;

+ 160 - 0
dashboard-v6/src/components/tag/TagList.tsx

@@ -0,0 +1,160 @@
+import { type ActionType, ProList } from "@ant-design/pro-components";
+import { Button, Popover, Tag } from "antd";
+import { PlusOutlined } from "@ant-design/icons";
+
+import type { ITagData, ITagResponseList } from "../../api/Tag";
+import { getSorterUrl, numToHex } from "../../utils";
+import { get } from "../../request";
+import { useRef, useState } from "react";
+import TagCreate from "./TagCreate";
+import { useIntl } from "react-intl";
+import { Link } from "react-router";
+
+interface IWidget {
+  studioName?: string;
+  readonly?: boolean;
+  onSelect?: (entity: ITagData) => void;
+}
+
+const TagsList = ({ studioName, readonly = false, onSelect }: IWidget) => {
+  const intl = useIntl(); //i18n
+  const ref = useRef<ActionType | null>(null);
+  const [openCreate, setOpenCreate] = useState(false);
+  return (
+    <ProList<ITagData>
+      actionRef={ref}
+      toolBarRender={() => {
+        return readonly
+          ? [
+              <Link to={`/studio/${studioName}/tags/list`} target="_blank">
+                {intl.formatMessage({ id: "buttons.manage" })}
+              </Link>,
+            ]
+          : [
+              <Popover
+                content={
+                  <TagCreate
+                    studio={studioName}
+                    onCreate={() => {
+                      //新建课程成功后刷新
+
+                      ref.current?.reload();
+                      setOpenCreate(false);
+                    }}
+                  />
+                }
+                title="Create"
+                placement="bottomRight"
+                trigger="click"
+                open={openCreate}
+                onOpenChange={(newOpen: boolean) => {
+                  setOpenCreate(newOpen);
+                }}
+              >
+                <Button key="button" icon={<PlusOutlined />} type="primary">
+                  {intl.formatMessage({ id: "buttons.create" })}
+                </Button>
+              </Popover>,
+            ];
+      }}
+      search={{
+        filterType: "light",
+      }}
+      rowKey="name"
+      request={async (params = {}, sorter, filter) => {
+        console.log(params, sorter, filter);
+        let url = `/v2/tag?view=studio&name=${studioName}`;
+        const offset =
+          ((params.current ? params.current : 1) - 1) *
+          (params.pageSize ? params.pageSize : 20);
+        url += `&limit=${params.pageSize}&offset=${offset}`;
+        url += params.keyword ? "&search=" + params.keyword : "";
+
+        url += getSorterUrl(sorter);
+
+        console.info("api request", url);
+        const res = await get<ITagResponseList>(url);
+        console.info("api response", res);
+        return {
+          total: res.data.count,
+          succcess: true,
+          data: res.data.rows,
+        };
+      }}
+      pagination={{
+        pageSize: 10,
+      }}
+      options={{
+        search: true,
+      }}
+      metas={{
+        title: {
+          dataIndex: "name",
+          title: "用户",
+          search: false,
+          render(_dom, entity) {
+            return (
+              <Tag
+                color={"#" + numToHex(entity.color ?? 13684944)}
+                onClick={() => {
+                  if (typeof onSelect !== "undefined") {
+                    onSelect(entity);
+                  }
+                }}
+              >
+                {entity.name}
+              </Tag>
+            );
+          },
+        },
+        subTitle: {
+          dataIndex: "description",
+          search: false,
+        },
+        actions: readonly
+          ? undefined
+          : {
+              render: (_dom, entity) => [
+                <Link to={`/studio/${studioName}/tags/${entity.id}/edit`}>
+                  {"edit"}
+                </Link>,
+                <Button danger>{"delete"}</Button>,
+              ],
+              search: false,
+            },
+        status: {
+          // 自己扩展的字段,主要用于筛选,不在列表中显示
+          title: "排序",
+          valueType: "select",
+          valueEnum: {
+            all: { text: "全部", status: "Default" },
+            open: {
+              text: "未解决",
+              status: "Error",
+            },
+            closed: {
+              text: "已解决",
+              status: "Success",
+            },
+            processing: {
+              text: "解决中",
+              status: "Processing",
+            },
+          },
+        },
+      }}
+      onItem={(record: ITagData) => {
+        return {
+          onClick: () => {
+            // 点击行
+            if (typeof onSelect !== "undefined") {
+              onSelect(record);
+            }
+          },
+        };
+      }}
+    />
+  );
+};
+
+export default TagsList;

+ 50 - 0
dashboard-v6/src/components/tag/TagSelect.tsx

@@ -0,0 +1,50 @@
+import { useState } from "react";
+import { Modal } from "antd";
+import TagList from "./TagList";
+import type { ITagData } from "../../api/Tag";
+
+interface IWidget {
+  studioName?: string;
+  trigger?: React.ReactNode;
+  onSelect?: (tag: ITagData) => void;
+}
+const TagSelectWidget = ({ studioName, trigger, onSelect }: IWidget) => {
+  const [isModalOpen, setIsModalOpen] = useState(false);
+
+  const showModal = () => {
+    setIsModalOpen(true);
+  };
+
+  const handleOk = () => {
+    setIsModalOpen(false);
+  };
+
+  const handleCancel = () => {
+    setIsModalOpen(false);
+  };
+
+  return (
+    <>
+      <span onClick={showModal}>{trigger}</span>
+      <Modal
+        width={500}
+        title="标签列表"
+        open={isModalOpen}
+        onOk={handleOk}
+        onCancel={handleCancel}
+      >
+        <TagList
+          readonly
+          studioName={studioName}
+          onSelect={(tag: ITagData) => {
+            if (typeof onSelect !== "undefined") {
+              onSelect(tag);
+            }
+          }}
+        />
+      </Modal>
+    </>
+  );
+};
+
+export default TagSelectWidget;

+ 62 - 0
dashboard-v6/src/components/tag/TagSelectButton.tsx

@@ -0,0 +1,62 @@
+import { Button } from "antd";
+import { TagOutlined } from "@ant-design/icons";
+
+import { useAppSelector } from "../../hooks";
+import { courseInfo } from "../../reducers/current-course";
+import { currentUser } from "../../reducers/current-user";
+import TagsManager from "./TagsManager";
+import type { ITagMapData } from "../../api/Tag";
+
+interface IWidget {
+  resId?: string;
+  resType?: string;
+  disabled?: boolean;
+  selectorTitle?: React.ReactNode;
+  trigger?: React.ReactNode;
+  onOpen?: () => void;
+  onCreate?: (tags: ITagMapData[]) => void;
+}
+
+const TagSelectButtonWidget = ({
+  resId,
+  resType,
+  disabled = false,
+  selectorTitle,
+  trigger,
+  onOpen,
+}: IWidget) => {
+  const course = useAppSelector(courseInfo);
+  const user = useAppSelector(currentUser);
+
+  const studioName =
+    course?.course?.studio?.realName ?? user?.nickName ?? undefined;
+
+  return (
+    <TagsManager
+      title={selectorTitle}
+      studioName={studioName}
+      courseId={course?.courseId}
+      resId={resId}
+      resType={resType}
+      trigger={
+        trigger ?? (
+          <Button
+            disabled={disabled}
+            type="text"
+            icon={
+              <TagOutlined
+                onClick={() => {
+                  if (typeof onOpen !== "undefined") {
+                    onOpen();
+                  }
+                }}
+              />
+            }
+          />
+        )
+      }
+    />
+  );
+};
+
+export default TagSelectButtonWidget;

+ 95 - 0
dashboard-v6/src/components/tag/TagShow.tsx

@@ -0,0 +1,95 @@
+import { type ActionType, ProList } from "@ant-design/pro-components";
+import { Button } from "antd";
+
+import type { ITagMapData, ITagMapResponseList } from "../../api/Tag";
+import { getSorterUrl } from "../../utils";
+import { get } from "../../request";
+import { useRef } from "react";
+
+interface IWidget {
+  tagId?: string;
+}
+
+const TagsList = ({ tagId }: IWidget) => {
+  const ref = useRef<ActionType | null>(null);
+  const pageSize = 10;
+
+  return (
+    <ProList<ITagMapData>
+      actionRef={ref}
+      search={{
+        filterType: "light",
+      }}
+      rowKey="name"
+      request={async (params = {}, sorter, filter) => {
+        console.log(params, sorter, filter);
+        let url = `/v2/tag-map?view=items&tag_id=${tagId}`;
+        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 += getSorterUrl(sorter);
+
+        console.info("api request", url);
+        const res = await get<ITagMapResponseList>(url);
+        console.info("api response", res);
+        return {
+          total: res.data.count,
+          succcess: true,
+          data: res.data.rows,
+        };
+      }}
+      pagination={{
+        pageSize: pageSize,
+      }}
+      options={{
+        search: true,
+      }}
+      metas={{
+        title: {
+          dataIndex: "title",
+          title: "title",
+          search: false,
+          render(_dom, entity) {
+            return <>{entity.title}</>;
+          },
+        },
+        subTitle: {
+          dataIndex: "description",
+          search: false,
+        },
+        actions: {
+          render: () => [
+            <Button>{"edit"}</Button>,
+            <Button danger>{"delete"}</Button>,
+          ],
+          search: false,
+        },
+        status: {
+          // 自己扩展的字段,主要用于筛选,不在列表中显示
+          title: "排序",
+          valueType: "select",
+          valueEnum: {
+            all: { text: "全部", status: "Default" },
+            open: {
+              text: "未解决",
+              status: "Error",
+            },
+            closed: {
+              text: "已解决",
+              status: "Success",
+            },
+            processing: {
+              text: "解决中",
+              status: "Processing",
+            },
+          },
+        },
+      }}
+    />
+  );
+};
+
+export default TagsList;

+ 68 - 0
dashboard-v6/src/components/tag/TagsArea.tsx

@@ -0,0 +1,68 @@
+import { Badge, Popover, Tag } from "antd";
+import type { ITagMapData } from "../../api/Tag";
+
+import { useAppSelector } from "../../hooks";
+import { tagList } from "../../reducers/discussion-count";
+
+import TagSelectButton from "./TagSelectButton";
+import { numToHex } from "../../utils";
+
+interface IWidget {
+  data?: ITagMapData[];
+  max?: number;
+  resId?: string;
+  resType?: string;
+  selectorTitle?: React.ReactNode;
+}
+const TagsAreaWidget = ({
+  max = 5,
+  resId,
+  resType,
+  selectorTitle,
+}: IWidget) => {
+  const tagMapList = useAppSelector(tagList);
+
+  const tags = tagMapList?.filter((v) => v.anchor_id === resId);
+
+  const currTags = tags?.map((item, id) => {
+    return id < max ? (
+      <Tag key={id} color={"#" + numToHex(item.color ?? 13684944)}>
+        {item.name}
+      </Tag>
+    ) : undefined;
+  });
+
+  const extraTags = tags?.map((item, id) => {
+    return id >= max ? (
+      <Tag key={id} color={"#" + numToHex(item.color ?? 13684944)}>
+        {item.name}
+      </Tag>
+    ) : undefined;
+  });
+  let extra = 0;
+  if (tags && typeof max !== "undefined") {
+    extra = tags.length - max;
+  }
+  if (extra < 0) {
+    extra = 0;
+  }
+
+  return (
+    <div style={{ width: "100%", lineHeight: "2em" }}>
+      <TagSelectButton
+        selectorTitle={selectorTitle}
+        resId={resId}
+        resType={resType}
+        trigger={<span style={{ cursor: "pointer" }}>{currTags}</span>}
+      />
+      <Popover content={<div>{extraTags}</div>}>
+        <Badge
+          count={extra}
+          style={{ backgroundColor: "#52c41a", cursor: "pointer" }}
+        />
+      </Popover>
+    </div>
+  );
+};
+
+export default TagsAreaWidget;

+ 60 - 0
dashboard-v6/src/components/tag/TagsManager.tsx

@@ -0,0 +1,60 @@
+import { useState } from "react";
+import { Alert, Modal } from "antd";
+
+import TagsOnItem from "./TagsOnItem";
+
+interface IWidget {
+  studioName?: string;
+  courseId?: string;
+  resId?: string;
+  resType?: string;
+  title?: React.ReactNode;
+  trigger?: React.ReactNode;
+}
+const TagsManagerWidget = ({
+  studioName,
+  courseId,
+  resId,
+  resType,
+  title,
+  trigger,
+}: IWidget) => {
+  const [isModalOpen, setIsModalOpen] = useState(false);
+
+  const showModal = () => {
+    setIsModalOpen(true);
+  };
+
+  const handleOk = () => {
+    setIsModalOpen(false);
+  };
+
+  const handleCancel = () => {
+    setIsModalOpen(false);
+  };
+
+  return (
+    <>
+      <span onClick={showModal}>{trigger}</span>
+      <Modal
+        width={500}
+        title={`${studioName}标签列表`}
+        open={isModalOpen}
+        onOk={handleOk}
+        onCancel={handleCancel}
+        destroyOnHidden
+        footer={false}
+      >
+        {title ? <Alert title={title} /> : undefined}
+        <TagsOnItem
+          studioName={studioName}
+          courseId={courseId}
+          resId={resId}
+          resType={resType}
+        />
+      </Modal>
+    </>
+  );
+};
+
+export default TagsManagerWidget;

+ 205 - 0
dashboard-v6/src/components/tag/TagsOnItem.tsx

@@ -0,0 +1,205 @@
+import { useIntl } from "react-intl";
+import { type ActionType, ProList } from "@ant-design/pro-components";
+import {
+  Button,
+  Popconfirm,
+  type PopconfirmProps,
+  Popover,
+  Tag,
+  message,
+} from "antd";
+import { PlusOutlined } from "@ant-design/icons";
+
+import type {
+  ITagData,
+  ITagMapData,
+  ITagMapRequest,
+  ITagMapResponse,
+  ITagMapResponseList,
+} from "../../api/Tag";
+import { getSorterUrl, numToHex } from "../../utils";
+import { delete_, get, post } from "../../request";
+import { useRef, useState } from "react";
+
+import TagsList from "./TagList";
+import type { IDeleteResponse } from "../../api/Article";
+import store from "../../store";
+import { tagsUpgrade } from "../../reducers/discussion-count";
+
+interface IWidget {
+  studioName?: string;
+  courseId?: string;
+  resId?: string;
+  resType?: string;
+  onSelect?: (entity: ITagMapData) => void;
+}
+
+const TagsOnItem = ({
+  studioName,
+  courseId,
+  resId,
+  resType,
+  onSelect,
+}: IWidget) => {
+  const intl = useIntl(); //i18n
+  const ref = useRef<ActionType | null>(null);
+  const [openCreate, setOpenCreate] = useState(false);
+
+  const cancel: PopconfirmProps["onCancel"] = (e) => {
+    console.debug(e);
+  };
+
+  return (
+    <ProList<ITagMapData>
+      actionRef={ref}
+      toolBarRender={() => {
+        return [
+          <Popover
+            overlayStyle={{ width: 300 }}
+            content={
+              <TagsList
+                studioName={studioName}
+                readonly
+                onSelect={async (record: ITagData) => {
+                  //新建记录
+                  const url = "/v2/tag-map";
+                  const data: ITagMapRequest = {
+                    table_name: resType,
+                    anchor_id: resId,
+                    tag_id: record.id,
+                    studio: studioName,
+                    course: courseId,
+                  };
+                  console.info("tag create api request", url, data);
+                  const json = await post<ITagMapRequest, ITagMapResponse>(
+                    url,
+                    data
+                  );
+                  console.info("tag create api response", json);
+                  if (json.ok) {
+                    //新建课程成功后刷新
+                    ref.current?.reload();
+                  } else {
+                    message.error(json.message);
+                    console.error(json.message);
+                  }
+                  setOpenCreate(false);
+                }}
+              />
+            }
+            style={{ width: 300 }}
+            title="select"
+            placement="bottom"
+            trigger="click"
+            open={openCreate}
+            onOpenChange={(newOpen: boolean) => {
+              setOpenCreate(newOpen);
+            }}
+          >
+            <Button key="button" icon={<PlusOutlined />} type="primary">
+              {intl.formatMessage({ id: "buttons.add" })}
+            </Button>
+          </Popover>,
+        ];
+      }}
+      search={{
+        filterType: "light",
+      }}
+      rowKey="name"
+      request={async (params = {}, sorter, filter) => {
+        console.log(params, sorter, filter);
+        let url = `/v2/tag-map?view=item&studio=${studioName}&res_id=${resId}`;
+        const offset =
+          ((params.current ? params.current : 1) - 1) *
+          (params.pageSize ? params.pageSize : 10);
+        url += `&limit=${params.pageSize}&offset=${offset}`;
+        url += params.keyword ? "&search=" + params.keyword : "";
+
+        url += getSorterUrl(sorter);
+
+        console.info("api request", url);
+        const res = await get<ITagMapResponseList>(url);
+        console.info("api response", res);
+        if (res.ok) {
+          if (resId) {
+            store.dispatch(
+              tagsUpgrade({
+                resId: resId,
+                tags: res.data.rows,
+              })
+            );
+          }
+        }
+        return {
+          total: res.data.count,
+          succcess: true,
+          data: res.data.rows,
+        };
+      }}
+      pagination={{
+        pageSize: 10,
+      }}
+      options={{
+        search: true,
+      }}
+      metas={{
+        title: {
+          dataIndex: "name",
+          title: "用户",
+          search: false,
+          render(_dom, entity) {
+            return (
+              <Tag
+                color={"#" + numToHex(entity.color ?? 13684944)}
+                onClick={() => {
+                  if (onSelect) {
+                    onSelect(entity);
+                  }
+                }}
+              >
+                {entity.name}
+              </Tag>
+            );
+          },
+        },
+        subTitle: {
+          dataIndex: "description",
+          search: false,
+        },
+        actions: {
+          render: (_dom, entity) => [
+            <Popconfirm
+              title="Delete the tag?"
+              onConfirm={async () => {
+                const url = `/v2/tag-map/${entity.id}?course=${courseId}`;
+                console.log("delete api request", url);
+                try {
+                  const json = await delete_<IDeleteResponse>(url);
+                  console.info("api response", json);
+                  if (json.ok) {
+                    message.success("删除成功");
+                    ref.current?.reload();
+                  } else {
+                    message.error(json.message);
+                  }
+                } catch (e) {
+                  return console.log("Oops errors!", e);
+                }
+              }}
+              onCancel={cancel}
+              okText="Yes"
+              cancelText="No"
+            >
+              <Button type="text" danger>
+                Delete
+              </Button>
+            </Popconfirm>,
+          ],
+          search: false,
+        },
+      }}
+    />
+  );
+};
+
+export default TagsOnItem;

+ 192 - 0
dashboard-v6/src/components/template/Video.tsx

@@ -0,0 +1,192 @@
+import { Button, Card, Collapse, Modal, Popover, Space } from "antd";
+import { Typography } from "antd";
+import { useState } from "react";
+import { CloseOutlined } from "@ant-design/icons";
+
+import Video from "../video/Video";
+import { VideoIcon } from "../../assets/icon";
+import type { IAttachmentResponse } from "../../api/Attachments";
+import { get } from "../../request";
+import type { TDisplayStyle } from "../../types/template";
+
+const { Text } = Typography;
+
+const getUrl = async (fileId: string) => {
+  const url = `/v2/attachment/${fileId}`;
+  const res = await get<IAttachmentResponse>(url);
+  return res.ok ? res.data.url : "";
+};
+
+const getLink = async ({ url, id }: IVideoCtl) => {
+  let link = url;
+  if (!link && id) {
+    link = await getUrl(id);
+  }
+  return link;
+};
+
+interface IVideoCtl {
+  url?: string;
+  id?: string;
+  type?: string;
+  title?: React.ReactNode;
+  style?: TDisplayStyle;
+}
+
+// ---- 所有子组件提取到顶层 ----
+
+const VideoPopover = ({ url, id, type, title }: IVideoCtl) => {
+  const [popOpen, setPopOpen] = useState(false);
+  return (
+    <Popover
+      title={
+        <div>
+          {title}
+          <Button
+            type="link"
+            icon={<CloseOutlined />}
+            onClick={() => setPopOpen(false)}
+          />
+        </div>
+      }
+      content={
+        <div style={{ width: 600, height: 480 }}>
+          <Video fileId={id} src={url} type={type} />
+        </div>
+      }
+      trigger="click"
+      placement="bottom"
+      open={popOpen}
+    >
+      <span onClick={() => setPopOpen(true)}>
+        <VideoIcon />
+        {title}
+      </span>
+    </Popover>
+  );
+};
+
+const VideoModal = ({ url, id, type, title }: IVideoCtl) => {
+  const [isModalOpen, setIsModalOpen] = useState(false);
+
+  return (
+    <>
+      <Typography.Link
+        onClick={async (e: React.MouseEvent<HTMLElement>) => {
+          if (e.ctrlKey || e.metaKey) {
+            const link = await getLink({ url, id });
+            window.open(link, "_blank");
+          } else {
+            setIsModalOpen(true);
+          }
+        }}
+      >
+        <Space>
+          <VideoIcon />
+          {title}
+        </Space>
+      </Typography.Link>
+      <Modal
+        width={800}
+        destroyOnClose
+        style={{ maxWidth: "90%", top: 20, height: 700 }}
+        title={
+          <div
+            style={{
+              display: "flex",
+              justifyContent: "space-between",
+              marginRight: 30,
+            }}
+          >
+            <Text>{title}</Text>
+          </div>
+        }
+        open={isModalOpen}
+        onOk={() => setIsModalOpen(false)}
+        onCancel={() => setIsModalOpen(false)}
+        footer={[]}
+      >
+        <div style={{ height: 550 }}>
+          <Video fileId={id} src={url} type={type} />
+        </div>
+      </Modal>
+    </>
+  );
+};
+
+const VideoCard = ({ url, id, type, title }: IVideoCtl) => (
+  <Card title={title} bodyStyle={{ width: 550, height: 420 }}>
+    <Video fileId={id} src={url} type={type} />
+  </Card>
+);
+
+const VideoWindow = ({ url, id, type }: IVideoCtl) => (
+  <div style={{ width: 550, height: 420 }}>
+    <Video fileId={id} src={url} type={type} />
+  </div>
+);
+
+const VideoToggle = ({ url, id, type, title }: IVideoCtl) => (
+  <Collapse bordered={false}>
+    <Collapse.Panel header={title} key="parent2">
+      <Video fileId={id} src={url} type={type} />
+    </Collapse.Panel>
+  </Collapse>
+);
+
+const VideoLink = ({ url, id, title }: IVideoCtl) => (
+  <Typography.Link
+    onClick={async () => {
+      const link = await getLink({ url, id });
+      window.open(link, "_blank");
+    }}
+  >
+    <Space>
+      <VideoIcon />
+      {title}
+    </Space>
+  </Typography.Link>
+);
+
+// ---- VideoCtl 主组件 ----
+
+export const VideoCtl = ({
+  url,
+  id,
+  type,
+  title,
+  style = "modal",
+}: IVideoCtl) => {
+  const props = { url, id, type, title };
+
+  switch (style) {
+    case "modal":
+      return <VideoModal {...props} />;
+    case "card":
+      return <VideoCard {...props} />;
+    case "window":
+      return <VideoWindow {...props} />;
+    case "toggle":
+      return <VideoToggle {...props} />;
+    case "link":
+      return <VideoLink {...props} />;
+    case "popover":
+      return <VideoPopover {...props} />;
+    default:
+      return <></>;
+  }
+};
+
+// ---- Widget 入口 ----
+
+interface IWidget {
+  props: string;
+  children?: React.ReactNode;
+}
+
+const VideoWidget = ({ props }: IWidget) => {
+  const prop = JSON.parse(atob(props)) as IVideoCtl;
+  return <VideoCtl {...prop} />;
+};
+
+export default VideoWidget;

+ 7 - 12
dashboard-v6/src/components/general/Video.tsx → dashboard-v6/src/components/video/Video.tsx

@@ -1,8 +1,9 @@
 import { useEffect, useRef, useState } from "react";
 import { useEffect, useRef, useState } from "react";
-import players from "video.js";
+
 import VideoPlayer from "./VideoPlayer";
 import VideoPlayer from "./VideoPlayer";
 import type { IAttachmentResponse } from "../../api/Attachments";
 import type { IAttachmentResponse } from "../../api/Attachments";
 import { get } from "../../request";
 import { get } from "../../request";
+import type Player from "video.js/dist/types/player";
 
 
 interface IWidget {
 interface IWidget {
   fileName?: string;
   fileName?: string;
@@ -11,8 +12,8 @@ interface IWidget {
   type?: string;
   type?: string;
 }
 }
 const VideoWidget = ({ fileId, src, type }: IWidget) => {
 const VideoWidget = ({ fileId, src, type }: IWidget) => {
-  const playerRef = useRef<players.Player | null>(null);
-  const [url, setUrl] = useState<string>();
+  const playerRef = useRef<Player | null>(null);
+  const [urlFetch, setUrlFetch] = useState<string>("");
 
 
   useEffect(() => {
   useEffect(() => {
     if (fileId) {
     if (fileId) {
@@ -21,19 +22,13 @@ const VideoWidget = ({ fileId, src, type }: IWidget) => {
       get<IAttachmentResponse>(url).then((json) => {
       get<IAttachmentResponse>(url).then((json) => {
         console.debug("VideoWidget api response", json);
         console.debug("VideoWidget api response", json);
         if (json.ok) {
         if (json.ok) {
-          setUrl(json.data.url);
+          setUrlFetch(json.data.url);
         }
         }
       });
       });
     }
     }
   }, [fileId]);
   }, [fileId]);
 
 
-  useEffect(() => {
-    if (src) {
-      setUrl(src);
-    }
-  }, [src]);
-
-  const handlePlayerReady = (player: players.Player) => {
+  const handlePlayerReady = (player: Player) => {
     if (playerRef.current) {
     if (playerRef.current) {
       playerRef.current = player;
       playerRef.current = player;
       player.on("waiting", () => {
       player.on("waiting", () => {
@@ -56,7 +51,7 @@ const VideoWidget = ({ fileId, src, type }: IWidget) => {
         poster: "",
         poster: "",
         sources: [
         sources: [
           {
           {
-            src: url ? url : "",
+            src: src ?? urlFetch,
             type: type ? type : "video/mp4",
             type: type ? type : "video/mp4",
           },
           },
         ],
         ],

+ 0 - 0
dashboard-v6/src/components/general/VideoModal.tsx → dashboard-v6/src/components/video/VideoModal.tsx


+ 17 - 8
dashboard-v6/src/components/general/VideoPlayer.tsx → dashboard-v6/src/components/video/VideoPlayer.tsx

@@ -1,14 +1,23 @@
 import { useRef, useEffect } from "react";
 import { useRef, useEffect } from "react";
 import videojs from "video.js";
 import videojs from "video.js";
+import type Player from "video.js/dist/types/player";
+import "video.js/dist/video-js.css";
+
+type PlayerOptions = typeof videojs.options;
 
 
 interface IProps {
 interface IProps {
-  options: videojs.PlayerOptions;
-  onReady: (player: videojs.Player) => void;
+  options: PlayerOptions;
+  onReady?: (player: Player) => void;
 }
 }
 
 
 const VideoPlayerWidget = ({ options, onReady }: IProps) => {
 const VideoPlayerWidget = ({ options, onReady }: IProps) => {
   const videoRef = useRef<HTMLDivElement>(null);
   const videoRef = useRef<HTMLDivElement>(null);
-  const playerRef = useRef<VideoJsPlayer | null>(null);
+  const playerRef = useRef<Player | null>(null);
+  const onReadyRef = useRef(onReady);
+
+  useEffect(() => {
+    onReadyRef.current = onReady;
+  }, [onReady]);
 
 
   useEffect(() => {
   useEffect(() => {
     if (!playerRef.current) {
     if (!playerRef.current) {
@@ -17,9 +26,9 @@ const VideoPlayerWidget = ({ options, onReady }: IProps) => {
 
 
       videoRef.current?.appendChild(videoElement);
       videoRef.current?.appendChild(videoElement);
 
 
-      const player = (playerRef.current = videojs(videoElement, options, () =>
-        onReady?.(player)
-      ));
+      const player = (playerRef.current = videojs(videoElement, options, () => {
+        onReadyRef.current?.(player);
+      }));
     } else {
     } else {
       const player = playerRef.current;
       const player = playerRef.current;
 
 
@@ -30,7 +39,7 @@ const VideoPlayerWidget = ({ options, onReady }: IProps) => {
         player.src(options.sources);
         player.src(options.sources);
       }
       }
     }
     }
-  }, [options, onReady]);
+  }, [options]); // 移除 onReady 依赖,改用 ref
 
 
   useEffect(() => {
   useEffect(() => {
     return () => {
     return () => {
@@ -44,7 +53,7 @@ const VideoPlayerWidget = ({ options, onReady }: IProps) => {
 
 
   return (
   return (
     <div data-vjs-player>
     <div data-vjs-player>
-      <div ref={videoRef} className="video-js" />
+      <div ref={videoRef} />
     </div>
     </div>
   );
   );
 };
 };

+ 216 - 0
dashboard-v6/src/components/video/VideoPlayerTest.tsx

@@ -0,0 +1,216 @@
+import { useState, useCallback, useRef, useMemo } from "react";
+import VideoPlayer from "./VideoPlayer";
+import type Player from "video.js/dist/types/player";
+
+const VideoPlayerTest = () => {
+  const [isPlaying, setIsPlaying] = useState(false);
+  const [currentTime, setCurrentTime] = useState(0);
+  const [duration, setDuration] = useState(0);
+  const [volume, setVolume] = useState(1);
+  const [log, setLog] = useState<string[]>([]);
+  const playerRef = useRef<Player | null>(null);
+
+  const addLog = useCallback((msg: string) => {
+    const time = new Date().toLocaleTimeString();
+    setLog((prev) => [`[${time}] ${msg}`, ...prev].slice(0, 20));
+  }, []);
+
+  const handleReady = useCallback(
+    (player: Player) => {
+      playerRef.current = player;
+      addLog("播放器已就绪");
+
+      player.on("play", () => {
+        setIsPlaying(true);
+        addLog("▶ 开始播放");
+      });
+
+      player.on("pause", () => {
+        setIsPlaying(false);
+        addLog("⏸ 暂停");
+      });
+
+      player.on("ended", () => {
+        setIsPlaying(false);
+        addLog("⏹ 播放结束");
+      });
+
+      player.on("timeupdate", () => {
+        setCurrentTime(player.currentTime() ?? 0);
+      });
+
+      player.on("loadedmetadata", () => {
+        setDuration(player.duration() ?? 0);
+        addLog(`视频时长: ${Math.round(player.duration() ?? 0)}s`);
+      });
+
+      player.on("volumechange", () => {
+        setVolume(player.volume() ?? 1);
+      });
+
+      player.on("error", () => {
+        addLog("❌ 播放出错");
+      });
+    },
+    [addLog]
+  );
+
+  const options = useMemo(
+    () => ({
+      autoplay: false,
+      controls: true,
+      responsive: true,
+      fluid: true,
+      sources: [
+        {
+          src: "https://vjs.zencdn.net/v/oceans.mp4",
+          type: "video/mp4",
+        },
+      ],
+    }),
+    []
+  );
+
+  const formatTime = (seconds: number) => {
+    const m = Math.floor(seconds / 60)
+      .toString()
+      .padStart(2, "0");
+    const s = Math.floor(seconds % 60)
+      .toString()
+      .padStart(2, "0");
+    return `${m}:${s}`;
+  };
+
+  const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const time = Number(e.target.value);
+    playerRef.current?.currentTime(time);
+    addLog(`跳转到 ${formatTime(time)}`);
+  };
+
+  const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const vol = Number(e.target.value);
+    playerRef.current?.volume(vol);
+  };
+
+  const handlePlayPause = () => {
+    const player = playerRef.current;
+    if (!player) return;
+    isPlaying ? player.pause() : player.play();
+  };
+
+  const handleMute = () => {
+    const player = playerRef.current;
+    if (!player) return;
+    player.muted(!player.muted());
+    addLog(player.muted() ? "🔇 已静音" : "🔊 取消静音");
+  };
+
+  const handleRestart = () => {
+    const player = playerRef.current;
+    if (!player) return;
+    player.currentTime(0);
+    player.play();
+    addLog("🔁 重新播放");
+  };
+
+  return (
+    <div
+      style={{
+        maxWidth: 800,
+        margin: "40px auto",
+        fontFamily: "sans-serif",
+        padding: "0 16px",
+      }}
+    >
+      <h2 style={{ marginBottom: 16 }}>🎬 VideoPlayer 测试</h2>
+
+      <VideoPlayer options={options} onReady={handleReady} />
+
+      <div
+        style={{
+          marginTop: 16,
+          padding: 16,
+          background: "#f5f5f5",
+          borderRadius: 8,
+          display: "flex",
+          flexDirection: "column",
+          gap: 12,
+        }}
+      >
+        <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
+          <button onClick={handlePlayPause} style={btnStyle}>
+            {isPlaying ? "⏸ 暂停" : "▶ 播放"}
+          </button>
+          <button onClick={handleMute} style={btnStyle}>
+            🔇 静音切换
+          </button>
+          <button onClick={handleRestart} style={btnStyle}>
+            🔁 重播
+          </button>
+        </div>
+
+        <div>
+          <label style={{ fontSize: 13, color: "#555" }}>
+            进度: {formatTime(currentTime)} / {formatTime(duration)}
+          </label>
+          <input
+            type="range"
+            min={0}
+            max={duration || 100}
+            value={currentTime}
+            onChange={handleSeek}
+            style={{ width: "100%", marginTop: 4 }}
+          />
+        </div>
+
+        <div>
+          <label style={{ fontSize: 13, color: "#555" }}>
+            音量: {Math.round(volume * 100)}%
+          </label>
+          <input
+            type="range"
+            min={0}
+            max={1}
+            step={0.01}
+            value={volume}
+            onChange={handleVolumeChange}
+            style={{ width: "100%", marginTop: 4 }}
+          />
+        </div>
+      </div>
+
+      <div style={{ marginTop: 16 }}>
+        <h4 style={{ marginBottom: 8 }}>📋 事件日志</h4>
+        <div
+          style={{
+            background: "#1e1e1e",
+            color: "#d4d4d4",
+            borderRadius: 6,
+            padding: 12,
+            height: 160,
+            overflowY: "auto",
+            fontSize: 13,
+            fontFamily: "monospace",
+          }}
+        >
+          {log.length === 0 ? (
+            <span style={{ color: "#666" }}>等待事件...</span>
+          ) : (
+            log.map((entry, i) => <div key={i}>{entry}</div>)
+          )}
+        </div>
+      </div>
+    </div>
+  );
+};
+
+const btnStyle: React.CSSProperties = {
+  padding: "6px 14px",
+  borderRadius: 6,
+  border: "1px solid #ccc",
+  background: "#fff",
+  cursor: "pointer",
+  fontSize: 14,
+};
+
+export default VideoPlayerTest;

+ 93 - 0
dashboard-v6/src/components/wbw/CaseFormula.tsx

@@ -0,0 +1,93 @@
+import { MoreOutlined } from "@ant-design/icons";
+import { Button, Dropdown, type MenuProps } from "antd";
+import { useMemo } from "react";
+import { useAppSelector } from "../../hooks";
+import type { IWbw } from "../../types/wbw";
+import { inlineDict as _inlineDict } from "../../reducers/inline-dict";
+
+interface IWidget {
+  data?: IWbw;
+  onChange?: (key: string) => void;
+}
+
+const CaseFormulaWidget = ({ data, onChange }: IWidget) => {
+  const inlineDict = useAppSelector(_inlineDict);
+
+  const formula = useMemo<MenuProps["items"]>(() => {
+    if (!data?.case?.value) return [];
+
+    const _case = data.case.value.split("#");
+    if (_case.length !== 2) return [];
+
+    let grammar = _case[1];
+    if (!grammar) return [];
+
+    let result = inlineDict.wordList.filter(
+      (word) => word.word === "_formula_" && word.grammar === grammar
+    );
+
+    if (result.length === 0) {
+      grammar = "*" + grammar.split("$").slice(1).join("$");
+      result = inlineDict.wordList.filter(
+        (word) => word.word === "_formula_" && word.grammar === grammar
+      );
+    }
+
+    const strFormula =
+      result.length > 0 && result[0].mean ? result[0].mean : "{无}";
+
+    const menu1 = strFormula.split("/").map((item) => item.split("$"));
+
+    return menu1[0].map((item1) => {
+      const children = menu1[1]
+        ? menu1[1].map((item2) => {
+            let key: string;
+            let label: string;
+
+            if (item1.includes("@")) {
+              key = item1.replace("@", item2);
+              label = key;
+            } else if (item2.includes("@")) {
+              key = item2.replace("@", item1);
+              label = key;
+            } else {
+              key = item1 + item2;
+              label = item2;
+            }
+
+            return {
+              key,
+              label: label.replaceAll("{", "").replaceAll("}", ""),
+            };
+          })
+        : undefined;
+
+      return {
+        key: item1,
+        label: item1.replace("@", "~").replaceAll("{", "").replaceAll("}", ""),
+        children,
+      };
+    });
+  }, [data, inlineDict.wordList]);
+
+  return (
+    <Dropdown
+      menu={{
+        items: formula,
+        onClick: (e) => {
+          onChange?.(e.key);
+        },
+      }}
+      placement="bottomRight"
+    >
+      <Button
+        type="text"
+        size="small"
+        icon={<MoreOutlined />}
+        onClick={(e) => e.preventDefault()}
+      />
+    </Dropdown>
+  );
+};
+
+export default CaseFormulaWidget;

+ 112 - 0
dashboard-v6/src/components/wbw/RelaGraphic.tsx

@@ -0,0 +1,112 @@
+import { Typography } from "antd";
+import { useIntl } from "react-intl";
+
+import Mermaid from "../general/Mermaid";
+import { useAppSelector } from "../../hooks";
+import { getGrammar } from "../../reducers/term-vocabulary";
+import type { IWbwRelation } from "./WbwDetailRelation";
+import type { IWbw } from "../../types/wbw";
+
+import { fullUrl } from "../../utils";
+import { relationWordId } from "./utils";
+
+const { Text } = Typography;
+
+const pureMeaning = (input: string | null | undefined) => {
+  return input
+    ? input
+        ?.replaceAll("[", "")
+        .replaceAll("]", "")
+        .replaceAll("{", "")
+        .replaceAll("}", "")
+    : "";
+};
+
+interface IWidget {
+  wbwData?: IWbw[];
+}
+const RelaGraphicWidget = ({ wbwData }: IWidget) => {
+  const terms = useAppSelector(getGrammar);
+  const intl = useIntl();
+
+  const grammarStr = (input?: string | null) => {
+    if (!input) {
+      return "";
+    }
+    const g = input.split("#");
+    const mType = g[0].replaceAll(".", "");
+    const type = intl.formatMessage({
+      id: `dict.fields.type.${mType}.short.label`,
+      defaultMessage: mType,
+    });
+    let strGrammar: string[] = [];
+    if (g.length > 1 && g[1].length > 0) {
+      strGrammar = g[1].split("$").map((item) => {
+        const mCase = item.replaceAll(".", "");
+        return intl.formatMessage({
+          id: `dict.fields.type.${mCase}.short.label`,
+          defaultMessage: mCase,
+        });
+      });
+    }
+
+    let output = type;
+    if (strGrammar.length > 0) {
+      output += `|${strGrammar.join("·")}`;
+    }
+    return output;
+  };
+
+  //根据relation 绘制关系图
+  function sent_show_rel_map(data?: IWbw[]): string {
+    let mermaid: string = "flowchart LR\n";
+
+    const relationWords = data
+      ?.filter((value) => value.relation)
+      .map((item) => {
+        if (item.relation && item.relation.value) {
+          const json: IWbwRelation[] = JSON.parse(item.relation.value);
+          const graphic = json.map((relation) => {
+            const localName = terms?.find(
+              (item) => item.word === relation.relation
+            )?.meaning;
+            const fromMeaning = pureMeaning(item.meaning?.value);
+            const fromGrammar = grammarStr(item.case?.value);
+            //查找目标意思
+            const toWord = data.find(
+              (value: IWbw) => relationWordId(value) === relation.dest_id
+            );
+            const toMeaning = pureMeaning(toWord?.meaning?.value);
+            const url = fullUrl("/term/list/" + relation.relation);
+            const toGrammar = grammarStr(toWord?.case?.value);
+            const strFrom = `${relation.sour_id}("${relation.sour_spell}<br />${fromMeaning}<br />${fromGrammar}")`;
+            const strRelation = `"<a href='${url}' target='_blank'>${relation.relation}</a><br />${localName}"`;
+            const strTo = `${relation.dest_id}("${relation.dest_spell}<br />${toMeaning}<br />${toGrammar}")`;
+            return `${strFrom} --${strRelation}--> ${strTo}\n`;
+          });
+          return graphic.join("");
+        } else {
+          return "";
+        }
+      });
+    mermaid += relationWords?.join("");
+    console.log("mermaid", mermaid);
+    return mermaid;
+  }
+  const mermaidText = sent_show_rel_map(wbwData);
+  return (
+    <div>
+      <div style={{ display: "flex", justifyContent: "space-between" }}>
+        <div>
+          <Text copyable={{ text: mermaidText, tooltips: "复制mermaid代码" }} />
+        </div>
+        <div></div>
+      </div>
+      <div>
+        <Mermaid text={mermaidText} />
+      </div>
+    </div>
+  );
+};
+
+export default RelaGraphicWidget;

+ 400 - 0
dashboard-v6/src/components/wbw/SelectCase.tsx

@@ -0,0 +1,400 @@
+import { useIntl } from "react-intl";
+import { Cascader } from "antd";
+import { useMemo, useState } from "react";
+
+interface CascaderOption {
+  value: string | number;
+  label: string;
+  children?: CascaderOption[];
+}
+interface IWidget {
+  value?: string | null;
+  readonly?: boolean;
+  onCaseChange?: (output: string) => void;
+}
+const SelectCaseWidget = ({
+  value,
+  readonly = false,
+  onCaseChange,
+}: IWidget) => {
+  const intl = useIntl();
+
+  // 用 useMemo 派生状态代替 useEffect + setState
+  const derivedValue = useMemo<(string | number)[] | undefined>(() => {
+    if (typeof value !== "string") return undefined;
+    return value
+      .replaceAll("#", "$")
+      .replaceAll(":", ".$.")
+      .split("$")
+      .map((item) => item.replaceAll(".", ""));
+  }, [value]);
+
+  const [currValue, setCurrValue] = useState<(string | number)[] | undefined>(
+    derivedValue
+  );
+
+  // 同步外部 value 变化(只在变化时更新)
+  if (
+    derivedValue &&
+    JSON.stringify(derivedValue) !== JSON.stringify(currValue)
+  ) {
+    setCurrValue(derivedValue);
+  }
+
+  const case8 = [
+    {
+      value: "nom",
+      label: intl.formatMessage({ id: "dict.fields.type.nom.label" }),
+    },
+    {
+      value: "acc",
+      label: intl.formatMessage({ id: "dict.fields.type.acc.label" }),
+    },
+    {
+      value: "gen",
+      label: intl.formatMessage({ id: "dict.fields.type.gen.label" }),
+    },
+    {
+      value: "dat",
+      label: intl.formatMessage({ id: "dict.fields.type.dat.label" }),
+    },
+    {
+      value: "inst",
+      label: intl.formatMessage({ id: "dict.fields.type.inst.label" }),
+    },
+    {
+      value: "abl",
+      label: intl.formatMessage({ id: "dict.fields.type.abl.label" }),
+    },
+    {
+      value: "loc",
+      label: intl.formatMessage({ id: "dict.fields.type.loc.label" }),
+    },
+    {
+      value: "voc",
+      label: intl.formatMessage({ id: "dict.fields.type.voc.label" }),
+    },
+    {
+      value: "?",
+      label: intl.formatMessage({ id: "dict.fields.type.?.label" }),
+    },
+  ];
+  const case2 = [
+    {
+      value: "sg",
+      label: intl.formatMessage({ id: "dict.fields.type.sg.label" }),
+      children: case8,
+    },
+    {
+      value: "pl",
+      label: intl.formatMessage({ id: "dict.fields.type.pl.label" }),
+      children: case8,
+    },
+    {
+      value: "?",
+      label: intl.formatMessage({ id: "dict.fields.type.?.label" }),
+    },
+  ];
+  const case3 = [
+    {
+      value: "m",
+      label: intl.formatMessage({ id: "dict.fields.type.m.label" }),
+      children: case2,
+    },
+    {
+      value: "nt",
+      label: intl.formatMessage({ id: "dict.fields.type.nt.label" }),
+      children: case2,
+    },
+    {
+      value: "f",
+      label: intl.formatMessage({ id: "dict.fields.type.f.label" }),
+      children: case2,
+    },
+  ];
+  const case3_ti = [
+    ...case3,
+    {
+      value: "base",
+      label: intl.formatMessage({ id: "dict.fields.type.base.label" }),
+      children: [
+        {
+          value: "base",
+          label: intl.formatMessage({ id: "dict.fields.type.base.label" }),
+        },
+        {
+          value: "prp",
+          label: intl.formatMessage({ id: "dict.fields.type.prp.label" }),
+        },
+        {
+          value: "pp",
+          label: intl.formatMessage({ id: "dict.fields.type.pp.label" }),
+        },
+        {
+          value: "fpp",
+          label: intl.formatMessage({ id: "dict.fields.type.fpp.label" }),
+        },
+      ],
+    },
+  ];
+  const case3_pron = [
+    ...case3,
+    {
+      value: "1p",
+      label: intl.formatMessage({ id: "dict.fields.type.1p.label" }),
+      children: case2,
+    },
+    {
+      value: "2p",
+      label: intl.formatMessage({ id: "dict.fields.type.2p.label" }),
+      children: case2,
+    },
+    {
+      value: "3p",
+      label: intl.formatMessage({ id: "dict.fields.type.3p.label" }),
+      children: case2,
+    },
+    {
+      value: "base",
+      label: intl.formatMessage({ id: "dict.fields.type.base.label" }),
+    },
+  ];
+  const case3_n = [
+    ...case3,
+    {
+      value: "base",
+      label: intl.formatMessage({ id: "dict.fields.type.base.label" }),
+      children: [
+        {
+          value: "m",
+          label: intl.formatMessage({ id: "dict.fields.type.m.label" }),
+        },
+        {
+          value: "nt",
+          label: intl.formatMessage({ id: "dict.fields.type.nt.label" }),
+        },
+        {
+          value: "f",
+          label: intl.formatMessage({ id: "dict.fields.type.f.label" }),
+        },
+      ],
+    },
+  ];
+  const case3_num = [
+    ...case3,
+    {
+      value: "base",
+      label: intl.formatMessage({ id: "dict.fields.type.base.label" }),
+    },
+  ];
+  const caseVerb3 = [
+    {
+      value: "pres",
+      label: intl.formatMessage({ id: "dict.fields.type.pres.label" }),
+    },
+    {
+      value: "aor",
+      label: intl.formatMessage({ id: "dict.fields.type.aor.label" }),
+    },
+    {
+      value: "fut",
+      label: intl.formatMessage({ id: "dict.fields.type.fut.label" }),
+    },
+    {
+      value: "pf",
+      label: intl.formatMessage({ id: "dict.fields.type.pf.label" }),
+    },
+    {
+      value: "imp",
+      label: intl.formatMessage({ id: "dict.fields.type.imp.label" }),
+    },
+    {
+      value: "cond",
+      label: intl.formatMessage({ id: "dict.fields.type.cond.label" }),
+    },
+    {
+      value: "opt",
+      label: intl.formatMessage({ id: "dict.fields.type.opt.label" }),
+    },
+  ];
+  const caseVerb2 = [
+    {
+      value: "sg",
+      label: intl.formatMessage({ id: "dict.fields.type.sg.label" }),
+      children: caseVerb3,
+    },
+    {
+      value: "pl",
+      label: intl.formatMessage({ id: "dict.fields.type.pl.label" }),
+      children: caseVerb3,
+    },
+  ];
+  const caseVerbInd = [
+    {
+      value: "abs",
+      label: intl.formatMessage({ id: "dict.fields.type.abs.label" }),
+    },
+    {
+      value: "ger",
+      label: intl.formatMessage({ id: "dict.fields.type.ger.label" }),
+    },
+    {
+      value: "inf",
+      label: intl.formatMessage({ id: "dict.fields.type.inf.label" }),
+    },
+  ];
+  const caseInd = [
+    {
+      value: "ind",
+      label: intl.formatMessage({ id: "dict.fields.type.ind.label" }),
+    },
+    {
+      value: "adv",
+      label: intl.formatMessage({ id: "dict.fields.type.adv.label" }),
+    },
+    {
+      value: "conj",
+      label: intl.formatMessage({ id: "dict.fields.type.conj.label" }),
+    },
+    {
+      value: "interj",
+      label: intl.formatMessage({ id: "dict.fields.type.interj.label" }),
+    },
+  ];
+  const caseOthers = [
+    {
+      value: "pre",
+      label: intl.formatMessage({ id: "dict.fields.type.pre.label" }),
+    },
+    {
+      value: "suf",
+      label: intl.formatMessage({ id: "dict.fields.type.suf.label" }),
+    },
+    {
+      value: "end",
+      label: intl.formatMessage({ id: "dict.fields.type.end.label" }),
+    },
+    {
+      value: "part",
+      label: intl.formatMessage({ id: "dict.fields.type.part.label" }),
+    },
+    {
+      value: "note",
+      label: intl.formatMessage({ id: "dict.fields.type.note.label" }),
+    },
+  ];
+  const caseVerb1 = [
+    {
+      value: "1p",
+      label: intl.formatMessage({ id: "dict.fields.type.1p.label" }),
+      children: caseVerb2,
+    },
+    {
+      value: "2p",
+      label: intl.formatMessage({ id: "dict.fields.type.2p.label" }),
+      children: caseVerb2,
+    },
+    {
+      value: "3p",
+      label: intl.formatMessage({ id: "dict.fields.type.3p.label" }),
+      children: caseVerb2,
+    },
+    {
+      value: "ind",
+      label: intl.formatMessage({ id: "dict.fields.type.ind.label" }),
+      children: caseVerbInd,
+    },
+    {
+      value: "base",
+      label: intl.formatMessage({ id: "dict.fields.type.base.label" }),
+    },
+  ];
+  const options: CascaderOption[] = [
+    {
+      value: "n",
+      label: intl.formatMessage({ id: "dict.fields.type.n.label" }),
+      children: case3_n,
+    },
+    {
+      value: "ti",
+      label: intl.formatMessage({ id: "dict.fields.type.ti.label" }),
+      children: case3_ti,
+    },
+    {
+      value: "v",
+      label: intl.formatMessage({ id: "dict.fields.type.v.label" }),
+      children: caseVerb1,
+    },
+    {
+      value: "ind",
+      label: intl.formatMessage({ id: "dict.fields.type.ind.label" }),
+      children: caseInd,
+    },
+    {
+      value: "pron",
+      label: intl.formatMessage({ id: "dict.fields.type.pron.label" }),
+      children: case3_pron,
+    },
+    {
+      value: "num",
+      label: intl.formatMessage({ id: "dict.fields.type.num.label" }),
+      children: case3_num,
+    },
+    {
+      value: "un",
+      label: intl.formatMessage({ id: "dict.fields.type.un.label" }),
+    },
+    {
+      value: "adj",
+      label: intl.formatMessage({ id: "dict.fields.type.adj.label" }),
+      children: case3_ti,
+    },
+    {
+      value: "others",
+      label: intl.formatMessage({ id: "dict.fields.type.others.label" }),
+      children: caseOthers,
+    },
+  ];
+  return (
+    <Cascader
+      disabled={readonly}
+      value={currValue}
+      options={options}
+      placeholder="Please select case"
+      onChange={(value?: (string | number)[]) => {
+        console.log("case changed", value);
+        if (typeof value === "undefined") {
+          if (typeof onCaseChange !== "undefined") {
+            onCaseChange("");
+          }
+          return;
+        }
+        let newValue: (string | number)[];
+        if (
+          value.length > 1 &&
+          value[value.length - 1] === value[value.length - 2]
+        ) {
+          newValue = value.slice(0, -1);
+        } else {
+          newValue = value;
+        }
+        setCurrValue(newValue);
+        if (typeof onCaseChange !== "undefined") {
+          let output = newValue.map((item) => `.${item}.`).join("$");
+          output = output.replace(".$.base", ":base").replace(".$.ind", ":ind");
+          if (output.indexOf("$") > 0) {
+            output =
+              output.substring(0, output.indexOf("$")) +
+              "#" +
+              output.substring(output.indexOf("$") + 1);
+          } else {
+            output += "#";
+          }
+          onCaseChange(output);
+        }
+      }}
+    />
+  );
+};
+
+export default SelectCaseWidget;

+ 153 - 0
dashboard-v6/src/components/wbw/WbwCase.tsx

@@ -0,0 +1,153 @@
+import { useMemo, type JSX } from "react";
+import { useIntl } from "react-intl";
+import { Typography, Button, Space } from "antd";
+import { SwapOutlined } from "@ant-design/icons";
+import type { MenuProps } from "antd";
+import { Dropdown } from "antd";
+import { LoadingOutlined } from "@ant-design/icons";
+
+import "./wbw.css";
+import { useAppSelector } from "../../hooks";
+import { inlineDict as _inlineDict } from "../../reducers/inline-dict";
+import WbwParent2 from "./WbwParent2";
+
+import WbwParentIcon from "./WbwParentIcon";
+import { caseInDict, errorClass } from "./utils";
+import type { IWbw, TWbwDisplayMode } from "../../types/wbw";
+
+export interface ValueType {
+  key: string;
+  label: string;
+}
+
+const { Text } = Typography;
+
+interface IWidget {
+  data: IWbw;
+  answer?: IWbw;
+  display?: TWbwDisplayMode;
+  onSplit?: (v: boolean) => void;
+  onChange?: (key: string) => void;
+}
+const WbwCaseWidget = ({
+  data,
+  answer,
+  display,
+  onSplit,
+  onChange,
+}: IWidget) => {
+  const intl = useIntl();
+
+  const inlineDict = useAppSelector(_inlineDict);
+
+  const items = useMemo(() => {
+    const defaultMenu: MenuProps["items"] = [
+      {
+        key: "loading",
+        label: (
+          <Space>
+            <LoadingOutlined />
+            {"Loading"}
+          </Space>
+        ),
+      },
+    ];
+
+    if (!data.real.value) return defaultMenu;
+
+    return caseInDict(
+      data.real.value,
+      inlineDict.wordIndex,
+      inlineDict.wordList,
+      intl
+    );
+  }, [data.real.value, inlineDict, intl]);
+
+  const onClick: MenuProps["onClick"] = (e) => {
+    console.log("click ", e);
+    if (typeof onChange !== "undefined") {
+      onChange(e.key);
+    }
+  };
+
+  const showSplit: boolean = data.factors?.value?.includes("+") ? true : false;
+  let caseElement: JSX.Element | JSX.Element[] | undefined;
+  if (display === "block") {
+    if (
+      typeof data.case?.value === "string" &&
+      data.case.value?.trim().length > 0
+    ) {
+      caseElement = data.case.value
+        .replace("#", "$")
+        .split("$")
+        .map((item, id) => {
+          if (item !== "") {
+            const strCase = item.replaceAll(".", "");
+            return (
+              <span key={id} className="case">
+                {intl.formatMessage({
+                  id: `dict.fields.type.${strCase}.short.label`,
+                  defaultMessage: strCase,
+                })}
+              </span>
+            );
+          } else {
+            return <span key={id}>-</span>;
+          }
+        });
+    } else {
+      //空白的语法信息在逐词解析模式显示占位字符串
+      caseElement = (
+        <span>{intl.formatMessage({ id: "forms.fields.case.label" })}</span>
+      );
+    }
+  }
+
+  if (
+    typeof data.real?.value === "string" &&
+    data.real.value.trim().length > 0
+  ) {
+    //非标点符号
+    const checkClass = answer
+      ? errorClass("case", data.case?.value, answer?.case?.value)
+      : "";
+    return (
+      <div className={"wbw_word_item"} style={{ display: "flex" }}>
+        <Text type="secondary">
+          <div>
+            <span className={checkClass}>
+              <Dropdown
+                key="dropdown"
+                menu={{ items, onClick }}
+                placement="bottomLeft"
+              >
+                <span>{caseElement}</span>
+              </Dropdown>
+            </span>
+            <WbwParentIcon data={data} answer={answer} />
+            <WbwParent2 data={data} />
+            {showSplit ? (
+              <Button
+                key="button"
+                className="wbw_split"
+                size="small"
+                shape="circle"
+                icon={<SwapOutlined />}
+                onClick={() => {
+                  if (typeof onSplit !== "undefined") {
+                    onSplit(true);
+                  }
+                }}
+              />
+            ) : undefined}
+          </div>
+        </Text>
+      </div>
+    );
+  } else {
+    //标点符号
+    return <></>;
+  }
+};
+
+export default WbwCaseWidget;

+ 368 - 0
dashboard-v6/src/components/wbw/WbwDetail.tsx

@@ -0,0 +1,368 @@
+import { useEffect, useState } from "react";
+import { useIntl } from "react-intl";
+import {
+  Dropdown,
+  Tabs,
+  Divider,
+  Button,
+  Switch,
+  Rate,
+  Space,
+  Tooltip,
+} from "antd";
+import {
+  SaveOutlined,
+  VerticalAlignBottomOutlined,
+  VerticalAlignTopOutlined,
+} from "@ant-design/icons";
+
+import type {
+  IWbw,
+  IWbwAttachment,
+  IWbwField,
+  TFieldName,
+} from "../../types/wbw";
+import WbwDetailBasic from "./WbwDetailBasic";
+import WbwDetailBookMark from "./WbwDetailBookMark";
+import WbwDetailNote from "./WbwDetailNote";
+import WbwDetailAdvance from "./WbwDetailAdvance";
+import { LockIcon, UnLockIcon } from "../../assets/icon";
+import type { IAttachmentRequest } from "../../api/Attachments";
+import WbwDetailAttachment from "./WbwDetailAttachment";
+import { useAppSelector } from "../../hooks";
+import { currentUser } from "../../reducers/current-user";
+//import DiscussionButton from "../discussion/DiscussionButton";
+
+import { tempSet } from "../../reducers/setting";
+import { PopPlacement } from "./WbwPali";
+import store from "../../store";
+import TagSelectButton from "../tag/TagSelectButton";
+import type { ITagMapData } from "../../api/Tag";
+
+interface IWidget {
+  data: IWbw;
+  visible?: boolean;
+  popIsTop?: boolean;
+  readonly?: boolean;
+  onClose?: () => void;
+  onSave?: (e: IWbw, isPublish: boolean, isPublic: boolean) => void;
+  onAttachmentSelectOpen?: (open: boolean) => void;
+  onTagCreate?: (tags: ITagMapData[]) => void;
+}
+const WbwDetailWidget = ({
+  data,
+  visible = true,
+  popIsTop = false,
+  readonly = false,
+  onClose,
+  onSave,
+  onAttachmentSelectOpen,
+  onTagCreate,
+}: IWidget) => {
+  const intl = useIntl();
+  const [currWbwData, setCurrWbwData] = useState<IWbw>(
+    JSON.parse(JSON.stringify(data))
+  );
+  const [tabKey, setTabKey] = useState<string>("basic");
+  const currUser = useAppSelector(currentUser);
+
+  useEffect(() => {
+    console.debug("input data", data);
+    setCurrWbwData(JSON.parse(JSON.stringify(data)));
+  }, [data]);
+
+  function fieldChanged(field: TFieldName, value: string) {
+    console.log("field", field, "value", value);
+    const origin = JSON.parse(JSON.stringify(currWbwData));
+    switch (field) {
+      case "note":
+        origin.note = { value: value, status: 7 };
+        break;
+      case "bookMarkColor":
+        origin.bookMarkColor = { value: parseInt(value), status: 7 };
+        break;
+      case "bookMarkText":
+        origin.bookMarkText = { value: value, status: 7 };
+        break;
+      case "word":
+        origin.word = { value: value, status: 7 };
+        break;
+      case "real":
+        origin.real = { value: value, status: 7 };
+        break;
+      case "meaning":
+        origin.meaning = { value: value, status: 7 };
+        break;
+      case "factors":
+        origin.factors = { value: value, status: 7 };
+        break;
+      case "factorMeaning":
+        origin.factorMeaning = { value: value, status: 7 };
+        break;
+      case "parent":
+        origin.parent = { value: value, status: 7 };
+        break;
+      case "parent2":
+        origin.parent2 = { value: value, status: 7 };
+        break;
+      case "grammar2":
+        origin.grammar2 = { value: value, status: 7 };
+        break;
+      case "case": {
+        const arrCase = value.split("#");
+        origin.case = { value: value, status: 7 };
+        origin.type = { value: arrCase[0] ? arrCase[0] : "", status: 7 };
+        origin.grammar = { value: arrCase[1] ? arrCase[1] : "", status: 7 };
+        break;
+      }
+      case "relation":
+        origin.relation = { value: value, status: 7 };
+        break;
+      case "confidence":
+        origin.confidence = parseFloat(value);
+        break;
+      case "locked":
+        origin.locked = JSON.parse(value);
+        break;
+      case "attachments":
+        //mData.attachments = value;
+        break;
+      default:
+        break;
+    }
+    console.debug("origin", origin);
+    setCurrWbwData(origin);
+  }
+
+  return (
+    <div
+      className="wbw_detail"
+      style={{
+        minWidth: 450,
+      }}
+    >
+      <Tabs
+        size="small"
+        type="card"
+        tabBarExtraContent={
+          <Space>
+            <Tooltip
+              title={popIsTop ? "底端弹窗" : "顶端弹窗"}
+              getTooltipContainer={() =>
+                document.getElementsByClassName("wbw_detail")[0] as HTMLElement
+              }
+            >
+              <Button
+                type="text"
+                icon={
+                  popIsTop ? (
+                    <VerticalAlignBottomOutlined />
+                  ) : (
+                    <VerticalAlignTopOutlined />
+                  )
+                }
+                onClick={() => {
+                  store.dispatch(
+                    tempSet({
+                      key: PopPlacement,
+                      value: !popIsTop,
+                    })
+                  );
+                }}
+              />
+            </Tooltip>
+            <TagSelectButton
+              selectorTitle={data.word.value}
+              resType="wbw"
+              resId={data.uid}
+              onOpen={() => {
+                if (onClose) {
+                  onClose();
+                }
+              }}
+              onCreate={(tags: ITagMapData[]) => {
+                if (onTagCreate) {
+                  onTagCreate(tags);
+                }
+              }}
+            />
+            {/** TODO reload
+               *             <DiscussionButton
+              initCount={data.hasComment ? 1 : 0}
+              hideCount
+              resId={data.uid}
+              resType="wbw"
+            />
+               */}
+          </Space>
+        }
+        onChange={(activeKey: string) => {
+          setTabKey(activeKey);
+        }}
+        items={[
+          {
+            label: intl.formatMessage({ id: "buttons.basic" }),
+            key: "basic",
+            children: (
+              <WbwDetailBasic
+                visible={visible && tabKey === "basic"}
+                data={currWbwData}
+                readonly={readonly}
+                onChange={(e: IWbwField) => {
+                  console.debug("WbwDetailBasic onchange", e);
+                  fieldChanged(e.field, e.value);
+                }}
+                onRelationAdd={onClose}
+              />
+            ),
+          },
+          {
+            label: intl.formatMessage({ id: "buttons.bookmark" }),
+            key: "bookmark",
+            children: (
+              <div style={{ minHeight: 270 }}>
+                <WbwDetailBookMark
+                  data={data}
+                  onChange={(e: IWbwField) => {
+                    fieldChanged(e.field, e.value);
+                  }}
+                />
+              </div>
+            ),
+          },
+          {
+            label: intl.formatMessage({ id: "buttons.note" }),
+            key: "note",
+            children: (
+              <div style={{ minHeight: 270 }}>
+                <WbwDetailNote
+                  data={data}
+                  onChange={(e: IWbwField) => {
+                    fieldChanged(e.field, e.value);
+                  }}
+                />
+              </div>
+            ),
+          },
+          {
+            label: intl.formatMessage({ id: "buttons.spell" }),
+            key: "spell",
+            children: (
+              <div style={{ minHeight: 270 }}>
+                <WbwDetailAdvance
+                  data={currWbwData}
+                  onChange={(e: IWbwField) => {
+                    fieldChanged(e.field, e.value);
+                  }}
+                />
+              </div>
+            ),
+          },
+          {
+            label: intl.formatMessage({ id: "buttons.attachments" }),
+            key: "attachments",
+            children: (
+              <div style={{ minHeight: 270 }}>
+                <WbwDetailAttachment
+                  data={currWbwData}
+                  onUpload={(fileList: IAttachmentRequest[]) => {
+                    const mData = JSON.parse(JSON.stringify(currWbwData));
+                    mData.attachments = fileList.map((item) => {
+                      return {
+                        id: item.id,
+                        title: item.title,
+                        size: item.size ? item.size : 0,
+                        content_type: item.content_type,
+                      };
+                    });
+                    setCurrWbwData(mData);
+                  }}
+                  onDialogOpen={(open: boolean) => {
+                    if (typeof onAttachmentSelectOpen !== "undefined") {
+                      onAttachmentSelectOpen(open);
+                    }
+                  }}
+                  onChange={(value?: IWbwAttachment[]) => {
+                    const mData = JSON.parse(JSON.stringify(currWbwData));
+                    mData.attachments = value;
+                    setCurrWbwData(mData);
+                    //fieldChanged(e.field, e.value);
+                  }}
+                />
+              </div>
+            ),
+          },
+        ]}
+      />
+      <Divider style={{ margin: "4px 0" }}></Divider>
+      <div style={{ display: "flex", justifyContent: "space-between" }}>
+        <Switch
+          checkedChildren={<LockIcon />}
+          unCheckedChildren={<UnLockIcon />}
+        />
+        <div>
+          {"信心指数"}
+          <Rate
+            defaultValue={data.confidence * 5}
+            onChange={(value: number) => {
+              fieldChanged("confidence", (value / 5).toString());
+            }}
+          />
+        </div>
+      </div>
+      <Divider style={{ margin: "4px 0" }}></Divider>
+      <div style={{ display: "flex", justifyContent: "space-between" }}>
+        <div>
+          <Button
+            danger
+            onClick={() => {
+              if (typeof onClose !== "undefined") {
+                onClose();
+              }
+            }}
+          >
+            {intl.formatMessage({ id: "buttons.cancel" })}
+          </Button>
+        </div>
+        <Dropdown.Button
+          disabled={readonly}
+          style={{ width: "unset" }}
+          type="primary"
+          menu={{
+            items: [
+              {
+                key: "user-dict-public",
+                label: intl.formatMessage({ id: "buttons.save.publish" }),
+                disabled: currUser?.roles?.includes("basic"),
+              },
+              {
+                key: "user-dict-private",
+                label: intl.formatMessage({ id: "buttons.save.my.dict" }),
+              },
+            ],
+            onClick: (e) => {
+              if (typeof onSave !== "undefined") {
+                //保存并发布
+                if (e.key === "user-dict-public") {
+                  onSave(currWbwData, true, true);
+                } else {
+                  onSave(currWbwData, true, false);
+                }
+              }
+            },
+          }}
+          onClick={() => {
+            if (typeof onSave !== "undefined") {
+              onSave(currWbwData, false, false);
+            }
+          }}
+        >
+          <SaveOutlined />
+          {intl.formatMessage({ id: "buttons.save" })}
+        </Dropdown.Button>
+      </div>
+    </div>
+  );
+};
+
+export default WbwDetailWidget;

+ 46 - 0
dashboard-v6/src/components/wbw/WbwDetailAdvance.tsx

@@ -0,0 +1,46 @@
+import { Input } from "antd";
+
+import type { IWbw, IWbwField } from "../../types/wbw";
+
+interface IWidget {
+  data: IWbw;
+  onChange?: (data: IWbwField) => void;
+}
+const WbwDetailAdvanceWidget = ({ data, onChange }: IWidget) => {
+  const onWordChange = (
+    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
+  ) => {
+    console.log("onWordChange:", e.target.value);
+    if (typeof onChange !== "undefined") {
+      onChange({ field: "word", value: e.target.value });
+    }
+  };
+  const onRealChange = (
+    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
+  ) => {
+    console.log("onRealChange:", e.target.value);
+    if (typeof onChange !== "undefined") {
+      onChange({ field: "real", value: e.target.value });
+    }
+  };
+  return (
+    <>
+      <div>显示</div>
+      <Input
+        showCount
+        maxLength={512}
+        defaultValue={data.word.value}
+        onChange={onWordChange}
+      />
+      <div>拼写</div>
+      <Input
+        showCount
+        maxLength={512}
+        defaultValue={data.real?.value ? data.real?.value : ""}
+        onChange={onRealChange}
+      />
+    </>
+  );
+};
+
+export default WbwDetailAdvanceWidget;

+ 41 - 0
dashboard-v6/src/components/wbw/WbwDetailAttachment.tsx

@@ -0,0 +1,41 @@
+import type { IAttachmentRequest } from "../../api/Attachments";
+import type { IWbw, IWbwAttachment } from "../../types/wbw";
+import WbwDetailUpload from "./WbwDetailUpload";
+
+interface IWidget {
+  data: IWbw;
+  onChange?: (value?: IWbwAttachment[]) => void;
+  onUpload?: (fileList: IAttachmentRequest[]) => void;
+  onDialogOpen?: (open: boolean) => void;
+}
+const WbwDetailAttachmentWidget = ({
+  data,
+  onChange,
+  onUpload,
+  onDialogOpen,
+}: IWidget) => {
+  return (
+    <div>
+      <WbwDetailUpload
+        data={data}
+        onUpload={(fileList: IAttachmentRequest[]) => {
+          if (typeof onUpload !== "undefined") {
+            onUpload(fileList);
+          }
+        }}
+        onDialogOpen={(open: boolean) => {
+          if (typeof onDialogOpen !== "undefined") {
+            onDialogOpen(open);
+          }
+        }}
+        onChange={(value) => {
+          if (typeof onChange !== "undefined") {
+            onChange(value);
+          }
+        }}
+      />
+    </div>
+  );
+};
+
+export default WbwDetailAttachmentWidget;

+ 273 - 0
dashboard-v6/src/components/wbw/WbwDetailBasic.tsx

@@ -0,0 +1,273 @@
+import { useState } from "react";
+import { useIntl } from "react-intl";
+import { Form, Input, Button, Popover } from "antd";
+import { Collapse } from "antd";
+import { MoreOutlined } from "@ant-design/icons";
+
+import type { IWbw, IWbwField } from "../../types/wbw";
+import WbwMeaningSelect from "./WbwMeaningSelect";
+
+import WbwDetailFm from "./WbwDetailFm";
+import WbwDetailParent2 from "./WbwDetailParent2";
+import WbwDetailFactor from "./WbwDetailFactor";
+import WbwDetailBasicRelation from "./WbwDetailBasicRelation";
+import WbwDetailParent from "./WbwDetailParent";
+import WbwDetailCase from "./WbwDetailCase";
+import WbwDetailOrder from "./WbwDetailOrder";
+
+const { Panel } = Collapse;
+
+export interface IWordBasic {
+  meaning?: string[];
+  case?: string;
+  factors?: string;
+  factorMeaning?: string;
+  parent?: string;
+}
+
+interface IWidget {
+  data: IWbw;
+  visible?: boolean;
+  showRelation?: boolean;
+  readonly?: boolean;
+  onChange?: (data: IWbwField) => void;
+  onRelationAdd?: () => void;
+}
+const WbwDetailBasicWidget = ({
+  data,
+  visible,
+  showRelation = true,
+  readonly = false,
+  onChange,
+  onRelationAdd,
+}: IWidget) => {
+  const [form] = Form.useForm();
+  const intl = useIntl();
+  const [factors, setFactors] = useState<string[] | undefined>(
+    data.factors?.value?.split("+")
+  );
+  const [openCreate, setOpenCreate] = useState(false);
+  const [innerMeaning, setInnerMeaning] = useState<string | undefined>();
+  const [currTip, setCurrTip] = useState(1);
+
+  const meaning = data.meaning?.value ?? innerMeaning;
+
+  const onMeaningChange = (value: string) => {
+    console.log(`Selected: ${value}`);
+    if (onChange) {
+      onChange({ field: "meaning", value: value });
+    } else {
+      setInnerMeaning(meaning);
+    }
+  };
+
+  return (
+    <>
+      <Form
+        labelCol={{ span: 4 }}
+        wrapperCol={{ span: 20 }}
+        className="wbw_detail_basic"
+        name="basic"
+        form={form}
+        initialValues={{
+          meaning: data.meaning?.value,
+          factors: data.factors?.value,
+          factorMeaning: data.factorMeaning?.value,
+          parent: data.parent?.value,
+          case: data.case?.value,
+          parent2: data.parent2?.value,
+          grammar2: data.grammar2?.value,
+        }}
+      >
+        <Form.Item
+          style={{ marginBottom: 6 }}
+          name="meaning"
+          label={intl.formatMessage({ id: "forms.fields.meaning.label" })}
+          tooltip={intl.formatMessage({ id: "forms.fields.meaning.tooltip" })}
+        >
+          <div style={{ display: "flex" }}>
+            <div style={{ display: "flex", width: "100%" }}>
+              <Input
+                disabled={readonly}
+                value={meaning}
+                allowClear
+                placeholder="请输入"
+                onChange={(e) => {
+                  console.log("meaning input", e.target.value);
+                  onMeaningChange(e.target.value);
+                }}
+              />
+              <Popover
+                content={
+                  <WbwMeaningSelect
+                    data={data}
+                    onSelect={(meaning: string) => {
+                      console.log(meaning);
+                      form.setFieldsValue({
+                        meaning: meaning,
+                      });
+                      onMeaningChange(meaning);
+                    }}
+                  />
+                }
+                trigger={readonly ? undefined : "click"}
+                styles={{ root: { width: 500 } }}
+                placement="bottom"
+                open={openCreate}
+                onOpenChange={(open: boolean) => {
+                  setOpenCreate(open);
+                }}
+              >
+                <Button
+                  disabled={readonly}
+                  type="text"
+                  icon={<MoreOutlined />}
+                />
+              </Popover>
+            </div>
+            <WbwDetailOrder
+              sn={5}
+              visible={visible}
+              curr={currTip}
+              onChange={() => setCurrTip(currTip + 1)}
+            />
+          </div>
+        </Form.Item>
+        <Form.Item
+          style={{ marginBottom: 6 }}
+          name="factors"
+          label={intl.formatMessage({ id: "forms.fields.factors.label" })}
+          tooltip={intl.formatMessage({ id: "forms.fields.factors.tooltip" })}
+        >
+          <div style={{ display: "flex" }}>
+            <WbwDetailFactor
+              readonly={readonly}
+              data={data}
+              onChange={(value: string) => {
+                setFactors(value.split("+"));
+                if (typeof onChange !== "undefined") {
+                  onChange({ field: "factors", value: value });
+                }
+              }}
+            />
+            <WbwDetailOrder
+              sn={2}
+              visible={visible}
+              curr={currTip}
+              onChange={() => setCurrTip(currTip + 1)}
+            />
+          </div>
+        </Form.Item>
+        <Form.Item
+          style={{ marginBottom: 6 }}
+          name="factorMeaning"
+          label={intl.formatMessage({
+            id: "forms.fields.factor.meaning.label",
+          })}
+          tooltip={intl.formatMessage({
+            id: "forms.fields.factor.meaning.tooltip",
+          })}
+        >
+          <div style={{ display: "flex" }}>
+            <WbwDetailFm
+              factors={factors}
+              readonly={readonly}
+              value={data.factorMeaning?.value?.split("+")}
+              onChange={(value: string[]) => {
+                console.log("fm change", value);
+                if (typeof onChange !== "undefined") {
+                  onChange({ field: "factorMeaning", value: value.join("+") });
+                }
+              }}
+              onJoin={(value: string) => {
+                onMeaningChange(value);
+              }}
+            />
+            <WbwDetailOrder
+              sn={4}
+              visible={visible}
+              curr={currTip}
+              onChange={() => setCurrTip(currTip + 1)}
+            />
+          </div>
+        </Form.Item>
+        <Form.Item
+          style={{ marginBottom: 6 }}
+          label={intl.formatMessage({ id: "forms.fields.case.label" })}
+          tooltip={intl.formatMessage({ id: "forms.fields.case.tooltip" })}
+          name="case"
+        >
+          <div style={{ display: "flex" }}>
+            <WbwDetailCase
+              readonly={readonly}
+              data={data}
+              onChange={(value: string) => {
+                if (typeof onChange !== "undefined") {
+                  onChange({ field: "case", value: value });
+                }
+              }}
+            />
+            <WbwDetailOrder
+              sn={3}
+              visible={visible}
+              curr={currTip}
+              onChange={() => setCurrTip(currTip + 1)}
+            />
+          </div>
+        </Form.Item>
+        <Form.Item
+          style={{ marginBottom: 6 }}
+          name="parent"
+          label={intl.formatMessage({
+            id: "forms.fields.parent.label",
+          })}
+          tooltip={intl.formatMessage({
+            id: "forms.fields.parent.tooltip",
+          })}
+        >
+          <div style={{ display: "flex" }}>
+            <WbwDetailParent
+              readonly={readonly}
+              data={data}
+              onChange={(value: string) => {
+                if (typeof onChange !== "undefined") {
+                  onChange({ field: "parent", value: value });
+                }
+              }}
+            />
+            <WbwDetailOrder
+              sn={1}
+              visible={visible}
+              curr={currTip}
+              onChange={() => setCurrTip(currTip + 1)}
+            />
+          </div>
+        </Form.Item>
+        <Collapse bordered={false}>
+          <Panel header="词源" key="parent2">
+            <WbwDetailParent2
+              data={data}
+              onChange={(e: IWbwField) => {
+                if (typeof onChange !== "undefined") {
+                  onChange(e);
+                }
+              }}
+            />
+          </Panel>
+        </Collapse>
+        <WbwDetailBasicRelation
+          data={data}
+          showRelation={showRelation}
+          onChange={(e: IWbwField) => {
+            if (typeof onChange !== "undefined") {
+              onChange(e);
+            }
+          }}
+          onRelationAdd={onRelationAdd}
+        />
+      </Form>
+    </>
+  );
+};
+
+export default WbwDetailBasicWidget;

+ 78 - 0
dashboard-v6/src/components/wbw/WbwDetailBasicRelation.tsx

@@ -0,0 +1,78 @@
+import { Badge, Button, Collapse, Space, Tooltip } from "antd";
+import { QuestionCircleOutlined } from "@ant-design/icons";
+import WbwDetailRelation from "./WbwDetailRelation";
+import store from "../../store";
+import { grammar } from "../../reducers/command";
+import type { IWbw, IWbwField } from "../../types/wbw";
+import { useIntl } from "react-intl";
+import { useState } from "react";
+import { openPanel } from "../../reducers/right-panel";
+
+interface IWidget {
+  data: IWbw;
+  showRelation?: boolean;
+  onChange?: (e: IWbwField) => void;
+  onRelationAdd?: () => void;
+}
+const WbwDetailBasicRelationWidget = ({
+  data,
+  showRelation,
+  onChange,
+  onRelationAdd,
+}: IWidget) => {
+  const intl = useIntl();
+  const [fromList, setFromList] = useState<string[]>();
+
+  const relationCount = data.relation?.value
+    ? JSON.parse(data.relation.value).length
+    : 0;
+  return (
+    <Collapse bordered={false} collapsible={"icon"}>
+      <Collapse.Panel
+        header={
+          <div style={{ display: "flex", justifyContent: "space-between" }}>
+            <Space>
+              {intl.formatMessage({ id: "buttons.relate" })}
+              <Badge color="geekblue" count={relationCount} />
+            </Space>
+            <Tooltip
+              title={intl.formatMessage({
+                id: "columns.library.palihandbook.title",
+              })}
+            >
+              <Button
+                type="link"
+                onClick={() => {
+                  if (fromList) {
+                    const endCase = fromList
+                      .map((item) => item + ".relations")
+                      .join(",");
+                    console.debug("from", fromList, endCase);
+                    store.dispatch(grammar(endCase));
+                    store.dispatch(openPanel("grammar"));
+                  }
+                }}
+                icon={<QuestionCircleOutlined />}
+              />
+            </Tooltip>
+          </div>
+        }
+        key="relation"
+        style={{ display: showRelation ? "block" : "none" }}
+      >
+        <WbwDetailRelation
+          data={data}
+          onChange={(e: IWbwField) => {
+            if (typeof onChange !== "undefined") {
+              onChange(e);
+            }
+          }}
+          onAdd={onRelationAdd}
+          onFromList={(value?: string[]) => setFromList(value)}
+        />
+      </Collapse.Panel>
+    </Collapse>
+  );
+};
+
+export default WbwDetailBasicRelationWidget;

+ 75 - 0
dashboard-v6/src/components/wbw/WbwDetailBookMark.tsx

@@ -0,0 +1,75 @@
+import { useState } from "react";
+import type { RadioChangeEvent } from "antd";
+import { Radio } from "antd";
+import { Input } from "antd";
+
+import type { IWbw, IWbwField } from "../../types/wbw";
+import { bookMarkColor } from "./utils";
+
+const { TextArea } = Input;
+
+interface IWidget {
+  data: IWbw;
+  onChange?: (value: IWbwField) => void;
+}
+const WbwDetailBookMarkWidget = ({ data, onChange }: IWidget) => {
+  const [value, setValue] = useState("none");
+
+  const styleColor: React.CSSProperties = {
+    display: "inline-block",
+    width: 28,
+    height: 18,
+  };
+
+  const options = bookMarkColor.map((item, id) => {
+    return {
+      label: (
+        <span
+          style={{
+            ...styleColor,
+            backgroundColor: item,
+          }}
+        ></span>
+      ),
+      value: id,
+    };
+  });
+
+  const onColorChange = ({ target: { value } }: RadioChangeEvent) => {
+    console.log("radio3 checked", value);
+    if (onChange) {
+      onChange({ field: "bookMarkColor", value: value });
+    } else {
+      setValue(value);
+    }
+  };
+  const onTextChange = (
+    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
+  ) => {
+    console.log("Change:", e.target.value);
+    if (typeof onChange !== "undefined") {
+      onChange({ field: "bookMarkText", value: e.target.value });
+    }
+  };
+  return (
+    <>
+      <Radio.Group
+        options={options}
+        defaultValue={data.bookMarkColor?.value}
+        onChange={onColorChange}
+        value={value}
+      />
+      <TextArea
+        defaultValue={
+          data.bookMarkText?.value ? data.bookMarkText?.value : undefined
+        }
+        showCount
+        maxLength={512}
+        autoSize={{ minRows: 6, maxRows: 8 }}
+        onChange={onTextChange}
+      />
+    </>
+  );
+};
+
+export default WbwDetailBookMarkWidget;

+ 60 - 0
dashboard-v6/src/components/wbw/WbwDetailCase.tsx

@@ -0,0 +1,60 @@
+import { Button, Dropdown } from "antd";
+
+import { MoreOutlined } from "@ant-design/icons";
+
+import { useAppSelector } from "../../hooks";
+import { inlineDict as _inlineDict } from "../../reducers/inline-dict";
+
+import type { IWbw } from "../../types/wbw";
+
+import { useIntl } from "react-intl";
+import { caseInDict } from "./utils";
+import SelectCase from "./SelectCase";
+
+interface IWidget {
+  data: IWbw;
+  readonly?: boolean;
+  onChange?: (value: string) => void;
+}
+const WbwDetailCaseWidget = ({ data, readonly = false, onChange }: IWidget) => {
+  const inlineDict = useAppSelector(_inlineDict);
+  const intl = useIntl();
+  console.debug("readonly", readonly);
+  return (
+    <div style={{ display: "flex", width: "100%" }}>
+      <SelectCase
+        readonly={readonly}
+        value={data.case?.value}
+        onCaseChange={(value: string) => {
+          if (typeof onChange !== "undefined") {
+            onChange(value);
+          }
+        }}
+      />
+      <Dropdown
+        trigger={readonly ? [] : ["click"]}
+        menu={{
+          items: data.real.value
+            ? caseInDict(
+                data.real.value,
+                inlineDict.wordIndex,
+                inlineDict.wordList,
+                intl
+              )
+            : [],
+          onClick: (e) => {
+            console.log("click ", e.key);
+            if (typeof onChange !== "undefined") {
+              onChange(e.key);
+            }
+          },
+        }}
+        placement="bottomRight"
+      >
+        <Button disabled={readonly} type="text" icon={<MoreOutlined />} />
+      </Dropdown>
+    </div>
+  );
+};
+
+export default WbwDetailCaseWidget;

+ 91 - 0
dashboard-v6/src/components/wbw/WbwDetailFactor.tsx

@@ -0,0 +1,91 @@
+import { AutoComplete, Input } from "antd";
+import { useCallback, useEffect, useMemo } from "react";
+import { useAppSelector } from "../../hooks";
+
+import {
+  add,
+  inlineDict as _inlineDict,
+  wordIndex,
+} from "../../reducers/inline-dict";
+import { get } from "../../request";
+import store from "../../store";
+import type { IApiResponseDictList } from "../../api/Dict";
+
+import type { IWbw } from "../../types/wbw";
+import { getFactorsInDict } from "./utils";
+
+interface ValueType {
+  key?: string;
+  label: React.ReactNode;
+  value: string | number;
+}
+interface IWidget {
+  data: IWbw;
+  readonly?: boolean;
+  onChange?: (value: string) => void;
+}
+
+const WbwDetailFactorWidget = ({
+  data,
+  readonly = false,
+  onChange,
+}: IWidget) => {
+  const inlineDict = useAppSelector(_inlineDict);
+  const inlineWordIndex = useAppSelector(wordIndex);
+
+  // 1. Wrap lookup in useCallback to stabilize the reference
+  const lookup = useCallback(
+    (words: string[]) => {
+      const search = words.filter((word) => !inlineWordIndex.includes(word));
+
+      if (search.length === 0) return;
+
+      get<IApiResponseDictList>(`/v2/wbwlookup?base=${search}`).then((json) => {
+        console.log("lookup ok", json.data.count);
+        store.dispatch(add(json.data.rows));
+      });
+    },
+    [inlineWordIndex]
+  );
+
+  // 2. Effect for external API synchronization
+  useEffect(() => {
+    const factorValue = data.factors?.value;
+    if (typeof factorValue !== "string") return;
+
+    const words = factorValue.replaceAll("-", "+").split("+");
+    lookup(words);
+  }, [data.factors?.value, lookup]);
+
+  // 3. Derived State: Calculate options during render instead of useEffect + setState
+  const factorOptions = useMemo(() => {
+    const realValue = data.real?.value;
+    if (!realValue) return [];
+
+    const factors = getFactorsInDict(
+      realValue,
+      inlineDict.wordIndex,
+      inlineDict.wordList
+    );
+
+    const options: ValueType[] = factors.map((item) => ({
+      label: item,
+      value: item,
+    }));
+
+    return [...options, { label: realValue, value: realValue }];
+  }, [data.real?.value, inlineDict.wordIndex, inlineDict.wordList]);
+
+  return (
+    <AutoComplete
+      disabled={readonly}
+      options={factorOptions}
+      value={data.factors?.value ?? ""}
+      onChange={(value: string) => onChange?.(value)}
+    >
+      <Input disabled={readonly} placeholder="请输入" allowClear />
+    </AutoComplete>
+  );
+};
+
+export default WbwDetailFactorWidget;

+ 175 - 0
dashboard-v6/src/components/wbw/WbwDetailFm.tsx

@@ -0,0 +1,175 @@
+import { Button, Input, Space, Tooltip } from "antd";
+import { useState } from "react";
+import { PlusOutlined, EditOutlined, CheckOutlined } from "@ant-design/icons";
+
+import { MergeIcon } from "../../assets/icon";
+import WbwFactorMeaningItem from "./WbwFactorMeaningItem";
+
+const resizeArray = (input: string[], factors: string[]) => {
+  const newFm = factors.map((_item, index) => {
+    if (index < input.length) {
+      return input[index];
+    } else {
+      return "";
+    }
+  });
+  return newFm;
+};
+interface IWidget {
+  factors?: string[];
+  value?: string[];
+  readonly?: boolean;
+  onChange?: (data: string[]) => void;
+  onJoin?: (newMeaning: string) => void;
+}
+const WbwDetailFmWidget = ({
+  factors = [],
+  value = [],
+  readonly = false,
+  onChange,
+  onJoin,
+}: IWidget) => {
+  console.debug("WbwDetailFmWidget render");
+  const [factorInputEnable, setFactorInputEnable] = useState(false);
+
+  const currValue = resizeArray(value, factors);
+
+  const combine = (input: string): string => {
+    let meaning = "";
+    input.split("-").forEach((value: string, index: number) => {
+      if (index === 0) {
+        meaning += value;
+      } else {
+        if (value.includes("~")) {
+          meaning = value.replace("~", meaning);
+        } else {
+          meaning += value;
+        }
+      }
+    });
+    console.debug("combine", meaning);
+    return meaning;
+  };
+  return (
+    <div className="wbw_word_item" style={{ width: "100%" }}>
+      <div style={{ display: "flex", width: "100%" }}>
+        <Input
+          key="input"
+          allowClear
+          hidden={!factorInputEnable}
+          value={currValue.join("+")}
+          placeholder="请输入"
+          onChange={(e) => {
+            console.log(e.target.value);
+            const newData = resizeArray(e.target.value.split("+"), factors);
+            if (typeof onChange !== "undefined") {
+              onChange(newData);
+            }
+          }}
+        />
+        {factorInputEnable ? (
+          <Button
+            key="input-button"
+            type="text"
+            icon={<CheckOutlined />}
+            onClick={() => setFactorInputEnable(false)}
+          />
+        ) : undefined}
+      </div>
+      {!factorInputEnable ? (
+        <Space size={0} key="space">
+          {currValue.map((item, index) => {
+            const fm = item.split("-");
+            return (
+              <span key={index} style={{ display: "flex" }}>
+                {factors[index]?.split("-").map((item1, index1) => {
+                  return (
+                    <WbwFactorMeaningItem
+                      readonly={readonly}
+                      key={index1}
+                      pali={item1}
+                      meaning={fm[index1]}
+                      onChange={(value?: string) => {
+                        if (value) {
+                          const newData = [...currValue];
+                          const currFm = resizeArray(
+                            currValue[index].split("-"),
+                            factors[index].split("-")
+                          );
+                          currFm.forEach(
+                            (
+                              _value3: string,
+                              index3: number,
+                              array: string[]
+                            ) => {
+                              if (index3 === index1) {
+                                array[index3] = value;
+                              }
+                            }
+                          );
+                          newData[index] = currFm.join("-");
+                          if (typeof onChange !== "undefined") {
+                            onChange(newData);
+                          }
+                        }
+                      }}
+                    />
+                  );
+                })}
+
+                {index < currValue.length - 1 ? (
+                  <PlusOutlined disabled={readonly} key={`icon-${index}`} />
+                ) : (
+                  <>
+                    <Tooltip title="在文本框中编辑">
+                      <Button
+                        disabled={readonly}
+                        key="EditOutlined"
+                        size="small"
+                        type="text"
+                        icon={<EditOutlined />}
+                        onClick={() => setFactorInputEnable(true)}
+                      />
+                    </Tooltip>
+                    <Tooltip title="合并后替换含义">
+                      <Button
+                        disabled={readonly}
+                        key="CheckOutlined"
+                        size="small"
+                        type="text"
+                        icon={<MergeIcon />}
+                        onClick={() => {
+                          if (typeof onJoin !== "undefined") {
+                            const newMeaning = currValue
+                              .map((item) => {
+                                return item
+                                  .replaceAll("[[", "/*")
+                                  .replaceAll("]]", "*/");
+                              })
+                              .filter((value) => !value.includes("["))
+                              .map((item) => {
+                                return item
+                                  .replaceAll("/*", "[[")
+                                  .replaceAll("*/", "]]");
+                              })
+                              .map((item) => {
+                                return combine(item);
+                              })
+                              .join("");
+                            onJoin(newMeaning);
+                          }
+                        }}
+                      />
+                    </Tooltip>
+                  </>
+                )}
+              </span>
+            );
+          })}
+        </Space>
+      ) : undefined}
+    </div>
+  );
+};
+
+export default WbwDetailFmWidget;

+ 33 - 0
dashboard-v6/src/components/wbw/WbwDetailNote.tsx

@@ -0,0 +1,33 @@
+import { Input } from "antd";
+
+import type { IWbw, IWbwField } from "../../types/wbw";
+
+const { TextArea } = Input;
+
+interface IWidget {
+  data: IWbw;
+  onChange?: (data: IWbwField) => void;
+}
+const WbwDetailNoteWidget = ({ data, onChange }: IWidget) => {
+  const onTextChange = (
+    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
+  ) => {
+    console.log("Change:", e.target.value);
+    if (typeof onChange !== "undefined") {
+      onChange({ field: "note", value: e.target.value });
+    }
+  };
+  return (
+    <>
+      <TextArea
+        defaultValue={data.note?.value ? data.note?.value : undefined}
+        showCount
+        maxLength={512}
+        autoSize={{ minRows: 8, maxRows: 10 }}
+        onChange={onTextChange}
+      />
+    </>
+  );
+};
+
+export default WbwDetailNoteWidget;

+ 58 - 0
dashboard-v6/src/components/wbw/WbwDetailOrder.tsx

@@ -0,0 +1,58 @@
+import { Button, Tooltip } from "antd";
+import { useEffect, useState } from "react";
+import { useAppSelector } from "../../hooks";
+import { settingInfo } from "../../reducers/setting";
+import { GetUserSetting } from "../setting/default";
+
+interface IWidget {
+  sn: number;
+  curr: number;
+  visible?: boolean;
+  onChange?: () => void;
+}
+
+const WbwDetailOrderWidget = ({
+  sn,
+  curr,
+  visible = false,
+  onChange,
+}: IWidget) => {
+  const [show, setShow] = useState(true);
+  const settings = useAppSelector(settingInfo);
+
+  const showOrder = GetUserSetting("setting.wbw.order", settings);
+  const enable = visible && showOrder;
+
+  useEffect(() => {
+    setShow(sn === curr);
+  }, [curr, sn]);
+
+  return enable ? (
+    <Tooltip
+      open={show}
+      placement="right"
+      getTooltipContainer={() =>
+        document.getElementsByClassName("wbw_detail")[0] as HTMLElement
+      }
+      title={
+        <Button
+          type="link"
+          size="small"
+          onClick={() => {
+            if (typeof onChange !== "undefined") {
+              onChange();
+            }
+          }}
+        >
+          {curr === 5 ? "完成" : "下一步"}
+        </Button>
+      }
+    >
+      <span style={{ display: "inline-box", width: 1, height: 30 }}></span>
+    </Tooltip>
+  ) : (
+    <></>
+  );
+};
+
+export default WbwDetailOrderWidget;

+ 68 - 0
dashboard-v6/src/components/wbw/WbwDetailParent.tsx

@@ -0,0 +1,68 @@
+import { AutoComplete, Input } from "antd";
+import { useMemo } from "react"; // 替换 useEffect, useState
+import { useAppSelector } from "../../hooks";
+import { inlineDict as _inlineDict } from "../../reducers/inline-dict";
+import type { IWbw } from "../../types/wbw";
+import { getParentInDict } from "./utils";
+
+interface IWidget {
+  data: IWbw;
+  readonly?: boolean;
+  onChange?: (value: string) => void;
+}
+
+const WbwDetailParentWidget = ({
+  data,
+  readonly = false,
+  onChange,
+}: IWidget) => {
+  const inlineDict = useAppSelector(_inlineDict);
+
+  // 使用 useMemo 代替 useState + useEffect
+  const parentOptions = useMemo(() => {
+    // 基础校验:如果没有值,返回空数组
+    if (!data.real.value) {
+      return [];
+    }
+
+    // 从字典获取父级选项
+    const parentsFromDict = getParentInDict(
+      data.word.value,
+      inlineDict.wordIndex,
+      inlineDict.wordList
+    );
+
+    const options = parentsFromDict.map((item) => ({
+      label: item,
+      value: item,
+    }));
+
+    // 检查当前值是否已在选项中
+    const exists = options.some((opt) => opt.value === data.real.value);
+
+    if (exists) {
+      return options;
+    } else {
+      // 如果不在,手动添加当前值作为选项之一
+      return [...options, { label: data.real.value, value: data.real.value }];
+    }
+  }, [inlineDict, data.word.value, data.real.value]); // 仅在依赖项变化时重新计算
+
+  return (
+    <AutoComplete
+      disabled={readonly}
+      options={parentOptions}
+      value={data.parent?.value}
+      onChange={(value: string) => {
+        console.debug("wbw parent onChange", value);
+        if (onChange) {
+          onChange(value);
+        }
+      }}
+    >
+      <Input disabled={readonly} allowClear placeholder="请输入" />
+    </AutoComplete>
+  );
+};
+
+export default WbwDetailParentWidget;

+ 96 - 0
dashboard-v6/src/components/wbw/WbwDetailParent2.tsx

@@ -0,0 +1,96 @@
+import { AutoComplete, Form, Input, Select } from "antd";
+import { useMemo } from "react"; // 替换 useEffect, useState
+import { useIntl } from "react-intl";
+import { useAppSelector } from "../../hooks";
+
+import type { IWbw, IWbwField } from "../../types/wbw";
+import { inlineDict as _inlineDict } from "../../reducers/inline-dict";
+
+interface IWidget {
+  data: IWbw;
+  onChange?: (value: IWbwField) => void;
+}
+
+const WbwParent2Widget = ({ data, onChange }: IWidget) => {
+  const intl = useIntl();
+  const inlineDict = useAppSelector(_inlineDict);
+
+  // 1. 将计算逻辑封装,并用 useMemo 缓存结果
+  const parentOptions = useMemo(() => {
+    const wordIn = data.parent?.value;
+
+    // 基础校验:如果没有输入值,直接返回空
+    if (typeof wordIn !== "string" || !wordIn) {
+      return [];
+    }
+
+    const { wordIndex, wordList } = inlineDict;
+
+    if (wordIndex.includes(wordIn)) {
+      // 过滤并提取 parent 字段
+      const results = wordList.filter((word) => word.word === wordIn);
+
+      // 使用 Set 进行去重(比 Map 更简洁)
+      const parentSet = new Set<string>();
+      results.forEach((item) => {
+        if (item.parent) {
+          parentSet.add(item.parent);
+        }
+      });
+
+      // 转换为 AntD 要求的 Options 格式
+      return Array.from(parentSet).map((p) => ({
+        label: p,
+        value: p,
+      }));
+    }
+
+    return [];
+  }, [inlineDict, data.parent?.value]); // 仅在字典或输入值改变时重新计算
+
+  // 2. 语法选项同样可以使用 useMemo 优化(避免每次 render 都重新创建数组对象)
+  const grammarOptions = useMemo(() => {
+    const grammar = ["prp", "pp", "fpp", "pass", "caus", "vdn"];
+    return grammar.map((item) => ({
+      value: `.${item}.`,
+      label: intl.formatMessage({
+        id: `dict.fields.type.${item}.label`,
+        defaultMessage: item,
+      }),
+    }));
+  }, [intl]);
+
+  return (
+    <div style={{ display: "flex", gap: "8px" }}>
+      <Form.Item
+        name="parent2"
+        label={intl.formatMessage({ id: "forms.fields.parent2.label" })}
+        tooltip={intl.formatMessage({ id: "forms.fields.parent2.tooltip" })}
+        style={{ flex: 1, marginBottom: 0 }}
+      >
+        <AutoComplete
+          options={parentOptions}
+          onChange={(value: string) => {
+            onChange?.({ field: "parent2", value: value });
+          }}
+        >
+          <Input allowClear placeholder="请输入" />
+        </AutoComplete>
+      </Form.Item>
+
+      <Form.Item name="grammar2" noStyle>
+        <Select
+          style={{ width: 120 }}
+          allowClear
+          options={grammarOptions}
+          placeholder="语法"
+          onChange={(value: string) => {
+            onChange?.({ field: "grammar2", value: value });
+          }}
+        />
+      </Form.Item>
+    </div>
+  );
+};
+
+export default WbwParent2Widget;

+ 272 - 0
dashboard-v6/src/components/wbw/WbwDetailRelation.tsx

@@ -0,0 +1,272 @@
+import { Button, List, Select, Space } from "antd";
+import { useEffect, useMemo, useRef, useState, type JSX } from "react";
+import {
+  DeleteOutlined,
+  PlusOutlined,
+  InfoCircleOutlined,
+} from "@ant-design/icons";
+
+import { useAppSelector } from "../../hooks";
+import { getRelation } from "../../reducers/relation";
+import { getGrammar } from "../../reducers/term-vocabulary";
+
+import { useIntl } from "react-intl";
+import store from "../../store";
+import { add, relationAddParam } from "../../reducers/relation-add";
+import { grammar } from "../../reducers/command";
+import { openPanel } from "../../reducers/right-panel";
+import type { IWbw, IWbwField } from "../../types/wbw";
+
+interface IOptions {
+  value: string;
+  label: JSX.Element;
+}
+
+export interface IWbwRelation {
+  sour_id: string;
+  sour_spell: string;
+  dest_id: string;
+  dest_spell: string;
+  relation?: string;
+  is_new?: boolean;
+}
+
+interface IWidget {
+  data: IWbw;
+  onChange?: (value: IWbwField) => void;
+  onAdd?: () => void;
+  onFromList?: (fromList: string[] | undefined) => void;
+}
+
+const WbwDetailRelationWidget = ({
+  data,
+  onChange,
+  onAdd,
+  onFromList,
+}: IWidget) => {
+  const getSourId = () => `${data.book}-${data.para}-` + data.sn.join("-");
+
+  const intl = useIntl();
+
+  // Use a ref for onFromList to avoid stale closures without triggering re-renders
+  const onFromListRef = useRef(onFromList);
+  useEffect(() => {
+    onFromListRef.current = onFromList;
+  }, [onFromList]);
+
+  const [relation, setRelation] = useState<IWbwRelation[]>(() => {
+    if (typeof data.relation === "undefined") return [];
+    return JSON.parse(data.relation?.value ? data.relation?.value : "[]");
+  });
+
+  const [newRelationName, setNewRelationName] = useState<string>();
+
+  const terms = useAppSelector(getGrammar);
+  const relations = useAppSelector(getRelation);
+  const addParam = useAppSelector(relationAddParam);
+
+  // Sync relation from external data.relation changes (after initial render)
+  const prevRelationValue = useRef(data.relation?.value);
+  useEffect(() => {
+    if (data.relation?.value === prevRelationValue.current) return;
+    prevRelationValue.current = data.relation?.value;
+    const arrRelation: IWbwRelation[] = JSON.parse(
+      data.relation?.value ? data.relation?.value : "[]"
+    );
+    setRelation(arrRelation);
+  }, [data.relation?.value]);
+
+  // Derive grammar tags from data
+  const grammarTags = useMemo(() => {
+    let tags = data.case?.value
+      ?.replace("v:ind", "v")
+      .replace("#", "$")
+      .replace(":", "$")
+      .replaceAll(".", "")
+      .split("$");
+    if (data.grammar2?.value) {
+      const g2 = data.grammar2.value.replaceAll(".", "");
+      tags = tags ? [g2, ...tags] : [g2];
+    }
+    return tags;
+  }, [data.case?.value, data.grammar2?.value]);
+
+  // Derive filtered relations (replaces the big useEffect)
+  const filteredRelations = useMemo(() => {
+    if (!relations) return undefined;
+    return relations.filter((value) => {
+      if (!value.from) return false;
+      let caseMatch = true;
+      let spellMatch = true;
+
+      if (value.from.case) {
+        let matchCount = 0;
+        if (grammarTags) {
+          for (const iterator of value.from.case) {
+            if (grammarTags.includes(iterator)) matchCount++;
+          }
+        }
+        if (matchCount !== value.from.case.length) caseMatch = false;
+      }
+
+      if (value.from.spell && data.real.value) {
+        const regexString = value.from.spell.replaceAll("*", "\\w");
+        const regex = new RegExp(regexString);
+        spellMatch = regex.test(data.real.value);
+      }
+
+      return caseMatch && spellMatch;
+    });
+  }, [grammarTags, data.real.value, relations]);
+
+  // Derive select options from filteredRelations
+  const options = useMemo<IOptions[] | undefined>(() => {
+    if (!filteredRelations) return undefined;
+    const relationName = new Map<string, string>();
+    filteredRelations.forEach((value) => {
+      relationName.set(value.name, value.name);
+    });
+    return Array.from(relationName.keys()).map((item) => {
+      const localName = terms?.find((term) => term.word === item)?.meaning;
+      return {
+        value: item,
+        label: (
+          <Space>
+            {item}
+            {localName}
+          </Space>
+        ),
+      };
+    });
+  }, [filteredRelations, terms]);
+
+  // Notify parent of fromList changes
+  useEffect(() => {
+    if (!filteredRelations) return;
+    const relationFrom: string[] = [];
+    filteredRelations.forEach((value) => {
+      let from: string[] = [];
+      if (value.from?.spell) from.push(value.from.spell);
+      if (value.from?.case) from = [...from, ...value.from.case];
+      const key = from.join(".");
+      if (!relationFrom.includes(key)) relationFrom.push(key);
+    });
+    onFromListRef.current?.(relationFrom);
+  }, [filteredRelations]);
+
+  // Handle addParam apply command
+  useEffect(() => {
+    if (
+      addParam?.command === "apply" &&
+      addParam.src_sn === data.sn.join("-") &&
+      addParam.target_spell
+    ) {
+      const newRelation: IWbwRelation = {
+        sour_id: getSourId(),
+        sour_spell: data.word.value,
+        dest_id: addParam.target_id ?? "",
+        dest_spell: addParam.target_spell,
+        relation: newRelationName,
+      };
+      setRelation((origin) => {
+        const updated = [...origin, newRelation];
+        onChange?.({ field: "relation", value: JSON.stringify(updated) });
+        return updated;
+      });
+      setNewRelationName(undefined);
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [addParam?.command]);
+
+  const newRelationRow: IWbwRelation = {
+    sour_id: getSourId(),
+    sour_spell: data.word.value,
+    dest_id: "",
+    dest_spell: "",
+    relation: undefined,
+    is_new: true,
+  };
+
+  const addButton = (
+    <Button
+      type="dashed"
+      icon={<PlusOutlined />}
+      onClick={() => {
+        onAdd?.();
+        store.dispatch(
+          add({
+            book: data.book,
+            para: data.para,
+            src_sn: data.sn.join("-"),
+            command: "add",
+            relations: filteredRelations,
+          })
+        );
+      }}
+    >
+      {intl.formatMessage({ id: "buttons.relate.to" })}
+    </Button>
+  );
+
+  return (
+    <List
+      itemLayout="vertical"
+      size="small"
+      dataSource={[...relation, newRelationRow]}
+      renderItem={(item, index) => (
+        <List.Item>
+          <Space>
+            {!item.is_new && (
+              <Button
+                type="text"
+                icon={<DeleteOutlined />}
+                onClick={() => {
+                  const arrRelation = [...relation];
+                  arrRelation.splice(index, 1);
+                  setRelation(arrRelation);
+                  onChange?.({
+                    field: "relation",
+                    value: JSON.stringify(arrRelation),
+                  });
+                }}
+              />
+            )}
+            <Select
+              defaultValue={item.relation}
+              placeholder={"请选择关系"}
+              allowClear={!!item.is_new}
+              style={{ width: 180 }}
+              onChange={(value: string) => {
+                if (item.is_new) {
+                  setNewRelationName(value);
+                  return;
+                }
+                setRelation((origin) => {
+                  const updated = [...origin];
+                  updated[index] = { ...updated[index], relation: value };
+                  onChange?.({
+                    field: "relation",
+                    value: JSON.stringify(updated),
+                  });
+                  return updated;
+                });
+              }}
+              options={options}
+            />
+            <Button
+              type="link"
+              icon={<InfoCircleOutlined />}
+              onClick={() => {
+                store.dispatch(grammar(relation[index].relation));
+                store.dispatch(openPanel("grammar"));
+              }}
+            />
+            {item.dest_spell ? item.dest_spell : addButton}
+          </Space>
+        </List.Item>
+      )}
+    />
+  );
+};
+
+export default WbwDetailRelationWidget;

+ 72 - 0
dashboard-v6/src/components/wbw/WbwDetailUpload.tsx

@@ -0,0 +1,72 @@
+import { DeleteOutlined } from "@ant-design/icons";
+import { Button, List } from "antd";
+
+import { currentUser } from "../../reducers/current-user";
+import AttachmentDialog from "../attachment/AttachmentDialog";
+import { useAppSelector } from "../../hooks";
+import type { IAttachmentRequest } from "../../api/Attachments";
+import type { IWbw, IWbwAttachment } from "../../types/wbw";
+
+interface IWidget {
+  data: IWbw;
+  onUpload?: (value: IAttachmentRequest[]) => void;
+  onChange?: (output?: IWbwAttachment[]) => void;
+  onDialogOpen?: (open: boolean) => void;
+}
+const WbwDetailUploadWidget = ({
+  data,
+  onUpload,
+  onChange,
+  onDialogOpen,
+}: IWidget) => {
+  const user = useAppSelector(currentUser);
+  const attachments = data.attachments;
+
+  return (
+    <>
+      <List
+        itemLayout="vertical"
+        size="small"
+        header={
+          <AttachmentDialog
+            trigger={<Button>上传</Button>}
+            studioName={user?.realName}
+            onOpenChange={(open: boolean) => {
+              if (typeof onDialogOpen !== "undefined") {
+                onDialogOpen(open);
+              }
+            }}
+            onSelect={(value: IAttachmentRequest) => {
+              onUpload?.([value]);
+            }}
+          />
+        }
+        dataSource={attachments}
+        renderItem={(item, id) => (
+          <List.Item>
+            <div style={{ display: "flex", justifyContent: "space-between" }}>
+              <div style={{ maxWidth: 400, overflowX: "hidden" }}>
+                {item.title}
+              </div>
+              <div style={{ marginLeft: 20 }}>
+                <Button
+                  type="link"
+                  size="small"
+                  icon={<DeleteOutlined />}
+                  onClick={() => {
+                    const output = data.attachments?.filter(
+                      (_value: IWbwAttachment, index: number) => index !== id
+                    );
+                    onChange?.(output);
+                  }}
+                />
+              </div>
+            </div>
+          </List.Item>
+        )}
+      />
+    </>
+  );
+};
+
+export default WbwDetailUploadWidget;

+ 130 - 0
dashboard-v6/src/components/wbw/WbwFactorMeaning.tsx

@@ -0,0 +1,130 @@
+import { useIntl } from "react-intl";
+import { useState, useEffect } from "react";
+import type { MenuProps } from "antd";
+import { Dropdown, Space, Typography } from "antd";
+import { LoadingOutlined } from "@ant-design/icons";
+
+import { PaliReal } from "../../utils";
+import { useAppSelector } from "../../hooks";
+import { inlineDict as _inlineDict } from "../../reducers/inline-dict";
+
+import type { ItemType } from "antd/es/menu/interface";
+import { errorClass } from "./utils";
+import type { IWbw, TWbwDisplayMode } from "../../types/wbw";
+
+const { Text } = Typography;
+
+interface IWidget {
+  data: IWbw;
+  answer?: IWbw;
+  factors?: string;
+  display?: TWbwDisplayMode;
+  onChange?: (key: string) => void;
+}
+const WbwFactorMeaningWidget = ({
+  data,
+  answer,
+  display,
+  onChange,
+}: IWidget) => {
+  const intl = useIntl();
+  const defaultMenu: ItemType[] = [
+    {
+      key: "loading",
+      label: (
+        <Space>
+          <LoadingOutlined />
+          {"Loading"}
+        </Space>
+      ),
+    },
+  ];
+  const [items, setItems] = useState<ItemType[]>(defaultMenu);
+
+  const inlineDict = useAppSelector(_inlineDict);
+  useEffect(() => {
+    if (inlineDict.wordIndex.includes(data.word.value)) {
+      const result = inlineDict.wordList.filter(
+        (word) => word.word === data.word.value
+      );
+      //查重
+      //TODO 加入信心指数并排序
+      const myMap = new Map<string, number>();
+      const factors: string[] = [];
+      for (const iterator of result) {
+        if (iterator.factormean) {
+          myMap.set(iterator.factormean, 1);
+        }
+      }
+      myMap.forEach((_value, key) => {
+        factors.push(key);
+      });
+
+      const menu = factors.map((item) => {
+        return { key: item, label: item };
+      });
+      setItems(menu);
+    }
+  }, [data.word.value, inlineDict]);
+
+  const onClick: MenuProps["onClick"] = (e) => {
+    console.log("click ", e);
+    if (typeof onChange !== "undefined") {
+      onChange(e.key);
+    }
+  };
+
+  let factorMeaning = <></>;
+  if (display === "block") {
+    if (
+      typeof data.factorMeaning?.value === "string" &&
+      data.factorMeaning.value.replaceAll("+", "").trim().length > 0
+    ) {
+      factorMeaning = <span>{data.factorMeaning?.value}</span>;
+    } else {
+      //空白的意思在逐词解析模式显示占位字符串
+      factorMeaning = (
+        <Text type="secondary">
+          {intl.formatMessage({ id: "forms.fields.factor.meaning.label" })}
+        </Text>
+      );
+    }
+  }
+
+  if (typeof data.real !== "undefined" && PaliReal(data.real.value) !== "") {
+    const checkClass = answer
+      ? errorClass(
+          "factorMeaning",
+          data.factorMeaning?.value,
+          answer?.factorMeaning?.value
+        )
+      : "";
+    return (
+      <div className={"wbw_word_item" + checkClass}>
+        <Text type="secondary">
+          <Dropdown
+            menu={{
+              items: [
+                ...items.filter((_value, index) => index <= 5),
+                {
+                  key: "more",
+                  label: intl.formatMessage({ id: "buttons.more" }),
+                  children: items.filter((_value, index) => index > 5),
+                },
+              ],
+              onClick,
+            }}
+            placement="bottomLeft"
+          >
+            {factorMeaning}
+          </Dropdown>
+        </Text>
+      </div>
+    );
+  } else {
+    //标点符号
+    return <></>;
+  }
+};
+
+export default WbwFactorMeaningWidget;

+ 161 - 0
dashboard-v6/src/components/wbw/WbwFactorMeaningItem.tsx

@@ -0,0 +1,161 @@
+import { useIntl } from "react-intl";
+import { Button, Dropdown, Input, Space } from "antd";
+import { useMemo, useState } from "react";
+import {
+  MoreOutlined,
+  EditOutlined,
+  CheckOutlined,
+  SearchOutlined,
+  CloseOutlined,
+} from "@ant-design/icons";
+import { useAppSelector } from "../../hooks";
+
+import { inlineDict as _inlineDict } from "../../reducers/inline-dict";
+import store from "../../store";
+import { lookup } from "../../reducers/command";
+import { openPanel } from "../../reducers/right-panel";
+import type { ItemType } from "antd/es/menu/interface";
+
+interface IWidgetFactor {
+  pali: string;
+  meaning?: string;
+  readonly?: boolean;
+  onChange?: (input: string | undefined) => void;
+}
+
+const WbwFactorMeaningItem = ({
+  pali,
+  readonly = false,
+  meaning = "",
+  onChange,
+}: IWidgetFactor) => {
+  const intl = useIntl();
+  const [input, setInput] = useState<string>();
+  const [editable, setEditable] = useState(false);
+  const inlineDict = useAppSelector(_inlineDict);
+
+  // 1. Memoize the menu items to avoid useEffect/setState and dependency issues
+  const menuItems = useMemo(() => {
+    const defaultMenu: ItemType[] = [
+      {
+        key: "_lookup",
+        label: (
+          <Space>
+            <SearchOutlined />
+            {intl.formatMessage({ id: "buttons.lookup" })}
+          </Space>
+        ),
+      },
+      {
+        key: "_edit",
+        label: (
+          <Space>
+            <EditOutlined />
+            {intl.formatMessage({ id: "buttons.edit" })}
+          </Space>
+        ),
+      },
+      { key: pali, label: pali },
+      { type: "divider" }, // Visual separation
+    ];
+
+    if (!inlineDict.wordIndex.includes(pali)) {
+      return defaultMenu;
+    }
+
+    const result = inlineDict.wordList.filter((word) => word.word === pali);
+
+    // De-duplicate meanings
+    const uniqueMeanings = new Set<string>();
+    result.forEach((it) => {
+      if (typeof it.mean === "string") {
+        it.mean.split("$").forEach((m) => {
+          if (m.trim() !== "") uniqueMeanings.add(m);
+        });
+      }
+    });
+
+    const dynamicMenu: ItemType[] = Array.from(uniqueMeanings).map((m) => ({
+      key: m,
+      label: m,
+    }));
+
+    const allItems = [...defaultMenu, ...dynamicMenu];
+
+    // Handle "More" grouping logic within memo
+    if (allItems.length <= 6) return allItems;
+
+    return [
+      ...allItems.slice(0, 5),
+      {
+        key: "more",
+        label: intl.formatMessage({ id: "buttons.more" }),
+        children: allItems.slice(5),
+      },
+    ];
+  }, [pali, inlineDict, intl]);
+
+  const handleOk = () => {
+    setEditable(false);
+    onChange?.(input);
+  };
+
+  const handleCancel = () => {
+    setEditable(false);
+    setInput(meaning);
+  };
+
+  const meaningInner = editable ? (
+    <Input
+      defaultValue={meaning}
+      size="small"
+      autoFocus // Better UX when switching to edit mode
+      addonAfter={
+        <Space size={4}>
+          <CheckOutlined style={{ cursor: "pointer" }} onClick={handleOk} />
+          <CloseOutlined style={{ cursor: "pointer" }} onClick={handleCancel} />
+        </Space>
+      }
+      style={{ width: 160 }}
+      onChange={(e) => setInput(e.target.value)}
+      onPressEnter={handleOk}
+      onKeyDown={(e) => e.key === "Escape" && handleCancel()}
+    />
+  ) : (
+    <Button
+      disabled={readonly}
+      size="small"
+      type="text"
+      icon={meaning === "" ? <MoreOutlined /> : undefined}
+      onClick={() => setEditable(true)}
+    >
+      {meaning}
+    </Button>
+  );
+
+  if (editable || readonly) return meaningInner;
+
+  return (
+    <Dropdown
+      menu={{
+        items: menuItems,
+        onClick: (e) => {
+          if (e.key === "_lookup") {
+            store.dispatch(lookup(pali));
+            store.dispatch(openPanel("dict"));
+          } else if (e.key === "_edit") {
+            setEditable(true);
+          } else {
+            onChange?.(e.key);
+          }
+        },
+      }}
+      placement="bottomLeft"
+      trigger={["hover"]}
+    >
+      {meaningInner}
+    </Dropdown>
+  );
+};
+
+export default WbwFactorMeaningItem;

+ 112 - 0
dashboard-v6/src/components/wbw/WbwFactors.tsx

@@ -0,0 +1,112 @@
+import { useMemo } from "react";
+import { useIntl } from "react-intl";
+import { type MenuProps, Tooltip } from "antd";
+import { Dropdown, Space, Typography } from "antd";
+import { LoadingOutlined } from "@ant-design/icons";
+
+import { useAppSelector } from "../../hooks";
+import { inlineDict as _inlineDict } from "../../reducers/inline-dict";
+import { errorClass } from "./utils";
+import type { IWbw, TWbwDisplayMode } from "../../types/wbw";
+
+const { Text } = Typography;
+
+interface IWidget {
+  data: IWbw;
+  answer?: IWbw;
+  display?: TWbwDisplayMode;
+  onChange?: (key: string) => void;
+}
+
+const defaultMenu: MenuProps["items"] = [
+  {
+    key: "loading",
+    label: (
+      <Space>
+        <LoadingOutlined />
+        {"Loading"}
+      </Space>
+    ),
+  },
+];
+
+const WbwFactorsWidget = ({ data, answer, display, onChange }: IWidget) => {
+  const intl = useIntl();
+  const inlineDict = useAppSelector(_inlineDict);
+
+  const items = useMemo<MenuProps["items"]>(() => {
+    const realValue = data.real.value;
+    if (!realValue || !inlineDict.wordIndex.includes(realValue)) {
+      return defaultMenu;
+    }
+
+    const result = inlineDict.wordList.filter(
+      (word) => word.word === realValue
+    );
+
+    const uniqueFactors = [
+      ...new Map(
+        result
+          .filter(
+            (item): item is typeof item & { factors: string } =>
+              typeof item.factors === "string"
+          )
+          .map((item) => [item.factors, 1] as const)
+      ).keys(),
+    ];
+
+    return [...uniqueFactors, realValue].map((item) => ({
+      key: item,
+      label: item,
+    }));
+  }, [data.real.value, inlineDict]);
+
+  const onClick: MenuProps["onClick"] = (e) => {
+    console.log("click ", e);
+    onChange?.(e.key);
+  };
+
+  const realValue = data.real?.value;
+  if (typeof realValue !== "string" || realValue.trim().length === 0) {
+    return <></>;
+  }
+
+  let factors = <></>;
+  if (display === "block") {
+    if (
+      typeof data.factors?.value === "string" &&
+      data.factors.value.trim().length > 0
+    ) {
+      const maxLen = realValue.length + 6 + Math.floor(realValue.length / 3);
+      const shortString = data.factors.value.slice(0, maxLen);
+      factors =
+        shortString === data.factors.value ? (
+          <span>{shortString}</span>
+        ) : (
+          <Tooltip title={data.factors.value}>{`${shortString}…`}</Tooltip>
+        );
+    } else {
+      factors = (
+        <Text type="secondary">
+          {intl.formatMessage({ id: "forms.fields.factors.label" })}
+        </Text>
+      );
+    }
+  }
+
+  const checkClass = answer
+    ? errorClass("factors", data.factors?.value, answer?.factors?.value)
+    : "";
+
+  return (
+    <div className={"wbw_word_item" + checkClass}>
+      <Text type="secondary">
+        <Dropdown menu={{ items, onClick }} placement="bottomLeft">
+          {factors}
+        </Dropdown>
+      </Text>
+    </div>
+  );
+};
+
+export default WbwFactorsWidget;

+ 42 - 0
dashboard-v6/src/components/wbw/WbwFactorsEditor.tsx

@@ -0,0 +1,42 @@
+import { useState } from "react";
+import { Space } from "antd";
+import { LoadingOutlined, WarningOutlined } from "@ant-design/icons";
+
+import WbwFactors from "./WbwFactors";
+import type { IPreferenceResponse } from "../../api/Dict";
+import type { IWbw, TWbwDisplayMode } from "../../types/wbw";
+
+interface IWidget {
+  initValue: IWbw;
+  display?: TWbwDisplayMode;
+  onChange?: (key: string) => Promise<IPreferenceResponse>;
+}
+const WbwFactorsEditor = ({ initValue, display, onChange }: IWidget) => {
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState(false);
+
+  return (
+    <Space>
+      {loading ? <LoadingOutlined /> : error ? <WarningOutlined /> : <></>}
+      <WbwFactors
+        key="factors"
+        data={initValue}
+        display={display}
+        onChange={async (e: string) => {
+          console.log("factor change", e);
+          if (onChange) {
+            setLoading(true);
+            setError(false);
+            const response = await onChange(e);
+            setLoading(false);
+            if (!response.ok) {
+              setError(true);
+            }
+          }
+        }}
+      />
+    </Space>
+  );
+};
+
+export default WbwFactorsEditor;

+ 76 - 0
dashboard-v6/src/components/wbw/WbwLookup.tsx

@@ -0,0 +1,76 @@
+import { useEffect, useRef } from "react";
+import { useAppSelector } from "../../hooks";
+import { add, updateIndex, wordIndex } from "../../reducers/inline-dict";
+import { get } from "../../request";
+import type { IApiResponseDictList } from "../../api/Dict";
+import store from "../../store";
+
+interface IWidget {
+  run?: boolean;
+  words?: string[];
+  delay?: number;
+}
+const WbwLookup = ({ words, run = false, delay = 300 }: IWidget) => {
+  const inlineWordIndex = useAppSelector(wordIndex);
+
+  const intervalRef = useRef<number | null>(null); //防抖计时器句柄
+  /**
+   * 停止查字典计时
+   * 在两种情况下停止计时
+   * 1. 开始查字典
+   * 2. 防抖时间内鼠标移出单词区
+   */
+  const stopLookup = () => {
+    if (intervalRef.current) {
+      window.clearInterval(intervalRef.current);
+      intervalRef.current = null;
+    }
+  };
+  /**
+   * 查字典
+   * @param word 要查的单词
+   */
+  const lookup = (words: string[]) => {
+    stopLookup();
+
+    //查询这个词在内存字典里是否有
+    const searchWord = words.filter((value) => {
+      if (inlineWordIndex.includes(value)) {
+        //已经有了
+        return false;
+      } else {
+        return true;
+      }
+    });
+    if (searchWord.length === 0) {
+      return;
+    }
+    const url = `/v2/wbwlookup?word=${searchWord.join()}`;
+    console.info("api request", url);
+    get<IApiResponseDictList>(url).then((json) => {
+      console.debug("api response", json);
+      //存储到redux
+      store.dispatch(add(json.data.rows));
+      store.dispatch(updateIndex(searchWord));
+    });
+
+    console.log("lookup", searchWord);
+  };
+  useEffect(() => {
+    // 监听store中的words变化
+    if (run && words && words.length > 0) {
+      // 开始查字典
+      intervalRef.current = window.setInterval(lookup, delay, words);
+    } else {
+      stopLookup();
+    }
+    return () => {
+      // 组件销毁时清除计时器
+      clearInterval(intervalRef.current!);
+    };
+  }, [run, words]);
+
+  return <></>;
+};
+
+export default WbwLookup;

+ 218 - 0
dashboard-v6/src/components/wbw/WbwMeaning.tsx

@@ -0,0 +1,218 @@
+import { useState } from "react";
+import { useIntl } from "react-intl";
+import { Input, Popover, Typography } from "antd";
+
+import WbwMeaningSelect from "./WbwMeaningSelect";
+
+import CaseFormula from "./CaseFormula";
+import EditableLabel from "../general/EditableLabel";
+import type { ArticleMode } from "../../api/Corpus";
+import { errorClass } from "./utils";
+import type { IWbw, TWbwDisplayMode } from "../../types/wbw";
+
+const { Text } = Typography;
+
+interface IWidget {
+  data: IWbw;
+  answer?: IWbw;
+  display?: TWbwDisplayMode;
+  mode?: ArticleMode;
+  onChange?: (value?: string | null) => void;
+}
+const WbwMeaningWidget = ({
+  data,
+  answer,
+  display = "block",
+  mode = "edit",
+  onChange,
+}: IWidget) => {
+  const intl = useIntl();
+  const [open, setOpen] = useState(false);
+  const [input, setInput] = useState(data.meaning?.value);
+  const [editable, setEditable] = useState(false);
+
+  const inputOk = () => {
+    setEditable(false);
+    if (typeof onChange !== "undefined") {
+      onChange(input ? input : "");
+    }
+  };
+
+  const hide = () => {
+    setOpen(false);
+  };
+
+  const handleOpenChange = (newOpen: boolean) => {
+    setOpen(newOpen);
+  };
+
+  let meaning = <></>;
+  if (
+    typeof data.meaning?.value === "string" &&
+    data.meaning.value.trim().length > 0
+  ) {
+    const eMeaning = data.meaning.value
+      .replaceAll("[", "@[")
+      .replaceAll("]", "]@")
+      .replaceAll("{", "@{")
+      .replaceAll("}", "}@")
+      .split("@")
+      .map((item, index) => {
+        if (item.includes("[")) {
+          return (
+            <span key={index} style={{ color: "rosybrown" }}>
+              {item.replaceAll("[", "").replaceAll("]", "")}
+            </span>
+          );
+        } else if (item.includes("{")) {
+          return (
+            <span key={index} style={{ color: "lightskyblue" }}>
+              {item.replaceAll("{", "").replaceAll("}", "")}
+            </span>
+          );
+        } else {
+          return <Text key={index}>{item}</Text>;
+        }
+      });
+    meaning = <Text>{eMeaning}</Text>;
+  } else if (mode === "wbw" || display === "inline") {
+    //空白的意思在逐词解析模式显示占位字符串
+    meaning = (
+      <Text type="secondary">
+        {intl.formatMessage({ id: "forms.fields.meaning.label" })}
+      </Text>
+    );
+  }
+
+  let meaningInner = <></>;
+  if (display === "list") {
+    meaningInner = (
+      <EditableLabel
+        defaultValue={data.meaning?.value ? data.meaning?.value : ""}
+        value={data.meaning?.value ? data.meaning?.value : ""}
+        placeholder="meaning"
+        style={{ width: "100%" }}
+        onChange={(event) => {
+          console.log("on change", event.target.value);
+          setInput(event.target.value);
+        }}
+        onPressEnter={() => {
+          if (typeof onChange !== "undefined") {
+            onChange(input ? input : "");
+          }
+        }}
+        onKeyDown={() => {}}
+        onBlur={() => {
+          if (typeof onChange !== "undefined") {
+            onChange(input ? input : "");
+          }
+        }}
+      />
+    );
+  } else if (editable) {
+    meaningInner = (
+      <Input
+        defaultValue={data.meaning?.value ? data.meaning?.value : ""}
+        placeholder="meaning"
+        style={{ width: "100%" }}
+        onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
+          setInput(event.target.value);
+        }}
+        onPressEnter={() => {
+          if (typeof onChange !== "undefined") {
+            onChange(input ? input : "");
+          }
+        }}
+        onKeyDown={(event: React.KeyboardEvent<HTMLInputElement>) => {
+          if (event.key === "Escape") {
+            setEditable(false);
+          }
+        }}
+        onBlur={() => {
+          inputOk();
+        }}
+      />
+    );
+  } else {
+    meaningInner = (
+      <span
+        onClick={() => {
+          setEditable(true);
+        }}
+      >
+        {meaning}
+      </span>
+    );
+  }
+
+  if (
+    typeof data.real?.value === "string" &&
+    data.real.value.trim().length > 0
+  ) {
+    //非标点符号
+    const checkClass = answer
+      ? errorClass("meaning", data.meaning?.value, answer?.meaning?.value)
+      : "";
+    return (
+      <div className={"wbw_word_item" + checkClass}>
+        {editable || display === "list" ? (
+          meaningInner
+        ) : (
+          <div style={{ display: "flex", justifyContent: "space-between" }}>
+            <Popover
+              open={open}
+              onOpenChange={handleOpenChange}
+              content={
+                <div style={{ width: 500, height: "450px", overflow: "auto" }}>
+                  <WbwMeaningSelect
+                    data={data}
+                    onSelect={(e: string) => {
+                      hide();
+                      if (typeof onChange !== "undefined") {
+                        onChange(e);
+                      }
+                    }}
+                  />
+                </div>
+              }
+              placement="bottomLeft"
+              trigger="hover"
+            >
+              {meaningInner}
+            </Popover>
+            {mode === "wbw" ? (
+              <CaseFormula
+                data={data}
+                onChange={(formula: string) => {
+                  /**
+                   * 有 [ ] 不自动替换
+                   * 有{ } 祛除 { }
+                   * 把 格位公式中的 ~ 替换为 data.meaning.value
+                   */
+                  let meaning: string = data.meaning?.value
+                    ? data.meaning?.value
+                    : "";
+                  meaning = meaning.replace(/\{(.+?)\}/g, "");
+                  meaning = meaning.replace(/\[(.+?)\]/g, "");
+
+                  meaning = formula
+                    .replaceAll("{", "[")
+                    .replaceAll("}", "]")
+                    .replace("~", meaning);
+                  if (typeof onChange !== "undefined") {
+                    onChange(meaning);
+                  }
+                }}
+              />
+            ) : undefined}
+          </div>
+        )}
+      </div>
+    );
+  } else {
+    //标点符号
+    return <></>;
+  }
+};
+
+export default WbwMeaningWidget;

+ 217 - 0
dashboard-v6/src/components/wbw/WbwMeaningSelect.tsx

@@ -0,0 +1,217 @@
+/**
+ * 逐词解析意思选择菜单
+ * 基本算法:
+ * 从redux 获取单词列表。找到与拼写完全相同的单词。按照词典渲染单词意思列表
+ * 词典相同语法信息不同的单独一行
+ * 在上面的单词数据里面 找到 base 列表,重复上面的步骤
+ * 菜单显示结构:
+ * 拼写1
+ *    词典1  词性  意思1 意思2
+ *    词典2  词性  意思1 意思2
+ * 拼写2
+ *    词典1  词性  意思1 意思2
+ *    词典2  词性  意思1 意思2
+ *
+ */
+import { useEffect, useState } from "react";
+import { useIntl } from "react-intl";
+import { Button, Collapse, Tag, Typography } from "antd";
+
+import { useAppSelector } from "../../hooks";
+import { inlineDict as _inlineDict } from "../../reducers/inline-dict";
+import type { IWbw } from "../../types/wbw";
+
+const { Panel } = Collapse;
+const { Text } = Typography;
+
+interface IMeaning {
+  text: string;
+  count: number;
+}
+interface ICase {
+  name: string;
+  local: string;
+  meaning: IMeaning[];
+}
+interface IDict {
+  id: string;
+  name?: string;
+  case: ICase[];
+}
+interface IParent {
+  word: string;
+  dict: IDict[];
+}
+
+interface IWidget {
+  data: IWbw;
+  onSelect?: (value: string) => void;
+}
+const WbwMeaningSelectWidget = ({ data, onSelect }: IWidget) => {
+  const intl = useIntl();
+  const inlineDict = useAppSelector(_inlineDict);
+  const [parent, setParent] = useState<IParent[]>();
+
+  useEffect(() => {
+    //判断单词列表里面是否有这个词
+    if (typeof data.real === "undefined" || data.real.value === null) {
+      return;
+    }
+    if (inlineDict.wordIndex.includes(data.real.value)) {
+      const baseRemind: string[] = [];
+      const baseDone: string[] = [];
+      baseRemind.push(data.real.value);
+      const mParent: IParent[] = [];
+      while (baseRemind.length > 0) {
+        const word1 = baseRemind.pop();
+        if (typeof word1 === "undefined") {
+          break;
+        }
+        baseDone.push(word1);
+        const result1 = inlineDict.wordList.filter(
+          (word) => word.word === word1
+        );
+        mParent.push({ word: word1, dict: [] });
+        const indexParent = mParent.findIndex((item) => item.word === word1);
+        result1.forEach((value) => {
+          if (
+            value.parent &&
+            value.parent !== "" &&
+            !baseRemind.includes(value.parent) &&
+            !baseDone.includes(value.parent)
+          ) {
+            baseRemind.push(value.parent);
+          }
+          let indexDict = mParent[indexParent].dict.findIndex(
+            (item) => item.id === value.dict_id
+          );
+          if (indexDict === -1) {
+            //没找到,添加一个dict
+            mParent[indexParent].dict.push({
+              id: value.dict_id,
+              name: value.shortname,
+              case: [],
+            });
+            indexDict = mParent[indexParent].dict.findIndex(
+              (item) => item.id === value.dict_id
+            );
+          }
+          const wordType = value.type
+            ? value.type === ""
+              ? "null"
+              : value.type.replaceAll(".", "")
+            : "null";
+          let indexCase = mParent[indexParent].dict[indexDict].case.findIndex(
+            (item) => item.name === wordType
+          );
+          if (indexCase === -1) {
+            //没找到,新建
+            mParent[indexParent].dict[indexDict].case.push({
+              name: wordType,
+              local: intl.formatMessage({
+                id: `dict.fields.type.${wordType}.short.label`,
+                defaultMessage: wordType,
+              }),
+              meaning: [],
+            });
+            indexCase = mParent[indexParent].dict[indexDict].case.findIndex(
+              (item) => item.name === wordType
+            );
+          }
+          if (value.mean && value.mean.trim() !== "") {
+            for (const valueMeaning of value.mean.trim().split("$")) {
+              if (valueMeaning.trim() !== "") {
+                const mValue = valueMeaning.trim();
+                const indexMeaning = mParent[indexParent].dict[indexDict].case[
+                  indexCase
+                ].meaning.findIndex(
+                  (itemMeaning) => itemMeaning.text === mValue
+                );
+
+                let indexM: number;
+                const currMeanings =
+                  mParent[indexParent].dict[indexDict].case[indexCase].meaning;
+                for (indexM = 0; indexM < currMeanings.length; indexM++) {
+                  if (currMeanings[indexM].text === mValue) {
+                    break;
+                  }
+                }
+
+                if (indexMeaning === -1) {
+                  mParent[indexParent].dict[indexDict].case[
+                    indexCase
+                  ].meaning.push({
+                    text: mValue,
+                    count: 1,
+                  });
+                } else {
+                  mParent[indexParent].dict[indexDict].case[indexCase].meaning[
+                    indexMeaning
+                  ].count++;
+                }
+              }
+            }
+          }
+        });
+      }
+
+      setParent(mParent);
+    }
+  }, [data.real, data.real.value, inlineDict, intl]);
+
+  return (
+    <div>
+      <Collapse defaultActiveKey={["0"]}>
+        {parent?.map((item, id) => {
+          return (
+            <Panel header={item.word} style={{ padding: 0 }} key={id}>
+              {item.dict.map((itemDict, idDict) => {
+                return (
+                  <div key={idDict} style={{ display: "flex" }}>
+                    <Text strong style={{ whiteSpace: "nowrap" }}>
+                      {itemDict.name}
+                    </Text>
+                    <div>
+                      {itemDict.case.map((itemCase, idCase) => {
+                        return (
+                          <div key={idCase}>
+                            <Tag>{itemCase.local}</Tag>
+                            <span>
+                              {itemCase.meaning.map(
+                                (itemMeaning, idMeaning) => {
+                                  return (
+                                    <Button
+                                      type="text"
+                                      size="middle"
+                                      key={idMeaning}
+                                      onClick={(
+                                        e: React.MouseEvent<HTMLAnchorElement>
+                                      ) => {
+                                        e.preventDefault();
+                                        if (typeof onSelect !== "undefined") {
+                                          onSelect(itemMeaning.text);
+                                        }
+                                      }}
+                                    >
+                                      {itemMeaning.text}
+                                    </Button>
+                                  );
+                                }
+                              )}
+                            </span>
+                          </div>
+                        );
+                      })}
+                    </div>
+                  </div>
+                );
+              })}
+            </Panel>
+          );
+        })}
+      </Collapse>
+    </div>
+  );
+};
+
+export default WbwMeaningSelectWidget;

+ 16 - 0
dashboard-v6/src/components/wbw/WbwPage.tsx

@@ -0,0 +1,16 @@
+import { Tag } from "antd";
+
+import type { IWbw } from "../../types/wbw";
+
+interface IWidget {
+  data: IWbw;
+}
+const WbwPageWidget = ({ data }: IWidget) => {
+  return (
+    <span>
+      <Tag>{data.word.value}</Tag>
+    </span>
+  );
+};
+
+export default WbwPageWidget;

+ 463 - 0
dashboard-v6/src/components/wbw/WbwPali.tsx

@@ -0,0 +1,463 @@
+import { useEffect, useRef, useState, useMemo, useCallback } from "react";
+import { Popover, Typography } from "antd";
+import {
+  TagTwoTone,
+  InfoCircleOutlined,
+  ApartmentOutlined,
+  EditOutlined,
+  QuestionCircleOutlined,
+} from "@ant-design/icons";
+
+import "./wbw.css";
+import WbwDetail from "./WbwDetail";
+
+import WbwVideoButton from "./WbwVideoButton";
+import store from "../../store";
+import { grammarId, lookup } from "../../reducers/command";
+import { useAppSelector } from "../../hooks";
+import { add, relationAddParam } from "../../reducers/relation-add";
+
+import { anchor, showWbw } from "../../reducers/wbw";
+//import { ParaLinkCtl } from "./ParaLink";
+
+import WbwPaliDiscussionIcon from "./WbwPaliDiscussionIcon";
+import type { TooltipPlacement } from "antd/es/tooltip"; // antd6: 路径从 lib → es
+import { temp } from "../../reducers/setting";
+import TagsArea from "../tag/TagsArea";
+import type { IStudio } from "../../api/Auth";
+import type { ArticleMode } from "../../api/Corpus";
+import type { ITagMapData } from "../../api/Tag";
+import PaliText from "../general/PaliText";
+import { bookMarkColor } from "./utils";
+import type { IWbw, IWbwAttachment, TWbwDisplayMode } from "../../types/wbw";
+
+export const PopPlacement = "setting.wbw.pop.placement";
+
+// ─── VideoIcon ────────────────────────────────────────────────────────────────
+
+interface IVideoIcon {
+  attachments?: IWbwAttachment[];
+}
+
+const VideoIcon = ({ attachments }: IVideoIcon) => {
+  const videoList = attachments?.filter((item) =>
+    item.content_type?.includes("video")
+  );
+  if (!videoList?.length) return null;
+  return (
+    <WbwVideoButton
+      video={videoList.map((item) => ({
+        videoId: item.id,
+        type: item.content_type,
+        title: item.title,
+      }))}
+    />
+  );
+};
+
+// ─── NoteIcon ─────────────────────────────────────────────────────────────────
+
+interface INoteIcon {
+  note?: string;
+}
+
+const NoteIcon = ({ note }: INoteIcon) => {
+  if (!note?.trim()) return null;
+  return (
+    <Popover content={note} placement="bottom">
+      <InfoCircleOutlined style={{ color: "blue" }} />
+    </Popover>
+  );
+};
+
+// ─── RelationIcon ─────────────────────────────────────────────────────────────
+
+interface IRelationIcon {
+  hasRelation?: boolean;
+}
+
+const RelationIcon = ({ hasRelation }: IRelationIcon) =>
+  hasRelation ? <ApartmentOutlined style={{ color: "blue" }} /> : null;
+
+// ─── BookMarkIcon ─────────────────────────────────────────────────────────────
+
+interface IBookMarkIcon {
+  text?: string;
+  color: string;
+}
+
+const BookMarkIcon = ({ text, color }: IBookMarkIcon) => {
+  if (!text?.trim()) return null;
+  return (
+    <Popover
+      content={<Typography.Paragraph copyable>{text}</Typography.Paragraph>}
+      placement="bottom"
+    >
+      <TagTwoTone twoToneColor={color} />
+    </Popover>
+  );
+};
+
+// ─── Types ────────────────────────────────────────────────────────────────────
+
+interface IWidget {
+  data: IWbw;
+  studio?: IStudio;
+  channelId: string;
+  display?: TWbwDisplayMode;
+  mode?: ArticleMode;
+  readonly?: boolean;
+  onSave?: (e: IWbw, isPublish: boolean, isPublic: boolean) => void;
+}
+
+// ─── WbwPaliWidget ────────────────────────────────────────────────────────────
+
+const WbwPaliWidget = ({
+  data,
+  channelId,
+  mode,
+  studio,
+  readonly = false,
+  onSave,
+}: IWidget) => {
+  const [popOpen, setPopOpen] = useState(false);
+  const [tags, setTags] = useState<ITagMapData[]>();
+  const [paliColor, setPaliColor] = useState("unset");
+
+  const divShell = useRef<HTMLDivElement>(null);
+  const wbwAnchor = useAppSelector(anchor);
+  const addParam = useAppSelector(relationAddParam);
+  const wordSn = `${data.book}-${data.para}-${data.sn.join("-")}`;
+  const tempSettings = useAppSelector(temp);
+
+  /**
+   * 修复1:popOnTop 是纯派生值(来自 tempSettings),无需 state + effect 同步。
+   * 直接用 useMemo 在渲染时计算,消除一次 setState-in-effect。
+   */
+  const popOnTop = useMemo(() => {
+    const popSetting = tempSettings?.find((v) => v.key === PopPlacement);
+    console.debug("PopPlacement change", popSetting);
+    return popSetting?.value === true;
+  }, [tempSettings]);
+
+  /**
+   * 修复2:popPlacement 用 useState 存储,但只在事件处理器中更新。
+   * 在事件处理器(用户点击)里读取 DOM ref 并 setState 完全合法,
+   * 不会产生"effect 内 setState"的级联渲染警告。
+   */
+  const [popPlacement, setPopPlacement] = useState<TooltipPlacement>("bottom");
+
+  const computeAndSetPlacement = useCallback(() => {
+    const rightPanel = document.getElementById("article_right_panel");
+    const rightPanelWidth = rightPanel?.offsetWidth ?? 0;
+    const containerWidth = window.innerWidth - rightPanelWidth;
+    const divRight = divShell.current?.getBoundingClientRect().right ?? 0;
+    const toDivRight = containerWidth - divRight;
+    setPopPlacement(
+      popOnTop
+        ? toDivRight > 200
+          ? "top"
+          : "topRight"
+        : toDivRight > 200
+          ? "bottom"
+          : "bottomRight"
+    );
+  }, [popOnTop]);
+
+  // ── Popover open/close ────────────────────────────────────────────────────
+  const popOpenChange = useCallback(
+    (open: boolean) => {
+      if (open) {
+        // 事件处理器中读取 ref + setState,合法,不产生级联渲染
+        computeAndSetPlacement();
+        setPaliColor("lightblue");
+      } else {
+        setPaliColor("unset");
+      }
+      setPopOpen(open);
+    },
+    [computeAndSetPlacement]
+  );
+
+  /**
+   * relation 高亮颜色是派生值,用 useMemo 直接计算。
+   */
+  const relationHighlight = useMemo(() => {
+    let grammar = data.case?.value
+      ?.replace("v:ind", "v")
+      .replace("#", "$")
+      .replace(":", "$")
+      .replaceAll(".", "")
+      .split("$");
+
+    if (data.grammar2?.value) {
+      grammar = grammar
+        ? [data.grammar2.value, ...grammar]
+        : [data.grammar2.value];
+    }
+
+    if (!grammar) return false;
+
+    const match = addParam?.relations?.filter((value) => {
+      if (!value.to) return false;
+      const caseMatch =
+        !value.to.case ||
+        value.to.case.filter((c) => grammar!.includes(c)).length ===
+          value.to.case.length;
+      const spellMatch = !value.to.spell || data.real.value === value.to.spell;
+      return caseMatch && spellMatch;
+    });
+
+    return !!(match && match.length > 0);
+  }, [
+    addParam?.relations,
+    data.case?.value,
+    data.grammar2?.value,
+    data.real.value,
+  ]);
+
+  /**
+   * 修复3:popOpen 和 paliColor 的"关闭"逻辑来自 wbwAnchor 的变化。
+   * 不能在 effect 里 setState,改为用 useMemo 派生出是否应该强制关闭,
+   * 再结合实际的 open state 计算最终值。
+   * anchorClosed = true 表示当前 anchor 指向别处,本组件应处于关闭态。
+   */
+  const anchorClosed = useMemo(() => {
+    if (!wbwAnchor) return false;
+    return wbwAnchor.id !== wordSn || wbwAnchor.channel !== channelId;
+  }, [wbwAnchor, wordSn, channelId]);
+
+  // 最终是否打开:自身 state 为 true 且 anchor 未指向别处
+  const resolvedPopOpen = popOpen && !anchorClosed;
+
+  /**
+   * 修复5:addParam apply/cancel 时重置 paliColor 并重新打开弹窗。
+   * 原来在 effect 里 setState,改为用 useMemo 派生:
+   * - forceOpen: 当前 addParam 命令要求重新打开本组件的弹窗
+   * - forceColorReset: 当前 addParam 命令要求重置颜色
+   */
+  const forceOpen = useMemo(() => {
+    if (addParam?.command !== "apply" && addParam?.command !== "cancel")
+      return false;
+    return (
+      addParam.src_sn === data.sn.join("-") &&
+      addParam.book === data.book &&
+      addParam.para === data.para
+    );
+  }, [addParam, data.book, data.para, data.sn]);
+
+  const forceColorReset = useMemo(() => {
+    return addParam?.command === "apply" || addParam?.command === "cancel";
+  }, [addParam?.command]);
+
+  // 派生最终 popOpen:forceOpen 优先
+  const finalPopOpen = forceOpen ? true : resolvedPopOpen;
+
+  // 当 forceOpen 激活时,触发 dispatch(side effect 仍需 effect,但 setState 已移除)
+  useEffect(() => {
+    if (forceOpen) {
+      store.dispatch(
+        add({
+          book: data.book,
+          para: data.para,
+          src_sn: data.sn.join("-"),
+          command: "finish",
+        })
+      );
+    }
+    // 仅在 forceOpen 变为 true 时触发一次,dispatch 不是 setState,合法
+  }, [forceOpen, data.book, data.para, data.sn]);
+
+  // paliColor 合并:交互状态优先,forceColorReset/anchorClosed 时重置,其次 relation 高亮
+  const resolvedPaliColor =
+    anchorClosed || forceColorReset
+      ? relationHighlight
+        ? "greenyellow"
+        : "unset"
+      : paliColor !== "unset"
+        ? paliColor
+        : relationHighlight
+          ? "greenyellow"
+          : "unset";
+
+  // ── Sub-components ────────────────────────────────────────────────────────
+
+  const wbwDialog = () => (
+    <WbwDetail
+      data={data}
+      visible={finalPopOpen}
+      popIsTop={popOnTop}
+      readonly={readonly}
+      onClose={() => {
+        setPaliColor("unset");
+        setPopOpen(false);
+      }}
+      onSave={(e: IWbw, isPublish: boolean, isPublic: boolean) => {
+        onSave?.(e, isPublish, isPublic);
+        setPopOpen(false);
+        setPaliColor("unset");
+      }}
+      onAttachmentSelectOpen={(open: boolean) => {
+        setPopOpen(!open);
+      }}
+      onTagCreate={(newTags: ITagMapData[]) => {
+        setTags(newTags);
+      }}
+    />
+  );
+
+  // ── Pali class & padding ──────────────────────────────────────────────────
+  let classPali = "pali";
+  if (data.style?.value === "note") {
+    classPali = "wbw_note";
+  } else if (data.style?.value === "bld" && !data.word.value.includes("{")) {
+    classPali = "pali wbw_bold";
+  }
+
+  const padding =
+    typeof data.real !== "undefined" && data.real.value !== ""
+      ? "4px"
+      : "4px 0";
+
+  // ── Pali node ─────────────────────────────────────────────────────────────
+  let pali: React.ReactNode;
+  if (data.word.value.includes("}")) {
+    const paliArray = data.word.value.replace("{", "").split("}");
+    pali = (
+      <>
+        <span style={{ fontWeight: 700 }}>
+          <PaliText
+            style={{ color: "brown" }}
+            text={paliArray[0]}
+            termToLocal={false}
+          />
+        </span>
+        <PaliText
+          style={{ color: "brown" }}
+          text={paliArray[1]}
+          termToLocal={false}
+        />
+      </>
+    );
+  } else {
+    pali = (
+      <PaliText
+        style={{ color: "brown" }}
+        text={data.word.value}
+        termToLocal={false}
+      />
+    );
+  }
+
+  const paliWord = (
+    <span
+      className={classPali}
+      style={{ backgroundColor: resolvedPaliColor, padding, borderRadius: 5 }}
+      onClick={() => {
+        if (typeof data.real?.value === "string") {
+          store.dispatch(lookup(data.real.value));
+        }
+      }}
+    >
+      {pali}
+    </span>
+  );
+
+  // ── Render ────────────────────────────────────────────────────────────────
+
+  if (typeof data.real !== "undefined" && data.real.value !== "") {
+    // 非标点符号
+    return (
+      <div className="pali_shell" ref={divShell}>
+        <div style={{ position: "absolute", marginTop: -24 }}>
+          <TagsArea
+            resId={data.uid}
+            resType="wbw"
+            selectorTitle={data.word.value}
+            data={tags}
+            max={1}
+          />
+        </div>
+
+        <span className="pali_shell_spell">
+          {data.grammarId ? (
+            <span
+              onClick={() => store.dispatch(grammarId(data.grammarId))}
+              style={{ cursor: "pointer" }}
+            >
+              <QuestionCircleOutlined style={{ color: "blue" }} />
+            </span>
+          ) : null}
+
+          <Popover
+            content={wbwDialog}
+            placement={popPlacement}
+            trigger="click"
+            open={finalPopOpen}
+          >
+            <span
+              onClick={() => {
+                popOpenChange(true);
+                store.dispatch(showWbw({ id: wordSn, channel: channelId }));
+              }}
+            >
+              {mode === "wbw" ? (
+                paliWord
+              ) : (
+                <span className="edit_icon">
+                  <EditOutlined style={{ cursor: "pointer" }} />
+                </span>
+              )}
+            </span>
+          </Popover>
+
+          {mode === "edit" ? paliWord : null}
+        </span>
+
+        {/*
+          antd6: Space 组件内部渲染方式调整,子节点返回 null 可能导致多余间距。
+          改用 inline-flex 容器替代 <Space>,更可预期地处理动态显隐子节点。
+        */}
+        <span style={{ display: "inline-flex", gap: 4, alignItems: "center" }}>
+          <VideoIcon attachments={data.attachments} />
+          <NoteIcon note={data.note?.value ?? undefined} />
+          <BookMarkIcon
+            text={data.bookMarkText?.value ?? undefined}
+            color={
+              data.bookMarkColor?.value
+                ? bookMarkColor[data.bookMarkColor.value]
+                : "white"
+            }
+          />
+          <RelationIcon hasRelation={!!data.relation} />
+          <WbwPaliDiscussionIcon
+            data={data}
+            studio={studio}
+            channelId={channelId}
+          />
+        </span>
+      </div>
+    );
+  }
+
+  // 标点符号
+  return (
+    <div className="pali_shell" style={{ cursor: "unset" }}>
+      {data.bookName ? (
+        <>
+          {/** TODO reload
+         * <ParaLinkCtl
+          title={data.word.value}
+          bookName={data.bookName}
+          paragraphs={data.word?.value}
+        />
+         */}
+        </>
+      ) : (
+        paliWord
+      )}
+    </div>
+  );
+};
+
+export default WbwPaliWidget;

+ 61 - 0
dashboard-v6/src/components/wbw/WbwPaliDiscussionIcon.tsx

@@ -0,0 +1,61 @@
+import { useAppSelector } from "../../hooks";
+import { courseUser } from "../../reducers/course-user";
+import { courseInfo } from "../../reducers/current-course";
+import { currentUser } from "../../reducers/current-user";
+import type { IDiscussionCountWbw } from "../../api/Comment";
+//import DiscussionButton from "../../discussion/DiscussionButton";
+
+import type { IStudio } from "../../api/Auth";
+import type { IWbw } from "../../types/wbw";
+
+interface IWidget {
+  data: IWbw;
+  studio?: IStudio;
+  channelId?: string;
+}
+const WbwPaliDiscussionIcon = ({ data, studio, channelId }: IWidget) => {
+  const userInCourse = useAppSelector(courseUser);
+  const currUser = useAppSelector(currentUser);
+  const course = useAppSelector(courseInfo);
+
+  let wbw: IDiscussionCountWbw | undefined;
+  let onlyMe = false;
+  if (userInCourse) {
+    if (userInCourse.role === "student") {
+      if (studio?.id === currUser?.id) {
+        //我自己的wbw channel 显示全部
+        onlyMe = false;
+      } else {
+        //其他channel 只显示自己的
+        onlyMe = true;
+      }
+    } else {
+      if (course) {
+        if (course.channelId === channelId) {
+          wbw = {
+            book_id: data.book,
+            paragraph: data.para,
+            wid: data.sn[0],
+          };
+        }
+      }
+    }
+  }
+  console.debug("wbw discussion", wbw, onlyMe);
+  /** TODO reload
+ *  return (
+    <DiscussionButton
+      initCount={data.hasComment ? 1 : 0}
+      hideCount
+      hideInZero
+      onlyMe={onlyMe}
+      resId={data.uid}
+      resType="wbw"
+      wbw={wbw}
+    />
+  );
+ */
+  return <></>;
+};
+
+export default WbwPaliDiscussionIcon;

+ 12 - 0
dashboard-v6/src/components/wbw/WbwPara.tsx

@@ -0,0 +1,12 @@
+import { Button } from "antd";
+import { PicCenterOutlined } from "@ant-design/icons";
+
+const WbwParaWidget = () => {
+  return (
+    <span>
+      <Button size="small" type="link" icon={<PicCenterOutlined />} />
+    </span>
+  );
+};
+
+export default WbwParaWidget;

+ 109 - 0
dashboard-v6/src/components/wbw/WbwParent.tsx

@@ -0,0 +1,109 @@
+import { useMemo } from "react";
+import { useIntl } from "react-intl";
+import { type MenuProps, Tooltip } from "antd";
+import { Dropdown, Space, Typography } from "antd";
+import { LoadingOutlined } from "@ant-design/icons";
+
+import { useAppSelector } from "../../hooks";
+import { inlineDict as _inlineDict } from "../../reducers/inline-dict";
+import { errorClass } from "./utils";
+import type { IWbw, TWbwDisplayMode } from "../../types/wbw";
+
+const { Text } = Typography;
+
+interface IWidget {
+  data: IWbw;
+  answer?: IWbw;
+  display?: TWbwDisplayMode;
+  onChange?: (key: string) => void;
+}
+
+const defaultMenu: MenuProps["items"] = [
+  {
+    key: "loading",
+    label: (
+      <Space>
+        <LoadingOutlined />
+        {"Loading"}
+      </Space>
+    ),
+  },
+];
+
+const WbwParent = ({ data, answer, display, onChange }: IWidget) => {
+  const intl = useIntl();
+  const inlineDict = useAppSelector(_inlineDict);
+
+  const items = useMemo<MenuProps["items"]>(() => {
+    const word = data.real.value;
+    if (!word || !inlineDict.wordIndex.includes(word)) {
+      return defaultMenu;
+    }
+
+    const result = inlineDict.wordList.filter((w) => w.word === word);
+    const uniqueParents = [
+      ...new Map(
+        result
+          .filter(
+            (w): w is typeof w & { parent: string } =>
+              typeof w.parent === "string"
+          )
+          .map((w) => [w.parent, 1] as const)
+      ).keys(),
+    ];
+
+    return [...uniqueParents, word].map((item) => ({
+      key: item,
+      label: item,
+    })) satisfies MenuProps["items"];
+  }, [data.real.value, inlineDict]);
+
+  const onClick: MenuProps["onClick"] = (e) => {
+    console.log("click ", e);
+    onChange?.(e.key);
+  };
+
+  const word = data.real?.value;
+  if (typeof word !== "string" || word.trim().length === 0) {
+    return <></>;
+  }
+
+  let parent = <></>;
+  if (display === "block") {
+    if (
+      typeof data.parent?.value === "string" &&
+      data.parent.value.trim().length > 0
+    ) {
+      const maxLen = word.length + 6 + Math.floor(word.length / 3);
+      const shortString = data.parent.value.slice(0, maxLen);
+      parent =
+        shortString === data.parent.value ? (
+          <span>{shortString}</span>
+        ) : (
+          <Tooltip title={data.parent.value}>{`${shortString}…`}</Tooltip>
+        );
+    } else {
+      parent = (
+        <Text type="secondary">
+          {intl.formatMessage({ id: "forms.fields.parent.label" })}
+        </Text>
+      );
+    }
+  }
+
+  const checkClass = answer
+    ? errorClass("factors", data.factors?.value, answer?.factors?.value)
+    : "";
+
+  return (
+    <div className={"wbw_word_item" + checkClass}>
+      <Text type="secondary">
+        <Dropdown menu={{ items, onClick }} placement="bottomLeft">
+          {parent}
+        </Dropdown>
+      </Text>
+    </div>
+  );
+};
+
+export default WbwParent;

+ 32 - 0
dashboard-v6/src/components/wbw/WbwParent2.tsx

@@ -0,0 +1,32 @@
+import { Tag, Tooltip } from "antd";
+import { useIntl } from "react-intl";
+import type { IWbw } from "../../types/wbw";
+
+interface IWidget {
+  data: IWbw;
+}
+const WbwParent2Widget = ({ data }: IWidget) => {
+  const intl = useIntl();
+
+  return data.grammar2?.value ? (
+    data.grammar2.value.trim() !== "" ? (
+      <Tooltip title={data.parent2?.value}>
+        <Tag color={"yellow"}>
+          {intl.formatMessage({
+            id:
+              "dict.fields.type." +
+              data.grammar2.value?.replaceAll(".", "") +
+              ".short.label",
+            defaultMessage: data.grammar2.value?.replaceAll(".", ""),
+          })}
+        </Tag>
+      </Tooltip>
+    ) : (
+      <></>
+    )
+  ) : (
+    <></>
+  );
+};
+
+export default WbwParent2Widget;

+ 42 - 0
dashboard-v6/src/components/wbw/WbwParentEditor.tsx

@@ -0,0 +1,42 @@
+import { useState } from "react";
+import { LoadingOutlined, WarningOutlined } from "@ant-design/icons";
+
+import type { IPreferenceResponse } from "../../api/Dict";
+import { Space } from "antd";
+import WbwParent from "./WbwParent";
+import type { IWbw, TWbwDisplayMode } from "../../types/wbw";
+
+interface IWidget {
+  initValue: IWbw;
+  display?: TWbwDisplayMode;
+  onChange?: (key: string) => Promise<IPreferenceResponse>;
+}
+const WbwParentEditor = ({ initValue, display, onChange }: IWidget) => {
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState(false);
+
+  return (
+    <Space>
+      {loading ? <LoadingOutlined /> : error ? <WarningOutlined /> : <></>}
+      <WbwParent
+        key="factors"
+        data={initValue}
+        display={display}
+        onChange={async (e: string) => {
+          console.log("factor change", e);
+          if (onChange) {
+            setLoading(true);
+            setError(false);
+            const response = await onChange(e);
+            setLoading(false);
+            if (!response.ok) {
+              setError(true);
+            }
+          }
+        }}
+      />
+    </Space>
+  );
+};
+
+export default WbwParentEditor;

+ 38 - 0
dashboard-v6/src/components/wbw/WbwParentIcon.tsx

@@ -0,0 +1,38 @@
+import { Tooltip } from "antd";
+import type { IWbw } from "../../types/wbw";
+import { Dict2SvgIcon } from "../../assets/icon";
+import { errorClass } from "./utils";
+
+interface IWidget {
+  data: IWbw;
+  answer?: IWbw;
+}
+const WbwParentIcon = ({ data, answer }: IWidget) => {
+  const iconEmpty = answer?.parent ? <Dict2SvgIcon /> : <></>;
+  let title: string | null | undefined = "空";
+  if (typeof data.parent?.value === "string" && data.parent?.value.length > 0) {
+    title = data.parent?.value;
+  }
+  const icon = data.parent?.value ? (
+    data.parent.value.trim() !== "" ? (
+      <Tooltip title={title}>
+        <Dict2SvgIcon />
+      </Tooltip>
+    ) : (
+      iconEmpty
+    )
+  ) : (
+    iconEmpty
+  );
+
+  const errClass = answer
+    ? errorClass("parent", data.parent?.value, answer?.parent?.value)
+    : "";
+  return (
+    <span className={"wbw_word_item" + errClass}>
+      <span className="icon">{icon}</span>
+    </span>
+  );
+};
+
+export default WbwParentIcon;

+ 56 - 0
dashboard-v6/src/components/wbw/WbwReal.tsx

@@ -0,0 +1,56 @@
+import { Tooltip } from "antd";
+import { Typography } from "antd";
+import Lookup from "../dict/Lookup";
+import type { IWbw, TWbwDisplayMode } from "../../types/wbw";
+
+const { Text } = Typography;
+
+interface IWidget {
+  data: IWbw;
+  display?: TWbwDisplayMode;
+}
+
+const WbwReal = ({ data, display }: IWidget) => {
+  if (
+    typeof data.real?.value === "string" &&
+    data.real.value.trim().length > 0
+  ) {
+    let wordReal: React.ReactNode = <></>;
+
+    if (display === "block") {
+      //block 模式下 限制宽度
+      const shortString = data.real.value.slice(
+        0,
+        data.word.value.length * 1.3 + 3
+      );
+      if (shortString === data.real.value) {
+        wordReal = <span>{shortString}</span>;
+      } else {
+        wordReal = (
+          <Tooltip title={data.real.value}>{`${shortString}…`}</Tooltip>
+        );
+      }
+    } else {
+      wordReal = (
+        <>
+          {data.real.value.split(" ").map((item, index) => (
+            <Lookup search={item} key={index}>
+              <Text type="secondary">{item} </Text>
+            </Lookup>
+          ))}
+        </>
+      );
+    }
+
+    return (
+      <div className="wbw_word_item">
+        <Text type="secondary">{wordReal}</Text>
+      </div>
+    );
+  } else {
+    //标点符号
+    return <></>;
+  }
+};
+
+export default WbwReal;

+ 64 - 0
dashboard-v6/src/components/wbw/WbwRelationAdd.tsx

@@ -0,0 +1,64 @@
+import { Button, Space } from "antd";
+
+import { useAppSelector } from "../../hooks";
+import { add, relationAddParam } from "../../reducers/relation-add";
+import store from "../../store";
+
+import { relationWordId } from "./utils";
+import type { IWbw } from "../../types/wbw";
+
+interface IWidget {
+  data: IWbw;
+}
+const WbwRelationAddWidget = ({ data }: IWidget) => {
+  const addParam = useAppSelector(relationAddParam);
+
+  const show = addParam?.command === "add" ? true : false;
+
+  return (
+    <div style={{ position: "absolute", marginTop: "-24px" }}>
+      {show ? (
+        <Space>
+          <Button
+            onClick={() => {
+              if (typeof addParam === "undefined") {
+                return;
+              }
+              store.dispatch(
+                add({
+                  book: addParam.book,
+                  para: addParam.para,
+                  src_sn: addParam?.src_sn,
+                  target_id: relationWordId(data),
+                  target_spell: data.word.value,
+                  command: "apply",
+                })
+              );
+            }}
+          >
+            add
+          </Button>
+          <Button
+            onClick={() => {
+              if (typeof addParam === "undefined") {
+                return;
+              }
+              store.dispatch(
+                add({
+                  book: addParam.book,
+                  para: addParam.para,
+                  src_sn: addParam.src_sn,
+                  command: "cancel",
+                })
+              );
+            }}
+          >
+            cancel
+          </Button>
+        </Space>
+      ) : undefined}
+    </div>
+  );
+};
+
+export default WbwRelationAddWidget;

+ 20 - 0
dashboard-v6/src/components/wbw/WbwVideoButton.tsx

@@ -0,0 +1,20 @@
+import { VideoCtl } from "../template/Video";
+
+export interface IVideo {
+  videoId: string;
+  url?: string;
+  type?: string;
+  title?: string;
+}
+interface IWidget {
+  video: IVideo[];
+}
+const WbwVideoButtonWidget = ({ video }: IWidget) => {
+  return video && video.length > 0 ? (
+    <VideoCtl id={video[0].videoId} type={video[0].type} style="modal" />
+  ) : (
+    <></>
+  );
+};
+
+export default WbwVideoButtonWidget;

+ 311 - 0
dashboard-v6/src/components/wbw/WbwWord.tsx

@@ -0,0 +1,311 @@
+import { useState, useEffect, useRef } from "react";
+
+import { useAppSelector } from "../../hooks";
+import { add, updateIndex, wordIndex } from "../../reducers/inline-dict";
+import { get } from "../../request";
+import store from "../../store";
+
+import type { IApiResponseDictList } from "../../api/Dict";
+import WbwCase from "./WbwCase";
+
+import WbwFactorMeaning from "./WbwFactorMeaning";
+import WbwFactors from "./WbwFactors";
+import WbwMeaning from "./WbwMeaning";
+import WbwPali from "./WbwPali";
+import "./wbw.css";
+import WbwPara from "./WbwPara";
+import WbwPage from "./WbwPage";
+import WbwRelationAdd from "./WbwRelationAdd";
+import WbwReal from "./WbwReal";
+import WbwDetailFm from "./WbwDetailFm";
+import type { IStudio } from "../../api/Auth";
+import type { ArticleMode } from "../../api/Corpus";
+import {
+  WbwStatus,
+  type IWbw,
+  type IWbwFields,
+  type TWbwDisplayMode,
+} from "../../types/wbw";
+import { bookMarkColor } from "./utils";
+
+interface IWidget {
+  data: IWbw;
+  answer?: IWbw;
+  channelId: string;
+  display?: TWbwDisplayMode;
+  fields?: IWbwFields;
+  mode?: ArticleMode;
+  wordDark?: boolean;
+  studio?: IStudio;
+  readonly?: boolean;
+  onChange?: (e: IWbw, isPublish?: boolean, isPublic?: boolean) => void;
+  onSplit?: (e: boolean) => void;
+}
+const WbwWordWidget = ({
+  data,
+  answer,
+  channelId,
+  display,
+  mode = "edit",
+  fields = {
+    real: false,
+    meaning: true,
+    factors: true,
+    factorMeaning: true,
+    factorMeaning2: false,
+    case: true,
+  },
+  wordDark = false,
+  readonly = false,
+  studio,
+  onChange,
+  onSplit,
+}: IWidget) => {
+  const [wordData, setWordData] = useState(data);
+  const fieldDisplay = fields;
+  const [newFactors, setNewFactors] = useState<string>();
+  const [showRelationTool, setShowRelationTool] = useState(false);
+  const intervalRef = useRef<number | null>(null); //防抖计时器句柄
+  const inlineWordIndex = useAppSelector(wordIndex);
+
+  useEffect(() => {
+    setWordData(data);
+  }, [data]);
+
+  const color = wordData.bookMarkColor?.value
+    ? bookMarkColor[wordData.bookMarkColor.value]
+    : "unset";
+  const wbwCtl = wordData.type?.value === ".ctl." ? "wbw_ctl" : "";
+  const wbwAnchor = wordData.grammar?.value === ".a." ? "wbw_anchor" : "";
+  const wbwDark = wordDark ? "dark" : "";
+
+  const styleWbw: React.CSSProperties = {
+    display: display === "block" ? "block" : "flex",
+  };
+
+  /**
+   * 停止查字典计时
+   * 在两种情况下停止计时
+   * 1. 开始查字典
+   * 2. 防抖时间内鼠标移出单词区
+   */
+  const stopLookup = () => {
+    if (intervalRef.current) {
+      window.clearInterval(intervalRef.current);
+      intervalRef.current = null;
+    }
+  };
+  /**
+   * 查字典
+   * @param word 要查的单词
+   */
+  const lookup = (words: string[]) => {
+    stopLookup();
+
+    //查询这个词在内存字典里是否有
+    const searchWord = words.filter((value) => {
+      if (inlineWordIndex.includes(value)) {
+        //已经有了
+        return false;
+      } else {
+        return true;
+      }
+    });
+    if (searchWord.length === 0) {
+      return;
+    }
+    get<IApiResponseDictList>(`/v2/wbwlookup?word=${searchWord.join()}`).then(
+      (json) => {
+        console.log("lookup ok", json.data.count);
+        console.log("time", json.data.time);
+        //存储到redux
+        store.dispatch(add(json.data.rows));
+        store.dispatch(updateIndex(searchWord));
+      }
+    );
+
+    console.log("lookup", searchWord);
+  };
+
+  if (wordData.type?.value === ".ctl.") {
+    if (wordData.word.value?.includes("para")) {
+      return <WbwPara />;
+    } else {
+      return <WbwPage data={wordData} />;
+    }
+  } else {
+    return (
+      <div
+        className={`wbw_word ${display}_${mode} display_${display} ${wbwCtl} ${wbwAnchor} ${wbwDark} `}
+        style={styleWbw}
+        onMouseEnter={() => {
+          setShowRelationTool(true);
+          if (
+            intervalRef.current === null &&
+            wordData.real &&
+            wordData.real.value &&
+            wordData.real.value.length > 0
+          ) {
+            //开始计时,计时结束查字典
+            let words: string[] = [wordData.real.value];
+            if (
+              wordData.parent &&
+              wordData.parent?.value !== "" &&
+              wordData.parent?.value !== null
+            ) {
+              words.push(wordData.parent.value);
+            }
+            if (
+              wordData.factors &&
+              wordData.factors?.value !== "" &&
+              wordData.factors?.value !== null
+            ) {
+              words = [
+                ...words,
+                ...wordData.factors.value.replaceAll("-", "+").split("+"),
+              ];
+            }
+            intervalRef.current = window.setInterval(lookup, 300, words);
+          }
+        }}
+        onMouseLeave={() => {
+          stopLookup();
+          setShowRelationTool(false);
+        }}
+      >
+        {showRelationTool && data.real.value ? (
+          <WbwRelationAdd data={data} />
+        ) : undefined}
+        <WbwPali
+          key="pali"
+          data={wordData}
+          channelId={channelId}
+          mode={mode}
+          display={display}
+          studio={studio}
+          readonly={readonly}
+          onSave={(e: IWbw, isPublish: boolean, isPublic: boolean) => {
+            const newData: IWbw = JSON.parse(JSON.stringify(e));
+            setWordData(newData);
+            if (typeof onChange !== "undefined") {
+              onChange(e, isPublish, isPublic);
+            }
+          }}
+        />
+        <div
+          className="wbw_body"
+          style={{
+            background: `linear-gradient(90deg, rgba(255, 255, 255, 0), ${color})`,
+          }}
+        >
+          {fieldDisplay?.real ? (
+            <WbwReal key="real" data={wordData} display={display} />
+          ) : undefined}
+          {fieldDisplay?.meaning ? (
+            <WbwMeaning
+              key="meaning"
+              mode={mode}
+              data={wordData}
+              answer={answer}
+              display={display}
+              onChange={(value?: string | null) => {
+                if (value) {
+                  const newData: IWbw = JSON.parse(JSON.stringify(wordData));
+                  newData.meaning = { value: value, status: WbwStatus.manual };
+                  setWordData(newData);
+                  if (typeof onChange !== "undefined") {
+                    onChange(newData, false, false);
+                  }
+                }
+              }}
+            />
+          ) : undefined}
+          {fieldDisplay?.factors ? (
+            <WbwFactors
+              key="factors"
+              data={wordData}
+              answer={answer}
+              display={display}
+              onChange={(e: string) => {
+                console.log("factor change", e);
+                const newData: IWbw = JSON.parse(JSON.stringify(wordData));
+                newData.factors = { value: e, status: 5 };
+                setNewFactors(e);
+                setWordData(newData);
+                if (typeof onChange !== "undefined") {
+                  onChange(newData, false, false);
+                }
+              }}
+            />
+          ) : undefined}
+          {fieldDisplay?.factorMeaning ? (
+            <WbwFactorMeaning
+              key="fm"
+              data={wordData}
+              answer={answer}
+              display={display}
+              factors={newFactors}
+              onChange={(e: string) => {
+                const newData: IWbw = JSON.parse(JSON.stringify(wordData));
+                newData.factorMeaning = { value: e, status: 5 };
+                setWordData(newData);
+                if (typeof onChange !== "undefined") {
+                  onChange(newData, false, false);
+                }
+              }}
+            />
+          ) : undefined}
+          {fieldDisplay?.factorMeaning2 ? (
+            <WbwDetailFm
+              factors={wordData.factors?.value?.split("+")}
+              value={wordData.factorMeaning?.value?.split("+")}
+              onChange={(value: string[]) => {
+                const newData: IWbw = JSON.parse(JSON.stringify(wordData));
+                newData.factorMeaning = {
+                  value: value.join("+"),
+                  status: WbwStatus.manual,
+                };
+                setWordData(newData);
+                if (typeof onChange !== "undefined") {
+                  onChange(newData);
+                }
+              }}
+              onJoin={(value: string) => {
+                const newData: IWbw = JSON.parse(JSON.stringify(wordData));
+                newData.meaning = { value: value, status: WbwStatus.manual };
+                setWordData(newData);
+                if (typeof onChange !== "undefined") {
+                  onChange(newData);
+                }
+              }}
+            />
+          ) : undefined}
+          {fieldDisplay?.case ? (
+            <WbwCase
+              key="case"
+              data={wordData}
+              answer={answer}
+              display={display}
+              onSplit={(e: boolean) => {
+                console.log("onSplit", wordData.factors?.value);
+                if (typeof onSplit !== "undefined") {
+                  onSplit(e);
+                }
+              }}
+              onChange={(e: string) => {
+                const newData: IWbw = JSON.parse(JSON.stringify(wordData));
+                newData.case = { value: e, status: 7 };
+                setWordData(newData);
+                if (typeof onChange !== "undefined") {
+                  onChange(newData);
+                }
+              }}
+            />
+          ) : undefined}
+        </div>
+      </div>
+    );
+  }
+};
+
+export default WbwWordWidget;

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

@@ -0,0 +1,134 @@
+import type { IntlShape } from "react-intl";
+import type { MenuProps } from "antd";
+import type { IApiResponseDictData } from "../../api/Dict";
+import type { IWbw, TFieldName } from "../../types/wbw";
+
+export const caseInDict = (
+  wordIn: string,
+  wordIndex: string[],
+  wordList: IApiResponseDictData[],
+  intl: IntlShape
+): MenuProps["items"] => {
+  if (!wordIn) return [];
+
+  if (wordIndex.includes(wordIn)) {
+    const result = wordList.filter((w) => w.word === wordIn);
+
+    const myMap = new Map<string, number>();
+    const factors: string[] = [];
+
+    for (const item of result) {
+      myMap.set(item.type + "#" + item.grammar, 1);
+    }
+
+    myMap.forEach((_value, key) => {
+      factors.push(key);
+    });
+
+    const menu = factors.map((item) => {
+      const arr = item.replaceAll(".", "").replaceAll("#", "$").split("$");
+
+      const noNull = arr.filter(Boolean);
+
+      noNull.forEach((v, i) => {
+        noNull[i] = intl.formatMessage({
+          id: `dict.fields.type.${v}.short.label`,
+          defaultMessage: v,
+        });
+      });
+
+      return { key: item, label: noNull.join(" ") };
+    });
+
+    return menu.length ? menu : [{ key: "", disabled: true, label: "Empty" }];
+  }
+
+  return [{ key: "", disabled: true, label: "Loading" }];
+};
+
+export const bookMarkColor = ["#fff", "#f99", "#ff9", "#9f9", "#9ff", "#99f"];
+
+export const getParentInDict = (
+  wordIn: string,
+  wordIndex: string[],
+  wordList: IApiResponseDictData[]
+): string[] => {
+  if (wordIndex.includes(wordIn)) {
+    const result = wordList.filter((word) => word.word === wordIn);
+    //查重
+    //TODO 加入信心指数并排序
+    const myMap = new Map<string, number>();
+    const parent: string[] = [];
+    for (const iterator of result) {
+      if (iterator.parent) {
+        myMap.set(iterator.parent, 1);
+      }
+    }
+    myMap.forEach((_value, key) => {
+      parent.push(key);
+    });
+    return parent;
+  } else {
+    return [];
+  }
+};
+
+export const getFactorsInDict = (
+  wordIn: string,
+  wordIndex: string[],
+  wordList: IApiResponseDictData[]
+): string[] => {
+  if (wordIndex.includes(wordIn)) {
+    const result = wordList.filter((word) => word.word === wordIn);
+    //查重
+    //TODO 加入信心指数并排序
+    const myMap = new Map<string, number>();
+    const factors: string[] = [];
+    for (const iterator of result) {
+      if (iterator.factors) {
+        myMap.set(iterator.factors, 1);
+      }
+    }
+    myMap.forEach((_value, key) => {
+      factors.push(key);
+    });
+    return factors;
+  } else {
+    return [];
+  }
+};
+
+export const errorClass = (
+  field: TFieldName,
+  data?: string | null,
+  answer?: string | null
+): string => {
+  let classError = "";
+
+  if (answer !== data) {
+    classError = " wbw_check";
+    switch (field) {
+      case "parent":
+        classError += " wbw_error";
+        break;
+      case "case":
+        classError += " wbw_error";
+        break;
+      case "factors":
+        classError += " wbw_warning";
+        break;
+      case "factorMeaning":
+        classError += " wbw_info";
+        break;
+      case "meaning":
+        classError += " wbw_info";
+        break;
+    }
+  }
+
+  return classError;
+};
+
+export const relationWordId = (word: IWbw) => {
+  return `${word.book}-${word.para}-` + word.sn.join("-");
+};

+ 126 - 0
dashboard-v6/src/components/wbw/wbw.css

@@ -0,0 +1,126 @@
+.layout-h {
+  display: flex;
+  flex-wrap: wrap;
+}
+.layout-v {
+  display: block;
+}
+.layout-v .pali_shell {
+  display: flex;
+}
+.block_wbw {
+  display: block;
+}
+.inline_wbw {
+  display: flex;
+}
+.layout-v .wbw_body {
+  display: unset;
+}
+.wbw_word {
+  margin: 0 0 0.2em 0;
+  padding-right: 0;
+  max-width: 60vw;
+}
+.wbw_detail_basic .ant-form-item {
+  margin: 0 0 4px;
+}
+.wbw_detail_basic .ant-collapse > .ant-collapse-item > .ant-collapse-header {
+  padding-top: 2px;
+  padding-bottom: 2px;
+}
+.wbw_ctl {
+  display: none;
+}
+.wbw_anchor {
+  display: none;
+}
+
+.wbw_split {
+  visibility: hidden;
+}
+.wbw_word:hover .wbw_split {
+  visibility: visible;
+}
+.block_wbw .pali {
+  font-size: 1.1em;
+}
+.pali:hover {
+  cursor: pointer;
+}
+.inline .pali:hover {
+  text-decoration-line: underline;
+  text-underline-offset: 5px;
+}
+.block .pali {
+  font-weight: 500;
+  padding: 0px 2px;
+  margin: 0px;
+  line-height: 1.5em;
+}
+.block_wbw .pali_shell {
+  border-bottom: 1px solid gray;
+}
+.inline .pali {
+  color: brown;
+}
+.wbw_note {
+  color: #177ddc;
+}
+.wbw_bold {
+  font-weight: 700;
+}
+.block_wbw .wbw_note {
+  font-weight: 500;
+}
+.block_wbw .wbw_body {
+  padding-right: 0.5em;
+  padding-top: 0.2em;
+}
+.wbw_word_item {
+  padding: 0;
+  cursor: pointer;
+}
+
+.wbw_check {
+  text-decoration: underline wavy;
+  text-underline-offset: 2px;
+}
+.wbw_error {
+  text-decoration-color: red;
+}
+.wbw_error .icon {
+  color: red;
+}
+.wbw_warning {
+  text-decoration-color: orange;
+}
+
+.wbw_info {
+  text-decoration-color: blue;
+}
+
+.layout-v .wbw_word_item {
+  margin-right: 8px;
+  flex: 5;
+}
+.case {
+  padding: 0 2px;
+  line-height: 1.5em;
+}
+.case:first-child {
+  outline: 1px solid;
+  outline-offset: -1px;
+  margin: 0 0.3em 0 1px;
+  padding: 0px 2px;
+}
+.edit_icon {
+  display: none;
+  position: absolute;
+  border: 1px solid gray;
+  background-color: wheat;
+  margin-top: -1.2em;
+}
+.pali_shell:hover .edit_icon {
+  display: inline-block;
+}

+ 181 - 0
dashboard-v6/src/layouts/test/index.tsx

@@ -0,0 +1,181 @@
+import { useState, useMemo } from "react";
+import { Layout, Tree, Typography, Empty, theme } from "antd";
+import { Outlet, useNavigate, useLocation } from "react-router";
+import { testRoutes, type TestRouteObject } from "../../routes/testRoutes";
+import type { TreeDataNode } from "antd";
+
+const { Sider, Content } = Layout;
+const { Title, Text } = Typography;
+
+// ─── 递归构建 antd TreeDataNode ───────────────────────────────────────────────
+
+function buildTreeData(
+  routes: TestRouteObject[],
+  parentPath = "/test"
+): TreeDataNode[] {
+  return routes.map((route) => {
+    const fullPath = `${parentPath}/${route.path}`;
+    return {
+      key: fullPath,
+      title: route.label,
+      children: route.children?.length
+        ? buildTreeData(route.children, fullPath)
+        : undefined,
+      // 叶子节点(有 Component、无 children)才可点击跳转
+      isLeaf: !route.children?.length,
+    };
+  });
+}
+
+// ─── 收集所有父节点 key,用于默认展开 ─────────────────────────────────────────
+
+function collectParentKeys(
+  routes: TestRouteObject[],
+  parentPath = "/test"
+): string[] {
+  const keys: string[] = [];
+  routes.forEach((route) => {
+    const fullPath = `${parentPath}/${route.path}`;
+    if (route.children?.length) {
+      keys.push(fullPath);
+      keys.push(...collectParentKeys(route.children, fullPath));
+    }
+  });
+  return keys;
+}
+
+// ─── 根据当前路径找到所有祖先 key,用于展开当前选中节点的父级 ─────────────────
+
+function getAncestorKeys(pathname: string): string[] {
+  const parts = pathname.split("/").filter(Boolean); // ["test", "button", "basic"]
+  const keys: string[] = [];
+  for (let i = 1; i < parts.length; i++) {
+    keys.push("/" + parts.slice(0, i).join("/"));
+  }
+  return keys;
+}
+
+// ─── TestLayout ───────────────────────────────────────────────────────────────
+
+export default function TestLayout() {
+  const navigate = useNavigate();
+  const location = useLocation();
+  const { token } = theme.useToken();
+
+  const treeData = useMemo(() => buildTreeData(testRoutes), []);
+  const allParentKeys = useMemo(() => collectParentKeys(testRoutes), []);
+
+  const [expandedKeys, setExpandedKeys] = useState<string[]>(() => [
+    ...allParentKeys,
+    ...getAncestorKeys(location.pathname),
+  ]);
+
+  // 当路由变化时,确保当前节点的祖先都展开(用 useMemo 避免在 effect 中 setState)
+  const mergedExpandedKeys = useMemo(() => {
+    const ancestors = getAncestorKeys(location.pathname);
+    const merged = new Set([...expandedKeys, ...ancestors]);
+    return Array.from(merged);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [location.pathname]);
+
+  const isIndexRoute =
+    location.pathname === "/test" || location.pathname === "/test/";
+
+  const handleSelect = (selectedKeys: React.Key[]) => {
+    const key = selectedKeys[0] as string;
+    if (key) navigate(key);
+  };
+
+  return (
+    <Layout style={{ minHeight: "100vh", background: token.colorBgLayout }}>
+      {/* ── 左侧栏 ── */}
+      <Sider
+        width={240}
+        style={{
+          background: token.colorBgContainer,
+          borderRight: `1px solid ${token.colorBorderSecondary}`,
+          overflow: "auto",
+          position: "sticky",
+          top: 0,
+          height: "100vh",
+        }}
+      >
+        {/* 标题 */}
+        <div
+          style={{
+            padding: "16px 20px 12px",
+            borderBottom: `1px solid ${token.colorBorderSecondary}`,
+            marginBottom: 8,
+          }}
+        >
+          <Text
+            type="secondary"
+            style={{
+              fontSize: 11,
+              letterSpacing: "0.08em",
+              textTransform: "uppercase",
+            }}
+          >
+            组件测试
+          </Text>
+          <Title level={5} style={{ margin: "4px 0 0", lineHeight: 1.4 }}>
+            Test Playground
+          </Title>
+        </div>
+
+        {/* Tree */}
+        {treeData.length > 0 ? (
+          <Tree
+            treeData={treeData}
+            selectedKeys={[location.pathname]}
+            expandedKeys={mergedExpandedKeys}
+            onExpand={(keys) => setExpandedKeys(keys as string[])}
+            onSelect={handleSelect}
+            blockNode
+            style={{
+              padding: "4px 8px",
+              background: "transparent",
+            }}
+          />
+        ) : (
+          <Empty
+            description="暂无测试组件"
+            image={Empty.PRESENTED_IMAGE_SIMPLE}
+            style={{ marginTop: 40 }}
+          />
+        )}
+      </Sider>
+
+      {/* ── 右侧内容 ── */}
+      <Content
+        style={{
+          padding: 32,
+          overflow: "auto",
+          minHeight: "100vh",
+        }}
+      >
+        {isIndexRoute ? (
+          // 首页提示
+          <div
+            style={{
+              display: "flex",
+              flexDirection: "column",
+              alignItems: "center",
+              justifyContent: "center",
+              height: "60vh",
+              gap: 12,
+            }}
+          >
+            <div style={{ fontSize: 48 }}>🧪</div>
+            <Title level={3} style={{ margin: 0 }}>
+              组件测试平台
+            </Title>
+            <Text type="secondary">请从左侧目录选择一个组件开始测试</Text>
+          </div>
+        ) : (
+          <Outlet />
+        )}
+      </Content>
+    </Layout>
+  );
+}

+ 32 - 0
dashboard-v6/src/layouts/workspace/editor.tsx

@@ -0,0 +1,32 @@
+import React, { useState } from "react";
+import { Splitter } from "antd";
+import { Outlet } from "react-router";
+import RightPanel from "../../components/right-panel/RightPanel";
+
+const defaultSizes = ["70%", "30%"];
+
+const App: React.FC = () => {
+  const [sizes, setSizes] = useState<(number | string)[]>(defaultSizes);
+
+  const handleDoubleClick = () => {
+    setSizes(defaultSizes);
+  };
+
+  return (
+    <Splitter
+      style={{ height: "100vh" }}
+      onResize={setSizes}
+      onDraggerDoubleClick={handleDoubleClick}
+    >
+      <Splitter.Panel size={sizes[0]}>
+        <Outlet />
+      </Splitter.Panel>
+
+      <Splitter.Panel size={sizes[1]}>
+        <RightPanel />
+      </Splitter.Panel>
+    </Splitter>
+  );
+};
+
+export default App;

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

@@ -41,7 +41,7 @@ const Widget = () => {
           <HeaderBreadcrumb />
           <HeaderBreadcrumb />
         </Header>
         </Header>
 
 
-        <Content style={{ padding: 20 }}>
+        <Content style={{ padding: 12 }}>
           <Outlet />
           <Outlet />
         </Content>
         </Content>
       </Layout>
       </Layout>

+ 13 - 0
dashboard-v6/src/routes/buildRoutes.ts

@@ -0,0 +1,13 @@
+import type { RouteObject } from "react-router";
+import type { TestRouteObject } from "./testRoutes";
+
+/**
+ * 递归地将 TestRouteObject[] 转换为 react-router RouteObject[]
+ */
+export function buildRouteConfig(routes: TestRouteObject[]): RouteObject[] {
+  return routes.map((route) => ({
+    path: route.path,
+    Component: route.Component,
+    children: route.children ? buildRouteConfig(route.children) : undefined,
+  }));
+}

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

@@ -0,0 +1,37 @@
+import { lazy } from "react";
+import type { ComponentType } from "react";
+
+const TestVideoPlayerTest = lazy(
+  () => import("../components/video/VideoPlayerTest")
+);
+
+// 你可以继续添加更多测试组件
+// const TestButtonDemo = lazy(() => import("../components/button/ButtonDemo"));
+
+export interface TestRouteObject {
+  path: string;
+  label: string;
+  icon?: string; // 可选图标(emoji 或 icon key)
+  Component?: ComponentType;
+  children?: TestRouteObject[];
+}
+
+export const testRoutes: TestRouteObject[] = [
+  {
+    path: "VideoPlayer",
+    label: "视频播放器",
+    Component: TestVideoPlayerTest,
+  },
+  // 示例:嵌套结构
+  // {
+  //   path: "button",
+  //   label: "按钮",
+  //   children: [
+  //     {
+  //       path: "basic",
+  //       label: "基础按钮",
+  //       Component: TestButtonDemo,
+  //     },
+  //   ],
+  // },
+];

+ 8 - 0
dashboard-v6/src/types/template.ts

@@ -6,3 +6,11 @@ export type TCodeConvertor =
   | "roman_to_thai"
   | "roman_to_thai"
   | "roman_to_taitham"
   | "roman_to_taitham"
   | "roman_to_si";
   | "roman_to_si";
+
+export type TDisplayStyle =
+  | "modal"
+  | "card"
+  | "toggle"
+  | "link"
+  | "window"
+  | "popover";

+ 86 - 0
dashboard-v6/src/types/wbw.ts

@@ -0,0 +1,86 @@
+import type { IUser } from "../api/Auth";
+
+export type TFieldName =
+  | "word"
+  | "real"
+  | "meaning"
+  | "type"
+  | "grammar"
+  | "grammar2"
+  | "case"
+  | "parent"
+  | "parent2"
+  | "factors"
+  | "factorMeaning"
+  | "relation"
+  | "note"
+  | "bookMarkColor"
+  | "bookMarkText"
+  | "locked"
+  | "attachments"
+  | "confidence";
+
+export interface IWbwField {
+  field: TFieldName;
+  value: string;
+}
+
+export enum WbwStatus {
+  initiate = 0,
+  auto = 3,
+  apply = 5,
+  manual = 7,
+}
+
+export interface IWbwAttachment {
+  id: string;
+  content_type: string;
+  size: number;
+  title: string;
+}
+export interface WbwElement<R> {
+  value: R;
+  status: WbwStatus;
+}
+
+export interface IWbw {
+  uid?: string;
+  book: number;
+  para: number;
+  sn: number[];
+  word: WbwElement<string>;
+  real: WbwElement<string | null>;
+  meaning?: WbwElement<string | null>;
+  type?: WbwElement<string | null>;
+  grammar?: WbwElement<string | null>;
+  style?: WbwElement<string | null>;
+  case?: WbwElement<string | null>;
+  parent?: WbwElement<string | null>;
+  parent2?: WbwElement<string | null>;
+  grammar2?: WbwElement<string | null>;
+  factors?: WbwElement<string | null>;
+  factorMeaning?: WbwElement<string | null>;
+  relation?: WbwElement<string | null>;
+  note?: WbwElement<string | null>;
+  bookMarkColor?: WbwElement<number | null>;
+  bookMarkText?: WbwElement<string | null>;
+  locked?: boolean;
+  confidence: number;
+  attachments?: IWbwAttachment[];
+  hasComment?: boolean;
+  grammarId?: string;
+  bookName?: string;
+  editor?: IUser;
+  created_at?: string;
+  updated_at?: string;
+}
+export interface IWbwFields {
+  real?: boolean;
+  meaning?: boolean;
+  factors?: boolean;
+  factorMeaning?: boolean;
+  factorMeaning2?: boolean;
+  case?: boolean;
+}
+
+export type TWbwDisplayMode = "block" | "inline" | "list";

+ 20 - 1
dashboard-v6/src/utils.ts

@@ -1,4 +1,4 @@
-import type { SortOrder } from "antd/lib/table/interface"
+import type { SortOrder } from "antd/lib/table/interface";
 import lodash from "lodash";
 import lodash from "lodash";
 
 
 export function dashboardBasePath(): string {
 export function dashboardBasePath(): string {
@@ -81,3 +81,22 @@ export const convertToPlain = (html: string): string => {
 export const randomString = (): string => {
 export const randomString = (): string => {
   return lodash.times(20, () => lodash.random(35).toString(36)).join("");
   return lodash.times(20, () => lodash.random(35).toString(36)).join("");
 };
 };
+/**
+ * 10进制数字转为16进制字符串
+ * @param {number} arg
+ * @returns
+ */
+/*
+作者:sq800
+链接:https://juejin.cn/post/7250029395024281656
+来源:稀土掘金
+著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
+*/
+export const numToHex = (arg: number) => {
+  try {
+    const a = arg.toString(16).toUpperCase();
+    return a.length % 2 === 1 ? "0" + a : a;
+  } catch (e) {
+    console.warn("数字转16进制出错:", e);
+  }
+};

+ 33 - 0
dashboard-v6/src/utils/code/core/buildConverter.ts

@@ -0,0 +1,33 @@
+export type MapTable = Record<string, string>;
+
+export function buildConverter(map: MapTable) {
+  const keys = Object.keys(map).sort((a, b) => b.length - a.length);
+
+  return (input: string) => {
+    if (!input) return "";
+
+    input = input.normalize("NFC").toLowerCase();
+
+    let out = "";
+    let i = 0;
+
+    while (i < input.length) {
+      let matched = false;
+
+      for (const k of keys) {
+        if (input.startsWith(k, i)) {
+          out += map[k];
+          i += k.length;
+          matched = true;
+          break;
+        }
+      }
+
+      if (!matched) {
+        out += input[i++];
+      }
+    }
+
+    return out;
+  };
+}

+ 137 - 0
dashboard-v6/src/utils/code/index.ts

@@ -0,0 +1,137 @@
+/**
+ * ============================================================
+ * Script Conversion Engine — 统一文字系统转换入口
+ * ============================================================
+ *
+ * 本模块是所有文字系统转换器的统一入口。
+ * 提供:
+ *
+ *   script.<lang>.fromRoman(text)
+ *   script.<lang>.toRoman(text)
+ *
+ * ------------------------------------------------------------
+ * 📌 使用方式
+ * ------------------------------------------------------------
+ *
+ * import { script } from "@/utils/code";
+ *
+ * script.thai.fromRoman("namo tassa");
+ * script.my.toRoman("ဓမ္မ");
+ *
+ *
+ * ------------------------------------------------------------
+ * 📌 动态语言调用
+ * ------------------------------------------------------------
+ *
+ * import { script, ScriptName } from "@/utils/code";
+ *
+ * function convert(
+ *   lang: ScriptName,
+ *   dir: "fromRoman" | "toRoman",
+ *   text: string
+ * ){
+ *   return script[lang][dir](text);
+ * }
+ *
+ *
+ * ------------------------------------------------------------
+ * 📌 添加新语言方法(标准流程)
+ * ------------------------------------------------------------
+ *
+ * ① 在 scripts 目录创建文件
+ *
+ *    utils/code/scripts/lao.ts
+ *
+ * ② 写入结构
+ *
+ *    import { buildConverter } from "../core/buildConverter";
+ *
+ *    const romanToLao = { k:"ກ" } as const;
+ *    const laoToRoman = { ກ:"k" } as const;
+ *
+ *    export const lao = {
+ *      fromRoman: buildConverter(romanToLao),
+ *      toRoman: buildConverter(laoToRoman)
+ *    };
+ *
+ * ③ 在 index.ts 注册
+ *
+ *    import { lao } from "./scripts/lao";
+ *
+ *    export const script = {
+ *      ...,
+ *      lao
+ *    } as const;
+ *
+ *
+ * ------------------------------------------------------------
+ * 📌 维护规范(必须遵守)
+ * ------------------------------------------------------------
+ *
+ * ✔ 所有语言模块必须:
+ *   - 使用 buildConverter()
+ *   - 导出对象名必须与文件名一致
+ *   - 必须包含:
+ *        fromRoman
+ *        toRoman
+ *
+ * ✔ mapping 表必须:
+ *   - 使用 as const
+ *   - key 不可重复
+ *   - 长规则写在短规则前(或交给 buildConverter 排序)
+ *
+ *
+ * ------------------------------------------------------------
+ * 📌 不要做的事(重要)
+ * ------------------------------------------------------------
+ *
+ * ❌ 不要直接导出函数
+ * ❌ 不要在组件中写转换逻辑
+ * ❌ 不要动态构造 mapping
+ *
+ * 所有转换规则必须写在 scripts 文件内。
+ *
+ *
+ * ------------------------------------------------------------
+ * 📌 类型系统说明
+ * ------------------------------------------------------------
+ *
+ * ScriptName 类型自动生成:
+ *
+ * type ScriptName = "thai" | "my" | "si" | "taitham"
+ *
+ * 新语言注册后类型会自动更新。
+ *
+ *
+ * ------------------------------------------------------------
+ * 📌 架构设计原则
+ * ------------------------------------------------------------
+ *
+ * 本系统遵循:
+ *
+ *   「语言是对象,转换是方法」
+ *
+ * 而不是:
+ *
+ *   「转换是函数」
+ *
+ * 这样设计的好处:
+ *
+ * - API 可读性高
+ * - IDE 自动提示完整
+ * - 可扩展语言
+ * - 支持插件化
+ * - 易维护
+ *
+ *
+ * ============================================================
+ */
+import { thai } from "./scripts/thai";
+import { my } from "./scripts/my";
+
+export const script = {
+  thai,
+  my,
+} as const;
+
+export type ScriptName = keyof typeof script;

+ 22 - 0
dashboard-v6/src/utils/code/scripts/my.ts

@@ -0,0 +1,22 @@
+import { buildConverter } from "../core/buildConverter";
+
+const romanToLocalMap = {
+  kh: "ခ္",
+  gh: "ဃ္",
+  k: "က္",
+  a: "အ",
+  ā: "အာ",
+} as const;
+
+const localToRomanMap = {
+  ခ္: "kh",
+  ဃ္: "gh",
+  က္: "k",
+  အ: "a",
+  အာ: "ā",
+} as const;
+
+export const my = {
+  fromRoman: buildConverter(romanToLocalMap),
+  toRoman: buildConverter(localToRomanMap),
+};

+ 16 - 0
dashboard-v6/src/utils/code/scripts/thai.ts

@@ -0,0 +1,16 @@
+import { buildConverter } from "../core/buildConverter";
+
+const romanToLocalMap = {
+  k: "ก",
+  a: "อ",
+} as const;
+
+const localToRomanMap = {
+  ก: "k",
+  อ: "a",
+} as const;
+
+export const thai = {
+  fromRoman: buildConverter(romanToLocalMap),
+  toRoman: buildConverter(localToRomanMap),
+};