2026-05-26 05:17:07 +08:00

638 lines
21 KiB
JavaScript

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 (
<section className={["table-frame", className].filter(Boolean).join(" ")}>
{title || total !== undefined ? (
<div className="table-toolbar">
<div className="card-title">{title}</div>
{total !== undefined ? <div className="muted"> {total} </div> : null}
</div>
) : null}
<div className="table-scroll" ref={scrollRef}>
<div
className={["admin-table", resizeSession ? "admin-table--resizing" : "", tableClassName].filter(Boolean).join(" ")}
style={{ "--admin-table-columns": gridTemplate, "--admin-table-min-width": minWidth }}
>
<div className="admin-row admin-row--head">
{preparedColumns.map((column, index) => {
const isLastColumn = index === preparedColumns.length - 1;
return (
<div
className={cellClassName(column, ["admin-cell--head", resizeSession?.key === column.key ? "admin-cell--resizing" : ""])}
data-table-header-cell
key={column.key}
>
<HeaderLabel
column={column}
trailingAction={
isLastColumn && hasColumnFilters && hasActiveColumnFilters ? (
<TableFilterResetButton onClick={resetColumnFilters} />
) : null
}
onOpenFilter={openColumnFilter}
/>
{resizableColumns && column.resizable !== false ? (
<button
aria-label={`调整${typeof column.label === "string" ? column.label : column.key}列宽`}
className="admin-column-resizer"
tabIndex={-1}
type="button"
onPointerDown={(event) => startResize(event, column)}
/>
) : null}
</div>
);
})}
</div>
{safeItems.length ? (
<>
{safeItems.map((item, index) => {
const rowProps = getRowProps ? getRowProps(item, index) : {};
const { className: rowClassName = "", ...restRowProps } = rowProps;
return (
<div className={["admin-row", rowClassName].filter(Boolean).join(" ")} key={rowKey(item)} {...restRowProps}>
{preparedColumns.map((column) => (
<div className={cellClassName(column)} key={column.key}>
{column.render ? column.render(item, index, renderContext) : displayValue(item[column.key])}
</div>
))}
</div>
);
})}
{infiniteScroll || paginationHasMore || paginationLoadingMore ? (
<div
className={["admin-row", "admin-row--load-more", !infiniteHasMore && !infiniteLoading ? "admin-row--load-more-done" : ""]
.filter(Boolean)
.join(" ")}
ref={loadMoreRef}
>
<div className="admin-cell admin-cell--load-more">
{infiniteLoading ? infiniteLoadLabel : infiniteHasMore ? "" : infiniteDoneLabel}
</div>
</div>
) : null}
</>
) : infiniteLoading ? (
<div className="admin-row admin-row--load-more" ref={loadMoreRef}>
<div className="admin-cell admin-cell--load-more">
{infiniteLoadLabel}
</div>
</div>
) : (
<div className="empty-state empty-state--table">
<div className="empty-state__title">{emptyLabel}</div>
</div>
)}
{safeItems.length === 0 && (infiniteScroll || paginationHasMore) && !infiniteLoading ? (
<div className="admin-row--load-more-sentinel" ref={loadMoreRef} />
) : null}
</div>
</div>
{pagination ? (
<PaginationBar
{...pagination}
itemCount={safeItems.length}
/>
) : null}
<Popover
anchorEl={filterMenu.anchorEl}
anchorOrigin={{ horizontal: "left", vertical: "bottom" }}
className="admin-table-filter-popover"
open={Boolean(activeFilterColumn)}
transformOrigin={{ horizontal: "left", vertical: "top" }}
onClose={closeColumnFilter}
>
{activeFilterColumn ? (
activeFilterIsText ? (
<form className="admin-table-filter" onSubmit={submitTextFilter}>
<TextField
autoFocus
className="admin-table-filter__search"
disabled={activeFilter.loading}
placeholder={activeFilter.placeholder || `搜索${activeFilterColumn.label || ""}`}
size="small"
value={filterQuery}
onChange={(event) => setFilterQuery(event.target.value)}
/>
<div className="admin-table-filter__actions">
<Button disabled={activeFilter.loading || !isFilterActive(activeFilter)} onClick={clearTextFilter}>
清空
</Button>
<Button disabled={activeFilter.loading} type="submit" variant="primary">
搜索
</Button>
</div>
</form>
) : (
<div className="admin-table-filter">
<TextField
autoFocus
className="admin-table-filter__search"
disabled={activeFilter.loading}
placeholder={activeFilter.placeholder || `搜索${activeFilterColumn.label || ""}`}
size="small"
value={filterQuery}
onChange={(event) => setFilterQuery(event.target.value)}
/>
<MenuList className="admin-table-filter__options">
{filterOptions.length ? (
filterOptions.map((option) => (
<MenuItem
key={option.value}
selected={String(activeFilter.value ?? "") === option.value}
onClick={() => changeFilter(option.value)}
>
{option.label}
</MenuItem>
))
) : (
<div className="admin-table-filter__empty">{activeFilter.loading ? "加载中..." : "无匹配选项"}</div>
)}
</MenuList>
</div>
)
) : null}
</Popover>
</section>
);
}
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 ? (
<span className="admin-cell__head-label">{content}</span>
) : (
<FilterHeaderButton column={column} onOpenFilter={onOpenFilter} />
);
return (
<span className="admin-cell__head-content">
{label}
{trailingAction}
</span>
);
}
function FilterHeaderButton({ column, onOpenFilter }) {
const content = column.header || column.label;
if (!column.filter) {
return <span className="admin-cell__head-label">{content}</span>;
}
const active = isFilterActive(column.filter);
const filterValueLabel = active ? filterDisplayValue(column.filter) : "";
return (
<button
className={["admin-cell__head-trigger", active ? "admin-cell__head-trigger--active" : ""].filter(Boolean).join(" ")}
type="button"
onClick={(event) => onOpenFilter(event, column)}
>
<SearchOutlined className="admin-cell__head-icon" fontSize="inherit" />
<span className="admin-cell__head-label">
{content}
{filterValueLabel ? <span className="admin-cell__head-filter">({filterValueLabel})</span> : null}
</span>
</button>
);
}
function TableFilterResetButton({ onClick }) {
return (
<Tooltip arrow title="重置筛选">
<span className="admin-table-filter-reset-wrap">
<IconButton
className="admin-table-filter-reset"
label="重置筛选"
sx={tableFilterResetSx}
onClick={(event) => {
event.stopPropagation();
onClick();
}}
>
<RestartAltOutlined fontSize="small" />
</IconButton>
</span>
</Tooltip>
);
}
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)"
}
};