Переглянути джерело

Merge pull request #2460 from visuddhinanda/development

Development
visuddhinanda 1 тиждень тому
батько
коміт
44880e0bd9

+ 11 - 3
dashboard-v6/src/components/ai-model/AiModelList.tsx

@@ -19,8 +19,10 @@ import { EResType } from "../share/utils";
 
 interface IWidget {
   studioName?: string;
+  /** 在弹层(Modal/Drawer/Popover)中使用时置为 true,链接改为新标签页打开 */
+  openInNewTab?: boolean;
 }
-const AiModelList = ({ studioName }: IWidget) => {
+const AiModelList = ({ studioName, openInNewTab }: IWidget) => {
   const intl = useIntl(); //i18n
 
   const [openCreate, setOpenCreate] = useState(false);
@@ -41,7 +43,10 @@ const AiModelList = ({ studioName }: IWidget) => {
               return (
                 <Space>
                   <PublicityIcon value={entity.privacy} />
-                  <Link to={`/workspace/settings/ai-model/${entity.uid}/edit`}>
+                  <Link
+                    to={`/workspace/settings/ai-model/${entity.uid}/edit`}
+                    target={openInNewTab ? "_blank" : undefined}
+                  >
                     {entity.name}
                   </Link>
                 </Space>
@@ -70,7 +75,10 @@ const AiModelList = ({ studioName }: IWidget) => {
             render(_dom, entity) {
               return (
                 <Space>
-                  <Link to={`/workspace/settings/ai-model/${entity.uid}/log`}>
+                  <Link
+                    to={`/workspace/settings/ai-model/${entity.uid}/log`}
+                    target={openInNewTab ? "_blank" : undefined}
+                  >
                     logs
                   </Link>
                   <ShareModal

+ 1 - 1
dashboard-v6/src/components/auth/Avatar.tsx

@@ -45,7 +45,7 @@ const UserCard = ({ user }: IUserCard) => {
             id: "columns.library.blog.label",
           })}
         >
-          <Link to={`/blog/${user?.realName}/overview`}>
+          <Link to={`/blog/${user?.realName}/overview`} target="_blank">
             <HomeOutlined key="home" />
           </Link>
         </Tooltip>,

+ 54 - 10
dashboard-v6/src/components/general/TermTextArea.tsx

@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useLayoutEffect, useRef, useState } from "react";
 import "./style.css";
 import TermTextAreaMenu from "./TermTextAreaMenu";
 
@@ -21,18 +21,18 @@ const TermTextAreaWidget = ({
   const [shadowHeight, setShadowHeight] = useState<number>();
   const [menuFocusIndex, setMenuFocusIndex] = useState(0);
   const [menuDisplay, setMenuDisplay] = useState("none");
-  const [menuTop, setMenuTop] = useState(0);
-  const [menuLeft, setMenuLeft] = useState(0);
+  /** 光标位置,菜单实际坐标由它再做边界钳制 */
+  const [cursorPos, setCursorPos] = useState({ top: 0, left: 0 });
+  const [menuItemCount, setMenuItemCount] = useState(0);
   const [menuSelected, setMenuSelected] = useState<string>();
 
   const [textAreaValue, setTextAreaValue] = useState(value);
   const [textAreaHeight, setTextAreaHeight] = useState(100);
   const [termSearch, setTermSearch] = useState<string>();
 
-  const _term_max_menu = 10;
-
   const refTextArea = useRef<HTMLTextAreaElement>(null);
   const refShadow = useRef<HTMLDivElement>(null);
+  const refMenu = useRef<HTMLDivElement>(null);
 
   useEffect(() => {
     if (!refTextArea.current) return;
@@ -48,6 +48,41 @@ const TermTextAreaWidget = ({
     return () => observer.disconnect();
   }, []);
 
+  /**
+   * 菜单显示后按容器和视口做边界钳制,避免超出可视区域
+   */
+  useLayoutEffect(() => {
+    if (menuDisplay !== "block" || !refMenu.current || !refTextArea.current) {
+      return;
+    }
+    const menu = refMenu.current;
+    const container = refTextArea.current;
+    const menuWidth = menu.offsetWidth;
+    const menuHeight = menu.offsetHeight;
+
+    let left = cursorPos.left;
+    const maxLeft = container.clientWidth - menuWidth;
+    if (left > maxLeft) {
+      left = maxLeft;
+    }
+    if (left < 0) {
+      left = 0;
+    }
+
+    let top = cursorPos.top + 20;
+    // 菜单底部若超出视口,改为显示在光标上方
+    const containerTop = container.getBoundingClientRect().top;
+    if (containerTop + top + menuHeight > window.innerHeight) {
+      const above = cursorPos.top - menuHeight;
+      if (containerTop + above > 0) {
+        top = above;
+      }
+    }
+
+    menu.style.top = `${top}px`;
+    menu.style.left = `${left}px`;
+  }, [menuDisplay, cursorPos, termSearch]);
+
   function term_at_menu_hide() {
     setMenuDisplay("none");
     setTermSearch("");
@@ -83,8 +118,13 @@ const TermTextAreaWidget = ({
   return (
     <div className="text_input">
       <div
+        ref={refMenu}
         className="menu"
-        style={{ display: menuDisplay, top: menuTop, left: menuLeft }}
+        style={{
+          display: menuDisplay,
+          top: cursorPos.top + 20,
+          left: cursorPos.left,
+        }}
       >
         <TermTextAreaMenu
           currIndex={menuFocusIndex}
@@ -97,6 +137,7 @@ const TermTextAreaWidget = ({
           onChange={(value: string) => {
             setMenuSelected(value);
           }}
+          onCount={setMenuItemCount}
         />
       </div>
       <div
@@ -120,7 +161,7 @@ const TermTextAreaWidget = ({
           switch (event.key) {
             case "ArrowDown":
               if (menuDisplay === "block") {
-                if (menuFocusIndex < _term_max_menu) {
+                if (menuFocusIndex < menuItemCount - 1) {
                   setMenuFocusIndex((value) => ++value);
                 }
                 event.preventDefault();
@@ -201,8 +242,7 @@ const TermTextAreaWidget = ({
             if (menuDisplay !== "block") {
               setMenuFocusIndex(0);
               setMenuDisplay("block");
-              setMenuTop(cursor.offsetTop + 20);
-              setMenuLeft(cursor.offsetLeft);
+              setCursorPos({ top: cursor.offsetTop, left: cursor.offsetLeft });
               //menu.innerHTML = TermAtRenderMenu({ focus: 0 });
               //term_at_menu_show(cursor);
             }
@@ -228,7 +268,11 @@ const TermTextAreaWidget = ({
               if (pos2 === -1 || pos2 < pos1) {
                 //光标
                 const term_input = str1.slice(str1.lastIndexOf("[[") + 2);
-                setTermSearch(term_input);
+                if (term_input !== termSearch) {
+                  //候选列表变了,焦点回到第一项
+                  setMenuFocusIndex(0);
+                  setTermSearch(term_input);
+                }
               }
             }
           }

+ 23 - 4
dashboard-v6/src/components/general/TermTextAreaMenu.tsx

@@ -25,6 +25,8 @@ interface IWidget {
   currIndex?: number;
   onChange?: (word: string) => void;
   onSelect?: (word: string) => void;
+  /** 当前可见候选项数量,供父组件限制上下键范围 */
+  onCount?: (count: number) => void;
 }
 
 const TermTextAreaMenuWidget = ({
@@ -35,6 +37,7 @@ const TermTextAreaMenuWidget = ({
   currIndex = 0,
   onChange,
   onSelect,
+  onCount,
 }: IWidget) => {
   const sysTerms = useAppSelector(getTerm);
 
@@ -81,7 +84,17 @@ const TermTextAreaMenuWidget = ({
         isTerm: true,
       }));
 
-    return [...parentTerm, ...mWords, ...sysTerm];
+    // 术语表可能有同一个 word 的多条记录(不同 tag/meaning),
+    // 句子单词也可能同时出现在术语表里,这里按 word 统一去重,
+    // 保留优先级 parentTerm > mWords > sysTerm。
+    const unique = new Map<string, IWordWithEn>();
+    [...parentTerm, ...mWords, ...sysTerm].forEach((item) => {
+      if (!unique.has(item.word)) {
+        unique.set(item.word, item);
+      }
+    });
+
+    return Array.from(unique.values());
   }, [items, sysTerms]);
 
   /**
@@ -98,13 +111,19 @@ const TermTextAreaMenuWidget = ({
   /**
    * ✅ 只有真正副作用才用 useEffect
    */
+  const visibleCount = Math.min(filtered.length, maxItem);
+
+  useEffect(() => {
+    onCount?.(visibleCount);
+  }, [visibleCount, onCount]);
+
   useEffect(() => {
-    if (!filtered.length || !onChange) return;
+    if (!visibleCount || !onChange) return;
 
-    const index = currIndex < filtered.length ? currIndex : filtered.length - 1;
+    const index = currIndex < visibleCount ? currIndex : visibleCount - 1;
 
     onChange(filtered[index].word);
-  }, [currIndex, filtered, onChange]);
+  }, [currIndex, filtered, visibleCount, onChange]);
 
   if (!visible) return null;
 

+ 6 - 3
dashboard-v6/src/components/general/style.css

@@ -30,9 +30,11 @@
 }
 .text_input > .menu {
   background-color: #b2b2b2;
-  width: 200px;
-  height: 300px;
-  box-shadow: #000;
+  min-width: 200px;
+  max-width: 320px;
+  max-height: 300px;
+  overflow-y: auto;
+  overflow-x: hidden;
   position: absolute;
   display: none;
   z-index: 100;
@@ -47,6 +49,7 @@
   cursor: pointer;
   padding: 0;
   margin: 5px;
+  word-break: break-word;
 }
 .text_input > .menu ul li:hover {
   background: linear-gradient(90deg, #40a9ff, transparent);

+ 3 - 6
dashboard-v6/src/components/navigation/MainMenu.tsx

@@ -355,12 +355,9 @@ const Widget = ({ onSearch }: Props) => {
       <RecentModal
         open={recentOpen}
         onOpenChange={() => setRecentOpen(false)}
-        onSelect={(e, row) => {
-          if (e.ctrlKey || e.metaKey) {
-            window.open("");
-          } else {
-            navigate(recentPath(row.type, row.articleId));
-          }
+        onSelect={(_e, row) => {
+          // 弹窗中的链接一律新标签页打开,避免弹窗被原地跳转关掉
+          window.open(fullUrl(recentPath(row.type, row.articleId)), "_blank");
           setRecentOpen(false);
         }}
       />

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

@@ -128,7 +128,7 @@ const SentTabWidget = ({
       tabBarExtraContent={
         <Space>
           <TocPath
-            link="none"
+            link="blank"
             data={mPath}
             channels={channelsId}
             trigger={path ? path.length > 0 ? path[0].title : <></> : <></>}

+ 3 - 1
dashboard-v6/src/components/setting/SettingModal.tsx

@@ -65,7 +65,9 @@ const SettingModal = ({ trigger, open, onClose }: IWidget) => {
             {
               label: "model",
               key: "model",
-              children: <AiModelList studioName={currUser?.realName} />,
+              children: (
+                <AiModelList studioName={currUser?.realName} openInNewTab />
+              ),
             },
           ]}
         />

+ 8 - 3
dashboard-v6/src/components/tipitaka/ChapterInChannel.tsx

@@ -36,11 +36,16 @@ const ChapterInChannelWidget = ({
   book,
   para,
   channelId,
+  openTarget,
 }: IWidgetChapterInChannel) => {
   const intl = useIntl(); //i18n
   const [searchParams] = useSearchParams();
   const [open, setOpen] = useState(false);
-  const ChannelList = (channels: IChapterChannelData[]): JSX.Element => {
+  const ChannelList = (
+    channels: IChapterChannelData[],
+    /** 在弹层中渲染时传 "_blank",避免原地跳转导致弹层关闭 */
+    target: React.HTMLAttributeAnchorTarget | undefined = openTarget
+  ): JSX.Element => {
     return channels.length ? (
       <List
         style={{ maxWidth: 500 }}
@@ -70,7 +75,7 @@ const ChapterInChannelWidget = ({
             <List.Item key={id}>
               <Row>
                 <Col span={12}>
-                  <Link to={url}>
+                  <Link to={url} target={target}>
                     <ChannelListItem
                       channel={item.channel}
                       studio={item.studio}
@@ -139,7 +144,7 @@ const ChapterInChannelWidget = ({
           onCancel={handleCancel}
           onOk={handleCancel}
         >
-          <div>{ChannelList(data)}</div>
+          <div>{ChannelList(data, "_blank")}</div>
         </Modal>
       </div>
     );

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

@@ -71,7 +71,6 @@ const PaliChapterHeadWidget = ({ para, onChange }: IWidget) => {
             }
           }
         }}
-        link={"none"}
       />
       <ChapterHead data={chapterData} />
     </>

+ 5 - 2
dashboard-v6/src/components/tipitaka/TocPath.tsx

@@ -6,7 +6,7 @@ import { articlePath, fullUrl } from "../../utils";
 import type { ITocPathNode } from "../../api/pali-text";
 import PaliText from "../general/PaliText";
 
-export declare type ELinkType = "none" | "blank" | "self";
+export declare type ELinkType = "blank" | "self";
 
 interface IWidgetTocPath {
   data?: ITocPathNode[];
@@ -24,6 +24,7 @@ interface IWidgetTocPath {
 const TocPathWidget = ({
   data = [],
   trigger,
+  link,
   channels,
   style,
   onChange,
@@ -51,7 +52,9 @@ const TocPathWidget = ({
           const urlMode = mode ? mode : "read";
           let url = `${articlePath(type, `${item.book}-${item.paragraph}`)}?mode=${urlMode}${param}`;
           url += channel ? `&channel=${channel}` : "";
-          if (e.ctrlKey || e.metaKey) {
+          // link="blank":在弹层(Modal/Drawer/Popover)中使用时,
+          // 一律新标签页打开,避免原地跳转把弹层关掉
+          if (link === "blank" || e.ctrlKey || e.metaKey) {
             window.open(fullUrl(url), "_blank");
           } else {
             navigate(url);

+ 8 - 6
dashboard-v6/src/utils.ts

@@ -27,12 +27,14 @@ export function dashboardBasePath(): string {
 }
 
 export function fullUrl(url: string): string {
-  if (import.meta.env.BASE_URL.includes("http")) {
-    //for CDN
-    return import.meta.env.BASE_URL + url;
-  } else {
-    return window.location.origin + import.meta.env.BASE_URL + url;
-  }
+  // BASE_URL 通常以 "/" 结尾,而调用方传入的 url 有的带前导 "/" 有的不带,
+  // 这里统一去掉重复的斜杠,避免拼出 ".../pcd-v2026//workspace/..." 这种地址
+  const base = import.meta.env.BASE_URL.replace(/\/+$/, "");
+  const path = url.replace(/^\/+/, "");
+  const prefix = base.includes("http")
+    ? base //for CDN
+    : window.location.origin + base;
+  return path ? `${prefix}/${path}` : `${prefix}/`;
 }
 
 export function PaliToEn(pali: string): string {