Files
OneOS1.2/src/common/OperationActions.tsx

270 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};
}