fix: prevent repeated menu activations

This commit is contained in:
t8y2
2026-05-16 19:52:31 +08:00
parent 1d77bac694
commit 320db12209
9 changed files with 193 additions and 36 deletions
@@ -5,6 +5,7 @@ import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { CheckIcon } from "lucide-vue-next";
import { ContextMenuCheckboxItem, ContextMenuItemIndicator, useForwardPropsEmits } from "reka-ui";
import { shouldSuppressRepeatedActivation, suppressEvent, type ActionActivationGuard } from "@/lib/actionActivation";
import { cn } from "@/lib/utils";
const props = defineProps<ContextMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>();
@@ -13,12 +14,21 @@ const emits = defineEmits<ContextMenuCheckboxItemEmits>();
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const activationGuard: ActionActivationGuard = {};
function guardRepeatedClick(event: MouseEvent) {
if (props.disabled) return;
if (shouldSuppressRepeatedActivation(activationGuard)) {
suppressEvent(event);
}
}
</script>
<template>
<ContextMenuCheckboxItem
data-slot="context-menu-checkbox-item"
v-bind="forwarded"
@click.capture="guardRepeatedClick"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
@@ -3,6 +3,7 @@ import type { ContextMenuItemEmits, ContextMenuItemProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { ContextMenuItem, useForwardPropsEmits } from "reka-ui";
import { shouldSuppressRepeatedActivation, suppressEvent, type ActionActivationGuard } from "@/lib/actionActivation";
import { cn } from "@/lib/utils";
const props = withDefaults(
@@ -22,6 +23,14 @@ const emits = defineEmits<ContextMenuItemEmits>();
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const activationGuard: ActionActivationGuard = {};
function guardRepeatedClick(event: MouseEvent) {
if (props.disabled) return;
if (shouldSuppressRepeatedActivation(activationGuard)) {
suppressEvent(event);
}
}
</script>
<template>
@@ -30,6 +39,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
:data-inset="inset ? '' : undefined"
:data-variant="variant"
v-bind="forwarded"
@click.capture="guardRepeatedClick"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive focus:*:[svg]:text-accent-foreground gap-2 rounded-md px-2 py-1 text-[13px] data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 group/context-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
@@ -5,6 +5,7 @@ import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { CheckIcon } from "lucide-vue-next";
import { ContextMenuItemIndicator, ContextMenuRadioItem, useForwardPropsEmits } from "reka-ui";
import { shouldSuppressRepeatedActivation, suppressEvent, type ActionActivationGuard } from "@/lib/actionActivation";
import { cn } from "@/lib/utils";
const props = defineProps<ContextMenuRadioItemProps & { class?: HTMLAttributes["class"] }>();
@@ -13,12 +14,21 @@ const emits = defineEmits<ContextMenuRadioItemEmits>();
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const activationGuard: ActionActivationGuard = {};
function guardRepeatedClick(event: MouseEvent) {
if (props.disabled) return;
if (shouldSuppressRepeatedActivation(activationGuard)) {
suppressEvent(event);
}
}
</script>
<template>
<ContextMenuRadioItem
data-slot="context-menu-radio-item"
v-bind="forwarded"
@click.capture="guardRepeatedClick"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
@@ -5,6 +5,7 @@ import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { CheckIcon } from "lucide-vue-next";
import { DropdownMenuCheckboxItem, DropdownMenuItemIndicator, useForwardPropsEmits } from "reka-ui";
import { shouldSuppressRepeatedActivation, suppressEvent, type ActionActivationGuard } from "@/lib/actionActivation";
import { cn } from "@/lib/utils";
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>();
@@ -13,12 +14,21 @@ const emits = defineEmits<DropdownMenuCheckboxItemEmits>();
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const activationGuard: ActionActivationGuard = {};
function guardRepeatedClick(event: MouseEvent) {
if (props.disabled) return;
if (shouldSuppressRepeatedActivation(activationGuard)) {
suppressEvent(event);
}
}
</script>
<template>
<DropdownMenuCheckboxItem
data-slot="dropdown-menu-checkbox-item"
v-bind="forwarded"
@click.capture="guardRepeatedClick"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
@@ -3,6 +3,7 @@ import type { DropdownMenuItemProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { DropdownMenuItem, useForwardProps } from "reka-ui";
import { shouldSuppressRepeatedActivation, suppressEvent, type ActionActivationGuard } from "@/lib/actionActivation";
import { cn } from "@/lib/utils";
const props = withDefaults(
@@ -21,6 +22,14 @@ const props = withDefaults(
const delegatedProps = reactiveOmit(props, "inset", "variant", "class");
const forwardedProps = useForwardProps(delegatedProps);
const activationGuard: ActionActivationGuard = {};
function guardRepeatedClick(event: MouseEvent) {
if (props.disabled) return;
if (shouldSuppressRepeatedActivation(activationGuard)) {
suppressEvent(event);
}
}
</script>
<template>
@@ -29,6 +38,7 @@ const forwardedProps = useForwardProps(delegatedProps);
:data-inset="inset ? '' : undefined"
:data-variant="variant"
v-bind="forwardedProps"
@click.capture="guardRepeatedClick"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
@@ -5,6 +5,7 @@ import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { CheckIcon } from "lucide-vue-next";
import { DropdownMenuItemIndicator, DropdownMenuRadioItem, useForwardPropsEmits } from "reka-ui";
import { shouldSuppressRepeatedActivation, suppressEvent, type ActionActivationGuard } from "@/lib/actionActivation";
import { cn } from "@/lib/utils";
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }>();
@@ -14,12 +15,21 @@ const emits = defineEmits<DropdownMenuRadioItemEmits>();
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
const activationGuard: ActionActivationGuard = {};
function guardRepeatedClick(event: MouseEvent) {
if (props.disabled) return;
if (shouldSuppressRepeatedActivation(activationGuard)) {
suppressEvent(event);
}
}
</script>
<template>
<DropdownMenuRadioItem
data-slot="dropdown-menu-radio-item"
v-bind="forwarded"
@click.capture="guardRepeatedClick"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=size-])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
+56 -36
View File
@@ -12,6 +12,7 @@ import {
} from "@/lib/gridSelection";
import { useToast } from "@/composables/useToast";
import { displayCellValue, type CellValue } from "@/lib/cellValue";
import { tryStartExclusiveActivation, type ActionActivationGuard } from "@/lib/actionActivation";
interface RowItem {
id: number;
@@ -46,6 +47,7 @@ export interface UseDataGridExportOptions {
export function useDataGridExport(options: UseDataGridExportOptions) {
const { t } = useI18n();
const { toast } = useToast();
const exportGuard: ActionActivationGuard = {};
const {
columns,
@@ -239,56 +241,74 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
}
// --- Export functions ---
async function exportCsv(rowIds?: number[]) {
async function runExclusiveExport(action: () => Promise<void>) {
const finish = tryStartExclusiveActivation(exportGuard);
if (!finish) return;
try {
const rows = rowsToExport(rowIds).map((item) => item.data.map((c) => displayCellValue(c)));
if (await saveFileContent(formatCsv(columns.value, rows), "export.csv", "CSV", "csv")) {
toast(t("grid.exported"));
}
} catch (e: any) {
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
await action();
} finally {
finish();
}
}
async function exportCsv(rowIds?: number[]) {
await runExclusiveExport(async () => {
try {
const rows = rowsToExport(rowIds).map((item) => item.data.map((c) => displayCellValue(c)));
if (await saveFileContent(formatCsv(columns.value, rows), "export.csv", "CSV", "csv")) {
toast(t("grid.exported"));
}
} catch (e: any) {
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
}
});
}
async function exportJson(rowIds?: number[]) {
try {
const rows = rowsToExport(rowIds).map((item) => item.data);
if (await saveFileContent(formatJson(columns.value, rows), "export.json", "JSON", "json")) {
toast(t("grid.exported"));
await runExclusiveExport(async () => {
try {
const rows = rowsToExport(rowIds).map((item) => item.data);
if (await saveFileContent(formatJson(columns.value, rows), "export.json", "JSON", "json")) {
toast(t("grid.exported"));
}
} catch (e: any) {
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
}
} catch (e: any) {
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
}
});
}
async function exportMarkdown(rowIds?: number[]) {
try {
const cols = columns.value;
const visibleRows = rowsToExport(rowIds).map((item) => item.data);
const { formatMarkdownTable } = await import("@/lib/markdownTable");
const md = formatMarkdownTable({ columns: cols, rows: visibleRows });
if (await saveFileContent(md, "export.md", "Markdown", "md")) {
toast(t("grid.exported"));
await runExclusiveExport(async () => {
try {
const cols = columns.value;
const visibleRows = rowsToExport(rowIds).map((item) => item.data);
const { formatMarkdownTable } = await import("@/lib/markdownTable");
const md = formatMarkdownTable({ columns: cols, rows: visibleRows });
if (await saveFileContent(md, "export.md", "Markdown", "md")) {
toast(t("grid.exported"));
}
} catch (e: any) {
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
}
} catch (e: any) {
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
}
});
}
async function exportXlsx(rowIds?: number[]) {
try {
const { buildXlsxWorkbook } = await import("@/lib/xlsxExport");
const workbook = buildXlsxWorkbook({
sheetName: tableMeta.value?.tableName || "Export",
columns: columns.value,
rows: rowsToExport(rowIds).map((item) => item.data),
});
if (await saveBinaryFileContent(workbook, "export.xlsx", "Excel", "xlsx")) {
toast(t("grid.exported"));
await runExclusiveExport(async () => {
try {
const { buildXlsxWorkbook } = await import("@/lib/xlsxExport");
const workbook = buildXlsxWorkbook({
sheetName: tableMeta.value?.tableName || "Export",
columns: columns.value,
rows: rowsToExport(rowIds).map((item) => item.data),
});
if (await saveBinaryFileContent(workbook, "export.xlsx", "Excel", "xlsx")) {
toast(t("grid.exported"));
}
} catch (e: any) {
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
}
} catch (e: any) {
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
}
});
}
function copySql() {
+42
View File
@@ -0,0 +1,42 @@
export const DEFAULT_REPEATED_ACTIVATION_WINDOW_MS = 500;
export interface ActionActivationGuard {
lastActivatedAt?: number;
running?: boolean;
}
function nowMs(): number {
if (typeof performance !== "undefined" && typeof performance.now === "function") {
return performance.now();
}
return Date.now();
}
export function shouldSuppressRepeatedActivation(
guard: ActionActivationGuard,
now = nowMs(),
windowMs = DEFAULT_REPEATED_ACTIVATION_WINDOW_MS,
): boolean {
if (guard.lastActivatedAt !== undefined && now - guard.lastActivatedAt < windowMs) {
return true;
}
guard.lastActivatedAt = now;
return false;
}
export function suppressEvent(event: Event) {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation?.();
}
export function tryStartExclusiveActivation(guard: ActionActivationGuard): (() => void) | null {
if (guard.running) return null;
guard.running = true;
let finished = false;
return () => {
if (finished) return;
finished = true;
guard.running = false;
};
}