Просмотр исходного кода

Merge remote-tracking branch 'refs/remotes/origin/main'

Talegorithm 1 час назад
Родитель
Сommit
1ed8acbc07

+ 5 - 1
knowhub/frontend/.claude/settings.local.json

@@ -11,7 +11,11 @@
       "Bash(curl -sI -H \"Referer: http://localhost:3000/\" \"https://mmbiz.qpic.cn/sz_mmbiz_jpg/xicicA5WtbWWWBkYQ7MTZZnV4paD82VtqW1pdHvicNibCA9cic5tSAmJLia8bmcXZrVUKe54qcOw5Nia9wc7w4V3npRbVcbzaIunSpYt5hmm3XS17U/640?wx_fmt=webp&from=appmsg\")",
       "Bash(curl -sI http://localhost:3000/)",
       "Bash(curl -sI http://localhost:5173/)",
-      "Bash(curl -sI \"https://mmbiz.qpic.cn/sz_mmbiz_jpg/xicicA5WtbWWWBkYQ7MTZZnV4paD82VtqW1pdHvicNibCA9cic5tSAmJLia8bmcXZrVUKe54qcOw5Nia9wc7w4V3npRbVcbzaIunSpYt5hmm3XS17U/640?wx_fmt=webp&from=appmsg\")"
+      "Bash(curl -sI \"https://mmbiz.qpic.cn/sz_mmbiz_jpg/xicicA5WtbWWWBkYQ7MTZZnV4paD82VtqW1pdHvicNibCA9cic5tSAmJLia8bmcXZrVUKe54qcOw5Nia9wc7w4V3npRbVcbzaIunSpYt5hmm3XS17U/640?wx_fmt=webp&from=appmsg\")",
+      "Bash(echo \"exit: $?\")",
+      "Bash(grep -oE \"[a-z]+/[a-zA-Z-]+$|@[a-z-]+/[a-z-]+|cascading renders|never used|exhaustive-deps\" /tmp/lint-before.txt)",
+      "Bash(grep -oE \"[a-z]+/[a-zA-Z-]+$|@[a-z-]+/[a-z-]+|cascading renders|never used|exhaustive-deps\" /tmp/lint-after.txt)",
+      "Bash(npm run *)"
     ]
   }
 }

+ 17 - 5
knowhub/frontend/src/components/common/SideDrawer.tsx

@@ -6,12 +6,14 @@ interface SideDrawerProps {
   isOpen: boolean;
   onClose: () => void;
   title: React.ReactNode;
+  subtitle?: React.ReactNode;
+  icon?: React.ReactNode;
   children: React.ReactNode;
   width?: string;
   defaultWidth?: number;
 }
 
-export function SideDrawer({ isOpen, onClose, title, children, width: originalWidthStr, defaultWidth = 480 }: SideDrawerProps) {
+export function SideDrawer({ isOpen, onClose, title, subtitle, icon, children, width: originalWidthStr, defaultWidth = 480 }: SideDrawerProps) {
   const [width, setWidth] = useState(defaultWidth);
   const [isResizing, setIsResizing] = useState(false);
 
@@ -70,15 +72,25 @@ export function SideDrawer({ isOpen, onClose, title, children, width: originalWi
         <div className={cn("h-16 w-1 rounded-full bg-slate-300 transition-opacity", isResizing ? "opacity-100" : "opacity-0 group-hover:opacity-100")} />
       </div>
 
-      <div className="border-b border-slate-100 bg-white shrink-0 px-4 py-4 flex justify-between items-center gap-2">
-        <div className="text-lg font-bold text-slate-900 min-w-0 flex-1 truncate">{title}</div>
+      <div className="border-b border-slate-100 bg-white shrink-0 px-4 py-3.5 flex items-center gap-3">
+        {icon && (
+          <div className="shrink-0 w-9 h-9 rounded-lg bg-blue-50 text-[#3b82f6] flex items-center justify-center">
+            {icon}
+          </div>
+        )}
+        <div className="min-w-0 flex-1 flex flex-col justify-center">
+          <div className="text-base font-bold text-slate-900 truncate leading-tight">{title}</div>
+          {subtitle && (
+            <div className="text-xs text-slate-400 mt-0.5 truncate">{subtitle}</div>
+          )}
+        </div>
         <button
           onClick={onClose}
-          className="p-2 hover:bg-slate-100 rounded-lg text-slate-400 hover:text-slate-600 transition-colors"
+          className="shrink-0 w-8 h-8 flex items-center justify-center rounded-md border border-slate-200 bg-white text-slate-500 hover:text-slate-700 hover:bg-slate-50 transition-colors"
           title="关闭"
           type="button"
         >
-          <X size={18} />
+          <X size={16} />
         </button>
       </div>
 

+ 65 - 12
knowhub/frontend/src/components/dashboard/details/DrawerContentComponents.tsx

@@ -1117,7 +1117,15 @@ export function RequirementPostsDrawer({
 
 // ─── 叶子节点详情抽屉 ─────────────────────────────────────────────────────────
 
-export function LeafNodeDrawer({ node, onOpenPost }: { node: any; onOpenPost: (postId: string, post: any) => void }) {
+export function LeafNodeDrawer({
+  node,
+  path,
+  onOpenPost,
+}: {
+  node: any;
+  path?: string[];
+  onOpenPost: (postId: string, post: any) => void;
+}) {
   const [posts, setPosts] = useState<Record<string, any>>({});
   const [loading, setLoading] = useState(false);
 
@@ -1135,6 +1143,22 @@ export function LeafNodeDrawer({ node, onOpenPost }: { node: any; onOpenPost: (p
     return ids;
   }, [node]);
 
+  const directChildrenCount = (node.children || []).length;
+  const allDescendantsCount = useMemo(() => {
+    let count = 0;
+    const walk = (current: any) => {
+      (current.children || []).forEach((child: any) => {
+        count += 1;
+        walk(child);
+      });
+    };
+    walk(node);
+    return count;
+  }, [node]);
+  const totalElementsCount = node.total_posts_count || postIds.length;
+  const breadcrumb = (path && path.length > 0 ? path : [node.name]).filter(Boolean);
+  const depth = Math.max(breadcrumb.length - 1, 0);
+
   useEffect(() => {
     if (postIds.length === 0) return;
     setLoading(true);
@@ -1147,19 +1171,48 @@ export function LeafNodeDrawer({ node, onOpenPost }: { node: any; onOpenPost: (p
       .finally(() => setLoading(false));
   }, [node.name, postIds]);
 
+  const statCards = [
+    { label: "元素总数", value: totalElementsCount },
+    { label: "直接子节点", value: directChildrenCount },
+    { label: "全部后代", value: allDescendantsCount },
+    { label: "所在层级", value: depth },
+  ];
+
   return (
     <div className="flex flex-col gap-3 h-full overflow-hidden min-w-0">
-      <div className="shrink-0 bg-slate-50 rounded-xl border border-slate-100 p-3">
-        <div className="text-xs font-bold text-slate-600 mb-2">节点概览</div>
-        <div className="grid grid-cols-2 gap-3">
-          <div className="rounded-lg border border-slate-200 bg-white p-3">
-            <div className="text-[10px] text-slate-400 mb-1">帖子总数</div>
-            <div className="text-xl font-black text-slate-800">{node.total_posts_count || 0}</div>
-          </div>
-          <div className="rounded-lg border border-slate-200 bg-white p-3">
-            <div className="text-[10px] text-slate-400 mb-1">去重帖子</div>
-            <div className="text-xl font-black text-slate-800">{postIds.length}</div>
-          </div>
+      <div className="shrink-0">
+        <div className="text-xs font-bold text-slate-500 mb-2">统计数据</div>
+        <div className="grid grid-cols-2 gap-2.5">
+          {statCards.map((stat) => (
+            <div
+              key={stat.label}
+              className="rounded-lg border border-slate-200 bg-white p-3"
+            >
+              <div className="text-xl font-black text-[#3b82f6] leading-none">
+                {stat.value.toLocaleString()}
+              </div>
+              <div className="text-[11px] text-slate-400 mt-1.5">{stat.label}</div>
+            </div>
+          ))}
+        </div>
+      </div>
+
+      <div className="shrink-0">
+        <div className="text-xs font-bold text-slate-500 mb-2">层级路径</div>
+        <div className="flex items-center flex-wrap gap-1 text-xs text-slate-700">
+          {breadcrumb.map((name, idx) => (
+            <Fragment key={`${name}-${idx}`}>
+              {idx > 0 && <ChevronRight size={12} className="text-slate-300 shrink-0" />}
+              <span
+                className={cn(
+                  "px-2 py-0.5 rounded-md max-w-[140px] truncate bg-slate-100 text-slate-600",
+                  idx === breadcrumb.length - 1 && "text-[#3b82f6] font-bold",
+                )}
+              >
+                {name}
+              </span>
+            </Fragment>
+          ))}
         </div>
       </div>
 

+ 58 - 31
knowhub/frontend/src/pages/Dashboard.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useMemo, useRef, useCallback, Fragment, type ReactNode, type WheelEvent } from "react";
 import { createPortal } from "react-dom";
-import { Target, Wrench, FileText, ListTree, Cpu, X, ChevronLeft, ChevronRight } from "lucide-react";
+import { Target, Wrench, FileText, ListTree, Cpu, X, ChevronLeft, ChevronRight, Grid2x2Plus } from "lucide-react";
 import { CategoryTreeNew } from "../components/dashboard/CategoryTreeNew";
 import { VersionSwitcher } from "../components/layout/VersionSwitcher";
 import { SideDrawer } from "../components/common/SideDrawer";
@@ -2129,36 +2129,63 @@ export function Dashboard({
           </div>
         </div>
 
-        <SideDrawer
-          isOpen={!!drawerItem}
-          onClose={() => setDrawerItem(null)}
-          title={
-            drawerItem
-              ? drawerItem.type === "itemset"
-                ? `Pattern #${drawerItem.data.id}`
-                : drawerItem.data.name ||
-                  drawerItem.data.description?.slice(0, 20) ||
-                  drawerItem.data.task?.slice(0, 20) ||
-                  drawerItem.data.id?.slice(0, 12)
-              : ""
-          }
-        >
-          {drawerItem &&
-            (drawerItem.type === "node" ? (
-              <LeafNodeDrawer
-                node={drawerItem.data}
-                onOpenPost={(postId, post) => setSelectedPostDetail({ postId, post })}
-              />
-            ) : (
-              <DrawerContent
-                type={drawerItem.type}
-                data={drawerItem.data}
-                dbData={{ ...dbData, caps: allCaps }}
-                nodePostsMap={drawerItem.nodePostsMap}
-                onOpenPost={(postId, post) => setSelectedPostDetail({ postId, post })}
-              />
-            ))}
-        </SideDrawer>
+        {(() => {
+          const drawerNodePath = (() => {
+            if (!drawerItem || drawerItem.type !== "node" || !treeData) return undefined;
+            const targetName = drawerItem.data?.name;
+            if (!targetName) return undefined;
+            const stack: { node: any; chain: string[] }[] = [{ node: treeData, chain: [] }];
+            while (stack.length) {
+              const { node, chain } = stack.pop()!;
+              if (!node) continue;
+              const nextChain = node.name ? [...chain, node.name] : chain;
+              if (node.name === targetName) {
+                return nextChain[0] === "root" ? nextChain.slice(1) : nextChain;
+              }
+              (node.children || []).forEach((child: any) => stack.push({ node: child, chain: nextChain }));
+            }
+            return [targetName];
+          })();
+          const drawerTitle = drawerItem
+            ? drawerItem.type === "itemset"
+              ? `Pattern #${drawerItem.data.id}`
+              : drawerItem.data.name ||
+                drawerItem.data.description?.slice(0, 20) ||
+                drawerItem.data.task?.slice(0, 20) ||
+                drawerItem.data.id?.slice(0, 12)
+            : "";
+          const isNodeDrawer = drawerItem?.type === "node";
+          const drawerSubtitle = isNodeDrawer && drawerNodePath
+            ? `层级深度: ${Math.max(drawerNodePath.length - 1, 0)}`
+            : undefined;
+          const drawerIcon = isNodeDrawer ? <Grid2x2Plus size={18} strokeWidth={2} /> : undefined;
+          return (
+            <SideDrawer
+              isOpen={!!drawerItem}
+              onClose={() => setDrawerItem(null)}
+              title={drawerTitle}
+              subtitle={drawerSubtitle}
+              icon={drawerIcon}
+            >
+              {drawerItem &&
+                (drawerItem.type === "node" ? (
+                  <LeafNodeDrawer
+                    node={drawerItem.data}
+                    path={drawerNodePath}
+                    onOpenPost={(postId, post) => setSelectedPostDetail({ postId, post })}
+                  />
+                ) : (
+                  <DrawerContent
+                    type={drawerItem.type}
+                    data={drawerItem.data}
+                    dbData={{ ...dbData, caps: allCaps }}
+                    nodePostsMap={drawerItem.nodePostsMap}
+                    onOpenPost={(postId, post) => setSelectedPostDetail({ postId, post })}
+                  />
+                ))}
+            </SideDrawer>
+          );
+        })()}
       </div>
     </div>
   );