Merge branch 'vbenjs:main' into main

This commit is contained in:
zhb410181
2026-07-22 14:59:16 +08:00
committed by GitHub
40 changed files with 4863 additions and 4478 deletions

3
.github/config.yml vendored
View File

@@ -21,12 +21,10 @@ newPRWelcomeComment: |
firstPRMergeComment: > firstPRMergeComment: >
Thanks for your contribution! 🎉🎉🎉 Thanks for your contribution! 🎉🎉🎉
# Comment to be posted to on first time issues # Comment to be posted to on first time issues
newIssueWelcomeComment: > newIssueWelcomeComment: >
Thanks for opening your first issue! Be sure to follow the issue template and provide every bit of information to help the developers! Thanks for opening your first issue! Be sure to follow the issue template and provide every bit of information to help the developers!
# *OPTIONAL* default titles to check against for lack of descriptiveness # *OPTIONAL* default titles to check against for lack of descriptiveness
# MUST BE ALL LOWERCASE # MUST BE ALL LOWERCASE
requestInfoDefaultTitles: requestInfoDefaultTitles:
@@ -36,4 +34,3 @@ requestInfoDefaultTitles:
# *Required* Comment to reply with # *Required* Comment to reply with
requestInfoReplyComment: > requestInfoReplyComment: >
Thanks for filing this issue/PR! It would be much appreciated if you could provide us with more information so we can effectively analyze the situation in context. Thanks for filing this issue/PR! It would be much appreciated if you could provide us with more information so we can effectively analyze the situation in context.

View File

