Browse Source

pref: improve table rendering (#928)

Signed-off-by: ryjiang <jiangruiyi@gmail.com>
ryjiang 2 weeks ago
parent
commit
6bc42f63bc

+ 173 - 42
client/src/components/MediaPreview/MediaPreview.tsx

@@ -11,47 +11,122 @@ const MediaPreview = (props: { value: string }) => {
     width: 0,
     height: 0,
   });
+  const [imageCache, setImageCache] = useState<Map<string, HTMLImageElement>>(
+    new Map()
+  );
+  const [imageError, setImageError] = useState(false);
 
   useEffect(() => {
     if (isImageSource(value)) {
       setImage(value);
-
-      // Create an Image object to get natural dimensions
-      const img = new Image();
-      img.src = value;
-      img.onload = () => {
-        setImageDimensions({
-          width: img.naturalWidth,
-          height: img.naturalHeight,
-        });
-      };
+      setImageError(false); // Reset error state
+      loadImageWithCache(value);
     }
   }, [value]);
 
+  // Cleanup cache when component unmounts or cache gets too large
+  useEffect(() => {
+    const cleanupCache = () => {
+      if (imageCache.size > 50) {
+        // Limit cache to 50 images
+        const entries = Array.from(imageCache.entries());
+        const newCache = new Map(entries.slice(-30)); // Keep last 30 images
+        setImageCache(newCache);
+      }
+    };
+
+    cleanupCache();
+  }, [imageCache.size]);
+
+  // Cleanup on unmount
+  useEffect(() => {
+    return () => {
+      setImageCache(new Map());
+    };
+  }, []);
+
+  const loadImageWithCache = (imageSrc: string) => {
+    console.log('Loading image:', imageSrc); // Debug log
+
+    // Check if image is already cached
+    if (imageCache.has(imageSrc)) {
+      const cachedImg = imageCache.get(imageSrc)!;
+      setImageDimensions({
+        width: cachedImg.naturalWidth,
+        height: cachedImg.naturalHeight,
+      });
+      console.log(
+        'Image loaded from cache:',
+        cachedImg.naturalWidth,
+        'x',
+        cachedImg.naturalHeight
+      );
+      return;
+    }
+
+    // Create an Image object to get natural dimensions with caching
+    const img = new Image();
+
+    // Don't set crossOrigin for external images to avoid CORS issues
+    // The browser will handle CORS automatically if the server allows it
+
+    img.onload = () => {
+      console.log(
+        'Image loaded successfully:',
+        img.naturalWidth,
+        'x',
+        img.naturalHeight
+      );
+      // Cache the loaded image
+      setImageCache(prev => new Map(prev).set(imageSrc, img));
+
+      setImageDimensions({
+        width: img.naturalWidth,
+        height: img.naturalHeight,
+      });
+    };
+
+    img.onerror = () => {
+      console.warn('Failed to load image:', imageSrc);
+      setImageError(true);
+      // Set default dimensions if image fails to load
+      setImageDimensions({
+        width: 200,
+        height: 200,
+      });
+    };
+
+    // Set src after setting up event handlers
+    img.src = imageSrc;
+  };
+
   const handleMouseOver = (e: React.MouseEvent) => {
-    // Use dynamic image dimensions instead of fixed values
+    // Use dynamic image dimensions if available, otherwise use defaults
     const maxDimension = 200;
-    const aspectRatio = imageDimensions.width / imageDimensions.height;
-
-    let imageWidth, imageHeight;
-
-    if (
-      imageDimensions.width > maxDimension ||
-      imageDimensions.height > maxDimension
-    ) {
-      if (aspectRatio > 1) {
-        // Landscape orientation
-        imageWidth = maxDimension;
-        imageHeight = maxDimension / aspectRatio;
+    let imageWidth = maxDimension;
+    let imageHeight = maxDimension;
+
+    if (imageDimensions.width > 0 && imageDimensions.height > 0) {
+      const aspectRatio = imageDimensions.width / imageDimensions.height;
+
+      if (
+        imageDimensions.width > maxDimension ||
+        imageDimensions.height > maxDimension
+      ) {
+        if (aspectRatio > 1) {
+          // Landscape orientation
+          imageWidth = maxDimension;
+          imageHeight = maxDimension / aspectRatio;
+        } else {
+          // Portrait or square orientation
+          imageHeight = maxDimension;
+          imageWidth = maxDimension * aspectRatio;
+        }
       } else {
-        // Portrait or square orientation
-        imageHeight = maxDimension;
-        imageWidth = maxDimension * aspectRatio;
+        // Use original dimensions if they're within the limit
+        imageWidth = imageDimensions.width;
+        imageHeight = imageDimensions.height;
       }
-    } else {
-      // Use original dimensions if they're within the limit
-      imageWidth = imageDimensions.width;
-      imageHeight = imageDimensions.height;
     }
 
     const offset = 10; // Small offset to position the image beside the cursor
@@ -82,6 +157,13 @@ const MediaPreview = (props: { value: string }) => {
         left: `${left}px`,
         zIndex: 1000,
         pointerEvents: 'none',
+        backgroundColor: 'white',
+        border: '1px solid #ccc',
+        borderRadius: '4px',
+        padding: '4px',
+        boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
+        minWidth: '50px',
+        minHeight: '50px',
       });
     }
   };
