This commit is contained in:
xingyu4j
2026-08-27 18:33:45 +08:00
45 changed files with 1287 additions and 331 deletions
@@ -0,0 +1,5 @@
---
'@vben/plugins': patch
---
fix(@vben/plugins): register echarts GraphicComponent for empty-state text
+5
View File
@@ -0,0 +1,5 @@
---
'@vben-core/layout-ui': patch
---
fix(@vben-core/layout-ui): keep in-flow footer below page content
@@ -141,7 +141,7 @@ function getAllNodeIds(nodes: any[], ids: number[] = []): number[] {
multiple
bordered
:default-expanded-keys="expandedKeys"
v-bind="slotProps.componentProps"
v-bind="slotProps"
:check-strictly="!isCheckStrictly"
value-field="id"
label-field="name"
@@ -161,7 +161,7 @@ function getNodeClass(node: Recordable<any>) {
bordered
:default-expanded-keys="expandedKeys"
:get-node-class="getNodeClass"
v-bind="slotProps.componentProps"
v-bind="slotProps"
value-field="id"
label-field="name"
/>
@@ -159,7 +159,7 @@ function getAllNodeIds(nodes: any[], ids: number[] = []): number[] {
multiple
bordered
:default-expanded-keys="expandedKeys"
v-bind="slotProps.componentProps"
v-bind="slotProps"
value-field="id"
label-field="name"
/>
+25 -25
View File
@@ -101,42 +101,42 @@ const items = computed(() => [{ title: $t('demos.title') }]);
## Adding a New Language Pack
If you need to add a new language pack, follow these steps:
If you need to add a new language pack, follow these steps (taking `zh-TW` Traditional Chinese as an example):
- Create the corresponding language pack folder and files under `packages/locales/langs` (e.g., `zh-TW/*.json`) and translate the texts accordingly.
- Create the corresponding language pack folder and files under `packages/locales/src/langs` (e.g., `zh-TW/*.json`) and translate the texts accordingly.
- 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`:
- Create a new d.ts file in the application (e.g., `src/locales/languages.d.ts`) and extend the language types via module augmentation:
```ts
export interface LanguageOption {
label: string;
value: 'en-US' | 'zh-CN'; // [!code --]
value: 'en-US' | 'zh-CN' | 'zh-TW'; // [!code ++]
export type { SupportedLanguages } from '@vben-core/typings';
declare module '@vben-core/typings' {
interface SupportedLanguages {
'zh-TW': '繁體中文';
}
}
export const SUPPORT_LANGUAGES: LanguageOption[] = [
{
label: '简体中文',
value: 'zh-CN',
},
{
label: 'English',
value: 'en-US',
},
{
label: '繁体中文', // [!code ++]
value: 'zh-TW', // [!code ++]
},
];
```
- In `packages/locales/typing.ts`, add a new TypeScript type:
::: tip Tip
The re-export at the top must not be omitted: it makes the file a module so that `declare module` acts as module augmentation. Otherwise it would be treated as an ambient module declaration that shadows the original module and loses all its types. The application also needs to declare the `@vben-core/typings` dependency (usually already available for internal monorepo packages).
:::
- Register the runtime language list at application startup (e.g., in `src/bootstrap.ts`). The language toggle component will display the new language automatically:
```ts
export type SupportedLanguagesType = 'en-US' | 'zh-CN'; // [!code --]
export type SupportedLanguagesType = 'en-US' | 'zh-CN' | 'zh-TW'; // [!code ++]
import { setSupportLanguages, SUPPORT_LANGUAGES } from '@vben/constants';
setSupportLanguages([
...SUPPORT_LANGUAGES,
{ label: '繁體中文', value: 'zh-TW' },
]);
```
At this point, you can use the newly added language pack in the project.
- If the application uses third-party libraries such as dayjs or component libraries, add the corresponding language pack branches in the locale loading logic of `src/locales/index.ts`.
At this point, you can use the newly added language pack in the project. The `SupportedLanguagesType` union will automatically include the new language, and all related APIs gain full type constraints.
## Interface Language Switching Function
+25 -25
View File
@@ -101,42 +101,42 @@ const items = computed(() => [{ title: $t('demos.title') }]);
## 新增语言包
如果你需要新增语言包,需要按照以下步骤进行:
如果你需要新增语言包,按照以下步骤进行(以新增 `zh-TW` 繁体中文为例):
- 在 `packages/locales/langs` 目录下新增对应的语言包文件夹和文件,例:`zh-TW/*.json`,并翻译对应的文本。
- 在 `packages/locales/src/langs` 目录下新增对应的语言包文件夹和文件,例:`zh-TW/*.json`,并翻译对应的文本。
- 在对应的应用内,找到 `src/locales/langs` 目录,新增对应的语言包文件夹和文件 `zh-TW/*.json`。
- 在 `packages/constants/src/core.ts`内,新增对应的语言:
- 在应用内新建一个 d.ts 文件(例:`src/locales/languages.d.ts`),通过模块增强扩展语言类型:
```ts
export interface LanguageOption {
label: string;
value: 'en-US' | 'zh-CN'; // [!code --]
value: 'en-US' | 'zh-CN' | 'zh-TW'; // [!code ++]
export type { SupportedLanguages } from '@vben-core/typings';
declare module '@vben-core/typings' {
interface SupportedLanguages {
'zh-TW': '繁體中文';
}
}
export const SUPPORT_LANGUAGES: LanguageOption[] = [
{
label: '简体中文',
value: 'zh-CN',
},
{
label: 'English',
value: 'en-US',
},
{
label: '繁体中文', // [!code ++]
value: 'zh-TW', // [!code ++]
},
];
```
- 在 `packages/locales/typing.ts`内,新增 Typescript 类型:
::: tip 提示
顶部的 re-export 不可省略:它使文件成为模块,`declare module` 才是模块增强;否则会被视为环境模块声明,遮蔽原模块导致其下所有类型丢失。同时应用需要声明 `@vben-core/typings` 依赖(monorepo 内部包通常已具备)。
:::
- 在应用启动时(例:`src/bootstrap.ts`)注册运行时语言列表,语言切换组件会自动显示新语言:
```ts
export type SupportedLanguagesType = 'en-US' | 'zh-CN'; // [!code --]
export type SupportedLanguagesType = 'en-US' | 'zh-CN' | 'zh-TW'; // [!code ++]
import { setSupportLanguages, SUPPORT_LANGUAGES } from '@vben/constants';
setSupportLanguages([
...SUPPORT_LANGUAGES,
{ label: '繁體中文', value: 'zh-TW' },
]);
```
到这里,你就可以在项目内使用新增的语言包了。
- 如果应用使用了 dayjs、组件库等第三方库,在 `src/locales/index.ts` 的语言加载逻辑中补充对应的语言包分支。
到这里,你就可以在项目内使用新增的语言包了,`SupportedLanguagesType` 联合类型会自动包含新语言,所有相关 API 均获得完整的类型约束。
## 界面切换语言功能
+1
View File
@@ -1,6 +1,7 @@
export type * from './app';
export type * from './basic';
export type * from './helper';
export type * from './languages';
export type * from './menu-record';
export type * from './tabs';
export type * from './vue-router';
@@ -0,0 +1,28 @@
/**
* 支持的语言注册表(核心默认语言)。
*
* 接口值仅为文档用途的语言标签,类型系统只消费键。
* 应用如需扩展语言,无需改动核心包,只需在应用内新建 d.ts 文件,
* 通过模块增强(declaration merging)追加语言键:
*
* ```ts
* declare module '@vben-core/typings' {
* interface SupportedLanguages {
* 'zh-TW': '繁體中文';
* }
* }
* ```
*
* 追加后 `SupportedLanguagesType` 联合类型会自动包含新语言,
* 所有引用该类型的包(preferences、locales、constants 等)同步获得类型约束;
* 运行时语言列表则通过 `setSupportLanguages` 注册。
*/
export interface SupportedLanguages {
'en-US': 'English';
'zh-CN': '简体中文';
}
/**
* 支持的语言类型
*/
export type SupportedLanguagesType = keyof SupportedLanguages;
+1
View File
@@ -37,6 +37,7 @@
},
"dependencies": {
"@vben-core/shared": "workspace:*",
"@vben-core/typings": "workspace:*",
"@vueuse/core": "catalog:",
"reka-ui": "catalog:",
"sortablejs": "catalog:",
@@ -1,6 +1,8 @@
export type Locale = 'en-US' | 'zh-CN';
import type { SupportedLanguagesType } from '@vben-core/typings';
export const messages: Record<Locale, Record<string, string>> = {
export type Locale = SupportedLanguagesType;
export const messages: Partial<Record<Locale, Record<string, string>>> = {
'en-US': {
cancel: 'Cancel',
collapse: 'Collapse',
@@ -25,4 +27,5 @@ export const messages: Record<Locale, Record<string, string>> = {
},
};
export const getMessages = (locale: Locale) => messages[locale];
export const getMessages = (locale: Locale) =>
messages[locale] ?? messages['en-US'] ?? {};
+1 -1
View File
@@ -12,11 +12,11 @@ import type {
NavigationStyleType,
PageTransitionType,
PreferencesButtonPositionType,
SupportedLanguagesType,
TabsStyleType,
ThemeModeType,
} from '@vben-core/typings';
type SupportedLanguagesType = 'en-US' | 'zh-CN';
type CustomPreferencesValue = boolean | number | string;
interface CustomPreferencesOption<TValue extends string = string> {
@@ -116,8 +116,7 @@ const computedSchema = computed((): NormalizedFormFieldSchema[] => {
:rules="cSchema.rules"
>
<template #default="slotProps">
<slot v-bind="slotProps.componentProps" :name="cSchema.fieldName">
</slot>
<slot v-bind="slotProps" :name="cSchema.fieldName"> </slot>
</template>
</FormField>
</template>
@@ -45,7 +45,6 @@ const style = computed((): CSSProperties => {
return {
...compactStyle,
flex: 1,
minHeight: 0,
minWidth: 0,
padding: `${padding}px`,
paddingBottom: `${paddingBottom}px`,
@@ -57,7 +56,7 @@ const style = computed((): CSSProperties => {
</script>
<template>
<main :style="style" class="relative min-h-0 min-w-0">
<main :style="style" class="relative min-w-0">
<div
v-if="$slots.overlay"
data-layout-region="content-overlay"
@@ -38,6 +38,7 @@ const style = computed((): CSSProperties => {
<template>
<footer
:style="style"
data-layout-region="footer"
class="bottom-0 w-full shrink-0 bg-background-deep transition-all duration-200"
>
<slot></slot>
@@ -373,6 +373,7 @@ const layoutScrollStyle = computed((): CSSProperties => {
const contentStyle = computed((): CSSProperties => {
const { footerEnable, footerFixed, footerHeight } = props;
return {
minHeight: footerEnable && !footerFixed ? undefined : 0,
paddingBottom: `${footerEnable && footerFixed ? footerHeight : 0}px`,
};
});
@@ -0,0 +1,112 @@
import type { App } from 'vue';
import { computed, createApp, defineComponent, h, nextTick, ref } from 'vue';
import { afterEach, describe, expect, it, vi } from 'vitest';
import Alert from '../alert.vue';
vi.mock('@vben-core/composables', () => ({
useScrollLock: () =>
computed({
get: () => false,
set: () => {},
}),
useSimpleLocale: () => ({
$t: (key: string) => key,
}),
}));
vi.mock('@vben-core/preferences', () => ({
usePreferences: () => ({
globalEscapeShortcutKey: { value: true },
}),
}));
let activeApp: App | undefined;
/**
* 挂载一个受控的 Alert 组件,用于验证鼠标点击与键盘交互后的关闭行为和事件副作用。
*/
async function mountAlert() {
const state = ref(true);
const onConfirm = vi.fn();
const onUpdateOpen = vi.fn((value: boolean) => {
state.value = value;
});
const Consumer = defineComponent(
() => () =>
h(Alert, {
content: 'Alert content',
title: 'Alert title',
confirmText: 'Confirm',
cancelText: 'Cancel',
open: state.value,
showCancel: true,
onConfirm,
'onUpdate:open': onUpdateOpen,
}),
);
const host = document.createElement('div');
document.body.append(host);
activeApp = createApp(Consumer);
activeApp.mount(host);
await nextTick();
await nextTick();
return { onConfirm, onUpdateOpen, state };
}
afterEach(() => {
activeApp?.unmount();
activeApp = undefined;
document.body.innerHTML = '';
vi.restoreAllMocks();
});
describe('vben alert', () => {
it('closes and emits confirm once when the confirm button is clicked', async () => {
const { onConfirm, onUpdateOpen, state } = await mountAlert();
const button = [...document.querySelectorAll('button')].find(
(element) => element.textContent?.trim() === 'Confirm',
);
expect(button).toBeInstanceOf(HTMLElement);
if (!(button instanceof HTMLElement)) return;
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await nextTick();
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(onUpdateOpen).toHaveBeenCalledWith(false);
expect(state.value).toBe(false);
});
it('closes without confirming when the cancel button is clicked', async () => {
const { onConfirm, onUpdateOpen, state } = await mountAlert();
const button = [...document.querySelectorAll('button')].find(
(element) => element.textContent?.trim() === 'Cancel',
);
expect(button).toBeInstanceOf(HTMLElement);
if (!(button instanceof HTMLElement)) return;
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await nextTick();
expect(onConfirm).not.toHaveBeenCalled();
expect(onUpdateOpen).toHaveBeenCalledWith(false);
expect(state.value).toBe(false);
});
it('closes when Escape is pressed', async () => {
const { onConfirm, onUpdateOpen, state } = await mountAlert();
document.dispatchEvent(
new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }),
);
await nextTick();
expect(onConfirm).not.toHaveBeenCalled();
expect(onUpdateOpen).toHaveBeenCalledWith(false);
expect(state.value).toBe(false);
});
});
@@ -31,7 +31,7 @@ export type AlertProps = {
/** 弹窗容器的额外样式 */
containerClass?: string;
/** 弹窗提示内容 */
content: Component | string;
content?: Component | string;
/** 弹窗内容的额外样式 */
contentClass?: string;
/** 执行beforeClose回调期间,在内容区域显示一个loading遮罩*/
@@ -57,7 +57,7 @@ export type PromptProps<T = any> = {
/** 关闭前的回调,如果返回false,则终止关闭 */
beforeClose?: (scope: {
isConfirm: boolean;
value: T | undefined;
value?: T;
}) => boolean | Promise<boolean | undefined> | undefined;
/** 用于接受用户输入的组件 */
component?: Component;
@@ -6,7 +6,7 @@ import type { ClassType, Recordable } from '@vben-core/typings';
import type { TreeProps } from './types';
import { computed, onMounted, ref, watchEffect } from 'vue';
import { computed, ref, watch } from 'vue';
import { ChevronRight, IconifyIcon } from '@vben-core/icons';
import { cn, get } from '@vben-core/shared/utils';
@@ -62,36 +62,21 @@ function flatten<T = Recordable<any>, P = number | string>(
const flattenData = ref<Array<InnerFlattenItem>>([]);
const modelValue = defineModel<Arrayable<number | string>>();
const expanded = ref<Array<number | string>>(props.defaultExpandedKeys ?? []);
const expanded = ref<Array<number | string>>([]);
const treeValue = ref();
let lastTreeData: any = null;
onMounted(() => {
watchEffect(() => {
flattenData.value = flatten(props.treeData, props.childrenField);
if (flattenData.value.length > 0) {
updateTreeValue();
}
// 只在 treeData 变化时执行展开
const currentTreeData = JSON.stringify(props.treeData);
if (lastTreeData !== currentTreeData) {
lastTreeData = currentTreeData;
if (
props.defaultExpandedLevel !== undefined &&
props.defaultExpandedLevel > 0
) {
expandToLevel(props.defaultExpandedLevel);
}
}
});
/** ID → 原始数据映射,O(1) 查找替代 O(n) find */
const valueMap = computed(() => {
const map = new Map<number | string, Recordable<any>>();
for (const item of flattenData.value) {
map.set(get(item.value, props.valueField) as number | string, item.value);
}
return map;
});
function getItemByValue(value: number | string) {
return flattenData.value.find(
(item) => get(item.value, props.valueField) === value,
)?.value;
return valueMap.value.get(value);
}
function updateTreeValue() {
@@ -102,14 +87,30 @@ function updateTreeValue() {
if (val.length === 0) {
treeValue.value = [];
} else {
const filteredValues = val.filter((v) => {
// 单次遍历完成过滤 + 映射,避免重复 getItemByValue
const enabledItems: Recordable<any>[] = [];
const enabledIds: (number | string)[] = [];
for (const v of val) {
const item = getItemByValue(v);
return item && !get(item, props.disabledField);
});
treeValue.value = filteredValues.map((v) => getItemByValue(v));
if (item && !get(item, props.disabledField)) {
enabledItems.push(item);
enabledIds.push(v);
}
}
if (filteredValues.length !== val.length) {
modelValue.value = filteredValues;
// 半选节点不传给 Reka UI 内部选择:Reka UI 会把 model-value 中的节点当作已选中,
// 点击半选祖先时会被视为取消选择,误移除祖先及其所有后代。半选节点仅保留在 modelValue 返回值中。
if (props.includeIndeterminate && props.multiple) {
const excludedKeys = computeIndeterminateKeysInSelection(enabledIds);
treeValue.value = enabledItems.filter(
(item) => !excludedKeys.has(get(item.value, props.valueField)),
);
} else {
treeValue.value = enabledItems;
}
if (enabledIds.length !== val.length) {
modelValue.value = mergeIndeterminate(enabledIds);
}
}
} else {
@@ -123,24 +124,85 @@ function updateTreeValue() {
}
}
// 初始化:构建 flattenData 并同步 treeValue
flattenData.value = flatten(props.treeData, props.childrenField);
if (flattenData.value.length > 0) {
updateTreeValue();
}
// 初始化:展开 — defaultExpandedLevel 优先,否则使用 defaultExpandedKeys
if (
props.defaultExpandedLevel !== undefined &&
props.defaultExpandedLevel > 0
) {
expandToLevel(props.defaultExpandedLevel);
} else {
expanded.value = props.defaultExpandedKeys ?? [];
}
// 仅监听 treeData 及相关字段变更,重建 flattenData
let lastTreeData: any = JSON.stringify(props.treeData);
watch(
() => ({
treeData: props.treeData,
childrenField: props.childrenField,
valueField: props.valueField,
}),
() => {
flattenData.value = flatten(props.treeData, props.childrenField);
if (flattenData.value.length > 0) {
updateTreeValue();
}
// 仅在 treeData 内容实质变化时才重新展开(避免过度展开)
const currentTreeData = JSON.stringify(props.treeData);
if (lastTreeData !== currentTreeData) {
lastTreeData = currentTreeData;
if (
props.defaultExpandedLevel !== undefined &&
props.defaultExpandedLevel > 0
) {
expandToLevel(props.defaultExpandedLevel);
}
}
},
{ deep: true },
);
// 仅监听 modelValue 变更,同步 treeValue(不重建 flattenData,避免响应式回环)
// flush: 'sync' 确保赋值后立即同步,配合 checkAll/unCheckAll 移除手动 updateTreeValue 调用
watch(
() => modelValue.value,
() => {
if (flattenData.value.length > 0) {
updateTreeValue();
}
},
{ flush: 'sync' },
);
function updateModelValue(val: Arrayable<Recordable<any>>) {
if (Array.isArray(val)) {
const filteredVal = val.filter((v) => !get(v, props.disabledField));
modelValue.value = [
...new Set(filteredVal.map((v) => get(v, props.valueField))),
];
// modelValue.value = [
// ...new Set(filteredVal.map((v) => get(v, props.valueField))),
// ];
const selectedIds = filteredVal.map((v) => get(v, props.valueField));
modelValue.value = mergeIndeterminate(selectedIds);
} else {
if (val && !get(val, props.disabledField)) {
modelValue.value = get(val, props.valueField);
}
// 单选模式下取消选择时 val 为 null/undefined,需要同步清空
modelValue.value =
val && !get(val, props.disabledField)
? get(val, props.valueField)
: undefined;
}
}
function expandToLevel(level: number) {
const keys: string[] = [];
const keys: (number | string)[] = [];
flattenData.value.forEach((item) => {
if (item.level <= level - 1) {
keys.push(get(item.value, props.valueField));
keys.push(get(item.value, props.valueField) as number | string);
}
});
expanded.value = keys;
@@ -174,20 +236,19 @@ function collapseAll() {
function checkAll() {
if (!props.multiple) return;
modelValue.value = [
const selectedIds = [
...new Set(
flattenData.value
.filter((item) => !get(item.value, props.disabledField))
.map((item) => get(item.value, props.valueField)),
),
];
updateTreeValue();
modelValue.value = mergeIndeterminate(selectedIds);
}
function unCheckAll() {
if (!props.multiple) return;
modelValue.value = [];
updateTreeValue();
}
function isNodeDisabled(item: FlattenedItem<Recordable<any>>) {
@@ -203,9 +264,8 @@ const selectAllStatus = computed<'indeterminate' | boolean>(() => {
.filter((item) => !get(item.value, props.disabledField))
.map((item) => get(item.value, props.valueField));
const selectedCount = allValues.filter((v) =>
(modelValue.value as (number | string)[]).includes(v),
).length;
const selectedSet = new Set(modelValue.value as (number | string)[]);
const selectedCount = allValues.filter((v) => selectedSet.has(v)).length;
if (selectedCount === 0) return false;
if (selectedCount === allValues.length) return true;
@@ -228,67 +288,177 @@ function onSelect(item: FlattenedItem<Recordable<any>>, isSelected: boolean) {
return;
}
if (
props.checkStrictly &&
props.multiple &&
props.autoCheckParent &&
isSelected
) {
flattenData.value
.find((i) => {
return (
get(i.value, props.valueField) === get(item.value, props.valueField)
);
})
?.parents?.filter((item) => !get(item, props.disabledField))
?.forEach((p) => {
if (Array.isArray(modelValue.value) && !modelValue.value.includes(p)) {
modelValue.value.push(p);
}
});
}
if (
props.checkStrictly &&
props.multiple &&
props.autoCheckParent &&
!isSelected
) {
flattenData.value
.find((i) => {
return (
get(i.value, props.valueField) === get(item.value, props.valueField)
);
})
?.parents?.filter((item) => !get(item, props.disabledField))
?.toReversed()
.forEach((p) => {
const children = flattenData.value.filter((i) => {
return (
i.parents.length > 0 &&
i.parents.includes(p) &&
i.id !== item._id &&
i.parentId === p
);
if (props.checkStrictly && props.multiple && props.autoCheckParent) {
// 获取当前节点在 flattenData 中的记录以读取 parents
const flatItem = flattenData.value.find(
(i) =>
get(i.value, props.valueField) === get(item.value, props.valueField),
);
if (isSelected) {
// 选中时:将所有已启用的祖先追加到 modelValue(keepParentOnUncheck 不影响选中时的祖先追加)
flatItem?.parents
?.filter((p) => {
const parentItem = getItemByValue(p);
return parentItem && !get(parentItem, props.disabledField);
})
?.forEach((p) => {
if (
Array.isArray(modelValue.value) &&
!modelValue.value.includes(p)
) {
modelValue.value.push(p);
}
});
if (Array.isArray(modelValue.value)) {
const hasSelectedChild = children.some((child) =>
(modelValue.value as unknown[]).includes(
get(child.value, props.valueField),
),
} else if (!props.keepParentOnUncheck) {
// 取消选中时:移除不再有已选子节点的祖先;keepParentOnUncheck 为 true 时保留父节点选中状态
flatItem?.parents
?.filter((p) => {
const parentItem = getItemByValue(p);
return parentItem && !get(parentItem, props.disabledField);
})
?.toReversed()
?.forEach((p) => {
const hasSelectedChild = flattenData.value.some(
(i) =>
i.parentId === p &&
i.id !== item._id &&
Array.isArray(modelValue.value) &&
(modelValue.value as (number | string)[]).includes(
get(i.value, props.valueField) as number | string,
),
);
if (!hasSelectedChild) {
const index = modelValue.value.indexOf(p);
const index = (modelValue.value as (number | string)[]).indexOf(p);
if (index !== -1) {
modelValue.value.splice(index, 1);
(modelValue.value as (number | string)[]).splice(index, 1);
}
}
}
});
});
}
}
// 确保 watcher 同步 treeValue:checkStrictly 模式需要新引用(祖先 push/pop 已原位修改数组),
// 或 includeIndeterminate 需合并半选节点时创建新引用
if (props.multiple && Array.isArray(modelValue.value)) {
if (props.includeIndeterminate) {
modelValue.value = mergeIndeterminate(
modelValue.value as (number | string)[],
);
} else if (props.checkStrictly) {
modelValue.value = [...modelValue.value];
}
}
updateTreeValue();
emits('select', item);
}
/** 计算给定选中列表中,处于半选(indeterminate)状态的节点 key */
function computeIndeterminateKeys(
selectedIds: (number | string)[],
): (number | string)[] {
if (
!props.multiple ||
selectedIds.length === 0 ||
flattenData.value.length === 0
)
return [];
const selectedSet = new Set(selectedIds);
// 统计每个祖先节点下有多少个选中后代
const ancestorCounts = new Map<number | string, number>();
for (const node of flattenData.value) {
const nodeId = get(node.value, props.valueField) as number | string;
if (!selectedSet.has(nodeId)) continue;
// 为当前选中节点的所有祖先累加计数
for (const parentId of node.parents) {
ancestorCounts.set(parentId, (ancestorCounts.get(parentId) ?? 0) + 1);
}
}
// 过滤出自身未选中的祖先即为半选节点
const result: (number | string)[] = [];
for (const [nodeId] of ancestorCounts) {
if (!selectedSet.has(nodeId)) {
result.push(nodeId);
}
}
return result;
}
/**
* 计算选中列表中"半选祖先节点"的 key:节点自身已在选中列表,但后代仅部分被选中。
* 与 computeIndeterminateKeys 不同,它基于后代选中数量统计,适用于 modelValue 已合并半选节点的场景,
* 用于从传给 Reka UI 的内部选择中剔除半选节点,并用于补充复选框的半选状态展示。
*/
function computeIndeterminateKeysInSelection(
selectedIds: (number | string)[],
): Set<number | string> {
const result = new Set<number | string>();
if (
!props.multiple ||
selectedIds.length === 0 ||
flattenData.value.length === 0
) {
return result;
}
const selectedSet = new Set(selectedIds);
const totalDescendants = new Map<number | string, number>();
const selectedDescendants = new Map<number | string, number>();
for (const node of flattenData.value) {
const nodeId = get(node.value, props.valueField) as number | string;
for (const parentId of node.parents) {
totalDescendants.set(parentId, (totalDescendants.get(parentId) ?? 0) + 1);
if (selectedSet.has(nodeId)) {
selectedDescendants.set(
parentId,
(selectedDescendants.get(parentId) ?? 0) + 1,
);
}
}
}
for (const id of selectedIds) {
const total = totalDescendants.get(id) ?? 0;
const selected = selectedDescendants.get(id) ?? 0;
if (total > 0 && selected > 0 && selected < total) {
result.add(id);
}
}
return result;
}
/** 半选节点 key 集合,用于 Reka UI 不计算半选状态的场景(如 checkStrictly)下补充复选框展示 */
const indeterminateKeys = computed(() => {
if (!props.multiple || !props.includeIndeterminate) {
return new Set<number | string>();
}
const ids = Array.isArray(modelValue.value)
? (modelValue.value as (number | string)[])
: [];
return computeIndeterminateKeysInSelection(ids);
});
/** 根据 includeIndeterminate 配置,将半选节点合并到选中列表 */
function mergeIndeterminate(
selectedIds: (number | string)[],
): (number | string)[] {
if (
!props.includeIndeterminate ||
!props.multiple ||
selectedIds.length === 0
) {
return selectedIds;
}
const indeterminate = computeIndeterminateKeys(selectedIds);
if (indeterminate.length === 0) return selectedIds;
return [...new Set([...selectedIds, ...indeterminate])];
}
defineExpose({
collapseAll,
collapseNodes,
@@ -302,8 +472,8 @@ defineExpose({
</script>
<template>
<TreeRoot
:get-key="(item) => get(item, valueField)"
:get-children="(item) => get(item, childrenField)"
:get-key="(item: any) => get(item, valueField)"
:get-children="(item: any) => get(item, childrenField)"
:items="treeData"
:model-value="treeValue"
v-model:expanded="expanded as string[]"
@@ -368,19 +538,17 @@ defineExpose({
handleToggle,
}"
:key="item._id"
:style="{ 'margin-left': `${item.level - 1}rem` }"
:class="
cn('cursor-pointer', getNodeClass?.(item), {
'data-[selected]:bg-accent': !multiple,
'text-foreground/50 cursor-not-allowed': isNodeDisabled(item),
})
"
v-bind="
Object.assign(item.bind, {
onfocus: isNodeDisabled(item) ? 'this.blur()' : undefined,
disabled: isNodeDisabled(item),
})
"
v-bind="{
...item.bind,
onfocus: isNodeDisabled(item) ? 'this.blur()' : undefined,
disabled: isNodeDisabled(item),
}"
@select="
(event: any) => {
if (isNodeDisabled(item)) {
@@ -402,67 +570,78 @@ defineExpose({
!isNodeDisabled(item) && onToggle(item);
}
"
class="tree-node focus:ring-grass8 my-0.5 flex items-center rounded p-1 outline-hidden"
class="tree-node focus:ring-grass8 my-0.5 rounded outline-hidden"
>
<!-- class="hover:ring-2" 鼠标移动上去时2px的圆环边框 -->
<ChevronRight
v-if="
item.hasChildren &&
Array.isArray(item.value[childrenField]) &&
item.value[childrenField].length > 0
"
class="text-foreground/80 hover:text-foreground size-4 cursor-pointer transition"
:class="{ 'rotate-90': isExpanded }"
@click.stop="
() => {
handleToggle();
onToggle(item);
}
"
/>
<div v-else class="h-4 w-4"></div>
<div class="flex items-center gap-1 item-checkbox">
<Checkbox
v-if="multiple"
:model-value="isSelected && !isNodeDisabled(item)"
:disabled="isNodeDisabled(item)"
:indeterminate="isIndeterminate && !isNodeDisabled(item)"
@click="
(event: MouseEvent) => {
if (isNodeDisabled(item)) {
event.preventDefault();
event.stopPropagation();
return;
<!-- 内容行通过 paddingLeft 控制缩进,不受 TreeItem 自身的渲染行为影响 -->
<div
class="flex items-center w-full p-1"
:style="{ paddingLeft: `${item.level - 1 + 0.25}rem` }"
>
<!-- 固定宽度展开区,有子节点时内部渲染箭头,无子节点时为空但宽度不变 -->
<div class="flex shrink-0 items-center justify-center w-5">
<ChevronRight
v-if="
item.hasChildren &&
Array.isArray(item.value[childrenField]) &&
item.value[childrenField].length > 0
"
class="text-foreground/80 hover:text-foreground size-4 cursor-pointer transition"
:class="{ 'rotate-90': isExpanded }"
@click.stop="
() => {
handleToggle();
onToggle(item);
}
handleSelect();
}
"
/>
<div
class="flex items-center gap-1 item-checkbox"
:title="get(item.value, labelField)"
@click="
(event: MouseEvent) => {
if (isNodeDisabled(item)) {
event.preventDefault();
event.stopPropagation();
return;
}
handleSelect();
}
"
>
<slot name="node" v-bind="item">
<IconifyIcon
class="size-4"
v-if="showIcon && get(item.value, iconField)"
:icon="get(item.value, iconField)"
/>
{{ get(item.value, labelField) }}
</slot>
"
/>
</div>
<div class="flex items-center gap-1 item-checkbox">
<Checkbox
v-if="multiple"
:model-value="isSelected && !isNodeDisabled(item)"
:disabled="isNodeDisabled(item)"
:indeterminate="
(isIndeterminate ||
indeterminateKeys.has(get(item.value, valueField))) &&
!isNodeDisabled(item)
"
@click="
(event: MouseEvent) => {
if (isNodeDisabled(item)) {
event.preventDefault();
event.stopPropagation();
return;
}
handleSelect();
}
"
/>
<div
class="flex items-center gap-1 item-checkbox"
:title="get(item.value, labelField)"
@click="
(event: MouseEvent) => {
if (isNodeDisabled(item)) {
event.preventDefault();
event.stopPropagation();
return;
}
handleSelect();
}
"
>
<slot name="node" v-bind="item">
<IconifyIcon
class="size-4"
v-if="showIcon && get(item.value, iconField)"
:icon="get(item.value, iconField)"
/>
{{ get(item.value, labelField) }}
</slot>
</div>
</div>
<div class="h-4 w-4 shrink-0"></div>
</div>
<div class="h-4 w-4"></div>
</TreeItem>
</TransitionGroup>
<div
@@ -475,21 +654,8 @@ defineExpose({
</div>
</TreeRoot>
</template>
<style lang="scss" scoped>
.container {
position: relative;
padding: 0;
list-style-type: none;
}
.item {
box-sizing: border-box;
width: 100%;
height: 30px;
background-color: #f3f3f3;
border: 1px solid #666;
}
.item-checkbox {
width: 100%;
overflow: hidden;
@@ -27,6 +27,10 @@ export interface TreeProps {
/** 自定义节点类名 */
getNodeClass?: (item: FlattenedItem<Recordable<any>>) => string;
iconField?: string;
/** 返回值是否包含半选节点(默认 false,仅返回全选节点;true 则返回全选 ∪ 半选) */
includeIndeterminate?: boolean;
/** 取消勾选子节点时保持父节点的选中状态,不级联取消父节点(默认 false:级联取消) */
keepParentOnUncheck?: boolean;
/** label字段 */
labelField?: string;
/** 是否多选 */
@@ -58,6 +62,8 @@ export function treePropsDefaults() {
multiple: false,
showIcon: true,
transition: true,
includeIndeterminate: false,
keepParentOnUncheck: false,
valueField: 'value',
childrenField: 'children',
};
+2 -1
View File
@@ -20,6 +20,7 @@
}
},
"dependencies": {
"@vben-core/shared": "workspace:*"
"@vben-core/shared": "workspace:*",
"@vben-core/typings": "workspace:*"
}
}
+57 -4
View File
@@ -1,17 +1,24 @@
import type { SupportedLanguagesType } from '@vben-core/typings';
/**
* @zh_CN 登录页面 url 地址
*/
export const LOGIN_PATH = '/auth/login';
export interface LanguageOption {
/**
* 语言选项;value 默认为 string 以支持运行时注册自定义语言(见 setSupportLanguages)
*/
export interface LanguageOption<TValue extends string = string> {
label: string;
value: 'en-US' | 'zh-CN';
value: TValue;
}
/**
* Supported languages
* 支持的语言(默认值,可在运行时通过 setSupportLanguages 覆盖)。
* value 约束到 SupportedLanguagesType,应用通过模块增强扩展语言后,
* 默认列表同样接受新语言,拼写错误会在编译期报错
*/
export const SUPPORT_LANGUAGES: LanguageOption[] = [
export const SUPPORT_LANGUAGES: LanguageOption<SupportedLanguagesType>[] = [
{
label: '简体中文',
value: 'zh-CN',
@@ -21,3 +28,49 @@ export const SUPPORT_LANGUAGES: LanguageOption[] = [
value: 'en-US',
},
];
// --- 动态语言列表管理 ---
type LanguageListListener = (languages: LanguageOption[]) => void;
// 深拷贝语言列表,外部拿到的永远是独立快照,
// 避免绕过 setSupportLanguages 直接修改注册表内部状态
function cloneLanguages(languages: LanguageOption[]): LanguageOption[] {
return languages.map((language) => ({ ...language }));
}
let currentLanguages: LanguageOption[] = cloneLanguages(SUPPORT_LANGUAGES);
const listeners: LanguageListListener[] = [];
/**
* 获取当前动态语言列表
*/
export function getSupportLanguages(): LanguageOption[] {
return cloneLanguages(currentLanguages);
}
/**
* 更新语言列表并通知所有监听器
*/
export function setSupportLanguages(languages: LanguageOption[]) {
// 先固化本次快照,即使监听器回调中嵌套调用 setSupportLanguages,
// 后续监听器收到的仍是本次更新的列表
const nextLanguages = cloneLanguages(languages);
currentLanguages = nextLanguages;
// 迭代监听器快照,避免监听器在回调中取消订阅导致跳过后续监听器
for (const listener of [...listeners]) {
listener(cloneLanguages(nextLanguages));
}
}
/**
* 订阅语言列表变化,返回取消订阅函数
*/
export function onSupportLanguagesChange(listener: LanguageListListener) {
listeners.push(listener);
// 订阅时立即回调当前值,便于消费方初始化
listener(cloneLanguages(currentLanguages));
return () => {
const index = listeners.indexOf(listener);
if (index !== -1) listeners.splice(index, 1);
};
}
@@ -206,7 +206,7 @@ defineExpose({
@start="handleStart"
>
<template v-for="(_, key) in $slots" :key="key" #[key]="slotProps">
<slot :name="key" v-bind="slotProps.componentProps"></slot>
<slot :name="key" v-bind="slotProps"></slot>
</template>
</SliderCaptcha>
</div>
@@ -304,7 +304,7 @@ onMounted(() => {
@start="handleStart"
>
<template v-for="(_, key) in $slots" :key="key" #[key]="slotProps">
<slot :name="key" v-bind="slotProps.componentProps"></slot>
<slot :name="key" v-bind="slotProps"></slot>
</template>
</SliderCaptcha>
</div>
@@ -63,7 +63,7 @@ defineExpose({
:key="slotName"
#[slotName]="slotProps"
>
<slot :name="slotName" v-bind="slotProps.componentProps"></slot>
<slot :name="slotName" v-bind="slotProps"></slot>
</template>
<ResizablePanelGroup class="w-full" direction="horizontal">
@@ -12,7 +12,7 @@ const props = withDefaults(defineProps<TreeProps>(), treePropsDefaults());
<template>
<VbenTree v-if="props.treeData?.length > 0" v-bind="props">
<template v-for="(_, key) in $slots" :key="key" #[key]="slotProps">
<slot :name="key" v-bind="slotProps.componentProps"> </slot>
<slot :name="key" v-bind="slotProps"> </slot>
</template>
</VbenTree>
<div
+25 -5
View File
@@ -38,7 +38,10 @@ import { useLayoutScroll } from './use-layout-scroll';
defineOptions({ name: 'BasicLayout' });
withDefaults(defineProps<Props>(), {
const props = withDefaults(defineProps<Props>(), {
logoSrc: '',
logoSrcDark: '',
logoText: '',
avatar: '',
text: '',
});
@@ -50,10 +53,27 @@ const emit = defineEmits<{
}>();
interface Props {
/** 自定义 Logo 图片地址,不传则使用偏好设置默认值 */
logoSrc?: string;
/** 自定义暗色主题 Logo 图片地址 */
logoSrcDark?: string;
/** 自定义 Logo 文本,不传则使用偏好设置默认值 */
logoText?: string;
/** 用户头像图片地址 */
avatar?: string;
/** 用户文本(如用户名) */
text?: string;
}
/** 最终使用的 Logo 图片地址(自定义优先,否则使用默认) */
const finalLogoSrc = computed(() => props.logoSrc || preferences.logo.source);
/** 最终使用的暗色 Logo 图片地址 */
const finalLogoSrcDark = computed(
() => props.logoSrcDark || preferences.logo.sourceDark,
);
/** 最终使用的 Logo 文本(自定义优先,否则使用默认应用名称) */
const finalLogoText = computed(() => props.logoText || preferences.app.name);
const {
isDark,
isHeaderNav,
@@ -323,9 +343,9 @@ const headerSlots = computed(() => {
:fit="preferences.logo.fit"
:class="logoClass"
:collapsed="logoCollapsed"
:src="preferences.logo.source"
:src-dark="preferences.logo.sourceDark"
:text="preferences.app.name"
:src="finalLogoSrc"
:src-dark="finalLogoSrcDark"
:text="finalLogoText"
:show-text="preferences.logo.showText"
:logo-mode="preferences.logo.logoMode"
:full-logo-height="preferences.logo.fullLogoHeight"
@@ -419,8 +439,8 @@ const headerSlots = computed(() => {
<VbenLogo
v-if="preferences.logo.enable"
:fit="preferences.logo.fit"
:text="finalLogoText"
:show-text="preferences.logo.showText"
:text="preferences.app.name"
:theme="sidebarThemeSub"
>
<template v-if="$slots['logo-text']" #text>
@@ -1,7 +1,10 @@
<script setup lang="ts">
import type { LanguageOption } from '@vben/constants';
import type { SupportedLanguagesType } from '@vben/locales';
import { SUPPORT_LANGUAGES } from '@vben/constants';
import { onUnmounted, ref } from 'vue';
import { onSupportLanguagesChange } from '@vben/constants';
import { Languages } from '@vben/icons';
import { loadLocaleMessages } from '@vben/locales';
import { preferences, updatePreferences } from '@vben/preferences';
@@ -12,6 +15,12 @@ defineOptions({
name: 'LanguageToggle',
});
const languageList = ref<LanguageOption[]>([]);
const unsubscribe = onSupportLanguagesChange((langs) => {
languageList.value = [...langs];
});
onUnmounted(unsubscribe);
async function handleUpdate(value: string | undefined) {
if (!value) return;
const locale = value as SupportedLanguagesType;
@@ -27,7 +36,7 @@ async function handleUpdate(value: string | undefined) {
<template>
<div>
<VbenDropdownRadioMenu
:menus="SUPPORT_LANGUAGES"
:menus="languageList"
:model-value="preferences.app.locale"
@update:model-value="handleUpdate"
>
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { onMounted, ref, unref } from 'vue';
import type { LanguageOption } from '@vben/constants';
import { SUPPORT_LANGUAGES } from '@vben/constants';
import { onMounted, onUnmounted, ref, unref } from 'vue';
import { onSupportLanguagesChange } from '@vben/constants';
import { $t } from '@vben/locales';
import { useTimezoneStore } from '@vben/stores';
@@ -24,6 +26,12 @@ const appEnableCopyPreferences = defineModel<boolean>(
);
const timezoneStore = useTimezoneStore();
const languageList = ref<LanguageOption[]>([]);
const unsubscribe = onSupportLanguagesChange((langs) => {
languageList.value = [...langs];
});
onUnmounted(unsubscribe);
const timezoneOptionsRef = ref<
{
label: string;
@@ -42,7 +50,7 @@ onMounted(async () => {
</script>
<template>
<SelectItem v-model="appLocale" :items="SUPPORT_LANGUAGES">
<SelectItem v-model="appLocale" :items="languageList">
{{ $t('preferences.language') }}
</SelectItem>
<SelectItem v-model="appTimezone" :items="timezoneOptionsRef">
@@ -1,11 +1,13 @@
<script setup lang="ts">
import type { Component } from 'vue';
import type { LanguageOption } from '@vben/constants';
import type { SupportedLanguagesType } from '@vben/locales';
import type { AnyFunction } from '@vben/types';
import { computed, ref, useTemplateRef, watch } from 'vue';
import { computed, onUnmounted, ref, useTemplateRef, watch } from 'vue';
import { SUPPORT_LANGUAGES } from '@vben/constants';
import { onSupportLanguagesChange } from '@vben/constants';
import { useHoverToggle, useRefresh } from '@vben/hooks';
import {
createIconifyIcon,
@@ -278,15 +280,22 @@ function handleNotificationSelect(event?: Event) {
// 语言切换 - 阻止 Radix 默认关闭外层 dropdown,就地展开/收起 locale 列表
const showLanguageList = ref(false);
const languageList = ref<LanguageOption[]>([]);
const unsubLang = onSupportLanguagesChange((langs) => {
languageList.value = [...langs];
});
onUnmounted(unsubLang);
function handleLanguageToggleSelect(event?: Event) {
event?.preventDefault();
showLanguageList.value = !showLanguageList.value;
}
async function handleLocaleChange(event: Event, value: 'en-US' | 'zh-CN') {
async function handleLocaleChange(event: Event, value: string) {
// 阻止默认关闭,让用户能继续看到选择结果;选完手动收起
event.preventDefault();
updatePreferences({ app: { locale: value } });
await loadLocaleMessages(value);
// 菜单项来自运行时语言列表(可为自定义语言),此处为运行时边界断言
const locale = value as SupportedLanguagesType;
updatePreferences({ app: { locale } });
await loadLocaleMessages(locale);
showLanguageList.value = false;
openPopover.value = false;
}
@@ -480,7 +489,7 @@ if (preferences.shortcutKeys.enable) {
</DropdownMenuItem>
<template v-if="showLanguageList">
<DropdownMenuItem
v-for="lang in SUPPORT_LANGUAGES"
v-for="lang in languageList"
: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)"
+1
View File
@@ -66,6 +66,7 @@
"@vben-core/popup-ui": "workspace:*",
"@vben-core/shadcn-ui": "workspace:*",
"@vben-core/shared": "workspace:*",
"@vben-core/typings": "workspace:*",
"@vben/hooks": "workspace:*",
"@vben/icons": "workspace:*",
"@vben/locales": "workspace:*",
@@ -13,6 +13,7 @@ import {
DataZoomInsideComponent,
DataZoomSliderComponent,
GeoComponent,
GraphicComponent,
GridComponent,
LegendComponent,
TitleComponent,
@@ -54,6 +55,7 @@ echarts.use([
VisualMapComponent,
MapChart,
GeoComponent,
GraphicComponent,
]);
export default echarts;
+11 -3
View File
@@ -1,3 +1,5 @@
import type { SupportedLanguagesType } from '@vben-core/typings';
import type { SetupVxeTable } from './types';
import { defineComponent, watch } from 'vue';
@@ -126,7 +128,7 @@ export function setupVbenVxeTable(setupOptions: SetupVxeTable) {
}
const { isDark, locale } = usePreferences();
const localMap = {
const localMap: Partial<Record<SupportedLanguagesType, any>> = {
'zh-CN': normalizeVxeLocale(zhCN),
'en-US': normalizeVxeLocale(enUS),
};
@@ -135,8 +137,14 @@ export function setupVbenVxeTable(setupOptions: SetupVxeTable) {
[() => isDark.value, () => locale.value],
([isDarkValue, localeValue]) => {
VxeUI.setTheme(isDarkValue ? 'dark' : 'light');
VxeUI.setI18n(localeValue, localMap[localeValue]);
VxeUI.setLanguage(localeValue);
// vxe-table 仅为其内置语言提供类型定义,应用通过模块增强扩展的
// 语言可能超出 VxeGlobalI18nLocale 联合范围,因此边界处需要断言,
// 未打包的自定义语言回退到 en-US
VxeUI.setI18n(
localeValue as any,
localMap[localeValue] ?? localMap['en-US'],
);
VxeUI.setLanguage(localeValue as any);
},
{
immediate: true,
@@ -419,7 +419,7 @@ onUnmounted(() => {
</VbenHelpTooltip>
</div>
</slot>
<slot name="toolbar-actions" v-bind="slotProps.componentProps"> </slot>
<slot name="toolbar-actions" v-bind="slotProps"> </slot>
</template>
<!-- 继承默认的slot -->
@@ -428,10 +428,10 @@ onUnmounted(() => {
:key="slotName"
#[slotName]="slotProps"
>
<slot :name="slotName" v-bind="slotProps.componentProps"></slot>
<slot :name="slotName" v-bind="slotProps"></slot>
</template>
<template #toolbar-tools="slotProps">
<slot name="toolbar-tools" v-bind="slotProps.componentProps"></slot>
<slot name="toolbar-tools" v-bind="slotProps"></slot>
<VxeButton
icon="vxe-icon-search"
circle
@@ -470,32 +470,20 @@ onUnmounted(() => {
>
<slot
:name="`${FORM_SLOT_PREFIX}${slotName}`"
v-bind="slotProps.componentProps"
v-bind="slotProps"
></slot>
</template>
<template #reset-before="slotProps">
<slot
name="reset-before"
v-bind="slotProps.componentProps"
></slot>
<slot name="reset-before" v-bind="slotProps"></slot>
</template>
<template #submit-before="slotProps">
<slot
name="submit-before"
v-bind="slotProps.componentProps"
></slot>
<slot name="submit-before" v-bind="slotProps"></slot>
</template>
<template #expand-before="slotProps">
<slot
name="expand-before"
v-bind="slotProps.componentProps"
></slot>
<slot name="expand-before" v-bind="slotProps"></slot>
</template>
<template #expand-after="slotProps">
<slot
name="expand-after"
v-bind="slotProps.componentProps"
></slot>
<slot name="expand-after" v-bind="slotProps"></slot>
</template>
</Form>
</slot>
+1
View File
@@ -22,6 +22,7 @@
"dependencies": {
"@intlify/core-base": "catalog:",
"@vben-core/composables": "workspace:*",
"@vben-core/typings": "workspace:*",
"vue": "catalog:",
"vue-i18n": "catalog:"
}
@@ -0,0 +1,65 @@
{
"welcomeBack": "歡迎回來",
"pageTitle": "開箱即用的大型中後臺管理系統",
"pageDesc": "工程化、高效能、跨元件庫的前端模版",
"loginSuccess": "登入成功",
"loginSuccessDesc": "歡迎回來",
"loginSubtitle": "請輸入您的賬戶資訊以開始管理您的專案",
"selectAccount": "快速選擇賬號",
"username": "賬號",
"password": "密碼",
"usernameTip": "請輸入使用者名稱",
"passwordTip": "請輸入密碼",
"verifyRequiredTip": "請先完成驗證",
"passwordErrorTip": "密碼錯誤",
"rememberMe": "記住賬號",
"createAnAccount": "建立一個賬號",
"createAccount": "建立賬號",
"alreadyHaveAccount": "已經有賬號了?",
"accountTip": "還沒有賬號?",
"signUp": "註冊",
"signUpSubtitle": "讓您的應用程式管理變得簡單而有趣",
"confirmPassword": "確認密碼",
"confirmPasswordTip": "兩次輸入的密碼不一致",
"agree": "我同意",
"privacyPolicy": "隱私政策",
"terms": "條款",
"agreeTip": "請同意隱私政策和條款",
"goToLogin": "去登入",
"passwordStrength": "使用 8 個或更多字元,混合字母、數字和符號",
"forgetPassword": "忘記密碼?",
"forgetPasswordSubtitle": "輸入您的電子郵件,我們將向您傳送重置密碼的連線",
"emailTip": "請輸入郵箱",
"emailValidErrorTip": "你輸入的郵箱格式不正確",
"sendResetLink": "傳送重置連結",
"email": "郵箱",
"qrcodeSubtitle": "請用手機掃描二維碼登入",
"qrcodePrompt": "掃碼後點擊 '確認',即可完成登入",
"qrcodeLogin": "掃碼登入",
"wechatLogin": "微信登入",
"qqLogin": "QQ登入",
"githubLogin": "Github登入",
"googleLogin": "Google登入",
"dingdingLogin": "釘釘登入",
"codeSubtitle": "請輸入您的手機號碼以開始管理您的專案",
"code": "驗證碼",
"codeTip": "請輸入{0}位驗證碼",
"mobile": "手機號碼",
"mobileTip": "請輸入手機號",
"mobileErrortip": "手機號碼格式錯誤",
"mobileLogin": "手機號登入",
"sendCode": "獲取驗證碼",
"sendText": "{0}秒後重新獲取",
"thirdPartyLogin": "其他登入方式",
"weChat": "微信",
"qq": "QQ",
"gitHub": "GitHub",
"google": "Google",
"loginAgainTitle": "重新登入",
"loginAgainSubTitle": "您的登入狀態已過期,請重新登入以繼續。",
"layout": {
"center": "居中",
"alignLeft": "居左",
"alignRight": "居右"
}
}
@@ -0,0 +1,26 @@
{
"back": "返回",
"backToHome": "返回首頁",
"login": "登入",
"logout": "退出登入",
"prompt": "提示",
"cancel": "取消",
"confirm": "確認",
"reset": "重置",
"noData": "暫無資料",
"refresh": "重新整理",
"loadingMenu": "載入選單中",
"query": "查詢",
"search": "搜尋",
"enabled": "已啟用",
"disabled": "已停用",
"edit": "修改",
"delete": "刪除",
"detail": "詳情",
"create": "新增",
"yes": "是",
"no": "否",
"showSearchPanel": "顯示搜尋面板",
"hideSearchPanel": "隱藏搜尋面板",
"notShow": "不顯示"
}
@@ -0,0 +1,245 @@
{
"title": "偏好設定",
"subtitle": "自定義偏好設定 & 即時預覽",
"enableStickyPreferencesNavigationBar": "開啟首選項導航欄吸頂效果",
"disableStickyPreferencesNavigationBar": "關閉首選項導航欄吸頂效果",
"resetTitle": "重置偏好設定",
"resetTip": "資料有變化,點選可進行重置",
"resetSuccess": "重置偏好設定成功",
"appearance": "外觀",
"layout": "佈局",
"content": "內容",
"other": "其它",
"wide": "流式",
"compact": "定寬",
"followSystem": "跟隨系統",
"vertical": "垂直",
"verticalTip": "側邊垂直選單模式",
"horizontal": "水平",
"horizontalTip": "水平選單模式,選單全部顯示在頂部",
"twoColumn": "雙列選單",
"twoColumnTip": "垂直雙列選單模式",
"headerSidebarNav": "側邊導航",
"headerSidebarNavTip": "頂部通欄,側邊導航模式",
"headerTwoColumn": "混合雙列",
"headerTwoColumnTip": "雙列、水平選單共存模式",
"mixedMenu": "混合垂直",
"mixedMenuTip": "垂直水平選單共存",
"fullContent": "內容全屏",
"fullContentTip": "不顯示任何選單,只顯示內容主體",
"normal": "常規",
"plain": "樸素",
"rounded": "圓潤",
"copyPreferences": "複製偏好設定",
"enableCopyPreferences": "顯示複製偏好設定按鈕",
"copyPreferencesSuccessTitle": "複製成功",
"copyPreferencesSuccess": "複製成功,請在 app 下的 `src/preferences.ts`內進行覆蓋",
"clearAndLogout": "清空快取 & 退出登入",
"mode": "模式",
"general": "通用",
"language": "語言",
"timezone": "時區",
"dynamicTitle": "動態標題",
"watermark": "水印",
"watermarkContent": "請輸入水印文案",
"checkUpdates": "定時檢查更新",
"position": {
"title": "偏好設定位置",
"header": "頂欄",
"auto": "自動",
"fixed": "固定",
"userDropdown": "使用者下拉窗"
},
"sidebar": {
"buttons": "顯示按鈕",
"buttonFixed": "固定按鈕",
"buttonCollapsed": "摺疊按鈕",
"title": "側邊欄",
"width": "寬度",
"visible": "顯示側邊欄",
"draggable": "側邊欄選單拖拽",
"collapsed": "摺疊選單",
"collapsedShowTitle": "摺疊顯示選單名",
"autoActivateChild": "自動啟用子選單",
"autoActivateChildTip": "點選頂層選單時,自動啟用第一個子選單或者上一次啟用的子選單",
"expandOnHover": "滑鼠懸停展開",
"expandOnHoverTip": "滑鼠在摺疊區域懸浮時,`啟用`則展開當前子選單,`停用`則展開整個側邊欄"
},
"tabbar": {
"title": "標籤欄",
"enable": "啟用標籤欄",
"icon": "顯示標籤欄圖示",
"showMore": "顯示更多按鈕",
"showMaximize": "顯示最大化按鈕",
"persist": "持久化標籤頁",
"visitHistory": "訪問歷史記錄",
"visitHistoryTip": "開啟後,標籤欄會記錄標籤訪問歷史\n關閉當前標籤,會自動選中上一個開啟的標籤",
"maxCount": "最大標籤數",
"maxCountTip": "每次開啟新的標籤時如果超過最大標籤數,\n會自動關閉一個最先開啟的標籤\n設定為 0 則不限制",
"draggable": "啟動拖拽排序",
"wheelable": "啟用縱向滾輪響應",
"middleClickClose": "點選滑鼠中鍵關閉標籤頁",
"wheelableTip": "開啟後,標籤欄區域可以響應滾輪的縱向滾動事件。\n關閉時,只能響應系統的橫向滾動事件(需要按下Shift再滾動滾輪)",
"styleType": {
"title": "標籤頁風格",
"chrome": "谷歌",
"card": "卡片",
"plain": "樸素",
"brisk": "輕快"
},
"contextMenu": {
"reload": "重新載入",
"close": "關閉",
"pin": "固定",
"unpin": "取消固定",
"closeLeft": "關閉左側標籤頁",
"closeRight": "關閉右側標籤頁",
"closeOther": "關閉其它標籤頁",
"closeAll": "關閉全部標籤頁",
"openInNewWindow": "在新視窗開啟",
"maximize": "最大化",
"restoreMaximize": "還原"
}
},
"navigationMenu": {
"title": "導航選單",
"style": "導航選單風格",
"accordion": "側邊導航選單手風琴模式",
"split": "導航選單分離",
"splitTip": "開啟時,側邊欄顯示頂欄對應選單的子選單"
},
"breadcrumb": {
"title": "麵包屑導航",
"enable": "開啟麵包屑導航",
"icon": "顯示麵包屑圖示",
"home": "顯示首頁按鈕",
"style": "麵包屑風格",
"hideOnlyOne": "僅有一個時隱藏",
"background": "背景"
},
"animation": {
"title": "動畫",
"loading": "頁面切換 Loading",
"transition": "頁面切換動畫",
"progress": "頁面切換進度條"
},
"theme": {
"title": "主題",
"radius": "圓角",
"fontSize": "字型大小",
"fontSizeTip": "調整全域性字型大小,即時預覽效果",
"light": "淺色",
"dark": "深色",
"darkSidebar": "深色側邊欄",
"darkSidebarTip": "當主題為淺色,佈局不為水平選單或不為內容全屏時可開啟",
"darkSidebarSub": "深色側邊欄子欄",
"darkSidebarSubTip": "當主題為淺色,開啟深色側邊欄且佈局使用雙列選單模式時可開啟",
"darkHeader": "深色頂欄",
"weakMode": "色弱模式",
"grayMode": "灰色模式",
"builtin": {
"title": "內建主題",
"default": "預設",
"violet": "紫羅蘭",
"pink": "櫻花粉",
"rose": "玫瑰紅",
"skyBlue": "天藍色",
"deepBlue": "深藍色",
"green": "淺綠色",
"deepGreen": "深綠色",
"orange": "橙黃色",
"yellow": "檸檬黃",
"zinc": "鋅色灰",
"neutral": "中性色",
"slate": "石板灰",
"gray": "中灰色",
"custom": "自定義"
}
},
"header": {
"title": "頂欄",
"modeStatic": "靜止",
"modeFixed": "固定",
"modeAuto": "自動隱藏和顯示",
"modeAutoScroll": "滾動隱藏和顯示",
"visible": "顯示頂欄",
"menuAlign": "選單位置",
"menuAlignStart": "左側",
"menuAlignEnd": "右側",
"menuAlignCenter": "居中"
},
"footer": {
"title": "底欄",
"visible": "顯示底欄",
"fixed": "固定在底部"
},
"copyright": {
"title": "版權",
"enable": "啟用版權",
"companyName": "公司名",
"companySiteLink": "公司主頁",
"date": "日期",
"icp": "ICP 備案號",
"icpLink": "ICP 網站連結"
},
"shortcutKeys": {
"title": "快捷鍵",
"global": "全域性",
"search": "全域性搜尋",
"logout": "退出登入",
"escape": "關閉當前視窗",
"preferences": "偏好設定"
},
"widget": {
"title": "小部件",
"globalSearch": "全域性搜尋",
"globalSearchPosition": "全域性搜尋位置",
"fullscreen": "全屏",
"fullscreenPosition": "全屏按鈕位置",
"themeToggle": "主題切換",
"themeTogglePosition": "主題切換位置",
"languageToggle": "語言切換",
"languageTogglePosition": "語言切換位置",
"notification": "通知",
"notificationPosition": "通知位置",
"sidebarToggle": "啟用側邊欄切換",
"lockScreen": "鎖屏",
"lockScreenPosition": "鎖屏按鈕位置",
"logoutButtonPosition": "退出按鈕位置",
"header": "頂欄",
"userDropdown": "使用者下拉窗",
"hidden": "已隱藏",
"refresh": "重新整理",
"refreshPosition": "重新整理按鈕位置",
"timezone": "時區",
"timezonePosition": "時區按鈕位置"
},
"antd": {
"tabLabel": "Antd 拓展",
"title": "業務偏好",
"fields": {
"enableFormFullscreen": {
"label": "啟用表單全屏模式",
"tip": "子專案可在自己的業務場景中讀取這個拓展配置。"
},
"tenantMode": {
"label": "租戶模式",
"options": {
"single": {
"label": "單租戶"
},
"multi": {
"label": "多租戶"
}
}
},
"defaultTableSize": {
"label": "預設分頁條數"
},
"reportTitle": {
"label": "報表預設標題",
"placeholder": "請輸入報表預設標題"
}
}
}
}
@@ -0,0 +1,4 @@
{
"updatePassword": "更新密碼",
"updateBasicProfile": "更新基本資訊"
}
+165
View File
@@ -0,0 +1,165 @@
{
"formRules": {
"required": "請輸入{0}",
"selectRequired": "請選擇{0}",
"minLength": "{0}至少{1}個字元",
"maxLength": "{0}最多{1}個字元",
"length": "{0}長度必須為{1}個字元",
"alreadyExists": "{0} `{1}` 已存在",
"startWith": "{0}必須以 {1} 開頭",
"invalidURL": "請輸入有效的連結",
"sizeLimit": "檔案大小不能超過 {0}MB",
"previewWarning": "無法開啟檔案,沒有可用的URL或預覽地址"
},
"actionTitle": {
"edit": "修改{0}",
"create": "新增{0}",
"delete": "刪除{0}",
"view": "檢視{0}"
},
"actionMessage": {
"deleteConfirm": "確定刪除 {0} 嗎?",
"deleting": "正在刪除 {0} ...",
"deleteSuccess": "{0} 刪除成功",
"operationSuccess": "操作成功",
"operationFailed": "操作失敗"
},
"placeholder": {
"input": "請輸入",
"select": "請選擇",
"upload": "點選上傳"
},
"captcha": {
"title": "請完成安全驗證",
"sliderSuccessText": "驗證通過",
"sliderDefaultText": "請按住滑塊拖動",
"sliderRotateDefaultTip": "點選圖片可重新整理",
"sliderTranslateDefaultTip": "點選圖片可重新整理",
"sliderRotateFailTip": "驗證失敗",
"sliderRotateSuccessTip": "驗證成功,耗時{0}秒",
"sliderTranslateFailTip": "驗證失敗",
"sliderTranslateSuccessTip": "驗證成功,耗時{0}秒",
"alt": "支援img標籤src屬性值",
"refreshAriaLabel": "重新整理驗證碼",
"confirmAriaLabel": "確認選擇",
"confirm": "確認",
"pointAriaLabel": "點選點",
"clickInOrder": "請依次點選"
},
"iconPicker": {
"placeholder": "選擇一個圖示",
"search": "搜尋圖示..."
},
"jsonViewer": {
"copy": "複製",
"copied": "已複製"
},
"crop": {
"title": "圖片裁剪",
"titleTip": "裁剪比例 {0}",
"confirm": "裁剪",
"cancel": "取消裁剪",
"errorTip": "裁剪錯誤"
},
"tiptap": {
"placeholder": "請輸入內容...",
"prompts": {
"image": "請輸入圖片地址",
"link": "請輸入連結地址"
},
"toolbar": {
"bold": "加粗",
"italic": "斜體",
"underline": "下劃線",
"strike": "刪除線",
"code": "行內程式碼",
"codeBlock": "程式碼塊",
"heading": "標題",
"paragraph": "正文",
"heading1": "標題1",
"heading2": "標題2",
"heading3": "標題3",
"heading4": "標題4",
"bulletList": "無序列表",
"orderedList": "有序列表",
"blockquote": "引用",
"link": "連結",
"unlink": "移除連結",
"image": "圖片",
"imageUrl": "圖片URL",
"imageUpload": "從本地上傳",
"textColor": "文字顏色",
"highlightColor": "高亮顏色",
"alignLeft": "左對齊",
"alignCenter": "居中",
"alignRight": "右對齊",
"preview": "預覽",
"undo": "撤銷",
"redo": "重做",
"clear": "清除"
},
"upload": {
"fileTooLarge": "檔案大小超出限制",
"fileTypeNotAllowed": "不支援的檔案型別",
"onlySingleImage": "僅支援單張圖片上傳,已選取第一張",
"uploadFailed": "上傳失敗"
}
},
"fallback": {
"pageNotFound": "哎呀!未找到頁面",
"pageNotFoundDesc": "抱歉,我們無法找到您要找的頁面。",
"forbidden": "哎呀!訪問被拒絕",
"forbiddenDesc": "抱歉,您沒有許可權訪問此頁面。",
"internalError": "哎呀!出錯了",
"internalErrorDesc": "抱歉,伺服器遇到錯誤。",
"offline": "離線頁面",
"offlineError": "哎呀!網路錯誤",
"offlineErrorDesc": "抱歉,無法連線到網際網路,請檢查您的網路連線並重試。",
"comingSoon": "即將推出",
"http": {
"requestTimeout": "請求超時,請稍後再試。",
"networkError": "網路異常,請檢查您的網路連線後重試。",
"badRequest": "請求錯誤。請檢查您的輸入並重試。",
"unauthorized": "登入認證過期,請重新登入後繼續。",
"forbidden": "禁止訪問, 您沒有許可權訪問此資源。",
"notFound": "未找到, 請求的資源不存在。",
"internalServerError": "內部伺服器錯誤,請稍後再試。"
}
},
"widgets": {
"document": "文件",
"qa": "問題 & 幫助",
"setting": "設定",
"logoutTip": "是否退出登入?",
"viewAll": "檢視所有訊息",
"notifications": "通知",
"markAllAsRead": "全部標記為已讀",
"clearNotifications": "清空",
"checkUpdatesTitle": "新版本可用",
"checkUpdatesDescription": "點選重新整理以獲取最新版本",
"search": {
"title": "搜尋",
"searchNavigate": "搜尋導航選單",
"select": "選擇",
"navigate": "導航",
"close": "關閉",
"noResults": "未找到搜尋結果",
"noRecent": "沒有搜尋歷史",
"recent": "搜尋歷史"
},
"lockScreen": {
"title": "鎖定螢幕",
"screenButton": "鎖定",
"password": "密碼",
"placeholder": "請輸入鎖屏密碼",
"unlock": "點選解鎖",
"errorPasswordTip": "密碼錯誤,請重新輸入",
"backToLogin": "返回登入",
"entry": "進入系統"
},
"timezone": {
"setTimezone": "設定時區",
"setSuccess": "時區設定成功"
}
}
}
+3 -1
View File
@@ -1,4 +1,6 @@
export type SupportedLanguagesType = 'en-US' | 'zh-CN';
import type { SupportedLanguagesType } from '@vben-core/typings';
export type { SupportedLanguagesType };
export type ImportLocaleFn = () => Promise<{ default: Record<string, string> }>;
+20 -8
View File
@@ -274,7 +274,7 @@ catalogs:
specifier: ^4.2.0
version: 4.2.0
cspell:
specifier: ^10.0.1
specifier: ^10.1.0
version: 10.1.1
cz-git:
specifier: ^1.13.1
@@ -307,7 +307,7 @@ catalogs:
specifier: ^1.49.0
version: 1.51.0
eslint:
specifier: ^10.8.1
specifier: ^10.9.0
version: 10.9.1
eslint-plugin-better-tailwindcss:
specifier: ^4.7.0
@@ -352,7 +352,7 @@ catalogs:
specifier: ^17.7.0
version: 17.11.0
happy-dom:
specifier: ^20.11.2
specifier: ^20.11.6
version: 20.11.8
highlight.js:
specifier: ^11.11.1
@@ -418,7 +418,7 @@ catalogs:
specifier: ^0.60.0
version: 0.60.0
oxlint:
specifier: ^1.78.0
specifier: ^1.79.0
version: 1.80.0
oxlint-tsgolint:
specifier: ^0.25.0
@@ -523,7 +523,7 @@ catalogs:
specifier: ^0.22.14
version: 0.22.14
turbo:
specifier: ^2.10.10
specifier: ^2.10.11
version: 2.10.12
tw-animate-css:
specifier: ^1.4.0
@@ -547,7 +547,7 @@ catalogs:
specifier: ^7.21.7
version: 7.21.7
vite:
specifier: ^8.2.1
specifier: ^8.2.2
version: 8.2.2
vite-plugin-compression:
specifier: ^0.5.1
@@ -568,7 +568,7 @@ catalogs:
specifier: ^1.7.5
version: 1.7.6
vitest:
specifier: ^4.1.10
specifier: ^4.1.11
version: 4.1.11
vue-dompurify-html:
specifier: ^5.3.0
@@ -589,7 +589,7 @@ catalogs:
specifier: ^6.7.1
version: 6.7.1
vue-tsc:
specifier: ^3.3.9
specifier: ^3.3.11
version: 3.3.11
vue3-print-nb:
specifier: ^0.1.4
@@ -1695,6 +1695,9 @@ importers:
'@vben-core/shared':
specifier: workspace:*
version: link:../base/shared
'@vben-core/typings':
specifier: workspace:*
version: link:../base/typings
'@vueuse/core':
specifier: 'catalog:'
version: 14.4.0([email protected]([email protected]))
@@ -1932,6 +1935,9 @@ importers:
'@vben-core/shared':
specifier: workspace:*
version: link:../@core/base/shared
'@vben-core/typings':
specifier: workspace:*
version: link:../@core/base/typings
packages/effects/access:
dependencies:
@@ -2182,6 +2188,9 @@ importers:
'@vben-core/shared':
specifier: workspace:*
version: link:../../@core/base/shared
'@vben-core/typings':
specifier: workspace:*
version: link:../../@core/base/typings
'@vben/hooks':
specifier: workspace:*
version: link:../hooks
@@ -2283,6 +2292,9 @@ importers:
'@vben-core/composables':
specifier: workspace:*
version: link:../@core/composables
'@vben-core/typings':
specifier: workspace:*
version: link:../@core/base/typings
vue:
specifier: ^3.5.40
version: 3.5.42([email protected])
+8 -8
View File
@@ -128,7 +128,7 @@ catalog:
cropperjs: ^1.6.3
cross-env: ^10.1.0
crypto-js: ^4.2.0
cspell: ^10.0.1
cspell: ^10.1.0
cz-git: ^1.13.1
czg: ^1.13.1
dayjs: ^1.11.21
@@ -139,7 +139,7 @@ catalog:
echarts: ^6.1.0
element-plus: ^2.14.3
es-toolkit: ^1.49.0
eslint: ^10.8.1
eslint: ^10.9.0
eslint-plugin-better-tailwindcss: ^4.7.0
eslint-plugin-command: ^3.5.3
eslint-plugin-jsonc: ^3.3.0
@@ -154,7 +154,7 @@ catalog:
find-up: ^8.0.0
get-port: ^7.2.0
globals: ^17.7.0
happy-dom: ^20.11.2
happy-dom: ^20.11.6
highlight.js: ^11.11.1
html-minifier-terser: ^7.2.0
is-ci: ^4.1.0
@@ -176,7 +176,7 @@ catalog:
nprogress: ^0.2.0
ora: ^9.4.1
oxfmt: ^0.60.0
oxlint: ^1.78.0
oxlint: ^1.79.0
oxlint-tsgolint: ^0.25.0
pinia: ^4.0.2
pinia-plugin-persistedstate: ^4.7.1
@@ -212,7 +212,7 @@ catalog:
tinymce: ^7.9.3
tippy.js: ^6.3.7
tsdown: ^0.22.14
turbo: ^2.10.10
turbo: ^2.10.11
tw-animate-css: ^1.4.0
tyme4ts: ^1.5.2
typescript: ^6.0.3
@@ -220,14 +220,14 @@ catalog:
unplugin-element-plus: ^0.11.2
unplugin-vue: ^7.2.0
video.js: ^7.21.7
vite: ^8.2.1
vite: ^8.2.2
vite-plugin-compression: ^0.5.1
vite-plugin-lazy-import: ^1.0.7
vite-plugin-pwa: ^1.3.0
vite-plugin-vue-devtools: ^8.1.5
vitepress: ^2.0.0-alpha.18
vitepress-plugin-group-icons: ^1.7.5
vitest: ^4.1.10
vitest: ^4.1.11
vue: ^3.5.40
vue-dompurify-html: ^5.3.0
vue-eslint-parser: ^10.4.1
@@ -235,7 +235,7 @@ catalog:
vue-json-pretty: ^2.6.0
vue-router: ^5.2.0
vue-tippy: ^6.7.1
vue-tsc: ^3.3.9
vue-tsc: ^3.3.11
vue3-print-nb: ^0.1.4
vue3-signature: ^0.4.4
vuedraggable: ^4.1.0
+7 -1
View File
@@ -6,7 +6,7 @@ import { version } from '../package.json';
import { defineCheckCircularCommand } from './check-circular';
import { defineCheckDepCommand } from './check-dep';
import { defineCodeWorkspaceCommand } from './code-workspace';
import { defineLintCommand } from './lint';
import { defineLintCommand, LintError } from './lint';
import { definePubLintCommand } from './publint';
// 命令描述
@@ -57,6 +57,12 @@ async function main(): Promise<void> {
await vsh.runMatchedCommand();
} catch (error) {
// lint 检查失败是预期内的结果(格式/规范不达标),
// 直接展示可操作的错误信息,而非笼统的 "unexpected error"
if (error instanceof LintError) {
consola.error(error.message);
process.exit(1);
}
consola.error(
colors.red('An unexpected error occurred:'),
'\n',
+30 -26
View File
@@ -1,9 +1,8 @@
import type { CAC } from 'cac';
import { execSync } from 'node:child_process';
import { availableParallelism, freemem } from 'node:os';
import { execa } from '@vben/node-utils';
import { colors, execa } from '@vben/node-utils';
interface LintCommandOptions {
/**
@@ -48,6 +47,21 @@ function formatCommand([file, args]: Command) {
return [file, ...args].join(' ');
}
/**
* 统一的 lint 失败错误。
* 相比 execa 的原始错误,会列出所有失败命令并给出修复提示。
*/
class LintError extends Error {
constructor(failed: Command[]) {
super(
`Lint failed:\n${failed
.map((command) => ` - ${formatCommand(command)}`)
.join('\n')}\n\n运行 ${colors.cyan('vsh lint --format')} 可自动修复。`,
);
this.name = 'LintError';
}
}
/**
* 串行执行所有命令:一次只运行一个进程。
* 保证一次能看到所有工具的报错(配置低的机器更友好)。
@@ -64,39 +78,29 @@ async function runSerial(commands: Command[]) {
}
if (failed.length > 0) {
throw new Error(
`Lint failed:\n${failed
.map((command) => ` - ${formatCommand(command)}`)
.join('\n')}`,
);
throw new LintError(failed);
}
}
/**
* 并行执行所有命令:同时启动全部进程。
* 任一进程失败时,强制结束其余仍在运行的进程,避免产生遗漏进程。
* 等待全部结束后汇总失败命令,与串行模式保持一致的错误输出。
*/
async function runParallel(commands: Command[]) {
const subprocesses = commands.map((command) => runCommand(command));
try {
await Promise.all(subprocesses);
} catch (error) {
for (const subprocess of subprocesses) {
try {
if (process.platform === 'win32' && subprocess.pid) {
execSync(`taskkill /F /T /PID ${subprocess.pid}`, {
stdio: 'ignore',
});
} else {
subprocess.kill('SIGKILL');
}
} catch {
// process may have already exited
}
const results = await Promise.allSettled(subprocesses);
// 汇总所有失败的命令,与串行模式保持一致的错误输出
const failed: Command[] = [];
results.forEach((result, index) => {
if (result.status === 'rejected') {
failed.push(commands[index]);
}
await Promise.allSettled(subprocesses);
throw error;
});
if (failed.length > 0) {
throw new LintError(failed);
}
}
@@ -143,4 +147,4 @@ function defineLintCommand(cac: CAC) {
.action(runLint);
}
export { defineLintCommand };
export { defineLintCommand, LintError };