270 lines
6.7 KiB
TypeScript
270 lines
6.7 KiB
TypeScript
import React, { useMemo, useState } from 'react';
|
||
import { Dropdown } from 'antd';
|
||
import type { MenuProps } from 'antd';
|
||
import {
|
||
ArrowRightLeft,
|
||
Car,
|
||
CircleDollarSign,
|
||
Download,
|
||
Eye,
|
||
FileText,
|
||
MoreHorizontal,
|
||
OctagonX,
|
||
Pencil,
|
||
RefreshCw,
|
||
Settings2,
|
||
Trash2,
|
||
Undo2,
|
||
Upload,
|
||
Users,
|
||
type LucideIcon,
|
||
} from 'lucide-react';
|
||
|
||
export type OperationActionItem = {
|
||
key: string;
|
||
label: string;
|
||
onClick: () => void;
|
||
danger?: boolean;
|
||
disabled?: boolean;
|
||
hidden?: boolean;
|
||
icon?: LucideIcon;
|
||
};
|
||
|
||
const MORE_ACTION_ICONS: Record<string, LucideIcon> = {
|
||
edit: Pencil,
|
||
del: Trash2,
|
||
delete: Trash2,
|
||
addVehicle: Car,
|
||
renew: RefreshCw,
|
||
authorized: FileText,
|
||
extraFee: CircleDollarSign,
|
||
toTripartite: Users,
|
||
terminate: OctagonX,
|
||
withdraw: Undo2,
|
||
toFormal: ArrowRightLeft,
|
||
stampSupplement: Upload,
|
||
uploadStamped: Upload,
|
||
manage: Settings2,
|
||
preview: Eye,
|
||
download: Download,
|
||
};
|
||
|
||
function renderMenuItemLabel(item: OperationActionItem) {
|
||
const Icon = item.icon ?? MORE_ACTION_ICONS[item.key];
|
||
return (
|
||
<span
|
||
className={[
|
||
'vm-op-menu-item',
|
||
item.danger ? 'vm-op-menu-item--danger' : '',
|
||
].filter(Boolean).join(' ')}
|
||
>
|
||
{Icon ? (
|
||
<Icon className="vm-op-menu-item__icon" aria-hidden strokeWidth={1.75} />
|
||
) : (
|
||
<span className="vm-op-menu-item__icon-spacer" aria-hidden />
|
||
)}
|
||
<span className="vm-op-menu-item__label">{item.label}</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
export type OperationPrimaryAction = {
|
||
onClick: () => void;
|
||
label?: string;
|
||
hidden?: boolean;
|
||
disabled?: boolean;
|
||
};
|
||
|
||
export type OperationActionsProps = {
|
||
/** 详情:固定外显主操作 */
|
||
view?: OperationPrimaryAction;
|
||
/**
|
||
* @deprecated 编辑已收入「更多」下拉,请放入 more 数组
|
||
*/
|
||
edit?: OperationPrimaryAction;
|
||
/** 其余操作(含编辑)收入「更多」下拉 */
|
||
more?: OperationActionItem[];
|
||
/** 更多按钮文案,默认「更多」 */
|
||
moreLabel?: string;
|
||
className?: string;
|
||
annotationId?: string;
|
||
};
|
||
|
||
function ViewIcon() {
|
||
return <Eye className="vm-op-action__icon" aria-hidden />;
|
||
}
|
||
|
||
function MoreIcon() {
|
||
return <MoreHorizontal className="vm-op-action__icon" aria-hidden />;
|
||
}
|
||
|
||
const PrimaryActionButton = React.forwardRef<
|
||
HTMLButtonElement,
|
||
{
|
||
icon: 'view' | 'more';
|
||
label: string;
|
||
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||
disabled?: boolean;
|
||
ariaLabel: string;
|
||
ariaHasPopup?: boolean;
|
||
className?: string;
|
||
}
|
||
>(function PrimaryActionButton(
|
||
{
|
||
icon,
|
||
label,
|
||
onClick,
|
||
disabled,
|
||
ariaLabel,
|
||
ariaHasPopup,
|
||
className,
|
||
},
|
||
ref,
|
||
) {
|
||
return (
|
||
<button
|
||
ref={ref}
|
||
type="button"
|
||
className={['vm-op-action', className].filter(Boolean).join(' ')}
|
||
aria-label={ariaLabel}
|
||
aria-haspopup={ariaHasPopup ? 'menu' : undefined}
|
||
disabled={disabled}
|
||
onClick={onClick}
|
||
>
|
||
{icon === 'view' ? <ViewIcon /> : <MoreIcon />}
|
||
<span className="vm-op-action__label">{label}</span>
|
||
</button>
|
||
);
|
||
});
|
||
|
||
function mergeMoreItems(
|
||
more: OperationActionItem[],
|
||
edit?: OperationPrimaryAction,
|
||
): OperationActionItem[] {
|
||
const visibleMore = more.filter((item) => !item.hidden);
|
||
if (!edit || edit.hidden) return visibleMore;
|
||
const hasEdit = visibleMore.some((item) => item.key === 'edit' || item.label === '编辑');
|
||
if (hasEdit) return visibleMore;
|
||
return [
|
||
{
|
||
key: 'edit',
|
||
label: edit.label || '编辑',
|
||
onClick: edit.onClick,
|
||
disabled: edit.disabled,
|
||
},
|
||
...visibleMore,
|
||
];
|
||
}
|
||
|
||
export function OperationActions({
|
||
view,
|
||
edit,
|
||
more = [],
|
||
moreLabel = '更多',
|
||
className,
|
||
annotationId,
|
||
}: OperationActionsProps) {
|
||
const [moreOpen, setMoreOpen] = useState(false);
|
||
const visibleMore = useMemo(
|
||
() => mergeMoreItems(more, edit),
|
||
[more, edit],
|
||
);
|
||
|
||
const menuItems: MenuProps['items'] = useMemo(() => {
|
||
const items: MenuProps['items'] = [];
|
||
visibleMore.forEach((item, index) => {
|
||
if (item.danger && index > 0 && !visibleMore[index - 1]?.danger) {
|
||
items.push({ type: 'divider' });
|
||
}
|
||
items.push({
|
||
key: item.key,
|
||
label: renderMenuItemLabel(item),
|
||
danger: item.danger,
|
||
disabled: item.disabled,
|
||
className: item.danger ? 'vm-op-dropdown-menu-item--danger' : undefined,
|
||
});
|
||
});
|
||
return items;
|
||
}, [visibleMore]);
|
||
|
||
const showView = !!(view && !view.hidden);
|
||
const showMore = visibleMore.length > 0;
|
||
|
||
if (!showView && !showMore) {
|
||
return <span className="vm-operation-actions vm-operation-actions--empty">-</span>;
|
||
}
|
||
|
||
const handleMenuClick: MenuProps['onClick'] = ({ key, domEvent }) => {
|
||
domEvent.stopPropagation();
|
||
const target = visibleMore.find((item) => item.key === key);
|
||
if (target && !target.disabled) target.onClick();
|
||
};
|
||
|
||
return (
|
||
<div
|
||
className={['vm-row-actions', 'vm-operation-actions', className].filter(Boolean).join(' ')}
|
||
data-annotation-id={annotationId}
|
||
onClick={(event) => event.stopPropagation()}
|
||
>
|
||
{showView ? (
|
||
<PrimaryActionButton
|
||
icon="view"
|
||
label={view?.label || '详情'}
|
||
ariaLabel={view?.label || '详情'}
|
||
disabled={view?.disabled}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
if (!view?.disabled) view!.onClick();
|
||
}}
|
||
/>
|
||
) : null}
|
||
{showMore ? (
|
||
<Dropdown
|
||
menu={{
|
||
items: menuItems,
|
||
onClick: handleMenuClick,
|
||
className: 'vm-op-dropdown-menu',
|
||
}}
|
||
trigger={['click']}
|
||
placement="bottomRight"
|
||
getPopupContainer={() => document.body}
|
||
overlayClassName="vm-op-dropdown"
|
||
open={moreOpen}
|
||
onOpenChange={setMoreOpen}
|
||
align={{ offset: [0, 4] }}
|
||
>
|
||
<PrimaryActionButton
|
||
icon="more"
|
||
label={moreLabel}
|
||
ariaLabel={moreLabel + '操作'}
|
||
ariaHasPopup
|
||
className={moreOpen ? 'vm-op-action--open' : undefined}
|
||
/>
|
||
</Dropdown>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 从扁平操作列表中拆分出 view / more(编辑收入 more)。
|
||
*/
|
||
export function splitOperationActions(items: OperationActionItem[]): {
|
||
view?: OperationPrimaryAction;
|
||
more: OperationActionItem[];
|
||
} {
|
||
const visible = items.filter((item) => !item.hidden);
|
||
const viewItem = visible.find((item) => item.label === '详情' || item.label === '查看' || item.key === 'view');
|
||
const more = visible.filter((item) => item !== viewItem);
|
||
return {
|
||
view: viewItem
|
||
? {
|
||
onClick: viewItem.onClick,
|
||
label: viewItem.label === '查看' ? '详情' : viewItem.label,
|
||
disabled: viewItem.disabled,
|
||
}
|
||
: undefined,
|
||
more,
|
||
};
|
||
}
|