迭代 ONE-OS 多原型:统一标注壳与操作规范,租赁明细/合同/提车应收款增强,新增任务工单,同步导航与合包页面。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
263
src/common/OperationActions.tsx
Normal file
263
src/common/OperationActions.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Dropdown } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
Car,
|
||||
CircleDollarSign,
|
||||
Eye,
|
||||
FileText,
|
||||
MoreHorizontal,
|
||||
OctagonX,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
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,
|
||||
addVehicle: Car,
|
||||
renew: RefreshCw,
|
||||
authorized: FileText,
|
||||
extraFee: CircleDollarSign,
|
||||
toTripartite: Users,
|
||||
terminate: OctagonX,
|
||||
withdraw: Undo2,
|
||||
toFormal: ArrowRightLeft,
|
||||
stampSupplement: Upload,
|
||||
uploadStamped: Upload,
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user