import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined"; import SearchOutlined from "@mui/icons-material/SearchOutlined"; import MenuItem from "@mui/material/MenuItem"; import MenuList from "@mui/material/MenuList"; import Popover from "@mui/material/Popover"; import TextField from "@mui/material/TextField"; import Tooltip from "@mui/material/Tooltip"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTimeZone } from "@/app/timezone/TimeZoneProvider.jsx"; import { Button } from "@/shared/ui/Button.jsx"; import { IconButton } from "@/shared/ui/IconButton.jsx"; import { PaginationBar } from "@/shared/ui/PaginationBar.jsx"; const DEFAULT_COLUMN_WIDTH = "minmax(120px, 1fr)"; const DEFAULT_RESIZE_MIN_WIDTH = 72; const DEFAULT_RESIZE_MAX_WIDTH = 960; const ACTION_COLUMN_WIDTH = "112px"; const DEFAULT_COMPACT_MIN_WIDTH = 220; const LOW_PRIORITY_COMPACT_MIN_WIDTH = 180; export function DataTable({ className = "", columns, context, emptyLabel = "当前无数据", fixedEdges = true, getRowProps, infiniteScroll, items, minWidth = "980px", onColumnWidthsChange, pagination, resizableColumns = true, rowKey, tableClassName = "", title, total }) { const { timeZone } = useTimeZone(); const [columnWidths, setColumnWidths] = useState({}); const [filterMenu, setFilterMenu] = useState({ anchorEl: null, columnKey: "" }); const [filterQuery, setFilterQuery] = useState(""); const [hasHorizontalOverflow, setHasHorizontalOverflow] = useState(false); const [paginationLoadingMore, setPaginationLoadingMore] = useState(false); const [resizeSession, setResizeSession] = useState(null); const loadMoreRef = useRef(null); const scrollRef = useRef(null); const safeItems = Array.isArray(items) ? items : []; const paginationTotal = Number(pagination?.total || 0); const paginationHasMore = Boolean(pagination?.onPageChange) && (Number.isFinite(paginationTotal) && paginationTotal > 0 ? safeItems.length < paginationTotal : Boolean(pagination?.hasNextPage)); const infiniteLoading = Boolean(infiniteScroll?.loading || paginationLoadingMore); const infiniteHasMore = Boolean(infiniteScroll?.hasMore || paginationHasMore); const infiniteLoadLabel = infiniteScroll?.loadingLabel || "加载中..."; const infiniteDoneLabel = infiniteScroll?.doneLabel || ""; const loadMore = useCallback(() => { if (infiniteScroll?.onLoadMore) { infiniteScroll.onLoadMore(); return; } pagination?.onPageChange?.(Number(pagination?.page || 1) + 1); }, [infiniteScroll, pagination]); const renderContext = useMemo(() => (context ? { ...context, timeZone } : { timeZone }), [context, timeZone]); const preparedColumns = useMemo( () => columns.map((column) => { const normalizedColumn = normalizeColumn(column); return { ...normalizedColumn, fixed: resolveColumnFixed(normalizedColumn, fixedEdges && hasHorizontalOverflow), width: columnWidths[normalizedColumn.key] || normalizedColumn.width || DEFAULT_COLUMN_WIDTH }; }), [columnWidths, columns, fixedEdges, hasHorizontalOverflow] ); const gridTemplate = preparedColumns.map((column) => column.width).join(" "); const activeFilterColumn = preparedColumns.find((column) => column.key === filterMenu.columnKey && column.filter) || null; const activeFilter = activeFilterColumn?.filter || null; const activeFilterIsText = activeFilter?.type === "text"; const filterColumns = preparedColumns.filter((column) => column.filter); const hasColumnFilters = filterColumns.length > 0; const hasActiveColumnFilters = filterColumns.some((column) => isFilterActive(column.filter)); const filterOptions = useMemo(() => filterVisibleOptions(activeFilter, filterQuery), [activeFilter, filterQuery]); useEffect(() => { const scrollNode = scrollRef.current; if (!scrollNode) { return undefined; } const measureOverflow = () => { const nextHasOverflow = scrollNode.scrollWidth > scrollNode.clientWidth + 1; setHasHorizontalOverflow((current) => (current === nextHasOverflow ? current : nextHasOverflow)); }; measureOverflow(); window.addEventListener("resize", measureOverflow); if (typeof ResizeObserver === "undefined") { return () => { window.removeEventListener("resize", measureOverflow); }; } const observer = new ResizeObserver(measureOverflow); observer.observe(scrollNode); if (scrollNode.firstElementChild) { observer.observe(scrollNode.firstElementChild); } return () => { observer.disconnect(); window.removeEventListener("resize", measureOverflow); }; }, [gridTemplate, minWidth, safeItems.length]); useEffect(() => { if (!resizeSession) { return undefined; } const moveColumn = (event) => { const delta = event.clientX - resizeSession.startX; const nextWidth = clamp(resizeSession.startWidth + delta, resizeSession.minWidth, resizeSession.maxWidth); const cssWidth = `${Math.round(nextWidth)}px`; setColumnWidths((current) => { if (current[resizeSession.key] === cssWidth) { return current; } const next = { ...current, [resizeSession.key]: cssWidth }; onColumnWidthsChange?.(next); return next; }); }; const finishResize = () => { setResizeSession(null); }; document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; window.addEventListener("pointermove", moveColumn); window.addEventListener("pointerup", finishResize, { once: true }); window.addEventListener("pointercancel", finishResize, { once: true }); return () => { document.body.style.cursor = ""; document.body.style.userSelect = ""; window.removeEventListener("pointermove", moveColumn); window.removeEventListener("pointerup", finishResize); window.removeEventListener("pointercancel", finishResize); }; }, [onColumnWidthsChange, resizeSession]); useEffect(() => { const root = scrollRef.current; const target = loadMoreRef.current; if (!root || !target || !loadMore || !infiniteHasMore || infiniteLoading) { return undefined; } const observer = new IntersectionObserver( (entries) => { if (entries.some((entry) => entry.isIntersecting)) { setPaginationLoadingMore(Boolean(paginationHasMore)); loadMore(); } }, { root, rootMargin: "0px 0px 96px", threshold: 0.01 } ); observer.observe(target); return () => { observer.disconnect(); }; }, [infiniteHasMore, infiniteLoading, loadMore, paginationHasMore, safeItems.length]); useEffect(() => { setPaginationLoadingMore(false); }, [safeItems.length]); const startResize = (event, column) => { if (!resizableColumns || column.resizable === false) { return; } const headerCell = event.currentTarget.closest("[data-table-header-cell]"); const startWidth = headerCell?.getBoundingClientRect().width; if (!startWidth) { return; } event.preventDefault(); event.stopPropagation(); setResizeSession({ key: column.key, maxWidth: resolveResizeMaxWidth(column), minWidth: resolveResizeMinWidth(column), startWidth, startX: event.clientX }); }; const openColumnFilter = (event, column) => { if (!column.filter) { return; } event.preventDefault(); setFilterMenu({ anchorEl: event.currentTarget, columnKey: column.key }); setFilterQuery(column.filter.type === "text" ? String(column.filter.value ?? "") : ""); }; const closeColumnFilter = () => { setFilterMenu({ anchorEl: null, columnKey: "" }); setFilterQuery(""); }; const changeFilter = (value) => { activeFilter?.onChange?.(value); closeColumnFilter(); }; const submitTextFilter = (event) => { event.preventDefault(); activeFilter?.onChange?.(filterQuery.trim()); closeColumnFilter(); }; const clearTextFilter = () => { if (activeFilter?.onReset) { activeFilter.onReset(); } else { activeFilter?.onChange?.(""); } closeColumnFilter(); }; const resetColumnFilters = () => { closeColumnFilter(); filterColumns.forEach((column) => { if (!isFilterActive(column.filter)) { return; } if (column.filter.onReset) { column.filter.onReset(); return; } column.filter.onChange?.(""); }); }; return (
{title || total !== undefined ? (
{title}
{total !== undefined ?
共 {total} 条
: null}
) : null}
{preparedColumns.map((column, index) => { const isLastColumn = index === preparedColumns.length - 1; return (
) : null } onOpenFilter={openColumnFilter} /> {resizableColumns && column.resizable !== false ? (
); })}
{safeItems.length ? ( <> {safeItems.map((item, index) => { const rowProps = getRowProps ? getRowProps(item, index) : {}; const { className: rowClassName = "", ...restRowProps } = rowProps; return (
{preparedColumns.map((column) => (
{column.render ? column.render(item, index, renderContext) : displayValue(item[column.key])}
))}
); })} {infiniteScroll || paginationHasMore || paginationLoadingMore ? (
{infiniteLoading ? infiniteLoadLabel : infiniteHasMore ? "" : infiniteDoneLabel}
) : null} ) : infiniteLoading ? (
{infiniteLoadLabel}
) : (
{emptyLabel}
)} {safeItems.length === 0 && (infiniteScroll || paginationHasMore) && !infiniteLoading ? (
) : null}
{pagination ? ( ) : null} {activeFilterColumn ? ( activeFilterIsText ? (
setFilterQuery(event.target.value)} />
) : (
setFilterQuery(event.target.value)} /> {filterOptions.length ? ( filterOptions.map((option) => ( changeFilter(option.value)} > {option.label} )) ) : (
{activeFilter.loading ? "加载中..." : "无匹配选项"}
)}
) ) : null}
); } function displayValue(value) { return value === 0 || value === false || value ? value : "-"; } function HeaderLabel({ column, onOpenFilter, trailingAction }) { const content = column.header || column.label; const label = !column.filter ? ( {content} ) : ( ); return ( {label} {trailingAction} ); } function FilterHeaderButton({ column, onOpenFilter }) { const content = column.header || column.label; if (!column.filter) { return {content}; } const active = isFilterActive(column.filter); const filterValueLabel = active ? filterDisplayValue(column.filter) : ""; return ( ); } function TableFilterResetButton({ onClick }) { return ( { event.stopPropagation(); onClick(); }} > ); } function cellClassName(column, extraClassName = "") { const extraClassNames = Array.isArray(extraClassName) ? extraClassName : [extraClassName]; return [ "admin-cell", column.fixed === "left" ? "admin-cell--fixed-left" : "", column.fixed === "right" ? "admin-cell--fixed-right" : "", column.className || "", ...extraClassNames ] .filter(Boolean) .join(" "); } function normalizeColumn(column) { const actionColumn = isActionColumn(column); const className = [column.className || "", actionColumn ? "admin-cell--actions" : ""].filter(Boolean).join(" "); return { ...column, className, resizable: actionColumn ? false : column.resizable, width: actionColumn ? ACTION_COLUMN_WIDTH : normalizeColumnWidth(column) }; } function normalizeColumnWidth(column) { if (!column.width || typeof column.width !== "string") { return column.width; } const minmaxMatch = column.width.match(/^minmax\(\s*(\d+(?:\.\d+)?)px\s*,\s*(.+)\)$/i); if (!minmaxMatch) { return column.width; } const minWidth = Number(minmaxMatch[1]); const flexibleWidth = minmaxMatch[2]; const maxCompactWidth = isLowPriorityColumn(column) ? LOW_PRIORITY_COMPACT_MIN_WIDTH : DEFAULT_COMPACT_MIN_WIDTH; const compactMinWidth = Math.min(minWidth, maxCompactWidth); return `minmax(${compactMinWidth}px, ${flexibleWidth})`; } function isActionColumn(column) { return column.key === "actions" || column.label === "操作"; } function isLowPriorityColumn(column) { return /description|detail|remark|param|url|metadata|context|reason/i.test(String(column.key || "")); } function resolveColumnFixed(column, fixedEdges) { if (fixedEdges && (column.fixed === "left" || column.fixed === "right")) { return column.fixed; } return ""; } function resolveResizeMinWidth(column) { return readPixelWidth(column.resizeMinWidth) || readPixelWidth(column.minWidth) || readMinWidth(column.width) || DEFAULT_RESIZE_MIN_WIDTH; } function resolveResizeMaxWidth(column) { return readPixelWidth(column.resizeMaxWidth) || readPixelWidth(column.maxWidth) || DEFAULT_RESIZE_MAX_WIDTH; } function readMinWidth(width) { const pixelWidth = readPixelWidth(width); if (pixelWidth) { return pixelWidth; } if (typeof width === "number") { return width; } if (typeof width !== "string") { return 0; } const minmaxMatch = width.match(/minmax\(\s*(\d+(?:\.\d+)?)px/i); if (minmaxMatch) { return Number(minmaxMatch[1]); } return 0; } function readPixelWidth(width) { if (typeof width === "number") { return width; } if (typeof width !== "string") { return 0; } const pixelMatch = width.match(/^(\d+(?:\.\d+)?)px$/i); return pixelMatch ? Number(pixelMatch[1]) : 0; } function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } function isFilterActive(filter) { const value = filter?.activeValue !== undefined ? filter.activeValue : filter?.value; return value !== undefined && value !== null && String(value) !== ""; } function filterDisplayValue(filter) { if (filter?.activeLabel) { return filter.activeLabel; } const value = String((filter?.activeValue !== undefined ? filter.activeValue : filter?.value) ?? ""); if (!value) { return ""; } if (filter?.type === "text") { return value; } const matchedOption = normalizeFilterOptions(filter).find((option) => option.value === value); return matchedOption?.label || value; } function filterVisibleOptions(filter, query) { if (!filter) { return []; } const normalizedOptions = normalizeFilterOptions(filter); const normalizedQuery = query.trim().toLowerCase(); if (!normalizedQuery) { return normalizedOptions; } return normalizedOptions.filter((option) => option.label.toLowerCase().includes(normalizedQuery) || option.value.toLowerCase().includes(normalizedQuery)); } function normalizeFilterOptions(filter) { const options = (filter.options || []).map((option) => { if (Array.isArray(option)) { return { label: String(option[1] ?? option[0] ?? ""), value: String(option[0] ?? "") }; } return { label: String(option.label ?? option.name ?? option.value ?? ""), value: String(option.value ?? "") }; }); if (filter.emptyLabel !== undefined && !options.some((option) => option.value === "")) { return [{ label: filter.emptyLabel, value: "" }, ...options]; } return options; } const tableFilterResetSx = { width: 28, height: 28, color: "var(--text-secondary)", backgroundColor: "var(--bg-card)", "&:hover": { borderColor: "var(--primary-border-strong)", backgroundColor: "var(--primary-hover)", color: "var(--text-primary)" } };