@@ -100,30 +182,75 @@ const MediaPreview = (props: { value: string }) => {
         style={isImg ? { cursor: 'pointer' } : {}}
       >
         {isImg ? (
-          <>
+          <div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
             <icons.img />
             <Typography variant="mono" component="p" title={String(value)}>
-              <a href={value} target="_blank">
+              <a href={value} target="_blank" rel="noopener noreferrer">
                 {value}
               </a>
             </Typography>
-          </>
+          </div>
         ) : (
           <Typography variant="mono" component="p" title={String(value)}>
             {value}
           </Typography>
         )}
       </div>
-      {showImage && (
+      {showImage && image && (
         <div style={showImageStyle}>
-          <img
-            src={image}
-            alt="preview"
-            style={{
-              width: imageDimensions.width > 200 ? 200 : imageDimensions.width,
-              borderRadius: '4px',
-            }}
-          />
+          {imageError ? (
+            <div
+              style={{
+                padding: '20px',
+                textAlign: 'center',
+                color: '#666',
+                fontSize: '12px',
+                minWidth: '150px',
+                minHeight: '100px',
+                display: 'flex',
+                alignItems: 'center',
+                justifyContent: 'center',
+              }}
+            >
+              <div>
+                <div>⚠️ 图片无法加载</div>
+                <div style={{ marginTop: '8px', fontSize: '11px' }}>
+                  可能是跨域限制
+                </div>
+                <div style={{ marginTop: '4px', fontSize: '11px' }}>
+                  点击链接查看原图
+                </div>
+              </div>
+            </div>
+          ) : (
+            <img
+              src={image}
+              alt="preview"
+              style={{
+                maxWidth: '200px',
+                maxHeight: '200px',
+                width: 'auto',
+                height: 'auto',
+                display: 'block',
+                borderRadius: '2px',
+                minWidth: '50px',
+                minHeight: '50px',
+              }}
+              onLoad={e => {
+                console.log(
+                  'Preview image loaded:',
+                  e.currentTarget.naturalWidth,
+                  'x',
+                  e.currentTarget.naturalHeight
+                );
+              }}
+              onError={e => {
+                console.warn('Failed to display image:', image);
+                setImageError(true);
+                e.currentTarget.style.display = 'none';
+              }}
+            />
+          )}
         </div>
       )}
     </div>
@@ -132,6 +259,10 @@ const MediaPreview = (props: { value: string }) => {
 
 // Helper function to detect if the value is a URL or Base64-encoded image
 function isImageSource(value: string): boolean {
+  if (!value || typeof value !== 'string') {
+    return false;
+  }
+
   const urlPattern = /\.(jpeg|jpg|gif|png|bmp|webp|svg)$/i;
   const base64Pattern =
     /^data:image\/(png|jpeg|jpg|gif|bmp|webp|svg\+xml);base64,/i;

+ 1 - 1
client/src/components/grid/Table.tsx

@@ -62,7 +62,7 @@ const EnhancedTable: FC<TableType> = props => {
           stickyHeader
           sx={{
             minWidth: '100%',
-            height: hasData ? 'auto' : '100%',
+            height: hasData ? 'auto' : 'fit-content',
           }}
           aria-labelledby="tableTitle"
           size="medium"

+ 2 - 0
client/src/components/grid/TableEditableHead.tsx

@@ -7,6 +7,8 @@ import type { TableEditableHeadType } from './Types';
 
 const StyledTableCell = styled(TableCell)(({ theme }) => ({
   paddingLeft: theme.spacing(2),
+  minHeight: 40,
+  maxHeight: 60,
 }));
 
 const StyledTableRow = styled(TableRow)(({ theme }) => ({

+ 1 - 0
client/src/components/grid/TableHead.tsx

@@ -33,6 +33,7 @@ const StyledTableHeader = styled(Typography)(({ theme }) => ({
   padding: theme.spacing(1.5, 1),
   fontWeight: 500,
   maxHeight: 45,
+  minHeight: 20,
   fontSize: 13,
   overflow: 'hidden',
   whiteSpace: 'nowrap',

+ 2 - 20
client/src/pages/databases/collections/Collections.tsx

@@ -313,7 +313,7 @@ const Collections = () => {
       sortType: 'string',
       formatter({ collection_name }) {
         return (
-          <Typography variant="body1" sx={{ width: '10vw' }}>
+          <Box sx={{ maxWidth: '120px' }}>
             <Link
               to={`/databases/${database}/${collection_name}/overview`}
               style={{
@@ -336,12 +336,9 @@ const Collections = () => {
                 }}
               />
             </Link>
-          </Typography>
+          </Box>
         );
       },
-      getStyle: () => {
-        return { minWidth: '10vw' };
-      },
       label: collectionTrans('name'),
     },
     {
@@ -362,9 +359,6 @@ const Collections = () => {
           </Typography>
         );
       },
-      getStyle: () => {
-        return { minWidth: '130px' };
-      },
     },
     {
       id: 'rowCount',
@@ -386,9 +380,6 @@ const Collections = () => {
       formatter(v) {
         return formatNumber(v.rowCount);
       },
-      getStyle: () => {
-        return { minWidth: '100px' };
-      },
     },
     {
       id: 'description',
@@ -406,9 +397,6 @@ const Collections = () => {
       formatter(v) {
         return v.description || '--';
       },
-      getStyle: () => {
-        return { minWidth: '120px' };
-      },
     },
     {
       id: 'createdTime',
@@ -418,9 +406,6 @@ const Collections = () => {
       formatter(data) {
         return new Date(data.createdTime).toLocaleString();
       },
-      getStyle: () => {
-        return { minWidth: '165px' };
-      },
     },
   ];
 
@@ -444,9 +429,6 @@ const Collections = () => {
       formatter(v) {
         return <Aliases aliases={v.aliases} collection={v} />;
       },
-      getStyle: () => {
-        return { minWidth: '100px' };
-      },
     });
   }
 

+ 36 - 13
client/src/pages/databases/collections/data/CollectionData.tsx

@@ -2,7 +2,6 @@ import { useState, useEffect, useRef, useContext, useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 import { dataContext } from '@/context';
 import { useQuery } from '@/hooks';
-import { getColumnWidth } from '@/utils';
 import icons from '@/components/icons/Icons';
 import AttuGrid from '@/components/grid/Grid';
 import CustomToolBar from '@/components/grid/ToolBar';
@@ -38,6 +37,7 @@ const CollectionData = (props: CollectionDataProps) => {
   const [selectedData, setSelectedData] = useState<any[]>([]);
   const exprInputRef = useRef<string>(queryState.expr);
   const [, forceUpdate] = useState({});
+  const loadingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
 
   // UI functions
   const { fetchCollection } = useContext(dataContext);
@@ -63,13 +63,27 @@ const CollectionData = (props: CollectionDataProps) => {
   } = useQuery({
     collection,
     onQueryStart: (expr: string = '') => {
-      setTableLoading(true);
+      // Clear any existing timeout
+      if (loadingTimeoutRef.current) {
+        clearTimeout(loadingTimeoutRef.current);
+      }
+
+      // Set a timeout to show loading after 100ms
+      loadingTimeoutRef.current = setTimeout(() => {
+        setTableLoading(true);
+      }, 100);
+
       if (expr === '') {
         handleFilterReset();
         return;
       }
     },
     onQueryFinally: () => {
+      // Clear the timeout if it exists
+      if (loadingTimeoutRef.current) {
+        clearTimeout(loadingTimeoutRef.current);
+        loadingTimeoutRef.current = null;
+      }
       setTableLoading(false);
     },
     queryState: queryState,
@@ -212,8 +226,26 @@ const CollectionData = (props: CollectionDataProps) => {
     setSelectedData([]);
     exprInputRef.current = queryState.expr;
     forceUpdate({});
+
+    // Clean up timeout on unmount or when collection changes
+    return () => {
+      if (loadingTimeoutRef.current) {
+        clearTimeout(loadingTimeoutRef.current);
+        loadingTimeoutRef.current = null;
+      }
+    };
   }, [collection.collection_name, queryState.expr]);
 
+  // Cleanup timeout on component unmount
+  useEffect(() => {
+    return () => {
+      if (loadingTimeoutRef.current) {
+        clearTimeout(loadingTimeoutRef.current);
+        loadingTimeoutRef.current = null;
+      }
+    };
+  }, []);
+
   return (
     <Root>
       {collection && (
@@ -252,17 +284,6 @@ const CollectionData = (props: CollectionDataProps) => {
                     <CollectionColHeader def={v} collection={collection} />
                   );
                 },
-                getStyle: d => {
-                  const field = collection.schema.fields.find(
-                    f => f.name === i
-                  );
-                  if (!d || !field) {
-                    return {};
-                  }
-                  return {
-                    minWidth: getColumnWidth(field),
-                  };
-                },
                 label: i === DYNAMIC_FIELD ? searchTrans('dynamicFields') : i,
               };
             })}
@@ -272,6 +293,8 @@ const CollectionData = (props: CollectionDataProps) => {
             rows={queryResult.data}
             rowCount={total}
             selected={selectedData}
+            tableHeaderHeight={43.5}
+            rowHeight={43}
             setSelected={onSelectChange}
             page={currentPage}
             onPageChange={handlePageChange}

+ 25 - 0
server/src/utils/Helper.ts

@@ -41,7 +41,32 @@ export const makeRandomSparse = (dim: number) => {
   return sparseObject;
 };
 
+export const makeImageUrl = (): string => {
+  const sizes = [
+    '200x150',
+    '300x200', 
+    '400x300',
+    '500x400',
+    '600x450',
+    '800x600',
+    '1024x768',
+    '1200x800'
+  ];
+  
+  const formats = ['jpg', 'png', 'gif'];
+  
+  const randomSize = sizes[Math.floor(Math.random() * sizes.length)];
+  const randomFormat = formats[Math.floor(Math.random() * formats.length)];
+  
+  return `https://dummyimage.com/${randomSize}.${randomFormat}`;
+};
+
 export const makeRandomVarChar = (maxLength: number) => {
+  // 20% 的几率返回图片URL
+  if (Math.random() < 0.2) {
+    return makeImageUrl();
+  }
+
   const words = [
     'quick',
     'brown',