casualroom/apps/fenghuo/web/components/profile/profile-data-table.tsx

824 lines
25 KiB
TypeScript
Raw Normal View History

2025-07-28 07:50:50 +08:00
'use client';
import * as React from 'react';
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
type UniqueIdentifier,
} from '@dnd-kit/core';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconChevronsLeft,
IconChevronsRight,
IconDotsVertical,
IconLayoutColumns,
IconPlus,
IconStarFilled,
IconBuilding,
IconId,
IconMars,
IconVenus,
IconEye,
IconTrash,
} from '@tabler/icons-react';
import {
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getSortedRowModel,
Row,
SortingState,
useReactTable,
VisibilityState,
} from '@tanstack/react-table';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import { Badge } from '@nice/ui/components/badge';
import { Button } from '@nice/ui/components/button';
import { Checkbox } from '@nice/ui/components/checkbox';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@nice/ui/components/dropdown-menu';
import { Label } from '@nice/ui/components/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@nice/ui/components/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@nice/ui/components/table';
import { Avatar, AvatarFallback, AvatarImage } from '@nice/ui/components/avatar';
import { Input } from '@nice/ui/components/input';
import { ProfileDetail, ProfileMetadata } from '@fenghuo/common/profile';
import { TaxonomySlug } from '@fenghuo/common/term';
import { MultipleDeptSelector } from '../selector/dept-select';
import { useTranslation } from '@nice/i18n'
// 职务等级星星显示组件
function DutyLevelStars({ level }: { level: number | string }) {
// 转换为数字并检查是否为有效的正整数
const numLevel = Number(level);
if (!numLevel || numLevel <= 0) return null;
const stars = Array.from({ length: 3 }, (_, index) => {
const filled = index < numLevel;
return filled ? (
<IconStarFilled key={index} className="size-3 text-yellow-500" />
) : (
null
);
});
return <div className="flex items-center gap-0.5">{stars}</div>;
}
// 日期格式化辅助函数
function formatDate(date: Date | string | null | undefined): string {
if (!date) return '-';
try {
return dayjs(date).format('YYYY-MM-DD');
} catch {
return '-';
}
}
// 政治面貌和党派职务显示组件
function PoliticalInfo({ metadata }: { metadata?: ProfileMetadata }) {
if (!metadata?.politicalStatus && !metadata?.partyPosition) {
return <span className="text-muted-foreground">-</span>;
}
return (
<div className="space-y-1">
{metadata.politicalStatus && (
<Badge variant="outline" className="text-xs">
{metadata.politicalStatus}
</Badge>
)}
{metadata.partyPosition && (
<div className="text-xs text-muted-foreground break-words">{metadata.partyPosition}</div>
)}
</div>
);
}
// 籍贯显示组件
function NativePlace({ native }: { native?: ProfileMetadata['native'] }) {
if (!native) return <span className="text-muted-foreground">-</span>;
const parts = [native.province, native.city, native.county].filter(Boolean);
return <span className="break-words">{parts.join(' ')}</span>;
}
// 学历信息显示组件
function EducationInfo({ metadata }: { metadata?: ProfileMetadata }) {
if (!metadata?.education && !metadata?.educationForm && !metadata?.schoolMajor) {
return <span className="text-muted-foreground">-</span>;
}
return (
<div className="space-y-1">
<div className="flex items-start gap-1 flex-wrap">
{metadata.education && (
<Badge variant="outline" className="text-xs flex-shrink-0">
{metadata.education}
</Badge>
)}
{metadata.educationForm && (
<span className="text-xs text-muted-foreground break-words">({metadata.educationForm})</span>
)}
</div>
{metadata.schoolMajor && (
<div className="text-xs text-muted-foreground break-words">{metadata.schoolMajor}</div>
)}
</div>
);
}
// 组织信息展示组件
function OrganizationInfo({ organization }: { organization?: ProfileDetail['organization'] }) {
const { t } = useTranslation();
if (!organization) {
return (
<div className="flex items-center gap-2">
<IconBuilding className="size-4 text-muted-foreground flex-shrink-0" />
<div className="text-sm">{t('profile.profile_table.table.no_organization')}</div>
</div>
);
}
// 提取组织类型术语(作为标签展示)
const organizationTypeTerms = organization.terms?.filter(
(term) => term.taxonomy?.slug === TaxonomySlug.ORGANIZATION_TYPE
) || [];
// 提取专业术语(作为小字展示)
const professionTerms = organization.terms?.filter(
(term) => term.taxonomy?.slug === TaxonomySlug.PROFESSION
) || [];
return (
<div className="flex items-start gap-2">
<IconBuilding className="size-4 text-muted-foreground flex-shrink-0 mt-0.5" />
<div className="space-y-1 min-w-0 flex-1">
<div className="flex items-start gap-2 flex-wrap">
{
organization.parentId ? (
<span className="text-sm break-words min-w-0">{organization?.parent?.name}/{organization.name}</span>
) : (
<span className="text-sm break-words min-w-0">{organization.name}</span>
)
}
{/* 组织类型标签 */}
{organizationTypeTerms.map((term) => (
<Badge key={term.id} variant="secondary" className="text-xs flex-shrink-0">
{term.name}
</Badge>
))}
</div>
{/* 专业信息 */}
{professionTerms.length > 0 && (
<div className="text-xs text-muted-foreground break-words">
{professionTerms.map((term) => term.name).join(' · ')}
</div>
)}
</div>
</div>
);
}
// 可拖拽行组件
function DraggableRow({ row }: { row: Row<ProfileDetail> }) {
const { transform, transition, setNodeRef, isDragging } = useSortable({
id: row.original.id,
});
return (
<TableRow
data-state={row.getIsSelected() && 'selected'}
data-dragging={isDragging}
ref={setNodeRef}
className="relative z-0 data-[dragging=true]:z-10 data-[dragging=true]:opacity-80"
style={{
transform: CSS.Transform.toString(transform),
transition: transition,
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="whitespace-normal break-words"
style={{
width: `${cell.column.getSize()}px`,
minWidth: `${cell.column.getSize()}px`,
maxWidth: `${cell.column.getSize()}px`,
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
);
}
// 分页信息接口
interface PaginationInfo {
page: number;
pageSize: number;
totalPages: number;
totalCount: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
}
// 主数据表组件
export function ProfileDataTable({
data: initialData,
isLoading = false,
pagination,
onPageChange,
onPageSizeChange,
onAddEmployee,
onViewDetail,
onDeleteEmployee,
searchValue = '',
onSearchChange,
// 添加部门筛选相关属性
selectedOrganizationIds = [],
onOrganizationChange,
sortBy = 'default',
onSortChange,
}: {
data: ProfileDetail[];
isLoading?: boolean;
pagination?: PaginationInfo;
onPageChange?: (page: number) => void;
onPageSizeChange?: (size: number) => void;
onAddEmployee?: () => void;
onViewDetail?: (profileId: string) => void;
onDeleteEmployee?: (profileId: string) => void;
searchValue?: string;
onSearchChange?: (value: string) => void;
// 添加部门筛选相关类型
selectedOrganizationIds?: string[];
onOrganizationChange?: (organizationIds: string[]) => void;
// 添加排序相关类型
sortBy?: string;
onSortChange?: (sortBy: string) => void;
}) {
const [data, setData] = React.useState(() => initialData);
const [rowSelection, setRowSelection] = React.useState({});
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({
// 默认隐藏的列
hireInfo: false,
politicalInfo: false,
nativePlace: false,
trainAppraisal: false,
educationInfo: false,
otherInfo: false,
birthDate: false,
});
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
const [sorting, setSorting] = React.useState<SortingState>([]);
// 移除本地分页状态,使用服务端分页
const sortableId = React.useId();
const sensors = useSensors(useSensor(MouseSensor, {}), useSensor(TouchSensor, {}), useSensor(KeyboardSensor, {}));
const { t } = useTranslation();
// 定义列配置 - 移到组件内部以访问回调函数
const columns: ColumnDef<ProfileDetail>[] = [
{
id: 'select',
header: ({ table }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && 'indeterminate')}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label={t('common.select_all')}
/>
</div>
),
cell: ({ row }) => (
<div className="flex items-center justify-center">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label={t('common.select_row')}
/>
</div>
),
enableSorting: false,
enableHiding: false,
size: 20,
},
{
accessorKey: 'name',
header: t('profile.profile_table.column.name'),
cell: ({ row }) => {
const profile = row.original;
return (
<div className="flex items-center gap-3">
<Avatar className="size-12 flex-shrink-0">
{profile.avatar ? (
<AvatarImage
src={profile.avatar}
alt={profile.name}
className="object-contain" // 添加这个类
/>
) : (
<AvatarFallback className="text-xs">
{profile.name.slice(0, 1)}
</AvatarFallback>
)}
</Avatar>
<div className="flex flex-row items-center justify-center gap-1">
<div className="font-medium">{profile.name}</div>
<div className="text-xs text-muted-foreground">
{profile.gender === 1 ? (
<IconMars className="size-4 text-blue-500" />
) : (
<IconVenus className="size-4 text-pink-500" />
)}
</div>
</div>
</div>
);
},
enableHiding: false,
size: 120,
},
{
accessorKey: 'certificates',
header: t('profile.profile_table.column.id_card'),
cell: ({ row }) => {
const profile = row.original;
return (
<div className="space-y-1">
<div className="text-sm font-mono flex items-center gap-1 break-all">
<IconId className="size-4 text-muted-foreground flex-shrink-0" />
<span className="break-all">{profile.paperId}</span>
</div>
{profile.paperId && (
<div className="text-xs text-muted-foreground font-mono break-all">{t('profile.profile_table.table.id_card_label')}{profile.idNum}</div>
)}
</div>
);
},
enableHiding: true,
size: 140,
},
{
accessorKey: 'command',
header: t('profile.profile_table.column.formation_command'),
cell: ({ row }) => {
const profile = row.original;
return (
<div className="text-xs break-words whitespace-normal text-muted-foreground">
{profile.command || <span className="text-muted-foreground"></span>}
</div>
);
},
enableHiding: true,
size: 120,
},
{
accessorKey: 'dutyInfo',
header: t('profile.profile_table.column.duty'),
cell: ({ row }) => {
const profile = row.original;
return (
<div className="space-y-1">
<div className="text-sm font-medium flex items-center gap-2 break-words">
<span className="break-words">{profile.dutyName}</span>
{profile.dutyLevel > 0 && <DutyLevelStars level={profile.dutyLevel} />}
</div>
<div className="text-xs text-muted-foreground break-words">{profile.dutyCode}</div>
</div>
);
},
enableHiding: true,
size: 100,
},
{
accessorKey: 'organization',
header: t('profile.profile_table.column.organization'),
cell: ({ row }) => {
const profile = row.original;
return <OrganizationInfo organization={profile.organization} />;
},
enableHiding: true,
size: 180,
},
{
accessorKey: 'identityInfo',
header: t('profile.profile_table.column.identity'),
cell: ({ row }) => {
const profile = row.original;
return (
<div className="space-y-1">
{profile.identity && (
<div className="flex items-start gap-2 flex-wrap">
<Badge variant="outline" className="text-xs flex-shrink-0">
{profile.identity}
</Badge>
{
profile.level && (
<span className="font-medium break-words min-w-0">{profile.level}</span>
)
}
</div>
)}
{profile.levelDate && (
<div className="text-muted-foreground text-xs break-words">
{formatDate(profile.levelDate)}
</div>
)}
</div>
);
},
enableHiding: true,
size: 120,
},
{
accessorKey: 'hireInfo',
header: t('profile.profile_table.column.hire_date'),
cell: ({ row }) => {
const profile = row.original;
return (
<div className="space-y-1">
<div className="text-sm break-words">{formatDate(profile.hireDate)}</div>
{profile.relativeHireDate && profile.hireDate !== profile.relativeHireDate && (
<div className="text-xs text-muted-foreground break-words">
{t('profile.profile_sheet.relative_hire_time')}: {formatDate(profile.relativeHireDate)}
</div>
)}
</div>
);
},
enableHiding: true,
size: 120,
},
// 以下列默认隐藏
{
accessorKey: 'politicalInfo',
header: t('profile.profile_table.column.political'),
cell: ({ row }) => {
const metadata = row.original.metadata as ProfileMetadata;
return <PoliticalInfo metadata={metadata} />;
},
enableHiding: true,
size: 100,
},
{
accessorKey: 'nativePlace',
header: t('profile.profile_table.column.native'),
cell: ({ row }) => {
const metadata = row.original.metadata as ProfileMetadata;
return <NativePlace native={metadata?.native} />;
},
enableHiding: true,
size: 130,
},
{
accessorKey: 'educationInfo',
header: t('profile.profile_table.column.education'),
cell: ({ row }) => {
const metadata = row.original.metadata as ProfileMetadata;
return <EducationInfo metadata={metadata} />;
},
enableHiding: true,
size: 100,
},
{
accessorKey: 'birthDate',
header: t('profile.profile_table.table.birth_date'),
cell: ({ row }) => {
const profile = row.original;
return (
<div className="text-sm break-words font-medium">
{formatDate(profile.birthday)}
</div>
);
},
enableHiding: true,
size: 80,
},
{
id: 'actions',
header: t('common.actions'),
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="data-[state=open]:bg-muted text-muted-foreground flex size-8" size="icon">
<IconDotsVertical />
<span className="sr-only">{t('common.open_menu')}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-32">
<DropdownMenuItem onClick={() => onViewDetail?.(row.original.id)}>
<IconEye className="mr-2 h-4 w-4 cursor-pointer" />
{t('common.view_details')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => onDeleteEmployee?.(row.original.id)}>
<IconTrash className="mr-2 h-4 w-4" />
{t('common.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
enableHiding: false,
size: 50,
},
];
// 当数据更新时,同步本地状态
React.useEffect(() => {
setData(initialData);
}, [initialData]);
const dataIds = React.useMemo<UniqueIdentifier[]>(() => data?.map(({ id }) => id) || [], [data]);
const table = useReactTable({
data,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
},
getRowId: (row) => row.id,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
// 禁用本地分页,使用服务端分页
manualPagination: true,
// 禁用列调整大小功能,保持列宽度固定
enableColumnResizing: false,
});
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (active && over && active.id !== over.id) {
setData((data) => {
const oldIndex = dataIds.indexOf(active.id);
const newIndex = dataIds.indexOf(over.id);
return arrayMove(data, oldIndex, newIndex);
});
}
}
return (
<div className="w-full space-y-4">
{/* 顶部工具栏 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<h2 className="text-lg font-semibold">{t('profile.profile_table.title')}</h2>
<Badge variant="outline">
{isLoading ? t('common.loading') : t('profile.profile_table.table.records_count', { count: pagination?.totalCount || 0 })}
</Badge>
</div>
<div className="flex items-center gap-2">
{/* 部门筛选器 */}
<MultipleDeptSelector
value={selectedOrganizationIds}
onValueChange={onOrganizationChange}
placeholder={t('profile.profile_table.toolbar.filter_organization')}
className="w-92 h-8"
allowClear
/>
{/* 搜索框 */}
<div className="relative]">
<Input
placeholder={t('profile.profile_table.toolbar.search_placeholder')}
value={searchValue}
onChange={(e) => onSearchChange?.(e.target.value)}
className="w-56 border border-[#5D6E89] h-8"
/>
</div>
{/* 列显示选择器 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<IconLayoutColumns className="size-4" />
<span className="hidden lg:inline">{t('common.custom_columns')}</span>
<span className="lg:hidden">{t('common.columns')}</span>
<IconChevronDown className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{table
.getAllColumns()
.filter((column) => typeof column.accessorFn !== 'undefined' && column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
onSelect={(event) => {
event.preventDefault();
}}
>
{column.columnDef.header as string}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
{/* 排序方式选择器 */}
<Select value={sortBy || 'default'} onValueChange={onSortChange}>
<SelectTrigger className="w-46 border-[#5D6E89] !h-8 min-h-8">
<SelectValue placeholder={t('profile.profile_table.toolbar.sort_method')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">{t('profile.profile_table.toolbar.default_sort')}</SelectItem>
<SelectItem value="dutyLevelDown">{t('profile.profile_table.toolbar.duty_level_desc')}</SelectItem>
<SelectItem value="dutyLevelUp">{t('profile.profile_table.toolbar.duty_level_asc')}</SelectItem>
<SelectItem value="hireDateDown">{t('profile.profile_table.toolbar.hire_date_desc')}</SelectItem>
<SelectItem value="hireDateUp">{t('profile.profile_table.toolbar.hire_date_asc')}</SelectItem>
<SelectItem value="birthdayDown">{t('profile.profile_table.toolbar.birthday_desc')}</SelectItem>
<SelectItem value="birthdayUp">{t('profile.profile_table.toolbar.birthday_asc')}</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" className='border-[#5D6E89]' size="sm" onClick={onAddEmployee}>
<IconPlus className="size-4" />
<span className="hidden lg:inline">{t('profile.profile_table.toolbar.add_employee')}</span>
<span className="lg:hidden">{t('common.add')}</span>
</Button>
</div>
</div>
{/* 数据表 */}
<div className="rounded-md border border-border max-h-[720px] overflow-auto">
<DndContext
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
sensors={sensors}
id={sortableId}
>
<Table className="table-fixed w-full" stickyHeader>
<TableHeader className="sticky top-0 z-10 bg-background">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="bg-muted/50">
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
colSpan={header.colSpan}
className="whitespace-normal break-words"
style={{
width: `${header.getSize()}px`,
minWidth: `${header.getSize()}px`,
maxWidth: `${header.getSize()}px`,
}}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={table.getAllColumns().filter(column => column.getIsVisible()).length} className="h-24 text-center">
{t('common.loading')}
</TableCell>
</TableRow>
) : table.getRowModel().rows?.length ? (
<SortableContext items={dataIds} strategy={verticalListSortingStrategy}>
{table.getRowModel().rows.map((row) => (
<DraggableRow key={row.id} row={row} />
))}
</SortableContext>
) : (
<TableRow>
<TableCell colSpan={table.getAllColumns().filter(column => column.getIsVisible()).length} className="h-24 text-center">
{t('common.no_data')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</DndContext>
</div>
{/* 分页器 - 使用服务端分页 */}
{pagination && (
<div className="flex items-center justify-between px-4">
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
{t('pagination.selected_rows', {
selected: table.getFilteredSelectedRowModel().rows.length,
total: pagination.totalCount
})}
</div>
<div className="flex w-full items-center gap-8 lg:w-fit">
<div className="hidden items-center gap-2 lg:flex">
<Label htmlFor="rows-per-page" className="text-sm font-medium">
{t('pagination.rows_per_page')}
</Label>
<Select
value={`${pagination.pageSize}`}
onValueChange={(value) => {
onPageSizeChange?.(Number(value));
}}
>
<SelectTrigger size="sm" className="w-20" id="rows-per-page">
<SelectValue placeholder={pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-fit items-center justify-center text-sm font-medium">
{t('pagination.page_info', {
current: pagination.page,
total: pagination.totalPages
})}
</div>
<div className="ml-auto flex items-center gap-2 lg:ml-0">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => onPageChange?.(1)}
disabled={!pagination.hasPreviousPage}
>
<span className="sr-only">{t('pagination.first_page')}</span>
<IconChevronsLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => onPageChange?.(pagination.page - 1)}
disabled={!pagination.hasPreviousPage}
>
<span className="sr-only">{t('pagination.previous_page')}</span>
<IconChevronLeft />
</Button>
<Button
variant="outline"
className="size-8"
size="icon"
onClick={() => onPageChange?.(pagination.page + 1)}
disabled={!pagination.hasNextPage}
>
<span className="sr-only">{t('pagination.next_page')}</span>
<IconChevronRight />
</Button>
<Button
variant="outline"
className="hidden size-8 lg:flex"
size="icon"
onClick={() => onPageChange?.(pagination.totalPages)}
disabled={!pagination.hasNextPage}
>
<span className="sr-only">{t('pagination.last_page')}</span>
<IconChevronsRight />
</Button>
</div>
</div>
</div>
)}
</div>
);
}