@@ -19,6 +19,7 @@ export const MOCK_USERS: UserInfo[] = [
realName: 'Vben', realName: 'Vben',
roles: ['super'], roles: ['super'],
username: 'vben', username: 'vben',
homePath: '/dashboard/workspace',
}, },
{ {
id: 1, id: 1,
@@ -194,18 +195,46 @@ export const MOCK_MENUS = [
export const MOCK_MENU_LIST = [ export const MOCK_MENU_LIST = [
{ {
id: 1, id: 1,
name: 'Workspace', name: 'Dashboard',
status: 1, status: 1,
type: 'menu', type: 'catalog',
icon: 'mdi:dashboard', icon: 'lucide:layout-dashboard',
path: '/workspace', path: '/dashboard',
component: '/dashboard/workspace/index',
meta: { meta: {
icon: 'carbon:workspace', icon: 'lucide:layout-dashboard',
title: 'page.dashboard.workspace', order: -1,
affixTab: true, title: 'page.dashboard.title',
order: 0,
}, },
children: [
{
id: 101,
pid: 1,
status: 1,
type: 'menu',
name: 'Analytics',
path: 'analytics',
component: '/dashboard/analytics/index',
meta: {
affixTab: true,
icon: 'lucide:area-chart',
title: 'page.dashboard.analytics',
keepAlive: true,
},
},
{
id: 102,
pid: 1,
status: 1,
type: 'menu',
name: 'Workspace',
path: 'workspace',
component: '/views/dashboard/workspace/index',
meta: {
icon: 'carbon:workspace',
title: 'page.dashboard.workspace',
},
},
],
}, },
{ {
id: 2, id: 2,

View File

@@ -12,7 +12,8 @@ function info() {
function error() { function error() {
message.error({ message.error({
content: 'Once upon a time you dressed so fine', content: 'Once upon a time you dressed so fine',
duration: 2500, // ant-design-vue 的 message.duration 单位是「秒」, 不是毫秒
duration: 2.5,
}); });
} }
@@ -25,7 +26,8 @@ function success() {
function notify(type: NotificationType) { function notify(type: NotificationType) {
notification[type]({ notification[type]({
duration: 2500, // ant-design-vue 的 notification.duration 单位是「秒」, 不是毫秒
duration: 2.5,
message: '说点啥呢', message: '说点啥呢',
type, type,
}); });

View File

@@ -80,10 +80,7 @@ export type PromptProps<T = any> = {
component?: Component; component?: Component;
componentProps?: Recordable<any>; componentProps?: Recordable<any>;
componentSlots?: componentSlots?:
| (() => any) (() => any) | Recordable<unknown> | VNode | VNodeArrayChildren;
| Recordable<unknown>
| VNode
| VNodeArrayChildren;
defaultValue?: T; defaultValue?: T;
modelPropName?: string; modelPropName?: string;
} & Omit<AlertProps, 'beforeClose'>; } & Omit<AlertProps, 'beforeClose'>;

View File

@@ -9,7 +9,7 @@ const items = [
]; ];
</script> </script>
<template> <template>
<div style="display: flex; flex-direction: column; gap: 16px"> <div class="flex gap-4 flex-col">
<VbenDescriptions <VbenDescriptions
size="small" size="small"
bordered bordered

View File

@@ -1,7 +1,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { DescriptionsItemType } from '@vben/common-ui';
import { VbenDescriptions } from '@vben/common-ui'; import { VbenDescriptions } from '@vben/common-ui';
const items = [ const items: DescriptionsItemType[] = [
{ content: '1', label: 'A' }, { content: '1', label: 'A' },
{ content: '2(span: 2)', label: 'B', span: 2 }, { content: '2(span: 2)', label: 'B', span: 2 },
{ content: '3', label: 'C' }, { content: '3', label: 'C' },

View File

@@ -1,7 +1,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { DescriptionsItemType } from '@vben/common-ui';
import { VbenDescriptions } from '@vben/common-ui'; import { VbenDescriptions } from '@vben/common-ui';
const items = [ const items: DescriptionsItemType[] = [
{ content: 'Vben', label: '用户名' }, { content: 'Vben', label: '用户名' },
{ content: '13800138000', label: '手机号' }, { content: '13800138000', label: '手机号' },
{ content: '中国 · 杭州', label: '居住地' }, { content: '中国 · 杭州', label: '居住地' },

View File

@@ -66,10 +66,7 @@ export type PromptProps<T = any> = {
component?: Component; component?: Component;
componentProps?: Recordable<any>; componentProps?: Recordable<any>;
componentSlots?: componentSlots?:
| (() => any) (() => any) | Recordable<unknown> | VNode | VNodeArrayChildren;
| Recordable<unknown>
| VNode
| VNodeArrayChildren;
defaultValue?: T; defaultValue?: T;
modelPropName?: string; modelPropName?: string;
} & Omit<AlertProps, 'beforeClose'>; } & Omit<AlertProps, 'beforeClose'>;

View File

@@ -341,12 +341,7 @@ interface RouteMeta {
* Badge color * Badge color
*/ */
badgeVariants?: badgeVariants?:
| 'default' 'default' | 'destructive' | 'primary' | 'success' | 'warning' | string;
| 'destructive'
| 'primary'
| 'success'
| 'warning'
| string;
/** /**
* The children of the current route are not displayed in the menu * The children of the current route are not displayed in the menu
* @default false * @default false

View File

@@ -122,12 +122,12 @@ const dashboardMenus = [
title: 'page.dashboard.title', title: 'page.dashboard.title',
}, },
name: 'Dashboard', name: 'Dashboard',
path: '/', path: '/dashboard',
redirect: '/analytics', redirect: '/dashboard/analytics',
children: [ children: [
{ {
name: 'Analytics', name: 'Analytics',
path: '/analytics', path: 'analytics',
// Here is the path of the page, need to remove 'views/' and '.vue' // Here is the path of the page, need to remove 'views/' and '.vue'
component: '/dashboard/analytics/index', component: '/dashboard/analytics/index',
meta: { meta: {
@@ -137,7 +137,7 @@ const dashboardMenus = [
}, },
{ {
name: 'Workspace', name: 'Workspace',
path: '/workspace', path: 'workspace',
component: '/dashboard/workspace/index', component: '/dashboard/workspace/index',
meta: { meta: {
title: 'page.dashboard.workspace', title: 'page.dashboard.workspace',

View File

@@ -60,25 +60,23 @@ To add new translation texts, simply find `src/locales/langs/` in the correspond
**src/locales/langs/zh-CN/\*.json** **src/locales/langs/zh-CN/\*.json**
````ts
```json ```json
{ {
"about": { "about": {
"desc": "Vben Admin 是一个现代的管理模版。" "desc": "Vben Admin 是一个现代的管理模版。"
} }
} }
```` ```
**src/locales/langs/en-US.ts** **src/locales/langs/en-US/\*.json**
````ts
```json ```json
{ {
"about": { "about": {
"desc": "Vben Admin is a modern management template." "desc": "Vben Admin is a modern management template."
} }
} }
```` ```
## Using Translation Texts ## Using Translation Texts
@@ -91,11 +89,11 @@ With `@vben/locales`, you can easily use translation texts:
import { computed } from 'vue'; import { computed } from 'vue';
import { $t } from '@vben/locales'; import { $t } from '@vben/locales';
const items = computed(() => [{ title: $t('about.desc') }]); const items = computed(() => [{ title: $t('demos.title') }]);
</script> </script>
<template> <template>
<div>{{ $t('about.desc') }}</div> <div>{{ $t('demos.title') }}</div>
<template v-for="item in items.value"> <template v-for="item in items">
<div>{{ item.title }}</div> <div>{{ item.title }}</div>
</template> </template>
</template> </template>
@@ -105,8 +103,8 @@ const items = computed(() => [{ title: $t('about.desc') }]);
If you need to add a new language pack, follow these steps: If you need to add a new language pack, follow these steps:
- Add the corresponding language pack file in the `packages/locales/langs` directory, for example, `zh-TW.json`, and translate the respective texts. - Create the corresponding language pack folder and files under `packages/locales/langs` (e.g., `zh-TW/*.json`) and translate the texts accordingly.
- In the corresponding application, locate the `src/locales/langs` file and add the new language pack `zh-TW.json`. - In the corresponding application, find the `src/locales/langs` directory and create the same language pack folder and files (e.g., `zh-TW/*.json`).
- Add the corresponding language in `packages/constants/src/core.ts`: - Add the corresponding language in `packages/constants/src/core.ts`:
```ts ```ts

View File

@@ -334,12 +334,7 @@ interface RouteMeta {
* 徽标颜色 * 徽标颜色
*/ */
badgeVariants?: badgeVariants?:
| 'default' 'default' | 'destructive' | 'primary' | 'success' | 'warning' | string;
| 'destructive'
| 'primary'
| 'success'
| 'warning'
| string;
/** /**
* 路由的完整路径作为key(默认true) * 路由的完整路径作为key(默认true)
*/ */

View File

@@ -120,12 +120,12 @@ const dashboardMenus = [
title: 'page.dashboard.title', title: 'page.dashboard.title',
}, },
name: 'Dashboard', name: 'Dashboard',
path: '/', path: '/dashboard',
redirect: '/analytics', redirect: '/dashboard/analytics',
children: [ children: [
{ {
name: 'Analytics', name: 'Analytics',
path: '/analytics', path: 'analytics',
// 这里为页面的路径,需要去掉 views/ 和 .vue // 这里为页面的路径,需要去掉 views/ 和 .vue
component: '/dashboard/analytics/index', component: '/dashboard/analytics/index',
meta: { meta: {
@@ -135,7 +135,7 @@ const dashboardMenus = [
}, },
{ {
name: 'Workspace', name: 'Workspace',
path: '/workspace', path: 'workspace',
component: '/dashboard/workspace/index', component: '/dashboard/workspace/index',
meta: { meta: {
title: 'page.dashboard.workspace', title: 'page.dashboard.workspace',

View File

@@ -60,25 +60,23 @@ updateLocale('en-US');
**src/locales/langs/zh-CN/\*.json** **src/locales/langs/zh-CN/\*.json**
````ts
```json ```json
{ {
"about": { "about": {
"desc": "Vben Admin 是一个现代的管理模版。" "desc": "Vben Admin 是一个现代的管理模版。"
} }
} }
```` ```
**src/locales/langs/en-US.ts** **src/locales/langs/en-US/\*.json**
````ts
```json ```json
{ {
"about": { "about": {
"desc": "Vben Admin is a modern management template." "desc": "Vben Admin is a modern management template."
} }
} }
```` ```
## 使用翻译文本 ## 使用翻译文本
@@ -91,11 +89,11 @@ updateLocale('en-US');
import { computed } from 'vue'; import { computed } from 'vue';
import { $t } from '@vben/locales'; import { $t } from '@vben/locales';
const items = computed(() => [{ title: $t('about.desc') }]); const items = computed(() => [{ title: $t('demos.title') }]);
</script> </script>
<template> <template>
<div>{{ $t('about.desc') }}</div> <div>{{ $t('demos.title') }}</div>
<template v-for="item in items.value"> <template v-for="item in items">
<div>{{ item.title }}</div> <div>{{ item.title }}</div>
</template> </template>
</template> </template>
@@ -105,8 +103,8 @@ const items = computed(() => [{ title: $t('about.desc') }]);
如果你需要新增语言包,需要按照以下步骤进行: 如果你需要新增语言包,需要按照以下步骤进行:
- 在 `packages/locales/langs` 目录下新增对应的语言包文件,例:`zh-TW.json`,并翻译对应的文本。 - 在 `packages/locales/langs` 目录下新增对应的语言包文件夹和文件,例:`zh-TW/*.json`,并翻译对应的文本。
- 在对应的应用内,找到 `src/locales/langs` 文件,新增对应的语言包 `zh-TW.json` - 在对应的应用内,找到 `src/locales/langs` 目录,新增对应的语言包文件夹和文件 `zh-TW/*.json`。
- 在 `packages/constants/src/core.ts`内,新增对应的语言: - 在 `packages/constants/src/core.ts`内,新增对应的语言:
```ts ```ts

View File

@@ -79,6 +79,7 @@ export default {
'nesting-selector-no-missing-scoping-root': null, 'nesting-selector-no-missing-scoping-root': null,
'no-descending-specificity': null, 'no-descending-specificity': null,
'no-empty-source': null, 'no-empty-source': null,
'no-invalid-position-declaration': null,
'order/order': [ 'order/order': [
[ [
'dollar-variables', 'dollar-variables',

View File

@@ -70,6 +70,7 @@
"@changesets/cli": "catalog:", "@changesets/cli": "catalog:",
"@tsdown/css": "catalog:", "@tsdown/css": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"@types/sortablejs": "catalog:",
"@vben/commitlint-config": "workspace:*", "@vben/commitlint-config": "workspace:*",
"@vben/eslint-config": "workspace:*", "@vben/eslint-config": "workspace:*",
"@vben/oxfmt-config": "workspace:*", "@vben/oxfmt-config": "workspace:*",
@@ -103,8 +104,11 @@
"vue-tsc": "catalog:" "vue-tsc": "catalog:"
}, },
"engines": { "engines": {
"node": "^22.18.0 || ^24.0.0", "node": "^22.18.0 || ^24.12.0",
"pnpm": ">=11.0.0" "pnpm": ">=11.0.0"
}, },
"packageManager": "pnpm@11.7.0" "packageManager": "pnpm@11.15.1",
"dependencies": {
"sortablejs": "catalog:"
}
} }

View File

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

View File

@@ -136,14 +136,23 @@ exports[`defaultPreferences immutability test > should not modify the config obj
}, },
"widget": { "widget": {
"fullscreen": true, "fullscreen": true,
"fullscreenButtonPosition": "header",
"globalSearch": true, "globalSearch": true,
"globalSearchButtonPosition": "header",
"languageToggle": true, "languageToggle": true,
"languageToggleButtonPosition": "header",
"lockScreen": true, "lockScreen": true,
"lockScreenButtonPosition": "header",
"logoutButtonPosition": "header",
"notification": true, "notification": true,
"notificationButtonPosition": "header",
"refresh": true, "refresh": true,
"refreshButtonPosition": "header",
"sidebarToggle": true, "sidebarToggle": true,
"themeToggle": true, "themeToggle": true,
"themeToggleButtonPosition": "header",
"timezone": true, "timezone": true,
"timezoneButtonPosition": "header",
}, },
} }
`; `;

View File

@@ -154,6 +154,17 @@ const defaultPreferences: Preferences = {
themeToggleButtonPosition: 'header', themeToggleButtonPosition: 'header',
timezone: true, timezone: true,
timezoneButtonPosition: 'header', 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 { markRaw, reactive, readonly, watch } from 'vue';
import { StorageManager } from '@vben-core/shared/cache'; 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 { import {
breakpointsTailwind, breakpointsTailwind,
@@ -133,7 +137,11 @@ class PreferenceManager {
// 加载缓存的偏好设置,并仅用缓存补齐初始化配置中未显式设置的字段 // 加载缓存的偏好设置,并仅用缓存补齐初始化配置中未显式设置的字段
const cachedPreferences = (await this.loadFromCache()) || {}; 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, // 用户缓存的设置优先 cachedPreferences, // 用户缓存的设置优先
this.initialPreferences, // 初始设置仅补齐缺失字段 this.initialPreferences, // 初始设置仅补齐缺失字段
@@ -144,7 +152,7 @@ class PreferenceManager {
const cachedCustom = (await this.loadCustomFromCache()) || {}; const cachedCustom = (await this.loadCustomFromCache()) || {};
this.replaceCustomPreferences( this.replaceCustomPreferences(
merge( mergeWithArrayOverride(
{}, {},
this.sanitizeCustomPreferences(cachedCustom), this.sanitizeCustomPreferences(cachedCustom),
this.initialCustomPreferences, this.initialCustomPreferences,
@@ -194,7 +202,7 @@ class PreferenceManager {
} }
this.replaceCustomPreferences( this.replaceCustomPreferences(
merge({}, sanitizedUpdates, markRaw(this.customState)), mergeWithArrayOverride({}, sanitizedUpdates, markRaw(this.customState)),
); );
this.debouncedSave(); this.debouncedSave();
}; };
@@ -205,7 +213,14 @@ class PreferenceManager {
*/ */
updatePreferences = (updates: DeepPartial<Preferences>) => { 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); Object.assign(this.state, mergedState);
// 根据更新的值执行更新 // 根据更新的值执行更新
@@ -361,6 +376,28 @@ class PreferenceManager {
: undefined; : 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( private sanitizeCustomPreferences(
updates: DeepPartial<CustomPreferencesRecord>, updates: DeepPartial<CustomPreferencesRecord>,
) { ) {

View File

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

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
// oxlint-disable unicorn/no-nested-ternary
import type { FormCommonConfig, FormSchema } from '../types'; import type { FormCommonConfig, FormSchema } from '../types';
import { computed } from 'vue'; import { computed } from 'vue';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,10 +24,16 @@ defineOptions({
}); });
const props = withDefaults( const props = withDefaults(
defineProps<{ enableShortcutKey?: boolean; menus?: MenuRecordRaw[] }>(), defineProps<{
enableShortcutKey?: boolean;
menus?: MenuRecordRaw[];
/** 是否显示触发按钮(嵌在 dropdown 等容器里时可关掉) */
showButton?: boolean;
}>(),
{ {
enableShortcutKey: true, enableShortcutKey: true,
menus: () => [], menus: () => [],
showButton: true,
}, },
); );
@@ -94,6 +100,13 @@ onMounted(() => {
window.removeEventListener('keydown', preventDefaultBrowserSearchHotKey); window.removeEventListener('keydown', preventDefaultBrowserSearchHotKey);
}); });
}); });
// 暴露给父组件,允许从外部触发搜索弹框(如 user-dropdown 里点击菜单项触发)
defineExpose({
open: () => modalApi.open(),
close: () => modalApi.close(),
toggle: toggleOpen,
});
</script> </script>
<template> <template>
@@ -135,6 +148,7 @@ onMounted(() => {
</template> </template>
</Modal> </Modal>
<div <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" 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()" @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;
// 划分规则:
// - hidden:position === '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"> <script setup lang="ts">
import type { SelectOption } from '@vben/types'; import type { PreferencesButtonPositionType, SelectOption } from '@vben/types';
import { computed } from 'vue'; import { computed } from 'vue';
import { $t } from '@vben/locales'; import { $t } from '@vben/locales';
import SelectItem from '../select-item.vue'; import DraggableList from '../draggable-list.vue';
defineOptions({ defineOptions({
name: 'PreferenceInterfaceControl', name: 'PreferenceInterfaceControl',
}); });
const widgetOrder = defineModel<string[]>('widgetOrder', { required: true });
const widgetGlobalSearchButtonPosition = defineModel<string>( const widgetGlobalSearchButtonPosition = defineModel<string>(
'widgetGlobalSearchButtonPosition', 'widgetGlobalSearchButtonPosition',
); );
@@ -32,17 +34,37 @@ const widgetLockScreenButtonPosition = defineModel<string>(
const widgetLogoutButtonPosition = defineModel<string>( const widgetLogoutButtonPosition = defineModel<string>(
'widgetLogoutButtonPosition', 'widgetLogoutButtonPosition',
); );
const appPreferencesButtonPosition = defineModel<string>(
'appPreferencesButtonPosition',
);
const widgetRefreshButtonPosition = defineModel<string>( const widgetRefreshButtonPosition = defineModel<string>(
'widgetRefreshButtonPosition', 'widgetRefreshButtonPosition',
); );
const widgetTimezoneButtonPosition = defineModel<string>( const widgetTimezoneButtonPosition = defineModel<string>(
'widgetTimezoneButtonPosition', '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'), label: $t('preferences.position.auto'),
value: 'auto', value: 'auto',
@@ -59,77 +81,99 @@ const positionItems = computed((): SelectOption[] => [
label: $t('preferences.position.userDropdown'), label: $t('preferences.position.userDropdown'),
value: 'user-dropdown', 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'), label: $t('common.notShow'),
value: 'none', 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> </script>
<template> <template>
<SelectItem <DraggableList
v-model="widgetGlobalSearchButtonPosition" :items="draggableItems"
:items="buttonPositionItems" :position-items="buttonPositionItems"
> @update-order="handleUpdateOrder"
{{ $t('preferences.widget.globalSearchPosition') }} @update-position="handleUpdatePosition"
</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>
</template> </template>

View File

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

View File

@@ -3,23 +3,24 @@ import type { Component } from 'vue';
import type { AnyFunction } from '@vben/types'; 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 { import {
Bell,
Fullscreen,
Languages, Languages,
LockKeyhole, LockKeyhole,
LogOut, LogOut,
RotateCw, RotateCw,
Search, Search,
Settings, Settings,
Sun,
SunMoon,
} from '@vben/icons'; } from '@vben/icons';
import { $t } from '@vben/locales'; import { $t, loadLocaleMessages } from '@vben/locales';
import { preferences, usePreferences } from '@vben/preferences'; import {
preferences,
updatePreferences,
usePreferences,
} from '@vben/preferences';
import { useAccessStore } from '@vben/stores'; import { useAccessStore } from '@vben/stores';
import { isWindowsOs } from '@vben/utils'; import { isWindowsOs } from '@vben/utils';
@@ -34,13 +35,19 @@ import {
DropdownMenuShortcut, DropdownMenuShortcut,
DropdownMenuTrigger, DropdownMenuTrigger,
VbenAvatar, VbenAvatar,
VbenFullScreen,
VbenIcon, VbenIcon,
VbenIconButton,
} from '@vben-core/shadcn-ui'; } from '@vben-core/shadcn-ui';
import { useMagicKeys, whenever } from '@vueuse/core'; import { useMagicKeys, whenever } from '@vueuse/core';
import { GlobalSearch } from '../global-search';
import { LockScreenModal } from '../lock-screen'; import { LockScreenModal } from '../lock-screen';
import { Notification } from '../notification';
import { Preferences } from '../preferences'; import { Preferences } from '../preferences';
import { ThemeToggle } from '../theme-toggle';
import { TimezoneButton } from '../timezone';
interface Props { interface Props {
/** /**
@@ -91,10 +98,12 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<{ clearPreferencesAndLogout: []; logout: [] }>(); const emit = defineEmits<{ clearPreferencesAndLogout: []; logout: [] }>();
const { const {
globalLockScreenShortcutKey,
globalLogoutShortcutKey, globalLogoutShortcutKey,
globalLockScreenShortcutKey,
globalSearchShortcutKey,
preferencesButtonPosition, preferencesButtonPosition,
} = usePreferences(); } = usePreferences();
const { refresh } = useRefresh();
const accessStore = useAccessStore(); const accessStore = useAccessStore();
const [LockModal, lockModalApi] = useVbenModal({ const [LockModal, lockModalApi] = useVbenModal({
connectedComponent: LockScreenModal, connectedComponent: LockScreenModal,
@@ -108,6 +117,7 @@ const [LogoutModal, logoutModalApi] = useVbenModal({
const refTrigger = useTemplateRef('refTrigger'); const refTrigger = useTemplateRef('refTrigger');
const refContent = useTemplateRef('refContent'); const refContent = useTemplateRef('refContent');
const refPreferences = useTemplateRef('refPreferences'); const refPreferences = useTemplateRef('refPreferences');
const refGlobalSearch = useTemplateRef('refGlobalSearch');
const [openPopover, hoverWatcher] = useHoverToggle( const [openPopover, hoverWatcher] = useHoverToggle(
[refTrigger, refContent], [refTrigger, refContent],
() => props.hoverDelay, () => props.hoverDelay,
@@ -233,6 +243,32 @@ function handleOpenSettings() {
refPreferences.value?.open(); 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) { if (enableShortcutKey.value) {
const keys = useMagicKeys(); const keys = useMagicKeys();
const logoutKey = keys['Alt+KeyQ']; const logoutKey = keys['Alt+KeyQ'];
@@ -285,6 +321,14 @@ if (enableShortcutKey.value) {
@clear-preferences-and-logout="emit('clearPreferencesAndLogout')" @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"> <DropdownMenu v-model:open="openPopover" :modal="false">
<DropdownMenuTrigger ref="refTrigger" :disabled="props.trigger === 'hover'"> <DropdownMenuTrigger ref="refTrigger" :disabled="props.trigger === 'hover'">
<div class="mr-2 ml-1 cursor-pointer rounded-full p-1.5 hover:bg-accent"> <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" class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@click="menu.handler" @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 }} {{ menu.text }}
</DropdownMenuItem> </DropdownMenuItem>
<template v-if="showLockInDropdown || showLogoutInDropdown"> <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" class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@click="handleOpenLock" @click="handleOpenLock"
> >
<LockKeyhole class="mr-2 size-4" /> <VbenIconButton class="mr-2" @click="handleOpenLock">
<LockKeyhole class="size-4" />
</VbenIconButton>
{{ $t('ui.widgets.lockScreen.title') }} {{ $t('ui.widgets.lockScreen.title') }}
<DropdownMenuShortcut v-if="enableLockScreenShortcutKey"> <DropdownMenuShortcut v-if="enableLockScreenShortcutKey">
{{ altView }} L {{ altView }} L
@@ -353,7 +401,9 @@ if (enableShortcutKey.value) {
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8" class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@click="handleLogout" @click="handleLogout"
> >
<LogOut class="mr-2 size-4" /> <VbenIconButton class="mr-2" @click="handleLogout">
<LogOut class="size-4" />
</VbenIconButton>
{{ $t('common.logout') }} {{ $t('common.logout') }}
<DropdownMenuShortcut v-if="enableLogoutShortcutKey"> <DropdownMenuShortcut v-if="enableLogoutShortcutKey">
{{ altView }} Q {{ altView }} Q
@@ -375,50 +425,75 @@ if (enableShortcutKey.value) {
<DropdownMenuItem <DropdownMenuItem
v-if="showGlobalSearchInDropdown" v-if="showGlobalSearchInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8" 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') }} {{ $t('preferences.widget.globalSearch') }}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
v-if="showThemeToggleInDropdown" v-if="showThemeToggleInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8" class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
> >
<Sun class="mr-2 size-4" /> <ThemeToggle class="mr-2" />
{{ $t('preferences.widget.themeToggle') }} {{ $t('preferences.theme.title') }}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
v-if="showLanguageToggleInDropdown" v-if="showLanguageToggleInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8" 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') }} {{ $t('preferences.widget.languageToggle') }}
</DropdownMenuItem> </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 <DropdownMenuItem
v-if="showTimezoneInDropdown" v-if="showTimezoneInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8" class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
> >
<SunMoon class="mr-2 size-4" /> <TimezoneButton class="mr-2" />
{{ $t('preferences.widget.timezone') }} {{ $t('ui.widgets.timezone.setTimezone') }}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
v-if="showFullscreenInDropdown" v-if="showFullscreenInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8" 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') }} {{ $t('preferences.widget.fullscreen') }}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
v-if="showNotificationInDropdown" v-if="showNotificationInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8" 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') }} {{ $t('preferences.widget.notification') }}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
v-if="showRefreshInDropdown" v-if="showRefreshInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8" 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') }} {{ $t('preferences.widget.refresh') }}
</DropdownMenuItem> </DropdownMenuItem>
</template> </template>
@@ -430,7 +505,9 @@ if (enableShortcutKey.value) {
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8" class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@click="handleOpenSettings" @click="handleOpenSettings"
> >
<Settings class="mr-2 size-4" /> <VbenIconButton class="mr-2" @click="handleOpenSettings">
<Settings class="size-4" />
</VbenIconButton>
{{ $t('preferences.title') }} {{ $t('preferences.title') }}
</DropdownMenuItem> </DropdownMenuItem>
</div> </div>

View File

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

View File

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

View File

@@ -76,7 +76,7 @@ const schema: VbenFormSchema[] = [
数组字段名。假设为 `contacts`,第 1 行子字段 `name` 会被转换成: 数组字段名。假设为 `contacts`,第 1 行子字段 `name` 会被转换成:
```ts ```ts
contacts[0].name contacts[0].name;
``` ```
### `children` ### `children`
@@ -102,22 +102,22 @@ contacts[0].name
传给数组编辑器的配置: 传给数组编辑器的配置:
| 字段 | 说明 | | 字段 | 说明 |
| --- | --- | | --------------- | ------------------------ |
| `addButtonText` | 新增按钮文案 | | `addButtonText` | 新增按钮文案 |
| `actionText` | 操作列表头文案 | | `actionText` | 操作列表头文案 |
| `emptyText` | 空数据文案 | | `emptyText` | 空数据文案 |
| `min` | 最少行数,达到后禁用删除 | | `min` | 最少行数,达到后禁用删除 |
| `max` | 最多行数,达到后禁用新增 | | `max` | 最多行数,达到后禁用新增 |
| `showIndex` | 是否显示序号 | | `showIndex` | 是否显示序号 |
| `createRow` | 新增行时生成默认数据 | | `createRow` | 新增行时生成默认数据 |
## 校验建议 ## 校验建议
数组父级 `rules` 建议只写数组级规则,例如至少一行: 数组父级 `rules` 建议只写数组级规则,例如至少一行:
```ts ```ts
rules: z.array(z.any()).min(1, '请至少添加一个联系人') rules: z.array(z.any()).min(1, '请至少添加一个联系人');
``` ```
每个子字段的必填、长度、格式校验写在 children 自己的 `rules`: 每个子字段的必填、长度、格式校验写在 children 自己的 `rules`:
@@ -156,20 +156,20 @@ children 里的 `dependencies.triggerFields` 默认是“当前行相对路径
在第 1 行中,`triggerFields: ['role']` 会被转换成: 在第 1 行中,`triggerFields: ['role']` 会被转换成:
```ts ```ts
contacts[0].role contacts[0].role;
``` ```
回调多了一个可选 `ctx` 参数: 回调多了一个可选 `ctx` 参数:
| 字段 | 说明 | | 字段 | 说明 |
| --- | --- | | ----------------------- | ---------------------------------------- |
| `ctx.row` | 当前行数据 | | `ctx.row` | 当前行数据 |
| `ctx.rowIndex` | 当前行索引 | | `ctx.rowIndex` | 当前行索引 |
| `ctx.rowPath` | 当前行路径,例如 `contacts[0]` | | `ctx.rowPath` | 当前行路径,例如 `contacts[0]` |
| `ctx.arrayField` | 数组字段名,例如 `contacts` | | `ctx.arrayField` | 数组字段名,例如 `contacts` |
| `ctx.fieldName` | 当前真实字段名,例如 `contacts[0].phone` | | `ctx.fieldName` | 当前真实字段名,例如 `contacts[0].phone` |
| `ctx.originalFieldName` | 原始 child 字段名,例如 `phone` | | `ctx.originalFieldName` | 原始 child 字段名,例如 `phone` |
| `ctx.rootValues` | 表单完整值 | | `ctx.rootValues` | 表单完整值 |
如果 child 需要依赖表单根字段,可以使用 `$root.` 前缀: 如果 child 需要依赖表单根字段,可以使用 `$root.` 前缀:
@@ -185,7 +185,7 @@ dependencies: {
如果想显式写当前行字段,也可以使用 `$row.` 前缀: 如果想显式写当前行字段,也可以使用 `$row.` 前缀:
```ts ```ts
triggerFields: ['$row.role'] triggerFields: ['$row.role'];
``` ```
## valueFormat 用法 ## valueFormat 用法
@@ -210,19 +210,19 @@ children 里的 `valueFormat` 也会按行执行:
在 `contacts[0]` 中,`setValue('phone', nextValue)` 会自动写到: 在 `contacts[0]` 中,`setValue('phone', nextValue)` 会自动写到:
```ts ```ts
contacts[0].phone contacts[0].phone;
``` ```
如果要写根字段,用 `$root.`: 如果要写根字段,用 `$root.`:
```ts ```ts
setValue('$root.firstContactPhone', value) setValue('$root.firstContactPhone', value);
``` ```
如果要显式写当前行字段,用 `$row.`: 如果要显式写当前行字段,用 `$row.`:
```ts ```ts
setValue('$row.phone', value) setValue('$row.phone', value);
``` ```
## updateSchema 用法 ## updateSchema 用法

View File

@@ -88,7 +88,13 @@ const schema: VbenFormSchema[] = [
label: $t('system.menu.parent'), label: $t('system.menu.parent'),
renderComponentContent() { renderComponentContent() {
return { return {
title({ label, meta }: { label: string; meta: Recordable<any> }) { treeTitleRender({
label,
meta,
}: {
label: string;
meta: Recordable<any>;
}) {
const coms = []; const coms = [];
if (!label) return ''; if (!label) return '';
if (meta?.icon) { if (meta?.icon) {
@@ -129,12 +135,6 @@ const schema: VbenFormSchema[] = [
.string() .string()
.min(2, $t('ui.formRules.minLength', [$t('system.menu.path'), 2])) .min(2, $t('ui.formRules.minLength', [$t('system.menu.path'), 2]))
.max(100, $t('ui.formRules.maxLength', [$t('system.menu.path'), 100])) .max(100, $t('ui.formRules.maxLength', [$t('system.menu.path'), 100]))
.refine(
(value: string) => {
return value.startsWith('/');
},
$t('ui.formRules.startWith', [$t('system.menu.path'), '/']),
)
.refine( .refine(
async (value: string) => { async (value: string) => {
return !(await isMenuPathExists(value, formData.value?.id)); return !(await isMenuPathExists(value, formData.value?.id));
@@ -162,12 +162,6 @@ const schema: VbenFormSchema[] = [
.string() .string()
.min(2, $t('ui.formRules.minLength', [$t('system.menu.path'), 2])) .min(2, $t('ui.formRules.minLength', [$t('system.menu.path'), 2]))
.max(100, $t('ui.formRules.maxLength', [$t('system.menu.path'), 100])) .max(100, $t('ui.formRules.maxLength', [$t('system.menu.path'), 100]))
.refine(
(value: string) => {
return value.startsWith('/');
},
$t('ui.formRules.startWith', [$t('system.menu.path'), '/']),
)
.refine(async (value: string) => { .refine(async (value: string) => {
return await isMenuPathExists(value, formData.value?.id); return await isMenuPathExists(value, formData.value?.id);
}, $t('system.menu.activePathMustExist')) }, $t('system.menu.activePathMustExist'))

8086
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -37,70 +37,70 @@ overrides:
vue: 'catalog:' vue: 'catalog:'
catalog: catalog:
'@ast-grep/napi': ^0.43.0 '@ast-grep/napi': ^0.44.1
'@changesets/changelog-github': ^0.7.0 '@changesets/changelog-github': ^0.7.0
'@changesets/cli': ^2.31.0 '@changesets/cli': ^2.31.1
'@changesets/git': ^3.0.4 '@changesets/git': ^3.0.4
'@clack/prompts': ^1.5.1 '@clack/prompts': ^1.7.0
'@commitlint/cli': ^21.0.2 '@commitlint/cli': ^21.2.1
'@commitlint/config-conventional': ^21.0.2 '@commitlint/config-conventional': ^21.2.0
'@ctrl/tinycolor': ^4.2.0 '@ctrl/tinycolor': ^4.2.0
'@eslint-community/eslint-plugin-eslint-comments': ^4.7.2 '@eslint-community/eslint-plugin-eslint-comments': ^4.7.2
'@eslint/js': ^10.0.1 '@eslint/js': ^10.0.1
'@faker-js/faker': ^10.4.0 '@faker-js/faker': ^10.5.0
'@iconify/json': ^2.2.487 '@iconify/json': ^2.2.501
'@iconify/tailwind4': ^1.2.3 '@iconify/tailwind4': ^1.2.3
'@iconify/vue': ^5.0.1 '@iconify/vue': ^5.0.1
'@intlify/core-base': ^11.4.5 '@intlify/core-base': ^11.4.6
'@intlify/unplugin-vue-i18n': ^11.2.3 '@intlify/unplugin-vue-i18n': ^11.2.4
'@jspm/generator': ^2.16.1 '@jspm/generator': ^2.16.3
'@lucide/vue': ^1.18.0 '@lucide/vue': ^1.25.0
'@manypkg/get-packages': ^3.1.0 '@manypkg/get-packages': ^3.1.0
'@nolebase/vitepress-plugin-git-changelog': ^2.18.2 '@nolebase/vitepress-plugin-git-changelog': ^2.18.2
'@playwright/test': ^1.61.0 '@playwright/test': ^1.61.1
'@pnpm/workspace.read-manifest': ^1000.3.1 '@pnpm/workspace.read-manifest': ^1000.3.1
'@stylistic/stylelint-plugin': ^5.2.0 '@stylistic/stylelint-plugin': ^5.2.1
'@tailwindcss/typography': ^0.5.20 '@tailwindcss/typography': ^0.5.20
'@tailwindcss/vite': ^4.3.1 '@tailwindcss/vite': ^4.3.3
'@tanstack/vue-query': ^5.101.0 '@tanstack/vue-query': ^5.101.2
'@tanstack/vue-store': ^0.11.0 '@tanstack/vue-store': ^0.11.0
'@tiptap/core': ^3.26.1 '@tiptap/core': ^3.28.0
'@tiptap/extension-document': ^3.26.1 '@tiptap/extension-document': ^3.28.0
'@tiptap/extension-highlight': ^3.26.1 '@tiptap/extension-highlight': ^3.28.0
'@tiptap/extension-image': ^3.26.1 '@tiptap/extension-image': ^3.28.0
'@tiptap/extension-link': ^3.26.1 '@tiptap/extension-link': ^3.28.0
'@tiptap/extension-placeholder': ^3.26.1 '@tiptap/extension-placeholder': ^3.28.0
'@tiptap/extension-text-align': ^3.26.1 '@tiptap/extension-text-align': ^3.28.0
'@tiptap/extension-text-style': ^3.26.1 '@tiptap/extension-text-style': ^3.28.0
'@tiptap/extension-underline': ^3.26.1 '@tiptap/extension-underline': ^3.28.0
'@tiptap/pm': ^3.26.1 '@tiptap/pm': ^3.28.0
'@tiptap/starter-kit': ^3.26.1 '@tiptap/starter-kit': ^3.28.0
'@tiptap/vue-3': ^3.26.1 '@tiptap/vue-3': ^3.28.0
'@tsdown/css': ^0.22.3 '@tsdown/css': ^0.22.9
'@types/archiver': ^8.0.0 '@types/archiver': ^8.0.0
'@types/html-minifier-terser': ^7.0.2 '@types/html-minifier-terser': ^7.0.2
'@types/json-bigint': ^1.0.4 '@types/json-bigint': ^1.0.4
'@types/jsonwebtoken': ^9.0.10 '@types/jsonwebtoken': ^9.0.10
'@types/lodash.clonedeep': ^4.5.9 '@types/lodash.clonedeep': ^4.5.9
'@types/node': ^25.9.4 '@types/node': ^25.9.5
'@types/nprogress': ^0.2.3 '@types/nprogress': ^0.2.3
'@types/qrcode': ^1.5.6 '@types/qrcode': ^1.5.6
'@types/qs': ^6.15.1 '@types/qs': ^6.15.1
'@types/sortablejs': ^1.15.9 '@types/sortablejs': ^1.15.9
'@typescript-eslint/parser': ^8.61.1 '@typescript-eslint/parser': ^8.64.0
'@vee-validate/zod': ^4.15.1 '@vee-validate/zod': ^4.15.1
'@vite-pwa/vitepress': ^1.1.0 '@vite-pwa/vitepress': ^1.1.0
'@vitejs/plugin-vue': ^6.0.7 '@vitejs/plugin-vue': ^6.0.8
'@vitejs/plugin-vue-jsx': ^5.1.5 '@vitejs/plugin-vue-jsx': ^5.1.6
'@vue/shared': ^3.5.38 '@vue/shared': ^3.5.40
'@vue/test-utils': ^2.4.11 '@vue/test-utils': ^2.4.11
'@vueuse/core': ^14.3.0 '@vueuse/core': ^14.3.0
'@vueuse/integrations': ^14.3.0 '@vueuse/integrations': ^14.3.0
'@vueuse/motion': ^3.0.3 '@vueuse/motion': ^3.0.3
ant-design-vue: ^4.2.6 ant-design-vue: ^4.2.6
antdv-next: ^1.3.5 antdv-next: ^1.4.3
archiver: ^8.0.0 archiver: ^8.0.0
axios: ^1.18.0 axios: ^1.18.1
axios-mock-adapter: ^2.1.0 axios-mock-adapter: ^2.1.0
cac: ^7.0.0 cac: ^7.0.0
chalk: ^5.6.2 chalk: ^5.6.2
@@ -118,50 +118,50 @@ catalog:
defu: ^6.1.7 defu: ^6.1.7
dotenv: ^17.4.2 dotenv: ^17.4.2
echarts: ^6.1.0 echarts: ^6.1.0
element-plus: ^2.14.2 element-plus: ^2.14.3
es-toolkit: ^1.47.1 es-toolkit: ^1.49.0
eslint: ^10.5.0 eslint: ^10.7.0
eslint-plugin-better-tailwindcss: ^4.6.0 eslint-plugin-better-tailwindcss: ^4.6.1
eslint-plugin-command: ^3.5.2 eslint-plugin-command: ^3.5.3
eslint-plugin-jsonc: ^3.2.0 eslint-plugin-jsonc: ^3.3.0
eslint-plugin-n: ^18.1.0 eslint-plugin-n: ^18.2.2
eslint-plugin-perfectionist: ^5.9.1 eslint-plugin-perfectionist: ^5.10.0
eslint-plugin-pnpm: ^1.6.1 eslint-plugin-pnpm: ^1.6.1
eslint-plugin-unused-imports: ^4.4.1 eslint-plugin-unused-imports: ^4.4.1
eslint-plugin-vue: ^10.9.2 eslint-plugin-vue: ^10.9.2
eslint-plugin-yml: ^3.4.0 eslint-plugin-yml: ^3.6.0
execa: ^9.6.1 execa: ^10.0.0
find-up: ^8.0.0 find-up: ^8.0.0
get-port: ^7.2.0 get-port: ^7.2.0
globals: ^17.6.0 globals: ^17.7.0
h3: ^1.15.11 h3: ^1.15.11
happy-dom: ^20.10.6 happy-dom: ^20.11.0
html-minifier-terser: ^7.2.0 html-minifier-terser: ^7.2.0
is-ci: ^4.1.0 is-ci: ^4.1.0
json-bigint: ^1.0.0 json-bigint: ^1.0.0
jsonwebtoken: ^9.0.3 jsonwebtoken: ^9.0.3
knip: ^6.17.0 knip: ^6.27.0
lefthook: ^2.1.9 lefthook: ^2.1.10
lodash.clonedeep: ^4.5.0 lodash.clonedeep: ^4.5.0
medium-zoom: ^1.1.0 medium-zoom: ^1.1.0
naive-ui: ^2.44.1 naive-ui: ^2.44.1
nitropack: ^2.13.4 nitropack: ^2.13.4
nprogress: ^0.2.0 nprogress: ^0.2.0
ora: ^9.4.0 ora: ^9.4.1
oxfmt: ^0.55.0 oxfmt: ^0.59.0
oxlint: ^1.70.0 oxlint: ^1.74.0
oxlint-tsgolint: ^0.23.0 oxlint-tsgolint: ^0.25.0
pinia: ^3.0.4 pinia: ^4.0.2
pinia-plugin-persistedstate: ^4.7.1 pinia-plugin-persistedstate: ^4.7.1
pkg-types: ^2.3.1 pkg-types: ^2.3.1
playwright: ^1.61.0 playwright: ^1.61.1
postcss: ^8.5.15 postcss: ^8.5.20
postcss-html: ^1.8.1 postcss-html: ^1.8.1
postcss-scss: ^4.0.9 postcss-scss: ^4.0.9
publint: ^0.3.21 publint: ^0.3.21
qrcode: ^1.5.4 qrcode: ^1.5.4
qs: ^6.15.2 qs: ^6.15.3
reka-ui: ^2.9.10 reka-ui: ^2.10.1
resolve.exports: ^2.0.3 resolve.exports: ^2.0.3
rimraf: ^6.1.3 rimraf: ^6.1.3
rollup-plugin-visualizer: ^7.0.1 rollup-plugin-visualizer: ^7.0.1
@@ -169,7 +169,7 @@ catalog:
sass-embedded: ^1.100.0 sass-embedded: ^1.100.0
secure-ls: ^2.0.0 secure-ls: ^2.0.0
sortablejs: ^1.15.7 sortablejs: ^1.15.7
stylelint: ^17.13.0 stylelint: ^17.14.0
stylelint-config-recess-order: ^7.7.0 stylelint-config-recess-order: ^7.7.0
stylelint-config-recommended: ^18.0.0 stylelint-config-recommended: ^18.0.0
stylelint-config-recommended-scss: ^17.0.1 stylelint-config-recommended-scss: ^17.0.1
@@ -178,37 +178,37 @@ catalog:
stylelint-order: ^8.1.1 stylelint-order: ^8.1.1
stylelint-scss: ^7.2.0 stylelint-scss: ^7.2.0
tailwind-merge: ^3.6.0 tailwind-merge: ^3.6.0
tailwindcss: ^4.3.1 tailwindcss: ^4.3.3
tdesign-vue-next: ^1.20.1 tdesign-vue-next: ^1.20.3
theme-colors: ^0.1.0 theme-colors: ^0.1.0
tippy.js: ^6.3.7 tippy.js: ^6.3.7
tsdown: ^0.22.3 tsdown: ^0.22.9
turbo: ^2.9.18 turbo: ^2.10.5
tw-animate-css: ^1.4.0 tw-animate-css: ^1.4.0
typescript: ^6.0.3 typescript: ^6.0.3
unplugin-dts: ^1.0.2 unplugin-dts: ^1.0.3
unplugin-element-plus: ^0.11.2 unplugin-element-plus: ^0.11.2
unplugin-vue: ^7.2.0 unplugin-vue: ^7.2.0
vee-validate: ^4.15.1 vee-validate: ^4.15.1
vite: 8.0.10 vite: ^8.1.5
vite-plugin-compression: ^0.5.1 vite-plugin-compression: ^0.5.1
vite-plugin-lazy-import: ^1.0.7 vite-plugin-lazy-import: ^1.0.7
vite-plugin-pwa: ^1.3.0 vite-plugin-pwa: ^1.3.0
vite-plugin-vue-devtools: ^8.1.3 vite-plugin-vue-devtools: ^8.1.5
vitepress: ^2.0.0-alpha.17 vitepress: ^2.0.0-alpha.18
vitepress-plugin-group-icons: ^1.7.5 vitepress-plugin-group-icons: ^1.7.5
vitest: ^4.1.9 vitest: ^4.1.10
vue: ^3.5.38 vue: ^3.5.40
vue-eslint-parser: ^10.4.1 vue-eslint-parser: ^10.4.1
vue-i18n: ^11.4.5 vue-i18n: ^11.4.6
vue-json-pretty: ^2.6.0 vue-json-pretty: ^2.6.0
vue-router: ^5.1.0 vue-router: ^5.2.0
vue-tippy: ^6.7.1 vue-tippy: ^6.7.1
vue-tsc: ^3.3.5 vue-tsc: ^3.3.7
vxe-pc-ui: ^4.14.39 vxe-pc-ui: ^4.16.15
vxe-table: ^4.19.14 vxe-table: ^4.20.5
watermark-js-plus: ^1.6.3 watermark-js-plus: ^1.6.6
yaml-eslint-parser: ^2.0.0 yaml-eslint-parser: ^2.1.0
zod: ^3.25.76 zod: ^3.25.76
zod-defaults: 0.1.3 zod-defaults: 0.1.3

View File

@@ -1,4 +1,4 @@
import { execaCommand, getPackages } from '@vben/node-utils'; import { execa, getPackages } from '@vben/node-utils';
import { cancel, isCancel, select } from '@clack/prompts'; import { cancel, isCancel, select } from '@clack/prompts';
@@ -46,9 +46,13 @@ export async function run(options: RunOptions) {
process.exit(1); process.exit(1);
} }
execaCommand(`pnpm --filter=${selectPkg} run ${command}`, { try {
stdio: 'inherit', await execa('pnpm', [`--filter=${selectPkg}`, 'run', command], {
}); stdio: 'inherit',
});
} catch (error: any) {
process.exit(error.exitCode || 1);
}
} }
/** /**

View File

@@ -3,7 +3,7 @@ import type { CAC } from 'cac';
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { availableParallelism, freemem } from 'node:os'; import { availableParallelism, freemem } from 'node:os';
import { execaCommand } from '@vben/node-utils'; import { execa } from '@vben/node-utils';
interface LintCommandOptions { interface LintCommandOptions {
/** /**
@@ -29,16 +29,35 @@ const CPU_CORE_THRESHOLD = 4;
*/ */
const FREE_MEMORY_THRESHOLD = 8 * 1024 ** 3; const FREE_MEMORY_THRESHOLD = 8 * 1024 ** 3;
/**
* 一条命令:可执行文件名 + 参数数组。
*
* execa v10 移除了 execaCommand,execa(file, args?, options?) 的第一个参数是
* 「可执行文件名」而非整条命令行;且默认不走 shell。因此统一用 [file, args] 数组形式,
* 参数逐个传入、无需 shell 转义,跨平台行为一致(避免把整条字符串误当文件名)。
*/
type Command = [file: string, args: string[]];
/** 执行单条命令。 */
function runCommand([file, args]: Command) {
return execa(file, args, { stdio: 'inherit' });
}
/** 将命令还原为可读字符串,用于失败信息展示。 */
function formatCommand([file, args]: Command) {
return [file, ...args].join(' ');
}
/** /**
* 串行执行所有命令:一次只运行一个进程。 * 串行执行所有命令:一次只运行一个进程。
* 保证一次能看到所有工具的报错(配置低的机器更友好)。 * 保证一次能看到所有工具的报错(配置低的机器更友好)。
*/ */
async function runSerial(commands: string[]) { async function runSerial(commands: Command[]) {
const failed: string[] = []; const failed: Command[] = [];
for (const command of commands) { for (const command of commands) {
try { try {
await execaCommand(command, { stdio: 'inherit' }); await runCommand(command);
} catch { } catch {
failed.push(command); failed.push(command);
} }
@@ -46,7 +65,9 @@ async function runSerial(commands: string[]) {
if (failed.length > 0) { if (failed.length > 0) {
throw new Error( throw new Error(
`Lint failed:\n${failed.map((command) => ` - ${command}`).join('\n')}`, `Lint failed:\n${failed
.map((command) => ` - ${formatCommand(command)}`)
.join('\n')}`,
); );
} }
} }
@@ -55,10 +76,8 @@ async function runSerial(commands: string[]) {
* 并行执行所有命令:同时启动全部进程。 * 并行执行所有命令:同时启动全部进程。
* 任一进程失败时,强制结束其余仍在运行的进程,避免产生遗漏进程。 * 任一进程失败时,强制结束其余仍在运行的进程,避免产生遗漏进程。
*/ */
async function runParallel(commands: string[]) { async function runParallel(commands: Command[]) {
const subprocesses = commands.map((command) => const subprocesses = commands.map((command) => runCommand(command));
execaCommand(command, { stdio: 'inherit' }),
);
try { try {
await Promise.all(subprocesses); await Promise.all(subprocesses);
@@ -89,29 +108,23 @@ async function runLint({ format, threads }: LintCommandOptions) {
// 用户通过 --threads 显式指定时优先使用其值。 // 用户通过 --threads 显式指定时优先使用其值。
const defaultThreads = const defaultThreads =
cpuCores > CPU_CORE_THRESHOLD && freemem() > FREE_MEMORY_THRESHOLD ? 4 : 2; cpuCores > CPU_CORE_THRESHOLD && freemem() > FREE_MEMORY_THRESHOLD ? 4 : 2;
const threadsArg = ` --threads=${threads || defaultThreads}`; const threadsArg = `--threads=${threads || defaultThreads}`;
if (format) { if (format) {
await execaCommand(`stylelint "**/*.{vue,css,less,scss}" --cache --fix`, { await runSerial([
stdio: 'inherit', ['stylelint', ['**/*.{vue,css,less,scss}', '--cache', '--fix']],
}); ['oxfmt', [threadsArg]],
await execaCommand(`oxfmt${threadsArg}`, { ['oxlint', ['--fix', '--type-aware', threadsArg]],
stdio: 'inherit', ['eslint', ['.', '--cache', '--fix']],
}); ]);
await execaCommand(`oxlint --fix --type-aware${threadsArg}`, {
stdio: 'inherit',
});
await execaCommand(`eslint . --cache --fix`, {
stdio: 'inherit',
});
return; return;
} }
const commands = [ const commands: Command[] = [
`oxfmt --check${threadsArg}`, ['oxfmt', ['--check', threadsArg]],
`oxlint --type-aware${threadsArg}`, ['oxlint', ['--type-aware', threadsArg]],
`eslint . --cache`, ['eslint', ['.', '--cache']],
`stylelint "**/*.{vue,css,less,scss}" --cache`, ['stylelint', ['**/*.{vue,css,less,scss}', '--cache']],
]; ];
// 低配机器(CPU 核心数较少)串行执行,避免多进程并发导致瞬时占用飙升; // 低配机器(CPU 核心数较少)串行执行,避免多进程并发导致瞬时占用飙升;