feat: 顶栏与用户下拉框按钮样式统一与 tooltip 修复

- 修复 VbenTooltip 的 :content prop 类型错误,改用默认插槽
- 修复 VbenIconButton 的 onClick prop 支持数组合并
- VbenFullScreen 新增 tooltip prop,支持直接设置提示
- 统一顶栏与用户下拉框所有按钮样式,使用 VbenIconButton 包装
- 移除 i18n 中多余的"启用"字样(全局搜索/语言切换)
- 修复 VbenPopover PopoverTrigger 嵌套 button 导致 tooltip 不显示

Co-Authored-By: AtomCode (GLM-5.2) <noreply@atomgit.com>
This commit is contained in:
金毛88
2026-07-21 22:36:42 +08:00
18 changed files with 614 additions and 224 deletions

View File

@@ -70,6 +70,7 @@
"@changesets/cli": "catalog:",
"@tsdown/css": "catalog:",
"@types/node": "catalog:",
"@types/sortablejs": "catalog:",
"@vben/commitlint-config": "workspace:*",
"@vben/eslint-config": "workspace:*",
"@vben/oxfmt-config": "workspace:*",
@@ -106,5 +107,8 @@
"node": "^22.18.0 || ^24.0.0",
"pnpm": ">=11.0.0"
},
"packageManager": "pnpm@11.7.0"
"packageManager": "pnpm@11.7.0",
"dependencies": {
"sortablejs": "catalog:"
}
}

View File

@@ -10,16 +10,18 @@ type LayoutType =
type ThemeModeType = 'auto' | 'dark' | 'light';
/**
* 按钮位置
* user-dropdown 用户的下拉弹出框中
* fixed 固定在右侧
* 偏好设置按钮位置
* auto 自动(按布局上下文在 header/fixed 间切换)
* fixed 固定在屏幕右边缘
* header 顶栏
* auto 自动
* user-dropdown 用户的下拉弹出框中
* none 不显示
*/
type PreferencesButtonPositionType =
| 'auto'
| 'fixed'
| 'header'
| 'none'
| 'user-dropdown';
type BuiltinThemeType =

View File

@@ -154,6 +154,17 @@ const defaultPreferences: Preferences = {
themeToggleButtonPosition: 'header',
timezone: true,
timezoneButtonPosition: 'header',
order: [
'globalSearch',
'preferences',
'themeToggle',
'languageToggle',
'timezone',
'fullscreen',
'notification',
'lockScreenBtn',
'logoutBtn',
],
},
};

View File

