Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions frontend/src/components/complex-table/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,25 @@ const rightClick = ref({
});
const selectedRows = ref<any[]>([]);
const handleRightClick = (row, column, event) => {
if (!tableRef.value) return;

try {
const selectionColumn = tableRef.value.refElTable.columns.find((col) => col.type === 'selection');
const isSelectable = selectionColumn?.selectable ? selectionColumn.selectable(row) : true;
if (!isSelectable) {
if (!props.rightButtons) return;
event.preventDefault();
rightClick.value = {
visible: true,
left: event.clientX + 5,
top: event.clientY,
currentRow: row,
};
document.addEventListener('click', closeRightClick);
return;
}
} catch {}

if (!selectedRows.value.includes(row)) {
clearSelects();
tableRef.value.refElTable.toggleRowSelection(row);
Expand Down
184 changes: 174 additions & 10 deletions frontend/src/components/fu/FuDropdownItem.vue
Original file line number Diff line number Diff line change
@@ -1,38 +1,202 @@
<template>
<el-dropdown-item v-bind="$attrs" :disabled="computedDisabled">
<slot></slot>
</el-dropdown-item>
<li
ref="itemRef"
v-bind="itemAttrs"
data-el-collection-item
:aria-disabled="computedDisabled"
:class="itemClass"
:style="attrs.style"
:tabindex="tabIndex"
:role="role"
@click="handleClick"
@focus="handleFocus"
@keydown.self="handleKeydown"
@mousedown="handleMousedown"
@pointermove="handlePointerMove"
@pointerleave="handlePointerLeave"
>
<el-icon v-if="icon || $slots.icon">
<slot name="icon">
<component :is="icon" />
</slot>
</el-icon>
<slot />
</li>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue';
import { hasManagePermissionAccess, type PermissionBindingValue } from '@/utils/permission';
import { DROPDOWN_INJECTION_KEY, EVENT_CODE } from 'element-plus';
import { COLLECTION_ITEM_SIGN } from 'element-plus/es/components/collection/index.mjs';
import {
ROVING_FOCUS_COLLECTION_INJECTION_KEY,
ROVING_FOCUS_GROUP_INJECTION_KEY,
} from 'element-plus/es/components/roving-focus-group/index.mjs';
import { computed, getCurrentInstance, inject, onBeforeUnmount, onMounted, ref, useAttrs } from 'vue';

defineOptions({
name: 'FuDropdownItem',
inheritAttrs: false,
});

const props = defineProps({
command: {
type: [Object, String, Number],
default: () => ({}),
},
disabled: {
type: Boolean,
default: false,
},
permission: {
type: [String, Array],
default: undefined,
divided: {
type: Boolean,
default: false,
},
textValue: String,
icon: {
type: [String, Object, Function],
},
});

const emit = defineEmits(['click', 'pointermove', 'pointerleave']);

const attrs = useAttrs();
const itemRef = ref<HTMLElement>();
const permissionDisabled = ref(false);
const instance = getCurrentInstance();
const itemId = `fu-dropdown-item-${instance?.uid ?? Math.random().toString(36).slice(2)}`;

const dropdownContext = inject<any>(DROPDOWN_INJECTION_KEY, undefined);
const collectionContext = inject<any>(ROVING_FOCUS_COLLECTION_INJECTION_KEY, undefined);
const rovingFocusContext = inject<any>(ROVING_FOCUS_GROUP_INJECTION_KEY, undefined);

const computedDisabled = computed(() => props.disabled || permissionDisabled.value);

const itemAttrs = computed(() => {
return Object.fromEntries(Object.entries(attrs).filter(([key]) => key !== 'class' && key !== 'style'));
});

const itemClass = computed(() => [
attrs.class,
'el-dropdown-menu__item',
{
'el-dropdown-menu__item--divided': props.divided,
'is-disabled': computedDisabled.value,
'fu-dropdown-item--permission-disabled': computedDisabled.value,
},
]);

const tabIndex = computed(() => (rovingFocusContext?.currentTabbedId?.value === itemId ? 0 : -1));

const role = computed(() => {
const menuRole = dropdownContext?.role?.value;
if (menuRole === 'menu') {
return 'menuitem';
}
if (menuRole === 'navigation') {
return 'link';
}
return 'button';
});

const hasPermission = computed(() => hasManagePermissionAccess(props.permission as PermissionBindingValue));
const handleMousedown = (event: MouseEvent) => {
if (computedDisabled.value) {
event.preventDefault();
return;
}
rovingFocusContext?.onItemFocus?.(itemId);
};

const computedDisabled = computed(() => props.disabled || permissionDisabled.value || !hasPermission.value);
const handleFocus = () => {
rovingFocusContext?.onItemFocus?.(itemId);
};

const handleKeydown = (event: KeyboardEvent) => {
if ([EVENT_CODE.enter, EVENT_CODE.numpadEnter, EVENT_CODE.space].includes(event.code)) {
event.preventDefault();
event.stopImmediatePropagation();
handleClick(event);
return;
}

if (event.code === EVENT_CODE.tab && event.shiftKey) {
rovingFocusContext?.onItemShiftTab?.();
return;
}
rovingFocusContext?.onKeydown?.(event);
};

const handleClick = (event: Event) => {
if (computedDisabled.value) {
event.stopImmediatePropagation();
return;
}

emit('click', event);
if (event.type !== 'keydown' && event.defaultPrevented) {
return;
}

if (dropdownContext?.hideOnClick?.value) {
dropdownContext.handleClick?.();
}
dropdownContext?.commandHandler?.(props.command, instance, event);
};

const handlePointerMove = (event: PointerEvent) => {
emit('pointermove', event);
if (event.pointerType !== 'mouse') {
return;
}
if (computedDisabled.value) {
dropdownContext?.onItemLeave?.(event);
return;
}

const target = event.currentTarget as HTMLElement;
if (target === document.activeElement || target.contains(document.activeElement)) {
return;
}

dropdownContext?.onItemEnter?.(event);
if (!event.defaultPrevented) {
target.focus({ preventScroll: true });
}
};

const handlePointerLeave = (event: PointerEvent) => {
emit('pointerleave', event);
if (event.pointerType === 'mouse') {
dropdownContext?.onItemLeave?.(event);
}
};

onMounted(() => {
if (!itemRef.value) {
return;
}
collectionContext?.itemMap?.set(itemRef.value, {
ref: itemRef.value,
...attrs,
[COLLECTION_ITEM_SIGN]: '',
});
});

onBeforeUnmount(() => {
if (itemRef.value) {
collectionContext?.itemMap?.delete(itemRef.value);
}
});

defineExpose({
setPermissionDisabled: (disabled: boolean) => {
permissionDisabled.value = disabled;
},
});
</script>

<style scoped>
.fu-dropdown-item--permission-disabled {
opacity: 0.45;
cursor: not-allowed;
}
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
trigger="hover"
@command="handleMoreCommand(row, $index, $event)"
>
<el-button v-permission link type="primary" :disabled="disabled">
<el-button link type="primary" :disabled="disabled">
{{ t('tabs.more') }}
</el-button>
<template #dropdown>
Expand Down
15 changes: 6 additions & 9 deletions frontend/src/views/ai/agents/agent/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,27 +39,24 @@
<Status v-permission :status="row.status" :operate="true" />
<template #dropdown>
<el-dropdown-menu>
<fu-dropdown-item
v-permission
<el-dropdown-item
:disabled="checkStatus('start', row)"
@click="onOperate(row, 'start')"
>
{{ $t('commons.operate.start') }}
</fu-dropdown-item>
<fu-dropdown-item
v-permission
</el-dropdown-item>
<el-dropdown-item
:disabled="checkStatus('stop', row)"
@click="onOperate(row, 'stop')"
>
{{ $t('commons.operate.stop') }}
</fu-dropdown-item>
<fu-dropdown-item
v-permission
</el-dropdown-item>
<el-dropdown-item
:disabled="checkStatus('restart', row)"
@click="onOperate(row, 'restart')"
>
{{ $t('commons.button.restart') }}
</fu-dropdown-item>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
Expand Down
15 changes: 6 additions & 9 deletions frontend/src/views/container/compose/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -87,29 +87,26 @@
/>
<template #dropdown>
<el-dropdown-menu>
<fu-dropdown-item
v-permission
<el-dropdown-item
:disabled="
row.containerCount === row.runningCount &&
row.runningCount > 0
"
@click="handleComposeOperate('up', row)"
>
{{ $t('commons.operate.start') }}
</fu-dropdown-item>
<fu-dropdown-item
v-permission
</el-dropdown-item>
<el-dropdown-item
:disabled="row.runningCount === 0"
@click="handleComposeOperate('stop', row)"
>
{{ $t('commons.operate.stop') }}
</fu-dropdown-item>
<fu-dropdown-item
v-permission
</el-dropdown-item>
<el-dropdown-item
@click="handleComposeOperate('restart', row)"
>
{{ $t('commons.button.restart') }}
</fu-dropdown-item>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
Expand Down
30 changes: 12 additions & 18 deletions frontend/src/views/container/container/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -149,48 +149,42 @@
<Status v-permission :status="row.state" :operate="true" />
<template #dropdown>
<el-dropdown-menu v-if="activeDropdownContainerId === row.containerID">
<fu-dropdown-item
v-permission
<el-dropdown-item
:disabled="checkStatus('start', row)"
@click="onOperate('start', row)"
Comment on lines +152 to 154
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep permission guard on status actions

When a user only has view permission, v-permission disables the Status button but the dropdown trigger is still attached to the wrapping component/root, so the hover dropdown can still open. These replacement el-dropdown-items no longer have v-permission/FuDropdownItem, so any action allowed by checkStatus remains clickable and calls onOperate, allowing read-only users to start/stop/restart containers from this menu. Keep the permission directive on the action items or disable the dropdown itself for non-manage users.

Useful? React with 👍 / 👎.

>
{{ $t('commons.operate.start') }}
</fu-dropdown-item>
<fu-dropdown-item
v-permission
</el-dropdown-item>
<el-dropdown-item
:disabled="checkStatus('stop', row)"
@click="onOperate('stop', row)"
>
{{ $t('commons.operate.stop') }}
</fu-dropdown-item>
<fu-dropdown-item
v-permission
</el-dropdown-item>
<el-dropdown-item
:disabled="checkStatus('restart', row)"
@click="onOperate('restart', row)"
>
{{ $t('commons.button.restart') }}
</fu-dropdown-item>
<fu-dropdown-item
v-permission
</el-dropdown-item>
<el-dropdown-item
:disabled="checkStatus('kill', row)"
@click="onOperate('kill', row)"
>
{{ $t('container.kill') }}
</fu-dropdown-item>
<fu-dropdown-item
v-permission
</el-dropdown-item>
<el-dropdown-item
:disabled="checkStatus('pause', row)"
@click="onOperate('pause', row)"
>
{{ $t('container.pause') }}
</fu-dropdown-item>
<fu-dropdown-item
v-permission
</el-dropdown-item>
<el-dropdown-item
:disabled="checkStatus('unpause', row)"
@click="onOperate('unpause', row)"
>
{{ $t('container.unpause') }}
</fu-dropdown-item>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/views/cronjob/library/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@
<fu-dropdown-item v-permission command="sync">
{{ $t('cronjob.library.syncNow') }}
</fu-dropdown-item>
<fu-dropdown-item v-permission v-if="scriptSync === 'Disable'" command="turnOnSync">
<fu-dropdown-item v-if="scriptSync === 'Disable'" v-permission command="turnOnSync">
{{ $t('cronjob.library.turnOnSync') }}
</fu-dropdown-item>
<fu-dropdown-item v-permission v-if="scriptSync === 'Enable'" command="turnOffSync">
<fu-dropdown-item v-if="scriptSync === 'Enable'" v-permission command="turnOffSync">
{{ $t('cronjob.library.turnOffSync') }}
</fu-dropdown-item>
</el-dropdown-menu>
Expand Down
Loading
Loading