@@ -11,7 +11,11 @@ import type {
import { markRaw, reactive, readonly, watch } from 'vue';
import { StorageManager } from '@vben-core/shared/cache';
import { isMacOs, merge } from '@vben-core/shared/utils';
import {
isMacOs,
merge,
mergeWithArrayOverride,
} from '@vben-core/shared/utils';
import {
breakpointsTailwind,
@@ -133,7 +137,11 @@ class PreferenceManager {
// 加载缓存的偏好设置,并仅用缓存补齐初始化配置中未显式设置的字段
const cachedPreferences = (await this.loadFromCache()) || {};
const mergedPreference = merge(
// 修复历史脏数据:早期版本用 defu(merge) 合并数组,导致 widget.order
// 在每次 updatePreferences 时被 concat 追加,膨胀到几十/上百条重复 key。
// 这里在加载缓存后做一次去重,保留首次出现的 key。
this.sanitizeCachedArray(cachedPreferences, 'widget', 'order');
const mergedPreference = mergeWithArrayOverride(
{},
cachedPreferences, // 用户缓存的设置优先
this.initialPreferences, // 初始设置仅补齐缺失字段
@@ -144,7 +152,7 @@ class PreferenceManager {
const cachedCustom = (await this.loadCustomFromCache()) || {};
this.replaceCustomPreferences(
merge(
mergeWithArrayOverride(
{},
this.sanitizeCustomPreferences(cachedCustom),
this.initialCustomPreferences,
@@ -194,7 +202,7 @@ class PreferenceManager {
}
this.replaceCustomPreferences(
merge({}, sanitizedUpdates, markRaw(this.customState)),
mergeWithArrayOverride({}, sanitizedUpdates, markRaw(this.customState)),
);
this.debouncedSave();
};
@@ -205,7 +213,14 @@ class PreferenceManager {
*/
updatePreferences = (updates: DeepPartial<Preferences>) => {
// 深度合并更新内容和当前状态
const mergedState = merge({}, updates, markRaw(this.state));
// 注意:必须用 mergeWithArrayOverride 而不是 merge。
// defu(merge) 对数组的语义是 concat 追加,会导致 widget.order 等
// 数组字段在每次 updatePreferences 时被重复追加,指数级膨胀。
const mergedState = mergeWithArrayOverride(
{},
updates,
markRaw(this.state),
);
Object.assign(this.state, mergedState);
// 根据更新的值执行更新
@@ -361,6 +376,28 @@ class PreferenceManager {
: undefined;
}
/**
* 清理缓存中膨胀的数组字段(保留首次出现的元素)
* 用于修复早期版本 defu 合并数组时 concat 追加导致的脏数据
*/
private sanitizeCachedArray(
cached: Record<string, any>,
group: string,
field: string,
) {
const node = cached?.[group]?.[field];
if (!Array.isArray(node) || node.length <= 1) return;
const seen = new Set<unknown>();
const deduped = node.filter((item) => {
if (seen.has(item)) return false;
seen.add(item);
return true;
});
if (deduped.length !== node.length) {
cached[group][field] = deduped;
}
}
private sanitizeCustomPreferences(
updates: DeepPartial<CustomPreferencesRecord>,
) {

View File

@@ -385,6 +385,8 @@ interface WidgetPreferences {
notification: boolean;
/** 通知按钮位置 */
notificationButtonPosition: 'header' | 'none' | 'user-dropdown';
/** 小部件排序 */
order: readonly string[];
/** 显示刷新按钮 */
refresh: boolean;
/** 刷新按钮位置 */

View File

@@ -12,7 +12,7 @@ import VbenButton from './button.vue';
interface Props extends VbenButtonProps {
class?: any;
disabled?: boolean;
onClick?: () => void;
onClick?: (() => void)[] | (() => void);
tooltip?: string;
tooltipDelayDuration?: number;
tooltipSide?: 'bottom' | 'left' | 'right' | 'top';
@@ -30,6 +30,16 @@ const props = withDefaults(defineProps<Props>(), {
const slots = useSlots();
const showTooltip = computed(() => !!slots.tooltip || !!props.tooltip);
function handleClick() {
if (Array.isArray(props.onClick)) {
for (const fn of props.onClick) {
fn?.();
}
} else {
props.onClick?.();
}
}
</script>
<template>
@@ -39,7 +49,7 @@ const showTooltip = computed(() => !!slots.tooltip || !!props.tooltip);
:disabled="disabled"
:variant="variant"
size="icon"
@click="onClick"
@click="handleClick"
>
<slot></slot>
</VbenButton>
@@ -55,7 +65,7 @@ const showTooltip = computed(() => !!slots.tooltip || !!props.tooltip);
:disabled="disabled"
:variant="variant"
size="icon"
@click="onClick"
@click="handleClick"
>
<slot></slot>
</VbenButton>

View File

@@ -7,6 +7,15 @@ import { VbenIconButton } from '../button';
defineOptions({ name: 'FullScreen' });
withDefaults(
defineProps<{
tooltip?: string;
}>(),
{
tooltip: '',
},
);
const { isFullscreen, toggle } = useFullscreen();
// 重新检查全屏状态
@@ -22,6 +31,7 @@ isFullscreen.value = !!(
</script>
<template>
<VbenIconButton
:tooltip="tooltip || undefined"
class="hover:animate-[shrink_0.3s_ease-in-out]"
@click="toggle"
>

View File

@@ -45,7 +45,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
<template>
<PopoverRoot v-bind="forwarded">
<PopoverTrigger :class="triggerClass">
<PopoverTrigger as="span" :class="triggerClass">
<slot name="trigger"></slot>
<PopoverContent

View File

@@ -8,7 +8,11 @@ import { preferences, usePreferences } from '@vben/preferences';
import { useAccessStore } from '@vben/stores';
import { useVbenModal } from '@vben-core/popup-ui';
import { VbenFullScreen, VbenIconButton } from '@vben-core/shadcn-ui';
import {
VbenFullScreen,
VbenIconButton,
VbenTooltip,
} from '@vben-core/shadcn-ui';
import { useMagicKeys, whenever } from '@vueuse/core';
@@ -16,6 +20,7 @@ import {
GlobalSearch,
LanguageToggle,
LockScreenModal,
Notification,
PreferencesButton,
ThemeToggle,
TimezoneButton,
@@ -121,94 +126,77 @@ type SlotItem = { index: number; name: string };
const rightSlots = computed(() => {
const list: Array<SlotItem> = [];
// 全局搜索
if (
preferences.widget.globalSearch &&
preferences.widget.globalSearchButtonPosition === 'header'
) {
list.push({
index: REFERENCE_VALUE,
name: 'global-search',
});
}
// 偏好设置快捷功能
if (preferencesButtonPosition.value.header) {
list.push({
index: REFERENCE_VALUE + 10,
name: 'preferences',
});
}
// 主题、语言、时区等子功能由各自的 widget 开关独立控制,
// 不应跟随偏好设置按钮的显示与否(如 enablePreferences 为 false 或按钮位于其他位置时)
if (
preferences.widget.themeToggle &&
preferences.widget.themeToggleButtonPosition === 'header'
) {
list.push({
index: REFERENCE_VALUE + 20,
name: 'theme-toggle',
});
}
if (
preferences.widget.languageToggle &&
preferences.widget.languageToggleButtonPosition === 'header'
) {
list.push({
index: REFERENCE_VALUE + 30,
name: 'language-toggle',
});
}
if (
preferences.widget.timezone &&
preferences.widget.timezoneButtonPosition === 'header'
) {
list.push({
index: REFERENCE_VALUE + 40,
name: 'timezone',
});
}
// 全屏
if (
preferences.widget.fullscreen &&
preferences.widget.fullscreenButtonPosition === 'header'
) {
list.push({
index: REFERENCE_VALUE + 50,
name: 'fullscreen',
});
}
// 消息通知
if (
preferences.widget.notification &&
preferences.widget.notificationButtonPosition === 'header'
) {
list.push({
index: REFERENCE_VALUE + 60,
name: 'notification',
});
}
// 锁定屏幕(仅在 header 位置显示)
if (
preferences.widget.lockScreen &&
preferences.widget.lockScreenButtonPosition === 'header'
) {
list.push({
index: REFERENCE_VALUE + 70,
name: 'lock-screen-btn',
});
}
// 退出登录(仅在 header 位置显示)
if (preferences.widget.logoutButtonPosition === 'header') {
list.push({
index: REFERENCE_VALUE + 80,
name: 'logout-btn',
});
// 按 widget.order 顺序遍历,检查每个部件是否应在 header 中显示
const widgetChecks: Record<string, { slotName: string; visible: boolean }> = {
globalSearch: {
visible:
preferences.widget.globalSearch &&
preferences.widget.globalSearchButtonPosition === 'header',
slotName: 'global-search',
},
preferences: {
visible: preferencesButtonPosition.value.header,
slotName: 'preferences',
},
themeToggle: {
visible:
preferences.widget.themeToggle &&
preferences.widget.themeToggleButtonPosition === 'header',
slotName: 'theme-toggle',
},
languageToggle: {
visible:
preferences.widget.languageToggle &&
preferences.widget.languageToggleButtonPosition === 'header',
slotName: 'language-toggle',
},
timezone: {
visible:
preferences.widget.timezone &&
preferences.widget.timezoneButtonPosition === 'header',
slotName: 'timezone',
},
fullscreen: {
visible:
preferences.widget.fullscreen &&
preferences.widget.fullscreenButtonPosition === 'header',
slotName: 'fullscreen',
},
notification: {
visible:
preferences.widget.notification &&
preferences.widget.notificationButtonPosition === 'header',
slotName: 'notification',
},
lockScreenBtn: {
visible:
preferences.widget.lockScreen &&
preferences.widget.lockScreenButtonPosition === 'header',
slotName: 'lock-screen-btn',
},
logoutBtn: {
visible: preferences.widget.logoutButtonPosition === 'header',
slotName: 'logout-btn',
},
refresh: {
visible:
preferences.widget.refresh &&
preferences.widget.refreshButtonPosition === 'header',
slotName: 'refresh',
},
};
for (const key of preferences.widget.order) {
const check = widgetChecks[key];
if (check?.visible) {
list.push({ index: REFERENCE_VALUE + list.length, name: check.slotName });
}
}
// 用户插槽header-right-N追加在后面
Object.keys(slots).forEach((key) => {
// 适配插槽名称例如第一个插槽名header-right-1
if (key.startsWith('header-right')) {
// 取第三个占位的数字,若是第三个占位不是数字,则自动分配排序索引
const slotIndex = Number(key.split('-')[2]);
const index = Number.isNaN(slotIndex) ? nextIndex(list) : slotIndex;
list.push({ index, name: key });
@@ -223,16 +211,6 @@ const rightSlots = computed(() => {
const leftSlots = computed(() => {
const list: Array<SlotItem> = [];
// 刷新
if (
preferences.widget.refresh &&
preferences.widget.refreshButtonPosition === 'header'
) {
list.push({
index: 0,
name: 'refresh',
});
}
Object.keys(slots).forEach((key) => {
// 适配插槽名称例如第一个插槽名header-left-1
@@ -322,19 +300,37 @@ function clearPreferencesAndLogout() {
</template>
<template v-else-if="slot.name === 'preferences'">
<PreferencesButton
class="mr-1"
@clear-preferences-and-logout="clearPreferencesAndLogout"
/>
<VbenTooltip side="bottom">
<template #trigger>
<PreferencesButton
class="mr-1"
@clear-preferences-and-logout="clearPreferencesAndLogout"
/>
</template>
{{ $t('preferences.title') }}
</VbenTooltip>
</template>
<template v-else-if="slot.name === 'theme-toggle'">
<ThemeToggle class="mt-0.5 mr-1" />
<VbenTooltip side="bottom">
<template #trigger>
<ThemeToggle class="mt-0.5 mr-1" />
</template>
{{ $t('preferences.theme.title') }}
</VbenTooltip>
</template>
<template v-else-if="slot.name === 'language-toggle'">
<LanguageToggle class="mr-1" />
<VbenTooltip side="bottom">
<template #trigger>
<LanguageToggle class="mr-1" />
</template>
{{ $t('preferences.widget.languageToggle') }}
</VbenTooltip>
</template>
<template v-else-if="slot.name === 'fullscreen'">
<VbenFullScreen class="mr-1" />
<VbenFullScreen
class="mr-1"
:tooltip="$t('preferences.widget.fullscreen')"
/>
</template>
<template v-else-if="slot.name === 'timezone'">
<TimezoneButton class="mt-0.5 mr-1" />
@@ -357,6 +353,23 @@ function clearPreferencesAndLogout() {
<LogOut class="size-4" />
</VbenIconButton>
</template>
<template v-else-if="slot.name === 'notification'">
<VbenTooltip side="bottom">
<template #trigger>
<Notification class="mr-1" />
</template>
{{ $t('preferences.widget.notification') }}
</VbenTooltip>
</template>
<template v-else-if="slot.name === 'refresh'">
<VbenIconButton
class="my-0 mr-1 rounded-md"
:tooltip="$t('preferences.widget.refresh')"
@click="refresh"
>
<RotateCw class="size-4" />
</VbenIconButton>
</template>
</slot>
</template>
</div>

View File

@@ -369,9 +369,6 @@ const headerSlots = computed(() => {
<template #notification>
<slot name="notification"></slot>
</template>
<template #timezone>
<slot name="timezone"></slot>
</template>
<template v-for="item in headerSlots" #[item]>
<slot :name="item"></slot>
</template>

View File

@@ -24,10 +24,16 @@ defineOptions({
});
const props = withDefaults(
defineProps<{ enableShortcutKey?: boolean; menus?: MenuRecordRaw[] }>(),
defineProps<{
enableShortcutKey?: boolean;
menus?: MenuRecordRaw[];
/** 是否显示触发按钮(嵌在 dropdown 等容器里时可关掉) */
showButton?: boolean;
}>(),
{
enableShortcutKey: true,
menus: () => [],
showButton: true,
},
);
@@ -94,6 +100,13 @@ onMounted(() => {
window.removeEventListener('keydown', preventDefaultBrowserSearchHotKey);
});
});
// 暴露给父组件,允许从外部触发搜索弹框(如 user-dropdown 里点击菜单项触发)
defineExpose({
open: () => modalApi.open(),
close: () => modalApi.close(),
toggle: toggleOpen,
});
</script>
<template>
@@ -135,6 +148,7 @@ onMounted(() => {
</template>
</Modal>
<div
v-if="showButton"
class="group flex h-8 cursor-pointer items-center gap-3 rounded-2xl border-none bg-none px-2 py-0.5 outline-hidden md:bg-accent"
@click="toggleOpen()"
>

View File

@@ -0,0 +1,140 @@
<script setup lang="ts">
import type { SelectOption } from '@vben/types';
import { computed, nextTick, onMounted, ref } from 'vue';
import { GripVertical } from '@vben/icons';
import { $t } from '@vben/locales';
import Sortable from 'sortablejs';
interface Item {
key: string;
label: string;
position: 'auto' | 'fixed' | 'header' | 'none' | 'user-dropdown';
/** 每项可选的 position 选项;不传则用 props.positionItems */
positionItems?: SelectOption[];
}
const props = defineProps<{
items: Item[];
positionItems: SelectOption[];
}>();
const emit = defineEmits<{
updateOrder: [keys: string[]];
updatePosition: [
key: string,
position: 'auto' | 'fixed' | 'header' | 'none' | 'user-dropdown',
];
}>();
const listRef = ref<HTMLElement>();
let sortableInstance: null | Sortable = null;
// 划分规则:
// - hiddenposition === 'none'(不显示)
// - sortable其它含 auto/fixed偏好按钮的智能模式视觉归入顶栏组参与排序
// 避免 preferences 从 widget.order 丢失)
const sortableList = computed(() =>
props.items.filter((item) => item.position !== 'none'),
);
const hiddenList = computed(() =>
props.items.filter((item) => item.position === 'none'),
);
function initSortable() {
if (!listRef.value) return;
sortableInstance?.destroy();
sortableInstance = Sortable.create(listRef.value, {
animation: 200,
handle: '.drag-handle',
onEnd() {
// Sortable 已经改了 DOM但 sortableList computed 还是旧顺序。
// 直接从 DOM 读 children 的 data-key 拿新顺序,再追加 hidden 部分。
const newOrder = [...listRef.value!.children]
.map((el) => (el as HTMLElement).dataset.key)
.filter(Boolean) as string[];
emit('updateOrder', [...newOrder, ...hiddenList.value.map((i) => i.key)]);
},
});
}
onMounted(initSortable);
function setPosition(key: string, event: Event) {
const value = (event.target as HTMLSelectElement).value as
| 'auto'
| 'fixed'
| 'header'
| 'none'
| 'user-dropdown';
emit('updatePosition', key, value);
nextTick(() => {
emit(
'updateOrder',
[...sortableList.value, ...hiddenList.value].map((i) => i.key),
);
});
}
</script>
<template>
<div class="space-y-1">
<div ref="listRef" class="space-y-1">
<div
v-for="item in sortableList"
:key="item.key"
:data-key="item.key"
class="bg-accent flex items-center gap-2 rounded-md px-2 py-1.5"
>
<GripVertical
class="drag-handle size-4 shrink-0 cursor-grab text-muted-foreground active:cursor-grabbing"
/>
<span class="min-w-0 flex-1 truncate text-sm">{{ item.label }}</span>
<select
:value="item.position"
class="bg-background h-7 w-28 shrink-0 rounded border px-1 text-xs"
@change="(e) => setPosition(item.key, e)"
>
<option
v-for="opt in item.positionItems ?? positionItems"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
</div>
</div>
<div v-if="hiddenList.length > 0" class="pt-2">
<div class="text-muted-foreground mb-1 text-xs font-medium">
{{ $t('preferences.widget.hidden') }}
</div>
<div
v-for="item in hiddenList"
:key="item.key"
class="flex items-center gap-2 rounded-md px-2 py-1"
>
<span
class="text-muted-foreground min-w-0 flex-1 truncate text-sm line-through decoration-dotted"
>
{{ item.label }}
</span>
<select
:value="item.position"
class="bg-background h-7 w-28 shrink-0 rounded border px-1 text-xs"
@change="(e) => setPosition(item.key, e)"
>
<option
v-for="opt in item.positionItems ?? positionItems"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
</div>
</div>
</div>
</template>

View File

@@ -1,16 +1,18 @@
<script setup lang="ts">
import type { SelectOption } from '@vben/types';
import type { PreferencesButtonPositionType, SelectOption } from '@vben/types';
import { computed } from 'vue';
import { $t } from '@vben/locales';
import SelectItem from '../select-item.vue';
import DraggableList from '../draggable-list.vue';
defineOptions({
name: 'PreferenceInterfaceControl',
});
const widgetOrder = defineModel<string[]>('widgetOrder', { required: true });
const widgetGlobalSearchButtonPosition = defineModel<string>(
'widgetGlobalSearchButtonPosition',
);
@@ -32,17 +34,37 @@ const widgetLockScreenButtonPosition = defineModel<string>(
const widgetLogoutButtonPosition = defineModel<string>(
'widgetLogoutButtonPosition',
);
const appPreferencesButtonPosition = defineModel<string>(
'appPreferencesButtonPosition',
);
const widgetRefreshButtonPosition = defineModel<string>(
'widgetRefreshButtonPosition',
);
const widgetTimezoneButtonPosition = defineModel<string>(
'widgetTimezoneButtonPosition',
);
const appPreferencesButtonPosition = defineModel<PreferencesButtonPositionType>(
'appPreferencesButtonPosition',
);
const positionItems = computed((): SelectOption[] => [
const buttonPositionItems = computed((): SelectOption[] => [
{
label: $t('preferences.widget.header'),
value: 'header',
},
{
label: $t('preferences.widget.userDropdown'),
value: 'user-dropdown',
},
{
label: $t('common.notShow'),
value: 'none',
},
]);
/**
* preferences 按钮独享的位置选项:
* 保留 auto/fixed 让 use-preferences.ts 的智能 fallback 生效
* (移动端/全屏内容模式下自动切换 header/fixed避免偏好入口死锁
*/
const preferencesPositionItems = computed((): SelectOption[] => [
{
label: $t('preferences.position.auto'),
value: 'auto',
@@ -59,77 +81,99 @@ const positionItems = computed((): SelectOption[] => [
label: $t('preferences.position.userDropdown'),
value: 'user-dropdown',
},
]);
const buttonPositionItems = computed((): SelectOption[] => [
{
label: $t('preferences.widget.header'),
value: 'header',
},
{
label: $t('preferences.widget.userDropdown'),
value: 'user-dropdown',
},
{
label: $t('common.notShow'),
value: 'none',
},
]);
const positionMap: Record<string, string> = {
globalSearch: 'widgetGlobalSearchButtonPosition',
preferences: 'appPreferencesButtonPosition',
themeToggle: 'widgetThemeToggleButtonPosition',
languageToggle: 'widgetLanguageToggleButtonPosition',
timezone: 'widgetTimezoneButtonPosition',
fullscreen: 'widgetFullscreenButtonPosition',
notification: 'widgetNotificationButtonPosition',
lockScreenBtn: 'widgetLockScreenButtonPosition',
logoutBtn: 'widgetLogoutButtonPosition',
refresh: 'widgetRefreshButtonPosition',
};
const labelMap: Record<string, string> = {
globalSearch: 'preferences.widget.globalSearch',
preferences: 'preferences.title',
themeToggle: 'preferences.widget.themeToggle',
languageToggle: 'preferences.widget.languageToggle',
timezone: 'preferences.widget.timezone',
fullscreen: 'preferences.widget.fullscreen',
notification: 'preferences.widget.notification',
lockScreenBtn: 'ui.widgets.lockScreen.title',
logoutBtn: 'common.logout',
refresh: 'preferences.widget.refresh',
};
const draggableItems = computed(() =>
(widgetOrder.value ?? []).map((key) => ({
key,
label: $t(labelMap[key] ?? key),
position: getPosition(key),
positionItems:
key === 'preferences' ? preferencesPositionItems.value : undefined,
})),
);
function getPosition(
key: string,
): 'auto' | 'fixed' | 'header' | 'none' | 'user-dropdown' {
const modelName = positionMap[key];
if (!modelName) return 'none';
const modelMap: Record<string, any> = {
widgetGlobalSearchButtonPosition,
widgetFullscreenButtonPosition,
widgetLanguageToggleButtonPosition,
widgetNotificationButtonPosition,
widgetThemeToggleButtonPosition,
widgetLockScreenButtonPosition,
widgetLogoutButtonPosition,
widgetRefreshButtonPosition,
widgetTimezoneButtonPosition,
appPreferencesButtonPosition,
};
return modelMap[modelName]?.value ?? 'none';
}
function handleUpdateOrder(keys: string[]) {
widgetOrder.value = keys;
}
function handleUpdatePosition(
key: string,
position: 'auto' | 'fixed' | 'header' | 'none' | 'user-dropdown',
) {
const modelName = positionMap[key];
if (!modelName) return;
const modelMap: Record<string, any> = {
widgetGlobalSearchButtonPosition,
widgetFullscreenButtonPosition,
widgetLanguageToggleButtonPosition,
widgetNotificationButtonPosition,
widgetThemeToggleButtonPosition,
widgetLockScreenButtonPosition,
widgetLogoutButtonPosition,
widgetRefreshButtonPosition,
widgetTimezoneButtonPosition,
appPreferencesButtonPosition,
};
modelMap[modelName].value = position;
}
</script>
<template>
<SelectItem
v-model="widgetGlobalSearchButtonPosition"
:items="buttonPositionItems"
>
{{ $t('preferences.widget.globalSearchPosition') }}
</SelectItem>
<SelectItem
v-model="widgetThemeToggleButtonPosition"
:items="buttonPositionItems"
>
{{ $t('preferences.widget.themeTogglePosition') }}
</SelectItem>
<SelectItem
v-model="widgetLanguageToggleButtonPosition"
:items="buttonPositionItems"
>
{{ $t('preferences.widget.languageTogglePosition') }}
</SelectItem>
<SelectItem
v-model="widgetFullscreenButtonPosition"
:items="buttonPositionItems"
>
{{ $t('preferences.widget.fullscreenPosition') }}
</SelectItem>
<SelectItem
v-model="widgetNotificationButtonPosition"
:items="buttonPositionItems"
>
{{ $t('preferences.widget.notificationPosition') }}
</SelectItem>
<SelectItem
v-model="widgetLockScreenButtonPosition"
:items="buttonPositionItems"
>
{{ $t('preferences.widget.lockScreenPosition') }}
</SelectItem>
<SelectItem v-model="widgetLogoutButtonPosition" :items="buttonPositionItems">
{{ $t('preferences.widget.logoutButtonPosition') }}
</SelectItem>
<SelectItem
v-model="widgetRefreshButtonPosition"
:items="buttonPositionItems"
>
{{ $t('preferences.widget.refreshPosition') }}
</SelectItem>
<SelectItem
v-model="widgetTimezoneButtonPosition"
:items="buttonPositionItems"
>
{{ $t('preferences.widget.timezonePosition') }}
</SelectItem>
<SelectItem v-model="appPreferencesButtonPosition" :items="positionItems">
{{ $t('preferences.position.title') }}
</SelectItem>
<DraggableList
:items="draggableItems"
:position-items="buttonPositionItems"
@update-order="handleUpdateOrder"
@update-position="handleUpdatePosition"
/>
</template>

View File

@@ -195,6 +195,7 @@ const widgetLockScreenButtonPosition = defineModel<string>(
const widgetLogoutButtonPosition = defineModel<string>(
'widgetLogoutButtonPosition',
);
const widgetOrder = defineModel<string[]>('widgetOrder', { required: true });
const widgetRefreshButtonPosition = defineModel<string>(
'widgetRefreshButtonPosition',
);
@@ -516,6 +517,7 @@ function handleCustomPreferencesUpdate(updates: CustomPreferencesRecord) {
v-model:widget-logout-button-position="
widgetLogoutButtonPosition
"
v-model:widget-order="widgetOrder"
v-model:widget-notification-button-position="
widgetNotificationButtonPosition
"

View File

@@ -3,23 +3,24 @@ import type { Component } from 'vue';
import type { AnyFunction } from '@vben/types';
import { computed, useTemplateRef, watch } from 'vue';
import { computed, ref, useTemplateRef, watch } from 'vue';
import { useHoverToggle } from '@vben/hooks';
import { SUPPORT_LANGUAGES } from '@vben/constants';
import { useHoverToggle, useRefresh } from '@vben/hooks';
import {
Bell,
Fullscreen,
Languages,
LockKeyhole,
LogOut,
RotateCw,
Search,
Settings,
Sun,
SunMoon,
} from '@vben/icons';
import { $t } from '@vben/locales';
import { preferences, usePreferences } from '@vben/preferences';
import { $t, loadLocaleMessages } from '@vben/locales';
import {
preferences,
updatePreferences,
usePreferences,
} from '@vben/preferences';
import { useAccessStore } from '@vben/stores';
import { isWindowsOs } from '@vben/utils';
@@ -34,13 +35,19 @@ import {
DropdownMenuShortcut,
DropdownMenuTrigger,
VbenAvatar,
VbenFullScreen,
VbenIcon,
VbenIconButton,
} from '@vben-core/shadcn-ui';
import { useMagicKeys, whenever } from '@vueuse/core';
import { GlobalSearch } from '../global-search';
import { LockScreenModal } from '../lock-screen';
import { Notification } from '../notification';
import { Preferences } from '../preferences';
import { ThemeToggle } from '../theme-toggle';
import { TimezoneButton } from '../timezone';
interface Props {
/**
@@ -91,10 +98,12 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<{ clearPreferencesAndLogout: []; logout: [] }>();
const {
globalLockScreenShortcutKey,
globalLogoutShortcutKey,
globalLockScreenShortcutKey,
globalSearchShortcutKey,
preferencesButtonPosition,
} = usePreferences();
const { refresh } = useRefresh();
const accessStore = useAccessStore();
const [LockModal, lockModalApi] = useVbenModal({
connectedComponent: LockScreenModal,
@@ -108,6 +117,7 @@ const [LogoutModal, logoutModalApi] = useVbenModal({
const refTrigger = useTemplateRef('refTrigger');
const refContent = useTemplateRef('refContent');
const refPreferences = useTemplateRef('refPreferences');
const refGlobalSearch = useTemplateRef('refGlobalSearch');
const [openPopover, hoverWatcher] = useHoverToggle(
[refTrigger, refContent],
() => props.hoverDelay,
@@ -233,6 +243,32 @@ function handleOpenSettings() {
refPreferences.value?.open();
}
// 刷新当前页面
function handleRefresh() {
openPopover.value = false;
refresh();
}
// 搜索 - 不关闭 dropdown直接触发 GlobalSearch 组件的弹框
function handleGlobalSearch() {
refGlobalSearch.value?.open();
}
// 语言切换 - 阻止 Radix 默认关闭外层 dropdown就地展开/收起 locale 列表
const showLanguageList = ref(false);
function handleLanguageToggleSelect(event?: Event) {
event?.preventDefault();
showLanguageList.value = !showLanguageList.value;
}
async function handleLocaleChange(event: Event, value: 'en-US' | 'zh-CN') {
// 阻止默认关闭,让用户能继续看到选择结果;选完手动收起
event.preventDefault();
updatePreferences({ app: { locale: value } });
await loadLocaleMessages(value);
showLanguageList.value = false;
openPopover.value = false;
}
if (enableShortcutKey.value) {
const keys = useMagicKeys();
const logoutKey = keys['Alt+KeyQ'];
@@ -285,6 +321,14 @@ if (enableShortcutKey.value) {
@clear-preferences-and-logout="emit('clearPreferencesAndLogout')"
/>
<GlobalSearch
v-if="showGlobalSearchInDropdown"
ref="refGlobalSearch"
:enable-shortcut-key="globalSearchShortcutKey"
:menus="accessStore.accessMenus"
:show-button="false"
/>
<DropdownMenu v-model:open="openPopover" :modal="false">
<DropdownMenuTrigger ref="refTrigger" :disabled="props.trigger === 'hover'">
<div class="mr-2 ml-1 cursor-pointer rounded-full p-1.5 hover:bg-accent">
@@ -331,7 +375,9 @@ if (enableShortcutKey.value) {
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@click="menu.handler"
>
<VbenIcon :icon="menu.icon" class="mr-2 size-4" />
<VbenIconButton class="mr-2" @click="menu.handler">
<VbenIcon :icon="menu.icon" class="size-4" />
</VbenIconButton>
{{ menu.text }}
</DropdownMenuItem>
<template v-if="showLockInDropdown || showLogoutInDropdown">
@@ -341,7 +387,9 @@ if (enableShortcutKey.value) {
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@click="handleOpenLock"
>
<LockKeyhole class="mr-2 size-4" />
<VbenIconButton class="mr-2" @click="handleOpenLock">
<LockKeyhole class="size-4" />
</VbenIconButton>
{{ $t('ui.widgets.lockScreen.title') }}
<DropdownMenuShortcut v-if="enableLockScreenShortcutKey">
{{ altView }} L
@@ -353,7 +401,9 @@ if (enableShortcutKey.value) {
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@click="handleLogout"
>
<LogOut class="mr-2 size-4" />
<VbenIconButton class="mr-2" @click="handleLogout">
<LogOut class="size-4" />
</VbenIconButton>
{{ $t('common.logout') }}
<DropdownMenuShortcut v-if="enableLogoutShortcutKey">
{{ altView }} Q
@@ -375,50 +425,75 @@ if (enableShortcutKey.value) {
<DropdownMenuItem
v-if="showGlobalSearchInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@select="handleGlobalSearch"
>
<Search class="mr-2 size-4" />
<VbenIconButton class="mr-2" @click="handleGlobalSearch">
<Search class="size-4" />
</VbenIconButton>
{{ $t('preferences.widget.globalSearch') }}
</DropdownMenuItem>
<DropdownMenuItem
v-if="showThemeToggleInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
>
<Sun class="mr-2 size-4" />
{{ $t('preferences.widget.themeToggle') }}
<ThemeToggle class="mr-2" />
{{ $t('preferences.theme.title') }}
</DropdownMenuItem>
<DropdownMenuItem
v-if="showLanguageToggleInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@select="handleLanguageToggleSelect"
>
<Languages class="mr-2 size-4" />
<VbenIconButton class="mr-2" @click="handleLanguageToggleSelect">
<Languages class="size-4" />
</VbenIconButton>
{{ $t('preferences.widget.languageToggle') }}
</DropdownMenuItem>
<template v-if="showLanguageList">
<DropdownMenuItem
v-for="lang in SUPPORT_LANGUAGES"
:key="lang.value"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 pl-8 leading-8"
@select="(e: Event) => handleLocaleChange(e, lang.value)"
>
<span
:class="
lang.value === preferences.app.locale ? 'bg-foreground' : ''
"
class="mr-2 size-1.5 rounded-full"
></span>
{{ lang.label }}
</DropdownMenuItem>
</template>
<DropdownMenuItem
v-if="showTimezoneInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
>
<SunMoon class="mr-2 size-4" />
{{ $t('preferences.widget.timezone') }}
<TimezoneButton class="mr-2" />
{{ $t('ui.widgets.timezone.setTimezone') }}
</DropdownMenuItem>
<DropdownMenuItem
v-if="showFullscreenInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
>
<Fullscreen class="mr-2 size-4" />
<VbenFullScreen class="mr-2" />
{{ $t('preferences.widget.fullscreen') }}
</DropdownMenuItem>
<DropdownMenuItem
v-if="showNotificationInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
>
<Bell class="mr-2 size-4" />
<Notification class="mr-2" />
{{ $t('preferences.widget.notification') }}
</DropdownMenuItem>
<DropdownMenuItem
v-if="showRefreshInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@click="handleRefresh"
>
<RotateCw class="mr-2 size-4" />
<VbenIconButton class="mr-2" @click="handleRefresh">
<RotateCw class="size-4" />
</VbenIconButton>
{{ $t('preferences.widget.refresh') }}
</DropdownMenuItem>
</template>
@@ -430,7 +505,9 @@ if (enableShortcutKey.value) {
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@click="handleOpenSettings"
>
<Settings class="mr-2 size-4" />
<VbenIconButton class="mr-2" @click="handleOpenSettings">
<Settings class="size-4" />
</VbenIconButton>
{{ $t('preferences.title') }}
</DropdownMenuItem>
</div>

View File

@@ -192,13 +192,13 @@
},
"widget": {
"title": "Widget",
"globalSearch": "Enable Global Search",
"globalSearch": "Global Search",
"globalSearchPosition": "Global Search Position",
"fullscreen": "Enable Fullscreen",
"fullscreenPosition": "Fullscreen Position",
"themeToggle": "Enable Theme Toggle",
"themeTogglePosition": "Theme Toggle Position",
"languageToggle": "Enable Language Toggle",
"languageToggle": "Language Toggle",
"languageTogglePosition": "Language Toggle Position",
"notification": "Enable Notification",
"notificationPosition": "Notification Position",
@@ -208,6 +208,7 @@
"logoutButtonPosition": "Logout Button Position",
"header": "Header",
"userDropdown": "User Dropdown",
"hidden": "Hidden",
"refresh": "Enable Refresh",
"refreshPosition": "Refresh Position",
"timezone": "Enable Timezone",

View File

@@ -192,13 +192,13 @@
},
"widget": {
"title": "小部件",
"globalSearch": "启用全局搜索",
"globalSearch": "全局搜索",
"globalSearchPosition": "全局搜索位置",
"fullscreen": "启用全屏",
"fullscreenPosition": "全屏按钮位置",
"themeToggle": "启用主题切换",
"themeTogglePosition": "主题切换位置",
"languageToggle": "启用语言切换",
"languageToggle": "语言切换",
"languageTogglePosition": "语言切换位置",
"notification": "启用通知",
"notificationPosition": "通知位置",
@@ -208,6 +208,7 @@
"logoutButtonPosition": "退出按钮位置",
"header": "顶栏",
"userDropdown": "用户下拉窗",
"hidden": "已隐藏",
"refresh": "启用刷新",
"refreshPosition": "刷新按钮位置",
"timezone": "启用时区",

39
pnpm-lock.yaml generated
View File

@@ -524,6 +524,10 @@ overrides:
importers:
.:
dependencies:
sortablejs:
specifier: 'catalog:'
version: 1.15.7
devDependencies:
'@changesets/changelog-github':
specifier: 'catalog:'
@@ -537,6 +541,9 @@ importers:
'@types/node':
specifier: 'catalog:'
version: 25.9.4
'@types/sortablejs':
specifier: 'catalog:'
version: 1.15.9
'@vben/commitlint-config':
specifier: workspace:*
version: link:internal/lint-configs/commitlint-config
@@ -1000,14 +1007,14 @@ importers:
version: 2.10.0(vue@3.5.38(typescript@6.0.3))
vitepress-plugin-group-icons:
specifier: 'catalog:'
version: 1.7.5(vite@8.0.10(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))
version: 1.7.5(vite@8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))
devDependencies:
'@nolebase/vitepress-plugin-git-changelog':
specifier: 'catalog:'
version: 2.18.2(vitepress@2.0.0-alpha.17(@types/node@26.0.0)(async-validator@4.2.5)(axios@1.18.0)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(nprogress@0.2.0)(postcss@8.5.15)(qrcode@1.5.4)(sass-embedded@1.100.0)(sass@1.101.0)(sortablejs@1.15.7)(terser@5.48.0)(typescript@6.0.3)(yaml@2.9.0))(vue@3.5.38(typescript@6.0.3))
'@tailwindcss/vite':
specifier: 'catalog:'
version: 4.3.1(vite@8.0.10(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))
version: 4.3.1(vite@8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))
'@vben/tailwind-config':
specifier: workspace:*
version: link:../internal/tailwind-config
@@ -1016,7 +1023,7 @@ importers:
version: link:../internal/vite-config
'@vite-pwa/vitepress':
specifier: 'catalog:'
version: 1.1.0(vite-plugin-pwa@1.3.0(vite@8.0.10(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))(workbox-build@7.4.1)(workbox-window@7.4.1))
version: 1.1.0(vite-plugin-pwa@1.3.0(vite@8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))(workbox-build@7.4.1)(workbox-window@7.4.1))
vitepress:
specifier: 'catalog:'
version: 2.0.0-alpha.17(@types/node@26.0.0)(async-validator@4.2.5)(axios@1.18.0)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(nprogress@0.2.0)(postcss@8.5.15)(qrcode@1.5.4)(sass-embedded@1.100.0)(sass@1.101.0)(sortablejs@1.15.7)(terser@5.48.0)(typescript@6.0.3)(yaml@2.9.0)
@@ -14437,6 +14444,13 @@ snapshots:
tailwindcss: 4.3.1
vite: 8.0.10(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)
'@tailwindcss/vite@4.3.1(vite@8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))':
dependencies:
'@tailwindcss/node': 4.3.1
'@tailwindcss/oxide': 4.3.1
tailwindcss: 4.3.1
vite: 8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)
'@tanstack/match-sorter-utils@8.19.4':
dependencies:
remove-accents: 0.5.0
@@ -15149,9 +15163,9 @@ snapshots:
- rollup
- supports-color
'@vite-pwa/vitepress@1.1.0(vite-plugin-pwa@1.3.0(vite@8.0.10(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))(workbox-build@7.4.1)(workbox-window@7.4.1))':
'@vite-pwa/vitepress@1.1.0(vite-plugin-pwa@1.3.0(vite@8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))(workbox-build@7.4.1)(workbox-window@7.4.1))':
dependencies:
vite-plugin-pwa: 1.3.0(vite@8.0.10(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))(workbox-build@7.4.1)(workbox-window@7.4.1)
vite-plugin-pwa: 1.3.0(vite@8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))(workbox-build@7.4.1)(workbox-window@7.4.1)
'@vitejs/plugin-vue-jsx@5.1.5(vite@8.0.10(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.38(typescript@6.0.3))':
dependencies:
@@ -20568,6 +20582,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
vite-plugin-pwa@1.3.0(vite@8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))(workbox-build@7.4.1)(workbox-window@7.4.1):
dependencies:
debug: 4.4.3
pretty-bytes: 6.1.1
tinyglobby: 0.2.17
vite: 8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)
workbox-build: 7.4.1
workbox-window: 7.4.1
transitivePeerDependencies:
- supports-color
vite-plugin-vue-devtools@8.1.3(vite@8.0.10(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.38(typescript@6.0.3)):
dependencies:
'@vue/devtools-core': 8.1.3(vue@3.5.38(typescript@6.0.3))
@@ -20670,13 +20695,13 @@ snapshots:
terser: 5.48.0
yaml: 2.9.0
vitepress-plugin-group-icons@1.7.5(vite@8.0.10(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)):
vitepress-plugin-group-icons@1.7.5(vite@8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)):
dependencies:
'@iconify-json/logos': 1.2.11
'@iconify-json/vscode-icons': 1.2.58
'@iconify/utils': 3.1.3
optionalDependencies:
vite: 8.0.10(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)
vite: 8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass-embedded@1.100.0)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)
vitepress@2.0.0-alpha.17(@types/node@26.0.0)(async-validator@4.2.5)(axios@1.18.0)(jiti@2.7.0)(less@4.6.6)(lightningcss@1.32.0)(nprogress@0.2.0)(postcss@8.5.15)(qrcode@1.5.4)(sass-embedded@1.100.0)(sass@1.101.0)(sortablejs@1.15.7)(terser@5.48.0)(typescript@6.0.3)(yaml@2.9.0):
dependencies: